画中画是把视频类内容(视频播放、视频通话、视频会议、直播)以小窗形式悬浮在屏幕上,切到小窗后用户还能干别的。它是系统级窗口,比应用主窗口层级高,退到桌面后依然可见。

1. 画中画与普通浮窗的区别

维度画中画 PiP闪控窗 FloatView应用内子窗口
内容类型仅视频 / 音视频任意 UI任意 UI
系统级置顶✅ 系统级小窗✅❌ 仅应用内
后台可见✅ 退桌面仍显示✅❌ 退后台即失效
自带交互拖动 / 缩放 / 隐藏 / 删除 / 控制条拖动 / 缩放 / 闪控球需自己实现
是否需申请权限无需受限权限(后台播放需长时任务)需 FLOAT_VIEW(前台授权)无需
渲染方式绑定视频 Surface加载页面加载页面

画中画是系统为 "视频小窗" 定制的专用窗口,官方推荐用于播放 / 通话 / 会议 / 直播;如果浮窗里放的是任意内容(不是视频),建议用闪控窗FloatView。

2. 画中画的三种开发方式怎么选

官方提供 3 种接入方式:

方式适用场景是否需要自行管理页面
XComponent应用用 Navigation 管理页面,或 Ability 单页面❌ 无需管理页面,最省事
typeNode所有场景,官方推荐✅ 需自行管理页面,灵活
NDK (C/C++)依赖 NDK 接口开发的应用✅ 需自行管理

3. 核心接口

使用时需要导入PiPWindow类。import { PiPWindow } from '@kit.ArkUI'; 主要接口如下:

主要接口作用
isPiPEnabled(): boolean判断当前系统是否开启画中画
create(config, contentNode)创建画中画控制器(typeNode 方式)
startPiP() / stopPiP()启动 / 停止画中画
setAutoStartEnabled(true)应用退后台时自动拉起画中画(关键)
updateContentSize(w, h)媒体源切换(如横竖屏切换)时更新尺寸
on('stateChange', cb)监听生命周期状态
on('controlEvent', cb)监听控制面板按钮点击
updatePiPControlStatus(type, status)更新控制按钮状态(如播放 / 暂停)
setPiPControlEnabled(type, enabled)设置按钮使能 / 禁用

画中画PiP 依赖系统能力,部分设备 / 系统版本不支持。启动前必须调用PiPWindow.isPiPEnabled() 判断,不支持时要做好降级(比如退后台继续用悬浮窗或直接停留原页),不能假设一定可用。另外,用户可在「设置 > 系统 > 智慧多窗」里关闭 "自动启动画中画" 开关。即使你 setAutoStartEnabled(true),用户关掉后依然不会自动拉起。这是系统对用户的兜底,别写成 "一定能自动切小窗"。

4. 完整实现(typeNode 推荐方式)

第 1 步:创建视频节点(XComponent + AVPlayer 绑定)

// PipManager.ets —— 画中画管理器(单例)
import { PiPWindow, typeNode, NodeController, FrameNode, UIContext,
         XComponentController, XComponentType } from '@kit.ArkUI';
import { AVPlayer } from '@kit.MediaKit';
import { common } from '@kit.AbilityKit';

export class CustomXComponentController extends XComponentController {
  onSurfaceCreated(surfaceId: string): void {
    // 把 AVPlayer 的渲染目标绑定到 XComponent 的 Surface
    PipManager.getInstance().setSurfaceId(surfaceId);
  }
}

export class PipManager {
  private static instance: PipManager = new PipManager();
  static getInstance(): PipManager { return PipManager.instance; }

  player: AVPlayer | undefined = undefined;
  pipController: PiPWindow.PiPController | undefined = undefined;
  private xcController: CustomXComponentController = new CustomXComponentController();
  private xComponent: typeNode.XComponent | undefined = undefined;

  // 创建自由节点(不加入布局,仅在画中画中显示,最简单)
  makeTypeNode(ctx: UIContext): void {
    this.xComponent = typeNode.createNode(ctx, 'XComponent', {
      type: XComponentType.SURFACE,
      controller: this.xcController,
    });
  }

