ArkTS + HarmonyOS 光境控制台:多灯分组指令与部分失败反馈(第四篇)【共创稿事节】
客厅主灯亮了,书桌台灯却没有反应。控制界面上到底应该显示“已完成”,还是“下发失败”?如果只在页面顶部放一个全局成功标记,两种回答都不准确:第一种掩盖了失败设备,第二种又否认已经成功的主灯。这就是 LumaRoom 进入多灯控制时遇到的第一道工程边界。多设备交互与单台设备不是“把请求循环三遍”那么简单,真正需要建模的是每个目标独立的序号、等待状态和最终结果。
这个连载先用 Slider 实现亮度与暖冷预览,再把专注、观影、夜灯预设整理成固定参数表。上一阶段建立了 desired 与 confirmed 的区分,并用指令序号丢弃过时回执。第四篇在相同架构上增加一个清楚可见的“灯组”层:主灯、台灯、灯带可分别选中,点击“应用到已选设备”后,每盏灯独立等待本地传输适配器回执。本文仍是 HarmonyOS 应用侧的交互和构造协议示例,不涉及厂商真实物联网 SDK;任何设备权限、局域网发现和云账号步骤都留给拥有合法接入文档的实际项目处理。

一、评审结论:群组是选择集合,不是第四台设备
假设页面内有三条灯具记录:main 表示客厅主灯,desk 表示书桌台灯,strip 表示背景灯带。用户可以只选择前两盏,灯带没有被选中时就不应收到指令。这看起来是一个布尔值判断,但容易被简化成“群组开关”后丢掉重要信息。尤其在网络慢、设备离线、某些设备仅支持亮度而不支持色温时,不同灯具的处理结果不可能由一个全局 isSynced 准确表示。
本例延续上一阶段三个预设的精确数值:专注78%/4800K,观影28%/3200K,夜灯12%/2700K。页面初始化在专注状态,亮度是78%,色温4800K,主灯与台灯默认勾选,灯带未勾选。用户点击应用按钮之后,示例传输适配器接受 main 的请求,拒绝 desk 的请求,strip 根本不发起请求。因此三种最终状态分别是“已确认 78%·4800K”“未接纳”和“未参与”。这个拒绝条件是为了示例反馈分支而人工设定,不能被解释成某型号台灯真的不支持这个色温。
在领域模型中应明确分开意图快照与设备结果。意图是一次按钮点击时冻结下来的 LightValues:isOn、brightness、kelvin。设备结果则由三项信息识别:deviceId 决定属于哪盏灯,sequence 识别同一灯具上的命令代次,accepted 表明该次预设请求是否被接纳。由于结果可能先后到达,设备卡片上的“已确认”只能由属于本设备最新代次的响应写入。
interface LightPreset {
id: string;
label: string;
brightness: number;
kelvin: number;
description: string;
}
const LIGHT_PRESETS: LightPreset[] = [
{ id: 'focus', label: '专注', brightness: 78, kelvin: 4800, description: '清爽明亮' },
{ id: 'cinema', label: '观影', brightness: 28, kelvin: 3200, description: '低亮暖光' },
{ id: 'night', label: '夜灯', brightness: 12, kelvin: 2700, description: '柔和入睡' }
];
interface LightValues { isOn: boolean; brightness: number; kelvin: number }
interface LightingCommand { deviceId: string; sequence: number; target: LightValues }
interface LightingAck {
deviceId: string;
sequence: number;
accepted: boolean;
applied: LightValues;
}
interface DeviceRow {
id: string;
label: string;
selected: boolean;
latestIssued: number;
deliveryStatus: string;
confirmedText: string;
}
主界面用 @State devices 保存卡片集合,并采用全数组更新而不是修改 row.deliveryStatus 后期待界面自动响应。latestIssued 是每个设备自己的数字,不是所有设备共用一个 latestIssued。第一条发给主灯的序号1和第一条发给台灯的序号1可以同时存在,原因是它们的身份域不同;判断一份回执时必须把设备 ID 与序号组合起来。倘若未来真正接入传输服务,还要为每个设备增加能力描述、在线状态、超时与重试预算,不能拿当前简单模型直接充当真实设备驱动层。
二、先隔离传输,避免让页面承诺外部连接
为了能够在没有实体灯具的环境里复现成功与失败,本篇延续第三篇的 LightingTransport 抽象,但把 deviceId 加入入参与回执。测试适配器对主灯和灯带返回成功,对台灯返回拒绝,所有结果来自 Promise.resolve,没有实际网络、蓝牙或分布式设备发现。这个边界的好处是:ArkUI 页面只关心收到何种结果,不需要知道未来传输层是设备 SDK、局域网协议还是测试替身。
interface LightingTransport {
send(command: LightingCommand): Promise<LightingAck>;
}
class LocalDemoTransport implements LightingTransport {
send(command: LightingCommand): Promise<LightingAck> {
return Promise.resolve({
deviceId: command.deviceId,
sequence: command.sequence,
accepted: command.deviceId !== 'desk',
applied: { ...command.target }
});
}
}
function initialDevices(): DeviceRow[] {
return [
{ id: 'main', label: '客厅主灯', selected: true, latestIssued: 0,
deliveryStatus: '未下发', confirmedText: '—' },
{ id: 'desk', label: '书桌台灯', selected: true, latestIssued: 0,
deliveryStatus: '未下发', confirmedText: '—' },
{ id: 'strip', label: '背景灯带', selected: false, latestIssued: 0,
deliveryStatus: '未参与', confirmedText: '—' }
];
}
为了让实际操作与文案一致,applyPreset() 只更新页面目标状态,不会立即广播到所有灯具。用户选中哪些设备,再点击“应用到已选设备”,才生成命令。这个修改与上一阶段“选择预设后立即调用 issueCurrent”存在明确的产品差异:在单灯页面,即时发送是可接受的示例;在多设备页面,选择预设与发送要有可辨认的确认边界。否则用户只是在两个场景之间比较颜色,也可能造成不必要的设备命令风暴。
三、逐台发送:序号属于设备,回执属于当前意图
dispatchOne 先读取对应设备当前行,为它生成新的序号,再将该行状态换为“等待回执”。因为每个 send 都是异步 Promise,不能把整个设备数组在循环外捕获为旧引用后连续覆盖;每次更新都从此刻的 this.devices 生成新数组。回执到来时再读取当前行,确认 deviceId 和 latestIssued 都匹配后才写入结果,这就是上一阶段“防倒灌”策略在设备维度上的落地。
@Entry
@Component
struct LightPage {
@State private brightness: number = 78;
@State private kelvin: number = 4800;
@State private isOn: boolean = true;
@State private activePresetId: string = 'focus';
@State private devices: DeviceRow[] = initialDevices();
private transport: LightingTransport = new LocalDemoTransport();
private updateDevice(next: DeviceRow): void {
this.devices = this.devices.map((row: DeviceRow): DeviceRow =>
row.id === next.id ? next : row);
}
private async dispatchOne(id: string, target: LightValues): Promise<void> {
const previous = this.devices.find((row: DeviceRow): boolean => row.id === id);
if (previous === undefined) { return; }
const seq: number = previous.latestIssued + 1;
this.updateDevice({ ...previous, latestIssued: seq,
deliveryStatus: '等待回执', confirmedText: previous.confirmedText });
try {
const ack = await this.transport.send({ deviceId: id, sequence: seq,
target: { ...target } });
const current = this.devices.find((row: DeviceRow): boolean => row.id === id);
if (current === undefined || current.latestIssued !== ack.sequence ||
ack.deviceId !== id) { return; }
this.updateDevice({ ...current,
deliveryStatus: ack.accepted ? '已确认' : '未接纳',
confirmedText: ack.accepted
? `${ack.applied.brightness}% · ${ack.applied.kelvin}K`
: current.confirmedText });
} catch (error) {
const current = this.devices.find((row: DeviceRow): boolean => row.id === id);
if (current !== undefined && current.latestIssued === seq) {
this.updateDevice({ ...current, deliveryStatus: '发送失败' });
}
}
}
private dispatchSelected(): void {
const target: LightValues = {
isOn: this.isOn, brightness: this.brightness, kelvin: this.kelvin
};
const ids: string[] = this.devices
.filter((row: DeviceRow): boolean => row.selected)
.map((row: DeviceRow): string => row.id);
ids.forEach((id: string): void => { this.dispatchOne(id, target); });
}
}
这段实现让某盏灯失败时不会重置其他灯的有效确认信息。假如用户先向主灯发送观影28%/3200K,后又很快选择专注78%/4800K,前一次的应答如果延迟到后一次之后,即便它的 accepted 为 true,也不能重新把主灯卡片写成观影状态。对于台灯同理,主灯的有效回执不代表台灯的同步完成。需要区分的是:丢弃旧回执能保护页面展示,并不能保证真实设备没有执行旧命令;接入真正设备时还需要设备端顺序保证或主动查询当前值。

