HarmonyOS 展馆导览工程·第4篇:给页面计时器加上双重生命周期约束【共创稿事节】
展品详情准备增加一行轮换的观察提示:先看轮廓,再看纹样,最后比较结构。功能很小,资源边界却不能省略。只在组件创建时启动计时器,返回列表之后它可能仍然存在;只在应用进入后台时停止,详情被其他页面覆盖时又会继续工作。问题的核心是两个生命周期描述了不同事实,不能相互替代。

先排除三个看似合理的做法
第一个误区是把 aboutToAppear 当成“现在观众正在看这页”。组件挂载与目的页可见不是同一个事件,导航历史中可以保留不在前台显示的页面。第二个误区是只依赖 aboutToDisappear 清理:页面还没有销毁时,某项面向当前观看行为的工作已经没有必要继续。第三个误区是认为计时器属于页面,所以页面走了就会自然释放;重复计时器需要显式取消。
GalleryGuide 的需求可以写成一个简单条件:应用在前台,并且当前详情可见,并且展品有效,才允许轮换提示。三个条件中任何一个变成不满足,就停止计时器。它不是后台音频,不需要后台任务能力,也不应该因为未来可能播放声音就预先申请相关权限。本篇继续使用 API 12 及以上的普通 Stage 工程。
UIAbility 的 onForeground 与 onBackground 表达应用组件的前后台状态,NavDestination 的 onShown 与 onHidden 表达目的页的显示状态。把它们合并,是应用自己的资源策略;不能要求某一个回调单独覆盖所有情形。尤其在分栏布局中,两个区域的可见关系与单栏不完全相同,策略应围绕功能是否需要继续,而不是死记某种回调顺序。
本系列当前只有一个 EntryAbility,因此使用一个 AppStorage 布尔值传递前后台事实,页面以单向的 StorageProp 读取。它保存的是当前状态,不是一次性事件,也不是需要持久化的用户数据。若工程扩展为多个 UIAbility 或多会话窗口,应按会话隔离状态,不能让一个实例进入后台就停止另一个实例的工作。
在能力层发布状态,在页面层决定资源
将 entry/src/main/ets/entryability/EntryAbility.ets 替换为以下实现,保留模板模块中对 EntryAbility 的注册。加载失败记录只包含错误码,不携带展品内容。这里展示的是日志调用代码,没有把任何输出描述成已经产生的运行日志。
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(stage: window.WindowStage): void {
stage.loadContent('pages/Index', (error) => {
if (error.code) {
hilog.error(0x0101, 'GalleryGuide',
'loadContent error=%{public}d', error.code);
}
});
}
onForeground(): void {
AppStorage.setOrCreate<boolean>('galleryForeground', true);
}
onBackground(): void {
AppStorage.setOrCreate<boolean>('galleryForeground', false);
}
onDestroy(): void {
AppStorage.setOrCreate<boolean>('galleryForeground', false);
}
}
能力层没有引用详情组件,也没有保存它的计时器编号。它只更新自己的状态,由资源拥有者做出决定。这样页面离开、输入失效和应用切后台都可以汇合到同一段逻辑,而不是从多个位置直接操作一个共享计时器。这里也没有在 onBackground 中做磁盘写入,因为这个回调应保持轻量,不能把重要保存工作赌在短暂的后台切换时机上。
初始化为 false 是保守的起点。页面尚未得到前台事实时不会提前启动工作;如果能力层已先写入 true,StorageProp 建立连接后读取该值。页面仍然要在 onShown 中主动执行一次条件检查,不能只等待状态变化监听,因为“第一次读取到 true”与“之后从 false 变为 true”是两种不同路径。
前台标志只允许能力层更新。详情使用单向的 StorageProp,是为了表达它只消费事实,不通过修改本地字段去宣称应用已经进入前台。即便双向绑定在技术上能够工作,所有权也会变得模糊:一个页面修复自己的显示问题时,可能错误地修改其他页面共同依赖的条件。对于这类生命周期事实,限制写入方向比增加更多状态字段更有帮助。
也不要把这个标志纳入持久化。进程重新启动时,上次结束前保存的 true 并不能证明当前窗口已经可见;相反,生命周期回调会提供本次会话的真实状态。持久化适合需要跨会话恢复的业务意图,资源许可应重新由当前环境计算。把二者混合,容易在冷启动最早阶段提前启动不该运行的工作。
一个入口负责开始与停止
保留第3篇的资源文件和第2篇的导航布局,用下列组件替换 pages/Index.ets 中的 ExhibitDetail。数据导入仍使用第1篇的 Exhibit、findExhibit 与 missingExhibit。提示内容属于本地样例讲解文本,与界面资源外壳分开管理。
@Component
struct ExhibitDetail {
@State item: Exhibit = missingExhibit();
@State hintIndex: number = 0;
@StorageProp('galleryForeground') @Watch('syncTimer')
foreground: boolean = false;
private stack: NavPathStack = new NavPathStack();
private shown: boolean = false;
private timer: number = -1;
private hints: string[] = [
'先观察整体轮廓', '再留意纹样的重复', '最后比较不同部位的结构'
];
private syncTimer(): void {
if (!this.foreground || !this.shown || this.item.id.length === 0) {
this.stopTimer();
return;
}
if (this.timer >= 0) {
return;
}
this.timer = setInterval(() => {
if (this.foreground && this.shown && this.item.id.length > 0) {
this.hintIndex = (this.hintIndex + 1) % this.hints.length;
}
}, 4000);
}
private stopTimer(): void {
if (this.timer >= 0) {
clearInterval(this.timer);
this.timer = -1;
}
}
aboutToDisappear(): void {
this.shown = false;
this.stopTimer();
}
@Builder
detailTitle() {
Text($r('app.string.guide_detail_title')).fontSize(20)
.fontWeight(FontWeight.Medium)
}
build() {
NavDestination() {
Scroll() {
Column({ space: 18 }) {
if (this.item.id.length > 0) {
Text(this.item.title).fontSize(28).fontWeight(FontWeight.Bold)
Text(this.item.hall).fontSize(16).fontColor('#52616B')
Text(this.item.summary).fontSize(18).lineHeight(28)
Text(this.hints[this.hintIndex]).fontSize(17).fontColor('#147D92')
} else {
Text($r('app.string.guide_unavailable_title')).fontSize(28)
Text($r('app.string.guide_unavailable_body')).fontSize(18)
}
Button($r('app.string.guide_back_to_list'))
.onClick(() => this.stack.pop())
}.width('100%').padding(24).alignItems(HorizontalAlign.Start)
}.width('100%').height('100%')
}
.title(this.detailTitle)
.onReady((context: NavDestinationContext) => {
this.stack = context.pathStack;
const raw = context.pathInfo.param;
this.item = typeof raw === 'string'
? (findExhibit(raw) ?? missingExhibit()) : missingExhibit();
})
.onShown(() => {
this.shown = true;
this.syncTimer();
})
.onHidden(() => {
this.shown = false;
this.syncTimer();
})
}
}

