给声笺播放器接入系统播控,评审时最先要问的并不是卡片放什么封面,而是谁拥有播放状态。应用按钮可以暂停,耳机按键也可以暂停,锁屏界面还能发送播放命令。如果每个入口都维护自己的布尔值,一次操作失败就足以让三个地方互相矛盾。本篇沿用第一篇的 AudioEngine,把 AVSession 定位成命令入口和状态出口,播放器仍然是播放事实的来源。

应用播放引擎与系统媒体会话的技术封面

一、确定会话与播放器的边界

声笺继续采用 Stage 模型和 API 12 基线。创建会话、设置元数据、设置播放状态、激活和销毁会话均使用公开接口。官方 媒体会话接入指南 区分了会话提供方与控制方:我们的应用持有会话,系统播控向它发命令。这里不需要创建第二个播放器,也不需要让系统卡片直接访问页面组件。

设计上保留三条方向明确的通路。系统播放或暂停命令进入桥接层,桥接层调用引擎;引擎接收播放器事件并发布快照;桥接层把快照映射成系统状态。命令不能直接改写快照,设置系统状态也不能反过来触发播放。这个单向约束使失败行为有明确归宿:调用失败显示错误,而不是先把所有界面改成成功再尝试回滚。

会话对象必须由一个稳定成员持有。若把创建动作放在按钮点击里,每次点击都创建一次,就会碰到重复创建限制。不同版本文档对约束范围的表述有所演进,应用采用更保守的进程内单会话设计:AudioNote 只创建一份会话桥,所有入口复用它,旧会话销毁完成前不启动新会话。这个决策同时减少重复监听和退出后的残留卡片。

第一篇引擎的订阅集合在这里发挥作用。页面已经注册了观察者,桥接层可以再注册一个,不必替换页面回调。页面退出先取消自己的订阅,并不意味着系统会话必须立刻消失;当前版本仍把整项播放业务放在页面内,第四篇才会把所有权上移到 Ability。所有权迁移是显式改造,不能通过删掉释放代码碰运气。

另一个需要提前约定的是支持哪些命令。声笺目前只有一段音源,所以只注册播放和暂停,不注册下一首或上一首。注册监听本身会向系统表达应用具有对应能力;如果收到了命令却什么也不做,用户看到的就是可用按钮失效。功能尚未实现时,应当保留能力边界,等播放列表模型完成后再增加相应入口。

标题、艺术家和封面也应来自同一份资源身份。不要让页面显示当前音源,系统卡片却使用上一段音源的封面;不要把展示标题当成唯一标识,因为两段音频可以同名。示例使用固定资源名,后续若接入内容服务,可以使用服务端稳定资源编号,再由该编号关联封面与音频地址。

二、桥接层只翻译命令和事实

在 service/MediaBridge.ets 新建下面的类,保留第一篇 AudioEngine.ets。封面像素在内存中生成,保证最小示例不依赖外链;正式产品可以替换为自己的内容封面。图像属于会话元数据,不会改变音源。元数据中的资源标识使用稳定的 focus.wav,不能每次刷新进度都换一个随机值。

import { avSession } from '@kit.AVSessionKit';
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AudioEngine, AudioSnapshot } from './AudioEngine';

export class MediaBridge {
  private engine: AudioEngine;
  private session?: avSession.AVSession;
  private cover?: image.PixelMap;
  private unsubscribe?: () => void;
  private opening?: Promise<void>;
  private writes: Promise<void> = Promise.resolve();
  private closing: boolean = false;
  private lastState: string = '';
  private lastDuration: number = -1;
  onError: (message: string) => void = () => {};

  constructor(engine: AudioEngine) { this.engine = engine; }

  open(context: common.UIAbilityContext): Promise<void> {
    if (!this.opening) { this.opening = this.create(context); }
    return this.opening;
  }

  private async create(context: common.UIAbilityContext): Promise<void> {
    const pixels = new Uint8Array(64 * 64 * 4);
    for (let i = 0; i < pixels.length; i += 4) {
      pixels[i] = 25; pixels[i + 1] = 125;
      pixels[i + 2] = 110; pixels[i + 3] = 255;
    }
    this.cover = await image.createPixelMap(pixels.buffer, {
      size: { width: 64, height: 64 },
      srcPixelFormat: image.PixelMapFormat.RGBA_8888,
      pixelFormat: image.PixelMapFormat.RGBA_8888
    });
    if (this.closing) { return; }
    const session = await avSession.createAVSession(context, 'AudioNote', 'audio');
    this.session = session;
    if (this.closing) { return; }
    session.on('play', () => { this.engine.play(); });
    session.on('pause', () => { this.engine.pause(); });
    await session.activate();
    if (this.closing) { return; }
    this.unsubscribe = this.engine.subscribe((snapshot: AudioSnapshot) => {
      if (snapshot.state === this.lastState &&
        snapshot.durationMs === this.lastDuration) { return; }
      this.lastState = snapshot.state;
      this.lastDuration = snapshot.durationMs;
      this.sync(snapshot);
    });
    await this.writes;
  }

