作者:李游
播放器缩成闪控窗以后,暂停按钮偶尔要点两次;把窗口展开回主页面,进度又会倒退半秒。音频没有断,AVSession 也仍然活着,表面看只是 UI 抖动。真正的问题是主页面和闪控窗都认为自己拥有播放控制权。

一、同一个暂停动作,日志里出现了两条命令
Demo 叫 PulseDock Lab ,会话编号 av_20261001_07,当前曲目 track_042。测试过程是:主页面播放到 02:18 / 04:32,缩成闪控窗,点击一次暂停,再立刻展开。复现时 HiLog 会出现两条相同来源时间的 PAUSE,序号却来自两个计数器。
第一条由闪控窗按钮发给播放服务,第二条来自主页面恢复时重新绑定的回调。播放服务把暂停当成幂等命令还不算最坏;如果重复的是 SEEK +10s,进度会跳二十秒。早期版本为了"确保执行",超时还会重发,三个机制叠起来就成了用户感知上的随机失控。
我一开始怀疑 AVSession 回调重复注册,逐个注销以后现象减少,却没有消失。继续看日志才发现,窗口重建两次时主页面产生了新的本地序号,闪控窗仍持有旧序号。只按 seq 去重不够,因为两个来源都可能发出 1;只按时间去重也不可靠,用户快速连点本来就是合法操作。
这次改造的目标被压缩成三条:控制权只能属于一个可见端;所有命令共用会话级序号;窗口销毁只解绑视图,不销毁仍在播放的 AVSession。最终验收数据固定为:所有者 MAIN → FLOAT → MAIN,lastSeq=184,丢弃重复命令 3 条,丢失命令 0 条,窗口重建 2 次,状态 OWNER_RESTORED。
二、AVSession 是播放事实,窗口只是控制入口
第一版把 isPlaying、position 和按钮回调各放一份在主页面与闪控窗里。两个页面都能显示,但状态来源变成了两套。现在 PlaybackCoordinator 是唯一事实源:它持有 AVSession、播放器适配器和命令闸门;页面只订阅快照,不能直接改播放器。
text
entry/src/main/ets
├── pages/PulseDockPage.ets
├── float/FloatPlayerPage.ets
├── session/PlaybackCoordinator.ets
├── session/CommandGate.ets
├── session/OwnerLease.ets
├── adapter/AVSessionAdapter.ets
├── model/PlaybackSnapshot.ets
└── diagnostics/SessionTrace.ets
这层拆分有一个很实际的判断:主页面销毁不等于播放结束,闪控窗关闭也不一定意味着用户要停止音乐。只有用户明确停止、播放队列结束或业务会话被回收时,才销毁 AVSession。窗口生命周期只负责 attach 与 detach。
三、命令信封里必须带来源、租约和序号
下面这段代码解决"两个界面分别编号,无法可靠去重"的问题。命令由协调器统一分配 seq,同时携带当前所有者租约 leaseEpoch。闸门先校验会话和租约,再比较序号;已经执行过的命令直接丢弃并计入 duplicateDropped。
ts
export interface PlaybackCommand {
sessionId: string
source: 'MAIN' | 'FLOAT' | 'SYSTEM'
type: 'PLAY' | 'PAUSE' | 'SEEK'
value?: number
leaseEpoch: number
seq: number
}
export class CommandGate {
private lastSeq: number = 0
duplicateDropped: number = 0
accept(cmd: PlaybackCommand, owner: OwnerLease): boolean {
if (cmd.sessionId !== owner.sessionId) return false
if (cmd.leaseEpoch !== owner.epoch || cmd.source !== owner.name) return false
if (cmd.seq <= this.lastSeq) {
this.duplicateDropped++
return false
}
this.lastSeq = cmd.seq
return true
}
}
seq 由会话级协调器递增,而不是按钮组件递增。所有者从 MAIN 切到 FLOAT 时,序号继续从 181 走到 182,不会归零。leaseEpoch 解决的是旧窗口晚到事件:旧主页面即使保存着合法 sessionId,它的 epoch 已经过期,命令也不能进入播放器。
这段代码并不负责执行播放,它只回答"这条命令是否仍有资格执行"。通过后才交给 adapter 调用实际播放器,再把新的播放状态同步给 AVSession。若执行失败,序号也不回退;重试要创建一条新命令,并在 trace 中关联原 seq,避免同一序号出现两种结果。
系统媒体控制属于第三种来源 SYSTEM。实际项目可以为系统命令设置独立授权路径,不能简单要求它等于窗口 owner。Demo 为了突出窗口移交,把系统回调先转交协调器,再由协调器按当前会话生成统一序号,因此 CommandGate 最终看到的仍是当前有效租约。
命令信封还解决了一类很难复现的错位:上一首歌曲的 SEEK 晚到,恰好作用在下一首歌。正式实现会再带 trackRevision,播放器切换曲目时 revision 增加;旧 revision 的命令即使 seq 更新也被拒绝。Demo 固定在 track_042,所以图片没有展示这个字段,但测试已经覆盖"切歌后旧拖动回调返回"的场景。
我没有用防抖替代去重。防抖会吞掉用户在短时间内真实的两次操作,例如暂停后立即播放;命令闸门判断的是身份、租约和顺序,不是简单比较时间间隔。连续的 PAUSE 与 PLAY 有不同 seq,会按顺序执行;同一个旧 seq 被重复投递才会进入 duplicateDropped。
序号不写进 Preferences。它只在当前 AVSession 生命周期内递增,进程重启后新的 sessionId 自然建立新序列。把上一次进程的 seq 恢复出来反而容易让日志看似连续、会话语义却已经改变。需要跨进程恢复的是曲目与 position,而不是旧命令的执行资格。
四、控制权移交是两阶段动作
窗口缩小时不能先让闪控窗接管、再慢慢解绑主页面,否则短暂重叠期间两个按钮都有效;也不能先释放 AVSession,否则音频会中断。这里采用两阶段移交:先冻结旧入口并生成新 epoch,再等新视图完成 attach,最后提交 owner。
下面这段代码解决"新窗口还没有可用,旧窗口已经失去控制"的空档。提交前,协调器缓存最新播放快照;新视图确认渲染完成后拿到同一份快照,再成为 owner。
ts
async handoffOwner(next: 'MAIN' | 'FLOAT'): Promise<void> {
if (this.owner.name === next && this.owner.attached) return
const epoch = this.owner.epoch + 1
const previous = this.owner
previous.acceptInput = false
this.trace.mark('HANDOFF_BEGIN', previous.name, next, epoch)
const ready = await this.views.waitUntilReady(next, epoch, 300)
if (!ready) {
previous.acceptInput = true
this.trace.mark('HANDOFF_ROLLBACK', previous.name, next, epoch)
return
}
this.owner = {
sessionId: 'av_20261001_07', name: next, epoch,
attached: true, acceptInput: true
}
this.views.publish(next, this.snapshot)
this.views.detach(previous.name, previous.epoch)
this.trace.mark('HANDOFF_COMMIT', previous.name, next, epoch)
}
超时 300 ms 不是"等够时间就成功",而是回滚上限。新闪控窗没有 ready,旧主页面恢复输入权;播放本身继续,用户不会得到一个既不能操作又没有错误提示的悬空状态。正式产品应按窗口创建耗时分布校准阈值,并记录超时设备形态。
publish() 发生在 owner 提交后,快照包含 PLAYING、02:18 / 04:32 和 track_042。新页面不能根据自己的默认值反向覆盖协调器。如果页面刚创建就上报 00:00,那是视图初始化数据,不是播放事实。
重复调用同一个 next 时直接返回,但"名称相同、实例已重建"不能被忽略,因此条件里还检查 attached。窗口重建两次后,owner 名仍可能是 FLOAT,旧实例的 epoch 却必须失效。
移交期间的播放进度仍由播放器推进。UI 冻结的是输入权,不是状态更新;协调器继续采样 position,并在新 owner 提交时发送最新快照。因此从主页面缩到闪控窗不会先显示 02:18、下一帧突然跳到 02:20。旧页面因为 acceptInput=false 不能发命令,但仍可在离场动画中接收只读快照,画面不会僵住。
如果新窗口 ready 后、提交前应用进入后台,事务会再次检查可见性策略。允许后台播放的产品可以继续提交 FLOAT;不允许的产品则回滚并把会话置为受限状态。这个判断放在 coordinator,而不是散落在两个页面里,否则不同入口会产生不同后台行为。
闪控窗的内容也要克制。它只保留标题、播放状态、进度和三个核心按钮,不把完整播放列表搬进小窗口。控件越多,窗口重建时需要恢复的本地状态越多,也越容易绕过统一命令入口。页面交互能力与会话所有权是两件事,能展示不等于能修改。
五、页面释放的是订阅,不是会话
第三段代码解决生命周期里最容易误写的一句:aboutToDisappear() 里直接 session.destroy()。现在页面只保存订阅 token,退出时注销;只有 coordinator 的业务 stopSession() 才停止播放器并销毁 AVSession。
ts
@Component
struct FloatPlayerPage {
private subscriptionId: number = 0
@State snapshot: PlaybackSnapshot = PlaybackSnapshot.empty()
aboutToAppear(): void {
this.subscriptionId = playbackCoordinator.attachView('FLOAT', (value) => {
this.snapshot = value
})
}
aboutToDisappear(): void {
playbackCoordinator.detachView('FLOAT', this.subscriptionId)
this.subscriptionId = 0
}
private pause(): void {
playbackCoordinator.dispatch('FLOAT', 'PAUSE')
}
}
detachView() 可重复调用,token 为零时直接返回。页面快速开关、系统重建和异常退出可能走不同路径,幂等释放可以减少调用方分支。订阅回调里也会检查 token 与 epoch,防止旧页面收到新快照后再次重绘。
如果音频已经停止且没有任何视图订阅,coordinator 会进入延迟回收;若只是从 MAIN 切到 FLOAT,订阅数短暂为零不能立刻销毁。Demo 用 handoff 标记保护这段窗口,正式项目还要结合后台播放策略和长时任务要求。
AVSession 的事件注册与注销必须成对。适配器内部保存实际 callback 引用,不能在 off 时重新创建一个长得相同的箭头函数;后者不是原对象,监听仍会残留。为了验证这一点,我记录 listenerCount,每次窗口往返以后数值保持不变。窗口重建 2 次而回调次数翻倍,几乎可以直接判断是解绑失败。
音频焦点变化不归窗口 owner 控制。来电或其他媒体抢占时,播放器进入系统要求的状态,coordinator 生成新的只读快照,同步给 MAIN 或 FLOAT。页面不能因为自己仍是 owner 就自动 resume;owner 表示谁能提交用户命令,不代表它能越过系统音频策略。
资源释放顺序也固定下来:先关闭命令入口,再注销 AVSession 回调,随后停止播放器,最后销毁会话对象。反过来先 destroy,晚到回调可能访问已经清空的 snapshot。释放过程可重复执行,每一步都有状态检查,避免异常路径二次关闭抛错。