开始与停止都需要幂等。多次调用 syncTimer 时,已经存在的计时器不能再创建;多次调用 stopTimer 时,已经清理的编号不能当成仍然有效。用负数表示当前没有计时器,是因为返回编号从非负整数范围使用,不能用“是否为零”判断。把编号置回无效值,也是保证下一次前台显示能够重新启动的关键步骤。
回调内部又检查一次条件,看起来与启动前判断重复,实际上覆盖不同的时间点。启动之后,页面可见性和应用前后台仍可能变化;条件检查让业务动作服从当前事实。取消计时器负责阻止后续调度,回调内检查负责约束执行时的业务许可。这里的动作只是切换一个索引,复杂任务还需要相应的取消或结果丢弃机制。
四秒是产品设定的提示轮换间隔,不是计时精度承诺。事件循环繁忙、应用状态和系统调度都会影响回调实际执行时刻。若业务需要计算真正经过的时间,应根据时间戳计算差值,而不是把回调次数乘以四秒。本例只改变观察提示,不统计参观时长,也不把这个计时器当作性能测量工具。
Watch 回调只协调资源,不反向修改 foreground。本例中的条件检查可以从页面显示、页面隐藏和能力状态变化三个方向触发,但它们最终进入同一个函数。若监听函数又修改被监听的字段,就可能形成难以追踪的同步循环。对这种短小状态机,保持一个事实输入、一个资源句柄和一个协调入口,比在每个回调里复制一遍创建逻辑更清楚。
提示索引在暂时隐藏时保留,重新可见后从原来的提示继续轮换;只有新建详情实例时才回到第一条。这是本篇选择的产品行为。如果要求每次回到前台都重置,应在明确的业务动作中设置索引,而不是顺手放进 stopTimer。停止资源与重置内容是两个动作,分开之后,页面切后台和用户切换展品才有机会采用不同策略。

