Android 画中画(PiP)小窗播放:怎么做、以及它到底是怎么工作的

视频类 App 切后台还能继续播的那个小窗口,大部分人第一反应是"这不就是个悬浮窗吗"。真去翻源码才发现完全不是这么回事------它压根不是自己 App 画出来的悬浮窗,而是系统把你的 Activity 整个"抓"出来做了一次合成层面的缩放动画。这篇东西记一下怎么接这个功能,顺便把它背后到底发生了什么捋一遍。

先说结论:它不是悬浮窗,也不是第二块屏幕

写这个功能之前很容易先入为主地觉得,无非是弄个 TYPE_APPLICATION_OVERLAY 悬浮窗,或者干脆开一块 VirtualDisplay 单独渲染。两个都不对。

PiP 用的还是同一块屏幕,你的 Activity 也没有被拆成两份------它本质是把这个 Activity 所在的 Task 塞进一个专门的"钉住(Pinned)"窗口模式,然后由系统去操作这个 Task 对应的 Surface,把它从铺满全屏缩小、挪到屏幕角落。全程你的 View 树没有被重新测量布局,只是整体被"缩放"过去了。这也是为什么这个动画能做到很顺滑------它走的是图形合成层面的变换,不是 Android 传统的 layout 重排。

接入步骤

第一步,manifest 里声明支持

ini 复制代码
<activity
    android:name=".VideoActivity"
    android:supportsPictureInPicture="true"
    android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />

configChanges 这一行千万别漏。没有它,进小窗那一下相当于触发了一次"屏幕尺寸变化",系统默认行为是把 Activity 销毁重建------视频播放到一半整个 Activity 重启一遍,用户看到的就是黑屏一闪。声明了之后,走的是 onConfigurationChanged(),Activity 活着,只是尺寸变了。

第二步,构造参数,选个时机调用

scss 复制代码
PictureInPictureParams params = new PictureInPictureParams.Builder()
    .setAspectRatio(new Rational(16, 9))
    .setAutoEnterEnabled(true)   // Android 12+,切到后台系统自动帮你进小窗
    .build();
setPictureInPictureParams(params);

Android 12 之前没有 setAutoEnterEnabled,得自己在 onUserLeaveHint() 里手动调 enterPictureInPictureMode(params)------这个回调是用户按 Home 键、切到别的 App 那一刻触发的,是官方推荐的进小窗时机。12 及以后建议直接用自动进入,代码更省心,用户体验也统一。

调用之前记得判断一下设备支不支持:

scss 复制代码
if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
    enterPictureInPictureMode(params);
}

不判断也不至于崩,但没有这个 feature 的设备(比如车机,后面会说)调了也是白调。

第三步,进小窗之后把 UI 收拾干净

小窗就那么大点地方,工具栏、推荐列表、标题这些东西留着只会显得很挤,而且用户根本点不到------PiP 状态下你自己布局里的按钮是收不到触摸事件的,系统把这块区域的触摸全接管去做拖拽和缩放了。

typescript 复制代码
@Override
public void onPictureInPictureUiStateChanged(PictureInPictureUiState pipState) {
    if (pipState.isTransitioningToPip()) {
        toolbar.setVisibility(View.GONE);
        recommendationList.setVisibility(View.GONE);
    }
}

@Override
public void onPictureInPictureModeChanged(boolean isInPip, Configuration newConfig) {
    if (!isInPip) {
        toolbar.setVisibility(View.VISIBLE);
        recommendationList.setVisibility(View.VISIBLE);
    }
}

两个回调分工不一样:onPictureInPictureUiStateChanged 在动画刚开始播放时就触发(Android 15 才有),这时候藏 UI 最合适,越早藏过渡越干净;onPictureInPictureModeChanged 是动画彻底播完才触发,恢复 UI 放在这里,不会出现动画还没结束 UI 就先跳出来的违和感。

播放页面留下来的那个内容 View(SurfaceView/播放器控件之类),直接 match_parent 铺满就行,不用为小窗单独适配尺寸------系统会按你给的宽高比和屏幕剩余空间自己算出小窗实际像素,你的内容跟着容器自适应就够了。

第四步,需要控制按钮就用 RemoteAction

小窗里想放暂停/快进这种交互,不能指望塞个普通 Button 进布局------摸不到。得用:

csharp 复制代码
RemoteAction pauseAction = new RemoteAction(
    Icon.createWithResource(this, R.drawable.ic_pause),
    "暂停", "暂停播放",
    pendingIntent
);
params = new PictureInPictureParams.Builder()
    .setActions(Arrays.asList(pauseAction))
    .build();
setPictureInPictureParams(params);

