3. 状态容器设计:单例状态仓库 vs 分片状态仓库,选择依据与接口约定

3.1 边界与职责定义

状态容器是播放器架构中处于 ViewModel 与 底层播放器引擎 之间的核心枢纽。它的职责与边界必须严格限定:

  • 职责:存储播放器全部运行时状态(播放/暂停、进度、缓冲、音量、播放列表等);对外提供可观察的状态访问入口;管理状态变更的同步与分发。
  • 边界:不包含任何业务逻辑、UI 逻辑或网络请求逻辑;不直接操作播放器 API;不持有 View 或 Context 强引用。
维度 状态容器职责界限
可访问 ViewModel、Service、跨组件通信层
可修改 仅限 ViewModel 中预定义的 Action 函数
不可访问 View、Adapter、Network 层

3.2 两种模式核心对比

架构实践中主要面临两种方案:单例状态仓库(Global Singleton Repository) 与 分片状态仓库(Scoped Slice Repository)。两者的核心差异体现在作用域、耦合度与生命周期管理上。

核心权衡点:当 App 内存在多个独立播放器实例(如 Feed 流自动播放 + 全屏播放器 + 悬浮小窗)时,状态仓库的实例划分方式直接影响数据一致性和内存开销。
对比维度 单例状态仓库 分片状态仓库
实例数量 全局唯一 1 个 按场景划分多个(N 个)
作用域 Application 级别 Activity / Fragment / 页面 Scope
状态隔离性 弱(所有播放器共享一份状态) 强(不同播放器拥有独立状态)
依赖复杂度 低(可直接注入) 中(需 Scope 管理 + 手动传递)
生命周期 与 App 共存亡 与宿主页面共存亡
典型场景 单播放器 App、悬浮窗与主播放器共用状态 多播放器并行、Feed + 详情 + 小窗独立状态

3.3 接口约定:通用契约设计

无论选择哪种模式,状态仓库对外的接口应遵循统一的约定,以保证使用者的可替换性与测试便利性。

// 状态数据接口
interface PlayerState {
    val playbackState: PlaybackState   // IDLE | PREPARING | PLAYING | PAUSED | ENDED | ERROR
    val positionMs: Long
    val durationMs: Long
    val bufferedPercent: Int
    val volume: Float
    val error: PlayerError?
}

// 状态容器接口
interface PlayerStateContainer {
    // 提供可观察的状态流
    val stateFlow: StateFlow<PlayerState>

    // 允许外部订阅特定字段变化(优化性能)
    fun observeByField(selector: (PlayerState) -> Any?): Flow<Any?>

    // 快照读取(非响应式)
    fun getSnapshot(): PlayerState

    // 内部状态变更入口(仅供 ViewModel 使用)
    fun dispatch(action: PlayerAction)

    // 清空状态(页面销毁时调用)
    fun clear()
}

信任链路:状态从 Player Engine —> Container —> ViewModel —> View,形成单向数据流。Container 是唯一状态源(Single Source of Truth),ViewModel 负责编排 Action,View 只做 Observe。

风险点:分片仓库模式下,如果不同切片之间需要共享部分状态(如全局静音、夜间模式),建议额外引入一个轻量级 跨切片路由表 或 SharedStateBridge,避免切片直接耦合。

3.4 内联 SVG 框架图:状态容器在 MVVM 分层中的位置与数据流

MVVM + 状态容器分层架构(数据流与信任链路) View 层(Activity / Fragment / Compose) 仅订阅 stateFlow,单向绑定,不修改状态 observe(stateFlow) ViewModel 层(编排 Action) 调用 dispatch(action) 触发状态变更 dispatch(action) 状态容器(State Container) 单例 vs 分片决策在此层实现 调用 Engine API 播放器引擎(ExoPlayer / IJK / Media3) 只执行播放指令,不持有业务状态 ⚠ 选型关键点 多播放器 ⇒ 分片

3.5 选型权衡矩阵

站在架构决策者的角度,从以下四项硬指标对两种模式进行评分(1~5 分,5 分最优):

评估指标 单例状态仓库 分片状态仓库
数据一致性 5(全局共享天然一致) 3(需额外同步机制)
内存开销 4(低,仅 1 份状态) 3(N 份状态,需注意 N 数量)
状态隔离性 1(所有播放器共用状态,易冲突) 5(完全隔离,互不干扰)
实现与测试复杂度 5(简单,单例注入即可) 2(需 Scope 管理 + 手动生命周期绑定)

结论型建议:如果 App 内同一时刻只有一个活跃播放器实例(如主流的单视频播放 App),优先使用 单例状态仓库,降低实现复杂度与出错概率;若 App 存在多个播放器实体并行运行(如短视频 App 中 Feed 流自动播放 + 用户点进详情页播放),必须采用 分片状态仓库,否则状态混乱将无法根除。

3.6 风险点与规避策略

  • 风险1:分片仓库导致状态冗余 — 避免过度细分,通常按页面级粒度划分即可(如 FeedScope、DetailScope、FloatingScope),不建议按播放器实例粒度为每个列表项创建独立仓库。
  • 风险2:单例仓库在异常场景下的状态残留 — 每次播放器释放后,必须调用 clear() 重置状态为初始值,否则下次启动可能残留上一次的错误信息或进度。
  • 风险3:跨切片通信缺乏契约 — 如分片仓库需要共享播放列表或用户偏好,应通过 全局轻量级状态桥接器(如 SharedPreferences + Flow)而非直接传递仓库引用。

3.7 落地步骤

  1. 盘点播放器场景:列出 App 内所有播放器出现的位置(列表、详情、小窗、后台),确定并发数。
  2. 选择模式:并发数 ≤ 1 选用单例;并发数 > 1 选用分片。
  3. 定义统一接口:确保两种模式对外暴露的 PlayerStateContainer 接口完全一致,以便后续切换。
  4. 实现状态仓库:单例模式直接使用 object 或 Dagger/Hilt 的 @Singleton;分片模式使用 Hilt 的 @ActivityScoped 或手动创建 ViewModelStoreOwner 关联生命周期。
  5. 编写契约测试:为 PlayerStateContainer 接口编写一套测试用例,确保两种实现都能通过同一套测试。

3.8 验收标准

  • ✅ 在并发多播放器场景切换时,各播放器的状态(进度、播放状态、错误信息)互不污染。
  • ✅ 在单例模式下,播放器释放后状态自动重置为 IDLE 初始态。
  • ✅ 调用 dispatch(action) 后,stateFlow 仅在状态实际变化时发射新值,禁止无意义的重复发射。
  • ✅ 状态仓库不存在任何内存泄漏(LeakCanary 检测通过)。
  • ✅ 替换状态仓库实现方式(单例 ↔ 分片)时,仅需修改 Injection 层的 Scope 注解,无需改动 ViewModel 和 View 层代码。