HarmonyOS 7 + EasyGo-AVSession Kit:平行视界双窗播控的单主租约与迟到命令拒绝【鸿蒙心迹】
平行视界把列表和详情同时留在屏幕上,对听书、播客和课程类应用很友好。麻烦也随之出现:左栏列表可能有试听按钮,右栏详情有完整播放器,耳机按键和播控中心却只有一个“当前播放”的概念。如果两个页面各自维护播放状态,同一个暂停命令就可能落到错误窗口。
本文构造一个 DualCast 示例,页面叫 PlaybackLeasePage,任务编号为 AVLEASE-DUAL-0084。左栏实例是 LEFT-7,右栏实例是 RIGHT-9,媒体条目是 chapter-12,交接位置为 186000 ms。系统级 AVSession 只在 UIAbility 级创建一份,两栏通过应用层“播放租约”竞争主控权;租约代际从 28 切到 29 后,两个仍携带代际 28 的迟到命令被拒绝。
文中的窗口编号、时间和命令数量均是用于说明状态机的演示数据,不表示某款设备的真实运行记录。EasyGo 负责平行视界布局与路由体验,AVSession Kit 负责系统播控会话;“单主租约”是本文在两者之间增加的应用架构,不是系统自动提供的 API。

一、先看故障结论:两个播放按钮争夺一个系统会话
假设用户在右栏打开 chapter-12 并播放到 03:06,此时 RIGHT-9 是可见详情。随后用户在左栏点击另一条试听,但右栏的异步准备回调晚了几十毫秒才回来。若两个页面都能直接更新播放器和 AVSession,界面可能出现三种互相矛盾的结果。
第一种,左栏按钮显示播放,声音仍来自右栏。第二种,声音已经切到左栏,播控中心标题仍是 chapter-12。第三种更隐蔽:耳机按下暂停,旧的右栏监听器先响应,把一个已经不属于它的播放器状态写回系统会话。
这不是 EasyGo 分栏本身的错误,而是应用把页面可见性误当成播放所有权。平行视界中两个页面可以同时可见,生命周期也可能重叠。“当前显示在右侧”“最后点击的按钮”“系统会话的控制对象”是三个概念,不能用一个布尔值替代。
官方 AVSession 指南明确说明,音视频应用作为提供方需要创建并激活会话,设置元数据和播放状态,监听 play、pause 等控制命令;同一 UIAbility 只能创建一个 AVSession。这个限制反而给架构一个清晰答案:页面不能各自创建会话,应该有一个 UIAbility 级宿主,两个窗格只是命令来源和状态消费者。
DualCast 最终采用四层:AvSessionHost 独占系统会话;PlaybackEngine 管理实际播放;PlaybackLeaseCoordinator 决定哪个窗格拥有控制权;左右页面只提交带 paneId 和 leaseEpoch 的意图。系统命令也先进入协调器,再转发给当前所有者。
二、AVSession 应该属于 UIAbility,而不是页面组件
页面组件的出现和销毁受路由、分栏模式与缓存策略影响。若在 aboutToAppear 创建 AVSession,在 aboutToDisappear 销毁,平行视界切换一次就可能创建、激活、注销多次。更麻烦的是,右栏页面离开并不一定意味着音频应该停止。
这段代码解决什么问题:在 UIAbility 级创建唯一 AVSession,集中注册播控命令,并在退出时成对注销和销毁。
// media/AvSessionHost.ets
import { avSession } from '@kit.AVSessionKit'
import { common } from '@kit.AbilityKit'
export class AvSessionHost {
private session?: avSession.AVSession
private readonly onPlay = (): void => PlaybackLeaseCoordinator.handleSystem('PLAY')
private readonly onPause = (): void => PlaybackLeaseCoordinator.handleSystem('PAUSE')
async start(context: common.UIAbilityContext): Promise<void> {
if (this.session) return
this.session = await avSession.createAVSession(context, 'dualcast.audio', 'audio')
this.session.on('play', this.onPlay)
this.session.on('pause', this.onPause)
await this.session.activate()
}
async reportPlaying(assetId: string, positionMs: number): Promise<void> {
if (!this.session) throw new Error('SESSION_NOT_READY')
await this.session.setAVMetadata({ assetId, title: '第十二章 · 单主租约' })
await this.session.setAVPlaybackState({
state: avSession.PlaybackState.PLAYBACK_STATE_PLAY,
position: { elapsedTime: positionMs, updateTime: Date.now() }
})
}
async stop(): Promise<void> {
if (!this.session) return
this.session.off('play', this.onPlay)
this.session.off('pause', this.onPause)
await this.session.deactivate()
await this.session.destroy()
this.session = undefined
}
}
监听函数保存为稳定字段,是为了让 off 使用相同引用。若注册时使用匿名函数,销毁时又写一段相同代码,二者并不是同一个监听器。页面切换数次后,旧监听仍可能收到命令,于是一次耳机按键触发多次暂停。
start() 具有幂等判断,但这不等于可以在任意页面反复调用。更合适的入口仍是 UIAbility 或明确的媒体服务初始化阶段。stop() 只在应用确实结束媒体会话时执行;页面离开但后台播放继续时,不应因为 UI 消失而销毁系统会话。
reportPlaying 先设置元数据,再设置播放状态。真正项目还要根据播放引擎状态更新暂停、缓冲、完成和错误。不能为了让播控中心显示“正在播放”,提前报告一个尚未开始的状态。AVSession 是系统看到的事实镜像,不是驱动播放器的唯一真相。
三、租约不是锁,而是一份带代际的所有权快照
普通互斥锁解决同一时刻只有一个任务进入临界区,却不解决异步回调迟到。右栏在代际 28 启动媒体准备,左栏在代际 29 取得租约;即使右栏回调获得锁,它仍然不应该提交。需要比较的是回调启动时拿到的租约代际。
DualCast 的租约包含 ownerPaneId、assetId、positionMs、epoch 和 grantedAt。每次所有者变化,代际严格递增。页面只能持有快照,不能直接修改全局租约。
这段代码解决什么问题:把双窗播放所有权收敛到单一协调器,并用递增代际表达每次交接。
// media/PlaybackLeaseCoordinator.ets
export interface PlaybackLease {
ownerPaneId: string
assetId: string
positionMs: number
epoch: number
grantedAt: number
}
export class PlaybackLeaseCoordinator {
private static lease?: PlaybackLease
private static epoch: number = 27
static request(paneId: string, assetId: string, positionMs: number): PlaybackLease {
this.epoch += 1
this.lease = {
ownerPaneId: paneId,
assetId,
positionMs,
epoch: this.epoch,
grantedAt: Date.now()
}
AppStorage.setOrCreate<PlaybackLease>('playbackLease', this.lease)
return { ...this.lease }
}
static current(): PlaybackLease | undefined {
return this.lease ? { ...this.lease } : undefined
}
static owns(paneId: string, epoch: number): boolean {
return this.lease?.ownerPaneId === paneId && this.lease.epoch === epoch
}
}
示例从初始值 27 开始,右栏请求后得到代际 28,左栏交接后得到 29。初始值本身没有业务意义,真正要求是同一运行会话内单调递增。应用重启后可以重置,但持久化恢复时要明确旧命令已经失效,不能让上次进程的代际与新进程混用。
返回租约副本可以减少页面误改协调器状态的风险。AppStorage 负责把当前快照分发给界面,不承担原子所有权;真正的写入口仍只有 request()。如果多个线程或 Worker 都能申请租约,还需要串行化写入,不能把普通静态字段误当成跨线程安全结构。
交接时携带当前位置 186000 ms,是为了让新所有者决定继续、重播或仅显示状态。位置不是所有权证明,不能用“谁的位置更新更晚”判断主控。网络波动下旧播放器可能不断上报更大的时间值,正因如此才需要独立的代际。
四、窗口标识必须稳定,不能使用数组下标
LEFT-7 和 RIGHT-9 是 DualCast 的页面实例标识,不是左右位置的永久名称。平行视界发生路由切换后,原右栏页面可能移到左侧,新详情进入右侧。如果所有权只记录 RIGHT,命令会随着位置变化错误地转给另一个页面。
页面实例创建时获得稳定 paneId,路由或布局位置作为另外的属性。RIGHT-9 移到左侧后,它仍是同一播放实例;只有新的用户意图或业务规则触发租约交接,位置变化本身不自动夺权。
DevEco Studio 演示图展示 AvSessionHost.ets、PlaybackLeaseCoordinator.ets、PlaybackCommandRouter.ets 和 PlaybackLeasePage.ets。右侧模拟器同时显示左右窗格,底部 HiLog 记录代际 28→29 与两条迟到命令。画面按本文状态制作,用于讲解代码关系,不是实际 IDE 或设备证明。

