HarmonyOS智能家居——设备数据的全链路追踪
前面几篇分别讲了每个页面怎么用数据,但没有把数据的完整流转过程串起来。这篇从 DeviceData 的定义出发,追踪 17 个设备数据从"定义 → 读取 → 修改 → 刷新"的完整链路,搞清楚每个页面读了什么、改了什么、怎么触发 UI 更新。
完整效果
一、数据定义层:DeviceData.ets 的完整结构
export interface Device {
id: number; name: string; icon: string; type: string; room: string; on: boolean
value: number; color: string
}
export interface Room {
name: string; icon: string; deviceCount: number; activeCount: number; color: string
}
export interface EnergyRecord { time: string; usage: number }
export interface Scene {
name: string; icon: string; desc: string; active: boolean; color: string
}

四个接口定义了四种数据结构。逐个看每个字段的作用:
Device 接口是核心——8 个字段覆盖了设备的所有属性:
| 字段 | 类型 | 含义 | 用途 |
|---|---|---|---|
| id | number | 唯一标识 | toggleDevice/setDeviceValue 通过 id 定位设备 |
| name | string | 显示名称 | 页面展示"主灯""空调"等 |
| icon | string | emoji 图标 | 页面展示 💡❄️ 等图标 |
| type | string | 设备类型 | 区分 light/thermostat/appliance/curtain |
| room | string | 所属房间 | getDevicesByRoom 通过 room 过滤 |
| on | boolean | 开关状态 | 页面展示 🟢/⚪,切换后刷新 |
| value | number | 参数值 | 亮度/温度/开合度等,Slider 控制 |
| color | string | 主题色 | 卡片边框和强调色 |
type 字段决定了页面的条件渲染逻辑——type === 'light' 显示亮度滑块,type === 'thermostat' 显示温度滑块,其他类型不显示滑块。这个字段是 RoomPage 里三种设备卡片分支的依据。
value 字段的含义随设备类型变化:灯是亮度(1-100),空调是温度(16-30),窗帘是开合度(0-100),冰箱是温度(-18°C),其他设备是 0。这种"一个字段承载多种含义"的设计比为每种设备定义独立字段更简洁,代价是读取时需要根据 type 做类型判断。
Room 接口有 5 个字段,但 deviceCount 和 activeCount 是写死的——没有从 DEVICES 数组动态计算。这意味着如果 DEVICES 的设备变化了,ROOMS 里的计数不会自动更新。在 mock 阶段这是可接受的,真实 App 应该从 DEVICES 动态计算:
// 真实 App 应该这样计算,而不是写死
function getRoomDeviceCount(roomName: string): number {
let count = 0
for (let i = 0; i < DEVICES.length; i++) {
if (DEVICES[i].room === roomName) count++
}
return count
}
二、数据存储方式:模块级常量
export const ROOMS: Room[] = [
{ name: '客厅', icon: '🛋️', deviceCount: 5, activeCount: 3, color: '#FF6B6B' },
{ name: '卧室', icon: '🛏️', deviceCount: 4, activeCount: 2, color: '#8A5DFA' },
{ name: '厨房', icon: '🍳', deviceCount: 3, activeCount: 1, color: '#FF9F43' },
{ name: '书房', icon: '📚', deviceCount: 3, activeCount: 2, color: '#5DADE2' },
{ name: '浴室', icon: '🛁', deviceCount: 2, activeCount: 1, color: '#00B894' },
]
export const DEVICES: Device[] = [
{ id: 1, name: '主灯', icon: '💡', type: 'light', room: '客厅', on: true, value: 80, color: '#FFD93D' },
{ id: 2, name: '空调', icon: '❄️', type: 'thermostat', room: '客厅', on: true, value: 24, color: '#5DADE2' },
// ... 共 17 个设备
]
export const ENERGY_DATA: EnergyRecord[] = [
{ time: '0时', usage: 0.3 }, { time: '4时', usage: 0.2 },
{ time: '8时', usage: 1.5 }, { time: '12时', usage: 2.1 },
{ time: '16时', usage: 1.8 }, { time: '20时', usage: 2.8 },
{ time: '24时', usage: 0.8 },
]
export const SCENES: Scene[] = [
{ name: '离家模式', icon: '🚪', desc: '关闭所有设备,启动安防', active: false, color: '#FF6B6B' },
{ name: '回家模式', icon: '🏠', desc: '开灯、空调、窗帘', active: true, color: '#00B894' },
// ... 共 5 个场景
]

