茶器艺科智造HarmonyOS应用实战-62-每42ms加5%,后台节流后切片为何忽快忽慢:让模拟进度由经过时间而不是回调次数决定

切片模拟把“定时器回调了一次”直接换算成“进度增加 5%”,再预约 42ms 后执行下一次。只要回调发生得不准时,进度对应的真实时长就会改变:回调密集时显得快,系统繁忙或后台节流时又会拖长。

第62篇封面

本文以 Gitee 仓库 aiding521/chaqi-app-giteemaster 分支、提交 f671fcd8de277973bd7518a3c462f68384575f1e 的静态源码为事实基线。本文没有修改项目源码,也没有执行 HAP 构建、模拟器或真机验证;建议代码属于演进方案,不能描述成已经落地的功能。

一、问题不在 42ms,而在把回调次数当成时间

静态源码显示,进度每进入一次 tick 就固定增加 5,随后通过 setTimeout 预约下一次 tick。当前代码可以概括为:

this.sliceJobProgress += 5;
this.sliceSimTimerId = setTimeout(() => this.runSliceSimTick(), 42) as number;

这相当于假设每次回调都严格相隔 42ms。实际上,定时器参数表达的是最短等待要求,不是精确执行时刻;主线程忙、页面进入后台或系统调度发生变化,都可能让实际间隔变长。即使只漏掉一次准点回调,“5%”代表的时间也已经变化。

静态阅读只能确认“按次数累加”的结构,不能据此断言某台设备会慢多少,也不能给出后台节流的固定比例。本文要收口的是时间模型:切片模拟的业务进度应由已经经过的时间决定,timer 只负责唤醒页面刷新。

二、先确定后台语义,再把时钟与刷新分开

建议记录开始时间与目标时长,每次被唤醒时按 elapsed / duration 重新推导进度。这样少执行几次回调只会让中间画面少刷新几帧,不会让任务凭空多花几段 42ms。与此同时,产品必须明确页面进入后台后的含义:切片模拟是按墙上时间继续,还是暂停并在回来时续算。

第62篇流程图

流程图应区分两个角色。时钟提供“现在”和“开始时刻”的差值,是业务进度的来源;setTimeout 只是建议何时再次刷新,是可丢弃、可重建的进程句柄。如果选择“后台继续”,回到前台后按当前时间一次校准;如果选择“后台暂停”,则需要记录暂停区间并从 elapsed 中扣除。两种语义不能靠回调次数偶然决定。

三、核心公式只依赖 now、start 和 duration

private updateSliceProgress(nowMs: number): void {
  const ratio = Math.min(1, (nowMs - this.sliceStartedAtMs) / this.sliceDurationMs);
  this.sliceJobProgress = Math.round(ratio * 100);
  this.sliceLayer = Math.round(ratio * this.sliceTotal);
}

ratio 先计算经过时间占目标时长的比例,再用 Math.min(1, ...) 封顶。进度和切片层数都从同一个比例推导,因此即使某次回调晚到,两个展示值也不会各自按不同次数漂移。nowMs 作为参数传入,还让公式能够用确定输入复算,而不是在纯逻辑内部偷偷读取时间。

这段演进代码仍需要调用方保证 sliceDurationMs 是有效正数,并决定开始前、到期后以及取消时如何处理。Math.round 只影响离散展示,不改变时间事实;若产品希望层数只增不减或采用其他取整方式,也应在同一个映射函数里明确,而不是重新引入另一套计数器。

四、timer 只负责叫醒页面,不再携带进度

this.sliceSimTimerId = setTimeout(() => {
  this.updateSliceProgress(Date.now());
  if (this.sliceJobRunning) this.scheduleNextSliceFrame();
}, 50) as number;

回调被触发后先读取当前时间并刷新一次,再依据 sliceJobRunning 决定是否预约下一帧。此时把 50 改成 42、60 或其他刷新间隔,只会影响画面更新频率,不应再改变任务预计完成时刻。任务是否完成也应由 ratio 到达 1 的终态决定,完成后停止继续预约。

接回页面时需要清理原来“每 tick 固定加 5%”的唯一写入口,避免新旧模型同时修改 sliceJobProgress。但修改范围仍应限定在切片模拟的开始、刷新、暂停/恢复和结束路径,不顺手改变杯型、商城或档案页面。

