一个短视频应用接画中画,用户点“小窗播放”,PiPWindow 拉起来,视频画面在悬浮窗里正常播放。用户把应用切到后台,再切回来,或者从小窗点“返回全屏”,视频停了,播放器状态还在 playing,进度条继续走,但没有声音也没有画面。日志里 onPageHide 和 onPageShow 交替触发,播放器的 stateChange 一次都没有。你检查 AVPlayer,没释放;检查 SurfaceID,没变;检查音频焦点,还在手里。问题不在播放器有没有被释放,在于你把画中画窗口的生命周期和页面生命周期当成了同一条线。PiPWindow 有自己的创建、启动、停止、销毁阶段,页面切走不等于 PiP 窗口还在,PiP 返回全屏也不等于页面 onPageShow 会替你恢复渲染。

PiP 窗口是独立于页面的系统级窗口,页面销毁它不一定销毁

先把这条边界钉死。PiPWindow 由 @kit.ArkUI 的 PiPWindow 模块创建,它依附于一个 UIAbility 的窗口上下文,但它的生命周期和页面组件的 onPageShow / onPageHide 不是同一条线。页面切到后台,onPageHide 触发,但 PiP 窗口可能仍然在前台可见;用户点小窗的关闭按钮,PiP 窗口销毁,但页面可能还在后台栈里,onPageShow 没有触发。

这意味着播放器的生命周期不能挂在页面的 aboutToDisappear 上。页面 aboutToDisappear 时把 AVPlayer 释放了,PiP 窗口还活着,它拿到的是一个已经释放的播放器句柄,画面黑掉是必然的。反过来,PiP 窗口关闭时,页面可能还没有重新可见,播放器需要有一个明确的收尾时机,而不是等页面 onPageShow 来补。

正确的结构是把播放器的生命周期交给一个独立于页面的播放管理器,页面和 PiP 窗口都只是这个管理器的消费者。管理器持有 AVPlayer、SurfaceID 和当前播放状态,页面销毁时只断开页面的 SurfaceID 绑定,不释放播放器;PiP 窗口启动时把 PiP 的 SurfaceID 绑上去,播放器继续用同一个实例输出画面。

typescript

class PlaybackManager {
  private player: media.AVPlayer | null = null;
  private surfaceId: string = '';

  async attachSurface(surfaceId: string): Promise<void> {
    this.surfaceId = surfaceId;
    if (this.player) {
      // 把新的 Surface 绑到已有播放器上,不重建播放器
      await this.player.setDisplaySurface(await this.buildSurface(surfaceId));
    }
  }

  async detachSurface(surfaceId: string): Promise<void> {
    if (this.surfaceId === surfaceId) {
      this.surfaceId = '';
    }
  }

  async release(): Promise<void> {
    if (this.player) {
      await this.player.release();
      this.player = null;
    }
  }
}

attachSurface 和 detachSurface 只做 Surface 的绑定和解绑,不碰播放器本身。页面 aboutToDisappear 调 detachSurface(pageSurfaceId),PiP 启动时调 attachSurface(pipSurfaceId),播放器实例从头到尾只有一个。release 只应在业务真正结束时调用,比如用户退出播放页或者主动停止播放。

PiP 启动不是“把页面缩小”,视频比例要提前协商

PiPWindow 启动时,PiPWindowOptions 里的 aspectRatio 决定了小窗的宽高比。这个比例不是从页面布局里推导出来的,它必须在 startPiP 时显式指定。如果你传的比例和视频的实际比例不一致,系统会在小窗里做裁剪或者加黑边,用户看到的是一个变形或者有边框的画面。

更隐蔽的是比例的计算来源。视频的宽高比来自 AVPlayer 的 videoScaleType 或者 VideoSize,它不是页面上 XComponent 的宽高比。页面上的 XComponent 可能被布局拉伸成了全屏比例,而视频本身是 16:9。用页面布局比例去启动 PiP,小窗里的画面比例是错的。

typescript

async function startPip(player: media.AVPlayer): Promise<void> {
  const videoSize = player.videoWidth && player.videoHeight
    ? { width: player.videoWidth, height: player.videoHeight }
    : { width: 16, height: 9 };
  const ratio = videoSize.width / videoSize.height;

  const pipConfig: PiPWindow.PiPConfiguration = {
    context: this.context,
    componentId: 'video_container',
    templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY,
    aspectRatio: ratio
  };
  this.pipController = await PiPWindow.create(pipConfig);
  await this.pipController.startPiP();
}

aspectRatio 这一行必须从视频的真实尺寸来,不能从布局尺寸来。componentId 指向页面里承载视频的组件,PiP 启动时系统会把这个组件的渲染内容迁移到小窗里。如果 componentId 在页面销毁时已经不存在,PiP 启动会失败,所以 componentId 对应的组件应该放在一个不受页面切换影响的容器里,或者 PiP 启动之前确保页面还在。

onPiPStateChange 是 PiP 的状态来源,不是页面的 onPageHide

PiPWindow 提供了 on('pipStateChange') 事件,回调里携带 PiPState:ABOUT_TO_START、STARTED、ABOUT_TO_STOP、STOPPED、ERROR。这是判断 PiP 窗口状态的唯一可靠来源,不要从页面的 onPageHide / onPageShow 去推断 PiP 是否在运行。

一个常见的错误是在 onPageHide 里暂停播放器,在 onPageShow 里恢复。用户点小窗播放时,页面 onPageHide 触发,播放器被暂停,PiP 窗口启动后拿到的是一个暂停状态的播放器,小窗里画面静止。用户以为 PiP 坏了,其实是页面生命周期回调替 PiP 做了不该做的决定。

