从闲置平板到家里的“控制大脑“:鸿蒙智慧中控面板完整实战

你家里有没有一台吃灰的旧平板?插上电往墙上一挂,它就能变成整个家的智能控制中心——灯、空调、窗帘、门锁、摄像头,一屏掌控。这不是需要花几百上千买的商业中控屏,用鸿蒙几行代码你就能自己做一个。
一、为什么我要自己做一个中控面板?
做这个项目之前,我先问了自己几个问题:
1.1 现有方案的三个痛点
市面上其实有很多智能家居中控方案,米家、HomeKit、涂鸦、Aqara都有自己的面板,但用下来有几个共同的问题:
-
被生态锁死:买了米家的面板,就很难控制HomeKit的设备;反过来也一样。我家就是混合生态——客厅灯是Yeelight、空调是美的、门锁是鹿客、窗帘是Aqara、热水器是海尔,每装一个设备就要多一个APP。
-
面板价格离谱:专门的中控屏从几百到上千不等,很多功能手机上都有,只是"挂墙上"这个形态就要多收这么多钱,我觉得不值。
-
闲置设备浪费:抽屉里躺着一台MatePad 11,骁龙865性能够用、屏幕素质不错,就是平时不怎么用。与其让它吃灰,不如挂墙上当24小时常驻的控制中心。
鸿蒙的分布式能力天然适合解决这个问题:它不要求所有设备都是鸿蒙生态,通过HTTP/websocket/MQTT对接第三方平台API,再通过统一的UI层做聚合,就能做成"一个面板管全家"。
1.2 这个面板到底要做什么?
动笔写代码之前,一定要先把边界划清楚。我给自己列了"必须有"和"不做"两张清单:
✅ 必须有:
- 家里所有灯光的分组控制(客厅、卧室、书房、厨房、卫生间),支持亮度、色温调节
- 空调、地暖、新风的温度、模式、风速控制
- 窗帘开合度控制,支持全开、全关、指定百分比
- 门锁状态实时显示,开锁记录推送
- 摄像头实时预览画面
- 一键场景模式:回家、离家、观影、睡眠、起夜
- 24小时常驻,有人靠近自动亮屏,离开自动熄屏省电
- 手机、平板、手表都能控制,状态实时同步
❌ 一开始不做:
- 不自己做硬件接入层——先通过各平台公开的HTTP API/局域网协议对接,本地直连后面再迭代
- 不做语音控制——系统自带小艺足够用,重复造轮子意义不大
- 不做复杂的自动化规则引擎——第一版只做手动控制+定时场景,自动化后面再加
- 不做家庭权限管理——第一版单用户用,家庭成员权限后面再扩展
划清楚边界很重要,否则项目很容易越做越大、永远做不完。先做最小可用版本,再慢慢迭代。
二、整体架构设计:为什么这样分层?
2.1 三层架构
我把整个系统分成了三层,每层职责单一,后面替换其中任何一层都不影响其他:
┌─────────────────────────────────────────────────────┐
│ UI 展示层(多端) │
│ 平板横屏中控 / 手机竖屏控制 / 手表快捷卡片 / 车机入口 │
├─────────────────────────────────────────────────────┤
│ 业务逻辑层 │
│ 设备状态管理 / 场景编排 / 自动化引擎 / 权限控制 │
├─────────────────────────────────────────────────────┤
│ 设备接入层 │
│ 米家适配 / HomeKit适配 / MQTT直连 / 涂鸦云 / 虚拟设备 │
└─────────────────────────────────────────────────────┘
为什么这么分?
- UI层只负责"显示什么"和"用户点了什么",不关心设备是哪个品牌的
- 业务层是核心大脑,维护设备的统一抽象状态——灯就是灯,不管是米家还是Aqara的,在业务层都有同样的"开/关/亮度/色温"属性
- 接入层负责把不同品牌的API转换成统一的内部格式,后面加新品牌只需要加一个适配器,不用改UI和业务逻辑
这是最典型的"适配器模式",但是做智能家居这个场景特别重要——你永远不知道以后会买什么品牌的设备,如果一开始就把某家API写死在UI代码里,后面改起来就是灾难。
2.2 统一设备模型设计
所有设备,不管是什么品牌、什么类型,都抽象成同一个基础结构:
// 所有设备的公共基类
interface BaseDevice {
id: string; // 全局唯一ID,格式:品牌:类型:序列号,比如"mi:light:0x1234"
name: string; // 用户自定义名称,比如"客厅主灯"
room: string; // 所在房间:living/bedroom/study/kitchen/bathroom
type: DeviceType; // 设备类型枚举
online: boolean; // 是否在线,离线设备灰显
lastUpdate: number; // 最后状态更新时间戳
brand: string; // 品牌标识,用于UI上显示小logo
}
// 设备类型枚举——所有支持的品类
enum DeviceType {
LIGHT = 'light', // 灯
AC = 'ac', // 空调
CURTAIN = 'curtain', // 窗帘
DOOR_LOCK = 'door_lock', // 门锁
CAMERA = 'camera', // 摄像头
SENSOR = 'sensor', // 传感器(温湿度/人体/门窗)
SWITCH = 'switch', // 智能插座/开关
SPEAKER = 'speaker', // 音箱
}
然后每个具体类型在基类基础上扩展自己的属性:
// 灯的扩展属性
interface LightDevice extends BaseDevice {
type: DeviceType.LIGHT;
power: boolean; // 开关
brightness: number; // 亮度 0-100
colorTemp?: number; // 色温 2700-6500K,支持调光的灯才有
color?: string; // RGB颜色,彩灯才有
}
// 空调的扩展属性
interface AcDevice extends BaseDevice {
type: DeviceType.AC;
power: boolean;
temperature: number; // 当前设定温度 16-30
mode: 'cool' | 'heat' | 'auto' | 'dry' | 'fan'; // 制冷/制热/自动/除湿/送风
fanSpeed: 'low' | 'mid' | 'high' | 'auto'; // 风速
currentTemp?: number; // 室温,有传感器的空调才支持
}
// 窗帘的扩展属性
interface CurtainDevice extends BaseDevice {
type: DeviceType.CURTAIN;
position: number; // 开合度 0=全关 100=全开
moving: boolean; // 是否正在运行中,运行中显示进度条
}
为什么要做这层抽象? 因为UI层面对"灯"的操作是固定的:一个开关、一个亮度滑条、一个色温调节。如果用户换了个品牌的灯,只要新适配器输出同样的LightDevice结构,UI一行代码都不用改。
如果不做这层抽象,直接在UI里调米家API,那后面加Aqara灯的时候就要把所有相关组件再写一遍,这是新手最容易犯的错误。
2.3 多HAP工程结构
按三层架构拆分模块,主HAP放UI,各设备适配层做成独立HAR包,业务核心逻辑也做成独立HAR:
SmartHomePanel/
├── entry/ # 主入口HAP
│ └── src/main/ets/
│ ├── entryability/
│ ├── pages/ # 所有页面
│ │ ├── MainPanel.ets # 平板横屏主面板(中控核心页面)
│ │ ├── PhoneHome.ets # 手机端首页
│ │ ├── DeviceDetail.ets # 设备详情调节页
│ │ ├── SceneEdit.ets # 场景编辑页
│ │ └── CameraView.ets # 摄像头预览页
│ └── components/ # 通用组件
│ ├── DeviceCard.ets # 设备卡片组件
│ ├── RoomTab.ets # 房间切换Tab
│ ├── SceneButton.ets # 场景大按钮
│ ├── LightSlider.ets # 灯亮度色温滑条
│ └── ClimateControl.ets # 空调地暖控制盘
├── smart-core/ # 业务核心HAR
│ └── src/main/ets/
│ ├── model/ # 统一设备模型定义
│ ├── manager/
│ │ ├── DeviceManager.ets # 设备状态中心
│ │ ├── SceneManager.ets # 场景管理器
│ │ └── RoomManager.ets # 房间分组管理
│ └── store/
│ └── AppState.ets # AppStorage全局状态
└── smart-adapters/ # 设备接入层HAR
└── src/main/ets/
├── BaseAdapter.ets # 适配器基类
├── MiHomeAdapter.ets # 米家适配
├── AqaraAdapter.ets // Aqara适配
├── MqttAdapter.ets // 通用MQTT设备直连
└── VirtualAdapter.ets // 虚拟设备,用于没有硬件时调试
这样拆分的好处是:开发调试的时候用VirtualAdapter生成一些假设备,UI和业务逻辑可以完全不依赖真实硬件,先跑通整个流程;等真实设备对接的时候只需要写适配器,前面的代码一行不改。
三、常驻中控面板:平板横屏布局的设计思考
挂在墙上的中控面板是这个项目的核心形态,也是用户用得最多的界面。这个页面我前后改了四版布局,这里把每版的思考过程写出来。
3.1 第一版:网格铺满——为什么不行?
最开始想的很简单:所有设备做成一个个方形卡片,按房间分Tab,网格铺满整个屏幕。类似手机上米家APP的首页,只是放大到平板尺寸。
做出来之后发现三个问题:
- 常用设备找起来慢:网格里混着灯、空调、窗帘、传感器,每次开客厅灯要扫一眼才能找到,不如物理开关快
- 信息密度不均匀:空调卡片需要显示温度、模式、风速,塞在小卡片里挤得慌;门窗传感器只需要显示"开/关"两个字,卡片空一大片
- 没有上下文感:用户站在面板前,他大概率是要控制"当前房间"的设备,而不是翻遍所有房间
3.2 第二版:分区域布局——方向对了,但还不够
第二版我把屏幕分成了左右两大块:
- 左侧1/4:房间列表,当前房间高亮
- 右侧3/4:当前房间的设备卡片,按类型分区(照明在上、环境在中、安防在下)
体验好了很多,但有个问题:有些操作是跨房间的。比如"观影模式"会关客厅灯、拉客厅窗帘、开客厅空调,但"睡眠模式"是关所有灯、检查所有门锁、开卧室夜灯——这种全局场景放在哪里?我一开始放在顶部工具栏,结果发现工具栏塞不下,而且太小,在墙上远远的点不到。
3.3 最终版:三区布局
第三版也就是现在用的版本,把屏幕分成了三个区域:
┌────────────────────────────────────────────────────────────┐
│ 🏠 回家 🎬 观影 🌙 睡眠 🚪 离家 💡 全关 [状态] │ ← 顶部场景栏(高度80vp)
├────────┬───────────────────────────────────────────────────┤
│ │ 💡 照明 │
│ 客厅 │ [主灯]● [射灯]○ [灯带]● [落地灯]○ [阳台灯]● │
│卧室 ├───────────────────────────────────────────────────┤
│书房 │ 🌡️ 环境 │
│厨房 │ 空调: 26°C 制冷 中速 新风: 2档 地暖: 关 │
│卫生间 ├───────────────────────────────────────────────────┤
│全部 │ 🪟 窗帘 │
│ │ 客厅窗帘: ████████░░ 80% 卧室窗帘: ░░░░░░░░ 0% │
│ ├───────────────────────────────────────────────────┤
│ │ 📹 安防 │
│ │ 门锁: 已锁 摄像头: 4路在线 门窗: 全部关闭 │
└────────┴───────────────────────────────────────────────────┘
1/5宽 4/5宽:设备按类型分组分区
为什么这么布局? 几个关键思考点:
-
场景栏放在最顶部,做成大按钮:场景操作是最高频的,"回家/离家/睡眠/观影"这几个按钮用户每天要点好几次,要大、要醒目、要点起来不费劲。每个按钮至少80vp高、120vp宽,手指按上去不会误触旁边的。
-
左侧房间栏常驻,但做窄:1/5宽度刚好够显示房间名+图标,当前房间用高亮背景+左侧色条标记。切换房间点一下就好,不需要额外操作。
-
右侧设备不做等大卡片,而是按类型分区流水布局:照明区每个灯就是一个横向的芯片式按钮(开着的高亮、关着的灰显),点一下开关、长按进详情调亮度;环境区显示温度风速数值;窗帘区显示进度条;安防区显示状态文字——不同类型设备用最适合它的交互控件,而不是硬塞进统一的方卡片里。
-
最常用的灯放在最上面:用户对灯的操作频率远高于其他设备,一进页面第一眼就能看到、手伸上去就能点到。
3.4 布局代码实现
用GridRow栅格系统做自适应,平板横屏是三栏结构,手机竖屏自动切换为单栏结构:
@Entry
@Component
struct MainPanel {
@StorageLink('currentRoom') currentRoom: string = 'living';
@StorageLink('devices') devices: Device[] = [];
@StorageLink('scenes') scenes: Scene[] = [];
// 根据当前房间过滤设备,按类型分组
private getRoomDevices() {
const roomDevices = this.devices.filter(d => d.room === this.currentRoom);
return {
lights: roomDevices.filter(d => d.type === DeviceType.LIGHT),
climates: roomDevices.filter(d => [DeviceType.AC, DeviceType.FAN, DeviceType.HEATER].includes(d.type)),
curtains: roomDevices.filter(d => d.type === DeviceType.CURTAIN),
security: roomDevices.filter(d => [DeviceType.DOOR_LOCK, DeviceType.CAMERA, DeviceType.SENSOR].includes(d.type)),
};
}
build() {
Column() {
// 1. 顶部场景栏
this.SceneBar()
// 2. 主体区域:左右分栏
GridRow({ columns: { sm: 4, md: 12, lg: 12 }, gutter: 12 }) {
GridCol({ span: { sm: 4, md: 12, lg: 2 } }) {
this.RoomSidebar() // 左侧房间栏:大屏占2列,小屏占满整行
}
GridCol({ span: { sm: 4, md: 12, lg: 10 } }) {
this.DeviceArea() // 右侧设备区:大屏占10列
}
}
.layoutWeight(1)
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
这里的断点设计:
sm(手机竖屏<600vp):房间栏放在设备区上面,占满整行,变成上下结构md(手机横屏/小平板<840vp):和sm类似,但间距更大lg(平板/大屏>=840vp):左右分栏,房间在左、设备在右
这样一套代码,手机和平板都能用,不需要写两个页面。
3.5 暗黑色模式默认开启
中控面板大概率是挂在客厅墙上的,晚上亮着个白屏幕会很刺眼,所以默认用暗黑模式:
// EntryAbility的onWindowStageCreate里设置
windowStage.loadContent('pages/MainPanel', (err) => {
const windowClass = windowStage.getMainWindowSync();
// 中控屏默认暗黑模式
windowClass.setWindowSystemBarProperties({
statusBarColor: '#121212',
navigationBarColor: '#121212',
isStatusBarLightIcon: true,
isNavigationBarLightIcon: true
});
});
暗黑模式下的颜色也要仔细调:
- 背景用#121212,不要纯黑——纯黑在OLED屏幕上虽然省电,但视觉上会太死,卡片浮起来的层次感出不来
- 卡片用#1E1E1E,比背景亮一个层级
- 开启状态的设备用品牌色高亮(比如灯开着显示暖黄色#FFB74D),关闭状态用#424242灰色
- 文字主色白色#FFFFFF,次要文字#B0B0B0,禁用文字#616161
四、设备状态管理:如何做到"多端状态实时同步"?
这是整个项目的技术核心。你在面板上点开灯,手机上要同步显示"开了";你在手机上把灯关了,面板上也要立刻变成关的状态;甚至你用物理开关按了灯,面板也要能感知到。
4.1 单例设备管理器
整个应用全局只应该有一个设备状态的"真相来源"(Single Source of Truth),所有页面、所有组件都从这同一个地方读状态,控制命令也都发到这同一个地方。绝不能每个组件自己维护一份设备状态,否则一定会出现"这里显示开、那里显示关"的不一致。
// 用单例模式实现全局设备管理器
class DeviceManager {
private static instance: DeviceManager;
private _devices: Map<string, Device> = new Map(); // id -> 设备对象
private _listeners: Set<(devices: Device[]) => void> = new Set();
private adapters: BaseAdapter[] = []; // 所有已注册的适配器
private constructor() {
// 私有构造函数,防止外部new
}
public static getInstance(): DeviceManager {
if (!DeviceManager.instance) {
DeviceManager.instance = new DeviceManager();
}
return DeviceManager.instance;
}
// 注册适配器,启动时调用
public registerAdapter(adapter: BaseAdapter) {
this.adapters.push(adapter);
// 监听适配器的状态变更事件
adapter.onDeviceStateChange((deviceId, state) => {
this.updateDeviceState(deviceId, state);
});
}
// 初始化:连接所有平台,拉取初始设备列表
public async init() {
for (const adapter of this.adapters) {
try {
await adapter.connect();
const devices = await adapter.discoverDevices();
devices.forEach(d => this._devices.set(d.id, d));
} catch (e) {
console.error(`适配器${adapter.name}连接失败`, e);
// 一个适配器失败不影响其他的,继续初始化
}
}
this.notifyListeners();
}
// 控制设备:UI层调用这个方法
public async controlDevice(deviceId: string, command: Partial<DeviceCommand>) {
const device = this._devices.get(deviceId);
if (!device || !device.online) return;
// 1. 先乐观更新UI——立刻在本地改状态,让用户感觉不到延迟
const oldState = { ...device };
this.updateDeviceState(deviceId, command);
try {
// 2. 找对应适配器发送真实命令
const brand = device.id.split(':')[0];
const adapter = this.adapters.find(a => a.brand === brand);
await adapter.sendCommand(deviceId, command);
// 3. 命令成功:以设备真实回报状态为准(有些设备执行完状态会有偏差)
const realState = await adapter.getState(deviceId);
this.updateDeviceState(deviceId, realState);
} catch (e) {
// 4. 命令失败:回滚到之前的状态,给用户提示
this.updateDeviceState(deviceId, oldState);
// 这里应该弹出一个Toast提示"控制失败,请检查设备"
console.error(`控制设备${deviceId}失败`, e);
}
}
// 更新设备状态,并通知所有监听者
private updateDeviceState(deviceId: string, partial: Partial<Device>) {
const old = this._devices.get(deviceId);
if (!old) return;
this._devices.set(deviceId, { ...old, ...partial, lastUpdate: Date.now() });
this.notifyListeners();
}
// 订阅状态变化,组件挂载时注册、卸载时取消
public subscribe(listener: (devices: Device[]) => void) {
this._listeners.add(listener);
return () => this._listeners.delete(listener); // 返回取消订阅函数
}
private notifyListeners() {
const all = Array.from(this._devices.values());
this._listeners.forEach(l => l(all));
}
}
这里有几个关键设计决策要讲清楚:
第一,为什么要用"乐观更新"?如果你等适配器把命令发给设备、设备回报执行成功、再更新UI,那整个过程可能要几百毫秒——用户点一下开关,过0.5秒灯的状态才变,会感觉"卡了、没反应"。乐观更新就是先假设命令会成功,立刻更新UI状态,后台异步发真实命令;如果失败了再回滚状态+提示。这个体验上的差别是巨大的,所有做智能家居控制的APP都会这么做,但很多新手开发者想不到。
第二,为什么命令失败要回滚?因为乐观更新本质是"赌命令会成功",但网络确实可能断、设备确实可能离线。如果失败了不回滚,UI上显示"灯开了",实际灯是关的,用户下次再来的时候看到的状态就是错的,会失去对产品的信任。
第三,为什么要用订阅模式而不是AppStorage直接存数组?AppStorage适合存简单状态,但设备列表是会频繁增量更新的。如果每次一个设备状态变了就整个数组替换,所有依赖@StorageLink('devices')的组件都会整棵重新渲染,设备多了性能会差。用订阅模式+组件内细粒度更新,性能好很多。
不过为了简化,我实际项目里还是两层结合:全局用AppStorage存设备列表保证跨页面同步,组件内部用@State订阅局部变更优化性能。
4.2 状态持久化:APP重启不丢状态
网络差的时候,APP启动可能拉不到最新设备状态,这时候应该显示上一次缓存的状态,而不是空白。用用户首选项持久化:
import preferences from '@ohos.data.preferences';
class StatePersistence {
private static pref: preferences.Preferences | null = null;
static async init(context: Context) {
this.pref = await preferences.getPreferences(context, 'device_state_cache');
}
// 每次状态变更后,1秒防抖写入缓存
static saveDevices(devices: Device[]) {
clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
this.pref?.put('devices', JSON.stringify(devices));
}, 1000);
}
// 启动时读取缓存
static loadDevices(): Device[] {
const cached = this.pref?.get('devices', '[]') as string;
return JSON.parse(cached);
}
}
注意加了1秒防抖——因为设备状态更新很频繁(比如滑条调亮度会连续触发更新),每次都写磁盘会伤闪存、也影响性能,攒1秒批量写一次就够了。
4.3 多端分布式状态同步
平板面板和手机是两个独立的设备,它们之间的状态同步用鸿蒙分布式数据服务:
import distributedKVStore from '@ohos.data.distributedKVStore';
class DistributedSync {
private kvManager: distributedKVStore.KVManager | null = null;
private kvStore: distributedKVStore.SingleKVStore | null = null;
async init(context: Context) {
const config: distributedKVStore.KVManagerConfig = {
context,
bundleName: 'com.smarthome.panel'
};
this.kvManager = distributedKVStore.createKVManager(config);
const options: distributedKVStore.Options = {
createIfMissing: true,
encrypt: false,
backup: false,
autoSync: true, // 自动同步到同账号其他设备
kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
securityLevel: distributedKVStore.SecurityLevel.S1
};
this.kvStore = await this.kvManager.getKVStore('device_state', options);
// 订阅远端数据变更——手机上改了设备状态,平板上自动更新
this.kvStore.on('dataChange', distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_ALL, (data) => {
data.insertEntries?.forEach(entry => {
try {
const device = JSON.parse(entry.value.toString());
DeviceManager.getInstance().updateLocalState(device);
} catch(e) {}
});
});
}
// 本地设备状态变化时,同步到其他端
async syncDeviceState(device: Device) {
await this.kvStore?.put(`device:${device.id}`, JSON.stringify(device));
}
}
这样你在手机上开了灯,平板面板上不用刷新,1秒内就会自动变成开的状态;反过来也一样。不需要自己搭服务器,鸿蒙分布式数据服务在同一个华为账号下的设备之间自动P2P同步。
五、设备控制交互:每个控件的设计都有讲究
设备控制的交互看起来简单,就是"开关、滑条、按钮",但每一个控件放到"墙上中控"这个场景里,都有特殊的设计考量。
5.1 灯光开关:为什么不用Toggle开关?
最开始我用的是标准的Toggle开关组件,点一下开、再点一下关。但实际装到墙上用的时候发现问题:
- Toggle开关很小,手指按上去经常点不准
- Toggle只有开关,看不到亮度信息,要调亮度必须点进详情页
- 一排Toggle看起来很像"设置页",不像"控制面板"
后来改成了大芯片按钮:每个灯是一个圆角矩形按钮,宽度根据屏幕自适应,里面左边是灯的图标和名称,右边是亮度小圆环,开着的按钮用亮色背景、关着的用深色背景,点一下整个按钮任意位置都能开关,长按0.5秒弹出亮度调节浮层。
@Component
export struct LightChip {
@ObjectLink device: LightDevice;
@State showBrightnessPopup: boolean = false;
private longPressTimer: number = -1;
build() {
Column() {
Row() {
Image(this.device.power ? $r('app.media.light_on') : $r('app.media.light_off'))
.width(24)
.height(24)
.fillColor(this.device.power ? '#FFB74D' : '#616161')
Text(this.device.name)
.fontSize(16)
.fontColor(this.device.power ? '#FFFFFF' : '#9E9E9E')
.margin({ left: 8 })
.layoutWeight(1)
// 亮度指示小圆环
if (this.device.power) {
Stack() {
Circle().width(32).height(32).fill('#333333')
Circle()
.width(32)
.height(32)
.fill('#FFB74D')
.opacity(this.device.brightness / 100)
Text(`${this.device.brightness}%`)
.fontSize(10)
.fontColor('#FFFFFF')
}
}
}
.padding(12)
.width('100%')
}
.backgroundColor(this.device.power ? '#2A2A2A' : '#1A1A1A')
.borderRadius(12)
.border({ width: this.device.power ? 1 : 0, color: '#FFB74D33' })
.onClick(() => {
// 点击:切换开关
DeviceManager.getInstance().controlDevice(this.device.id, {
power: !this.device.power
});
})
.onTouch((event: TouchEvent) => {
// 处理长按弹出亮度调节
if (event.type === TouchType.Down) {
this.longPressTimer = setTimeout(() => {
this.showBrightnessPopup = true;
}, 500);
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
clearTimeout(this.longPressTimer);
}
})
.bindSheet(this.showBrightnessPopup, this.BrightnessPopup(), {
height: 300,
dragBar: true,
onDisappear: () => this.showBrightnessPopup = false
})
}
@Builder
BrightnessPopup() {
Column({ space: 20 }) {
Text('调节亮度')
.fontSize(18)
.fontWeight(FontWeight.Medium)
Slider({
value: this.device.brightness,
min: 1,
max: 100,
step: 1,
style: SliderStyle.OutSet
})
.blockColor('#FFB74D')
.selectedColor('#FFB74D')
.width('100%')
.onChange((value: number) => {
// 滑条拖动时实时发送命令,但做节流处理
this.throttledControl({ brightness: Math.round(value) });
})
if (this.device.colorTemp) {
Text('调节色温')
.fontSize(18)
.margin({ top: 16 })
Slider({
value: this.device.colorTemp,
min: 2700,
max: 6500,
step: 100,
})
.onChange((value: number) => {
this.throttledControl({ colorTemp: Math.round(value) });
})
}
}
.padding(24)
}
// 节流:滑条拖动过程中100ms发一次命令,不要拖一点发一次
private throttledControl = throttle((cmd) => {
DeviceManager.getInstance().controlDevice(this.device.id, cmd);
}, 100);
}
这里有个重要细节:滑条控制一定要做节流。用户拖动亮度滑条的时候,onChange事件会以非常高的频率触发(可能几十毫秒一次),如果每次都给设备发命令,一者网络扛不住,二者设备接收太频繁会卡顿、甚至掉线。100ms发一次是比较合理的间隔,用户感觉不到延迟,设备也能跟得上。
5.2 空调控制:温度加减按钮要大
空调控制最核心的操作就是"加减温度",这个按钮一定要做的足够大:
@Component
export struct AcControl {
@ObjectLink device: AcDevice;
build() {
Row({ space: 24 }) {
// 温度减
Button('-')
.fontSize(32)
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor('#333333')
.onClick(() => {
if (this.device.temperature > 16) {
DeviceManager.getInstance().controlDevice(this.device.id, {
temperature: this.device.temperature - 1
});
}
})
// 当前温度大数字显示
Column() {
Text(`${this.device.temperature}`)
.fontSize(64)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('°C')
.fontSize(20)
.fontColor('#9E9E9E')
.margin({ top: -8 })
}
// 温度加
Button('+')
.fontSize(32)
.width(60)
.height(60)
.borderRadius(30)
.backgroundColor('#333333')
.onClick(() => {
if (this.device.temperature < 30) {
DeviceManager.getInstance().controlDevice(this.device.id, {
temperature: this.device.temperature + 1
});
}
})
}
// 模式切换横排按钮
Row({ space: 8 }) {
ForEach(['cool', 'heat', 'auto', 'dry', 'fan'], (mode) => {
Button(this.modeText(mode))
.fontSize(14)
.height(36)
.backgroundColor(this.device.mode === mode ? '#2196F3' : '#333333')
.onClick(() => {
DeviceManager.getInstance().controlDevice(this.device.id, { mode });
});
})
}
}
温度数字用64fp超大号字体,远远就能看到当前多少度;加减按钮60x60vp的大圆,点起来毫不费劲——这就是中控屏和手机APP的区别:手机是拿在手里凑到眼前看的,中控屏是挂在墙上,人可能站在一两米外看,字体和按钮都要大一号。
5.3 窗帘控制:进度条是最直观的
窗帘的开合度最适合用进度条:用户拖动到哪个位置,窗帘就开到哪个位置,比"点一下开/点一下关"的按钮体验好太多。
@Component
export struct CurtainSlider {
@ObjectLink device: CurtainDevice;
build() {
Row({ space: 16 }) {
Image($r('app.media.curtain'))
.width(24)
.height(24)
.fillColor(device.position > 0 ? '#4CAF50' : '#616161')
Text(device.name)
.fontSize(16)
.width(100)
Slider({
value: device.position,
min: 0,
max: 100,
step: 1
})
.layoutWeight(1)
.blockColor('#4CAF50')
.selectedColor('#4CAF50')
.onChange((value: number, mode: SliderChangeMode) => {
if (mode === SliderChangeMode.End) {
// 拖动结束再发命令,不要拖动过程中一直发——窗帘是机械结构,不能频繁换向
DeviceManager.getInstance().controlDevice(device.id, {
position: Math.round(value)
});
}
})
Text(`${device.position}%`)
.fontSize(14)
.width(50)
.textAlign(TextAlign.End)
}
.padding(12)
}
}
注意窗帘和灯光滑条的区别:
- 灯光滑条拖动过程中节流发命令,因为灯是电子设备,可以连续变化
- 窗帘滑条不要在拖动过程中发命令,只在拖动结束时发一次!因为窗帘是机械电机,如果拖到30%发一个命令,窗帘开始往30%走;拖到50%又发一个,电机突然换向,时间长了会烧坏电机。
这个细节很多人不知道,产品上线后坏了好几个窗帘电机才总结出来的。
六、一键场景:"回家模式"到底做了什么?
场景是智能家居最有魅力的部分——按一个按钮,多个设备同时动作,不用一个个去开。但场景的实现远不是"循环发几个命令"那么简单。
6.1 场景数据结构
interface Scene {
id: string;
name: string;
icon: ResourceStr;
color: string; // 按钮背景色
description: string;
actions: SceneAction[]; // 要执行的动作列表
confirmRequired?: boolean;// 是否需要二次确认(比如离家模式)
}
interface SceneAction {
deviceId: string;
command: Partial<DeviceCommand>;
delay?: number; // 执行延迟,毫秒,用于动作之间错开时间
}
每个场景就是一串有序的设备动作,每个动作可以指定延迟时间。
6.2 为什么动作之间要加延迟?
你可能会想:回家模式不就是"开灯+开空调+开窗帘",同时发三个命令不就行了?
实际不行,有两个原因:
- 很多智能家居网关有并发限制——比如米家网关一秒内最多接收3-5个命令,发多了会丢包、命令不执行
- 同时开多个大功率设备,电路有冲击——这个倒是小概率,但设计上留个心眼总是好的
所以动作之间要故意错开一点时间,间隔200-300毫秒一个,既不会让用户感觉到明显延迟,网关也能处理过来:
class SceneManager {
async executeScene(scene: Scene) {
console.log(`开始执行场景: ${scene.name}`);
// 按delay排序
const sorted = [...scene.actions].sort((a, b) => (a.delay || 0) - (b.delay || 0));
for (const action of sorted) {
// 等待到该动作的延迟时间
await new Promise(resolve => setTimeout(resolve, action.delay || 0));
// 发送命令,单个动作失败不影响其他动作继续执行
try {
await DeviceManager.getInstance().controlDevice(action.deviceId, action.command);
} catch (e) {
console.error(`场景动作失败: ${action.deviceId}`, e);
// 场景里一个设备失败不要中断整个场景,继续执行后面的
}
}
console.log(`场景执行完成: ${scene.name}`);
}
}
单个动作失败要不要中断整个场景?不要。 回家模式如果客厅灯坏了,空调该开还是要开,窗帘该拉还是要拉,不能因为一个设备出问题整个场景都停在那里。
6.3 几个核心场景的设计思路
回家模式(天暗时):
{
name: '回家',
icon: $r('app.media.scene_home'),
color: '#FFB74D',
actions: [
{ deviceId: 'mi:light:living_main', command: { power: true, brightness: 80 }, delay: 0 },
{ deviceId: 'aqara:curtain:living', command: { position: 100 }, delay: 200 }, // 拉开窗帘
{ deviceId: 'mi:ac:living', command: { power: true, temperature: 26, mode: 'auto' }, delay: 500 },
{ deviceId: 'mi:light:entrance', command: { power: true, brightness: 100 }, delay: 0 }, // 玄关灯立刻开
]
}
注意玄关灯delay是0,其他灯和空调有延迟——用户开门进来最先看到的就是玄关,玄关灯必须立刻亮;客厅灯、空调晚几百毫秒完全感知不到。
观影模式:
{
name: '观影',
icon: $r('app.media.scene_movie'),
color: '#7B1FA2',
actions: [
{ deviceId: 'mi:light:living_main', command: { power: false }, delay: 0 }, // 主灯关
{ deviceId: 'mi:light:living_strip', command: { power: true, brightness: 20 }, delay: 100 }, // 留氛围灯带
{ deviceId: 'aqara:curtain:living', command: { position: 0 }, delay: 300 }, // 关窗帘
{ deviceId: 'mi:ac:living', command: { fanSpeed: 'low' }, delay: 500 }, // 空调调静音
]
}
观影模式不是把所有灯都关掉,而是留一条低亮度的氛围灯带——全黑环境看投影/电视眼睛会累,20%亮度的背光刚好看得清路,又不影响画面。
睡眠模式:
{
name: '睡眠',
icon: $r('app.media.scene_sleep'),
color: '#3F51B5',
actions: [
{ deviceId: 'mi:light:all', command: { power: false }, delay: 0 }, // 所有灯关
{ deviceId: 'aqara:curtain:all', command: { position: 0 }, delay: 300 }, // 所有窗帘关
{ deviceId: 'mi:ac:bedroom', command: { power: true, temperature: 27, mode: 'cool', fanSpeed: 'low' }, delay: 500 },
{ deviceId: 'aqara:lock:door', command: { lock: true }, delay: 1000 }, // 检查门锁
]
}
睡眠模式有个特殊处理:如果发现门锁已经锁了,就不要重复发锁门命令;如果没锁,就自动锁上——给用户"我帮你检查好了"的安全感。
离家模式:为什么需要二次确认?因为离家模式会关所有灯、关空调、关窗帘、锁门——这些操作都是"不可轻易撤销"的,如果不小心误触了,人已经出门了发现空调被关了(夏天回家会很热),或者门锁了发现钥匙没带,体验会很差。所以离家模式点下去弹一个确认框"确定要离家吗?将关闭所有设备并锁门",用户点确认才执行。
6.4 场景执行反馈
场景执行的时候,一定要给用户明确的反馈,不要点了按钮安安静静什么反应都没有——用户会怀疑"到底有没有执行"。
我做的反馈是:点下场景按钮后,按钮先变成一个加载进度圈(进度按动作完成比例走),执行完成后按钮变成对号显示1秒,然后恢复原样。每个设备执行成功/失败在设备卡片上有个短暂的闪烁动效,让用户知道哪些设备动了。
七、常驻熄屏与人体感应:24小时在线的关键
中控面板是要24小时插电挂墙上的,一直亮屏既烧屏又费电,但熄屏了又要按电源键才能用,就失去了"中控"的意义。这个矛盾怎么解决?
7.1 亮屏策略:三层判断
我的方案是:有人靠近自动亮屏,没人自动熄屏;显示常亮性的极简时钟界面,不一直亮主界面。
具体策略分三层:
- 默认状态:熄屏显示极简时钟(AOD,Always On Display),只显示时间、日期、温度,整体亮度很低
- 有人靠近:通过超声波距离传感器(或平板前置摄像头的人体检测)感知到人走近了,立刻亮屏到主面板界面
- 操作超时:亮屏后30秒没人操作,自动回到AOD时钟界面
鸿蒙系统本身支持AOD能力,但第三方应用没法直接控制系统AOD,所以我自己实现了"应用内AOD":
@Component
struct MainPanel {
@State screenMode: 'active' | 'aod' | 'sleep' = 'active';
private lastInteractionTime: number = Date.now();
private idleCheckTimer: number = -1;
private wakeLock: window.WakeLock | null = null;
aboutToAppear() {
// 申请常亮锁,防止系统自动锁屏
window.getLastWindow(getContext(this)).then(win => {
win.setWindowKeepScreenOn(true);
});
// 启动空闲检测定时器,每5秒检查一次
this.idleCheckTimer = setInterval(() => {
const idleTime = Date.now() - this.lastInteractionTime;
if (idleTime > 30000 && this.screenMode === 'active') {
this.screenMode = 'aod'; // 30秒无操作进AOD
this.setScreenBrightness(0.1); // 把屏幕亮度调到最低
}
}, 5000);
// 启动人体感应检测
this.startProximityWatch();
}
// 监听距离传感器/人体检测
private startProximityWatch() {
// 鸿蒙人体感知能力:检测到人脸靠近时唤醒
import { humanDetection } from '@kit.SensingKit';
// 简化示意:实际实现中通过传感器或前置摄像头检测
sensor.on(sensor.SensorId.PROXIMITY, (data) => {
if (data.distance < 50) { // 50cm内检测到物体,认为有人靠近
this.wakeUpScreen();
}
});
}
private wakeUpScreen() {
this.lastInteractionTime = Date.now();
if (this.screenMode !== 'active') {
this.screenMode = 'active';
this.setScreenBrightness(-1); // -1表示恢复自动亮度
}
}
// 用户触摸屏幕任意位置,重置计时
onTouch(() => {
this.lastInteractionTime = Date.now();
if (this.screenMode === 'aod') {
this.screenMode = 'active';
this.setScreenBrightness(-1);
}
})
7.2 防烧屏处理
OLED屏幕长时间显示同一个静态画面会烧屏(像素老化留下永久残影),AOD界面必须做防烧屏处理:
@Component
struct AodClock {
@State offsetX: number = 0;
@State offsetY: number = 0;
aboutToAppear() {
// 每分钟随机移动一次时钟位置,防止同一像素长时间点亮
setInterval(() => {
this.offsetX = Math.random() * 40 - 20; // ±20vp随机偏移
this.offsetY = Math.random() * 20 - 10;
}, 60000);
}
build() {
Column() {
Text(this.currentTime)
.fontSize(72)
.fontColor('#FFFFFF')
.opacity(0.6)
Text(`${this.date} ${this.currentTemp}°C`)
.fontSize(16)
.fontColor('#9E9E9E')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.translate({ x: this.offsetX, y: this.offsetY }) // 随机偏移
.backgroundColor('#000000')
}
}
每分钟随机移动几个像素,肉眼几乎察觉不到,但能有效防止烧屏——这也是手机系统AOD的标准做法。
7.3 夜间自动调光
晚上睡觉的时候,即使是最低亮度的AOD,在全黑卧室里还是会觉得亮。所以加一个定时:晚上11点到早上7点,AOD亮度进一步降低,甚至可以关闭AOD、真正熄屏:
private checkNightMode() {
const hour = new Date().getHours();
if (hour >= 23 || hour < 7) {
// 夜间模式
if (this.screenMode === 'aod') {
this.setScreenBrightness(0.03); // 极低亮度,夜里不刺眼
}
}
}
八、摄像头预览:不要自己推流,用系统组件
安防摄像头的实时预览是个硬需求——门口有人按门铃,在面板上就能看到门口画面。一开始我想自己用RTSP拉流解码,后来发现鸿蒙已经有现成的分布式相机能力,根本不用自己做。
8.1 分布式摄像头调用
鸿蒙支持把其他设备的摄像头虚拟成本地摄像头,直接用XComponent渲染即可:
import camera from '@ohos.multimedia.camera';
@Component
export struct CameraPreview {
@Prop cameraId: string;
private cameraInput?: camera.CameraInput;
private previewOutput?: camera.PreviewOutput;
private cameraSession?: camera.CameraSession;
@State isOnline: boolean = false;
@State isLoading: boolean = true;
aboutToAppear() {
this.initCamera();
}
async initCamera() {
try {
const cameraManager = camera.getCameraManager(getContext(this));
// 枚举所有摄像头,包括分布式摄像头
const cameras = cameraManager.getSupportedCameras();
const targetCamera = cameras.find(c => c.connectionType === camera.ConnectionType.CAMERA_CONNECTION_TYPE_DISTRIBUTED);
if (!targetCamera) {
this.isOnline = false;
return;
}
this.isOnline = true;
this.cameraInput = cameraManager.createCameraInput(targetCamera);
await this.cameraInput.open();
const profile = targetCamera.cameraProfiles.find(p => p.size.width >= 1280);
const xComponentCtx = this.xComponentRef.getXComponentContext();
this.previewOutput = cameraManager.createPreviewOutput(profile, xComponentCtx.getSurfaceId());
this.cameraSession = cameraManager.createCameraSession();
await this.cameraSession.beginConfig();
this.cameraSession.addInput(this.cameraInput);
this.cameraSession.addOutput(this.previewOutput);
await this.cameraSession.commitConfig();
await this.cameraSession.start();
this.isLoading = false;
} catch (e) {
console.error('摄像头初始化失败', e);
this.isOnline = false;
}
}
build() {
Stack() {
XComponent({
id: 'camera_preview',
type: 'surface',
controller: this.xComponentController
})
.width('100%')
.height('100%')
if (this.isLoading) {
Progress({ type: ProgressType.Circular }).width(40)
}
if (!this.isOnline) {
Column() {
Image($r('app.media.camera_offline')).width(48).height(48)
Text('摄像头离线').fontColor('#9E9E9E').margin({ top: 8 })
}
}
}
.width('100%')
.aspectRatio(16 / 9)
.borderRadius(12)
.backgroundColor('#000000')
.clip(true)
}
}
如果摄像头不是鸿蒙智联的、是普通的RTSP网络摄像头,那就要用FFmpeg自己拉流解码了,这个比较复杂,建议第一版先对接鸿蒙智联摄像头,后续再扩展ONVIF/RTSP协议支持。
九、功耗优化:插电也不能浪费电
虽然中控面板是一直插电的,但功耗优化还是要做——不只是省电费,更重要的是平板长期插电高负载运行会发热、加速电池老化、缩短设备寿命。
9.1 功耗拆解
我用DevEco Profiler实测了一下,各个模块的耗电占比:
| 模块 | 功耗占比 | 说明 |
|---|---|---|
| 屏幕常亮 | ~55% | 最大头,所以AOD和亮度调节很重要 |
| Wifi网络心跳 | ~15% | 维持和设备的长连接 |
| 传感器轮询 | ~12% | 人体感应、温湿度更新 |
| UI渲染 | ~10% | 页面刷新、动画 |
| CPU空闲 | ~8% | 后台休眠 |
有针对性地优化:
屏幕层面:
- 30秒无操作自动进AOD,亮度降到10%
- 夜间(23:00-7:00)AOD亮度进一步降到3%
- AOD界面每分钟随机偏移防烧屏
- 主界面用深色背景,OLED屏黑色像素不发光本身就省电
网络层面:
- 设备状态轮询不要用高频轮询,用MQTT长连接+事件推送
- 没有事件推送能力的第三方平台(比如米家云API),轮询间隔设为30秒,不要1秒轮一次
- 后台时停止非必要的轮询,只保留门锁、传感器这类事件型推送
CPU层面:
- AOD模式下,主界面的渲染完全暂停,不要在后台跑动画
- 设备列表用LazyForEach懒加载,不渲染屏幕外的设备卡片
- 状态更新防抖,100ms内多次更新合并成一次UI刷新
9.2 长期插电的电池保护
平板长期插着电用,电池一直保持100%满电状态,会加速电池老化。鸿蒙系统有电池保护模式,但自己代码里也能做一些事情:
import batteryInfo from '@ohos.batteryInfo';
// 监听电池状态,如果一直在充电且电量高于90%,建议提醒用户开启电池保护
batteryInfo.on('batteryChange', (info) => {
if (info.charging && info.level >= 90) {
// 记录连续满电时间,如果超过24小时提示用户
this.continuousFullChargeTime += 1;
if (this.continuousFullChargeTime > 24 * 60) {
// 弹出提示:"建议在系统设置中开启智能充电模式,延长电池寿命"
}
} else {
this.continuousFullChargeTime = 0;
}
});
更极致的做法是:如果设备一直插电,可以root或者通过系统API把充电上限限制在80%,但这需要系统权限,普通应用做不到,只能提醒用户手动开智能充电。
十、适配手机、手表、车机:不只是平板能用
中控面板是核心形态,但其他设备也要能用,只是场景不同:
- 手机:用户在外面/沙发上不想站起来去面板跟前,用手机控制
- 手表:躺床上不想拿手机,抬腕就能关灯
- 车机:快到家的时候,在车上提前开空调
10.1 手机端适配
手机竖屏空间小,布局完全不同:
- 顶部不做横排场景大按钮,改成大卡片轮播(Swiper),每个场景一屏宽
- 不做左右分栏,房间切换用顶部横向Tab
- 设备卡片更大、更疏,适合手指点
- 每个设备单独一行,点击弹底部弹窗控制,不要像平板那样挤在一个页面
断点适配在前面布局代码里已经写了,sm断点下自动切换为手机布局。
10.2 手表端:极简卡片
手表屏幕极小,不能做复杂控制,只放最高频的三个功能:
- 三个最常用场景:回家、离家、睡眠——三个大按钮占满屏幕
- 当前房间的灯快速开关
- 门锁状态查看+开锁
不要尝试在手表上调温度、调窗帘,体验会很差,这些操作留给手机和平板。
10.3 车机端:只留一个"回家"按钮
用户在车机上对智能家居的唯一高频需求就是"快到家了提前开空调",所以车机端不需要做完整控制界面,只做一个万能卡片:显示"离家X公里,预计Y分钟到家",一个大按钮"提前开启回家模式"就够了。
鸿蒙万能卡片(FormAbility)天生适合这种场景:
// form_card.json 卡片配置
{
"name": "GoingHomeCard",
"description": "回家预热卡片",
"src": "./pages/GoingHomeCard",
"window": { "designWidth": 336, "autoDesignWidth": false },
"supportDimensions": ["2*2"],
"defaultDimension": "2*2"
}
卡片上实时显示离家距离和预计到家时间,点一下就触发回家模式——用户在车机主屏上点一下就行,不用进APP。
十一、上架审核:这些坑提前避
做这个应用的时候我前后被拒了三次,这些经验写出来让大家少走弯路:
11.1 权限问题是重灾区
这个应用需要的权限比较多,每一个都要写清楚用途,否则直接拒:
| 权限 | 用途说明怎么写才能过 |
|---|---|
| ohos.permission.INTERNET | 用于连接智能家居云平台,控制智能设备 |
| ohos.permission.GET_WIFI_INFO | 用于发现局域网内的智能设备(本地直连) |
| ohos.permission.KEEP_BACKGROUND_RUNNING | 用于保持设备状态实时同步,响应手动控制 |
| ohos.permission.CAMERA | 可选:用于人体感应亮屏,用户可在设置中关闭 |
| ohos.permission.DISTRIBUTED_DATASYNC | 用于多设备间设备状态同步 |
注意:所有涉及用户隐私的权限,一定要在第一次使用前弹框说明为什么要这个权限,用户同意了再申请,不能一启动就把所有权限都申请了。尤其是相机权限,一定要明确告诉用户"仅用于靠近自动亮屏,不会上传任何画面",否则用户会担心被偷拍。
11.2 后台运行审核
中控类应用需要长驻后台,这在审核里是重点关注项,需要在申请后台权限的时候说明:
- 为什么需要后台运行(实时接收设备状态变化、门锁异常报警推送)
- 后台做了什么功耗优化(AOD策略、网络长连接优化、CPU休眠)
- 给用户一个"退出后台运行"的开关,用户随时可以停掉
鸿蒙对后台应用的功耗要求越来越严,不要在后台做任何非必要的事情,否则很容易被系统杀掉,审核也过不了。
11.3 隐私合规要点
智能家居应用能拿到用户很多隐私数据:什么时候在家、什么时候出门、家里什么时候有人,这些数据非常敏感。审核时会查:
- 有没有隐私政策弹窗
- 用户数据(设备列表、操作记录)是不是只在本地存储,有没有上传到你自己的服务器
- 如果上传了,有没有明确告知用户、有没有加密传输
- 用户能不能一键删除所有数据
我的做法是:所有设备状态和操作记录默认只存在本地和鸿蒙分布式数据服务里,不经过第三方服务器,隐私审查一次就过了。如果你需要做云端服务(比如远程控制不在家的时候),一定要在隐私政策里写清楚哪些数据会上传、存在哪里、保存多久。
十二、踩坑总结:那些让我调试到半夜的问题
最后把整个开发过程中踩过的坑整理出来,希望你不用再踩一遍:
12.1 最容易踩的十个坑
| 问题描述 | 原因 | 解决方案 |
|---|---|---|
| 关灯后UI显示关了,过几秒又变成开的 | 适配器拉状态频率太高,乐观更新后被旧状态覆盖了 | 命令发送后3秒内忽略该设备的状态轮询结果,以命令返回为准 |
| 窗帘电机经常中途换向、咔哒响 | 滑条拖动过程中频繁发命令,电机频繁换向 | 滑条只在End事件发命令,拖动过程中不发 |
| 面板过夜后变卡、内存越来越大 | WebSocket连接断了后重连没释放旧连接,内存泄漏 | 重连前一定要关闭旧socket,定期重启网络模块 |
| AOD模式下半夜屏幕突然变亮 | 推送通知亮屏后没回到AOD亮度 | 监听系统屏幕亮屏事件,亮屏后5秒无操作自动回到AOD |
| 空调设置26度,过一会跳回25度 | 空调自身有温度校准(比如设定26实际到25就停),回报状态是25 | 命令发送后短时间内忽略温度字段的回滚,只接受用户主动设置的值 |
| 平板上布局正常,手机上元素溢出 | 平板用了固定vp宽度,小屏幕装不下 | 所有布局用百分比/GridCol/自适应,不要写死固定宽度 |
| 分布式同步经常失效,两台设备状态不一致 | KV存储同步需要同账号+同局域网+蓝牙开启,少一个条件就不行 | 做状态冲突解决机制:以时间戳最新的为准,用户手动操作优先级高于自动同步 |
| 场景执行到一半卡住 | 某个设备离线,await等待超时 | 每个设备控制加3秒超时,超时跳过继续下一个,不要无限等待 |
| 人体感应晚上不好用,白天乱亮屏 | 前置摄像头人脸检测夜间光线差、距离传感器被灰尘挡住 | 加环境光传感器判断:白天光线好才用人脸检测,晚上用距离传感器 |
| 平板插电半年后电池鼓包 | 长期100%满电高温加速老化 | 提醒用户开启智能充电模式,夏天注意设备散热,不要贴在不通风的墙面上 |
12.2 后续可以扩展的方向
这个项目第一版做到这里已经能用了,但还有很多可以迭代的空间:
- 自动化引擎:根据温度、人体感应、时间、地理位置自动触发场景(比如温度高于28度自动开空调、检测到人来自动亮玄关灯)
- 语音控制:接入鸿蒙小艺的自定义语音指令,说"我回来了"自动触发回家模式
- 能耗统计:统计每个设备每天/每月用电情况,给出节能建议
- 更多品牌适配器:华为智选、涂鸦、Home Assistant接入,支持更多设备
- 门锁摄像头联动:门锁被打开时自动抓拍照片、推送通知到手机
- 音乐控制:接入智能音箱,中控面板直接选歌、调音量
写在最后
做这个项目最大的感受是:鸿蒙的分布式能力不是炫技,它真的能解决实际生活里的小痛点。你不需要买新硬件,家里闲置的旧平板就能发挥余热;不需要被单一品牌生态绑定,通过分层架构自己做聚合;不需要等厂商出完美的产品,自己动手就能做一个最符合自己使用习惯的控制中心。
技术本身从来不酷,用技术把生活变方便一点,那才是酷的事情。
希望这篇实战记录能帮到你。如果你也做了自己的中控面板,欢迎分享你的经验——智能家居的乐趣,本来就在于折腾。
更多推荐


所有评论(0)