HarmonyOS应用实战-启示散页-06-仪式感动画不要卡业务:用 DrawingPage 隔离抽取和转场
答案之书需要一点仪式感:提问、翻动、停顿、出现答案。但动画一旦和业务抽取绑在一起,就很容易出问题。动画还没结束,结果已经写历史;用户返回了,定时器还在跑;抽取失败了,页面仍播放到最终态。更稳的做法是把 DrawingPage 当成状态机,把业务结果和转场节奏隔开。

这篇文章会把问题拆成四个可落地的点:
- 用 DrawingPage 承接抽取流程,不让首页直接处理动画。
- 业务先拿结果,动画按状态展示,不反向决定数据。
- 处理定时器清理、返回中断和失败态。
- 让历史、收藏和页面显示都基于同一个抽取结果。


1. 动画不能反过来驱动业务
很多页面会写成"动画结束后再随机抽答案"。这会让业务结果依赖 UI 时间:动画取消、页面返回、定时器丢失都会影响抽取。答案之书更稳的路径是先由 Service 得到结果,再让 DrawingPage 根据结果播放揭示动画。
ts
private async begin(): Promise<void> {
this.phase = 'drawing';
try {
const result: DrawAnswerResult = await AnswerDrawService.draw({
deckId: this.deckId,
question: this.question
});
this.result = result;
this.phase = 'revealing';
this.startRevealTimer();
} catch (err) {
this.phase = 'failed';
}
}
业务完成后才进入 revealing。动画可以慢一点,但不能决定答案是否存在。
2. 页面状态机要少而清楚
DrawingPage 至少有 idle、drawing、revealing、done、failed 五种状态。不要用多个 boolean 拼状态,例如 loading && showCard && hasError,后期很难判断组合是否合法。
ts
type DrawingPhase = 'idle' | 'drawing' | 'revealing' | 'done' | 'failed';
@State private phase: DrawingPhase = 'idle';
@State private result: DrawAnswerResult | null = null;
@State private revealProgress: number = 0;
单一 phase 能让 UI 分支和调试日志都更直接。出现异常时,先看 phase 停在哪一步。
3. 首页只传输入,不参与抽取细节
首页收集问题和当前题库 id,然后跳转到 DrawingPage。它不应该提前随机,也不应该写历史。这样首页可以保持轻,抽取页成为完整流程的唯一拥有者。
ts
this.pathStack.pushPath({
name: RouteName.Drawing,
param: {
deckId: this.currentDeckId,
question: this.question.trim()
} as DrawingParam
});
首页传的是上下文,不是结果。DrawingPage 才负责拿结果、展示结果和交给历史服务。
4. 参数进入页面先做兜底
导航参数可能为空,特别是调试、深链或后续扩展入口。DrawingPage 在 aboutToAppear 中解析参数,缺失时给出错误态,不要让空 deckId 进入 Service 后再出现难懂错误。
ts
interface DrawingParam {
deckId: string;
question?: string;
}
private readParam(): DrawingParam | null {
const param = this.pathStack.getParamByName(RouteName.Drawing) as DrawingParam | undefined;
if (!param?.deckId) {
return null;
}
return param;
}
参数校验是页面入口边界。Service 仍会校验,但页面可以更早给用户可理解的反馈。
5. 定时器必须跟页面生命周期绑定
动画通常需要 setTimeout 或 setInterval。如果页面返回后不清理,定时器回调还会修改已离开的页面状态,轻则日志异常,重则状态错乱。答案之书需要在离开页面时清理所有动画句柄。
ts
private revealTimer: number = -1;
aboutToDisappear(): void {
if (this.revealTimer !== -1) {
clearInterval(this.revealTimer);
this.revealTimer = -1;
}
}
动画资源属于页面生命周期。页面消失后,不再允许它继续推进状态。
6. 历史写入放在结果确认点
历史记录应该写在抽取结果生成后,而不是动画彻底结束后。用户中途返回也代表这次抽取发生过;如果产品希望只记录看完的答案,那也要显式定义。答案之书更适合在拿到结果后记录历史。
ts
const result: DrawAnswerResult = await AnswerDrawService.draw(options);
await HistoryService.appendFromDraw(result);
this.result = result;
this.phase = 'revealing';
历史的来源是领域结果,不是动画帧。这样历史列表和收藏按钮都能使用同一个 result。
7. 失败态要停住动画
抽取失败时继续播放动画,会让用户等到最后才看到空白。DrawingPage 应该在 Service 抛错后直接进入 failed,并提供重试或返回。错误提示也应该区分"题库不存在"和"题库没有答案"。
ts
@Builder FailedPanel() {
Column({ space: 12 }) {
Text(this.errorMessage || '暂时无法抽取答案')
Button('返回题库选择').onClick(() => this.pathStack.pop())
}
}
失败态是流程的一部分,不是兜底文案。只要业务可能失败,UI 就要给出明确出口。
8. 动画参数不要散落在 UI 里
转场时长、进度步长、结果停顿时间最好集中成常量。这样后续调节仪式感时,不会误改抽取逻辑。动画常量也能让真机调优更有依据。
ts
const REVEAL_TOTAL_MS: number = 1200;
const REVEAL_TICK_MS: number = 40;
private startRevealTimer(): void {
const startedAt: number = Date.now();
this.revealTimer = setInterval(() => {
this.revealProgress = Math.min(1, (Date.now() - startedAt) / REVEAL_TOTAL_MS);
if (this.revealProgress >= 1) {
this.phase = 'done';
clearInterval(this.revealTimer);
}
}, REVEAL_TICK_MS);
}
动画参数集中后,业务代码不需要因为"想慢一点"而被改动。
9. 验证与排障
DrawingPage 的验证要覆盖时间和中断:快速返回、连续进入、抽取失败、动画未结束时收藏、切换题库后再抽。尤其要看定时器是否清理,历史是否只写一次。
text
验证点:
1. 首页只负责 push DrawingPage
2. DrawingPage 进入后先拿 DrawAnswerResult
3. 返回页面后没有定时器继续打印日志
4. 抽取失败时 phase=failed,不播放到 done
5. 历史记录不因动画重复触发而写两次
如果页面偶发卡在 loading,优先看 phase 切换和 Service 异常处理,而不是只调动画时长。
还有一个需要在评审时说清楚的边界:返回不是业务失败。只要 Service 已经给出结果并写入历史,用户中途离开只代表不再观看本次动画。相反,如果 Service 还没有返回结果就离开页面,页面要清理定时器和挂起状态,不能在后台继续推进 UI。把这个边界写进状态机,后续调动画节奏时就不会误伤数据链路。
如果产品后来增加"再抽一次",也应该从 done 状态重新进入 drawing,而不是复用上一轮 result 改文案。每一次抽取都有独立结果、独立历史写入和独立动画周期,这样连续操作才不会把两次答案混在同一个页面状态里。
验证清单
- 清应用数据后从冷启动进入,确认默认数据、页面状态和日志分支符合预期。
- 对本文涉及的写路径准备正常、空值、重复、越界四类输入,确认错误停在 Service 或 Repository。
- 页面返回、重新进入、切换题库、收藏、历史或删除后,确认对应刷新信号触发重新读取。
- 修改资源或模块归属后重新构建,确认 HAP、HAR、HSP 的依赖方向没有反转。
- 涉及真机体验、备份恢复、发布素材的内容,单独记录是否已经在设备或平台侧验证。
常见问题与处理
| 现象 | 先看哪里 | 处理方式 |
|---|---|---|
| 返回后仍有动画日志 | aboutToDisappear 是否清理 timer | 记录并清理所有句柄 |
| 抽取失败仍显示翻牌 | 失败分支是否进入 failed | Service 抛错后停止动画 |
| 历史重复写入 | 是否在多个动画回调里 append | 只在拿到 DrawAnswerResult 后写一次 |
| 首页越来越复杂 | 首页是否承担抽取和动画 | 首页只传 DrawingParam |
小结
仪式感应该增强体验,不能接管业务。DrawingPage 用状态机隔离抽取结果和动画节奏,既能保留氛围,也能让数据链路可控。