四个 export const 数组是整个 App 的数据源。关键字是 export——每个导入 DeviceData 的页面都能读到同一份数据。
为什么用 const 而不是 let?因为数组本身是常量(不能重新赋值为另一个数组),但数组的内容是可变的(可以修改 DEVICES[0].on = false)。ArkTS/TypeScript 里 const 只保护引用不被替换,不保护对象属性不被修改。所以 toggleDevice 能直接写 DEVICES[i].on = !DEVICES[i].on,不需要先把 DEVICES 改成 let。
17 个设备分布在 5 个房间,每个房间 2-5 个设备。设备的 id 从 1 到 17 连续编号,room 字段用中文字符串匹配(“客厅”"卧室"等)。这种"中文字符串做外键"的做法在原型阶段最直观,但真实 App 应该用数字 ID 关联,避免中文编码问题。
三、操作函数的完整实现
export function toggleDevice(id: number): boolean {
for (let i: number = 0; i < DEVICES.length; i++) {
if (DEVICES[i].id === id) {
DEVICES[i].on = !DEVICES[i].on
return DEVICES[i].on
}
}
return false
}
export function setDeviceValue(id: number, val: number): void {
for (let i: number = 0; i < DEVICES.length; i++) {
if (DEVICES[i].id === id) {
DEVICES[i].value = val
return
}
}
}
export function getDevicesByRoom(room: string): Device[] {
const result: Device[] = []
for (let i: number = 0; i < DEVICES.length; i++) {
if (DEVICES[i].room === room) result.push(DEVICES[i])
}
return result
}
export function getActiveCount(): number {
let c: number = 0
for (let i: number = 0; i < DEVICES.length; i++) {
if (DEVICES[i].on) c++
}
return c
}

四个函数的实现有一个共同特点:全部用 for 循环遍历数组。没有用 find、filter、reduce 等高阶函数。这是 ArkTS 的限制——部分高阶函数在 ArkTS 里的支持不如 TypeScript 完善,用 for 循环最稳妥。
toggleDevice 返回 boolean——修改后的状态值。这让调用方可以根据返回值决定后续操作:
// 首页的调用方式
.onClick(() => {
toggleDevice(1) // 切换状态,返回值被忽略
this.devCount = getActiveCount() // 重新计算在线数
})
这里忽略了 toggleDevice 的返回值,因为首页只关心在线设备总数,不关心单个设备的新状态。如果需要根据新状态做不同操作(比如设备关闭后弹提示),可以用返回值判断。
setDeviceValue 返回 void——不需要返回值,因为调用方(RoomPage)已经通过 d.value = Math.round(v) 直接修改了引用对象的属性。注意这里有一个微妙的地方:RoomPage 的 Slider 回调里同时做了两件事——修改源数据(d.value = Math.round(v))和刷新 @State(this.devices = getDevicesByRoom(this.room))。修改源数据让 DEVICES 数组保持最新,刷新 @State 让 UI 重新渲染。
getDevicesByRoom 返回新数组——result.push(DEVICES[i]) 创建了一个新数组,不是返回 DEVICES 的引用。这是正确的做法——如果返回 DEVICES 的过滤引用,调用方修改返回值会影响原始数据。返回新数组隔离了原始数据和页面数据。
getActiveCount 返回数字——遍历所有设备,统计 on === true 的数量。每次调用都重新遍历整个数组,时间复杂度 O(n)。在 17 个设备的规模下性能完全不是问题,但如果设备增加到上千个,可以考虑缓存计数。
四、数据流向:每个页面读写什么
首页 Index.ets
// 导入
import { ROOMS, DEVICES, ENERGY_DATA, SCENES, Scene, toggleDevice, getActiveCount, EnergyRecord, Room } from '../services/DeviceData'
// 读取的数据
this.devCount + '/' + DEVICES.length // 设备状态卡片:在线数/总数
ForEach(ROOMS, ...) // 房间列表
DEVICES[0].on ? '🟢' : '⚪' // 快捷控制:设备状态
ForEach(ENERGY_DATA, ...) // 能耗柱状图
ForEach(this.scenes, ...) // 智能场景(从 SCENES 复制而来)
// 修改的数据
toggleDevice(1) // 切换客厅主灯
toggleDevice(2) // 切换客厅空调
toggleDevice(3) // 切换电视
toggleDevice(6) // 切换床头灯
this.scenes[idx].active = true // 激活场景

