# 〖共创稿事节〗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 塞进一个响应。

十、一句话收尾

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

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

Logo

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

更多推荐