  sync(snapshot: AudioSnapshot): void {
    this.writes = this.writes.then(async () => {
      const session = this.session;
      if (!session || this.closing) { return; }
      await session.setAVMetadata({
        assetId: 'focus.wav', title: '专注音景', artist: '声笺',
        duration: snapshot.durationMs, mediaImage: this.cover
      });
      let state = avSession.PlaybackState.PLAYBACK_STATE_STOP;
      if (snapshot.state === 'playing') {
        state = avSession.PlaybackState.PLAYBACK_STATE_PLAY;
      } else if (['prepared', 'paused'].includes(snapshot.state)) {
        state = avSession.PlaybackState.PLAYBACK_STATE_PAUSE;
      } else if (snapshot.state === 'initialized') {
        state = avSession.PlaybackState.PLAYBACK_STATE_BUFFERING;
      }
      await session.setAVPlaybackState({
        state: state, speed: 1,
        position: { elapsedTime: snapshot.currentMs, updateTime: Date.now() }
      });
    }).catch((error: BusinessError) => {
      this.onError(`媒体会话同步失败:${error.code}`);
    });
  }

  async close(): Promise<void> {
    if (this.closing) { return; }
    this.closing = true;
    try { await this.opening; } catch (error) {
      this.onError(`会话初始化失败:${(error as BusinessError).code}`);
    }
    this.unsubscribe?.();
    await this.writes;
    const session = this.session;
    if (session) {
      session.off('play'); session.off('pause');
      try { await session.deactivate(); } catch (error) {
        this.onError(`停用会话失败:${(error as BusinessError).code}`);
      }
      try { await session.destroy(); } catch (error) {
        this.onError(`销毁会话失败:${(error as BusinessError).code}`);
      }
    }
    this.session = undefined;
    if (this.cover) { await this.cover.release(); }
    this.cover = undefined;
  }
}

同步方法把快照排成一条写入链,确保先到的暂停状态不会在后到的播放状态之后才写完。状态发布不是每个时间事件都执行:正常播放时,系统可以基于播放位置、更新时间和速度推算进度;应用负责在状态、位置或者速度发生关键变化时重新对齐。第一篇没有跳转和倍速,所以这里先按状态和时长变化触发。第三篇加入跳转后,还会在跳转确认时主动调用同步方法。

elapsedTime 表示媒体内的位置,updateTime 表示本次状态对应的时间戳。把两者互换,或者把秒直接传进毫秒字段,都可能让播控进度失真。更隐蔽的问题是用户点击播放后立即上报播放状态,此时解码器还没有起播,系统进度先走了一段,下一次真实位置上报就可能表现为回退。当前实现等待引擎的播放事件,不提前宣告成功。

MediaBridge 中的命令注册、状态转换与播放页面

会话同步失败不会让应用里的音频自动停止。它意味着系统状态展示可能没有及时更新,需要独立反馈并允许重试。示例通过错误回调把问题交给页面,保留引擎原来的错误渠道。两类错误不能混在一个“播放失败”提示里,否则服务端会话异常会被误判为音源无法解码,排查方向就完全错了。

这里没有把异步异常简单地吞掉。写入链捕获错误是为了让下一次同步仍能执行,错误内容继续交给页面;如果不恢复链条,一次拒绝就会让后续所有写入都沿着失败分支跳过。对日志而言,应记录操作类别和错误码,而不是序列化整个会话对象。这样的记录足以区分创建、激活、更新与销毁失败,也不会泄漏不必要的媒体元信息。

快照进入同步队列时已经与引擎内部对象分离,排队过程中不会被下一次时间事件原地改写。否则同一个对象虽然排了多次,真正执行时可能全都读到最后一刻的数据,日志中的动作次序与最终发布内容就对不上。这个细节与是否使用复杂状态框架无关,本质上是在异步边界冻结一次操作所依据的事实。

三、把初始化和销毁接回现有页面

在第一篇 Index.ets 增加 MediaBridge 导入,以及下面这些成员和方法;它们替换原来的出现与消失回调,原有快照、引擎和页面主体保留。新的准备过程先连接会话,再打开音源。退出期间用页面关闭标记阻止后续初始化继续,避免已经执行清理后又创建新的播放资源。

import { MediaBridge } from '../service/MediaBridge';
import { BusinessError } from '@kit.BasicServicesKit';