开发截图中,左侧是 session / float / adapter / diagnostics 目录,中间打开 PlaybackCoordinator.ets,右侧模拟器运行 PulseDock Lab 。底部 HiLog 固定显示:session=av_20261001_07、track=track_042、owner=MAIN->FLOAT->MAIN、leaseEpoch=9、lastSeq=184、duplicateDropped=3、lost=0、recreate=2 state=OWNER_RESTORED。
六、三条重复命令是怎么被确认的
我把破坏性测试分成四组。第一组在 handoff 期间连续点两次暂停,确认只有当前 owner 的命令进入;第二组让旧主页面延迟 80 ms 发送 seek,epoch 校验应拒绝;第三组模拟发送成功但 UI 未收到确认,页面重发同一业务动作,协调器生成新 seq 并关联原命令;第四组连续重建闪控窗两次,旧订阅不能复活。
结果里 duplicateDropped=3 不是错误数,而是保护机制捕获的三次重复输入。lost=0 表示所有通过闸门的命令都有执行结果,不能用"丢弃了重复项"掩盖真正丢失。两项指标必须一起看。
进度同步采用单向快照。播放器每秒推进 position,协调器更新 AVSession,再通知当前 owner;页面拖动进度时先发 SEEK 命令,收到执行结果后再确认位置。没有把滑块每一个像素变化都当命令,避免用户拖动时形成命令风暴。
我另外测了耳机按键与锁屏控制。它们进入统一调度以后,会在 trace 中标成 SYSTEM,再映射成当前会话命令;主页面与闪控窗只收到结果,不重复模拟点击。这样同一时刻按耳机暂停、又点闪控窗播放,日志能按 seq 还原先后,而不是留下两套互不相干的布尔状态。
性能指标里没有追求极低的毫秒数,重点是切换期间不阻塞主线程。窗口 ready 等待基于回调,trace 写入使用轻量结构,较重的日志落盘在后台整理。若为了完整诊断在每个 position 回调里同步写文件,播放器不卡,界面也可能被日志拖慢。
验收脚本还会比较 AVSession 元数据和页面快照:曲目 ID、播放状态、position 误差都在范围内才通过。只检查按钮图标会漏掉系统媒体中心仍显示旧歌曲的问题。视图、播放器与系统会话三方一致,才算一次 handoff 结束。
异常路径也保留了清楚的状态:新窗口创建失败为 HANDOFF_ROLLBACK;会话已结束为 SESSION_CLOSED;播放器执行失败为 COMMAND_FAILED。只有 owner 已回到 MAIN、最后序号为 184、订阅与播放器一致时,才写 OWNER_RESTORED。
七、手机页证明的是控制权,而不只是播放器外观
07:28 的运行页显示曲目 track_042 正在 02:18 / 04:32 播放,owner 已经历 MAIN → FLOAT → MAIN,当前 lease epoch 9,最后命令序号 184,重复丢弃 3、命令丢失 0、窗口重建 2,最终状态 OWNER_RESTORED。

红色批注只指向 leaseEpoch 9 和 lost 0。前者证明旧窗口已经失去资格,后者证明去重没有误伤合法命令。运行图是一个纯手机屏幕,不额外拼接主页面与闪控窗两个方案;所有者变化用状态轨迹表达。
这次修复以后,闪控窗不再是第二个播放器,而是同一会话的临时控制入口。AVSession 保存系统可见的播放事实,coordinator 决定谁能发命令,窗口只负责呈现与输入。窗口可以重建,租约可以更新,播放会话不必跟着反复销毁。
仍需强调边界:Demo 没覆盖跨设备播控,也没有处理多个系统控制器竞争;后台播放策略应按应用类型和平台规范实现。对普通短音频,窗口关闭后直接停止可能更合理。工程封装的价值不是强迫所有产品后台播放,而是让"谁拥有控制权、何时释放会话"成为明确决策。
参考资料: