HarmonyOS智能家居——架构总览与通用模式提炼
智能家居 App 做到现在已经覆盖了 5 个页面和 1 个数据服务层。和其他两个 App 相比,它的页面数量最少,但每个页面的信息密度最高——首页塞了五个区块,房间页有三种设备类型的条件渲染,设置页有 12 个菜单项。回过头看整个项目,有些规律在单篇文章里讲不清楚,这篇做一次全局性的梳理。
完整效果
项目全貌
pages/
Index.ets 首页仪表盘
RoomPage.ets 房间详情(设备控制)
SecurityPage.ets 安防中心
EnergyPage.ets 能耗监测
SettingsPage.ets 设置中心
services/
DeviceData.ets 设备/房间/场景/能耗数据 + 操作函数
5 个页面,1 个服务层。页面数量是三个 App 里最少的(运动健康 12 页、旅行探索 10 页),但数据层是最复杂的——17 个设备、5 个房间、5 个场景、7 条能耗记录,还有 4 个操作函数。
数据层的集中管理
// DeviceData 暴露的内容
export interface Device { ... }
export interface Room { ... }
export interface Scene { ... }
export interface EnergyRecord { ... }
export const ROOMS: Room[] = [...]
export const DEVICES: Device[] = [...]
export const ENERGY_DATA: EnergyRecord[] = [...]
export const SCENES: Scene[] = [...]
export function toggleDevice(id: number): boolean { ... }
export function setDeviceValue(id: number, val: number): void { ... }
export function getDevicesByRoom(room: string): Device[] { ... }
export function getActiveCount(): number { ... }

四个接口、三组数据、四个操作函数——这是三个 App 里数据层最完整的。运动健康 App 的 WorkoutData 只有数据定义和查询函数,没有操作函数;旅行探索 App 的 TravelData 只有只读的 mock 数据。智能家居 App 的 DeviceData 同时承担了"数据存储"和"数据操作"两个职责。
这种集中管理的好处是所有页面共享同一份数据——首页调用 toggleDevice 修改设备状态后,RoomPage 通过 getDevicesByRoom 读到的是最新数据。代价是模块级常量被直接修改,没有封装保护。在原型阶段这是最简单的方案,后续需要引入服务层模式来规范数据访问。
页面间的数据流
DeviceData (数据源 + 操作函数)
│
├→ Index (首页仪表盘)
│ ├→ toggleDevice() 切换设备状态
│ ├→ getActiveCount() 统计在线数
│ ├→ activateScene() 激活场景
│ ├→ RoomPage (传房间名称)
│ ├→ SecurityPage (无参数)
│ ├→ EnergyPage (无参数)
│ └→ SettingsPage (无参数)
│
├→ RoomPage (房间详情)
│ ├→ getDevicesByRoom() 按房间查询设备
│ ├→ toggleDevice() 切换设备状态
│ └→ setDeviceValue() 调节设备参数
│
├→ SecurityPage (安防中心)
│ └→ 本地 @State (不依赖 DeviceData)
│
├→ EnergyPage (能耗监测)
│ └→ ENERGY_DATA (只读)
│
└→ SettingsPage (设置中心)
└→ 本地 @State (不依赖 DeviceData)
五个页面对 DeviceData 的依赖程度各不相同:
- Index:深度依赖——读取所有数据组,调用三个操作函数
- RoomPage:中度依赖——调用查询和操作函数
- EnergyPage:轻度依赖——只读取 ENERGY_DATA
- SecurityPage:无依赖——所有数据在页面内部
- SettingsPage:无依赖——所有数据在页面内部
这种"核心页面重依赖、边缘页面零依赖"的分布在中小型 App 里很常见。首页和房间页是功能核心,需要访问完整数据;安防、能耗、设置是辅助功能,数据独立或只读。
@State 的三种使用场景
整个项目里的 @State 可以归纳为三种使用场景:
场景一:动态数组(需要复制触发刷新)
// Index - 场景激活
@State scenes: Scene[] = []
this.scenes[idx].active = true
const copy: Scene[] = []
for (let j...) { copy.push(this.scenes[j]) }
this.scenes = copy
// RoomPage - 设备列表刷新
this.devices = getDevicesByRoom(this.room)
数组的修改需要通过"复制替换"或"重新查询"来触发 @State 刷新。这是 ArkTS 状态管理里最常见的坑。
场景二:简单布尔值(直接切换)
// SettingsPage - 开关状态
@State notifyOn: boolean = true
this.notifyOn = !this.notifyOn
// SecurityPage - 安防状态
@State doorLocked: boolean = true
this.doorLocked = !this.doorLocked
布尔值的切换直接赋值就行,不需要复制。@State 能直接检测到基本类型值的变化。
场景三:数值统计(重新计算赋值)
// Index - 在线设备数
@State devCount: number = getActiveCount()
onPageShow(): void { this.devCount = getActiveCount() }
数值型 @State 不需要手动修改,通过重新调用计算函数获取最新值再赋值。
三种场景对应三种不同的刷新策略:
| 场景 | 数据类型 | 刷新方式 | 复杂度 |
|---|---|---|---|
| 动态数组 | Array | 复制替换或重新查询 | 高 |
| 简单布尔 | boolean | 直接赋值 | 低 |
| 数值统计 | number | 重新计算赋值 | 中 |
理解这三种场景,就掌握了 ArkTS @State 的核心用法。
Builder 的职责分工
整个项目用了 7 个 Builder,每个 Builder 的职责都很单一:
| Builder | 所在页面 | 职责 | 参数数量 |
|---|---|---|---|
| NavBtn | Index | 底部导航按钮 | 3 |
| StatusCard | SecurityPage | 安防状态展示 | 4 |
| ActionBtn | SecurityPage | 安防操作按钮 | 3 |
| ECard | EnergyPage | 能耗统计卡片 | 4 |
| RoomEnergy | EnergyPage | 房间能耗条形图 | 3 |
| Link | SettingsPage | 跳转链接菜单项 | 4 |
| Toggle | SettingsPage | 开关切换菜单项 | 5 |
没有跨页面复用的 Builder——每个 Builder 都只在自己的页面里使用。唯一的页面内复用是 SettingsPage 的 Link 和 Toggle(分别被多次调用)。
这和前两个 App 的情况一致。Builder 在 ArkTS 里的定位是"页面内的轻量复用"。当同一个结构在页面里出现两次以上时,抽成 Builder;只出现一次的,内联写就行。
颜色常量的项目间一致性
| 常量 | 智能家居 | 运动健康 | 旅行探索 |
|---|---|---|---|
| A(主色) | #6C5CE7 紫 | #FF4757 红 | #FF6B35 橙 |
| T1 | #1E1B2E | #1E1B2E | #1E1B2E |
| T2 | #888888 | #888888 | #888888 |
| T3 | #BBBBBB | #BBBBBB | #BBBBBB |
| BG | #F5F4FA | #F8F7FC | #F8F7FC |
T1/T2/T3 在三个 App 里完全一致——它们是"中性色",不承载品牌调性。A(主色)根据 App 主题变化:智能家居用紫色(科技感),运动健康用红色(活力感),旅行探索用橙色(探索感)。
BG(背景色)在智能家居里是 #F5F4FA(偏冷灰),和其他两个 App 的 #F8F7FC(偏暖灰)略有不同。冷灰色和紫色主色搭配更协调,暖灰色和红/橙主色搭配更协调。
导航栏的统一模式
所有页面的导航栏结构完全一致:
Row() {
Row() { SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1]) }
.width(34).height(34).borderRadius(17)
.backgroundColor('rgba(0,0,0,0.03)')
.justifyContent(FlexAlign.Center)
.onClick(() => { router.back() })
Text('页面标题').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(T1).margin({ left: 10 }).layoutWeight(1)
}