四、UI 不再只有一个成功开关
页面布局依然沿用“光境控制台”和“客厅光境预览”的主视觉,顶部显示当前目标亮度与色温;三张预设按钮帮助用户迅速形成目标参数。下方三个设备卡片各有选择按钮、状态文案和确认参数。这样用户能区分三种情况:没有选中,意味着没有发起本轮动作;正在等待,意味着应用已提交请求但尚未获得最新有效回执;被拒绝,意味着这台灯的本次请求未被接纳。把三者都压成灰色图标,会让故障定位和二次操作变得困难。
private choosePreset(p: LightPreset): void {
this.activePresetId = p.id;
this.brightness = p.brightness;
this.kelvin = p.kelvin;
this.isOn = true;
}
private toggleSelection(id: string): void {
this.devices = this.devices.map((row: DeviceRow): DeviceRow =>
row.id === id ? { ...row, selected: !row.selected } : row);
}
build() {
Column({ space: 12 }) {
Text('光境控制台').fontSize(28).fontWeight(FontWeight.Bold)
Text('客厅灯组 · 应用侧示意').fontSize(14).fontColor('#6F6886')
Text(`${this.brightness}% · ${this.kelvin}K`).fontSize(28)
Row({ space: 8 }) {
ForEach(LIGHT_PRESETS, (p: LightPreset) => {
Button(p.label).backgroundColor(
this.activePresetId === p.id ? '#6954CF' : '#DFD9F4')
.onClick(() => this.choosePreset(p))
}, (p: LightPreset): string => p.id)
}
Text('亮度 ' + this.brightness + '%')
Slider({ value: this.brightness, min: 0, max: 100, step: 1 })
.onChange((value: number, mode: SliderChangeMode) => {
this.brightness = Math.round(value);
this.activePresetId = '';
})
Text('色温 ' + this.kelvin + 'K')
Slider({ value: this.kelvin, min: 2700, max: 6500, step: 100 })
.onChange((value: number, mode: SliderChangeMode) => {
this.kelvin = Math.round(value / 100) * 100;
this.activePresetId = '';
})
ForEach(this.devices, (row: DeviceRow) => {
Row({ space: 10 }) {
Button(row.selected ? '✓' : '+').onClick(() => this.toggleSelection(row.id))
Column({ space: 4 }) {
Text(row.label).fontSize(17)
Text(row.deliveryStatus + ' · ' + row.confirmedText).fontSize(13)
}.layoutWeight(1)
}.width('100%').padding(12)
}, (row: DeviceRow): string => row.id)
Button('应用到已选设备').onClick(() => this.dispatchSelected())
}.width('100%').height('100%').padding(22)
}

