茶器艺科智造HarmonyOS应用实战-59-animate从true切成false,星尘定时器为何不会停:让ArkUI Prop变化真正控制Canvas生命周期

ChaqiStarfieldCanvas 每 40ms 绘制 90 个粒子。animate 虽然是外部传入的 Prop,却只在启动时被读取,没有 Watch;因此页面仍可见时把它从 true 切到 false,已经创建的 interval 不会因为字段值变化而自动停止。

第59篇封面

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

一、Prop 变了,不代表 interval 会重新求值

静态源码显示,组件声明了 animate,并用 interval 周期绘制:

@Prop animate: boolean = true;
this.timerId = setInterval(() => this.drawFrame(), 40) as number;

创建 interval 的那一刻,框架只获得一个重复执行 drawFrame() 的回调。之后父组件更新 animate,字段值会变,但已注册的回调和定时器句柄不会凭空消失。若回调内部也没有再次判断开关,粒子绘制就继续运行。

这不是在断言设备已经出现耗电或掉帧,而是源码级控制流缺口:业务开关与运行时资源之间没有持续同步机制。性能影响需要 Profiler 或设备数据,不能由“40ms、90 粒子”直接推导出具体数值。

二、动画能否运行,至少取决于三道门

一个可控的 Canvas 循环不能只看 animate。还要知道画布是否可见,以及宽高是否已经可用:

第59篇流程图

条件为 false 时变为 true 时
animate停止现有循环再检查可见性和尺寸
canvasVisible停止现有循环再检查业务开关和尺寸
wPx > 0 && hPx > 0不应启动绘制在另外两门开放时启动

这三个条件共同决定“此刻是否应该有且仅有一个 interval”。把判断散在 onAppear、尺寸回调和父组件赋值中,很容易出现某条路径只会启动不会停止,或重复进入时创建多个句柄。更稳妥的做法是所有入口都调用同一个同步函数。

三、Watch 负责把 Prop 变化接到资源控制上

建议方案为 animate 增加 Watch,并在回调里统一决定启停:

@Prop @Watch('onAnimateChanged') animate: boolean = true;
private canvasVisible: boolean = false;
private onAnimateChanged(): void {
  if (!this.animate || !this.canvasVisible) { this.stopLoop(); return; }
  if (this.wPx > 0 && this.hPx > 0) this.startLoop();
}

这里虽然叫 onAnimateChanged,实质上更像“同步期望状态”。它不只在 Prop 改变时调用,也可以在 Canvas 出现、尺寸就绪后调用。函数读取最新的三道门,再落到 startLoop()stopLoop(),避免每个生命周期分别复制一套条件。

startLoop() 还必须幂等:已有有效句柄时直接返回。stopLoop() 同样要在清理后把句柄恢复到明确的“未运行”状态。否则连续收到相同 Prop 或多次 onAppear 时,即使有 Watch,也可能叠加多个 interval。

四、Canvas 可见性要与 Prop 同步走同一条路径

Canvas 出现和消失时更新可见标记,再调用同一个同步逻辑:

Canvas(this.ctx)
  .onAppear(() => { this.canvasVisible = true; this.onAnimateChanged(); })
  .onDisAppear(() => { this.canvasVisible = false; this.stopLoop(); })

onDisAppear 直接停止循环,是因为不可见已经足以否决运行;不必等待父组件把 animate 改为 false。再次出现时也不应无条件启动,而是重新检查当前 Prop 和尺寸。这样“页面可见但业务暂停”“业务允许但组件不可见”“画布刚出现但尺寸未就绪”三种状态都不会被混为一谈。

还应核对组件层与页面层生命周期的实际调用关系。本文代码是演进示意,未在模拟器或真机证明每次导航都按期望触发;最终要用日志验证出现、消失、Prop 更新和句柄变化的顺序。

五、把启停写成四状态转换更容易评审

当前状态事件条件动作目标状态
stoppedanimate=true可见且尺寸有效创建一个 intervalrunning
stoppedCanvas appearanimate 且尺寸有效创建一个 intervalrunning
runninganimate=false无需其他条件clearIntervalstopped
runningCanvas disappear无需其他条件clearIntervalstopped
running尺寸变化三门仍开放保持单一循环,后续帧读新尺寸running
stopped尺寸就绪animate 且可见创建一个 intervalrunning
任意重复同值事件期望状态未变不重复创建或清理原状态

状态表的关键不是事件多少,而是任意路径最后都满足不变量:running 时只有一个有效句柄,stopped 时没有句柄。若 drawFrame() 还依赖粒子数组初始化,则初始化就绪也应成为经源码核对后的前置条件,而不是由本篇凭空假设。

六、测试重点是事件排列,不是单个布尔值

原有边界用例骨架仍可用来描述输入和预期:

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' }
];

