LumaRoom 灯光分组控制工程收尾技术封面

客厅主灯已经切换到专注预设,书桌台灯离线,背景灯带没有参与操作。此时控制台如果只在页面顶端写一个绿色“已完成”,用户会误以为三盏灯都成功改变;若只把页面锁死并不断重试,离线台灯又可能在重新连接后执行用户已经取消的旧场景。LumaRoom 前五篇已经建立亮度与色温调节、专注/观影/夜灯预设、desired 与 confirmed 分离、逐设备回执序号、分组失败以及离线最新意图重放。连载最后一篇不再增加新灯型,而是解决工程交付前的两个缺口:如何把部分成功讲清楚,如何让用户明确控制失败后的恢复动作。

本篇仍使用三台示例设备:main 客厅主灯、desk 书桌台灯、strip 背景灯带。专注预设为78%与4800K,观影28%与3200K,夜灯12%与2700K。案例默认主灯在线且已确认专注,台灯离线待同步,灯带未选中。网络状态和设备回执均来自本地可控的适配器,不等于系统已经连接到真实智能灯,也不能由构造的“已确认”推断物理灯具达到了指定亮度。我们只讨论开发者能够负责的应用状态、错误分级、重试入口和可访问反馈。

一、先回答设计评审中最难的三个问题

第一个问题是“批量操作算不算成功”。如果主灯确认成功,台灯断开,灯带根本没参与,批量操作没有唯一的布尔结论。单个绿色勾号会掩盖失败,统一红色弹窗又会把正常完成的主灯说成失败。更准确的是由每台设备的结果聚合出三类摘要:全部选中设备已确认时才显示“本轮全部确认”;至少一台未确认时显示“部分设备未确认”;没有任何设备符合操作条件时显示“没有可发送的设备”。摘要只是一层阅读帮助,具体处理仍以卡片上各灯的状态为准。

第二个问题是“失败重试会不会重复执行”。从第五篇继承的 pendingTarget 保存每设备最后一次有效目标,intentVersion 标识这份目标代次,latestIssued 标识最近实际发出的一次请求。重试只允许引用最新 pendingTarget;新回执只有同时匹配设备ID、发送序号与意图版本,才有资格更新 confirmed。如果在离线期间用户连续选择观影、专注,台灯恢复后只允许重放最后的专注目标,而不是回放每个滑块变化。这些是应用层规则,真实设备若不支持幂等命令,还要额外做设备状态读取与协议级协调。

第三个问题是“用户能否理解并操作”。状态用红绿颜色区分,不能成为唯一反馈。文字“离线·待同步”“已确认”“未接纳”“待人工重试”比一个红点更具体;屏幕朗读需要知道是“哪盏灯、当前目标是什么、上次确认是什么”,而不只是听到“按钮”。官方 ArkUI 的 accessibilityText 与 accessibilityDescription 可用于补充焦点朗读内容,且这些属性本身不会替开发者产生业务结果。无障碍设计要从真实状态模型提取信息,不能独立维护一套容易过期的说明字符串。

光境控制台:逐设备状态与人工重试入口

二、把设备证据当作独立数据,不让颜色替代事实

我们沿用第五篇的 LightValues 与 LampRow,新增一个“当前目标和确认是否一致”的展示函数,而不是让 UI 直接把 online=true 翻译成“执行成功”。在线只表示适配器报告设备可达,既不说明上一条指令已经发送,也不说明设备已经执行。确认时要保存适配器回传的 applied 值,并检查该值在合法区间:亮度0~100、色温按示例设备定义限制在2700~6500K之间,开关为布尔。实际产品还要由设备规格声明可用亮度档位与色温范围,不能把示例范围当所有设备的共性。

interface LightValues {
  isOn: boolean;
  brightness: number;
  kelvin: number;
}
interface LampRow {
  id: string;
  label: string;
  selected: boolean;
  online: boolean;
  latestIssued: number;
  intentVersion: number;
  pendingTarget: LightValues | null;
  status: string;
  confirmedText: string;
}
function validApplied(values: LightValues): boolean {
  return typeof values.isOn === 'boolean' &&
    Number.isInteger(values.brightness) &&
    values.brightness >= 0 && values.brightness <= 100 &&
    Number.isInteger(values.kelvin) &&
    values.kelvin >= 2700 && values.kelvin <= 6500;
}
function lampSpokenText(row: LampRow): string {
  const reachability: string = row.online ? '在线' : '离线';
  return `${row.label},${reachability},${row.status},上次确认${row.confirmedText}`;
}

