ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

〖共创稿事节〗HarmonyOS 7 新特性实战(46):讲解放进后台之后——系统媒体卡与跨应用播控最小闭环

〖共创稿事节〗HarmonyOS 7 新特性实战(46):讲解放进后台之后——系统媒体卡与跨应用播控最小闭环

# 〖共创稿事节〗HarmonyOS 7 新特性实战(46):讲解放进后台之后——系统媒体卡与跨应用播控最小闭环

点开一只异兽的馆长讲解,听两句,切去别的应用查点东西,再回来——这是听书时最常见的动作。声音还在,说明播放没断;但屏幕上已经没有它了。系统下拉面板里的媒体卡如果空空如也,用户既没法暂停,也不知道刚才讲的是哪只异兽、点一下能不能回去。

这篇处理的就是这三十秒:把讲解接进系统的播控体系,让卡上写清楚"在讲谁"、"讲到哪"、"按播放暂停之后应用会做什么",以及"点这张卡回到哪一页"。

官方 API 参考:@ohos.multimedia.avsession(AVSessionKit)(核对日期 2026-10-29)

一、卡是系统画的,应用只能声明

媒体卡不在应用进程里渲染。它的样子、位置、手势都由系统决定,应用能做的只有通过AVSession声明四件事。四件事各有各的失败方式,混在一起写就很容易写成"卡上显示了,但按下播放没反应"。

动作应用声明什么系统负责什么缺了会怎样
createAVSession这是「audio」类型的一个会话分配会话、接管播控入口没有卡,后台播放也没有合法身份
setAVMetadata标题、副标题、assetId画卡上的文字与图标卡上只能显示应用名
setAVPlaybackState状态、位置、时长、倍速画进度条与按钮形态按钮能按,但进度条是空的
setLaunchAbility点卡时拉起哪个 Ability、带什么参数处理点击并投递 want点卡只回到应用默认页

会话名字固定成ShanhaiCuratorNarration,类型固定成audio。名字不能每次讲解换一个——它标识的是"这个应用有一个讲解会话",不是"这一条讲解"。换名字会让系统把每次讲解都当成新应用,旧卡不回收。

// 会话只建一次,后续讲解复用;退出一进一出对得上。 private static async ensureSession(): Promise<avSession.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 command=play idle=%{public}s', idle ? 'true' : 'false'); if (idle) { ShanhaiNarrationMediaSession.commands?.onReplay(); } else { ShanhaiNarrationMediaSession.commands?.onPlay(); } }); session.on('pause', () => { hilog.info(DOMAIN, TAG, 'card->app command=pause'); ShanhaiNarrationMediaSession.commands?.onPause(); }); session.on('stop', () => { hilog.info(DOMAIN, TAG, 'card->app command=stop'); 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 ): Promise<void> { 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 source=none,elapsedMs保持 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): Promise<void> { 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): Promise<WantAgent | undefined> { if (ShanhaiNarrationMediaSession.launchAgent !== undefined) { return ShanhaiNarrationMediaSession.launchAgent; } try { const hasBeast: boolean = ShanhaiNarrationMediaSession.beastId.length > 0; // ArkTS 不允许给 Record 直接写对象字面量,逐键赋值。 const launchParameters: Record<string, 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; } }

Record<string, 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 command=play\pause\stop`
app → card应用写完状态app->card state=<枚举> elapsedMs=<ms> durationMs=<ms>
app → card云端进度推进app->card progress elapsedMs=<ms> durationMs=<ms>
app → card元数据重写metadata set: beast=<id> title=... subtitle=...

关键是"先落日志、再转发"的顺序:拿到卡命令时先记一条,再调播放服务。反过来写,一旦播放服务里抛异常,日志已经丢了,事后只能看到"卡按了但没反应",无法判断是没收到还是收到后失败。

// 先写卡,再落一条应用侧状态日志:与系统卡命令(card->app)按时间成对。 private static async commitState(state: avSession.PlaybackState): Promise<void> { 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): Promise<void> { 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(): Promise<void> { 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、停掉后台任务,会话与元数据都保留。这样卡不会随着一次讲解结束而消失,用户回来还能按播放重讲;如果这里直接deactivate+destroy,卡一撤,播控窗立刻被别的播放应用接走,再想从卡上点回来就没有入口了。真正销毁放在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-AL80(HarmonyOS 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: beast=beast_baize title=白泽神兽导览 subtitle=白泽 · 馆长音色
真实时长同上app->card progress elapsedMs=0 durationMs=38887,与 WAV 实际时长一致
卡上暂停04-hilog-card-pause-play.txtcard->app command=pause→app->card state=3 elapsedMs=22059 durationMs=38887
卡上继续同上card->app command=play→app->card state=2,background 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: onNewWant

reuse:1说明没有新起一个实例,系统的启动能力把已有的界面拉到前台,再由onNewWant把OPEN_BEAST+beastId交给页面路由。截图里的落点是白泽详情页,不是首页,也不是馆长页——这正是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:5611,x86_64)走同一套局域网 mock 云端路径复查了一遍。

取证点证据结果
卡上封面01-card-artwork-playing.jpeg卡左侧是白泽插画,应用图标缩到右下角,不再是兜底音乐图标
封面落卡03-hilog-retained-and-replay.txtartwork set: beast=beast_baize width=512 height=512、metadata set ... artwork=beast
讲完保留02-card-retained-completed.jpegstate=7之后卡仍在,按钮回到播放;日志session retained ... completed=true
卡上重讲同上日志card->app command=play idle=true→card->app replay from beginning→ 元数据与封面重写 →state=2,进度 0 → 5055
停止也保留同上日志应用内停止后state=6,session retained ... completed=false,卡没有被释放

模拟器是 x86_64,这一轮构建把abiFilters放成arm64-v8a+x86_64两边都出产物,真机 arm64 路径不变。

九、这套做法没覆盖的部分

  • 闪控球没有公开 API。原本想写"用闪控球继续听",但 SDK 里没有可调的闪控球接口,于是把切片收在系统播控卡上——它本来就是跨应用场景里最稳定的那个入口。
  • 设备语音没有进度。TTS 引擎不给位置,卡上进度条不动,只剩"讲的是谁"。要补这一块,得换一个能上报位置的合成方案,不是本篇范围。
  • 拖拽进度条(seek)没有接。卡上可以拖,但应用侧还没注册seek回调,拖了不会真的跳转。这属于下一步,不做半截功能假装支持。
  • 多会话并发没有处理。同一时间只允许一条讲解,切换异兽时先finish再prepare。真要做"边听边排队",会话管理得重写。
  • 大响应的云端讲解会失败。同一套 mock 换成 116 秒音频(JSON 约 4.7 MB)后,真机上云端调用失败并回落设备语音;38.9 秒(约 1.7 MB)则正常。这提示接口层对单次音频体积有上限,正式服务应改为返回短音频或流式地址,而不是把整段 base64 塞进一个响应。

十、一句话收尾

媒体卡不是一块装饰,它是一份契约:应用声明"我在讲什么、讲到哪、按了之后做什么、点开去哪",系统负责把它画出来并转达。四件事里任何一件含糊,用户看到的就是一张对不上号的卡。把每一件事都写成可对账的日志,比在真机上盯着卡猜要快得多。

下一篇继续往"后台"再走一步:讲解放进后台以后,任务的生命周期与系统回收之间怎么对齐。

返回列表