这几个按钮是系统叠加画在小窗上面的,用户点一下窗口菜单才弹出来,触发的是你给的 PendingIntent,跟你自己布局里的点击事件完全是两套东西。数量上有上限,getMaxNumPictureInPictureActions() 能查到具体能塞几个,超了系统直接给你截断。

原理这块,稍微往下挖一挖

前面说"它是缩放动画不是新建屏幕",具体到系统内部,大概是这么几层:

系统里有个专门管这类窗口的组织者叫 PipTaskOrganizer,跑在 SystemUI 进程里。你的 Activity 进小窗那一刻,这个 Task 被挪进 Pinned 窗口模式,PipTaskOrganizer 拿到这个 Task 底层对应的 SurfaceControl(可以理解成合成器认的那个图层句柄),直接对它做缩放、平移、裁剪------你的 App 进程完全不知道自己被这么"操作"了,它感知到的只是过渡动画播完之后,配置(Configuration)变了一次。这中间配合一个叫 BoundsAnimationController 的东西控制动画时序,故意让 Activity 在动画播放过程中不触发真正的配置变化,等动画结束才一次性同步------这也是为什么 onPictureInPictureModeChanged 是"动画播完才触发"而不是"调用那一刻就触发"。

用户能拖着小窗到处挪、能双指缩放、松手会自动吸附到屏幕边缘,这几个交互也不是你的 App 处理的。负责识别边缘触摸手势的叫 PipResizeGestureHandler,负责算吸附到哪个角落的叫 PipSnapAlgorithm,都在 SystemUI 里,跟你的 Activity 没关系。小窗范围内的触摸,系统按一块专门划出来的"可触摸区域"拦截处理,区域外的触摸正常穿透给底下的 App------这就是为什么你能一边挂着小窗一边正常用别的 App,两边互不干扰。

悬浮在别的 App 上层这件事,靠的是合成时把这个 Task 的图层排在最上面,本质是 Z 序的问题,不是真的"多了一层窗口系统"。

整个链条串起来:声明支持 → 调用进入 → 系统把 Task 塞进 Pinned 模式并接管对应 Surface → 直接做图形变换动画 → 动画播完才回调通知你 → SystemUI 全程包办触摸、拖拽、吸附、控制按钮渲染。你能自己控制的,其实只有"声明支持"和"进入前后调整一下自己的 UI 显隐"这两件事,其余的接口以下全是系统的活。

一个容易被坑到的地方:车机上这套东西直接是废的

如果你的 App 同时要跑在 Android Automotive OS(车机原生系统,不是手机投屏那个 Android Auto)上,会发现这套东西完全用不了。查文档会发现官方对 PiP 的定义原文就是"for Android handheld devices",压根没提车机;实际测试也是------车机的 SystemUI 根本没接 PipTaskOrganizer 这一套,FEATURE_PICTURE_IN_PICTURE 这个 system feature 直接查不到:

perl 复制代码
adb shell pm list features | grep picture-in-picture

车机场景下这个空是意料之中的,往深了想也合理:PiP 这种"悬浮、可拖动、可能挡住别的内容"的交互方式,跟车机的驾驶分心设计原则天然是冲突的,车厂不太可能给这种交互开口子。做跨端(手机+车机)的播放器功能时,这一步最好提前判断好,别等真机测试的时候才发现小窗功能在车机上直接是摆设。

相关推荐
千里马学框架1 小时前
安卓系统性能优化高级实战开发专题--开机,app冷启动优化
android·智能手机·性能优化·framework·性能·开机优化·app冷启动优化
律宏阔2 小时前
Android 手机通过 adblib 使用 ADB Wi-Fi 控制 Android 9 开发板
android
律宏阔2 小时前
Android App 里实现开机动画替换
android
消失的旧时光-19433 小时前
Android 系统层扫盲 05:Android 开机后发生了什么?从 Bootloader 到 Launcher
android·zygote·fork·aosp·cow
hunterandroid4 小时前
Android 线上卡顿治理:从布局层级到主线程负载的全链路排查
android
恋猫de小郭4 小时前
Dart Skills CLI 1.0 :AI 时代的 Dart 交付支持
android·前端·flutter
2501_916007474 小时前
使用Apple Dashboards显示和自定iOS应用性能指标与数据可视化指南
android·ios·小程序·https·uni-app·iphone·webview
蜡台5 小时前
# 已解决|MySQL 8\.4\.11 密码正确仍报错1045 \(28000\) Access denied 终极修复方案
android·mysql·adb
JMchen1235 小时前
2026年六款主流AI编程工具深度实测:Cursor、Copilot、Claude Code等对比与选型思考
android·kotlin·copilot·ai编程·开发工具·cursor·claude code