离线文搜图应用「分拣线」把 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 八类事件:
- export type Stage = 'IDLE' | 'READY' | 'RUNNING' | 'PAUSED' | 'DONE';
- export type Event =
- | 'LOADED' | 'START' | 'PAUSE' | 'RESUME' | 'TICK_DONE'
- | 'CHANGE_RULE' | 'RERUN' | 'SPEED';
- export interface Ctx {
- stage: Stage;
- libSize: number;
- cursor: number;
- speed: number;
- }
- export interface Transition {
- next: Stage;
- action: string; // 由调用方映射到具体副作用
- }
- export function reduce(s: Ctx, e: Event): Transition {
- switch (e) {
- case 'LOADED':
- return { next: s.libSize > 0 ? 'READY' : 'IDLE', action: 'refreshIndex' };
- case 'START':
- if (s.libSize === 0) return { next: s.stage, action: 'noop' };
- return { next: 'RUNNING', action: 'classifyAndReset' };
- case 'PAUSE':
- return s.stage === 'RUNNING'
- ? { next: 'PAUSED', action: 'stopClock' }
- : { next: s.stage, action: 'noop' };
- case 'RESUME':
- return s.stage === 'PAUSED'
- ? { next: 'RUNNING', action: 'startClock' }
- : { next: s.stage, action: 'noop' };
- case 'TICK_DONE': {
- if (s.stage !== 'RUNNING') return { next: s.stage, action: 'noop' };
- const nextCursor = s.cursor + 1;
- return nextCursor >= s.libSize
- ? { next: 'DONE', action: 'commitAndFinalize' }
- : { next: 'RUNNING', action: 'commitAndAdvance' };
- }
- case 'CHANGE_RULE':
- return { next: 'READY', action: 'invalidateResults' };
- case 'RERUN':
- return s.libSize > 0
- ? { next: 'RUNNING', action: 'classifyAndReset' }
- : { next: s.stage, action: 'noop' };
- case 'SPEED':
- return { next: s.stage, action: 'changeTempo' };
- }
- }
复制代码
关键在于把 CHANGE_RULE 的 action 明确写成 invalidateResults:规则是查询的一部分,规则一变,旧分组就是脏数据。这个不变量如果只靠「记得在 onClick 里 reset()」来维持,迟早漏掉某条路径,比如以后新增「导入预设」。SPEED 既不动状态也不动作废,是另一个刻意区分:改节奏不该让已归类的图重新算,用户可以一边看一边调速,这是计算与呈现解耦带来的自由度。
三、四个分拣口的控件语义
四路槽口做的是同一件事:显示一个词、一个颜色、一个计数,点击切到词池里的下一个词。里面藏着三个约定。
第一,点击是循环,不是打开选择器。词池 10 个词,点一次前进一步、到尾回绕。好处是零额外 UI、单手可达,代价是想切到远处的词要连点多次;在「随手试不同配法」的场景里,这个取舍合理。
第二,同槽重复词是合法的。四路各自独立循环,完全可能把两路都切成「复杂」。此时同一条规则出现两次,argmax 总取先出现的那一路,另一路永远收不到图。这不是 bug,但界面没有任何提示——状态机允许的状态,未必是用户想要的状态。
第三,槽位在 RUNNING 时也能点。转移表允许,代价是立即作废整轮结果。更友好的做法是加守卫:运行中切词先弹确认,或把切词排队到 DONE 之后。
- export interface SlotState {
- word: string;
- color: string;
- count: number;
- }
- export function cycleWord(pool: string[], slots: SlotState[], idx: number): SlotState[] {
- const next = slots.slice(); // 整体替换,触发 UI 刷新
- const cur = next[idx];
- const p = pool.indexOf(cur.word);
- next[idx] = {
- word: pool[(p + 1) % pool.length],
- color: cur.color, // 颜色属于槽位,不属于词
- count: 0
- };
- return next;
- }
- export function duplicateRules(slots: SlotState[]): string[] {
- const seen = new Set<string>();
- const dup: string[] = [];
- for (const s of slots) {
- if (seen.has(s.word)) dup.push(s.word);
- seen.add(s.word);
- }
- return dup; // 非空时可在槽位上打小标记,提示该规则不会收到图
- }
复制代码
「颜色属于槽位而非词」也值得强调:词换了颜色不变,用户才能持续追踪「01 号槽的计数」这条线索;如果颜色跟着词走,切一次词整屏色彩重排,短时记忆就断了。
四、340ms 节拍究竟在服务谁
引擎把 24 张图全部分完是毫秒级的事,界面却按节拍逐张揭晓。交互上要回答的不是「为什么要慢」,而是「这段时间用户读到了什么」。以默认 340ms 估算两轮实跑时长:
- export interface Tempo {
- speed: number;
- perTickMs: number;
- totalMs: number;
- }
- export function tempoOf(speed: number, count: number): Tempo {
- const perTickMs = Math.round(340 / speed);
- return { speed: speed, perTickMs: perTickMs, totalMs: count * perTickMs };
- }
- // 24 张:1× → 340ms × 24 = 8.16s;4× → 85ms × 24 = 2.04s
复制代码
截图里第二轮拧到 4×,全程压到约 2 秒,但判定内容一字未变。这说明节拍是纯呈现层参数,不参与任何计算,这也正是它能被随手调节的前提。代价同样明确:4× 提升的是吞吐,损失的是可读性,第二轮判定标签在快速翻动中几乎只能看到颜色闪动。更贴心的设计是把速度切换做成「跳到结果」而非「加速播放」,数据不变,但用户不必在 85ms 里读懂一句话。
进度在不同状态下语义不同,这里最容易出错。另一条纪律是把完成度和 ETA 分开呈现:
- export interface Progress {
- done: number;
- total: number;
- etaMs: number | null; // null = 不承诺时间
- }
- export function progressOf(cursor: number, total: number,
- perTickMs: number, paused: boolean): Progress {
- return {
- done: cursor,
- total: total,
- etaMs: paused ? null : (total - cursor) * perTickMs
- };
- }
复制代码
PAUSED 时把 etaMs 置空,而不是显示「剩余 3.4 秒」再凝固不动,是一种很小但很重要的诚实:暂停期间没有 ETA 这回事。
五、五条误操作守卫
把状态机写成表之后,「哪些操作会毁掉数据」就一目了然。
其一,暂停不能多吃一张。节拍用 await sleep() 实现,暂停恰好可能发生在睡眠期间,醒来必须复查状态,否则「暂停」会静默多提交一张图。
- async function tickLoop(ctx: { running: boolean; cursor: number; total: number },
- commit: (i: number) => void): Promise<void> {
- while (ctx.running && ctx.cursor < ctx.total) {
- const i = ctx.cursor;
- await sleep(340);
- if (!ctx.running) return; // 睡眠期间被暂停:保留工位,不提交
- commit(i);
- ctx.cursor++;
- }
- }
复制代码
其二,重跑必须是全量重置。计数、网格、当前工位、选中项、状态灯一起归零,只重置其中几项会留下「上一轮的 19 张」和「这一轮的第 3 张」并存的画面。其三,切词作废且作废要可见:光清空数据不够,最好让状态灯回到 READY、按钮文案变回「启动分拣」,让用户看见结果已失效。其四,速度切换不动数据。其五,点选详情时结果集不能正在被覆盖——用户点开某张图看依据的瞬间,若恰好触发新一轮重算,详情面板会指向被作废的对象,因此选中项应在 invalidateResults 里一起清空。
- export interface UiState {
- stage: Stage;
- cursor: number;
- counts: number[];
- grouped: number[];
- selectedId: number;
- tempo: number;
- }
- export function invalidate(s: UiState): UiState {
- return {
- stage: 'READY',
- cursor: 0,
- counts: [0, 0, 0, 0],
- grouped: [],
- selectedId: -1,
- tempo: s.tempo // 唯一被保留的状态:用户选的速度
- };
- }
复制代码
保留 tempo 是有意的:速度是用户偏好,不是某轮结果的一部分。把偏好和结果分开,重跑才不会顺手把 4× 拧回 1×。
六、状态正确不等于信息可见
回到首屏只显示一个槽这件事。四路槽口横排、每路占整行宽度,从状态机角度看完全正常(四路都在工作),但从信息传达角度看,「四路并行」这个核心概念没有在第一眼被交付。作者给出三种处置方式,代价各不相同,更一般的教训是:槽口计数、退回数、命中率在状态机里从来没错过,可用户若看不到 02~04 号槽,就很难理解「为什么 01 号槽只有 2 张、其余 21 张去哪了」。
再往下是颜色与可访问性。四槽用四种高饱和色区分,深色底上对比度充足,但仅靠颜色传达归属,对色觉差异用户不友好;归类网格里同时印了规则名文字,这一层冗余是对的。「退回」用灰框而非红框,语义准确:它不是错误,是「没有规则达标」。状态灯用绿/琥珀两色加英文文案(RUNNING / DONE)双编码,比纯色点更可辨。
七、把状态机变成可回归的断言
交互 bug 难测,是因为它们常散落在 onClick 里、依赖真实时间。把 reduce 提成纯函数之后,一条状态转移就是一行断言,不需要模拟器、不需要等待。
- interface Case {
- name: string;
- ctx: Ctx;
- event: Event;
- wantStage: Stage;
- wantAction: string;
- }
- const CASES: Case[] = [
- { name: '空库不能启动', ctx: { stage: 'READY', libSize: 0, cursor: 0, speed: 1 },
- event: 'START', wantStage: 'READY', wantAction: 'noop' },
- { name: '启动进入运行', ctx: { stage: 'READY', libSize: 24, cursor: 0, speed: 1 },
- event: 'START', wantStage: 'RUNNING', wantAction: 'classifyAndReset' },
- { name: '最后一拍收尾', ctx: { stage: 'RUNNING', libSize: 24, cursor: 23, speed: 1 },
- event: 'TICK_DONE', wantStage: 'DONE', wantAction: 'commitAndFinalize' },
- { name: '运行中切词作废', ctx: { stage: 'RUNNING', libSize: 24, cursor: 9, speed: 1 },
- event: 'CHANGE_RULE', wantStage: 'READY', wantAction: 'invalidateResults' },
- { name: '完成态调速不动数据', ctx: { stage: 'DONE', libSize: 24, cursor: 24, speed: 1 },
- event: 'SPEED', wantStage: 'DONE', wantAction: 'changeTempo' },
- { name: '暂停只能从运行进入', ctx: { stage: 'PAUSED', libSize: 24, cursor: 5, speed: 1 },
- event: 'PAUSE', wantStage: 'PAUSED', wantAction: 'noop' }
- ];
- export function runTransitions(cases: Case[]): string[] {
- const fails: string[] = [];
- for (const c of cases) {
- const t = reduce(c.ctx, c.event);
- if (t.next !== c.wantStage || t.action !== c.wantAction) {
- fails.push(`${c.name}: got ${t.next}/${t.action}`);
- }
- }
- return fails;
- }
复制代码
再补一条全路径回放:从 READY 起模拟 24 次 TICK_DONE,断言恰好在第 24 次进入 DONE,中途任何一次都不提前结束。
- export function replayFullRun(libSize: number): Stage[] {
- let ctx: Ctx = { stage: 'READY', libSize: libSize, cursor: 0, speed: 1 };
- const trace: Stage[] = [ctx.stage];
- ctx = { ...ctx, stage: reduce(ctx, 'START').next };
- for (let i = 0; i < libSize; i++) {
- ctx = { ...ctx, stage: reduce(ctx, 'TICK_DONE').next };
- ctx.cursor++;
- trace.push(ctx.stage);
- }
- return trace; // 长度 = libSize + 2;末项必须是 DONE,倒数第二项仍是 RUNNING
- }
复制代码
把这类断言接进 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,在普通单元测试里跑完即可;只有节拍与动画这类时序逻辑才需要集成测试。
问:这套方法能用在别的应用上吗?
答:能。凡是「有开始/暂停/完成、有会作废结果的操作」的界面——上传、导出、批处理、分步表单——都可以套用同一张转移表与同一组回归断言。 |