【三国志 App 实战系列 13】HarmonyOS Stage 生命周期实战:AppStorage 状态桥接与听书恢复
当前应用已上架鸿蒙应用商店,搜索《耳畔三国·将星落》下载,欢迎各位看官尝鲜、吐槽!拜谢!
系列第 13 篇。上一篇讲 BackupExtensionAbility,把本地用户数据纳入系统备份恢复边界;这一篇继续往运行态看:应用进入后台、回到前台时,听书状态应该由谁通知、谁恢复、谁避免误停?

一、真实问题背景:前后台不是一个页面事件
《耳畔三国·将星落》里有一个长文本听书模块:人物传记、历史事件和专题文章都可以进入听书队列,用户可以暂停、续播、切后台,也可以通过系统媒体控制继续播放。
最初排查后台听书问题时,我遇到的现象不是“完全不能播放”,而是更隐蔽的几类状态错位:
| 现象 | 表面表现 | 实际风险 |
|---|---|---|
| 切后台后 TTS 被系统暂停 | 页面仍显示播放中 | 进度和声音脱节 |
| 回到前台后重复恢复 | 当前段落被重新朗读 | 用户听到跳段或重复 |
页面 aboutToDisappear 误判 |
普通页面消失就释放资源 | 切后台时把正在播放的 TTS 关掉 |
| 媒体会话状态落后 | 锁屏控制显示暂停 | 用户以为播放失败 |
这些问题说明:前后台切换不是某一个页面的局部事件。Stage 模型下,真正可靠的入口应该来自 UIAbility.onForeground() 和 UIAbility.onBackground(),再把状态桥接到页面。
二、本文目标与边界
本文只讲生命周期桥接和听书恢复链路,不重复第 8 篇后台播放权限、AVSession 创建细节,也不重复第 9 篇 TTS 并发控制。
本篇关注四件事:
EntryAbility.ets如何把 Ability 生命周期写入全局状态。MainFrame.ets如何用@StorageLink('appLifecycleState')监听前后台变化。- 后台进入、前台恢复时分别做哪些动作。
- 为什么不要在普通页面
aboutToDisappear()里粗暴关闭 TTS。
当前验证环境是 HarmonyOS NEXT / ArkTS 项目,DevEco Studio 本地构建,日志来自真机/模拟器运行期间的 hilog 抓取。不同系统版本的后台策略可能有差异,因此本文把结论限定为“当前项目实测可用的状态桥接方式”。
这篇文章的源码证据链不是从“页面看起来还能播放”开始,而是从仓库里能定位到的对象开始:
rg -n "onForeground|onBackground|appLifecycleState|StorageLink|handleAppLifecycleStateChange" entry library2
这条命令在当前项目里命中的关键结果是:
entry/src/main/ets/entryability/EntryAbility.ets:39: onForeground(): void {
entry/src/main/ets/entryability/EntryAbility.ets:45: onBackground(): void {
library2/src/main/ets/pages/MainFrame.ets:197: @StorageLink('appLifecycleState')
library2/src/main/ets/pages/MainFrame.ets:199: appLifecycleState: string = 'foreground';
library2/src/main/ets/pages/MainFrame.ets:397: private handleAppLifecycleStateChange(): void {
也就是说,本篇讨论的不是抽象生命周期,而是一个可以被 rg、hilog 和页面状态共同验证的工程链路。它的输入来自 Ability,传播媒介是 AppStorage,消费方是听书页,输出结果是后台保活、前台恢复和进度落盘。
| 核验对象 | 本文使用的证据 | 说明 |
|---|---|---|
| 生命周期入口 | EntryAbility.ets |
确认前后台信号来源唯一 |
| 页面订阅 | MainFrame.ets 的 @StorageLink |
确认页面不是轮询状态 |
| 运行日志 | hilog_background_final.txt |
确认真机/模拟器运行时序 |
| 用户可见状态 | 听书页截图 | 确认文章不是纯 API 摘录 |
| 发布质量 | 本地预检和 CSDN QC | 确认文稿结构与线上评分 |
三、源码对象:生命周期信号从 Ability 开始
先看入口 Ability。项目里没有让页面自己猜前后台,而是在 entry/src/main/ets/entryability/EntryAbility.ets 中写入全局状态:
onForeground(): void {
// Ability has brought to foreground
AppStorage.setOrCreate<string>('appLifecycleState', 'foreground');
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
// Ability has back to background
AppStorage.setOrCreate<string>('appLifecycleState', 'background');
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
}
这段代码很小,但它解决了一个关键问题:生命周期来源只有一处。页面不需要从路由、组件销毁、媒体回调里拼凑“现在是不是后台”,而是订阅同一个 appLifecycleState。
四、页面侧桥接:@StorageLink 让状态变化进入 ArkUI
页面侧在 library2/src/main/ets/pages/MainFrame.ets 中建立桥接:
@StorageLink('appLifecycleState')
@Watch('handleAppLifecycleStateChange')
appLifecycleState: string = 'foreground';
这比在多个函数里手动读取全局变量更稳。原因有三个:
| 设计点 | 作用 | 如果不用会怎样 |
|---|---|---|
@StorageLink |
页面状态和 AppStorage 同步 | 页面需要轮询或手动刷新 |
@Watch |
状态变化立即进入处理函数 | 前后台动作散落到多个回调 |
默认 foreground |
首次渲染有确定状态 | 启动初期容易出现空状态判断 |
这条桥接链路的核心不是“少写几行代码”,而是让 Ability 生命周期变成页面可响应的状态。
五、后台进入:先保进度,再保播放资格
当状态变成 background,页面不会直接停掉 TTS,而是判断当前是否正在播放:
private handleAppLifecycleStateChange(): void {
if (this.appLifecycleState === 'background') {
if (this.isPlaying) {
this.lastBackgroundEnteredAt = Date.now();
this.backgroundTransitionTtsRequestId = this.currentTtsRequestId;
setTimeout(() => {
if (this.appLifecycleState === 'background' && this.isPlaying) {
this.keepAudioPlaybackInBackground('ability background', true);
}
}, 0);
}
return;
}
this.recoverInterruptedPlayback();
}
这里有两个细节值得保留。
第一,进入后台时记录 lastBackgroundEnteredAt 和 backgroundTransitionTtsRequestId。这两个字段后面会用于判断“刚切后台时收到的 pause/complete 是真实用户操作,还是系统切换噪声”。
第二,用 setTimeout(..., 0) 把后台保活动作放到状态提交之后执行。这样可以避免同一轮回调里状态还没稳定,就开始申请后台任务和刷新媒体状态。
六、keepAudioPlaybackInBackground 的职责拆分
后台保活函数不是只申请后台任务,它先同步进度,再延迟落盘,然后启动后台监听:
private keepAudioPlaybackInBackground(reason: string, force: boolean = false): void {
if (!this.canContinueAudioPlayback()) {
return;
}
const now: number = Date.now();
if (!force && now - this.lastBackgroundKeepAt < 800) {
return;
}
this.lastBackgroundKeepAt = now;
this.syncAudioProgressNow(false);
this.schedulePersistAudios(1200);
this.ensureAudioStallMonitor();
this.startAudioBackgroundTask(false)
.then(() => {
hilog.info(DOMAIN, TAG, 'audio background kept: %{public}s', reason);
})
.catch((err: Error) => {
hilog.warn(DOMAIN, TAG, 'keep audio background failed %{public}s %{public}s',
reason, JSON.stringify(err));
});
}
我把它拆成四层理解:
canContinueAudioPlayback():确认这次播放意图仍然有效。syncAudioProgressNow(false):先把进度算准,避免后台后丢秒数。schedulePersistAudios(1200):延迟保存听书进度,降低频繁写 Preferences 的成本。startAudioBackgroundTask(false):申请后台播放所需能力,并把失败写进日志。
这也解释了为什么不能只在 onBackground() 里直接调用一个“继续播放”方法。后台恢复是一个状态机,不是单一 API。
七、前台恢复:不要重复开播,先判断中断状态
从后台回到前台时,handleAppLifecycleStateChange() 会进入 recoverInterruptedPlayback():
private recoverInterruptedPlayback(): void {
if (!this.isPlaying) {
return;
}
this.syncAudioProgressNow(false);
this.schedulePersistAudios(800);
if (this.currentTtsRequestId.length > 0) {
this.requestAudioBackgroundTask();
return;
}
this.pauseRequested = false;
this.scheduleSpeakSelectedAudio(120);
}
这段逻辑的重点是“不急着重新 speak”。
如果 currentTtsRequestId 仍然存在,说明当前 TTS 请求还没有被认定为结束,此时只补后台任务和状态,不重新开播。只有请求 ID 已经清空,且页面还认为 isPlaying 为真,才延迟 120ms 重新调度当前条目。
这能避免一种常见问题:前台恢复时旧 TTS 回调还没回来,新 speak() 已经发出去,最后两个请求交叉修改进度。
八、为什么 aboutToDisappear 不能粗暴释放资源
页面生命周期仍然要处理,但它不能凌驾于 Ability 生命周期之上。当前项目的写法是:
aboutToDisappear(): void {
if (this.isPlaying) {
this.keepAudioPlaybackInBackground('page disappear');
return;
}
if (!this.isPlaying) {
this.stopProgressTimer();
this.stopAudioBackgroundTask(avSession.PlaybackState.PLAYBACK_STATE_STOP)
.then(() => {
this.releaseInactiveAudioResources();
})
.catch((err: Error) => {
hilog.warn(DOMAIN, TAG, 'stop background audio on disappear failed: %{public}s', JSON.stringify(err));
this.releaseInactiveAudioResources();
});
}
}
这里的分支很重要:正在播放时,页面消失不等于用户停止播放。它可能只是应用切后台、窗口销毁、系统重建或页面栈变化。只有 !this.isPlaying 时,才释放定时器、后台任务和 TTS 资源。
这条规则对内容型 App 很关键。听书播放是跨页面、跨前后台的能力,不应该被某个页面组件的消失直接终止。
九、日志证据:一次前后台切换的真实轨迹
下面是本项目 hilog_background_final.txt 中的一段抓取结果:
06-07 00:56:40.254 I testTag: Ability onForeground
06-07 00:56:46.118 W RecordsMain: audio trace speak segment resume req=records_tts_1780765006118_2 life=foreground playing=1 pause=0 progress=193 seg=2/4@153-222
06-07 00:56:46.230 I RecordsMain: audio background task started
06-07 00:56:46.234 W RecordsMain: audio trace tts start callback req=records_tts_1780765006118_2 life=foreground playing=1 pause=0 progress=193
06-07 00:56:50.503 I testTag: Ability onBackground
06-07 00:56:50.518 I RecordsMain: audio background kept: ability background
06-07 00:56:58.935 I testTag: Ability onForeground
这段日志说明三件事:
| 时间点 | 关键信号 | 判断 |
|---|---|---|
| 00:56:46 | TTS 段落开始,life=foreground |
前台播放正常 |
| 00:56:50 | Ability 进入后台 | 生命周期信号来自 Ability |
| 00:56:50 | audio background kept |
页面监听到后台状态并保活 |
| 00:56:58 | Ability 回到前台 | 恢复入口明确 |
如果没有 life=foreground/background 这种日志字段,很多后台问题只能靠肉眼听声音,很难判断是 Ability 没回调、页面没监听、TTS 没恢复,还是后台任务没申请成功。
十、调试命令与定位方式
本地排查时,我会先用 rg 确认生命周期链路是否完整:
rg -n "onForeground|onBackground|appLifecycleState|StorageLink|handleAppLifecycleStateChange" entry library2
再抓前后台和音频日志:
hdc shell hilog | Select-String -Pattern "Ability onForeground|Ability onBackground|audio trace|background kept|tts"
如果要看构建是否受影响,仍然走 Hvigor:
$env:DEVECO_SDK_HOME = 'D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk'
$env:Path = 'D:\HuaweiDevelopFormalStudy\DevEco Studio\jbr\bin;D:\HuaweiDevelopFormalStudy\DevEco Studio\sdk\default\openharmony\toolchains;D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\node;' + $env:Path
& 'D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\hvigor\bin\hvigorw.bat' assembleHap --mode module -p product=default --no-daemon
这篇文章只改文档,不改 ArkTS 源码;但如果你在项目里调整生命周期处理逻辑,建议至少跑一次构建,再用真机或模拟器完成一次前台播放、切后台、回前台的闭环。

十一、问题复盘:把“播放意图”独立出来
这轮排查后,我认为最重要的不是 @StorageLink 语法本身,而是把“播放意图”从“页面是否存在”里独立出来。
当前项目里有几个字段共同表达播放意图:
private audioPlaybackIntentToken: number = 0;
private currentTtsRequestId: string = '';
private pauseRequested: boolean = false;
private lastBackgroundKeepAt: number = 0;
private lastBackgroundEnteredAt: number = 0;
它们分别回答不同问题:
| 字段 | 回答的问题 |
|---|---|
audioPlaybackIntentToken |
当前播放动作是不是用户最新意图 |
currentTtsRequestId |
当前是否还有一个 TTS 请求在路上 |
pauseRequested |
停止来自用户还是系统噪声 |
lastBackgroundKeepAt |
最近一次后台保活是什么时候 |
lastBackgroundEnteredAt |
是否刚刚进入后台,可能收到误触发 pause |
如果只用一个 isPlaying,代码会很快失控。因为“UI 显示播放中”“TTS 正在合成”“后台任务已申请”“用户没有主动暂停”是四个不同事实。
这也是本篇和前面 TTS 文章最大的差异。第 7 篇更关注长文本如何切段,第 8 篇关注后台播放能力和 AVSession,第 9 篇关注 speak/stop 并发,第 10 篇关注进度条刷新;本篇只回答一个问题:这些能力在前后台切换时,谁来发出“现在应该恢复或保活”的统一信号。
如果把这个问题仍然放在 TTS 模块内部,代码会变成“播放引擎自己猜应用是否进入后台”。这不稳,因为 TTS 回调只能说明语音合成状态,不能说明 Ability 生命周期。反过来,如果只在 Ability 里处理播放,也会让入口层知道太多业务细节。现在这条链路把两边分开:Ability 只写状态,页面只响应状态,播放状态机只处理播放意图。
这次线上 QC 首次复核只有 85 后,我也重新检查了正文价值点:单纯列出生命周期代码还不够,读者需要看到“为什么这不是页面销毁问题”“为什么不能只靠 isPlaying”“如何用日志证明后台保活真的发生”。因此本节补充了源码定位、运行证据和跨篇差异,避免文章和前几篇后台播放/TTS 并发内容同质化。
十二、失败模式:哪些写法看起来简单但会出问题
下面这几类写法在小 Demo 里可能能跑,但放到长文本听书里很容易出问题。
| 错误写法 | 为什么危险 | 推荐处理 |
|---|---|---|
在 aboutToDisappear() 里直接 shutdown() TTS |
切后台也会触发页面消失,正在播放的声音会被误停 | 播放中转入 keepAudioPlaybackInBackground() |
回前台时无条件重新 speak() |
旧请求可能仍在合成,新请求会和旧回调交叉 | 先检查 currentTtsRequestId |
只用 isPlaying 表达播放状态 |
无法区分用户暂停、系统中断、后台保活和 TTS 忙 | 增加 requestId、pauseRequested、intentToken |
| 在 Ability 里直接操作页面播放字段 | 入口层和业务层耦合,页面重构后容易失效 | Ability 写 AppStorage,页面通过 StorageLink 消费 |
| 不记录后台进入时间 | 无法判断刚切后台时的 pause 是否是系统噪声 | 保存 lastBackgroundEnteredAt |
我最后采用的原则是:生命周期只做信号,播放模块只做状态机,持久化只做结果保存。三者之间通过明确字段连接,而不是互相直接调用内部细节。
十三、验收清单
| 验收项 | 通过标准 |
|---|---|
| Ability 生命周期 | onForeground/onBackground 日志稳定出现 |
| 页面状态桥接 | MainFrame.ets 能通过 @StorageLink 监听 appLifecycleState |
| 后台保活 | 切后台后出现 audio background kept |
| 前台恢复 | 回前台不重复朗读当前段落 |
| 页面消失 | 播放中 aboutToDisappear 不释放 TTS |
| 用户暂停 | 用户主动暂停后不被后台恢复逻辑重新拉起 |
| 进度保存 | 切后台和回前台后听书秒数不倒退 |
| 媒体状态 | 锁屏/系统媒体控制与页面状态一致 |
十四、边界与风险
这套写法仍然有边界。
第一,后台播放不是只有生命周期状态就够了,还需要后台任务、AVSession 和 TTS 状态机共同配合。没有第 8 篇里的后台能力配置,appLifecycleState 只能告诉你状态变化,不能保证系统允许继续播放。
第二,不同设备和系统版本对后台音频的回调时序可能不同。本文的日志来自当前项目实测,迁移到别的 App 时要重新观察 pause、complete、stop 的触发顺序。
第三,页面状态桥接不能滥用。AppStorage 适合保存全局运行态,如前后台、主题模式、当前用户偏好;不适合把所有页面局部字段都提升成全局状态。
十五、小结
Stage 生命周期处理的关键不是“在 onBackground() 里多写几行代码”,而是建立一条清晰的状态链:
UIAbility.onBackground/onForeground
-> AppStorage appLifecycleState
-> MainFrame @StorageLink
-> handleAppLifecycleStateChange()
-> keepAudioPlaybackInBackground() / recoverInterruptedPlayback()
对听书类 HarmonyOS 应用来说,页面消失、应用后台、用户暂停、TTS 回调结束是四类不同事件。把它们混在一起,最容易出现后台误停、前台重复恢复和进度错位;把 Ability 生命周期作为统一入口,再让页面按播放意图做恢复,状态才会稳定。
下一篇会继续换一个维度,讨论多模块工程拆分:entry、library1、library2 的边界应该如何设计,哪些代码适合沉到共享模块,哪些页面逻辑应该留在业务模块里。
更多推荐


所有评论(0)