针对动画控制,应把测试输入换成事件序列,例如 appear→size ready→truerunning→falsefalse→disappear→appeartrue→true→true。断言对象不是“函数调用过”,而是活动句柄数量、绘制次数是否继续增长,以及停止后是否不再进入新一帧。

纯逻辑测试可以覆盖三道门的决策函数和 start/stop 幂等性。Canvas 是否真的停止绘制、定时器在前后台如何被调度、设备功耗是否变化,必须由模拟器或真机证据回答。

七、timerId 的所有权必须留在创建它的组件

资源清理应与创建成对,且不能依赖外部“顺手”把 Prop 复位:

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

本篇最重要的是第一行:谁调用 setInterval,谁保存并清理 timerId。页面退出、组件消失和业务开关关闭都可以触发清理,但最终都落到同一个 stopLoop()。timerId 是进程内资源,不属于需要持久化的业务事实,也不应跨组件共享。

若 interval 回调可能已经进入执行,stopLoop() 只能阻止后续调度,不能让正在运行的函数倒退。绘制函数应读取当前尺寸与可见状态,必要时在入口快速返回;是否需要代次防护,要根据实际异步边界判断。

八、验证时要同时看句柄和绘制计数

  1. 初次出现且 animate=true:尺寸就绪后只创建一个 interval。
  2. 运行中改为 false:句柄清空,之后绘制计数不再增长。
  3. 保持可见再改回 true:重新创建一个且只有一个 interval。
  4. 连续写入多次 true:不会叠加循环。
  5. 运行中离开页面:即使 Prop 仍为 true,也停止循环。
  6. 在不可见时切换 Prop:不会提前启动;重新出现后按最新值决策。
  7. 宽高为零时不启动,尺寸有效后再同步一次状态。

日志建议记录事件名、animatecanvasVisible、宽高、句柄是否存在和累计帧数,不记录每个粒子的完整坐标,以免调试日志本身干扰性能测量。

九、四种“看起来能停”的修补并不完整

误修为什么不够正确方向
回调里每帧判断 animate,但不清 timer停止绘制却仍持续唤醒回调关闭时清理 interval
只在页面销毁时清理页面可见且 animate=false 时仍运行Watch 连接 Prop 与 stopLoop
每次 true 都 setInterval重复赋值可能叠加句柄startLoop 幂等
用更大的间隔降低影响没有修复控制关系先保证启停正确,再谈频率
只看画面是否静止无法确认后台回调已停止观察句柄与帧计数

因此“星星不动了”不是充分证据。可能只是 drawFrame 早退、画布被遮挡或粒子变化不明显;只有句柄清理和调用计数一起记录,才能说明循环生命周期真正收口。

十、动画决策与绘制实现应保持分层

第59篇结构图

项目中 entry 负责产品入口,libraryhsp 负责页面和交互,libraryhar 负责模型、算法、路由与通用服务。持有 Canvas context、ArkUI 状态和 timerId 的逻辑应留在组件侧;若要提取纯函数,可以只提取“给定三道门,期望 running 还是 stopped”的判断,避免共享层反向依赖 UI 生命周期。

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

粒子计算是否值得继续拆分,需要结合现有源码和性能证据。本文问题仅是 Prop 变化没有控制 interval,不借此声称缓存、离屏绘制或其他优化一定有效。

十一、当前证据只到静态控制流

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

从基线可确认的是 40ms interval、90 个粒子、animate Prop 与缺少 Watch 的关系。尚未执行构建,也没有模拟器、真机、帧率、CPU 或功耗数据。后续实现若通过编译,仍只能说明 API 与类型可接受,不能等同于生命周期在设备上已验证。

十二、用一条往返序列证明开关真的可逆

最小回归应走完整往返:组件出现、尺寸就绪、开始绘制;保持页面可见,把 animate 从 true 改成 false;等待超过若干个 40ms 周期,确认帧数不再增加;再改回 true,确认只有一个循环恢复。之后离开并返回,重复检查。

场景操作序列必须观察的证据不能替代的证据
初次启动appear→size ready→true一个句柄、帧数增长星尘可见截图
业务暂停running→false句柄清空、帧数停止画面看似静止
再次启动stopped→true新建一个句柄帧数偶尔变化
重复事件true→true→appear仍只有一个句柄没有立即崩溃
离开页面running→disappear句柄清空页面已不可见
零尺寸appear→0x0→true不启动,尺寸就绪后再启没有绘制异常

若需要把一次启停过程加入统一追踪,可复用操作轨迹结构:

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;
}

对本篇更直接的字段可以是组件实例标识、start/stop 次数和当前句柄状态。引入 generation 只有在确有迟到异步结果时才有价值,不应为了套模板增加无用状态。

最终收口标准不是“给 Prop 加了 Watch”这一行,而是三道门始终通过同一决策路径控制唯一 interval,任何关闭条件都能停止,恢复时也不会叠加。本文仍是基于固定提交的建议,运行结果待后续补证据。

Logo

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

更多推荐