2026-04-04-unified-background-playback-design.md 10 KB

统一后台播放宿主设计

背景

当前项目同时存在两条独立的音乐播放链路:

  • LocalMusic.ets 页面内播放器链路
    • 页面持有 mIjkMediaPlayer
    • 页面直接负责起播、暂停、切歌、进度控制
    • 页面直接负责 AVSession 更新
  • BackgroundAudioPlaybackHost.ets 后台宿主链路
    • 卡片冷启动控播走后台宿主
    • 强杀后卡片播放由后台宿主恢复队列并起播

这两条链路同时存在,直接导致以下问题:

  • 进入 app 后,页面播放条和后台宿主状态不一致
  • LocalMusic 本地播放和卡片后台播放会双播
  • AVSession / 卡片 / 页面显示不是同一真源
  • “强杀后卡片播放可用但不拉前台”和“进入 app 后页面无缝接管”无法同时满足

目标

统一为唯一播放链路,满足以下行为:

  1. 唯一播放器永远是后台独立宿主,不再由 LocalMusic 持有另一套本地播放器。
  2. 音乐卡片、后台控播、进入 app 后的页面控播,全部走同一条播放路径。
  3. 强杀 app 后,点击卡片播放可以直接播放,不拉起 app 前台。
  4. 点击卡片进入 app 时,页面只能“接管显示和控制”,不能重新 doPlay()、不能重建播放器。
  5. 页面上的当前歌曲、播放状态、进度、时长、封面、AVSession 播放条,都以后台宿主为唯一真源。
  6. 彻底消除双播。

非目标

本次第一轮统一不追求一次性重构所有复杂播放分支。

以下内容不作为第一阶段必须完成项:

  • 所有投播分支的彻底统一
  • 所有特殊格式的完整播放能力迁移
  • 所有歌词、均衡器、HiCar、画中画等衍生能力的一次性全量迁移

第一阶段优先解决“普通音乐主链路”的统一,包括:

  • 卡片控播
  • 后台起播
  • 进入 app 后状态同步
  • 页面控播
  • AVSession / 播放条
  • 双播消除

现状结论

当前架构中:

  • PlaybackCoordinator 只是一个运行时转发器
  • 真正的播放 runtime 仍然由 LocalMusic 页面注册
  • EntryAbility 在无 runtime 时,才退回 BackgroundAudioPlaybackHost

因此系统实际是:

  • 卡片冷启动时走后台宿主
  • 页面活着时优先走 LocalMusic runtime

这不是“同一路径的不同入口”,而是“两个播放器并存”。双播是架构必然结果,不是单点 bug。

方案选择

方案 A:彻底单宿主化

做法:

  • BackgroundAudioPlaybackHost 成为唯一播放器和唯一 runtime
  • LocalMusic 只做 UI 与状态展示,不再自己持有和控制播放器

优点:

  • 从根上消除双播
  • 卡片、后台、页面全部统一
  • AVSession、卡片、页面状态可天然统一

缺点:

  • 改动范围较大
  • 需要拆出页面里混杂的播放器职责

方案 B:双播放器保留,增加接管/互斥

做法:

  • 保留 LocalMusic 和后台宿主
  • 在进入 app 时尝试从后台宿主迁移到页面播放器

优点:

  • 短期改动较小

缺点:

  • 本质仍有两条链路
  • 容易继续出现双播、状态漂移、AVSession 不一致
  • 不符合“真正完全独立且统一路径”的目标

方案 C:卡片走后台,进入 app 后迁回页面播放器

做法:

  • 后台宿主仅服务卡片冷启动
  • 页面打开后重新交回 LocalMusic

优点:

  • 最容易做

缺点:

  • 直接违背目标
  • 仍是两套播放器

结论

采用方案 A:彻底单宿主化。

目标架构

1. 唯一播放内核

BackgroundAudioPlaybackHost 成为整个应用唯一播放内核,负责:

  • 持有唯一 IjkMediaPlayer
  • 起播 / 暂停 / 停止 / 上一首 / 下一首 / seek
  • 持有当前播放队列、当前索引、当前歌曲、播放模式
  • 统一记忆播放恢复
  • 统一 AVSession 更新
  • 统一卡片更新
  • 统一后台任务管理
  • 统一发布当前播放快照

2. 统一控制入口

MusicPlaybackController / PlaybackCoordinator 继续作为控制入口,但只保留“转发到唯一宿主”的职责:

  • UI 点击播放/暂停
  • 卡片按钮控制
  • AVSession 回调控制
  • 页面快捷键和其他入口控制

所有控制命令最终都落到后台宿主,不再由 LocalMusic 自己直控播放器。

3. 页面层降为纯 UI 壳

LocalMusic 改为播放界面层:

  • 读取后台宿主发布的播放快照
  • 展示当前歌曲、进度、时长、播放状态、封面、歌词
  • 将页面上的播放/暂停/上一首/下一首/seek 操作转发给控制层
  • 保留页面展示逻辑和非播放器 UI 逻辑

LocalMusic 不再:

  • 创建本地 IjkMediaPlayer
  • 直接 doPlay()
  • 直接 startPlayOrResumePlay()
  • 直接 playNext() / playPrevious() 驱动底层播放器
  • 直接写 AVSession

4. 单一状态真源

后台宿主维护唯一播放状态,并通过统一状态桥暴露给页面消费。

状态真源至少包括:

  • 当前队列
  • 当前索引
  • 当前歌曲
  • 是否播放中
  • 当前进度
  • 总时长
  • 封面
  • 播放模式

页面打开时先拉取一次完整快照,再持续监听状态变化。

