:讲解放进后台之后——系统媒体卡与跨应用播控最小闭环)
# 〖共创稿事节〗HarmonyOS 7 新特性实战46讲解放进后台之后——系统媒体卡与跨应用播控最小闭环点开一只异兽的馆长讲解听两句切去别的应用查点东西再回来——这是听书时最常见的动作。声音还在说明播放没断但屏幕上已经没有它了。系统下拉面板里的媒体卡如果空空如也用户既没法暂停也不知道刚才讲的是哪只异兽、点一下能不能回去。这篇处理的就是这三十秒把讲解接进系统的播控体系让卡上写清楚在讲谁、讲到哪、按播放暂停之后应用会做什么以及点这张卡回到哪一页。官方 API 参考ohos.multimedia.avsessionAVSessionKit核对日期 2026-10-29一、卡是系统画的应用只能声明媒体卡不在应用进程里渲染。它的样子、位置、手势都由系统决定应用能做的只有通过AVSession声明四件事。四件事各有各的失败方式混在一起写就很容易写成卡上显示了但按下播放没反应。动作应用声明什么系统负责什么缺了会怎样createAVSession这是「audio」类型的一个会话分配会话、接管播控入口没有卡后台播放也没有合法身份setAVMetadata标题、副标题、assetId画卡上的文字与图标卡上只能显示应用名setAVPlaybackState状态、位置、时长、倍速画进度条与按钮形态按钮能按但进度条是空的setLaunchAbility点卡时拉起哪个 Ability、带什么参数处理点击并投递 want点卡只回到应用默认页会话名字固定成ShanhaiCuratorNarration类型固定成audio。名字不能每次讲解换一个——它标识的是这个应用有一个讲解会话不是这一条讲解。换名字会让系统把每次讲解都当成新应用旧卡不回收。// 会话只建一次后续讲解复用退出一进一出对得上。 private static async ensureSession(): PromiseavSession.AVSession | undefined { if (ShanhaiNarrationMediaSession.session ! undefined) { return ShanhaiNarrationMediaSession.session; } const context: common.UIAbilityContext | undefined ShanhaiNarrationMediaSession.context; if (context undefined) { return undefined; } try { const session: avSession.AVSession await avSession.createAVSession(context, ShanhaiCuratorNarration, audio); session.on(play, () { // 讲完或停下之后会话还在此时卡上按播放是「重新开讲」不是「继续」。 const idle: boolean ShanhaiNarrationMediaSession.idle; hilog.info(DOMAIN, TAG, card-app commandplay idle%{public}s, idle ? true : false); if (idle) { ShanhaiNarrationMediaSession.commands?.onReplay(); } else { ShanhaiNarrationMediaSession.commands?.onPlay(); } }); session.on(pause, () { hilog.info(DOMAIN, TAG, card-app commandpause); ShanhaiNarrationMediaSession.commands?.onPause(); }); session.on(stop, () { hilog.info(DOMAIN, TAG, card-app commandstop); ShanhaiNarrationMediaSession.commands?.onStop(); }); await session.activate(); try { await session.setBackgroundPlayMode(avSession.BackgroundPlayMode.ENABLE_BACKGROUND_PLAY); } catch (_) { } ShanhaiNarrationMediaSession.session session; hilog.info(DOMAIN, TAG, session created); return session; } catch (_) { return undefined; } }setBackgroundPlayMode单独包一层try有些设备或系统版本不认这个开关但它失败时前台播放仍然成立不该把整条会话带崩。二、元数据跟着异兽走不跟着应用走卡上最容易写错的一句话是副标题。写成固定的AI 馆长讲解用户切了五只异兽再切回来卡上还是同一行字等于没有信息。所以每次prepare都重写一遍元数据标题是讲解主题副标题带上异兽名与音色。assetId单独做一层处理有异兽时拼成shanhai-curator-beastId没有时退回shanhai-curator-narration。这个字段对用户不可见但它是系统识别这是同一条内容还是另一条的依据跨设备流转、收藏与历史都读它。static async prepare( title: string, subtitle: string, beastId: string, commands: ShanhaiNarrationMediaCommands ): Promisevoid { ShanhaiNarrationMediaSession.commands commands; ShanhaiNarrationMediaSession.beastId beastId; ShanhaiNarrationMediaSession.elapsedMs 0; ShanhaiNarrationMediaSession.durationMs 0; ShanhaiNarrationMediaSession.lastProgressLogSecond -1; const session: avSession.AVSession | undefined await ShanhaiNarrationMediaSession.ensureSession(); if (session undefined) { hilog.info(DOMAIN, TAG, degrade: avsession unavailable, foreground playback only); return; } try { const metadata: avSession.AVMetadata { assetId: shanhai-curator- (beastId.length 0 ? beastId : narration), title: title.length 0 ? title : 馆长讲解, artist: 中国山海万灵馆长, album: AI 馆长讲解, subtitle: subtitle.length 0 ? subtitle : 当前主题讲解 }; // 卡上的封面用当前异兽的插画而不是应用图标。 const artwork: image.PixelMap | undefined await ShanhaiNarrationMediaSession.ensureArtwork(beastId); if (artwork ! undefined) { metadata.mediaImage artwork; } await session.setAVMetadata(metadata); hilog.info(DOMAIN, TAG, metadata set: beast%{public}s title%{public}s subtitle%{public}s artwork%{public}s, beastId.length 0 ? beastId : none, title, subtitle, artwork ! undefined ? beast : none); await ShanhaiNarrationMediaSession.bindLaunchAbility(session); await ShanhaiNarrationMediaSession.commitState(avSession.PlaybackState.PLAYBACK_STATE_PREPARE); } catch (_) { hilog.info(DOMAIN, TAG, degrade: metadata or launch ability not applied); } }异兽 id 是从馆长页一层层传下来的讲解卡拿到beastId/beastName两个Prop调讲解服务时一起带过去。没有在媒体会话里反过来查当前页面——那样页面一销毁卡就成了孤儿。封面走mediaImage喂给系统的是image.PixelMap用resourceManager.getMediaContentSync读出当前异兽的插画按长边 512 解码换异兽才重新解一次。卡上那块小图因此是「白泽」「应龙」而不是所有讲解共用的应用图标。没有对应插画时不给mediaImage让系统自己兜底不硬塞一张错图。三、进度两条路径只有一条有真实位置这个应用有两种讲解云端馆长语音AVPlayer播一段落盘的 mp3和设备语音引擎系统 TTS。两者的能力差别正好落在媒体卡最显眼的那根进度条上。路径位置来源时长来源卡上进度条云端语音 AVPlayeron(timeUpdate)毫秒时间戳on(durationUpdate)/prepared后的duration真实走动设备语音 TTS没有位置查询接口没有时长回调停在起点日志里标注无来源对第二条唯一诚实的写法是不写。没有位置接口硬按字数折算出一个进度用户在卡上看到的是一根匀速前进、和声音毫无关系的条——那不是功能是噪声。所以设备语音路径只落一条日志progress sourcenoneelapsedMs保持 0。云端路径的订阅加了两道守卫每帧位置变化都过一次isCurrent(token)换了讲解或已经停止就不再往新会话里写释放播放器前显式off避免释放之后的回调还在打日志。// 真实进度AVPlayer 的 timeUpdate 给毫秒时间戳duration 是解码后的时长。 // 只上报当前这一条讲解的进度切换或停止后旧播放器的事件不再写进媒体卡。 player.on(timeUpdate, (time: number) { if (!ShanhaiAzureNarrationService.isCurrent(token)) { return; } ShanhaiNarrationMediaSession.updateProgress(time, player.duration); }); player.on(durationUpdate, (duration: number) { if (!ShanhaiAzureNarrationService.isCurrent(token)) { return; } ShanhaiNarrationMediaSession.updateProgress(player.currentTime, duration); });时长在prepared之后才可读所以进playing之前先补写一次updateProgress(0, player.duration)——否则进度条先出现一个没有分母的空壳等第一次timeUpdate才补上观感上就是卡了一秒。// 云端播放的真实进度写卡 每 5 秒一条 app-card 日志。 static async updateProgress(elapsedMs: number, durationMs: number): Promisevoid { ShanhaiNarrationMediaSession.elapsedMs elapsedMs 0 ? elapsedMs : 0; if (durationMs 0) { ShanhaiNarrationMediaSession.durationMs durationMs; } const session: avSession.AVSession | undefined ShanhaiNarrationMediaSession.session; if (session undefined) { return; } try { await ShanhaiNarrationMediaSession.setPlaybackState(avSession.PlaybackState.PLAYBACK_STATE_PLAY); const second: number Math.floor(ShanhaiNarrationMediaSession.elapsedMs / 5000); if (second ! ShanhaiNarrationMediaSession.lastProgressLogSecond) { ShanhaiNarrationMediaSession.lastProgressLogSecond second; hilog.info(DOMAIN, TAG, app-card progress elapsedMs%{public}d durationMs%{public}d, ShanhaiNarrationMediaSession.elapsedMs, ShanhaiNarrationMediaSession.durationMs); } } catch (_) { } }进度回调频率不低日志按 5 秒节流。不节流的话一段三分钟的讲解能在 hilog 里刷出几千行真机排查时反而找不到别的。四、点卡回到应用不新造协议复用启动白名单播放控制之外媒体卡还有一个点击动作点卡面回到应用。最省事的做法是拉起主 Ability 就完事用户回来看到的是首页得自己再点进图鉴、再找到那只异兽。既然这一条讲解本来就知道beastId就该把这个信息带回去。项目里已经有一份启动白名单桌面服务卡片用source/schemaVersion/action三个字段描述要打开什么ShanhaiLaunchIntentService负责校验与解码Index.consumeLaunchRequest负责跳转。媒体卡不另立一套直接复用。private static async ensureLaunchAgent(context: common.UIAbilityContext): PromiseWantAgent | undefined { if (ShanhaiNarrationMediaSession.launchAgent ! undefined) { return ShanhaiNarrationMediaSession.launchAgent; } try { const hasBeast: boolean ShanhaiNarrationMediaSession.beastId.length 0; // ArkTS 不允许给 Record 直接写对象字面量逐键赋值。 const launchParameters: Recordstring, Object {}; launchParameters[source] SHANHAI_LAUNCH_SOURCE_DESKTOP_FORM; launchParameters[schemaVersion] SHANHAI_LAUNCH_SCHEMA_VERSION; launchParameters[action] hasBeast ? OPEN_BEAST : OPEN_HOME; launchParameters[beastId] ShanhaiNarrationMediaSession.beastId; launchParameters[regionId] ; launchParameters[hallId] ; const info: wantAgent.WantAgentInfo { wants: [{ bundleName: context.abilityInfo.bundleName, abilityName: context.abilityInfo.name, parameters: launchParameters }], requestCode: LAUNCH_REQUEST_CODE, actionType: wantAgent.OperationType.START_ABILITY }; const agent: WantAgent await wantAgent.getWantAgent(info); ShanhaiNarrationMediaSession.launchAgent agent; return agent; } catch (_) { return undefined; } }Recordstring, Object这里踩了一次 ArkTS 的坑直接用对象字面量会触发arkts-no-untyped-obj-literals因为字面量没有被推断成显式类型。改成空 Record 之后逐键赋值编译就过了。复用白名单还带来一个好处冷启动和热启动只有一份解析代码。媒体卡点击时如果进程已经死了want 走onCreate进程还活着走onNewWant。两者都把 want 交给同一个publishLaunchRequest写进AppStorage再由页面消费。参数媒体卡写入接收端sourceDESKTOP_FORM与桌面卡片同源ShanhaiLaunchIntentService.createRequest校验actionOPEN_BEAST/OPEN_HOMEIndex.consumeLaunchRequest分支beastId当前讲解对应的异兽openBeast(request.beastId)直达详情没有beastId时写OPEN_HOME讲解还没绑定异兽比如兜底文案点卡落回首页而不是空白页或者一张不存在的详情。requestCode固定成一个常量同一个请求码重复触发时系统按同一事务处理。五、状态一致性不假装读回只留成对日志做完上面四步还剩一个问题卡上显示正在播放而应用里其实已经停了——这种不一致最难查因为两边都没有明显报错。第一反应是写完之后读回来比对。但AVSession上没有getAVPlaybackState这个方法同名的接口在AVSessionController上那是给控制端用的会话这一侧只能写、不能读。所以放弃读回校验改成双向日志方向触发点日志格式card → app系统卡按下播放/暂停/停止card-app commandplay\pause\stopapp → card应用写完状态app-card state枚举 elapsedMsms durationMsmsapp → card云端进度推进app-card progress elapsedMsms durationMsmsapp → card元数据重写metadata set: beastid title... subtitle...关键是先落日志、再转发的顺序拿到卡命令时先记一条再调播放服务。反过来写一旦播放服务里抛异常日志已经丢了事后只能看到卡按了但没反应无法判断是没收到还是收到后失败。// 先写卡再落一条应用侧状态日志与系统卡命令card-app按时间成对。 private static async commitState(state: avSession.PlaybackState): Promisevoid { await ShanhaiNarrationMediaSession.setPlaybackState(state); hilog.info(DOMAIN, TAG, app-card state%{public}d elapsedMs%{public}d durationMs%{public}d, state, ShanhaiNarrationMediaSession.elapsedMs, ShanhaiNarrationMediaSession.durationMs); } private static async setPlaybackState(state: avSession.PlaybackState): Promisevoid { const session: avSession.AVSession | undefined ShanhaiNarrationMediaSession.session; if (session undefined) { return; } try { const playbackState: avSession.AVPlaybackState { state: state, // HarmonyOS media cards expect a valid speed even while paused. speed: 1, position: { elapsedTime: ShanhaiNarrationMediaSession.elapsedMs, updateTime: Date.now() }, bufferedTime: 0, loopMode: avSession.LoopMode.LOOP_MODE_SEQUENCE, isFavorite: false }; if (ShanhaiNarrationMediaSession.durationMs 0) { playbackState.duration ShanhaiNarrationMediaSession.durationMs; } await session.setAVPlaybackState(playbackState); } catch (_) { } }两个细节speed即使暂停也要给有效值部分系统版本会因此判空duration只在大于 0 时赋值duration是可选字段写 0 会让进度条分母变成 0。六、降级四处台阶一级都不能少媒体卡涉及四类系统能力任何一类不可用都不该让讲解这个主功能失败。所以四处都留了降级日志且都止步于能力降级而不是功能报错。缺失日志用户侧表现AVSession 建不出来degrade: avsession unavailable, foreground playback only前台正常讲卡上没有WantAgent 建不出来degrade: launch WantAgent missing, card tap opens default page点卡回默认页不回当前异兽setLaunchAbility被拒degrade: setLaunchAbility rejected同上后台任务 / 写状态失败degrade: background task or media card play state rejected前台照讲切后台可能被回收static async markPlaying(): Promisevoid { const session: avSession.AVSession | undefined ShanhaiNarrationMediaSession.session; const context: common.UIAbilityContext | undefined ShanhaiNarrationMediaSession.context; if (session undefined || context undefined) { return; } try { await ShanhaiNarrationMediaSession.commitState(avSession.PlaybackState.PLAYBACK_STATE_PLAY); if (!ShanhaiNarrationMediaSession.backgroundRunning) { const agent: WantAgent | undefined await ShanhaiNarrationMediaSession.ensureLaunchAgent(context); if (agent undefined) { hilog.info(DOMAIN, TAG, degrade: cover WantAgent missing, background task skipped); return; } await backgroundTaskManager.startBackgroundRunning(context, [audioPlayback], agent); ShanhaiNarrationMediaSession.backgroundRunning true; } } catch (_) { // A missing system capability must not turn a supported foreground TTS // request into a failed lecture. hilog.info(DOMAIN, TAG, degrade: background task or media card play state rejected); } }后台任务复用的是同一个WantAgent连续任务的回到应用入口与媒体卡的点击入口指向同一处不需要建两个。会话的收场也要说清楚讲完或停止时只把状态写成COMPLETED/STOP、停掉后台任务会话与元数据都保留。这样卡不会随着一次讲解结束而消失用户回来还能按播放重讲如果这里直接deactivatedestroy卡一撤播控窗立刻被别的播放应用接走再想从卡上点回来就没有入口了。真正销毁放在EntryAbility.onDestroy由release()统一做deactivate→destroy不留下一个永远占着播控窗的死卡。七、把结论钉在门禁里媒体卡的行为大部分发生在系统侧本地能静态钉住的是应用这一侧该做的动作有没有做。所以新增了一个契约脚本43 条断言读源码判结构。断言分组钉住的内容会话本身audio 类型、固定会话名、复用、讲完保留、退出时 deactivate → destroy元数据assetId 带异兽、副标题来自调用方、封面取当前异兽插画、无插画不硬塞点卡返回setLaunchAbility、OPEN_BEAST、beastId参数、冷热启动双入口进度timeUpdate / durationUpdate 订阅、prepared 先写时长、旧播放器不越权日志三条卡命令、状态回写、每条命令先日志后转发、卡上播放按 idle 分流重讲降级四条降级日志、云端失败仍回落设备语音界面接线馆长页 → 讲解卡 → 讲解服务的两个字段反向验证做了三次每次只改一处看门禁是否只红对应那条删掉await session.setLaunchAbility(agent);→FAIL 会话绑定 setLaunchAbility把elapsedTime: ...elapsedMs写死成0→FAIL elapsedTime 用真实毫秒不用固定 0把player.on(timeUpdate, ...)改名成不存在的回调 →FAIL 订阅 AVPlayer timeUpdate。三次都退出码 1代码原样恢复后回到narration-session contract: all green。构建侧打包通过增量 5 秒 81 毫秒全量 50 个契约脚本全部通过其中这一个就是本篇新增的。八、真机取证卡上写的是什么按下去发生了什么门禁只能钉住应用这一侧的代码结构卡是系统画的所以这一节全部是实机跑出来的结果。设备是 HBN-AL80HarmonyOS 7.0.0.111 SP10C00E111R4P2通过hdc -t 192.168.3.208:43457连接。云端讲解走的是局域网临时 mock宿主机 3100 端口返回一段 38.9 秒的 WAV目的是让AVPlayer这条真实播放路径在真机上跑通而不是只看设备 TTS 的降级分支。取证点证据结果卡上文案01-shade-media-card.jpeg标题「白泽神兽导览」、副标题「中国山海万灵馆长」带进度条与播放/暂停按钮元数据落卡03-hilog-session-and-launch.txtmetadata set: beastbeast_baize title白泽神兽导览 subtitle白泽 · 馆长音色真实时长同上app-card progress elapsedMs0 durationMs38887与 WAV 实际时长一致卡上暂停04-hilog-card-pause-play.txtcard-app commandpause→app-card state3 elapsedMs22059 durationMs38887卡上继续同上card-app commandplay→app-card state2background running started进度接着 25077 → 30038 走点卡回跳02-card-return-beast-detail.jpeg点卡后系统MIP: startLaunchAbility with trigger success应用OnNewWant直接落在白泽详情页点卡那一段的日志链路完整闭合四行连起来看就是一次真实的跨应用返回17:42:46.207 MC: MIP: startLaunchAbility with trigger 17:42:46.221 WMSLife: PendingSessionActivation: id:162,bundleName:com.shanhai.spirits,abilityName:EntryAbility,reuse:1 17:42:46.221 MC: MIP: startLaunchAbility with trigger success 17:42:46.242 UIAbility: [JUA2203]JsUIAbility call js, name: onNewWantreuse:1说明没有新起一个实例系统的启动能力把已有的界面拉到前台再由onNewWant把OPEN_BEASTbeastId交给页面路由。截图里的落点是白泽详情页不是首页也不是馆长页——这正是setLaunchAbility带参数的意义。真机还暴露了两个只有真机才会暴露的问题都已修掉或写成限制player.url file://...在真机上直接被拒。报错是errorCode 401, Invalid Parameter: ... not fd:// or network address播放器连initialized都进不去讲解随即按失败处理。改成fileIo.openSync拿到句柄后喂player.fdSrc { fd }句柄随releasePlayer()一起关闭进度和时长才正常上报。这条没有门禁能拦只能靠真机跑。设备 TTS 的会话上不了卡。会话能建、元数据能写但下拉面板里显示的是「未在播放」见02-control-center-while-playing.jpeg。设备 TTS 引擎自己占着音频焦点卡片跟着焦点走所以有进度的那条路和能上卡的那条路是同一条——这也是本篇坚持把云端AVPlayer路径跑通的原因。讲完不撤卡、卡上封面用异兽模拟器复查第一版是讲完就销毁会话落到播控窗以后成了两个具体问题一次讲解结束卡直接消失播控窗被别的播放应用接走用户再想从卡上点回来就没有入口封面位只有应用图标切了几只异兽也看不出在讲谁。改法对应两处讲完/停止只写COMPLETED/STOP并停掉后台任务会话与元数据保留销毁挪到EntryAbility.onDestroy元数据补mediaImage把当前异兽插画解码成PixelMap。没有真机的时段用模拟器127.0.0.1:5611x86_64走同一套局域网 mock 云端路径复查了一遍。取证点证据结果卡上封面01-card-artwork-playing.jpeg卡左侧是白泽插画应用图标缩到右下角不再是兜底音乐图标封面落卡03-hilog-retained-and-replay.txtartwork set: beastbeast_baize width512 height512、metadata set ... artworkbeast讲完保留02-card-retained-completed.jpegstate7之后卡仍在按钮回到播放日志session retained ... completedtrue卡上重讲同上日志card-app commandplay idletrue→card-app replay from beginning→ 元数据与封面重写 →state2进度 0 → 5055停止也保留同上日志应用内停止后state6session retained ... completedfalse卡没有被释放模拟器是 x86_64这一轮构建把abiFilters放成arm64-v8ax86_64两边都出产物真机 arm64 路径不变。九、这套做法没覆盖的部分闪控球没有公开 API。原本想写用闪控球继续听但 SDK 里没有可调的闪控球接口于是把切片收在系统播控卡上——它本来就是跨应用场景里最稳定的那个入口。设备语音没有进度。TTS 引擎不给位置卡上进度条不动只剩讲的是谁。要补这一块得换一个能上报位置的合成方案不是本篇范围。拖拽进度条seek没有接。卡上可以拖但应用侧还没注册seek回调拖了不会真的跳转。这属于下一步不做半截功能假装支持。多会话并发没有处理。同一时间只允许一条讲解切换异兽时先finish再prepare。真要做边听边排队会话管理得重写。大响应的云端讲解会失败。同一套 mock 换成 116 秒音频JSON 约 4.7 MB后真机上云端调用失败并回落设备语音38.9 秒约 1.7 MB则正常。这提示接口层对单次音频体积有上限正式服务应改为返回短音频或流式地址而不是把整段 base64 塞进一个响应。十、一句话收尾媒体卡不是一块装饰它是一份契约应用声明我在讲什么、讲到哪、按了之后做什么、点开去哪系统负责把它画出来并转达。四件事里任何一件含糊用户看到的就是一张对不上号的卡。把每一件事都写成可对账的日志比在真机上盯着卡猜要快得多。下一篇继续往后台再走一步讲解放进后台以后任务的生命周期与系统回收之间怎么对齐。