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 是页面传入的 NavPathStackmainPageMap),弹出直播页、回到首页——这正是"点关闭 → 页面消失但视频还在小窗"的体验。

控制面板联动

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。
至此视频链路全部打通:XComponent 提供画布,AVPlayer 负责解码播放,PiPWindow 让画面脱离页面。接下来四篇回到 UI 层,看评论流、商品列表与头部这些悬浮组件。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