34×34 圆形返回按钮 + 标题 + layoutWeight(1) 占满剩余空间。这个结构在 5 个页面里出现了 4 次(Index 用底部导航栏代替)。
统一的导航栏意味着用户在任何页面都知道"左上角是返回按钮"——不需要重新学习每个页面的导航方式。这种一致性比任何单个页面的设计都重要。
条件渲染的两种模式
项目里有两种条件渲染模式:
模式一:if 控制显隐
// RoomPage - 设备关闭时隐藏滑块
if (d.on && d.type === 'light') {
Slider(...)
}
// SettingsPage - 无条件渲染所有菜单项
设备页面的滑块根据设备状态和类型决定是否显示——灯关闭时亮度滑块消失,空调关闭时温度滑块消失。这种条件渲染让卡片高度能自适应内容。
模式二:三元表达式控制样式
// SecurityPage - 状态文字颜色
Text(this.doorLocked ? '已锁定' : '未锁定')
.fontColor(this.doorLocked ? '#00B894' : '#FF6B6B')
// SettingsPage - 开关文字
Text(on ? '🟢 开' : '⚫ 关')
.fontColor(on ? A : T3)
不需要隐藏/显示元素,只需要根据状态改变文字或颜色时,用三元表达式更简洁。
两种模式的选择标准:需要隐藏元素用 if,只需要改样式用三元表达式。
数据修改的两种粒度
项目里的数据修改有两种粒度:
粗粒度:切换布尔状态
toggleDevice(id) // on: true → false
this.doorLocked = !this.doorLocked
this.alarm = !this.alarm
只修改一个布尔值,不涉及数值计算。
细粒度:调节连续数值
setDeviceValue(id, val) // value: 80 → 65
d.value = Math.round(v) // Slider 拖动
修改一个数值参数,需要取整、范围限制等处理。
粗粒度操作(开关、激活/撤防)适合用按钮实现,细粒度操作(亮度、温度)适合用 Slider 实现。页面的交互设计应该根据数据修改的粒度来选择控件类型。
页面信息密度的对比
| 页面 | 区块数 | @State 数量 | Builder 数量 | 数据来源 |
|---|---|---|---|---|
| Index | 5 | 2 | 2 | DeviceData(深度) |
| RoomPage | 1(列表) | 2 | 0 | DeviceData(中度) |
| SecurityPage | 3 | 3 | 2 | 本地 |
| EnergyPage | 4 | 0 | 2 | DeviceData(只读) |
| SettingsPage | 5 | 5 | 3 | 本地 |
首页的信息密度最高(5 个区块),但 @State 数量不是最多的(2 个)——因为很多数据是直接从 DeviceData 读取的,不需要在页面里维护副本。SettingsPage 的 @State 最多(5 个),因为每个开关都需要独立的状态变量。
这个规律说明:信息密度和状态管理复杂度不是正相关的。首页看起来最复杂,但状态管理相对简单;设置页看起来最简单,但状态变量最多。页面的复杂度取决于它的交互模式,而不是它的视觉复杂度。
更多推荐


所有评论(0)