自动化工具者快速学习鸿蒙开发第三部份 组件部份
鸿蒙物联网应用开发快速入门
适用场景:课程讲义 / CSDN 博客 / 新手教学 元歌 出品,必是精品
技术栈:HarmonyOS + ArkTS(声明式 UI)
目标:从零开始掌握鸿蒙物联网 APP 的开发核心技能
目录
一、开发环境准备
1.1 这是什么
鸿蒙物联网应用开发基于 HarmonyOS NEXT 系统,使用 ArkTS 语言(TypeScript 的超集)和声明式 UI 开发范式。开发者需要安装官方 IDE —— DevEco Studio。
1.2 怎么实现
-
下载并安装 DevEco Studio(官网:https://developer.huawei.com/consumer/cn/deveco-studio/)
-
配置 HarmonyOS SDK 和模拟器
-
创建一个新项目,选择
Empty Ability模板 -
项目结构核心目录说明:
entry/src/main/ets/
├── entryability/ # 入口 Ability(应用生命周期)
├── pages/ # 页面目录
│ └── Index.ets # 主页面
└── components/ # 自定义组件(自己创建)
resources/
└── base/element/ # 颜色、字体、尺寸等样式资源
1.3 技术细节
-
ArkTS 是鸿蒙官方推荐的开发语言,它在 TypeScript 基础上增加了严格的类型约束和运行时检查,提升应用性能。
-
声明式 UI:开发者只需描述"界面应该长什么样",系统会自动处理渲染和更新,类似 Flutter/React。
-
每个
.ets文件都是一个独立的模块,通过@Entry和@Component装饰器定义页面和组件。
小贴士:如果你已有 TypeScript / Vue3 / Flutter 基础,上手 ArkTS 会非常轻松。
二、第一个鸿蒙页面:Hello IoT
2.1 这是什么
本示例展示鸿蒙应用最基础的页面结构,包括文本显示、图片加载和按钮点击。这是每一个鸿蒙开发者写的第一段代码,类似于 "Hello World"。
2.2 怎么实现
在 entry/src/main/ets/pages/Index.ets 中编写以下代码:
// entry/src/main/ets/pages/Index.ets
@Entry // 标记这是一个页面入口
@Component // 标记这是一个 UI 组件
struct Index {
// 页面状态变量:按钮点击次数
@State clickCount: number = 0
build() {
// Column:垂直方向排列子组件
Column({ space: 20 }) {
// 标题文本
Text('鸿蒙物联网应用')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1a1a1a')
// 副标题
Text('Hello HarmonyOS IoT')
.fontSize(16)
.fontColor('#666666')
// 显示点击次数
Text(`按钮被点击了 ${this.clickCount} 次`)
.fontSize(20)
.fontColor('#007DFF')
// 按钮
Button('点击我')
.width(200)
.height(50)
.fontSize(18)
.backgroundColor('#007DFF')
.onClick(() => {
// 点击时状态变量 +1,UI 会自动重新渲染
this.clickCount++
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center) // 垂直居中
.alignItems(HorizontalAlign.Center) // 水平居中
.backgroundColor('#F1F3F5')
}
}
2.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
标记该组件为页面入口,每个页面必须有且仅有一个 |
|
|
标记该结构体为UI 组件,鸿蒙中所有可见界面元素都是组件 |
|
|
状态装饰器。被 |
|
|
组件的 UI 构建函数,所有界面描述都写在这里面 |
|
|
垂直布局容器, |
|
|
在主轴(垂直方向)上居中对齐 |
|
|
在交叉轴(水平方向)上居中对齐 |
核心原理:声明式 UI 的核心是数据驱动界面。你只需修改数据(如
clickCount),界面会自动更新,无需手动操作 DOM。
三、Flex 弹性布局:构建工业监控面板
3.1 这是什么
在物联网场景中,经常需要在同一行展示多个传感器数据(如温度、湿度、压力)。Flex 布局可以灵活地控制子组件在主轴和交叉轴上的排列方式,非常适合构建这种横向排列的监控面板。
3.2 怎么实现
// entry/src/main/ets/pages/MonitorPanel.ets
@Entry
@Component
struct MonitorPanel {
// 模拟传感器数据
@State temperature: number = 26.5
@State humidity: number = 62
@State pressure: number = 101.3
build() {
Column({ space: 16 }) {
// 页面标题
Text('工业传感器监控面板')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 40, bottom: 20 })
// ========== 第一行:三个传感器卡片 ==========
Row({ space: 12 }) {
// 温度卡片
SensorCard({
title: '温度',
value: this.temperature,
unit: '°C',
icon: '🌡️',
color: '#FF6B6B'
})
// 湿度卡片
SensorCard({
title: '湿度',
value: this.humidity,
unit: '%',
icon: '💧',
color: '#4ECDC4'
})
// 气压卡片
SensorCard({
title: '气压',
value: this.pressure,
unit: 'kPa',
icon: '📊',
color: '#45B7D1'
})
}
.width('100%')
.padding(16)
.justifyContent(FlexAlign.SpaceEvenly) // 子组件均匀分布
// ========== 模拟数据刷新按钮 ==========
Button('刷新传感器数据')
.width(200)
.height(48)
.backgroundColor('#007DFF')
.margin({ top: 30 })
.onClick(() => {
// 模拟从硬件读取新数据
this.temperature = 20 + Math.random() * 15
this.humidity = 40 + Math.random() * 40
this.pressure = 100 + Math.random() * 5
})
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
// ========== 自定义传感器卡片组件 ==========
@Component
struct SensorCard {
// 接收父组件传入的参数
@Prop title: string
@Prop value: number
@Prop unit: string
@Prop icon: string
@Prop color: string
build() {
Column({ space: 8 }) {
Text(this.icon)
.fontSize(36)
Text(this.title)
.fontSize(14)
.fontColor('#666666')
Text(`${this.value.toFixed(1)} ${this.unit}`)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(this.color)
}
.width('30%')
.padding(16)
.borderRadius(12)
.backgroundColor(Color.White)
.shadow({
radius: 8,
color: 'rgba(0, 0, 0, 0.08)',
offsetY: 4
})
}
}
3.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
水平布局容器,子组件从左到右排列 |
|
|
子组件在主轴上均匀分布,两端和中间间距相等 |
|
|
首尾贴边,中间均匀分布 |
|
|
子组件靠起点 / 居中 / 靠终点排列 |
|
|
父组件向子组件单向传递数据。子组件可以读取但不能修改(修改会报错) |
|
|
JavaScript 方法,保留 1 位小数 |
|
|
给组件添加阴影,提升视觉层次感 |
工业场景提示:实际项目中,这些传感器数据通常通过串口、蓝牙或 WiFi 从 STM32 / PLC 等硬件设备实时读取,而不是用随机数模拟。
四、Grid 网格布局:多设备数据卡片
4.1 这是什么
当需要展示大量同类数据时(如一个工厂有 20 台设备,每台设备显示温度、状态、运行时间),使用 Grid 网格布局可以自动换行排列,比手动写多个 Row 更加简洁优雅。
4.2 怎么实现
// entry/src/main/ets/pages/DeviceGrid.ets
// 定义设备数据模型(接口)
interface DeviceInfo {
id: number
name: string
temperature: number
status: 'running' | 'stopped' | 'alarm' // 联合类型
runtime: number // 运行小时数
}
@Entry
@Component
struct DeviceGrid {
// 设备列表数据
@State deviceList: DeviceInfo[] = [
{ id: 1, name: '1号机床', temperature: 45.2, status: 'running', runtime: 128 },
{ id: 2, name: '2号机床', temperature: 38.5, status: 'running', runtime: 96 },
{ id: 3, name: '3号机床', temperature: 72.0, status: 'alarm', runtime: 200 },
{ id: 4, name: '4号机床', temperature: 25.0, status: 'stopped', runtime: 0 },
{ id: 5, name: '5号机床', temperature: 41.3, status: 'running', runtime: 156 },
{ id: 6, name: '6号机床', temperature: 39.8, status: 'running', runtime: 88 },
]
// 根据状态返回对应的颜色
getStatusColor(status: string): string {
switch (status) {
case 'running': return '#52C41A' // 绿色 - 运行中
case 'stopped': return '#999999' // 灰色 - 已停止
case 'alarm': return '#FF4D4F' // 红色 - 报警
default: return '#999999'
}
}
// 根据状态返回中文描述
getStatusText(status: string): string {
switch (status) {
case 'running': return '运行中'
case 'stopped': return '已停止'
case 'alarm': return '报警'
default: return '未知'
}
}
build() {
Column() {
// 标题栏
Row() {
Text('车间设备监控')
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text(`共 ${this.deviceList.length} 台设备`)
.fontSize(14)
.fontColor('#999999')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(16)
// Grid 网格布局:2 列,自适应
Grid() {
// ForEach 循环渲染每个设备卡片
ForEach(this.deviceList, (device: DeviceInfo) => {
GridItem() {
DeviceCard({
name: device.name,
temperature: device.temperature,
statusColor: this.getStatusColor(device.status),
statusText: this.getStatusText(device.status),
runtime: device.runtime
})
}
})
}
.columnsTemplate('1fr 1fr') // 两列,等宽
.columnsGap(12) // 列间距
.rowsGap(12) // 行间距
.padding(16)
.layoutDirection(GridDirection.Row) // 按行填充
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
// ========== 设备卡片组件 ==========
@Component
struct DeviceCard {
@Prop name: string
@Prop temperature: number
@Prop statusColor: string
@Prop statusText: string
@Prop runtime: number
build() {
Column({ space: 10 }) {
// 设备名称 + 状态点
Row() {
Text(this.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
Row({ space: 6 }) {
// 状态指示圆点
Circle({ width: 10, height: 10 })
.fill(this.statusColor)
Text(this.statusText)
.fontSize(12)
.fontColor(this.statusColor)
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
// 温度
Row({ space: 4 }) {
Text('温度:')
.fontSize(13)
.fontColor('#666666')
Text(`${this.temperature.toFixed(1)} °C`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
}
.width('100%')
// 运行时长
Row({ space: 4 }) {
Text('运行:')
.fontSize(13)
.fontColor('#666666')
Text(`${this.runtime} 小时`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
}
.width('100%')
}
.width('100%')
.padding(14)
.borderRadius(10)
.backgroundColor(Color.White)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetY: 3 })
}
}
4.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
网格布局容器,可以设置行列模板 |
|
|
定义 2 列,每列占 1fr(等比例分配剩余空间) |
|
|
三列,比例为 1:2:1 |
|
|
循环遍历数组,为每个元素渲染 UI。必须提供唯一 key,这里默认用数组索引 |
|
|
Grid 中的每个单元格包裹器 |
|
|
ArkTS 中定义数据结构的接口,鸿蒙强烈推荐使用强类型 |
|
联合类型 |
限制变量只能取这三个值之一,提升代码健壮性 |
布局技巧:
1fr是 CSS Grid 的单位,表示"一份剩余空间"。鸿蒙 Grid 布局借鉴了 Web CSS Grid 的设计,有前端基础的同学会倍感亲切。
五、组件封装:可复用的仪表盘组件
5.1 这是什么
在工业物联网 APP 中,**仪表盘(Gauge)**是一种常见的数据可视化方式,用于直观展示某个数值在量程中的位置。本示例封装一个可复用的圆形仪表盘组件,支持传入量程、数值、颜色等参数。
5.2 怎么实现
// entry/src/main/ets/components/GaugeComponent.ets
@Component
export struct GaugeComponent {
// ===== 可配置属性(父组件传入)=====
@Prop value: number // 当前数值
@Prop min: number = 0 // 最小值(默认0)
@Prop max: number = 100 // 最大值(默认100)
@Prop title: string = '' // 仪表盘标题
@Prop unit: string = '' // 单位
@Prop primaryColor: string = '#007DFF' // 主色调
// 计算当前值在量程中的百分比(0~1)
getPercentage(): number {
let p = (this.value - this.min) / (this.max - this.min)
if (p < 0) p = 0
if (p > 1) p = 1
return p
}
// 根据百分比决定颜色(绿->黄->红)
getColorByValue(): string {
const p = this.getPercentage()
if (p < 0.5) return '#52C41A' // 安全 - 绿色
if (p < 0.8) return '#FAAD14' // 警告 - 黄色
return '#FF4D4F' // 危险 - 红色
}
build() {
Column({ space: 8 }) {
// 标题
if (this.title !== '') {
Text(this.title)
.fontSize(14)
.fontColor('#666666')
}
// 仪表盘圆形(用 Stack 叠加实现)
Stack() {
// 底层:灰色背景圆环
Circle({ width: 140, height: 140 })
.fill('transparent')
.stroke('#E8E8E8')
.strokeWidth(12)
// 上层:进度圆环(通过设置 dashArray 实现弧形)
Circle({ width: 140, height: 140 })
.fill('transparent')
.stroke(this.getColorByValue())
.strokeWidth(12)
.strokeLineCap(LineCapStyle.Round)
// dashArray: [实线长度, 虚线间隔]
// 周长 ≈ 440,这里用 0~75% 的圆弧表示量程
.strokeDashArray([
440 * this.getPercentage() * 0.75,
440
])
.rotate({ angle: 135 }) // 旋转到从底部开始
// 中心数值
Column({ space: 2 }) {
Text(`${this.value.toFixed(1)}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1a1a1a')
if (this.unit !== '') {
Text(this.unit)
.fontSize(12)
.fontColor('#999999')
}
}
}
.width(160)
.height(160)
// 量程提示
Row() {
Text(`${this.min}`)
.fontSize(11)
.fontColor('#BBBBBB')
Text(`${this.max}`)
.fontSize(11)
.fontColor('#BBBBBB')
}
.width(120)
.justifyContent(FlexAlign.SpaceBetween)
}
.padding(16)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 4 })
}
}
在页面中使用封装好的仪表盘组件:
// entry/src/main/ets/pages/GaugeDemo.ets
import { GaugeComponent } from '../components/GaugeComponent'
@Entry
@Component
struct GaugeDemo {
@State motorSpeed: number = 1450 // 电机转速 (rpm)
@State voltage: number = 24.5 // 电压 (V)
@State current: number = 8.2 // 电流 (A)
build() {
Column({ space: 20 }) {
Text('工业仪表盘监控')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 40, bottom: 20 })
// 三个仪表盘横向排列
Row({ space: 16 }) {
// 电机转速仪表盘:量程 0~2000 rpm
GaugeComponent({
title: '电机转速',
value: this.motorSpeed,
min: 0,
max: 2000,
unit: 'rpm'
})
// 电压仪表盘:量程 0~30V
GaugeComponent({
title: '电压',
value: this.voltage,
min: 0,
max: 30,
unit: 'V'
})
// 电流仪表盘:量程 0~20A
GaugeComponent({
title: '电流',
value: this.current,
min: 0,
max: 20,
unit: 'A'
})
}
.padding(16)
// 模拟数据变化
Button('模拟数据波动')
.width(200)
.height(48)
.backgroundColor('#007DFF')
.onClick(() => {
this.motorSpeed = 1000 + Math.random() * 1000
this.voltage = 20 + Math.random() * 10
this.current = Math.random() * 15
})
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
5.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
使用 |
|
|
层叠布局,子组件按顺序叠加显示,后写的在上面 |
|
|
圆形组件,可设置填充色、描边、描边宽度等 |
|
|
描边虚线模式, |
|
|
描边端点样式为圆角,让进度条末端更美观 |
|
|
将圆环旋转 135 度,让开口朝下(类似汽车仪表盘) |
组件封装原则:把通用的 UI 逻辑封装成独立组件,通过
@Prop接收外部参数,提高代码复用率,减少重复代码。
六、页面路由与传参
6.1 这是什么
一个完整的物联网 APP 通常包含多个页面:设备列表页 → 设备详情页 → 历史数据页 → 设置页。页面路由负责管理页面之间的跳转和数据传递。鸿蒙提供了 router 模块来实现这一功能。
6.2 怎么实现
// entry/src/main/ets/pages/DeviceList.ets
import router from '@ohos.router'
interface DeviceItem {
id: number
name: string
type: string
}
@Entry
@Component
struct DeviceList {
@State devices: DeviceItem[] = [
{ id: 1, name: '1号温湿度传感器', type: 'TH_SENSOR' },
{ id: 2, name: '2号压力传感器', type: 'PRESSURE_SENSOR' },
{ id: 3, name: '3号电流传感器', type: 'CURRENT_SENSOR' },
]
// 跳转到设备详情页
goToDetail(device: DeviceItem) {
router.pushUrl({
url: 'pages/DeviceDetail', // 目标页面路径(不带 .ets 后缀)
params: { // 传递给目标页面的参数
deviceId: device.id,
deviceName: device.name,
deviceType: device.type
}
})
}
build() {
Column() {
Text('设备列表')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(16)
List({ space: 12 }) {
ForEach(this.devices, (device: DeviceItem) => {
ListItem() {
Row() {
Column({ space: 4 }) {
Text(device.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(`类型: ${device.type}`)
.fontSize(12)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
Text('详情 >')
.fontSize(14)
.fontColor('#007DFF')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(10)
.justifyContent(FlexAlign.SpaceBetween)
}
.onClick(() => this.goToDetail(device))
})
}
.padding(16)
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
// entry/src/main/ets/pages/DeviceDetail.ets
import router from '@ohos.router'
@Entry
@Component
struct DeviceDetail {
// 从路由参数中获取设备信息
@State deviceId: number = 0
@State deviceName: string = ''
@State deviceType: string = ''
// 页面即将出现时,解析路由参数
aboutToAppear() {
// 获取路由传递的参数
const params = router.getParams() as Record<string, object>
if (params) {
this.deviceId = params['deviceId'] as number
this.deviceName = params['deviceName'] as string
this.deviceType = params['deviceType'] as string
}
}
// 返回上一页
goBack() {
router.back()
}
build() {
Column() {
// 顶部导航栏
Row() {
Button('< 返回')
.fontSize(14)
.fontColor('#007DFF')
.backgroundColor('transparent')
.onClick(() => this.goBack())
Text('设备详情')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Blank() // 占位,让标题居中
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
// 设备信息卡片
Column({ space: 12 }) {
InfoRow({ label: '设备编号', value: `${this.deviceId}` })
InfoRow({ label: '设备名称', value: this.deviceName })
InfoRow({ label: '设备类型', value: this.deviceType })
InfoRow({ label: '连接状态', value: '在线 ✅' })
InfoRow({ label: '最后上报', value: '2025-07-31 14:32:18' })
}
.width('100%')
.padding(16)
.margin(16)
.backgroundColor(Color.White)
.borderRadius(12)
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
// 信息行组件
@Component
struct InfoRow {
@Prop label: string
@Prop value: string
build() {
Row() {
Text(this.label)
.fontSize(14)
.fontColor('#666666')
.width(100)
Text(this.value)
.fontSize(14)
.fontColor('#1a1a1a')
.layoutWeight(1)
}
.width('100%')
.height(40)
}
}
同时需要在 main_pages.json 中注册新页面:
// entry/src/main/resources/base/profile/main_pages.json
{
"src": [
"pages/Index",
"pages/MonitorPanel",
"pages/DeviceGrid",
"pages/GaugeDemo",
"pages/DeviceList",
"pages/DeviceDetail"
]
}
6.3 技术细节
|
API |
说明 |
|---|---|
|
|
跳转到新页面,当前页面保留在页面栈中 |
|
|
替换当前页面,不保留在栈中(适用于登录页跳转首页) |
|
|
返回上一页 |
|
|
在目标页面获取传入的参数 |
|
|
页面即将显示时的生命周期回调,适合在这里初始化数据、解析参数 |
|
|
空白占位组件,自动填充剩余空间,常用于让标题居中 |
页面栈管理:鸿蒙使用页面栈管理导航历史。
pushUrl会把新页面压入栈,back会弹出栈顶页面。栈深度不宜过深,避免内存占用过高。
七、数据驱动:网络请求与生命周期
7.1 这是什么
物联网 APP 的核心价值在于连接硬件、展示数据。本节演示如何使用鸿蒙的 http 模块从后端接口获取传感器数据,并在合适的生命周期中管理数据的初始化和资源的释放。
7.2 怎么实现
// entry/src/main/ets/utils/HttpUtil.ets
import http from '@ohos.net.http'
// 封装 HTTP 请求工具类
export class HttpUtil {
private static baseUrl: string = 'https://your-iot-api.example.com'
// GET 请求封装
static async get<T>(path: string): Promise<T> {
return new Promise((resolve, reject) => {
const httpRequest = http.createHttp()
httpRequest.request(
`${this.baseUrl}${path}`,
{
method: http.RequestMethod.GET,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN_HERE'
},
connectTimeout: 10000, // 连接超时 10 秒
readTimeout: 10000 // 读取超时 10 秒
},
(err, data) => {
if (!err && data.responseCode === 200) {
// 解析 JSON 响应
const result = JSON.parse(data.result.toString()) as T
resolve(result)
} else {
reject(err || new Error(`HTTP ${data?.responseCode}`))
}
// 销毁请求对象,释放资源
httpRequest.destroy()
}
)
})
}
// POST 请求封装
static async post<T>(path: string, body: object): Promise<T> {
return new Promise((resolve, reject) => {
const httpRequest = http.createHttp()
httpRequest.request(
`${this.baseUrl}${path}`,
{
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN_HERE'
},
extraData: JSON.stringify(body),
connectTimeout: 10000,
readTimeout: 10000
},
(err, data) => {
if (!err && data.responseCode === 200) {
const result = JSON.parse(data.result.toString()) as T
resolve(result)
} else {
reject(err || new Error(`HTTP ${data?.responseCode}`))
}
httpRequest.destroy()
}
)
})
}
}
// entry/src/main/ets/pages/NetworkDemo.ets
import { HttpUtil } from '../utils/HttpUtil'
// 定义后端返回的数据结构
interface SensorData {
deviceId: number
temperature: number
humidity: number
pressure: number
timestamp: string
}
interface ApiResponse<T> {
code: number
message: string
data: T
}
@Entry
@Component
struct NetworkDemo {
@State isLoading: boolean = false // 加载状态
@State errorMessage: string = '' // 错误信息
@State sensorData: SensorData | null = null // 传感器数据
// 页面即将显示时,自动获取数据
aboutToAppear() {
this.fetchSensorData()
}
// 页面隐藏时,可以在这里停止定时器
onPageHide() {
console.info('页面隐藏,可以在这里停止轮询定时器')
}
// 获取传感器数据
async fetchSensorData() {
this.isLoading = true
this.errorMessage = ''
try {
// 调用封装好的 HTTP 工具
const response = await HttpUtil.get<ApiResponse<SensorData>>('/api/sensor/latest')
if (response.code === 200) {
this.sensorData = response.data
} else {
this.errorMessage = response.message || '获取数据失败'
}
} catch (error) {
this.errorMessage = `请求错误: ${JSON.stringify(error)}`
} finally {
this.isLoading = false
}
}
build() {
Column({ space: 16 }) {
Text('网络数据获取示例')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 40 })
// 加载中提示
if (this.isLoading) {
LoadingProgress()
.width(50)
.height(50)
.color('#007DFF')
Text('正在获取传感器数据...')
.fontSize(14)
.fontColor('#999999')
}
// 错误提示
if (this.errorMessage !== '') {
Text(this.errorMessage)
.fontSize(14)
.fontColor('#FF4D4F')
.padding(16)
.backgroundColor('#FFF1F0')
.borderRadius(8)
}
// 数据展示
if (this.sensorData !== null) {
Column({ space: 12 }) {
DataCard({
label: '温度',
value: `${this.sensorData.temperature} °C`,
color: '#FF6B6B'
})
DataCard({
label: '湿度',
value: `${this.sensorData.humidity} %`,
color: '#4ECDC4'
})
DataCard({
label: '气压',
value: `${this.sensorData.pressure} kPa`,
color: '#45B7D1'
})
DataCard({
label: '更新时间',
value: this.sensorData.timestamp,
color: '#666666'
})
}
.width('100%')
.padding(16)
}
// 刷新按钮
Button('刷新数据')
.width(200)
.height(48)
.backgroundColor('#007DFF')
.enabled(!this.isLoading) // 加载中禁用按钮
.onClick(() => {
this.fetchSensorData()
})
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#F1F3F5')
}
}
// 数据卡片组件
@Component
struct DataCard {
@Prop label: string
@Prop value: string
@Prop color: string
build() {
Row() {
Text(this.label)
.fontSize(14)
.fontColor('#666666')
.width(80)
Text(this.value)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(this.color)
.layoutWeight(1)
}
.width('100%')
.height(50)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
.borderRadius(8)
}
}
7.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
创建 HTTP 请求对象,每个请求应独立创建,用完调用 |
|
|
HTTP 请求方法枚举 |
|
|
异步编程模式,让网络请求代码看起来像同步写法,更易读 |
|
|
页面即将出现时调用,适合在这里发起初始化请求 |
|
|
页面隐藏时调用,适合在这里清理定时器、停止轮询、释放资源 |
|
|
鸿蒙内置的加载动画组件 |
|
|
控制组件是否可交互, |
工业场景最佳实践:物联网数据通常需要定时轮询(如每 5 秒刷新一次)。可以使用
setInterval在aboutToAppear()中启动定时器,在onPageHide()中用clearInterval停止,避免后台持续消耗资源。
八、全局状态管理:@AppStorage
8.1 这是什么
在多页面的物联网 APP 中,有些数据需要在整个应用范围内共享,例如:
-
用户的登录信息(用户名、Token)
-
全局配置(服务器地址、主题色)
-
设备连接状态
鸿蒙提供了 @AppStorage 装饰器,用于实现应用级的全局状态管理,类似 Vuex / Redux 的轻量版。
8.2 怎么实现
// entry/src/main/ets/utils/AppStore.ets
// 定义全局存储的 Key 常量,避免拼写错误
export class StoreKeys {
static readonly USER_NAME: string = 'user_name'
static readonly USER_TOKEN: string = 'user_token'
static readonly SERVER_URL: string = 'server_url'
static readonly DEVICE_LIST: string = 'device_list'
}
// 初始化全局存储(在 Ability 入口中调用)
export function initAppStorage(): void {
AppStorage.setOrCreate(StoreKeys.USER_NAME, '')
AppStorage.setOrCreate(StoreKeys.USER_TOKEN, '')
AppStorage.setOrCreate(StoreKeys.SERVER_URL, 'https://default-server.com')
AppStorage.setOrCreate(StoreKeys.DEVICE_LIST, [])
}
// entry/src/main/ets/pages/LoginPage.ets
import { StoreKeys } from '../utils/AppStore'
@Entry
@Component
struct LoginPage {
@State username: string = ''
@State password: string = ''
// 登录逻辑
async doLogin() {
// 模拟登录请求...
const mockToken = 'token_' + Date.now()
// 将用户信息存入全局存储
AppStorage.set(StoreKeys.USER_NAME, this.username)
AppStorage.set(StoreKeys.USER_TOKEN, mockToken)
console.info(`登录成功,用户名: ${this.username}`)
// 跳转到首页
// router.replaceUrl({ url: 'pages/Index' })
}
build() {
Column({ space: 20 }) {
Text('物联网平台登录')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ top: 80, bottom: 40 })
// 用户名输入
TextInput({ placeholder: '请输入用户名' })
.width(300)
.height(48)
.backgroundColor(Color.White)
.onChange((value: string) => {
this.username = value
})
// 密码输入
TextInput({ placeholder: '请输入密码' })
.width(300)
.height(48)
.type(InputType.Password)
.backgroundColor(Color.White)
.onChange((value: string) => {
this.password = value
})
Button('登 录')
.width(300)
.height(50)
.backgroundColor('#007DFF')
.onClick(() => this.doLogin())
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
}
}
// entry/src/main/ets/pages/ProfilePage.ets
import { StoreKeys } from '../utils/AppStore'
@Entry
@Component
struct ProfilePage {
// 使用 @StorageProp 将变量绑定到全局存储
// 当全局存储中的值变化时,UI 会自动更新
@StorageProp(StoreKeys.USER_NAME) userName: string = ''
@StorageProp(StoreKeys.USER_TOKEN) token: string = ''
// 退出登录
logout() {
// 清空全局存储
AppStorage.set(StoreKeys.USER_NAME, '')
AppStorage.set(StoreKeys.USER_TOKEN, '')
// 跳转到登录页
// router.replaceUrl({ url: 'pages/LoginPage' })
}
build() {
Column({ space: 20 }) {
Text('个人中心')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 40 })
// 用户信息卡片
Column({ space: 12 }) {
// 头像占位
Circle({ width: 80, height: 80 })
.fill('#007DFF')
Text(this.userName || '未登录')
.fontSize(18)
.fontWeight(FontWeight.Medium)
Text(`Token: ${this.token ? this.token.substring(0, 20) + '...' : '无'}`)
.fontSize(12)
.fontColor('#999999')
}
.padding(24)
.backgroundColor(Color.White)
.borderRadius(16)
Button('退出登录')
.width(200)
.height(48)
.backgroundColor('#FF4D4F')
.onClick(() => this.logout())
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#F1F3F5')
}
}
8.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
应用级别的全局键值存储,在整个应用生命周期内持久存在 |
|
|
设置键值,如果不存在则创建 |
|
|
设置键值,必须已存在否则会报错 |
|
|
获取键对应的值 |
|
|
将组件状态变量绑定到全局存储,实现双向同步 |
|
|
类似 |
注意:
@StorageProp是单向绑定(全局 → 组件),适合读取全局状态。如果需要在组件内修改并同步回全局,使用@StorageLink。
九、实战:工业传感器数据展示页面
9.1 这是什么
本节将前面所学的知识点整合起来,实现一个完整的工业传感器数据展示页面,包含:
-
顶部标题栏
-
实时数据仪表盘
-
设备状态列表
-
底部刷新按钮
-
加载状态和错误处理
9.2 怎么实现
// entry/src/main/ets/pages/SensorDashboard.ets
import { HttpUtil } from '../utils/HttpUtil'
// ==================== 数据模型 ====================
interface RealtimeData {
temperature: number // 温度 (°C)
humidity: number // 湿度 (%)
pressure: number // 气压 (kPa)
motorSpeed: number // 电机转速 (rpm)
voltage: number // 电压 (V)
current: number // 电流 (A)
timestamp: string
}
interface DeviceStatus {
id: number
name: string
isOnline: boolean
alarmCount: number
}
// ==================== 主页面 ====================
@Entry
@Component
struct SensorDashboard {
// ---- 状态变量 ----
@State isLoading: boolean = true
@State errorMsg: string = ''
@State realtimeData: RealtimeData = {
temperature: 0,
humidity: 0,
pressure: 0,
motorSpeed: 0,
voltage: 0,
current: 0,
timestamp: '--'
}
@State devices: DeviceStatus[] = [
{ id: 1, name: '1号温湿度传感器', isOnline: true, alarmCount: 0 },
{ id: 2, name: '2号压力变送器', isOnline: true, alarmCount: 1 },
{ id: 3, name: '3号电机控制器', isOnline: false, alarmCount: 0 },
{ id: 4, name: '4号电流传感器', isOnline: true, alarmCount: 0 },
]
// ---- 生命周期 ----
aboutToAppear() {
this.loadData()
}
// ---- 加载数据 ----
async loadData() {
this.isLoading = true
this.errorMsg = ''
try {
// 模拟 API 调用(实际项目中替换为真实接口)
// const res = await HttpUtil.get<ApiResponse<RealtimeData>>('/api/realtime')
// this.realtimeData = res.data
// 模拟数据(用于演示)
await this.mockDelay(800)
this.realtimeData = {
temperature: 32.5 + Math.random() * 5,
humidity: 55 + Math.random() * 20,
pressure: 101.3 + Math.random() * 2,
motorSpeed: 1400 + Math.random() * 200,
voltage: 23.5 + Math.random() * 3,
current: 5 + Math.random() * 8,
timestamp: new Date().toLocaleString()
}
} catch (e) {
this.errorMsg = '数据加载失败,请检查网络连接'
} finally {
this.isLoading = false
}
}
// 模拟延迟
mockDelay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
build() {
Column() {
// ========== 顶部标题栏 ==========
Row() {
Text('🏭 工业传感器监控中心')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Text(this.isLoading ? '同步中...' : '已同步')
.fontSize(12)
.fontColor('rgba(255,255,255,0.7)')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#1a1a2e')
.justifyContent(FlexAlign.SpaceBetween)
// ========== 可滚动内容区 ==========
Scroll() {
Column({ space: 16 }) {
// ---- 实时数据卡片区 ----
Text('实时数据')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Start)
// 第一行:温度、湿度、气压
Row({ space: 10 }) {
MiniCard({
title: '温度',
value: `${this.realtimeData.temperature.toFixed(1)}`,
unit: '°C',
color: '#FF6B6B'
})
MiniCard({
title: '湿度',
value: `${this.realtimeData.humidity.toFixed(1)}`,
unit: '%',
color: '#4ECDC4'
})
MiniCard({
title: '气压',
value: `${this.realtimeData.pressure.toFixed(1)}`,
unit: 'kPa',
color: '#45B7D1'
})
}
.width('100%')
// 第二行:转速、电压、电流
Row({ space: 10 }) {
MiniCard({
title: '电机转速',
value: `${Math.round(this.realtimeData.motorSpeed)}`,
unit: 'rpm',
color: '#A78BFA'
})
MiniCard({
title: '电压',
value: `${this.realtimeData.voltage.toFixed(1)}`,
unit: 'V',
color: '#FBBF24'
})
MiniCard({
title: '电流',
value: `${this.realtimeData.current.toFixed(1)}`,
unit: 'A',
color: '#34D399'
})
}
.width('100%')
// 更新时间
Text(`更新时间: ${this.realtimeData.timestamp}`)
.fontSize(11)
.fontColor('#999999')
.width('100%')
.textAlign(TextAlign.End)
// ---- 设备状态区 ----
Text('设备状态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Start)
.margin({ top: 8 })
Column({ space: 8 }) {
ForEach(this.devices, (device: DeviceStatus) => {
DeviceStatusItem({
name: device.name,
isOnline: device.isOnline,
alarmCount: device.alarmCount
})
})
}
.width('100%')
}
.width('100%')
.padding(16)
}
.width('100%')
.layoutWeight(1)
.backgroundColor('#F1F3F5')
// ========== 底部操作栏 ==========
Row({ space: 12 }) {
Button('🔄 刷新数据')
.layoutWeight(1)
.height(48)
.backgroundColor('#007DFF')
.enabled(!this.isLoading)
.onClick(() => this.loadData())
Button('⚙️ 设置')
.width(100)
.height(48)
.backgroundColor('#6B7280')
.onClick(() => {
// router.pushUrl({ url: 'pages/Settings' })
})
}
.width('100%')
.height(72)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: -2 })
}
.width('100%')
.height('100%')
}
}
// ==================== 小型数据卡片组件 ====================
@Component
struct MiniCard {
@Prop title: string
@Prop value: string
@Prop unit: string
@Prop color: string
build() {
Column({ space: 4 }) {
Text(this.title)
.fontSize(11)
.fontColor('#888888')
Row({ space: 2 }) {
Text(this.value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(this.color)
Text(this.unit)
.fontSize(11)
.fontColor('#AAAAAA')
}
}
.layoutWeight(1)
.height(80)
.padding(8)
.backgroundColor(Color.White)
.borderRadius(10)
.justifyContent(FlexAlign.Center)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetY: 2 })
}
}
// ==================== 设备状态项组件 ====================
@Component
struct DeviceStatusItem {
@Prop name: string
@Prop isOnline: boolean
@Prop alarmCount: number
build() {
Row() {
Row({ space: 8 }) {
// 在线状态指示点
Circle({ width: 8, height: 8 })
.fill(this.isOnline ? '#52C41A' : '#999999')
Text(this.name)
.fontSize(14)
.fontColor('#333333')
}
Row({ space: 8 }) {
// 报警数量徽章
if (this.alarmCount > 0) {
Text(`${this.alarmCount}`)
.fontSize(11)
.fontColor(Color.White)
.width(20)
.height(20)
.textAlign(TextAlign.Center)
.backgroundColor('#FF4D4F')
.borderRadius(10)
}
Text(this.isOnline ? '在线' : '离线')
.fontSize(12)
.fontColor(this.isOnline ? '#52C41A' : '#999999')
}
}
.width('100%')
.height(48)
.padding({ left: 12, right: 12 })
.backgroundColor(Color.White)
.borderRadius(8)
.justifyContent(FlexAlign.SpaceBetween)
}
}
9.3 技术细节
|
关键概念 |
说明 |
|---|---|
|
|
滚动容器,当内容超出屏幕高度时可上下滑动查看 |
|
|
在 Row/Column 中设置权重,按比例分配剩余空间 |
|
|
文本左对齐 / 右对齐(支持 RTL 语言自动适配) |
|
|
获取本地格式化的日期时间字符串 |
|
|
四舍五入取整,适用于转速等不需要小数的场景 |
十、课程总结
10.1 核心知识点回顾
|
模块 |
关键技能 |
应用场景 |
|---|---|---|
|
声明式 UI 基础 |
|
所有页面的基础结构 |
|
Flex 弹性布局 |
|
传感器卡片横向/纵向排列 |
|
Grid 网格布局 |
|
大量同类设备的网格展示 |
|
组件封装 |
|
仪表盘、卡片等复用 UI |
|
页面路由 |
|
多页面跳转导航 |
|
生命周期 |
|
数据初始化、资源释放 |
|
网络请求 |
|
从后端/硬件获取实时数据 |
|
全局状态 |
|
登录信息、全局配置共享 |
10.2 鸿蒙物联网开发核心流程
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 硬件设备 │────▶│ 通信网关 │────▶│ 后端 API │────▶│ 鸿蒙 APP │
│ (STM32/PLC) │ │ (WiFi/蓝牙) │ │ (数据解析) │ │ (ArkTS UI) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │
│ 数据采集 → 传输 → 存储 → 展示 │
│ ▼
└─────────────────────────────────────────────────── 工业监控大屏
实时数据面板
报警通知
10.3 学习路线建议
-
第一阶段(基础):掌握 ArkTS 语法 → 熟悉声明式 UI → 学会基础布局
-
第二阶段(进阶):组件封装 → 页面路由 → 网络请求 → 生命周期管理
-
第三阶段(实战):对接真实硬件 → 完成综合项目 → 性能优化
10.4 下一步学习方向
-
PLC 智能工业控制:学习西门子/三菱 PLC 编程,实现鸿蒙 APP 与产线设备的联动控制
-
物联网云平台:接入 AGC 云控制台,实现设备远程管理和数据上云
-
机器视觉:集成 OpenCV / YOLO,实现工业零件缺陷检测
📌 文档信息
作者:课程讲师: 稷下元歌 QQ309647724
技术栈:HarmonyOS NEXT + ArkTS
适用版本:API 12+
转载请注明出处
本文档专为课程教学设计,代码可直接复制到 DevEco Studio 中使用。如有疑问,欢迎在qq309647724 加好友 区交流讨论!
浙公网安备 33010602011771号