查看: 99|回复: 3

鸿蒙ArkTS交互状态机:切词作废、重跑重置与转移表回归

[复制链接]
发表于 半小时前 | 显示全部楼层 |阅读模式
离线文搜图应用「分拣线」把 24 张图逐张送过传送带,按四路规则拨进不同槽口。算法怎么归类是另一条线,本文只讨论鸿蒙应用里常被忽略的一件事:用户是怎么操作这台机器的,以及这些操作该由什么样的状态机来约束。素材来自 Mate 90 Pro 模拟器(HarmonyOS 7.0 / API 26)上的五张实机截图,文中的状态机、守卫与回归断言是可复现的交互分析代码,并非该工程已提交的实现。

一、五张截图拼出的操作路径

首屏处于 READY:24 张图已全部入库,四路分拣口各有一个词,传送带空转,三枚控制按钮待命。这一屏承担的是「承诺」——让用户看清有哪些槽、可以怎么调、按哪里开始。一个值得注意的可见事实是:首屏只完整显示 01 号槽,右侧仅露出相邻槽的一点边缘,四路并行这件事并没有在第一眼被传达。

第二张是 RUNNING 的中间帧:工位 3/24,当前卡片带着「→ 暖 · 实测 8% · 39 分」的判定,槽口计数在涨,队列缩略图向前挪。用户看到的是过程,而不是一次性弹出的结果。

第三张是 DONE:统计条、归类网格、按钮文案(变为「重新分拣」)同时切换。一次状态跃迁牵动四处 UI,任何一处没跟上,都会产生「看起来结束了但数字还在动」的割裂感。

第四张留下两次用户操作的痕迹:点了 01 号槽换词,又把速度拧到 4×。这两个操作都必须触发旧结果作废,否则界面会出现「规则已经变了,网格还是上一轮」这种最难排查的不一致。

第五张是第二轮 DONE。与第一轮对照:退回数 3 → 0,命中率 88% → 100%,01 号槽 2 张 → 19 张。同一个人、同一台机器,只改了控件,操作路径就完全重写了结果——这正是把状态机单独拎出来研究的价值。

二、把分散的 onClick 收进显式转移表

与其让规则散落在各个回调里,不如先写一张转移表。类型定义覆盖 IDLE、READY、RUNNING、PAUSED、DONE 五个阶段,以及 LOADED、START、PAUSE、RESUME、TICK_DONE、CHANGE_RULE、RERUN、SPEED 八类事件:
  1. export type Stage = 'IDLE' | 'READY' | 'RUNNING' | 'PAUSED' | 'DONE';
  2. export type Event =
  3. | 'LOADED' | 'START' | 'PAUSE' | 'RESUME' | 'TICK_DONE'
  4. | 'CHANGE_RULE' | 'RERUN' | 'SPEED';
  5. export interface Ctx {
  6.   stage: Stage;
  7.   libSize: number;
  8.   cursor: number;
  9.   speed: number;
  10. }
  11. export interface Transition {
  12.   next: Stage;
  13.   action: string; // 由调用方映射到具体副作用
  14. }
  15. export function reduce(s: Ctx, e: Event): Transition {
  16.   switch (e) {
  17.     case 'LOADED':
  18.       return { next: s.libSize > 0 ? 'READY' : 'IDLE', action: 'refreshIndex' };
  19.     case 'START':
  20.       if (s.libSize === 0) return { next: s.stage, action: 'noop' };
  21.       return { next: 'RUNNING', action: 'classifyAndReset' };
  22.     case 'PAUSE':
  23.       return s.stage === 'RUNNING'
  24.         ? { next: 'PAUSED', action: 'stopClock' }
  25.         : { next: s.stage, action: 'noop' };
  26.     case 'RESUME':
  27.       return s.stage === 'PAUSED'
  28.         ? { next: 'RUNNING', action: 'startClock' }
  29.         : { next: s.stage, action: 'noop' };
  30.     case 'TICK_DONE': {
  31.       if (s.stage !== 'RUNNING') return { next: s.stage, action: 'noop' };
  32.       const nextCursor = s.cursor + 1;
  33.       return nextCursor >= s.libSize
  34.         ? { next: 'DONE', action: 'commitAndFinalize' }
  35.         : { next: 'RUNNING', action: 'commitAndAdvance' };
  36.     }
  37.     case 'CHANGE_RULE':
  38.       return { next: 'READY', action: 'invalidateResults' };
  39.     case 'RERUN':
  40.       return s.libSize > 0
  41.         ? { next: 'RUNNING', action: 'classifyAndReset' }
  42.         : { next: s.stage, action: 'noop' };
  43.     case 'SPEED':
  44.       return { next: s.stage, action: 'changeTempo' };
  45.   }
  46. }