五、所有异步提交都要在最后一步检查租约
最危险的代码通常长这样:右栏点击播放,保存 isOwner=true,等待媒体准备,回调回来后开始播放。问题是等待期间所有权已经改变,布尔值没有版本,回调无法知道自己属于哪一轮请求。
这段代码解决什么问题:让媒体准备、播放和状态上报共享同一个租约代际,在提交点拒绝迟到结果。
// media/PlaybackCommandRouter.ets
interface PlayIntent {
paneId: string
assetId: string
startPositionMs: number
}
export async function playFromPane(intent: PlayIntent): Promise<void> {
const lease = PlaybackLeaseCoordinator.request(
intent.paneId,
intent.assetId,
intent.startPositionMs
)
await PlaybackEngine.prepare(intent.assetId, intent.startPositionMs)
if (!PlaybackLeaseCoordinator.owns(intent.paneId, lease.epoch)) {
console.info(`[DualCast] drop pane=${intent.paneId} epoch=${lease.epoch}`)
return
}
await PlaybackEngine.play()
if (!PlaybackLeaseCoordinator.owns(intent.paneId, lease.epoch)) {
await PlaybackEngine.pause()
console.info(`[DualCast] rollback stale pane=${intent.paneId}`)
return
}
await AvSessionRuntime.host.reportPlaying(intent.assetId, intent.startPositionMs)
}
这里检查了两次。第一次在准备完成后,防止旧请求启动播放;第二次在 play() 返回后,防止播放调用期间发生交接。第二次失败时主动暂停,再退出,不让短暂启动的旧媒体继续发声。
实际播放器可能不支持多个并发 prepare 共享同一实例。协调器应在新租约产生时取消可取消的旧任务;无法取消的任务由提交门禁收口。无论哪种方式,旧任务都不能更新 AVSession 元数据、播放状态或页面主控标记。
异常路径也必须带代际。代际 28 的准备失败在代际 29 建立后才返回时,不应把全局状态改成 ERROR,因为当前所有者可能正在正常播放。它只记录为旧请求失败并结束自身资源。错误归属和成功归属使用同一份租约判断,逻辑才对称。
六、系统播控命令只发送给当前所有者
耳机、锁屏卡片或播控中心触发 play、pause 时,命令先到唯一 AvSessionHost。宿主不关心左右位置,而是读取当前租约,把命令与 ownerPaneId、epoch 一起交给播放器协调层。
这段代码解决什么问题:为系统命令绑定当前租约快照,并阻止命令执行期间发生交接后继续上报旧状态。
// media/SystemControlBridge.ets
export async function handleSystemCommand(command: 'PLAY' | 'PAUSE'): Promise<void> {
const lease = PlaybackLeaseCoordinator.current()
if (!lease) {
console.info('[DualCast] system command ignored: no lease')
return
}
if (command === 'PLAY') {
await PlaybackEngine.play()
} else {
await PlaybackEngine.pause()
}
if (!PlaybackLeaseCoordinator.owns(lease.ownerPaneId, lease.epoch)) {
console.info(`[DualCast] late system command epoch=${lease.epoch}`)
return
}
PlaybackUiBus.publish({
paneId: lease.ownerPaneId,
epoch: lease.epoch,
state: command === 'PLAY' ? 'PLAYING' : 'PAUSED'
})
}
命令开始时读取快照,完成后再次验证。示例中交接发生在 16:21:18.420,租约从 RIGHT-9 / epoch 28 变成 LEFT-7 / epoch 29。两个较晚返回的 epoch 28 命令只写诊断日志,不再驱动界面。
系统命令没有租约时选择忽略,并记录 no lease。另一种策略是恢复最近播放项,但那需要明确的持久化和用户预期,不能在示例里凭空决定。播放所有权不存在时,自动挑一个可见窗口播放,往往会制造意外声音。
七、运行页把“可见窗格”和“播放所有者”分开显示
PlaybackLeasePage 的运行图同时展示两栏:左侧是章节列表,右侧是 chapter-12 详情。顶部状态为 ACTIVE_RIGHT,租约所有者 RIGHT-9,代际 28,播放位置 03:06。左栏虽然可见,但标记为 OBSERVER,点击播放才会发起新的租约请求。