五、状态表要记录时间事实,而不是累计了几次

阶段必要时间事实刷新句柄动作进度如何得到
未开始无开始时刻不创建 timer固定为初始值
运行中startedAtMsdurationMs预约下一次唤醒elapsed / duration
进入后台取决于继续或暂停语义可清理当前 timer不用回调次数补算
回到前台当前时刻,必要时含暂停累计重建一个 timer立即按时间校准
已完成比例已到 1不再预约固定为 100%
已取消取消终态清理 timer不接受迟到回调

表中最重要的是“运行中”与“已完成”的边界。timer 句柄存在并不表示任务仍运行,timer 句柄缺失也不表示业务时间停止。只要 startedAtMsdurationMs 和后台语义明确,页面就能在任意一次唤醒时重建正确进度。

六、用虚拟时刻覆盖跳帧与迟到回调

interface BoundaryCase<T> {
  name: string;
  input: T;
  expected: string;
}

const cases: BoundaryCase<string>[] = [
  { name: 'empty', input: '', expected: 'reject' },
  { name: 'normal', input: 'valid', expected: 'accept' },
  { name: 'repeat', input: 'valid', expected: 'idempotent' }
];

通用用例骨架落到本文时,input 应包含 nowMsstartedAtMsdurationMsexpected 则是比例、百分比和层数。可以连续传入开始时刻、四分之一时刻、结束时刻以及远晚于结束的时刻,确认结果从 0 向 100 推进并在终点封顶。还应直接从 10% 跳到 80%,模拟中间许多回调没有发生,结果仍由时间决定。

重复传入同一个 nowMs 应得到相同结果;两个不同刷新频率只要观察时刻相同,也应得到相同进度。纯逻辑无法证明系统后台时具体怎样调度 timer,但可以证明无论唤醒次数怎样变化,公式不会把“少回调”误判成“少经过时间”。

七、离开页面时清句柄,回来时先校时

private disposeOwnedResources(): void {
  // timer/listener只由创建它的组件清理
  // 异步结果写状态前核对taskId或pageGeneration
  // 可恢复任务先保存业务事实,再释放进程句柄
}

组件创建 timer 后就拥有清理责任。页面离开、任务完成、任务取消或新任务替换旧任务时,都应清掉当前句柄,避免多个刷新循环并存。仅把 sliceJobRunning 改成 false 不一定能取消已经排队的回调,因此迟到回调写状态前仍要核对任务身份或 generation。

回到前台的第一步不是补执行若干次 +5,而是读取当前时刻并调用一次时间公式。若产品选择后台继续,跳过的时间会一次性反映到进度;若选择后台暂停,则应先更新累计暂停时长,再恢复计算。页面生命周期能补偿节流,但进程重启是否需要恢复,还取决于这些时间事实是否持久化,本文不把两件事混成一个承诺。

八、验收重点是同一时刻不受回调频率影响

  1. 任务开始时记录唯一的开始时刻与有效目标时长,不再以 tick 计数作为事实。
  2. 在相同 nowMs 下,用 20 次小步刷新和 2 次大步刷新得到相同百分比。
  3. 回调比预期晚到时,进度按经过时间跳到正确位置,而不是只增加 5%。
  4. nowMs 晚于目标结束时刻后,进度封顶为 100%,不继续预约刷新。
  5. 页面离开后句柄被清理,旧回调不再修改新任务或已销毁页面。
  6. 回到前台先执行一次校时,再创建至多一个新的刷新循环。
  7. 后台继续或暂停的产品语义有明确记录,两条路径分别验证。
  8. 构建、模拟器、真机和前后台行为分别留证;没有执行的项目继续写“未执行”。

九、这些改动只是换了数字,没有修正时间模型

误修为什么仍会漂移正确方向
把 42ms 改成更大的值仍假设每次都准点执行用 elapsed 推导比例
每次增加更小的百分比只是把误差切得更细百分比不再由次数累加
回前台补跑若干 tick不知道究竟错过了多久用当前时刻一次校准
同时启动两个刷新循环两个回调竞争写同一进度任务只拥有一个句柄
到 95% 后固定等待用视觉掩盖时间来源由明确终点产生完成态

