HarmonyOS 「校园二手交易商城」App应用实战 44 画中画:PipWindowUtil 实现
44 画中画:PipWindowUtil 实现
系列五:直播与画中画 · 第 4 篇
对应工程:
common/multishoppingbase/src/main/ets/utils/PipWindowUtil.ets
引言
直播最大的魅力是"关掉页面还能看"。HarmonyOS 提供 PiPWindow(@kit.ArkUI)实现画中画:用户点关闭,视频画面脱离页面悬浮成小窗,同时自动返回上一页,小窗还带播放/暂停控制面板。这是全项目最复杂的系统能力对接,PipWindowUtil 用约两百行代码把它讲清楚了:创建配置、状态监听、控制面板事件、串行化停止、错误码降级。本篇逐段拆解。
单例与能力判断
与 AvPlayerUtil 一致,PipWindowUtil 也走 AppStorage 单例 + abilityScopedAppStorageKey(key 为 'MultiShoppingPipWindowUtil')。构造函数中顺手持有 AVPlayer 单例——PiP 控制面板的播放/暂停最终要落到播放器上:
constructor(uiContext: UIContext) {
this.avPlayerUtil = AvPlayerUtil.getAvPlayerUtil(uiContext);
}PiP 依赖窗口会话能力,调用前必须做两道门卫:
if (!canIUse('SystemCapability.Window.SessionManager')) {
return; // 能力不存在(部分低端设备/模拟器)直接降级为普通退出
}
if (!PiPWindow.isPiPEnabled()) {
Logger.error('picture in picture disabled for current OS');
return; // 系统级开关关闭
}canIUse 是同步能力探测 API,isPiPEnabled 查系统画中画开关,两道都过才继续。
PiPWindow.create:配置四大件
startPip 的核心是 PiPWindow.create,配置对象四个字段缺一不可:
const config: PiPWindow.PiPConfiguration = {
context: context, // 宿主 Context
componentController: xComponentController, // 复用直播页的 XComponentController
navigationId: navId, // 'navid',PiP 回栈凭据
templateType: PiPWindow.PiPTemplateType.VIDEO_LIVE // 视频直播模板(带控制面板)
};
PiPWindow.create(config).then((controller: PiPWindow.PiPController) => {
this.pipController = controller;
this.initPipController(); // 注册状态与控制面板回调
this.pipController.startPiP().then(() => {
this.avPlayerUtil.play(); // 小窗继续播
pageInfos.pop(); // 关闭直播页,回到上一页
}).catch((err: BusinessError) => {
this.releasePipCallbacksAndClear(); // 启动失败清理
});
});要点:
componentController与页面共用:就是LiveContent里那个 XComponent 的控制器(第 2 篇强调过),PiP 接管同一 Surface,画面零黑屏切换。navigationId: navid:与Index的 Navigation id、页面 NavDestination id 三处一致,PiP 用它在恢复时找到正确的导航栈。templateType: VIDEO_LIVE:决定 PiP 窗口样式。视频直播模板自带播放/暂停控制面板,对应controlPanelActionEvent里的playbackStateChanged事件。- 成功后
pageInfos.pop():pageInfos是页面传入的NavPathStack(mainPageMap),弹出直播页、回到首页——这正是"点关闭 → 页面消失但视频还在小窗"的体验。
控制面板联动
initPipController 注册两类回调:
this.pipController.on('stateChange', (state: PiPWindow.PiPState, reason: string) => {
this.onStateChange(state, reason);
});
this.pipController.on('controlPanelActionEvent', (event: PiPWindow.PiPActionEventType) => {
this.onActionEvent(event);
});控制面板事件里,playbackStateChanged 直接转发给播放器做播放/暂停切换——和页面点视频用的是同一个入口:
private onActionEvent(event: PiPWindow.PiPActionEventType): void {
switch (event) {
case 'playbackStateChanged':
this.avPlayerUtil?.playerStateControl();
break;
default:
break;
}
}onStateChange 则维护 isShowingPip 状态并处理各阶段:
switch (state) {
case PiPWindow.PiPState.STARTED: this.isShowingPip = true; break;
case PiPWindow.PiPState.STOPPED: this.isShowingPip = false;
this.releasePipCallbacksAndClear(); break;
case PiPWindow.PiPState.ABOUT_TO_RESTORE: this.isShowingPip = false; break;
case PiPWindow.PiPState.ERROR: this.isShowingPip = false;
this.releasePipCallbacksAndClear(); break;
// ABOUT_TO_START / ABOUT_TO_STOP 仅记录日志
}ABOUT_TO_RESTORE(用户从小窗点回页面)会把 isShowingPip 置回 false,因为画面将回归主页面 Surface。
stopPip:Promise 链串行化
stopPip 是本类最讲究的部分。PiP 的停止可能来自三条路径:页面 aboutToDisappear 主动调用、用户从小窗面板点关闭、系统回收。三者并发时底层 JsPipController::OnStopPictureInPicture 会崩溃。项目用 stopPipChain 把停止操作串成 Promise 链:
async stopPip(): Promise<void> {
const task = this.stopPipChain.then(async (): Promise<void> => {
await this.stopPipSerialized();
});
this.stopPipChain = task.catch((): void => {});
return task;
}每次调用都挂在上一次任务之后执行,前一个没完成,后一个绝不启动。真正的停止逻辑在 stopPipSerialized:
if (!this.isShowingPip) { // 用户已从小窗关闭 → 直接清理引用
this.releasePipCallbacksAndClear();
return;
}
await controller.stopPiP();这里用 isShowingPip 判断"是不是已经停了":如果 STOPPED 回调早已把引用清掉,就不再重复调 stopPiP(),只做清理收尾。
错误码降级
即使串行化,stopPiP() 仍可能抛"良性"错误,项目用两个常量识别并降级处理:
const PIP_STOP_ALREADY_DONE: number = 1300012; // PiP 已被用户/系统关闭,stop 无效
const PIP_STOP_STATE_MISMATCH: number = 1300015; // 多窗口/分屏下 stop 与状态竞争
catch (err) {
const be = err as BusinessError;
if (be.code === PIP_STOP_ALREADY_DONE || be.code === PIP_STOP_STATE_MISMATCH) {
Logger.info(`PiP stop finished with benign code ${be.code}, releasing controller.`);
} else {
Logger.error(`Failed to stop pip. Cause: ${be.code}, message: ${be.message}`);
}
} finally {
this.releasePipCallbacksAndClear();
}1300012 表示"本来就已经停了"(比如用户点了小窗删除按钮),1300015 表示"状态竞争导致的无效停止"——两者都不是真实故障,记录日志后照常清理控制器即可,避免把良性错误当成崩溃上报。finally 保证任何情况下回调都被注销、引用被清空。
releasePipCallbacksAndClear 是唯一的清理出口:
controller.off('stateChange');
controller.off('controlPanelActionEvent');
this.pipController = undefined;
this.isShowingPip = false;注意事项与总结
- 能力探测放最前:
canIUse('SystemCapability.Window.SessionManager')与isPiPEnabled()两道门卫缺一不可,模拟器/低端机常在这里降级。 - 三个 navid 必须一致:Navigation id、NavDestination id、
PiPConfiguration.navigationId,任何一处不一致都会导致 PiP 恢复失败或回栈错乱。 - 停止必须串行:不串行化,页面退出与用户手动关闭并发时底层会崩。
- 良性错误码要识别:1300012/1300015 按"已停止"处理,只清理不重试。
- isShowingPip 是唯一事实源:用它区分"正在展示"与"已被外部关闭",避免重复 stop。
更多推荐
所有评论(0)