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 分层中的位置与数据流
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 落地步骤
- 盘点播放器场景:列出 App 内所有播放器出现的位置(列表、详情、小窗、后台),确定并发数。
- 选择模式:并发数 ≤ 1 选用单例;并发数 > 1 选用分片。
- 定义统一接口:确保两种模式对外暴露的
PlayerStateContainer接口完全一致,以便后续切换。 - 实现状态仓库:单例模式直接使用
object或 Dagger/Hilt 的@Singleton;分片模式使用 Hilt 的@ActivityScoped或手动创建ViewModelStoreOwner关联生命周期。 - 编写契约测试:为
PlayerStateContainer接口编写一套测试用例,确保两种实现都能通过同一套测试。
3.8 验收标准
- ✅ 在并发多播放器场景切换时,各播放器的状态(进度、播放状态、错误信息)互不污染。
- ✅ 在单例模式下,播放器释放后状态自动重置为
IDLE初始态。 - ✅ 调用
dispatch(action)后,stateFlow仅在状态实际变化时发射新值,禁止无意义的重复发射。 - ✅ 状态仓库不存在任何内存泄漏(LeakCanary 检测通过)。
- ✅ 替换状态仓库实现方式(单例 ↔ 分片)时,仅需修改 Injection 层的 Scope 注解,无需改动 ViewModel 和 View 层代码。