调低刷新频率可以省一些绘制,调高刷新频率可以让动画更平滑,但二者都不应改变完成时间。若修改一个 timer 间隔就能改变业务进度,说明刷新机制仍在冒充时钟。

十、公式适合下沉,timer 仍属于页面运行时

第62篇结构图

按本文已有模块关系,entry 负责产品入口,libraryhsp 负责页面与交互,libraryhar 负责模型、算法、路由及通用服务。由 nowMsstartedAtMsdurationMs 计算比例和层数的函数不依赖 ArkUI,可放进 HAR 并接受确定输入;sliceSimTimerId、页面可见性和对 @State 的写入仍应留在 HSP/entry。

这样的切分让公式测试不需要等待真实的 42ms,也让页面更换刷新频率时无需修改业务算法。若以后选择把任务时间事实持久化,可以继续复用同一个推导函数;但 timerId 仍不能持久化,因为它只在创建它的当前进程中有意义。

entry / libraryhsp -> 页面、组件、生命周期
libraryhar        -> 纯类型、纯函数、可持久化模型
tests             -> 边界、幂等、恢复序列
runtime evidence  -> 日志、设备行为、服务读回

十一、证据边界:源码能证明计数模型,不能量出设备误差

baseline: f671fcd8de277973bd7518a3c462f68384575f1e
scope: source-level proposal
build: not run
simulator/device: not run
network/account: not run
result: static boundary identified; implementation pending

本文基线能确认固定 +5 和 42ms 后再次预约的源码形态,也能据此指出回调次数与经过时间被绑定。它不能证明某一设备实际用了多少毫秒完成,更不能证明前后台切换时系统一定采取某种调度策略。建议代码展示的是时间驱动的收口方向,不是已经落地的运行结果。

十二、用时间轴复现“少回调但不少时间”

验证时应同时记录计划刷新时刻、实际回调时刻和公式输出,而不是只盯着进度条是否平滑。最有价值的场景是主动制造较大的回调间隔:若中间少了多次刷新,下一次进度应直接追上已经经过的时间,而不是只向前挪 5%。

场景操作序列必须观察的证据不能作为结论的替代物
正常刷新启动→按常规间隔刷新→完成实际时刻与推导比例对应动画肉眼流畅
人为迟到启动→延后一次回调→继续下一帧按 elapsed 跳转回调总数接近预期
后台继续启动→进入后台→稍后返回返回即按当前时间校准在后台看不到卡顿
后台暂停启动→后台→返回并续算暂停区间未计入 elapsed进度没有到 100%
快速重启任务A 运行→立即开始 BA 的回调不改写 B最终只剩一个 timerId

日志至少包含任务身份、页面 generation、startedAtMs、本次 nowMs、目标时长、计算比例和是否继续预约。只有这样,才能区分“刷新少了一帧”和“业务时钟本身走错”这两种完全不同的问题。

interface OperationTrace {
  taskId: string;
  generation: number;
  startedAtMs: number;
  finishedAtMs?: number;
  terminalSource?: 'ui' | 'service' | 'restore' | 'cancel';
}

function canApplyResult(trace: OperationTrace, currentGeneration: number): boolean {
  return trace.generation === currentGeneration &&
    trace.finishedAtMs === undefined;
}

在本文场景里,taskId 用来区分先后两次切片模拟,generation 防止旧页面回调写入新页面。重复回调若携带相同 nowMs,公式应得到相同结果;顺序颠倒的旧回调即使算出一个比例,也应因身份不匹配而被拒绝。任务完成后再到达的回调不能重新把状态改回运行中。

截图能展示某一刻的百分比,却不能证明总时长稳定。真正需要对比的是多种刷新间隔、前后台序列下的开始时刻、结束时刻和进度轨迹。本文基线没有执行构建、模拟器、真机或计时实验,因此相关结果仍保持“未执行”。

补充审查要求:目标时长、层数映射、后台继续或暂停的语义,都要与后续源码和产品规则重新核对;若 SDK 或仓库版本变化,应重新建立基线,不沿用本文快照。对没有执行的构建、设备、前后台和性能步骤继续标记为未执行。

Logo

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

更多推荐