最近有朋友问我,HarmonyOS NEXT 上做一个智能家居控制页面到底麻不麻烦。我正好刚做完一个空调控制页面,从 API 12 的开发环境搭建到真机调试踩坑,前后折腾了小两周,今天把完整过程捋一遍——包括页面布局怎么拆分、温度调节交互怎么实现、设备数据怎么对接,以及那些文档里不会写的真机问题。
这篇内容基于 HarmonyOS NEXT SDK(API 12+ / 5.0.0(12))的 ArkTS 和 ArkUI 声明式开发,适合三种人看:准备给公司智能家居 App 做鸿蒙版的开发、个人开发者想在自己的鸿蒙应用里加一个设备控制面板、或者单纯对 "纯血鸿蒙" UI 开发感兴趣的学生。只要把工程跑通过一次,剩下的就是套路问题,看完这篇你至少能自己画出一个可交互的空调面板。
1. 开工前的技术选型:API 12 工程搭建与项目结构
1.1 为什么我锁定了 HarmonyOS NEXT 与 API 12
先说技术选型的逻辑。很多人第一次接触鸿蒙开发,容易在 API version 上犯迷糊:API 9、API 10、API 12 到底差在哪?我的选择很直接——直接上 HarmonyOS NEXT 5.0.0 SDK(对应 API 12),也就是俗称的"纯血鸿蒙"。原因有三个:
第一,API 9 到 API 11 阶段还需要兼容 AOSP 的安卓运行时,很多接口带着双框架的包袱,开发体验不干净。而 API 12 之后,HarmonyOS NEXT 去掉了 AOSP 兼容层,应用只能走 ArkTS/ArkUI 这一条路,组件、状态管理、生命周期这些概念反而更纯粹,没有历史包袱。
第二,API 12 的声明式 UI 能力和状态管理 V2 已经比较成熟,做智能家居这种重交互页面,动画、手势、组件自定义的能力都够用。
第三,华为应用市场现在对新应用上架有 API 版本要求,新开发的 App 基本都要基于 API 12 及以上,你按这个版本起步,后面审核少麻烦。
DevEco Studio 我用的 5.0.0 版本,下载后默认就带 5.0.0(12) 的 SDK。如果你之前装过老版本 DevEco,注意升级后要重新下载 SDK,路径在Settings -> SDK Manager里确认一下,否则编译时会报Failed to find SDK。
1.2 工程初始化与三方库策略
工程创建我选的是Empty Ability模板,这个模板给的是最干净的 Stage 模型结构,适合从零做自定义页面。Stage 模型和老的 FA 模型最大的区别是多模块、单入口,每个模块有独立的module.json5,权限配置在这里声明,而不是在代码里动态申请。
初始化完目录长这样:
entry/ ├── src/main/ │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets │ │ ├── pages/ │ │ │ └── Index.ets │ │ ├── components/ │ │ │ └── (你的自定义组件放这) │ │ └── common/ │ │ └── constants/ │ └── module.json5我的建议是一开始就把components和constants目录建好。空调控制页面不是一个小页面,温度环、模式切换、风速面板、定时按钮,这些东西如果全堆在Index.ets里,写到最后代码上千行,改一个样式要找半天。分层清楚,后面维护成本低很多。
关于三方库,HarmonyOS 生态有自己的包管理工具 ohpm,社区里有一些图表控件比如 MPChart 的鸿蒙版。但我做空调面板没有引入三方库,全部用 ArkUI 内置组件自己画。原因很现实:这种页面核心就是一个环形进度条加几个按钮,自绘代码量不大,引入外部依赖反而要适配 API 版本,出了问题排查链路还长。智能家居这种页面对包体积和启动速度有要求,能少依赖就少依赖。
2. 页面布局拆解:空调控制面板的 UI 骨架设计
2.1 控制面板的模块划分
动工之前先把产品需求拆清楚。一个空调控制页面,用户真正关心的东西就四块:当前温度、目标温度、运行模式、风速状态。次要的是摆风、定时、节能这些辅助功能。
我最终确定的 UI 结构是上中下三段式:
- 顶部:环境信息区,显示室内温度、室外温度、当前模式文案
- 中部:核心交互区,一个大号的环形温度调节器,中间显示目标温度数值
- 底部:功能区,一排模式按钮(制冷/制热/送风/除湿)和一排风速按钮(自动/低/中/高)
配色我选了深色系。这个不是拍脑袋,空调控制面板大多数时间是在家庭场景下使用的,深色背景在 OLED 屏幕上不仅更省电,还能让被照亮的温度数字和氛围光效成为视觉焦点。卡片用borderRadius做圆角,再加一层shadow模拟悬浮感,整体调性贴近华为智慧生活 App 的卡片风格。
2.2 用 @Builder 封装可复用组件
ArkUI 的@Builder是个好东西,它可以把一段 UI 结构封装成函数,在同一个页面里复用。我定义了两个基础卡片样式,一个叫ModeCard,一个叫FanCard,底部两排按钮都靠它们撑起来。比如模式按钮:
@Builder ModeCard(item: ModeItem, isActive: boolean) { Column() { Text(item.icon) .fontSize(28) .fontColor(isActive ? '#FFFFFF' : '#A0A0A0') Text(item.label) .fontSize(14) .fontColor(isActive ? '#FFFFFF' : '#808080') .margin({ top: 6 }) } .width(60) .height(72) .justifyContent(FlexAlign.Center) .borderRadius(18) .backgroundColor(isActive ? 'rgba(0, 180, 255, 0.25)' : '#1A1A1A') .onClick(() => { this.currentMode = item.mode; }) }这种封装方式有个好处:UI 结构、样式、点击逻辑在同一处定义,改模式按钮的选中态颜色,只需要动一个地方。后面加一个 "睡眠模式" 按钮,我只需要在数据数组里加一项,UI 不用动。
底部数据我抽了一个常量数组:
const MODE_LIST: ModeItem[] = [ { mode: 'cool', label: '制冷', icon: '❄' }, { mode: 'heat', label: '制热', icon: '☀' }, { mode: 'fan', label: '送风', icon: '🍃' }, { mode: 'dry', label: '除湿', icon: '💧' }, ];图标这里先用 emoji 居中了,正式项目建议换图标字体或者@ohos/waterprove一类的矢量图标库,避免不同机型 emoji 渲染不一致。
3. 温度调节核心交互:环形进度条与手势联动实现
3.1 环形进度条的自绘方案
温度调节是整个页面的灵魂。我见过不少实现方案:用Slider组件横着滑、用Progress组件转圈、甚至用图片旋转模拟。最终我选了Canvas自绘圆弧,配合手势在圆弧上滑动调温。
自绘圆弧的好处是视觉完全可控——圆弧粗细、渐变色、端点圆帽、动画过渡都能自己定义,这在做"温度从 16°C 到 30°C"的渐变效果时特别重要。
核心代码是这样的:
private drawTemperatureDial(ctx: CanvasRenderingContext2D, progress: number) { const diameter = 260; const centerX = diameter / 2; const centerY = diameter / 2; const radius = 104; const startAngle = -Math.PI / 2; // 从顶部开始 const sweepAngle = (progress / 100) * 2 * Math.PI; ctx.clearRect(0, 0, diameter, diameter); // 背景弧 ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.strokeStyle = '#262626'; ctx.lineWidth = 14; ctx.lineCap = 'round'; ctx.stroke(); // 前景弧,动态渐变 const gradient = ctx.createLinearGradient(0, 0, diameter, diameter); gradient.addColorStop(0, '#4FC3F7'); gradient.addColorStop(1, '#00E5FF'); ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, startAngle + sweepAngle); ctx.strokeStyle = gradient; ctx.lineWidth = 14; ctx.lineCap = 'round'; ctx.stroke(); }这里有个坑后面详细说:Canvas的默认坐标系单位是 vp,但实际设备像素密度不同,如果直接按像素画,在 2K 屏上圆弧会明显偏细,需要先获取display.getDefaultDisplaySync()的密度做适配。我先用固定值,后面统一处理。
3.2 手势联动与数值映射
温度环的手势我用 ArkUI 的PanGesture实现。用户手指在圆弧上滑动时,根据手指位置和圆心的夹角,计算出当前温度值。
角度转温度的核心工具函数:
private angleToTemp(x: number, y: number): number { const centerX = this.dialWidth / 2; const centerY = this.dialHeight / 2; // atan2 返回 -PI 到 PI,需要转换到 0~2PI let angle = Math.atan2(y - centerY, x - centerX) + Math.PI / 2; if (angle < 0) { angle += 2 * Math.PI; } // 温度范围 16~30,映射到 0~2PI const ratio = Math.min(1, Math.max(0, angle / (2 * Math.PI))); const temp = Math.round((16 + ratio * 14) * 2) / 2; return temp; }我允许的最小温度是 16°C,最高 30°C,步进 0.5°C。Math.round(x * 2) / 2这个写法就是为了保证输出 0.5 的倍数,避免出现 22.3 这种奇怪的数值。
实际项目中我把整段圆弧按比例压缩了一下,把可调节角度限制在 210° 左右,这样视觉上有一个 "未闭合" 的断口,暗示用户这里是可以拖动的,交互意图更明显。这个细节是仿照智能硬件常见的旋钮交互设计的。
手势绑定方式:
Stack() { Canvas(this.canvasCtx) .width(260) .height(260) .gesture( PanGesture() .onActionStart((event: GestureEvent) => { this.dragging = true; }) .onActionUpdate((event: GestureEvent) => { if (!this.dragging) return; this.currentTemp = this.angleToTemp(event.localX, event.localY); this.drawTemperatureDial(this.canvasCtx, this.tempToProgress(this.currentTemp)); }) .onActionEnd(() => { this.dragging = false; // 状态上报设备 this.pushDeviceCommand({ temp: this.currentTemp }); }) ) Text(`${this.currentTemp}°`) .fontSize(56) .fontColor('#FFFFFF') .fontWeight(FontWeight.Bold) }event.localX和localY是手势事件在当前组件上的局部坐标,直接用来算角度是准的。处理的时候记得判断dragging状态,避免手指没按下但onActionUpdate被误触发的边界情况。
温度数值变化后,给数字加了一个animateTo位移动画。实测下来,动画时长 200ms、曲线用Curves.EaseOut的效果最自然,太快会显得数字跳动,太慢会让人感觉设备反应迟钝。
4. 设备连接与状态同步:Mock 数据和真实 IPC 对接方案
4.1 定义统一的设备状态模型
控制页面的本质不是"画几个好看的控件",而是"把用户操作准确同步到真实设备"。这个同步链路如果设计不好,后面接真实空调时会推倒重来。所以我第一步先把空调的状态抽象成一个 TypeScript 接口,所有 UI 和逻辑都围绕这个接口展开:
export interface AirConditionerState { power: boolean; targetTemp: number; currentTemp: number; mode: 'cool' | 'heat' | 'fan' | 'dry'; fanSpeed: 'auto' | 'low' | 'mid' | 'high'; swing: boolean; timer: number; // 剩余分钟,0 表示未开启 } export interface DeviceCommand { temp?: number; mode?: AirConditionerState['mode']; fanSpeed?: AirConditionerState['fanSpeed']; power?: boolean; }有了这个接口,页面里的所有@State变量都可以收敛到一个deviceState对象上,UI 读取状态、操作下发指令,双向都是走这一个模型,不管后面接的是 zigbee、Wi-Fi 还是蓝牙,页面根本不用变。
4.2 开发期 Mock 数据怎么做到位
开发阶段没有真实设备,直接用setTimeout+ 假数据模拟设备上报。这里我特别强调一件事:Mock 不要只返回成功结果,还要模拟设备延迟和瞬时波动。
private mockDeviceResponse(cmd: DeviceCommand): Promise<AirConditionerState> { return new Promise((resolve) => { setTimeout(() => { if (cmd.temp !== undefined) { this.mockState.targetTemp = cmd.temp; } if (cmd.mode !== undefined) { this.mockState.mode = cmd.mode; } // 设备温度缓慢逼近目标温度 this.mockState.currentTemp += (this.mockState.targetTemp - this.mockState.currentTemp) * 0.1; resolve({ ...this.mockState }); }, 300 + Math.random() * 400); }); }为什么要随机 300-700ms?因为真实设备控制指令走网络/总线是有不确定性的,如果 Mock 永远固定 300ms,UI 的 loading 逻辑根本测不出来。我吃过这个亏——Mock 阶段一切瞬间响应,接真机后才发现没有做"指令已下发、等待设备回执"这个中间态,结果用户狂点按钮,指令重复下发。
所以我在页面上加了一个commandInFlight状态,每次 push 指令时把按钮置灰 500ms,防止重复操作。这个防抖在真实场景里也很有用。
4.3 真机 IPC 对接:分布式设备发现与指令下发
真实设备对接,我用的是 HarmonyOS 的分布式能力。在module.json5里声明权限:
"requestPermissions": [ { "name": "ohos.permission.DISTRIBUTED_DATASYNC", "reason": "需要同步空调设备状态和控制指令", "usedScene": { "abilities": ["EntryAbility"] } } ]然后通过@ohos.distributedDeviceManager发现周边设备,对目标设备建立会话,用 RPC 通道下发控制指令。发现设备的核心流程:
import { distributedDeviceManager } from '@kit.DistributedServiceKit'; const dm = distributedDeviceManager.createDeviceManager('com.example.aircontroller'); dm.getAvailableDeviceList((err, devices) => { const acDevice = devices.find((device: distributedDeviceManager.DeviceBasicInfo) => { return device.deviceType === distributedDeviceManager.DeviceType.SMART_AC; // 空调设备类型 }); // 拿到设备后绑定 IPC 会话 });指令下发我建议用 HarmonyOS 的DataShare或者自建的@ohos.rpc通道。空调这类设备往往有厂商自己的协议,和照明设备不一样,不建议直接套默认的"标准指令集",宁可多写一层协议解析,也要保证指令格式可控。
有一个非常容易忽略的点:设备类型枚举DeviceType.SMART_AC不是所有设备都返回空调类型,很多第三方品牌的空调在分布式设备列表里显示为NONE或者自定义类型。稳妥做法是让用户在发现列表里手动选择,再把这个设备 ID 缓存到本地Preferences,下次启动直接连接。这个选择界面虽然丑,但是能省掉大量协议兼容的调试时间。
5. 真机调试中踩过的坑:从白屏到数据不刷新
5.1 坑 1:Canvas 画不出圆弧?坐标系单位先搞清
我在模拟器上一切正常,换到真机(Mate 60 Pro)上一跑,背景弧直接看不到了。查了半天,问题出在 Canvas 坐标系。
ArkUI 的Canvas内部绘制接口CanvasRenderingContext2D使用的单位是 vp(虚拟像素),但实际上arc的半径如果写成104,在 2K 屏上渲染出来明显偏小且发虚。这不是 API 的 bug,而是CanvasRenderingContext2D需要配合设备像素密度做缩放。
解决方案是在绘制前获取屏幕密度,然后缩放上下文:
import { display } from '@kit.ArkUI'; const displayInfo = display.getDefaultDisplaySync(); const density = displayInfo.densityPixels; ctx.scale(density, density);这样圆弧的几何尺寸就是真正的 vp 单位,渲染在高低密度屏幕上都能保持一致的视觉效果。
5.2 坑 2:状态改了 UI 不刷新
开发过程中我遇到了一个最隐蔽的问题:温度环数值变了,但中间的Text不更新。原因是 ArkUI 的@State在修改对象嵌套属性时,无法触发刷新——这是 V1 状态管理的老问题了。
比如我写的是:
@State deviceState: AirConditionerState = { ... }; // 错误方式:直接修改嵌套属性 this.deviceState.targetTemp = newTemp;UI 不会刷新。原因是@State监听的是deviceState这个对象引用的变化,而不监听对象内部属性。正确的做法有两种:一是整个对象替换,二是开启 V2 状态管理用@ObservedV2和@Trace装饰器。
// 方式一:整体赋值(V1 兼容) this.deviceState = { ...this.deviceState, targetTemp: newTemp }; // 方式二:V2 状态管理(API 12 推荐) @ObservedV2 export class AirConditionerModel { @Trace targetTemp: number = 24; @Trace mode: string = 'cool'; }我最终选择了 V2 方案,API 12 里@ObservedV2和@Trace的组合专门解决这类问题,性能也比 V1 的深度监听好。这里提醒各位:如果你的项目已经用了 V1 的@State并遇到"改了不刷新"的问题,十有八九是嵌套对象的坑。
5.3 坑 3:动画失灵和过渡生硬
最开始我给温度数字做animateTo动画,明明数值变了,但数字就是"蹦"一下过去,没有平滑过渡。排查发现animateTo必须监听一个明确变化的属性,而且动画的触发时机要在状态变量变更的同时调用,不能写在tap事件之外。
正确写法:
this.animateTo({ duration: 200, curve: Curves.EaseOut }, () => { this.deviceState.targetTemp = newTemp; });注意:animateTo闭包里必须包含要改变的@State属性,动画系统才能感知变化。如果你把animateTo写在闭包外面,属性确实变了,但动画系统没有把"旧值到新值"的过渡过程录制进来,效果就是生硬的跳变。
5.4 坑 4:真机签名与调试通道
这个坑和代码无关,但每个新手都会卡:API 12 上 API 考不到deviceManager需要自动签名配置。
DevEco 5.0 支持自动签名(Automatically generate signature),但前提是你登录了华为开发者账号、创建了项目对应的证书和 Profile。我遇到的情况是自动签名提示成功,但真机跑起来还是The app is not signed。
处理办法:把自动签名关掉重新开一次,强制 DevEco 重新生成 Profile;如果还不行,手动检查build-profile.json5里的signingConfigs是否指向了default。另外确认你的开发者账号已经实名认证,否则debug类型的 Profile 会申请失败。这个环节我浪费了大半天,先记录下来给各位省点时间。
6. 还可以继续做深:服务卡片与多设备协同
6.1 服务卡片:让温度控制在桌面完成
空调控制页面上线后,用户使用频率最高的操作其实是"进门打开空调""睡前调低两度"。每次都解锁手机、打开应用、进到二级页面,路径太长。HarmonyOS 的元服务卡片(Form)恰好解决这个问题,ArkTS 支持构建卡片在桌面上直接展示关键状态和控制按钮。
卡片布局我做成两行:第一行显示"室温 27°C / 目标 24°C",第二行是一个"开关机"按钮加"模式切换"按钮。卡片禁用交互的机制很特殊:卡片本身的onClick事件不支持直接调方法,需要通过postCardAction拉起应用 Entry 来响应。所以在卡片里点按钮的实际体验是"轻柔拉起",而不是像普通页面那样立即响应。
做卡片时要注意:卡片大小限制比较严格,FormDimension的 2x2 卡片 H 第二行放三个按钮会超渲染区,实测两行各两个按钮最稳。
6.2 多设备协同场景
最后一个可以留作后续扩展的方向是分布式场景。比如冬天到家前 5 分钟,在手表上按一下"回家模式",通过同账号设备的分布式 Session 通知客厅空调面板进入制热;或者 Pad 靠近卧室门时自动把当前控制面板迁移到 Pad 上显示。
这两个场景在 HarmonyOS 里都已经从"概念"变成了"可落地的 API 方案"——分布式软总线天然支持跨设备流转,我在空调页面里预留了onPageShow时重新获取设备列表的逻辑,将来接手表和智慧屏的协同,页面代码可以复用大部分。
就我个人体验来说,HarmonyOS 上做智能家居控制页,复杂度不在于 UI 绘制,而在于状态同步和设备协议这两块的边界设计。UI 部分按组件拆好、状态模型定好,后面接什么设备都不慌。
最后分享一个小技巧:调温手势灵敏度不要太激进,我当时把一整个圆环都映射成温度范围,结果手指轻微动一下就跳好几度,体验很差。后来把可拖动角度压缩到 210° 并限制在上下各留 15° 的盲区,手感立刻舒服了。这类交互细节,只能靠真机一遍遍试,模拟器上是体会不到手指摩擦感的。