沿着资源状态排查异常
如果提示一直不切换,先确认当前展品有效,再看页面是否进入显示状态,最后检查能力层是否更新前台值。不要直接删除条件判断“让它先动起来”,那会掩盖真正的状态传递缺口。正常路径应能解释为三个条件都成立、编号从无效值变为有效值;只看界面有文字,不能证明计时器应该运行。
如果返回详情后提示变化越来越快,优先怀疑重复创建。检查是否仍保留了旧的 aboutToAppear 启动逻辑,或者某次代码合并又在按钮里调用 setInterval。正确实现只有一处创建点,编号归同一组件实例所有。把所有创建点先收敛,再讨论动画或者渲染性能,通常比盲目调大间隔更有效。
如果页面返回后仍有资源活动,区分“页面隐藏”和“页面销毁”。导航动画与历史缓存会影响两者时机,不能期待销毁回调立即发生。面向可见页面的提示应在 onHidden 关闭;aboutToDisappear 是最终兜底。相反,某些必须跨页面完成的事务不能随隐藏就取消,需要把所有权移到更长寿命的服务,不能照抄此处策略。
验证过程按独立动作进行:打开有效详情并观察提示轮换,返回列表后再次进入;在详情切后台再返回;打开无效标识确认错误页不启动轮换;在可调整窗口中切换布局。每一步记录期望的资源状态,而不是记录预设的回调顺序。测试次数增加后,提示仍应保持单一节奏,不应出现多个实例叠加的迹象。
还应检查本地开发中的热重载和完整启动差异。修改组件结构后,临时开发状态不一定代表正式冷启动路径;遇到无法解释的计时器残留,应先用干净启动重复同一操作序列。这个动作不是用重启掩盖缺陷,而是区分开发工具状态与应用本身的资源所有权,再在稳定复现条件下定位。
清理路径还可以通过静态检查先缩小风险:确认每一次 setInterval 返回值都被保存,只有创建它的线程负责取消,取消之后立即失效句柄。详情中没有将组件实例存入全局数组,也没有把闭包交给长期存在的服务;如果后续引入这些结构,应继续检查它们是否延长组件寿命。定时器清掉了不代表所有引用都消失,资源清理与对象引用是相关但不同的检查项。
把清理边界留给资源拥有者
这套实现解决的是当前详情的轻量提示,不能直接推广成后台播放方案。音频、定位和长时任务分别有自己的状态机、权限与后台运行规则,需要使用对应能力。页面计时器的经验在于所有权、条件和清理闭环,不在于用一个定时函数代替系统服务。明确适用范围,才不会把简单示例带进错误的工程场景。
后续增加网络刷新时,也应先回答同样的问题:谁拥有请求,页面隐藏后是否还需要结果,返回时如何判断旧结果是否过期。计时器的编号与请求标识只是不同资源句柄,共同点是不能依赖组件“看起来不见了”推断工作自动终止。第5篇将转向列表增量更新,继续保持这条原则,让数据变化通过明确通知到达可见组件。
官方参考
更多推荐

所有评论(0)