复制代码

关键在于把 CHANGE_RULE 的 action 明确写成 invalidateResults:规则是查询的一部分,规则一变,旧分组就是脏数据。这个不变量如果只靠「记得在 onClick 里 reset()」来维持,迟早漏掉某条路径,比如以后新增「导入预设」。SPEED 既不动状态也不动作废,是另一个刻意区分:改节奏不该让已归类的图重新算,用户可以一边看一边调速,这是计算与呈现解耦带来的自由度。

三、四个分拣口的控件语义

四路槽口做的是同一件事:显示一个词、一个颜色、一个计数,点击切到词池里的下一个词。里面藏着三个约定。

第一,点击是循环,不是打开选择器。词池 10 个词,点一次前进一步、到尾回绕。好处是零额外 UI、单手可达,代价是想切到远处的词要连点多次;在「随手试不同配法」的场景里,这个取舍合理。

第二,同槽重复词是合法的。四路各自独立循环,完全可能把两路都切成「复杂」。此时同一条规则出现两次,argmax 总取先出现的那一路,另一路永远收不到图。这不是 bug,但界面没有任何提示——状态机允许的状态,未必是用户想要的状态。

第三,槽位在 RUNNING 时也能点。转移表允许,代价是立即作废整轮结果。更友好的做法是加守卫:运行中切词先弹确认,或把切词排队到 DONE 之后。
  1. export interface SlotState {
  2.   word: string;
  3.   color: string;
  4.   count: number;
  5. }
  6. export function cycleWord(pool: string[], slots: SlotState[], idx: number): SlotState[] {
  7.   const next = slots.slice(); // 整体替换,触发 UI 刷新
  8.   const cur = next[idx];
  9.   const p = pool.indexOf(cur.word);
  10.   next[idx] = {
  11.     word: pool[(p + 1) % pool.length],
  12.     color: cur.color, // 颜色属于槽位,不属于词
  13.     count: 0
  14.   };
  15.   return next;
  16. }
  17. export function duplicateRules(slots: SlotState[]): string[] {
  18.   const seen = new Set<string>();
  19.   const dup: string[] = [];
  20.   for (const s of slots) {
  21.     if (seen.has(s.word)) dup.push(s.word);
  22.     seen.add(s.word);
  23.   }
  24.   return dup; // 非空时可在槽位上打小标记,提示该规则不会收到图
  25. }
复制代码

「颜色属于槽位而非词」也值得强调:词换了颜色不变,用户才能持续追踪「01 号槽的计数」这条线索;如果颜色跟着词走,切一次词整屏色彩重排,短时记忆就断了。

四、340ms 节拍究竟在服务谁

引擎把 24 张图全部分完是毫秒级的事,界面却按节拍逐张揭晓。交互上要回答的不是「为什么要慢」,而是「这段时间用户读到了什么」。以默认 340ms 估算两轮实跑时长:
  1. export interface Tempo {
  2.   speed: number;
  3.   perTickMs: number;
  4.   totalMs: number;
  5. }
  6. export function tempoOf(speed: number, count: number): Tempo {
  7.   const perTickMs = Math.round(340 / speed);
  8.   return { speed: speed, perTickMs: perTickMs, totalMs: count * perTickMs };
  9. }
  10. // 24 张:1× → 340ms × 24 = 8.16s;4× → 85ms × 24 = 2.04s
复制代码

截图里第二轮拧到 4×,全程压到约 2 秒,但判定内容一字未变。这说明节拍是纯呈现层参数,不参与任何计算,这也正是它能被随手调节的前提。代价同样明确:4× 提升的是吞吐,损失的是可读性,第二轮判定标签在快速翻动中几乎只能看到颜色闪动。更贴心的设计是把速度切换做成「跳到结果」而非「加速播放」,数据不变,但用户不必在 85ms 里读懂一句话。