// 以下成员置于第一篇 Index 结构体内部。
@State sessionText: string = '正在连接系统播控';
private bridge: MediaBridge = new MediaBridge(this.engine);
private pageClosing: boolean = false;
private bridgeFault: boolean = false;

aboutToAppear(): void {
  this.unsubscribe = this.engine.subscribe((value: AudioSnapshot) => {
    this.snapshot = value;
  });
  this.bridge.onError = (message: string) => {
    this.bridgeFault = true;
    if (!this.pageClosing) { this.sessionText = message; }
  };
  this.start();
}

private async start(): Promise<void> {
  try {
    await this.bridge.open(getContext(this) as common.UIAbilityContext);
    if (this.pageClosing) { return; }
    if (!this.bridgeFault) { this.sessionText = '系统播控已连接'; }
    await this.engine.open(getContext(this) as common.UIAbilityContext);
  } catch (error) {
    if (!this.pageClosing) {
      this.sessionText = `连接失败:${(error as BusinessError).code}`;
    }
  }
}

aboutToDisappear(): void {
  this.pageClosing = true;
  this.unsubscribe?.();
  this.cleanup();
}

private async cleanup(): Promise<void> {
  try { await this.bridge.close(); }
  catch (error) { console.error(`cleanup: ${(error as BusinessError).code}`); }
  finally { await this.engine.close(); }
}

在原页面 Column 的副标题之后加入状态文本和重试同步按钮,按钮只重新发布当前事实,不重新创建会话。第一次创建失败时应退出播放业务后重新进入,避免在不明确旧资源是否销毁的情况下叠加新实例。

Text(this.sessionText).fontColor('#177B75')
Button('同步播控状态').onClick(() => {
  this.bridge.sync(this.engine.snapshot());
})

系统命令经过引擎再回写媒体状态的单向流程

发布频率需要根据业务意义决定。单纯为了让进度条看起来连续而不断重复设置整份元数据,会把稳定的标题和封面也加入高频更新。真正产品可以进一步拆开元数据和播放状态的更新条件;当前桥接层把写入集中在一个地方,先确保顺序正确,再以明确的变化条件减少调用,避免把优化分散到每个按钮事件里。

四、评审与验收盯住三个不变量

第一个不变量是控制入口唯一。先在应用内播放,再从系统播控暂停,最后回到应用继续播放,三个动作都应该通过 AudioEngine。检查代码时,桥接层中不应出现另一份播放器创建逻辑,页面也不应绕过引擎直接调用底层对象。只有入口真正收敛,后续后台任务和焦点策略才能覆盖所有控制来源。

第二个不变量是事实只发布一次来源。系统卡片显示暂停时,应用也应显示暂停;音频完成后,两边都不应继续按播放状态推算时间。可以重点验证准备阶段、快速播放暂停、播放完成和错误恢复。若只有锁屏卡片异常,先检查元数据、激活状态与状态上报,不要盲目改动解码路径。

第三个不变量是销毁可等待。退出业务时先停止接收新命令,再结束状态发布,随后销毁会话和释放播放器。重复进入场景应在旧清理完成后再建立新实例。官方对创建失败的排查也强调了这一点:异步销毁已经被调用,不等于销毁已经结束。页面切换速度越快,这个差异越容易暴露。

媒体状态映射中的事实来源与毫秒时间字段批注

验收不能只看系统卡片有没有出现。卡片存在只能说明部分接入步骤成立,还要核对命令回调是否真正到达、引擎是否接受、状态是否回写。尤其不要为了让卡片出现就一直上报播放中;这会把暂停、完成甚至失败都伪装成活跃播放,后面接入后台运行时还可能形成错误的资源申请依据。

这一阶段并没有获得无限后台执行资格。媒体会话提供统一展示与控制,后台音频还需要按系统要求管理长时任务。先将会话与播放器的状态边界固定下来,第三篇再解决进度拖动的临时交互状态,第四篇再扩展生命周期。每次只新增一种状态来源,整个播放链路才容易被复核。

还有一条容易在产品联调中被忽略的验收路径:从系统卡片点击返回应用。当前单页面工程沿用创建会话时的 Ability,因此可以使用默认拉起行为;如果以后增加详情页和播放页分流,则应明确设置启动目标和恢复参数。恢复页面之后只重新订阅已有播放业务,不因为界面重新出现就覆盖正在播放的资源。

对于机型或系统版本差异,先分清“应用有没有上报”和“系统选择如何呈现”。应用能够保证元数据、状态和控制命令实现完整,却不应把某一款设备上的卡片位置、按钮排列当成跨版本协议。验收记录应围绕可观察操作和状态一致性,布局差异另行记录,避免为了匹配一张截图改坏通用控制逻辑。

参考资料

Logo

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

更多推荐