这两个函数可以在 entry/src/main/ets/common/LightModel.ets 中实现。validApplied 只能过滤明显错误数据,仍然不能证明回执由合法设备发出;真实接入还要校验设备身份与厂商协议证据。lampSpokenText 则故意同时包含当前状态和上次确认值。例如台灯离线待同步时,朗读应是“书桌台灯,离线,离线待重放,上次确认—”,而不能拼出“专注已完成”。当页面恢复后如果 pendingTarget 已更换,文本应随最新状态数组更新,避免让旧语音说明残留。

三、把“重试”从自动循环改为受控操作

第四、五篇的异步协调器已经负责发出最新意图与忽略过期回执。本篇增加用户手动的“重试未确认”动作:它只筛选已选中、在线、确有最新 pendingTarget 且当前不是“等待回执”的灯具。对于离线灯具,按钮只提示等待连接,不调用发送;对于已经完成的主灯,不能因为其他灯失败就再次发送相同命令。这里的“重试”针对目标状态,不是“对旧网络请求重发相同包”,所以要保持 intentVersion 不变、在真正发送时由已有的 sendLatest() 增加 latestIssued。

private retryUnconfirmed(): void {
  const candidates: string[] = this.lamps
    .filter((row: LampRow): boolean => row.selected && row.online &&
      row.pendingTarget !== null && row.status !== '等待回执')
    .map((row: LampRow): string => row.id);
  if (candidates.length === 0) {
    this.pageHint = '当前没有可人工重试的在线设备';
    return;
  }
  candidates.forEach((id: string): void => this.sendLatest(id));
  this.pageHint = `已尝试重新发送 ${candidates.length} 台设备的最新目标`;
}

private summary(): string {
  const chosen: LampRow[] = this.lamps.filter(
    (row: LampRow): boolean => row.selected);
  if (chosen.length === 0) { return '尚未选择设备'; }
  const pending: number = chosen.filter((row: LampRow): boolean =>
    row.pendingTarget !== null || row.status !== '已确认').length;
  return pending === 0 ? '本轮已选设备全部确认' :
    `${pending} 台设备尚未确认,请查看各设备状态`;
}

这里的 this.lamps、pageHint 与 sendLatest() 来自第五篇的 LightPage 协调页面:实际合并时需要把 pageHint 声明为 @State private pageHint: string = '',并确保异步回调通过替换整行对象更新数组。示例 summary() 属于界面阅读摘要,不是订单式“全局事务完成”判定;如果用户随后重新选择了设备,应以当前选择集合计算摘要,必要时再引入批次ID,防止“上一次分组结果”与“本次分组目标”混合。对真实设备而言,“发送成功”与“应用成功”也应当由适配器定义不同证据级别,不能只凭 Promise resolve 宣布已生效。

失败后应避免无限自动循环。设备离线、账号授权失效、设备主动拒绝、临时超时是四类不同的情况;本地示例只能构造其中的状态分支,并不能可靠识别真正故障根因。正式接入需要给每类错误分配不同恢复策略:离线等待上线、权限失效引导重新授权、明确拒绝给出目标不支持的解释、临时故障提供受限次数的重试。尤其后台切换时,不应因为页面 aboutToAppear 被再次触发而自动重发全部设备指令。

三灯的发送、回执、过期过滤与重试决策图

四、让无障碍和用户反馈一起随状态刷新

接下来将结果展示做成统一的卡片,而不是在每个预设按钮里写一套提示。页面使用 @State lamps 保存会影响渲染的行数组,三盏灯各自展示在线状态、处理结果和上次确认值。为了避免屏幕朗读把“主灯、已确认、已确认”重复读三次,设备卡片使用有业务含义的朗读文本;重试按钮的无障碍说明交代操作后果。如果未来引入更复杂的开关或滑杆,应为控件补充准确的最小值、最大值及当前值,并保证可通过焦点顺序完成操作。

@State private pageHint: string = '';

