鸿蒙开发中如何实现画中画
画中画是把视频类内容(视频播放、视频通话、视频会议、直播)以小窗形式悬浮在屏幕上,切到小窗后用户还能干别的。它是系统级窗口,比应用主窗口层级高,退到桌面后依然可见。
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 更新,否则画面会被拉伸。
最后,画中画是系统级视频小窗,只放视频,无需受限权限,退后台自动保持;如果你要浮的是 "任意内容"(面板、按钮、客服球),那建议不要使用用画中画,走闪控窗或子窗口。
更多推荐


所有评论(0)