这里的 SliderChangeMode 仍可用于区分拖动开始、移动和结束;但多灯示例只在“应用到已选设备”按钮上构造命令,所以滑动过程不触发真实发送。基础组件的 Slider 参数与 onChange 用法,可查华为开发者的滑动条接口文档;列表更新则依赖 ArkUI 状态变量。本文代码示例沿用 API 12 及以上常见基础组件的编写习惯,不宣称依赖 HarmonyOS 7 特定的设备联动开放 API。
对于用户操作还要补上一条容易遗漏的规则:取消选中设备不等于撤销已发送命令。当请求正在等待回执时,用户把台灯从本轮选择集合移除,只会影响下一次“应用”按钮的目标列表;上一条已经发出的命令仍可能到达。如果业务产品确实需要取消,应定义独立的取消协议和明确的设备端响应,而不是靠切换复选标记暗示远端停止。另一条规则是“未接纳”和“未知”不能合并:拒绝回执明确表示此次预设请求被否定,通信异常则只能表明应用尚无法判断最终效果。真实联网设备还应处理授权过期、重连后的状态查询与设备能力差异,不能因为 UI 当前显示成功就省去后端的状态核对。
五、把部分失败写进验收过程,而不是埋在异常日志
按页面默认选择,先保持“专注”预设78%/4800K,再点击“应用到已选设备”。测试适配器应分别给主灯与台灯返回不同结果,主灯卡片显示“已确认 · 78% · 4800K”,台灯显示“未接纳 · —”,灯带维持“未参与 · —”。然后取消台灯选择、选中灯带并再次应用;主灯和灯带应进入新的发送代次,而台灯的旧失败状态应保持,直到用户再次明确向它发送。这种显示策略使每台灯的结果具有清楚的时间归属,避免用户误认为群组选择动作本身就改变了全部设备状态。
下一步是验证旧回执处理。可以用自定义测试传输让较早命令的 Promise 晚于较新命令完成,分别注入主灯与台灯的应答。验收重点不在延迟了多少毫秒,而在旧回执不能覆盖当前 latestIssued 的卡片;每台设备的序号不应相互干扰。对异常分支,还应测试传输拒绝、Promise 抛错、未选择任何设备以及重复点击应用按钮。这里暂不提供伪造的设备在线率、发送时延或实际硬件成功截图,只给出可以在开发环境复现的业务状态变化。
多灯控制并没有消灭上一阶段的状态一致性问题,而是把它从单一序号扩展成多个独立的序号空间。页面负责呈现目标参数,传输适配器负责回执,设备卡片负责显示属于自己的最新有效结果;群组只定义一次操作的目标集合,而不冒充真实设备。后续连载可以在这一结构上加入场景参数的本地恢复与无障碍标签,再根据合法设备接入能力决定是否引入真正的设备状态订阅。这样设计既保留了体验升级的方向,也没有越过尚未验证的通信边界。
更多推荐

所有评论(0)