typescript

pipController.on('pipStateChange', (state: PiPWindow.PiPState) => {
  switch (state) {
    case PiPWindow.PiPState.STARTED:
      // PiP 已经接管画面,页面侧的 Surface 可以解绑
      this.playback.detachSurface(this.pageSurfaceId);
      break;
    case PiPWindow.PiPState.STOPPED:
      // PiP 已停止,如果页面重新可见则重新绑定页面 Surface
      if (this.pageVisible) {
        this.playback.attachSurface(this.pageSurfaceId);
      }
      break;
    case PiPWindow.PiPState.ERROR:
      this.handlePipError();
      break;
  }
});

STARTED 之后解绑页面 Surface,STOPPED 之后根据页面可见性重新绑定。页面的 onPageHide 只负责记录 pageVisible = false,不负责暂停播放器;onPageShow 只负责记录 pageVisible = true 并触发重新绑定,不负责恢复播放。播放器的播放/暂停状态由用户操作和 PiP 状态驱动,不由页面可见性驱动。

返回全屏不是“再绑一次 Surface”,播放器状态要重新对齐

从小窗点“返回全屏”,系统会把 PiP 窗口停止,页面重新回到前台。这个过程中,AVPlayer 的 stateChange 可能不会触发,因为播放器从来没有被暂停过,它一直在 playing。但页面侧的 XComponent 可能已经失去了 Surface 绑定,或者拿到了一个新的 SurfaceID。

正确的收尾是在 PiPState.STOPPED 之后,等页面的 onPageShow 触发,用当前的 SurfaceID 重新 attachSurface,而不是用 PiP 启动之前缓存的那个旧 SurfaceID。页面重新可见时,XComponent 的 onLoad 或者 onSurfaceCreated 会给出一个新的 SurfaceID,用旧的 ID 去绑定,画面出不来。

typescript

aboutToAppear(): void {
  this.pageSurfaceId = this.xComponentController.getXComponentSurfaceId();
  this.playback.attachSurface(this.pageSurfaceId);
}

onPageShow(): void {
  this.pageVisible = true;
  // 用当前 XComponent 的 SurfaceID 重新绑定,不要用缓存值
  this.pageSurfaceId = this.xComponentController.getXComponentSurfaceId();
  this.playback.attachSurface(this.pageSurfaceId);
}

getXComponentSurfaceId() 在页面重新可见时可能返回一个新的 ID,不要假设它和上次一样。attachSurface 内部如果发现 surfaceId 没变,可以跳过重建;变了就重建绑定。

音频焦点在 PiP 模式下不自动保持,被抢了要能恢复

PiP 窗口在前台播放时,音频焦点和普通播放一样,可能被来电、语音助手、其他媒体应用抢走。AVPlayer 的 audioInterrupt 事件会触发,但 PiP 窗口本身不会因为焦点丢失而自动暂停。用户看到的是小窗里画面还在走,声音没了。

处理方式和普通播放一致:监听 audioInterrupt,焦点丢失时 pause(),焦点恢复时根据业务决定是否 play()。但 PiP 场景下有一个额外的边界:用户可能在小窗里手动点了播放,此时焦点还没有恢复,play() 会失败。正确的做法是在焦点恢复的回调里检查 PiPState,如果 PiP 仍然在运行且用户没有主动暂停,再恢复播放。

typescript

player.on('audioInterrupt', (event: audio.InterruptEvent) => {
  if (event.hintType === audio.InterruptHint.INTERRUPT_HINT_PAUSE) {
    this.userPausedBeforeInterrupt = player.state === 'playing';
    player.pause();
  } else if (event.hintType === audio.InterruptHint.INTERRUPT_HINT_RESUME) {
    if (this.userPausedBeforeInterrupt) {
      player.play();
    }
  }
});

userPausedBeforeInterrupt 记录的是“焦点被抢之前,用户是不是在播”。如果用户本来就是暂停的,焦点恢复之后不应该替用户播放。这个标记要在 PiP 启动和停止时保持,不能因为窗口切换而重置。

排查顺序:先看播放器归属,再看 Surface 绑定,最后看状态来源

下次画中画“切出去就断”或者“返回全屏黑屏”的时候,按这个顺序走。

第一步,确认 AVPlayer 的 release 是不是挂在页面 aboutToDisappear 上。是的话,把它移到独立于页面的播放管理器里,页面只解绑 Surface,不释放播放器。

第二步,检查 PiP 启动时的 aspectRatio 是不是从视频真实尺寸算出来的,componentId 对应的组件在页面切走后是否还存在。

第三步,确认 PiP 状态是从 pipStateChange 事件读的,不是从页面的 onPageHide / onPageShow 推断的。页面生命周期回调不替 PiP 做播放/暂停决策。

第四步,返回全屏时用当前的 SurfaceID 重新绑定,不要用 PiP 启动之前缓存的旧 ID。页面重新可见时 XComponent 可能已经换了一个 Surface。

第五步,检查音频焦点事件有没有处理,焦点恢复时是否根据 PiPState 和用户意图决定要不要恢复播放。

画中画窗口是系统级窗口,它的生命周期不跟页面走。播放器要有一个独立于页面的归属,Surface 绑定要跟着 PiP 状态迁移,播放/暂停由用户操作和焦点事件驱动,不由 onPageHide 驱动。下一次小窗一开播放就断的时候,先别怀疑播放器坏了——打开代码看一眼,那个 release,到底是不是在页面 aboutToDisappear 里被调了。

Logo

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

更多推荐