来聊聊鸿蒙物联网开发里的 UI 界面怎么做。第三章的内容,我们聚焦 ARKUI2,也就是 OpenHarmony/HarmonyOS 从 API 9 开始主推的那套声明式开发框架,底层是 ArkUI 方舟UI框架,用 ArkTS 语言写。这套东西解决的核心问题是:物联网设备端和配套应用的界面,不再用传统命令式一级一级找控件、改属性的老办法,而是直接用数据状态驱动界面刷新。写出来的代码更短,逻辑更清晰,多人协作改页面也不容易互相踩踏。
我自己从 API 7 的 Java UI 框架转过来,刚开始很不适应,觉得声明式像“魔法”,摸清原理之后才发现真香。这一章我们就把它彻底拆开:先讲清楚为什么物联网场景特别适合声明式 UI,再梳理环境搭建和 ArkTS 语言基础,然后重点拆解状态管理、常用组件和布局,最后用一个完整的智能台灯控制页实例带大家走一遍实操流程,再把我会遇到的坑和排查方法整理成清单。
这套内容适合已经在用鸿蒙基础开发、但想转入应用层 UI 开发的工程师,也适合那些用 OpenHarmony 做智能硬件配套应用、又不想把时间耗在频繁改界面细节上的物联网开发者。如果你之前只写过 Java 或者 C,对前端式的响应式思维不熟,这一章尤其值得认真读,我会尽量把底层逻辑讲透,不绕弯子。
1. 为什么物联网应用需要 ARKUI2
很多做嵌入式或者智能硬件的朋友,一听到“UI 框架”就觉得那是 App 开发的事情,跟自己关系不大。但实际做过一个完整物联网项目就会明白,设备端采集数据只是第一步,数据要能看得见、控得动,界面这一层迟早要补上。而 ARKUI2 在物联网场景里,恰好解决了三个很痛的问题。
1.1 从命令式 UI 到声明式 UI
老一代的 UI 写法,比如 Android 的 XML 加 findViewById,或者鸿蒙早期版本的 Java UI 框架,核心逻辑是:你先创建控件,然后在代码里找到它,再设置属性、注册监听。这种命令式写法在界面复杂以后会非常痛苦,尤其是当多个数据源同时变化,你需要手动维护“哪块界面该刷新”的状态,很容易出现改了一个变量、忘了刷新另一个控件的情况。
ARKUI2 把整个思路颠倒过来。你只声明“界面长什么样”,系统负责在状态变量变化的时候自动帮你更新界面。举个例子:
@Entry @Component struct TemperatureView { @State temperature: number = 26 build() { Column() { Text(`当前温度:${this.temperature} ℃`) .fontSize(24) Button('温度 +1') .onClick(() => { this.temperature++ }) } } }temperature 是状态变量,界面上的 Text 组件显示它的值。你不需要手动去找 Text 控件,也不需要在每次温度变化时调用 setText。只要 temperature 变了,ARKUI 框架会重新执行 build 方法,找出界面里受影响的组件并做局部刷新。这种模式,写起来更像在描述界面,而不是指挥界面,代码量直接砍半。
1.2 物联网界面场景的特殊诉求
物联网 App 和普通管理类 App 相比,有非常明显的特征:
- 界面经常是单设备、单功能的极简操作页,比如一盏灯、一个温湿度计、一台电暖器。
- 需要高频显示实时状态,比如温度、湿度、开关状态、信号强度。
- 控制操作要即时反馈,点击开关、调节亮度,延迟高一点就非常难受。
- 同一个工程往往会出好几个设备页面,如果每个页面逻辑都是复制粘贴,维护成本会快速膨胀。
声明式 UI 加上组件化拆分,可以很好地匹配这些特征。特别是状态驱动的刷新机制,让“收到一条 MQTT 消息 → 更新状态变量 → 界面自动变化”这个链路变得非常顺滑。你不需要关心消息到底影响了哪一个控件,只要把数据映射到状态变量上,界面自然就同步了。
2. 环境准备与语言基础
很多初学者容易栽在第一步:工具链不熟悉,写出来的代码在 DevEco Studio 里编译不过,又看不懂报错。ARSKUI2 开发并不神秘,你真正要掌握的就三样:DevEco Studio 工程结构、ArkTS 语言语法、以及 ArkUI 内置组件和布局规则。
2.1 DevEco Studio 工程结构
当前主流的 OpenHarmony/HarmonyOS 应用开发,用的还是 DevEco Studio。新建工程时,语言选 ArkTS,模板选 Empty Ability,编译器会生成一个最基本的工程。
工程里几个关键目录需要注意:
- entry 模块:应用的主入口,绝大多数代码都写在这里。
- entry/src/main/ets/entryability:存放 UIAbility,也就是应用的启动入口。
- entry/src/main/ets/pages:存放页面文件,也就是你写 @Entry 结构体的地方。
- entry/src/main/resources:存放资源文件,如字符串、颜色、图片。
- module.json5:模块配置文件,里面需要声明权限、页面路由等信息。
物联网应用通常会用到网络权限,比如访问后台服务、连接局域网设备。这些权限需要在 module.json5 里加:
{ "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] }如果不加 INTERNET 权限,设备联网和 HTTP 请求都会直接失败,而且错误提示还不明显,这个坑我踩过好几次。你可以先用本地 mock 数据把界面调通,再接入真实网络,这样问题定位会更快。
2.2 ArkTS 语言基础精要
ArkTS 是 TypeScript 的超集,又针对声明式 UI 做了一些扩展。如果你以前写过前端,上手会很快;如果以前只写 Java/C,需要适应几个新概念。
第一个是类型系统。ArkTS 支持 TypeScript 的类型推断,所以你可以写let temperature = 26,也可以写let temperature: number = 26。在 UI 结构体内部,所有状态变量必须有明确的类型,否则框架不知道该怎么监听变化。
第二个是 UI 描述语法。ARKUI2 里常用组件有 Text、Image、Button、Column、Row、List、Stack、Slider、Toggle 等。这些组件用链式调用来设置属性:
Text('这是一个文本') .fontSize(20) .fontColor(Color.Blue) .fontWeight(FontWeight.Bold)每个组件都有自己专属的属性和通用属性。比如 Button 可以设置 type(胶囊、圆形、普通),Text 可以设置 maxLines(最大行数)、textOverflow(超长溢出方式)。具体属性记不住没关系,DevEco Studio 的代码提示很全,写一个点,候选列表就会弹出所有可用属性和枚举值。
第三是组件组织和复用。页面里所有内容都放在根组件里,常用的是 Column(垂直排列)和 Row(水平排列)。一个页面对应一个 @Entry 装饰的组件:
@Entry @Component struct PageName { // 状态变量、普通成员变量 build() { // 写 UI 结构 } }@Entry 表示这是页面入口,@Component 表示这是一个自定义组件。自定义组件可以嵌套使用,这样就能把重复的 UI 结构封成一个独立组件,比如一个显示设备状态的卡片,用五个不同的设备页,但卡片组件只写一次。
3. 核心细节:状态管理与数据同步
如果只学框架的皮毛,照着示例敲几个组件,开发一个静态页面是没问题的。但物联网应用必然要面对动态数据,数据一变,界面就要跟着变。状态管理这部分,直接决定了页面动态变化的能力。
3.1 @State:本地状态的基础用法
@State 是最常用的状态装饰器。被它修饰的变量发生变化时,关联的 UI 会自动刷新。这个装饰器要求变量所在组件必须有本地状态,所以它只能用在组件内部,不能用来跨组件传递,也不能在非组件类中单独使用。
实际使用中,@State 变量经常和网络回调或者定时器配合。比如在物联网场景里,你每隔 5 秒轮询一次后台,拿到设备最新状态,然后给它赋值:
@State temperature: number = 0 @State humidity: number = 0 aboutToAppear() { this.startPolling() } startPolling() { setInterval(() => { // 模拟从后台获取设备数据 this.temperature = 26 + Math.random() * 4 this.humidity = 40 + Math.random() * 20 }, 5000) }赋值成功后,界面上的温度、湿度文本就会自动刷新,不需要你去调用任何 update 方法。
但这里要注意一个性能问题。@State 的粒度是“变量级别的”,如果一个组件里有多个 @State,改任何一个,只有依赖它的 UI 会刷新。但如果把一整个设备对象放在一个 @State 里面,比如:
@State deviceInfo: DeviceInfo = { ... }修改这个对象里某个字段时,如果不重新赋值整个对象,ArkUI 是检测不到变化的。所以要么用类对象里带 @Observed 装饰器的属性,要么每次修改后整体重新赋值,这个是新手最容易掉进去的坑。
3.2 @Prop、@Link 与父子组件传参
物联网界面里,一个页面往往要拆分很多子组件。这时候就涉及父子组件传播数据的问题。@Prop 用来接收父组件的值,@Link 用来建立双向同步,两者区别很大。
@Prop 是单向的。父组件状态变化时,子组件里的 @Prop 会跟着更新;但子组件内部修改 @Prop,父组件并不感知:
@Component struct DeviceItem { @Prop deviceName: string build() { Text(this.deviceName) } } @Entry @Component struct DeviceList { @State name: string = '台灯' build() { Column() { DeviceItem({ deviceName: this.name }) Button('切换设备') .onClick(() => { this.name = '风扇' }) } } }@Link 是双向的。子组件改了 @Link 变量的值,父组件的状态也会变,因为 @Link 本质上和父组件的 @State 共享同一个数据源。典型场景是调节亮度,父组件把亮度值传给子组件的 Slider,Slider 拖动时直接改掉父组件的值:
@Component struct BrightnessSlider { @Link brightness: number build() { Slider({ value: this.brightness, max: 100 }) .onChange((value: number) => { this.brightness = value }) } }用 @Prop 还是 @Link,核心判断标准是:子组件修改后,父组件要不要跟着变。如果不需要,就尽量用 @Prop,避免不必要的联动刷新;如果需要,再用 @Link。
3.3 AppStorage:跨页面共享设备状态
物联网应用到了多页面阶段,状态管理会更复杂。设备列表页需要知道当前选中的是哪一个设备,控制页需要知道当前设备的基础信息,后台推送的消息可能需要同时更新多个模块的状态。
这时就用得上 AppStorage,也就是应用级的状态存储。它类似前端的全局 Store,可以跨页面共享状态:
AppStorage.SetOrCreate('currentDeviceId', 'device_12345')在页面组件里读取:
@StorageLink('currentDeviceId') currentDeviceId: string = ''@StorageLink 会跟 AppStorage 建立双向绑定,任何页面改了它,其他页面引用同一个 key 的变量都会自动刷新。这个机制在设备管理类应用里非常实用——比如在设备列表页点了一台设备,进入控制页后,控制页上所有跟当前设备相关的展示项都能自动同步。
使用 AppStorage 的注意事项:
- 比较小的临时状态不建议塞进去,能放在局部就放局部,减少全局耦合。
- key 命名要有约定,否则多个页面容易互相覆盖。建议用模块前缀,比如
device_${deviceId}_power。 - AppStorage 只在应用运行期间有效,不会持久化。设备重连、断电重启等状态,要配合 Preferences 或者数据库来做持久化。
4. 常用物联网组件:从布局到交互
熟悉了状态机制,接下来看搭建物联网界面最常用的组件。这里我不会把官方文档的所有 API 都罗列一遍,只挑在设备控制类页面中出现频率最高的几个,讲清楚各自的使用场景和要注意的点。
4.1 状态展示与列表布局
物联网界面里,列表是最高频的布局形式。设备列表、告警记录、消息通知,都是列表。ARKUI2 里写列表,首选是 List 组件,它的性能比在一个 Scroll 里堆 Column 要好很多,尤其是数据量超过一屏时更明显。List 写法如下:
List({ space: 12 }) { ForEach(this.devices, (device: DeviceInfo) => { ListItem() { DeviceCard({ device: device }) } }, (device: DeviceInfo) => device.id) }ForEach 的第三个参数 keyGenerator 建议一定要写,用设备唯一 id,而不是默认的下标。这样当数据列表发生删除、插入时,ArkUI 可以准确知道哪些项需要刷新,避免渲染错乱或性能下降。
单设备状态页还可以使用 Grid 网格布局,比如仪表盘样式的环境监测界面,温度和湿度两个大卡片并排。用 Grid 可以自动适配不同屏幕宽度,不需要手动计算位置。
4.2 交互控制与滑动条组件
设备控制页的核心交互就是按钮、开关、滑动条。
Button 是最普通的组件,但在物联网控制页里,很多按钮不是用来“跳转”,而是用来下发指令。比如开机、关机、重置设备,这些操作往往要通过网络发指令,点击之后应该给用户反馈。建议在按钮里维护一个 loading 状态:
@State isSending: boolean = false Button(this.isSending ? '发送中...' : '打开设备') .enabled(!this.isSending) .onClick(() => { this.isSending = true // 发送网络指令 setTimeout(() => { this.isSending = false }, 1000) })Toggle 组件专做开关:
Toggle({ type: ToggleType.Switch, isOn: this.powerOn }) .onChange((isOn: boolean) => { this.powerOn = isOn })Slider 是调节类组件的首选,亮度、温度、音量都能用。Slider 的 onChange 回调在拖动过程中会多次触发,如果每次触发都往后台发指令,后台会被请求淹没。建议在回调里做“防抖”,或者只在松开滑块(onChange 事件结束)时再发送最终值。更简单的做法是:拖动时只改本地 @State,界面即时反馈;松手时用 onChange 的 end 参数判断并发送指令。
除了上面这些,还有一个容易被忽略的高级组件是 Canvas。如果你需要绘制复杂的曲线图,比如温湿度历史曲线、电池放电特性曲线,ArkUI2 的 Canvas 组件可以支持自定义 2D 绘制,接口类似前端 Canvas。不过在工程上我一般不建议直接用 Canvas 画复杂图表,维护成本偏高;优先考虑用第三方图表库封装好的自定义组件,或者用 List 加简单条状图来实现轻量可视化。
5. 实操过程:做一个智能台灯控制页面
前面讲的都是知识点,现在串起来。这一节我们做一个完整的智能台灯控制页:包含开关状态显示、亮度调节、色温调节三个功能,再模拟一个定时上报状态的场景,让页面每隔几秒自动刷新。这段代码可以直接在 DevEco Studio 里建一个新项目跑起来看效果。
5.1 页面布局搭建
新建工程后,在 pages 目录创建一个 Index.ets,先搭页面结构:
@Entry @Component struct Index { @State powerOn: boolean = false @State brightness: number = 50 @State colorTemperature: number = 4000 build() { Column() { // 顶部状态栏区域 Text(this.powerOn ? '已开启' : '已关闭') .fontSize(32) .fontWeight(FontWeight.Bold) .fontColor(this.powerOn ? '#ffaa00' : '#999999') .margin({ top: 48 }) Text(`亮度:${this.brightness}%`) .fontSize(16) .margin({ top: 24 }) Slider({ value: this.brightness, min: 0, max: 100 }) .width('90%') .onChange((value: number, mode: SliderChangeMode) => { this.brightness = Math.round(value) // 拖动过程中不发送网络指令,松手时才发送 if (mode === SliderChangeMode.End) { this.sendCommand('brightness', this.brightness) } }) Text(`色温:${this.colorTemperature}K`) .fontSize(16) .margin({ top: 24 }) Slider({ value: this.colorTemperature, min: 2700, max: 6500, step: 100 }) .width('90%') .onChange((value: number, mode: SliderChangeMode) => { this.colorTemperature = Math.round(value) if (mode === SliderChangeMode.End) { this.sendCommand('colorTemperature', this.colorTemperature) } }) Toggle({ type: ToggleType.Switch, isOn: this.powerOn }) .margin({ top: 36 }) .onChange((isOn: boolean) => { this.powerOn = isOn this.sendCommand('power', isOn ? 1 : 0) }) Blank() } .width('100%') .height('100%') } sendCommand(type: string, value: number) { // 实际项目中,这里会通过 HTTP 或者 MQTT 发送指令到设备 console.info(`send command: ${type} = ${value}`) } }这个页面里,三个状态变量分别控制开关、亮度、色温。亮度与色温的调节轨迹中,onChange 会触发多次,所以我加了 SliderChangeMode.End 判断,只在用户松手那一刻才调用 sendCommand。开关状态则直接通过 Toggle 的 onChange 回调即时发送。
跑起来以后,会发现开关和滑块的 UI 变化是实时响应、不会卡顿的,就是因为 @State 驱动了 UI 局部刷新,没有走网络请求等待那条链路。
5.2 接入模拟设备状态上报
真实物联网场景中,页面的被动刷新同样重要。比如台灯内嵌了传感器,每隔几秒会上报温度和工作状态;或者另一个手机端改了灯的状态,本机页面需要同步更新。页面上需要增加一个定时查询的机制。
在自定义组件里,有个生命周期函数 aboutToAppear,页面即将显示时触发。我们在这里启动一个定时器:
private timerId: number = -1 aboutToAppear() { this.timerId = setInterval(() => { this.fetchDeviceStatus() }, 3000) } aboutToDisappear() { clearInterval(this.timerId) } fetchDeviceStatus() { // 实际项目中通过 HTTP 请求或者订阅数据通道获取 console.info('fetch device status ...') // 模拟一次返回 let remotePowerOn = Math.random() > 0.3 if (remotePowerOn !== this.powerOn) { this.powerOn = remotePowerOn } }aboutToDisappear 是页面销毁时触发的生命周期,一定记得清掉定时器,否则页面已经退出,后台定时器还在跑,要么持续拉取浪费流量,要么回调里访问了已销毁的组件,产生异常。
6. 常见问题与排查技巧实录
写 ARKUI2 的过程中,我收集了一些出现频率最高的问题,很多都是新手期会反复踩的坑,整理出来,方便大家排查。
6.1 界面不刷新
最集中的问题就是“我的数据改了,界面没反应”。这里先检查三件事:
第一,变量有没有用 @State 装饰。普通成员变量不触发 UI 刷新,改了也只有变量自己知道。
// 错误写法 temperature: number = 0 // 正确写法 @State temperature: number = 0第二,如果修改的是对象内部的字段,不能直接改属性,要重新赋值或者使用 @Observed + @ObjectLink。比如:
// 错误写法 this.deviceInfo.powerOn = true // 正确写法 this.deviceInfo = { ...this.deviceInfo, powerOn: true }第三,检查是不是异步回调里改状态。有时网络请求在主线程发出去,回调在子线程回来。ARKUI 的状态刷新要求在 UI 线程执行,直接在子线程改 @State 可能会报错或失效。应该通过 TaskPool、Emitter 等机制把结果切回 UI 上下文再更新。
6.2 列表卡顿和数据错乱
如果列表数据量较大,建议优先排查有没有写 keyGenerator。官方 ForEach 在没有 keyGenerator 时用索引,当数据新增或删除时,由于列表项复用机制,UI 可能会出现“串位”的现象。比如第一项显示的文本跑到第三项去了。我遇到过多次,都是因为列表项是动态增删,keyGenerator 缺失导致的。
写 keyGenerator 的基本原则是:key 必须在数据更新前后保持稳定,最好使用数据库主键或设备唯一 ID。如果实在没有唯一 ID,才考虑用组合出来的字符串,不要用 Math.random() 生成 key。
6.3 定时器引发的内存泄漏
在前面的例子里,aboutToDisappear 里 clearInterval 是必须的。如果没有清除,页面退出了,这个定时器还在。如果定时器回调里引用了 this,这个组件对象就无法被回收。尤其物联网页面是频繁进出、反复切换的,泄漏一次两次看不出来,但用户连续操作 20 次以后,应用就会明显变卡,严重的时候会被系统判定为无响应。
可以用 DevEco Studio 自带的 Profiler 工具检查内存,进入页面再退出,反复多次,看内存是否持续增长。如果增长明显,优先检查所有 setTimeout / setInterval 是否都留在页面销毁的生命周期里。
6.4 状态同步串线
一个页面同时控制多个设备时,如果状态变量没有区分设备 ID,界面很容易串线。比如当前操作的是 A 灯,却显示了 B 灯的亮度。建议状态变量名统一带上设备标识,或者封装成以 deviceId 为 key 的结构体存储:
@State deviceStatusMap: Record<string, DeviceStatus> = {}读取某个设备状态时,用this.deviceStatusMap[this.currentDeviceId]。不推荐把多个设备的状态都平铺在一个 Page 的状态变量里,当设备数量增长,代码会变得非常难维护。
6.5 代码编译不通过
ArkTS 对类型检查比 JavaScript 严格很多,不能把一个 undefined 直接赋给 number 类型。常见报错集中在:
- 网络返回的数据是 JSON 格式,取字段时没判空,直接赋值给 @State 变量。
- 组件属性接收特定枚举,你却传了普通字符串。
- ForEach 的回调参数类型推断不确定,建议显式标注类型。
排查编译错误时,不要只看错误信息的第一行,ArkTS 编译报错经常把真实原因藏在后面几行的“注意”里。点开发行信息,查看关联的具体代码上下文,往往比反复猜测更高效。
7. 从页面到完整应用的几个扩展建议
如果你已经把上面的页面跑通了,说明已经基本掌握了 ARKUI2 的核心用法。这里再给几个实战层面的扩展方向,直接从简单到复杂排个序。
首先,把重复的控件抽成自定义组件是必须做的一步。比如设备卡片、温湿度卡片、告警列表项,在多个页面里都会用,建议封装成独立组件,放在components目录下,通过参数控制展示逻辑。这样后续改样式,只需要改一处。
其次,需要引入模块化管理网络请求。不要在页面里直接写 fetch,容易造成页面代码冗长,也不方便统一处理 token 过期、错误提示等问题。建议单独封装一个api模块,把每个后台接口封装成一个函数,统一管理请求入口和服务端地址。这样页面代码在调用时看到的是一个明确语义的方法,而不是一堆 URL。
再次,如果页面跳转关联了设备参数,建议使用路由参数传递必要信息,比如设备 ID、设备名。进入控制页面时,在aboutToAppear里通过router.getParams()取出参数,再决定要加载哪台设备的状态。注意,路由传参只适合传递少量、轻量的数据,如果某个设备有几十个字段,应该在页面内部根据设备 ID 从数据仓库中读取完整信息。
最后,有条件的话,优先引入 MQTT 而不是 HTTP 轮询来做实时状态更新。ARKUI2 里使用 MQTT 并没有专门的官方组件,通常通过第三方 SDK 实现。但整体思路是一致的:收到消息之后,把数据更新到对应设备的 @State 变量上,UI 会自动响应。很多开发者在完成这一章的学习后,会尝试把灯光控制页接入真实的设备或者模拟器设备,这是最自然的下一步。
我在实际做项目时最深的一个体会是:声明式 UI 和状态管理的组合,概念上比传统 UI 简单,但设计状态粒度反而更考功力。太粗了容易引起整页刷新和性能浪费,太细了又导致代码碎片化。回到物联网场景,我更倾向于按设备维度组织状态,而不是把每个传感器字段平铺在页面上,再配合自定义组件做隔离,这样代码读起来也顺,后面加设备也容易扩展。编码愉快。