@Builder
private lampCard(row: LampRow) {
  Column({ space: 8 }) {
    Row() {
      Text(row.label).fontSize(20).layoutWeight(1)
      Text(row.online ? '在线' : '离线')
    }
    Text(row.status).fontColor('#6242A8')
    Text('上次确认:' + row.confirmedText)
      .fontColor('#536079')
  }
  .padding(18).width('100%').backgroundColor('#FFFFFF')
  .borderRadius(18)
  .accessibilityGroup(true)
  .accessibilityText(lampSpokenText(row))
}

@Builder
private recoveryActions() {
  Button('重试未确认设备')
    .onClick(() => this.retryUnconfirmed())
    .accessibilityDescription('仅对在线且待确认的已选设备重发最新目标')
  Text(this.pageHint).fontColor('#6B7280')
}

以上为放入第五篇 LightPage 的增量组件片段;最终页面中在 Column 内通过 ForEach(this.lamps, ...) 调用 this.lampCard(row),并调用 this.recoveryActions()。并非所有文本都必须设置 accessibilityText:有普通可见文字的组件通常已能提供基本可读性;覆盖朗读文本时要确认内容反而更准确。官方文档还说明,开启 accessibilityGroup(true) 会影响子元素的朗读焦点,因此不要把含可操作滑杆与开关的复杂组件随意合并成一个无法逐项操作的焦点。这里的卡片仅用于展示信息,操作入口作为独立按钮保留。

对于 Slider 的拖动事件,建议延续第二篇的区分:Moving 用于实时预览,End 或 Click 才生成一次待发送目标,避免拖动时把设备命令当成每帧动画发送。SliderChangeMode 是 ArkUI 公开定义的事件状态;不同业务还应考虑键盘输入及辅助操作后的数值变化,不能把触摸滑动视为唯一输入方式。若用户关闭灯具又打开灯具,状态播报应包含“开关”和“实际确认的亮度”,而不是让盲用者只感受到滑块位置没有变化。

五、用可控事件序列做发布前回归

工程验收不能只拿“所有灯在线且立刻成功”的理想路径示例。可以在本地适配器中暴露可控 Promise,由测试用例决定何时返回、返回哪个序号、是否抛异常。第一条序列:主灯确认专注,台灯离线待同步,灯带未选,摘要应显示一台设备尚未确认;第二条序列:台灯离线期间先改观影、再改专注,上线后只保留78%与4800K;第三条序列:旧观影回执晚于新专注回执到达,由于意图版本不符不能倒灌状态;第四条序列:台灯待人工重试时,重复触发入口不得使已确认主灯再次收到指令。再检验屏幕朗读是否准确覆盖设备名称、在线信息及结果提示。

还有两条容易在产品化时遗漏的负向流程。其一,用户注销账号或解除设备绑定,先前保留的 pendingTarget 不应在下次登录后自动重放给不再授权的设备;其二,设备支持的色温范围可能与本地示例不同,不能向不支持4800K的灯具发送目标后再把失败解释成无线网络不稳定。实际工程必须由设备能力协商、身份授权与协议适配器提供事实依据。没有真实设备授权与编译测试条件时,文中描述的是可复现的测试设计和期望判定,并非已测得的真实联网成功率或延迟数据。

最后形成的模块边界应当是清楚的:UI 只负责采集用户意图与展示摘要;协调器负责设备级意图版本、发送序号和最新目标;传输适配器负责设备协议与回执真实性;本地配置负责用户明确授权保留的预设,而不能偷偷承担交易日志或系统连接发现的职责。@State 可以驱动视图刷新,却不会替我们解决设备控制的时序、权限和可靠性。逐设备结果和清晰的恢复按钮,往往比一个看似统一的“成功”浮层更能避免误操作。

LumaRoom 的六篇连载从单灯双滑杆起步,逐步走向预设、回执、分组、离线覆盖,再到本篇的失败恢复与无障碍反馈。其最核心的原则是:用户期望不是设备事实,发送记录不是执行证据,失败恢复不能抹掉新意图。把这三组边界保持独立,即使以后换成真实硬件和后端连接协议,页面状态与异常处理仍有稳定的演进基础。相关开发接口参见华为官方 Slider 组件、ArkUI 无障碍属性 与 组件状态管理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