鸿蒙开发中如何实现画中画

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

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 更新,否则画面会被拉伸。

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

相关推荐
贾伟康5 小时前
【共创稿事节】HarmonyOS 7.0 BindContentCover全屏模态:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
KevinChaos8 小时前
声音明信片实验室 03|HarmonyOS 7.0 音频空间化:双版本试听无缝切换
harmonyos
贾伟康8 小时前
【共创稿事节】HarmonyOS 7.0 Menu菜单与上下文:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
行者-全栈开发9 小时前
Flutter 插件 device_calendar_plus 开源鸿蒙适配实战:从权限弹窗失灵到循环日程跑通
flutter·跨平台·harmonyos·arkts·日历·适配·开源鸿蒙
贾伟康11 小时前
【共创稿事节】HarmonyOS 7.0 BindSheet半模态浮层:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
JosieBook11 小时前
【鸿蒙PC GUI适配】chiaki-ng 移植的工程实践:Qt6/QML 与 ArkUI 的混合窗口架构
华为·架构·harmonyos
Dream-Y.ocean20 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
行者-全栈开发20 小时前
【共创稿事节】HarmonyOS 应用上架全流程中的隐私合规:权限最小化、数据本地化与 AppAnalyzer 体检
harmonyos·鸿蒙·隐私合规·应用上架·agc·appanalyzer
贾伟康1 天前
HarmonyOS API 26 UKey PIN 授权门禁:状态查询、失败归因与资源闭环
华为·harmonyos
Dream-Y.ocean1 天前
鸿蒙PC平台星图遥感适配实战:基于 Electron 壳方案的纯 JavaScript 遥感图像处理引擎开发
javascript·electron·harmonyos