这个 UI 设计的重点不是把内部状态暴露给普通用户,而是让开发版本能清楚区分:VISIBLE 表示页面可见,OWNER 表示拥有播放租约,SESSION 表示系统会话激活。三者可能同时为真,也可能分别变化。
状态栏统一为 16:21、Wi‑Fi、5G、信号和 73% 电量。红色箭头指向 RIGHT-9 / epoch 28,另一处红圈标出 chapter-12 · 03:06。图片中的任务 ID、位置和状态与正文使用同一份设定。
当用户点击左栏试听后,界面先进入 LEASE_REQUESTED,不要立刻把右栏播放标志清空。只有新媒体准备成功且代际 29 仍有效,才提交 ACTIVE_LEFT。若准备失败,协调器可以恢复上一租约或进入无主状态,具体策略必须明确,不能在 UI 和引擎间各做一次猜测。
八、诊断页应该能还原一次交接
详情图按照时间列出完整过程:16:21:18.102,RIGHT-9 持有 epoch 28;16:21:18.301,LEFT-7 请求 chapter-08;16:21:18.420,代际切换为 29;16:21:18.447 和 16:21:18.463,两条 epoch 28 命令被拒绝;16:21:18.488,新所有者进入 ACTIVE_LEFT。

本次演示共有 11 条命令,接受 9 条,丢弃 2 条,系统 AVSession 实例始终为 1。状态路径是 IDLE → LEASE_REQUESTED → ACTIVE_RIGHT → HANDOFF → ACTIVE_LEFT → RELEASED。诊断页用红圈标出两个迟到命令,用红箭头指向 sessionCount=1,解释问题而不是装饰画面。
如果日志只记录“pause ignored”,无法知道是没有租约、页面已销毁还是代际过期。建议固定字段:taskId、paneId、assetId、leaseEpoch、commandId、decision 和 reason。播放位置可以按需要采样,不必每几十毫秒写一次。
九、生命周期要分三层处理
页面层:窗格销毁时撤销自己的 UI 订阅和未完成请求,不直接销毁 AVSession。若它仍持有租约,需向协调器发出 releaseOwner(paneId, epoch),由协调器决定暂停、转交或继续后台播放。
播放层:媒体资源切换时释放上一数据源相关资源,取消准备任务,并阻止旧回调提交。播放引擎的资源生命周期与页面不完全相同,不能依赖 ArkUI 组件析构替代播放器清理。
会话层:应用不再提供媒体控制时,注销 play、pause 等监听,执行 deactivate() 与 destroy()。官方文档给出了对应的 off 与销毁流程。注册了不支持的命令又不处理,会让系统控制看起来可用却没有反馈;不支持的命令不要注册,暂时停用时及时注销。
后台播放是另一条产品边界。如果应用允许退到后台继续播放,UIAbility 级宿主和长时任务策略需要按官方媒体指南设计;本文的单主租约只解决双窗控制权,不自动获得后台执行能力。不要把 AVSession 激活等同于播放器可以无限期后台运行。
十、四类极端情况必须测试
第一类是快速连点。左右窗格在 200 ms 内交替请求播放,最终只有最大租约代际可以提交。测试不关心中间创建了多少准备任务,而要断言声音来源、界面所有者和 AVSession 元数据最终一致。
第二类是系统命令与页面点击同时发生。耳机暂停开始执行时,用户点击左栏播放。暂停完成后发现租约已变,应拒绝更新新所有者的 UI。命令本身若已影响共享播放器,还需要新租约的提交步骤重新校准最终状态。
第三类是页面被移位而非销毁。平行视界路由使详情从右侧移动到左侧,paneId 保持不变,租约不应因为视觉位置变化自动失效。测试要同时记录实例 ID 和当前位置,防止代码把二者混用。
第四类是 UIAbility 退出。所有页面订阅、播放器任务、AVSession 监听和会话资源都要在确定顺序中释放。建议先阻止新租约,再取消页面任务,停止播放,注销系统命令,最后 deactivate 与 destroy。若先销毁会话,迟到的页面回调可能继续尝试上报状态。
十一、性能与体验取舍
单主租约不会减少媒体准备成本,它只保证提交正确。若左右快速切换导致大量任务被拒绝,还要做更上游的取消和防抖。丢弃数长期偏高说明用户意图或页面事件过于频繁,不应把“门禁挡住了”当作最终优化结果。
交接时是否无缝续播取决于媒体是否相同。相同 assetId 可以保留 186000 ms 并只转移控制权;不同媒体应按产品规则开始新播放,不能为了平滑把旧位置套到新章节。位置恢复还要考虑播放器实际 seek 结果,AVSession 上报应以播放器确认值为准。
UI 上可以给非所有者显示静态进度,但不要让两个页面各自启动高频定时器。由播放服务生成一份位置快照,再分发给可见窗格,更省资源,也避免两个时钟漂移。页面不可见时停止订阅即可,不影响唯一会话继续工作。
十二、结论:平行视界需要单一媒体事实源
DualCast 的关键约束很简单:一个 UIAbility 只有一个 AVSession,一个时刻只有一个窗格拥有播放租约,每个异步提交都必须携带并复核租约代际。左右窗格可以同时可见,却不能同时成为系统播控的最终解释者。
示例中 RIGHT-9 在 epoch 28 播放 chapter-12 到 186000 ms,随后控制权交给 LEFT-7 / epoch 29。11 条命令中 9 条接受,2 条旧代际命令被拒绝,AVSession 实例数始终为 1。这个结果不是靠页面生命周期碰巧实现,而是由所有权协议明确保证。
当应用从单页进入平行视界,真正要迁移的不只是布局,还有状态的归属方式。把系统会话放到 UIAbility 级,把播放事实放到服务层,把窗格限制为带身份的客户端,双窗体验才不会因为一个耳机按键重新退回“最后写入者获胜”。
十三、上线前的最小验收清单
第一项是唯一性:同一 UIAbility 生命周期中,AVSession 创建次数为 1,重复初始化不会生成第二实例。页面切换、左右栏互换和返回栈变化都不影响这个数字。
第二项是一致性:声音来源、AVSession 元数据、系统播放状态和租约所有者必须指向同一个 assetId。任何一项不同,都不能只修 UI;应沿租约代际查到首次分叉的位置。
第三项是迟到拒绝:故意延迟旧窗格的准备、播放和异常回调,确认新租约建立后它们不会更新播放器、系统会话或当前页面。丢弃日志需要包含旧代际和当前代际,便于判断门禁是否按预期工作。
第四项是成对释放:页面取消 UI 订阅,播放器取消任务并释放媒体资源,AvSessionHost 注销命令监听、停用并销毁会话。测试结束后不应继续收到耳机命令,也不应存在仍更新 AppStorage 的定时器。
第五项是无主状态:当前所有者退出、准备失败或用户主动停止后,系统会话展示暂停、停止还是被销毁,需要产品明确。最危险的状态是界面看似无播放,AVSession 仍报告 PLAY,导致外部控制器继续显示错误信息。
这五项通过后,再讨论无缝交接、动画和预加载。单主租约首先是一套正确性协议,体验优化必须建立在声音、状态和系统会话已经一致的前提上。
十四、参考资料
- 华为开发者联盟:HarmonyOS 7 平行视界能力解读,https://developer.huawei.com/consumer/cn/forum/topic/0201221235973021541
- 华为开发者联盟:AVSession 提供方开发指南,https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V13/using-avsession-developer-V13
- 华为开发者联盟:AVSession Kit API 参考,https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/avsession-api
- 华为开发者联盟:多设备通用适配指南,https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
更多推荐




所有评论(0)