  // 创建画中画控制器
  async createPipController(ctx: Context): Promise {
    if (!PiPWindow.isPiPEnabled()) {
      console.error('当前系统未开启画中画');
      return;
    }
    const config: PiPWindow.PiPConfiguration = {
      context: ctx,
      componentController: this.xcController,   // 关联视频 surfaceId
      templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY,
      contentWidth: 1920,                        // 原始内容宽高
      contentHeight: 1080,
      // 可选:控制层控件组
      controlGroups: [PiPWindow.VideoPlayControlGroup.VIDEO_PREVIOUS_NEXT],
    };
    this.pipController = await PiPWindow.create(config, this.xComponent);
    // 关键:退后台自动拉起
    this.pipController.setAutoStartEnabled(true);
    // 注册生命周期 + 控制事件
    this.pipController.on('stateChange', (state, reason) => this.onStateChange(state, reason));
    this.pipController.on('controlEvent', (ctrl) => this.onControlEvent(ctrl));
  }

  startPip(): void {
    this.pipController?.startPiP();
  }

  stopPip(): void {
    this.pipController?.stopPiP();
  }

  updateContentSize(w: number, h: number): void {
    this.pipController?.updateContentSize(w, h);
  }
}

第 2 步:在 EntryAbility 里初始化节点

// EntryAbility.ets
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { PipManager } from '../manager/PipManager';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.getMainWindow().then((win) => {
      const ctx = win.getUIContext();
      // 用主窗口 UIContext 创建 typeNode
      PipManager.getInstance().makeTypeNode(ctx);
    });
  }
}

第 3 步:页面里放视频预览 + 启动按钮

// pages/PlayerPage.ets
@Entry
@Component
struct PlayerPage {
  build() {
    Column() {
      // 主页视频预览区(挂 AVPlayer 渲染的 XComponent)
      NodeContainer(PipManager.getInstance().getNodeController())
        .width('100%').height(400)

      Row({ space: 20 }) {
        Button('启动画中画').onClick(() => PipManager.getInstance().startPip())
        Button('停止').onClick(() => PipManager.getInstance().stopPip())
      }
    }
    .width('100%').height('100%')
  }
}

如果页面用 Navigation/Router 管理,需要在生命周期里做节点管理(ABOUT_TO_START 时移除节点、ABOUT_TO_RESTORE 时跳回原页)。另外,画中画PiP 窗口大小用的是 Surface 的宽高比,和渲染分辨率无关,给大一点或小一点都能渲染,所以不要试图用窗口尺寸去控制画质。XComponent 和 Surface 的大小是独立的,想精确控制 PiP 内画面区域,用 XComponent 的 setXComponentSurfaceRect() 单独设 Surface 矩形,而不是靠调 XComponent 本身。

横竖屏 / 媒体源切换时是出问题的重灾区,切横竖屏、分辨率变化时画面常会被拉伸或留黑边,正确做法是要监听 avPlayer.on('stateChange'),在 prepared 状态拿到视频真实宽高,动态更新 XComponent Surface 区域,并调用 updateContentSize(w, h) 通知 PiP 控制器。播放器 objectFit 设成 Auto 或 Contain(保持宽高比缩放),别用默认拉伸模式。视频节点要正确绑定 Surface,AVPlayer 是靠 XComponent 的 onSurfaceCreated(surfaceId) 拿到的 surfaceId 渲染的。节点被移除 / 重新添加(PiP 进出)时,要处理好 surfaceId 的复用和 onSurfaceDestroyed 释放,避免黑屏或画面丢失。

5. 生命周期状态机

ABOUT_TO_START → STARTED → (用户拖动到边缘隐藏) → HIDDEN → 点击图标恢复 → STARTED
      │                │
      │                └── 点击"关闭" 或 拖到垃圾桶热区 → ABOUT_TO_STOP → STOPPED
      │
      └── 点击"恢复全屏" → ABOUT_TO_RESTORE → RESTORED(回到应用原界面)

必须在 on('stateChange') 回调里处理这些状态:

状态含义你需要做的事
ABOUT_TO_START即将进入画中画从主页面移除视频节点、可返回上级页
STARTED已在画中画中正常播放
ABOUT_TO_RESTORE即将还原全屏若之前返回了上级页,需跳回原界面
ABOUT_TO_STOP即将关闭把视频节点重新加回布局
STOPPED已关闭清理资源;