关键职责划分

BackgroundAudioPlaybackHost

新增或收拢职责:

  • 对外暴露完整 PlaybackRuntime
  • 对外暴露当前播放快照
  • 对外提供状态变化通知
  • 成为唯一 AVSession 写入点
  • 成为唯一卡片同步点

PlaybackCoordinator

调整为:

  • 默认 runtime 直接绑定后台宿主
  • 不再依赖 LocalMusic 注册 runtime
  • 全局始终只有一个 runtime

MusicPlaybackController

保持:

  • 播放逻辑入口
  • 纯逻辑解析
  • 命令分发

变化:

  • 所有动作只走统一 runtime

LocalMusic

拆除职责:

  • 自己持有播放器
  • 自己写 AVSession
  • 自己维护另一套播放状态

保留职责:

  • 页面展示
  • 页面交互
  • 歌单展示
  • 歌词展示
  • 各类 UI 面板和辅助功能

数据流

统一后的数据流如下:

  1. 用户通过卡片 / 页面 / AVSession 发起控制命令。
  2. 命令进入 MusicPlaybackController
  3. MusicPlaybackController 转发给 PlaybackCoordinator
  4. PlaybackCoordinator 将命令转发给 BackgroundAudioPlaybackHost
  5. BackgroundAudioPlaybackHost 执行实际播放器操作。
  6. BackgroundAudioPlaybackHost 更新:
    • 卡片
    • AVSession
    • 全局播放快照
  7. LocalMusic 订阅并渲染该快照。

页面不再反向拥有播放状态,也不再反向驱动播放器。

进入 app 的行为设计

当后台宿主已经在播,用户从卡片进入 app 时:

  • 只能打开页面 UI
  • 页面必须直接读取后台宿主当前快照
  • 不允许再次 doPlay()
  • 不允许重建 IjkMediaPlayer
  • 不允许重新起播同一首歌

也就是说,进入 app 是“显示接管”,不是“播放器迁移”。

AVSession / 播放条设计

AVSession 的写入必须统一收口到后台宿主。

这样可保证:

  • 卡片播放时已有 AVSession
  • 进入 app 后系统播放条不会断链
  • 页面显示与系统播放条一致

LocalMusic 页面内部原有的 AVSession 初始化、状态更新、回调绑定逻辑,需要拆成两类:

  • 必须保留的控制入口:转发到统一宿主
  • 不再允许保留的状态写入:页面自己的 AVSession 更新

双播消除原则

双播的根因是存在两套底层播放器。

要彻底消除双播,必须同时满足:

  1. 页面层不再自行持有播放器并起播。
  2. 所有播放命令只走后台宿主。
  3. 进入页面时只同步状态,不重新起播。

只做其中任意一项都不够。

分阶段迁移策略

阶段 1:统一运行时入口

目标:

  • PlaybackCoordinator 永远绑定后台宿主
  • 卡片和页面的播放控制都进入后台宿主

结果:

  • 所有新的播放命令先统一到一个 runtime
  • 先把“入口分叉”问题收口

阶段 2:页面改为消费宿主状态

目标:

  • LocalMusic 页面上的播放状态、进度、歌曲、封面改为读取宿主快照
  • 页面进入时不再自己补建状态

结果:

  • 进 app 后能看到正确播放条
  • 页面与卡片、AVSession 状态一致

阶段 3:移除页面底层播放器职责

目标:

  • 删除 LocalMusic 内部本地播放器核心控制逻辑
  • 删除页面自己的 AVSession 写入路径

结果:

  • 从代码结构上彻底消除双播放器

阶段 4:逐项迁回复杂能力

目标:

  • 将歌词、随机播放、CUE、记忆播放、均衡器等能力逐步接回唯一宿主

原则:

  • 先保证主链路统一
  • 再逐项恢复复杂分支

文件级改造范围

第一阶段主要涉及:

  • entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets
  • entry/src/main/ets/controller/PlaybackCoordinator.ets
  • entry/src/main/ets/controller/MusicPlaybackController.ets
  • entry/src/main/ets/view/LocalMusic.ets
  • 必要时新增:
    • entry/src/main/ets/playback/BackgroundPlaybackStateStore.ets
    • entry/src/main/ets/playback/BackgroundPlaybackSnapshot.ets

风险与控制

风险 1:页面状态空白

原因:

  • 页面原本依赖自己内部播放器状态

控制:

  • 先建立宿主快照和订阅机制,再切 UI 读取路径

风险 2:复杂播放分支回归

原因:

  • LocalMusic 内部聚合了大量特殊播放逻辑

控制:

  • 第一阶段只统一主链路
  • 复杂分支分批迁移

风险 3:AVSession 回调回流冲突

原因:

  • 页面和后台宿主同时写 AVSession 时容易互相打架

控制:

  • AVSession 写入只允许后台宿主负责

测试策略

第一阶段至少验证以下场景:

  1. 强杀 app 后点击卡片播放,可直接播放,不拉前台。
  2. 播放中点击卡片进入 app,页面直接显示当前歌曲、进度、播放条。
  3. 进入 app 后点击播放/暂停、上一首、下一首,实际控制同一后台宿主。
  4. 页面播放过程中不会双播。
  5. 系统播放条状态与页面状态一致。
  6. 卡片状态、页面状态、AVSession 状态一致。

成功标准

当满足以下标准时,认为第一阶段完成:

  • 代码中只存在一套实际音乐播放器主链路
  • 卡片和页面控播落到同一宿主
  • 强杀后卡片播放可用
  • 进入 app 不重新起播
  • 不出现双播
  • 页面播放条与 AVSession 正常显示