进度在不同状态下语义不同,这里最容易出错。另一条纪律是把完成度和 ETA 分开呈现:
  1. export interface Progress {
  2.   done: number;
  3.   total: number;
  4.   etaMs: number | null; // null = 不承诺时间
  5. }
  6. export function progressOf(cursor: number, total: number,
  7.   perTickMs: number, paused: boolean): Progress {
  8.   return {
  9.     done: cursor,
  10.     total: total,
  11.     etaMs: paused ? null : (total - cursor) * perTickMs
  12.   };
  13. }
复制代码

PAUSED 时把 etaMs 置空,而不是显示「剩余 3.4 秒」再凝固不动,是一种很小但很重要的诚实:暂停期间没有 ETA 这回事。

五、五条误操作守卫

把状态机写成表之后,「哪些操作会毁掉数据」就一目了然。

其一,暂停不能多吃一张。节拍用 await sleep() 实现,暂停恰好可能发生在睡眠期间,醒来必须复查状态,否则「暂停」会静默多提交一张图。
  1. async function tickLoop(ctx: { running: boolean; cursor: number; total: number },
  2.   commit: (i: number) => void): Promise<void> {
  3.   while (ctx.running && ctx.cursor < ctx.total) {
  4.     const i = ctx.cursor;
  5.     await sleep(340);
  6.     if (!ctx.running) return; // 睡眠期间被暂停:保留工位,不提交
  7.     commit(i);
  8.     ctx.cursor++;
  9.   }
  10. }
复制代码

其二,重跑必须是全量重置。计数、网格、当前工位、选中项、状态灯一起归零,只重置其中几项会留下「上一轮的 19 张」和「这一轮的第 3 张」并存的画面。其三,切词作废且作废要可见:光清空数据不够,最好让状态灯回到 READY、按钮文案变回「启动分拣」,让用户看见结果已失效。其四,速度切换不动数据。其五,点选详情时结果集不能正在被覆盖——用户点开某张图看依据的瞬间,若恰好触发新一轮重算,详情面板会指向被作废的对象,因此选中项应在 invalidateResults 里一起清空。
  1. export interface UiState {
  2.   stage: Stage;
  3.   cursor: number;
  4.   counts: number[];
  5.   grouped: number[];
  6.   selectedId: number;
  7.   tempo: number;
  8. }
  9. export function invalidate(s: UiState): UiState {
  10.   return {
  11.     stage: 'READY',
  12.     cursor: 0,
  13.     counts: [0, 0, 0, 0],
  14.     grouped: [],
  15.     selectedId: -1,
  16.     tempo: s.tempo // 唯一被保留的状态:用户选的速度
  17.   };
  18. }
复制代码

保留 tempo 是有意的:速度是用户偏好,不是某轮结果的一部分。把偏好和结果分开,重跑才不会顺手把 4× 拧回 1×。

六、状态正确不等于信息可见

回到首屏只显示一个槽这件事。四路槽口横排、每路占整行宽度,从状态机角度看完全正常(四路都在工作),但从信息传达角度看,「四路并行」这个核心概念没有在第一眼被交付。作者给出三种处置方式,代价各不相同,更一般的教训是:槽口计数、退回数、命中率在状态机里从来没错过,可用户若看不到 02~04 号槽,就很难理解「为什么 01 号槽只有 2 张、其余 21 张去哪了」。

再往下是颜色与可访问性。四槽用四种高饱和色区分,深色底上对比度充足,但仅靠颜色传达归属,对色觉差异用户不友好;归类网格里同时印了规则名文字,这一层冗余是对的。「退回」用灰框而非红框,语义准确:它不是错误,是「没有规则达标」。状态灯用绿/琥珀两色加英文文案(RUNNING / DONE)双编码,比纯色点更可辨。

七、把状态机变成可回归的断言