申请了长时任务的要关掉任务 / 进程 |

示例代码如下:

onStateChange(state: PiPWindow.PiPState, reason: string): void {
  switch (state) {
    case PiPWindow.PiPState.ABOUT_TO_START:
      // 移除主页节点,进入画中画
      PipManager.getInstance().getNodeController()?.removeNode();
      break;
    case PiPWindow.PiPState.ABOUT_TO_RESTORE:
      // 还原时跳回原界面(如果 START 时 back 了)
      this.getUIContext().getRouter().pushUrl({ url: 'pages/PlayerPage' });
      break;
    case PiPWindow.PiPState.STOPPED:
      // 关闭长时任务、释放 AVPlayer
      break;
  }
}

需要后台持续播放时,必须配长时任务(continuousTask),PiP 窗口能后台渲染,但应用进程要防挂起,需申请长时任务。另外,STOPPED 时必须主动释放长时任务,画中画被关闭 / 删除(比如用户拖到底部垃圾桶)后,官方明确要求:申请了长时任务的应用要监听STOPPED,主动停掉任务或进程。不释放会被系统判定为不合理后台占用,导致耗电、被挂起甚至被系统杀掉。

6. 交互与控制面板

系统内置交互(无需自己写代码):

• 单击:显示 / 隐藏控制层(控制层 3 秒自动隐藏)

• 双击:放大 / 缩小窗口

• 拖动:移到屏幕任意位置;拖到左右边缘自动隐藏,边缘出现小图标,点击恢复

• 拖拽四边 / 四角:缩放窗口(有最小 / 最大档,超界回弹)

• 拖到底部垃圾桶热区:删除画中画

控制面板控件(按模板类型可选,controlGroups 配置):

模板可用控件
VIDEO_PLAY播放 / 暂停、上一集 / 下一集
VIDEO_CALL麦克风切换、挂断、摄像头切换
VIDEO_MEETING麦克风切换、挂断、摄像头切换
VIDEO_LIVE播放 / 暂停、静音
// 监听控制按钮点击
this.pipController.on('controlEvent', (ctrl: PiPWindow.ControlEventParam) => {
  switch (ctrl.controlType) {
    case PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE:
      // 切换播放/暂停,并同步按钮状态
      this.pipController.updatePiPControlStatus(
        PiPWindow.PiPControlType.VIDEO_PLAY_PAUSE,
        PiPWindow.PiPControlStatus.PLAY);
      break;
    case PiPWindow.PiPControlType.VIDEO_CALL_HANG_UP:
      this.stopPip();
      break;
  }
});

自定义 UI 不响应任何交互,PiPConfiguration.customUIController 里的自定义 UI 只是 "显示" 在内容上方,无法点击、无交互。想要按钮就得用系统 controlGroups 控件,别把交互逻辑塞进自定义 UI。视频通话、视频会议模板默认不存在任何按钮,点击窗口即还原。要显示按钮必须显式配置controlGroups。另外,按钮状态要主动同步,点了暂停、切了上一集,要调updatePiPControlStatus / setPiPControlEnabled 更新控制面板按钮,否则按钮状态和实际不一致。PiP 与闪控窗 / 闪控球是互斥的,同一个应用启动了画中画,就不能再启动闪控窗,需先 stopPiP。如果业务既要视频小窗又要任意内容浮窗,得设计好切换逻辑。

7. 关键注意事项

• 退后台自动拉起:出于安全,应用在后台时不允许直接 startPiP。想实现 "退出页面 → 自动切小窗" 必须用 setAutoStartEnabled(true),它会在主窗退后台时自动拉起画中画。

• 后台继续播放:画中画能在后台渲染视频,但涉及后台任务时需配合长时任务(continuousTask),并在STOPPED 时主动关闭任务 / 进程,否则会被系统回收。

• 媒体源切换:横竖屏切换、分辨率变化时调 updateContentSize 更新,否则画面会被拉伸。

最后,画中画是系统级视频小窗,只放视频,无需受限权限,退后台自动保持;如果你要浮的是 "任意内容"(面板、按钮、客服球),那建议不要使用用画中画,走闪控窗或子窗口。

Logo

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

更多推荐