茶器艺科智造HarmonyOS应用实战-66-启动屏和布局补偿的setTimeout没有句柄,页面离开后为何仍可能回写状态:用generation封住延迟回调

页面进入时安排了两次延迟动作:120ms 后复查布局,1300ms 后隐藏启动层。两次 setTimeout 都没有保存句柄,因此页面若在等待期间离开,无法主动取消;如果用户很快再次进入,旧一轮回调还可能与新一轮页面状态交错。本文不讨论延迟数值是否合适,而是把回调的所有权和有效代际补完整。

第66篇封面

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

一、两条匿名延迟回调跨过了页面边界

静态源码表明,aboutToAppear 安排布局补偿和启动层隐藏,但没有把返回的 timerId 存下来。当前代码可以概括为:

setTimeout(() => this.applyLayoutByDisplay(), 120);
setTimeout(() => { this.splashOverlayVisible = false; }, 1300);

第一条回调会再次调用布局逻辑,第二条会直接写 splashOverlayVisible。当页面在 120ms 或 1300ms 前离开时,组件没有句柄可清;当页面很快重新进入时,上一轮与下一轮回调也没有身份标记可区分。静态源码足以确认这个所有权缺口,但不能据此声称某台设备已经发生迟到回写,因为具体时序仍受页面行为和运行环境影响。

二、句柄取消与 generation 校验是两道防线

setTimeout 这类可取消资源,第一道防线是保存句柄,并由创建它的页面在离开时 clearTimeout。第二道防线是 pageGeneration:每次进入产生新代际,回调执行前比较捕获值与当前值,不一致就直接返回。离开时先让旧代际失效,再清理所有已知句柄。

第66篇流程图

两道防线解决的不是同一件事。clearTimeout 尽量让工作不再发生,减少无用布局计算与状态写入;generation 校验处理已经进入队列、未来扩展到难取消异步、或句柄清理路径漏过时的迟到结果。只加 generation 会让旧 timer 仍然存活到触发,只清句柄则把正确性完全押在资源登记无遗漏上。

流程图应明确所有权:页面进入创建本轮 generation 和两个 timer,页面离开让本轮失效并释放 timer;回调只在“本轮仍有效”时执行自己的那一个动作。布局复查与启动层隐藏虽然延迟不同,却遵循同一套代际规则。

三、先把两个 timer 变成可寻址资源

最小状态需要一个页面代际和两个语义明确的句柄:

private pageGeneration: number = 0;
private layoutRetryTimerId: number = -1;
private splashTimerId: number = -1;

分别命名比塞进一个模糊数组更适合当前只有两条固定回调的场景:layoutRetryTimerId 对应 120ms 布局复查,splashTimerId 对应 1300ms 启动层隐藏。初始值 -1 只是“当前无已登记句柄”的约定;创建、触发、取消后都应按同一约定复位,避免后续清理把过期编号当作活动资源。

这些字段只能存在于当前进程。它们不需要持久化,也不能用来证明页面动作已经完成;真正需要持久化的业务数据应另行建模。本篇的启动层可见性和布局补偿都是页面状态,因此重点是离开后禁止旧轮次继续写,而不是跨进程恢复 timerId。

四、回调闭包必须捕获创建时的代际

保存句柄后,布局回调还要捕获本轮 generation,并在执行前核对:

const generation = ++this.pageGeneration;
this.layoutRetryTimerId = setTimeout(() => {
  if (generation !== this.pageGeneration) return;
  this.applyLayoutByDisplay();
}, 120) as number;

同样的守卫也应放到 1300ms 的启动层回调中,检查通过后才允许把 splashOverlayVisible 写成 false。捕获值必须是安排回调那一刻的局部常量,不能在回调里重新读取并保存当前 generation,否则旧回调会把自己伪装成最新一轮。

接入顺序可以保持克制:增加三个字段;在 aboutToAppear 建立新代际并登记两条句柄;在两个回调开头校验;在离开路径使 generation 失效并清句柄。杯型、切片、商城和档案页面不属于这条延迟链,不应借机改动。

五、用时间窗口检查旧回调是否还有写权限

页面时序120ms 布局回调1300ms 启动层回调generation 结果
正常停留超过 1300ms本轮有效,可复查布局本轮有效,可隐藏启动层捕获值等于当前值
120ms 前离开句柄应被清;即使触发也拒绝句柄应被清;即使触发也拒绝离开后已失效
120ms 后、1300ms 前离开布局动作可能已合法完成启动层回调应取消或拒绝离开后已失效
离开后快速再次进入旧回调不可作用于新页面旧回调不可隐藏新轮启动层新旧代际不同
新一轮正常到期仅新布局回调可执行仅新启动层回调可执行新捕获值匹配

这张表的重点是“写权限”而非“timer 有没有触发”。在竞态边缘,是否成功取消可能依赖回调与离开事件的先后,但只要 generation 在写状态前再次校验,旧轮次就没有资格改动新轮页面。反过来,如果当前 generation 匹配,也仍需确认页面动作本身属于本轮生命周期。

六、把代际判定抽成纯逻辑用例

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

上面的通用边界骨架可以把输入改成 { capturedGeneration, currentGeneration }。相等时预期允许执行,不相等时预期拒绝;再组合“首次进入、离开、再次进入”的整数序列,确认任何旧捕获值都不能重新变成当前轮次。generation 应单调推进,不能在每次进入时重置为固定值。

timer 部分还要用可控时钟或明确的操作步骤验证 120ms 与 1300ms 两个窗口:提前离开、恰在第一条之后离开、以及旧页面离开后立即重进。纯函数测试只能验证代际比较;clearTimeout 是否登记完整、ArkUI 生命周期何时触发、页面是否出现可见闪动,仍需模拟器或真机证据。