交互 bug 难测,是因为它们常散落在 onClick 里、依赖真实时间。把 reduce 提成纯函数之后,一条状态转移就是一行断言,不需要模拟器、不需要等待。
  1. interface Case {
  2.   name: string;
  3.   ctx: Ctx;
  4.   event: Event;
  5.   wantStage: Stage;
  6.   wantAction: string;
  7. }
  8. const CASES: Case[] = [
  9.   { name: '空库不能启动', ctx: { stage: 'READY', libSize: 0, cursor: 0, speed: 1 },
  10.     event: 'START', wantStage: 'READY', wantAction: 'noop' },
  11.   { name: '启动进入运行', ctx: { stage: 'READY', libSize: 24, cursor: 0, speed: 1 },
  12.     event: 'START', wantStage: 'RUNNING', wantAction: 'classifyAndReset' },
  13.   { name: '最后一拍收尾', ctx: { stage: 'RUNNING', libSize: 24, cursor: 23, speed: 1 },
  14.     event: 'TICK_DONE', wantStage: 'DONE', wantAction: 'commitAndFinalize' },
  15.   { name: '运行中切词作废', ctx: { stage: 'RUNNING', libSize: 24, cursor: 9, speed: 1 },
  16.     event: 'CHANGE_RULE', wantStage: 'READY', wantAction: 'invalidateResults' },
  17.   { name: '完成态调速不动数据', ctx: { stage: 'DONE', libSize: 24, cursor: 24, speed: 1 },
  18.     event: 'SPEED', wantStage: 'DONE', wantAction: 'changeTempo' },
  19.   { name: '暂停只能从运行进入', ctx: { stage: 'PAUSED', libSize: 24, cursor: 5, speed: 1 },
  20.     event: 'PAUSE', wantStage: 'PAUSED', wantAction: 'noop' }
  21. ];
  22. export function runTransitions(cases: Case[]): string[] {
  23.   const fails: string[] = [];
  24.   for (const c of cases) {
  25.     const t = reduce(c.ctx, c.event);
  26.     if (t.next !== c.wantStage || t.action !== c.wantAction) {
  27.       fails.push(`${c.name}: got ${t.next}/${t.action}`);
  28.     }
  29.   }
  30.   return fails;
  31. }
复制代码

再补一条全路径回放:从 READY 起模拟 24 次 TICK_DONE,断言恰好在第 24 次进入 DONE,中途任何一次都不提前结束。
  1. export function replayFullRun(libSize: number): Stage[] {
  2.   let ctx: Ctx = { stage: 'READY', libSize: libSize, cursor: 0, speed: 1 };
  3.   const trace: Stage[] = [ctx.stage];
  4.   ctx = { ...ctx, stage: reduce(ctx, 'START').next };
  5.   for (let i = 0; i < libSize; i++) {
  6.     ctx = { ...ctx, stage: reduce(ctx, 'TICK_DONE').next };
  7.     ctx.cursor++;
  8.     trace.push(ctx.stage);
  9.   }
  10.   return trace; // 长度 = libSize + 2;末项必须是 DONE,倒数第二项仍是 RUNNING
  11. }
复制代码

把这类断言接进 CI 的价值很直接:以后有人给 DONE 状态新加一个按钮(比如「导出分组」),如果忘了在 reduce 里登记事件,测试会立刻指出「出现了一个未定义的转移」,而不是等用户点出诡异画面。

八、一条完整操作路径的验收轨迹

把五张截图对应的事件序列写全,就是状态机的验收轨迹:

LOADED(24) → READY,首屏四槽就位、计数 0、按钮「启动分拣」;
START → RUNNING,分类一次、开 340ms 节拍;
TICK_DONE × 2 → RUNNING,工位 3/24,当前卡「花间翠鸟 → 暖」;
TICK_DONE × 21 → RUNNING,归类网格逐张增长;
TICK_DONE → DONE,24/24、退回 3、命中率 88%、按钮「重新分拣」;
CHANGE_RULE(01→简洁) → READY,作废:网格清空、选中清空、速度保留;
SPEED(1×→4×) → READY,仅改 tempo;
START → RUNNING,重分类、开 85ms 节拍;
TICK_DONE × 24 → DONE,24/24、退回 0、命中率 100%、01 槽 19 张。

这条轨迹里有两个纯实现文档不会写、但交互复盘必须写的细节。第一,第 4 步和第 5 步之间,用户很可能点开某张图看依据,此时 selectedId 非 -1,第 6 步的 invalidate 必须连同它一起清掉,否则详情面板会挂在已作废的结果上。第二,第 7 步顺序可交换,先调速再切词或反过来都行,因为 SPEED 与 CHANGE_RULE 一个只动偏好、一个只动作废,互不读写对方字段。能交换的操作,才允许用户按任意习惯来。

九、小结

这台分拣机在算法上做的是「多规则流式归类」,在交互上做的其实是另一件事:把一次不可见的计算,编排成一段可暂停、可加速、可推翻重来的操作路径。显式转移表让「切词作废」「重跑全量重置」这类不变量有了唯一归属;340ms 节拍是呈现层参数,与计算解耦,因此能随手拧到 4×,代价是判定标签的可读时间被压缩到 85ms;进度、ETA、暂停守卫、选中项作废这四条细节,决定用户是否会在某个瞬间看到自相矛盾的画面;首屏只显示一个槽,则提醒我们状态正确不等于信息可见。最后,纯函数的 reduce 让每条状态转移都变成一行可回归的断言,这对 ArkTS 页面逻辑同样适用。