首页是数据的"总入口"——读取了全部四组数据,调用了 toggleDevice 和 getActiveCount。快捷控制的 4 个 GridItem 分别硬编码了 DEVICES[0]、DEVICES[1]、DEVICES[2]、DEVICES[5] 的 id(1、2、3、6),而不是用 ForEach 遍历。这种硬编码让每个 GridItem 的图标和名称是固定的,不需要额外的数据映射。
房间页 RoomPage.ets
// 导入
import { getDevicesByRoom, toggleDevice, setDeviceValue, Device } from '../services/DeviceData'
// 读取的数据
this.devices = getDevicesByRoom(this.room) // 按房间名查询设备列表
d.on // 设备开关状态
d.value // 设备参数值(亮度/温度)
// 修改的数据
toggleDevice(d.id) // 切换设备开关
setDeviceValue(d.id, Math.round(v)) // 调节设备参数
this.devices = getDevicesByRoom(this.room) // 刷新设备列表
房间页是数据的"操作中心"——所有设备控制都在这里完成。getDevicesByRoom 根据路由参数 room 过滤出当前房间的设备,返回一个新数组。每次操作后都重新调用 getDevicesByRoom 刷新 @State。
这里有一个关键的设计决策:为什么操作后要重新查询,而不是直接修改 @State 数组里的属性?
// 方案 A(当前做法):重新查询
d.value = Math.round(v)
this.devices = getDevicesByRoom(this.room)
// 方案 B(不推荐):直接修改
d.value = Math.round(v)
// 没有 this.devices = ...,不触发 @State 刷新
方案 B 不会触发 UI 更新——因为 d.value 修改的是 DEVICES 数组里的对象属性,不是 @State 的引用。@State 只检测引用变化,不检测属性变化。所以必须重新查询得到新数组,替换 @State 的引用。
安防页 SecurityPage.ets
// 没有导入 DeviceData
// 数据来源
@State cams: Cam[] = [
{ name: '大门摄像头', icon: '📷', online: true },
{ name: '客厅摄像头', icon: '📹', online: true },
{ name: '车库摄像头', icon: '🎥', online: false },
]
@State alarm: boolean = true
@State doorLocked: boolean = true
// 修改的数据
this.doorLocked = !this.doorLocked
this.alarm = !this.alarm
安防页完全不依赖 DeviceData——摄像头列表、门锁状态、警报状态都是页面内部的 @State。这意味着安防数据和设备数据是完全独立的:修改设备状态不会影响安防状态,反之亦然。
这种"数据隔离"在原型阶段是合理的——安防数据(摄像头、门锁、警报)和设备数据(灯、空调、电视)在业务上确实是两个独立的域。真实 App 里可能需要关联(比如"离家模式"触发门锁上锁),但当前版本不需要。
能耗页 EnergyPage.ets
// 导入
import { ENERGY_DATA, EnergyRecord } from '../services/DeviceData'
// 读取的数据
ForEach(ENERGY_DATA, (e: EnergyRecord) => {
Text(e.usage + 'kWh') // 柱状图数值
Column() {}.height(e.usage * 40) // 柱子高度
Text(e.time) // 时间标签
})
// 修改的数据
// 无——能耗数据只读
能耗页是只读的——只导入 ENERGY_DATA 和 EnergyRecord,不导入任何操作函数。页面展示的统计数据(今日用电、预计本月、同比上月)是硬编码的,不从 ENERGY_DATA 动态计算。
设置页 SettingsPage.ets
// 没有导入 DeviceData
// 数据来源
@State notifyOn: boolean = true
@State darkMode: boolean = false
@State autoUpdate: boolean = true
@State voiceWake: boolean = true
@State energySave: boolean = false
// 修改的数据
this.notifyOn = !this.notifyOn
this.darkMode = !this.darkMode
this.autoUpdate = !this.autoUpdate
this.voiceWake = !this.voiceWake
this.energySave = !this.energySave
设置页和安防页一样,完全不依赖 DeviceData。五个开关都是页面内部的 @State,修改后只影响页面内的显示。
五、两层状态的协作关系
整个 App 有两层状态:
第一层:DeviceData(共享状态)
- 17 个设备的 on/value 属性
- 通过操作函数修改
- 首页和房间页读取并响应变化
第二层:页面 @State(本地状态)
- 安防页的 alarm/doorLocked
- 设置页的 notifyOn/darkMode 等
- 首页的 scenes(从 SCENES 复制)
- 只在当前页面生效
两层状态的关系可以用一个图表示:
DeviceData(共享)
├→ Index 读取: ROOMS, DEVICES, ENERGY_DATA, SCENES
│ 修改: toggleDevice(), scenes[idx].active
├→ RoomPage 读取: getDevicesByRoom()
│ 修改: toggleDevice(), setDeviceValue()
├→ EnergyPage 读取: ENERGY_DATA
│ 修改: 无
├→ SecurityPage: 不读取,不修改(独立 @State)
└→ SettingsPage: 不读取,不修改(独立 @State)
首页和房间页共享 DeviceData 的状态变化——房间页修改设备后,首页能看到变化。安防页和设置页完全独立——它们的 @State 变化不会影响其他页面,其他页面的变化也不会影响它们。
这种"部分共享、部分独立"的架构适合智能家居的业务场景:设备状态需要全局一致(灯的开关在所有页面都一样),但安防和设置是局部状态(每个页面自己管理)。
六、数据修改的完整链路
以"在房间页调节客厅主灯亮度"为例,追踪完整的数据修改链路:
1. 用户拖动 Slider
↓
2. Slider.onChange 触发回调
↓
3. d.value = Math.round(v) ← 修改 DEVICES 数组里的对象属性
↓
4. this.devices = getDevicesByRoom(this.room) ← 重新查询,得到新数组
↓
5. @State 检测到引用变化,触发重新渲染
↓
6. UI 更新:Slider 显示新值,状态文字更新
第 3 步修改的是 DEVICES 数组(源数据),第 4 步创建的是新数组(@State 副本)。这两步缺一不可:
- 只做第 3 步不做第 4 步:DEVICES 更新了,但 UI 不刷新(@State 未变化)
- 只做第 4 步不做第 3 步:UI 刷新了,但 DEVICES 没更新(下次查询还是旧值)
这个"先改源数据、再查新数组"的模式是整个项目里 @State 刷新的核心范式。
七、数据流的断裂点
整个数据流有两个"断裂点"——数据不会自动同步的地方:
断裂点一:ROOMS 的 activeCount
export const ROOMS: Room[] = [
{ name: '客厅', icon: '🛋️', deviceCount: 5, activeCount: 3, color: '#FF6B6B' },
// ...
]
activeCount 是写死的 3,不会随着设备开关变化。首页显示的"3/5 设备"永远是这个值,即使用户关了客厅的所有灯。要修复这个问题,需要把 activeCount 改成动态计算:
function getRoomActiveCount(roomName: string): number {
let count = 0
for (let i = 0; i < DEVICES.length; i++) {
if (DEVICES[i].room === roomName && DEVICES[i].on) count++
}
return count
}
然后在首页的房间卡片里调用 getRoomActiveCount(room.name) 代替 room.activeCount。
断裂点二:设备状态卡片的总数
Text(this.devCount + '/' + DEVICES.length + ' 在线')
DEVICES.length 是固定的 17,this.devCount 通过 getActiveCount() 动态计算。但如果以后设备可以动态添加或删除,DEVICES.length 也需要动态计算。当前版本不需要处理这个场景。
这两个断裂点都是 mock 阶段的简化——数据是写死的,不需要动态同步。真实 App 需要解决这些问题,但不影响当前版本的功能演示。
八、数据层的扩展方向
如果要让这个数据层更接近真实 App,需要做三件事:
第一,引入设备状态持久化。 当前的 DEVICES 是内存数据,App 重启后恢复默认值。真实 App 需要将设备状态保存到本地存储(Preferences)或云端数据库。
第二,引入事件总线。 当前首页和房间页通过共享 DEVICES 数组实现状态同步。如果页面数量增加,需要引入事件总线(EventEmitter)来解耦页面间的数据依赖。
第三,引入数据校验。 setDeviceValue 没有校验参数范围——传入 val = 999 也会直接赋值。真实 App 需要在操作函数里加参数校验:
export function setDeviceValue(id: number, val: number): void {
for (let i: number = 0; i < DEVICES.length; i++) {
if (DEVICES[i].id === id) {
// 参数校验:根据设备类型限制范围
if (DEVICES[i].type === 'light') {
DEVICES[i].value = Math.max(1, Math.min(100, val))
} else if (DEVICES[i].type === 'thermostat') {
DEVICES[i].value = Math.max(16, Math.min(30, val))
} else {
DEVICES[i].value = val
}
return
}
}
}
这三件事是原型 → 产品化的必经之路,但不影响当前版本作为技术演示的价值。
更多推荐



所有评论(0)