七、创建、触发和离开都要更新句柄状态

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

这段通用清理接口需要在本篇落到两个明确句柄上:谁在 aboutToAppear 创建,谁就在对应离开路径清理。每条回调自然触发后,也应把自己的字段复位;否则调试信息会误以为句柄仍活动。清理方法应具备幂等性,重复离开或回调先到都不应造成第二次业务写入。

aboutToAppear/aboutToDisappear、组件级 onAppear/onDisAppear 与页面级 onPageShow 触发范围不同,具体挂载点必须跟当前源码核对。generation 的递增和 timer 的创建/清理应绑定同一层级,避免进入在页面级、清理却只发生在内部组件级。本文没有运行生命周期日志,因此这里只给出审查原则,不声称某个挂载点已验证正确。

八、围绕 120ms 和 1300ms 设计验证

  1. 搜索两处原始 setTimeout,确认返回句柄都被登记,两个回调都捕获并校验 generation。
  2. 页面正常停留超过 1300ms,确认布局复查和启动层隐藏各执行一次,句柄字段随后复位。
  3. 120ms 前离开,确认两条句柄均清理,旧回调没有状态写入日志。
  4. 120ms 后、1300ms 前离开,确认已完成的布局动作不重复,旧启动层回调无权写入。
  5. 离开后立即重进,记录旧、新 generation;等待超过 1300ms,确认只有新一轮回调改变新页面。
  6. 连续多次进入/离开,确认 timer 数量不累积,旧代际始终被拒绝。
  7. 静态检查、构建、模拟器和真机分别记录;未执行项继续明确标注。

九、延迟回调治理的五个误区

误修问题正确方向
把 120ms/1300ms 再调大只是改变竞态窗口,没有所有权保存句柄并校验 generation
只保存 timerId漏清理或难取消结果仍可迟到句柄取消与代际校验并用
只加 generation旧 timer 仍会存活到触发可取消资源继续主动清理
回调里读取当前 generation 当作捕获值旧回调永远看起来是最新安排回调时保存局部常量
离开时只隐藏启动层旧布局回调仍可能运行清理页面拥有的全部延迟资源

排障时先画出“哪次进入创建、哪次离开失效、哪个回调准备写什么”,再看延迟数值。启动层看起来按时消失,不代表旧回调没有碰到新轮页面;页面没有崩溃,也不等于资源所有权正确。

十、generation 应贴近页面所有者

第66篇结构图

按本文静态基线,entry 负责产品入口,libraryhsp 承担页面和交互,libraryhar 放置模型、算法、路由与通用服务。pageGenerationlayoutRetryTimerIdsplashTimerId 都与具体 ArkUI 页面生命周期绑定,应由实际创建回调的 HSP/entry 对象持有;比较 generation 的纯函数可以独立测试,但不必为了复用把页面句柄搬进 HAR。

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

结构图中的 runtime evidence 应重点记录生命周期事件、generation、timer 名称和是否执行写入。无需打印完整页面状态,更不能因为日志出现一次 clearTimeout 就推断所有迟到回调均已被阻止。

十一、静态结论与运行结论分开

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

本文能确认两处匿名 setTimeout、120ms/1300ms 延迟值以及相应的状态写入边界,也给出了保存句柄和 generation 守卫的最小方向。本文没有修改项目源码,没有观察真实生命周期顺序,也没有证明启动层或布局在设备上的视觉结果。构建、模拟器、真机、账号、网络和性能结果均未执行。

十二、用“进入一、离开、进入二”复现迟到写入

复现要把两次进入明确命名为 generation 1 和 generation 2。进入一安排两个 timer,在指定窗口内离开,再立即进入二。之后即使 generation 1 的回调仍尝试执行,也必须在写 splashOverlayVisible 或调用布局复查前被拒绝;generation 2 的对应回调则按本轮时序正常工作。

场景操作序列必须观察的证据不足以作为结论的现象
正常停留进入一→等待超过 1300ms两条本轮回调各执行一次,句柄复位启动层最终不可见
极快离开进入一→120ms 前离开generation 1 失效,两句柄清理页面没有崩溃
中间窗口进入一→120ms 后、1300ms 前离开布局动作至多一次,启动层旧写入被拒绝返回动作顺畅
快速重进进入一→离开→立即进入二旧回调记录 rejected,新回调记录 applied最终页面看起来正常
多轮往返连续重复进入/离开generation 单调变化,活动句柄不累积内存曲线暂时平稳

日志至少包含 timer 语义名、捕获的 generation、当前 generation、计划时间、实际触发时间以及 applied/rejected/cleared 结果。这个页面场景不需要借用 orderId;字段越贴近实际所有权,越容易从日志还原哪一轮回调越界。

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 可对应 layout-retrysplash-hidecanApplyResult 的 generation 比较正是最后一道写入门。评审还要检查:两个回调是否分别登记、自然触发后是否复位句柄、离开时是否先失效再清理、再次进入是否捕获新值。只要有一条回调绕开守卫,旧页面仍可能越过边界。

真实验收建议保留进入/离开的时间点、带 generation 的日志和关键画面。静态截图看不出迟到写入,编辑器预览也不能替代设备生命周期。本文基线没有执行这些运行步骤,因此结果栏仍保持“待验证”。

补充审查要求:120ms、1300ms、字段名和生命周期挂载点都要与变更时仓库核对;若源码、SDK 版本或页面结构变化,应重新建立基线,不沿用本文快照。没有执行的构建、设备、网络和账号步骤继续标记为未执行。

Logo

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

更多推荐