FAQ

问:为什么专门写状态机,直接在 onClick 里判断不行吗?
答:小规模时行得通,但「切词作废」「重跑重置」「暂停不重复提交」这些规则会散落到多个回调里,新增一个入口就多一处遗漏。集中成转移表后,不变量只有一份,还能直接跑断言。

问:4× 速度下判定标签读不完,是不是设计失误?
答:是取舍。快放服务的是「看整体分布」,不是「逐张读依据」,两者由同一个节拍承担,必然顾此失彼。改进方向是把 4× 做成「跳到结果」,或单独提供「只看当前卡」的暂停定格。

问:运行中允许切词吗?
答:当前允许,代价是立即作废整轮。更稳妥的是加守卫:运行中切词先确认,或把新规则排队到 DONE 后生效。转移表里 CHANGE_RULE → READY 已为这两种改法留好了位置。

问:为什么重跑要保留速度?
答:速度是用户偏好,不是某轮结果。作废函数里只保留 tempo,其余全清,重跑时不会把 4× 悄悄拧回 1×。

问:状态机测试需要真机或模拟器吗?
答:不需要。reduce 是纯函数,输入 Ctx + Event、输出 Transition,在普通单元测试里跑完即可;只有节拍与动画这类时序逻辑才需要集成测试。

问:这套方法能用在别的应用上吗?
答:能。凡是「有开始/暂停/完成、有会作废结果的操作」的界面——上传、导出、批处理、分步表单——都可以套用同一张转移表与同一组回归断言。
回复

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙ArkTS交互状态机:切词作废、重跑重置与转移表回归

看完很有共鸣,把分散的 onClick 收进 reduce 转移表这一段很清晰,尤其是 CHANGE_RULE 对应 invalidateResults、SPEED 只改节奏不作废旧结果这个区分,确实把“规则是查询的一部分”落成了不变量。实际落地时建议把 action 到具体副作用的映射集中在一处,不然转移表再清楚,漏接 invalidateResults 仍然会出脏数据。首屏只完整露出 01 号槽这一点也值得处理,哪怕加个横向可滑动的边缘暗示或四路总数提示,让用户第一眼知道是并行四槽。同槽重复词既然合法,界面最好给个轻量提示,比如重复词标记或该槽命中说明,避免用户误以为另一路失效。整体思路对做鸿蒙交互状态机很有参考价值。
回复 支持 反对

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙ArkTS交互状态机:切词作废、重跑重置与转移表回归

这篇把交互状态机从分散回调里抽出来,用转移表和 action 映射副作用,思路很清楚。尤其是 CHANGE_RULE 显式标成 invalidateResults,把“规则变了旧分组就是脏数据”当成不变量,比依赖各个 onClick 记得 reset 可靠得多;SPEED 只 changeTempo 不作废结果也讲得通,调速不该触发重算。 有两个小建议:同槽重复词既然合法,UI 上最好给个轻提示,否则用户看到某一路一直不收图容易误判;另外 DONE 后切词直接回 READY,是否要保留上一轮统计做对照,可以在交互上再明确一下。整体很有启发,特别是首屏四路并行只完整露出一路这个细节,说明状态机之外可见性也要一起设计。
回复 支持 反对

使用道具 举报

发表于 半小时前 | 显示全部楼层

Re: 鸿蒙ArkTS交互状态机:切词作废、重跑重置与转移表回归

这篇把交互状态机从界面回调里拎出来讲得很清楚,尤其是 CHANGE_RULE 明确映射到 invalidateResults、SPEED 只改节奏不作废旧结果,这两个不变量分开很关键。转移表把分散的 onClick 收拢后,后续新增入口确实不容易漏掉作废逻辑。 另外首屏只完整露出 01 号槽这点也值得注意,四路并行如果第一眼传达不足,用户可能低估可调空间。还有同槽重复词合法但 argmax 总取先出现的那一路,虽然不算 bug,但最好在槽口计数或提示上让用户感知到,否则容易误判算法结果。整体思路很受用。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-10 03:50 , Processed in 0.025175 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部