深入 Android 画中画 (PiP) 模式全链路:从 Activity 生命周期切换到 SurfaceView 无缝过渡的窗口管理架构解析
做视频播放 App 时踩过一个坑:用户按下 Home 键进入 PiP,画面切过去了但声音还在播,过了 3 秒 ANR。日志显示 onStop() 里有个耗时操作阻塞了主线程,而 PiP 进入流程卡在 onPause() 之后一直等不到 onStop() 完成。
这个问题本质上是整个 PiP 窗口管理链路的问题。
PiP 进入流程:生命周期是串行的
调用 enterPictureInPictureMode() 后,系统并不是直接把 Activity 缩小。实际的窗口过渡由 SystemUI 和 WindowManagerService(WMS) 协同完成:
// 1. App 端发起
enterPictureInPictureMode(params); // 异步,不等回调
// 2. AMS 接管
// → 先走 onPause()
// → 启动 PiP 过渡动画(由 SystemUI 的 PipTaskOrganizer 控制)
// → 走 onStop(),此时 Activity 不可见
// → 触发 onPictureInPictureModeChanged(true)
onPause() 和 onStop() 之间没有异步窗口。如果 onStop() 里做了耗时操作,PiP 动画会卡住——WMS 在等这个生命周期完成才能把窗口从全屏切到 PiP 模式。我当时直接把 MediaSession 状态更新扔到后台线程,onStop() 只做轻量操作,问题解决。
SurfaceView 的无缝过渡
PiP 进入时,视频画面要平滑缩小而不是闪烁重建。这依赖 SurfaceView 的 continuity 机制。
普通 View 在窗口模式切换时需要重绘,SurfaceView 不一样——它的 Surface 由独立的 SurfaceFlinger 图层管理。进入 PiP 时:
// SurfaceView 在 PiP 进入时的处理
// WMS 调用 relayoutWindow → 调整 Surface 尺寸
// SurfaceFlinger 直接缩放原 Surface 内容,不重建 BufferQueue
视频帧的渲染链路(MediaCodec → Surface → SurfaceFlinger)完全不受 Activity 生命周期中断影响。前提是两件事:
- 不要在
onStop()里调用mediaPlayer.pause() SurfaceHolder.Callback.surfaceChanged()不要重建 MediaCodec
实际项目中如果不需要极致的流畅度,我会用 TextureView 配合硬件加速做自定义过渡动画,灵活性更高。但 4K 视频这类性能敏感场景,SurfaceView 的无缝切换是 TextureView 替代不了的——少了 GPU 拷贝那一步,帧率就能稳住。
Ratio 自适应:三层约束
PictureInPictureParams.Builder.setAspectRatio() 看起来就是个宽高比设置,背后有三层约束:
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9)) // 期望比例
.build()
第一层:系统允许的范围在 Rational(1, 2) 到 Rational(2, 1) 之间,超出直接抛异常。
第二层:实际窗口比例由 SystemUI 的 PipResizeGestureHandler 动态调整。用户拖拽缩放时,系统会在你设定的 ratio 基础上做 snap(吸附),但吸附行为因 OEM 而异——Pixel 上平滑,某米手机上会跳变。调试时盯着 adb shell dumpsys activity 里 PiP 窗口的 bounds 值看。
第三层:折叠屏上,PiP 窗口会随屏幕物理比例变化而调整。我在 Flip 设备上遇到过 PiP 窗口被裁切,根因是 Rational 没随折叠状态变化更新。
// 正确做法:监听布局变化动态更新
override fun onLayoutChange(...) {
val newRatio = if (isInMultiWindowMode) Rational(3, 4) else Rational(16, 9)
setPictureInPictureParams(
PictureInPictureParams.Builder().setAspectRatio(newRatio).build()
)
}
RemoteAction:跨进程回调,不是本地调用
PiP 窗口支持 3 个 RemoteAction,通常用来做播放/暂停、上一首/下一首:
val action = RemoteAction(
Icon.createWithResource(context, R.drawable.ic_pause),
"暂停", "暂停播放",
PendingIntent.getBroadcast(
context, 0, Intent("ACTION_PAUSE"),
PendingIntent.FLAG_IMMUTABLE
)
)
RemoteAction 走的是 PendingIntent,通过 BroadcastReceiver 回调到 App——它不是直接调用你的 Activity 方法。这意味着几件事:
- App 进程被杀后,PendingIntent 唤醒的是 BroadcastReceiver,你需要在那里重建播放状态
- BroadcastReceiver 的
onReceive()必须在 10 秒内返回,否则 ANR - 不要在
onReceive()里做网络请求,切到 Service 处理
我踩过一个坑:在 BroadcastReceiver 里直接调 mediaPlayer.pause(),播放器状态机因为时序问题抛了 IllegalStateException。正确做法是通过 MediaSession 的回调桥接:
class PiPActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// 通过 MediaSession 分发,由 MediaSession.Callback 处理
val session = getMediaSession()
session?.controller?.transportControls?.pause()
}
}
MediaSession 联动:唯一状态源
PiP 窗口的 RemoteAction 和锁屏通知的控制按钮共享同一套播放状态,桥梁就是 MediaSession。
// 构建 MediaSession 时绑定 PiP 需要的信息
val session = MediaSession.Builder(context, player)
.setCallback(object : MediaSession.Callback {
override fun onPlay() {
player.play()
// 同步更新 PiP 的 RemoteAction 状态
updatePiPActions(isPlaying = true)
}
override fun onPause() {
player.pause()
updatePiPActions(isPlaying = false)
}
})
.build()
MediaSession 是唯一的状态源。PiP 的 RemoteAction、锁屏通知、蓝牙控制、车载系统都从 MediaSession 读取播放状态。在各处维护独立状态是自找麻烦——PiP 显示”暂停”图标但手机还在播,查半天才发现是 isPlaying 的 flag 没同步。
private fun updatePiPActions(isPlaying: Boolean) {
val actions = if (isPlaying) {
listOf(buildPauseAction(), buildNextAction(), buildPrevAction())
} else {
listOf(buildPlayAction(), buildNextAction(), buildPrevAction())
}
setPictureInPictureParams(
PictureInPictureParams.Builder().setActions(actions).build()
)
}
PiP 退出的坑
手动退出 PiP 用 moveTaskToBack(true),但实际开发中更多是被系统自动关闭:
- Android 12+ 的高耗电限制会在 App 进入后台一定时间后关掉 PiP 窗口
- 内存压力下 LMK 杀进程,PiP 窗口跟着消失
- 部分 ROM 对非视频类 PiP 有限制(某些定制系统超过 3 分钟自动关闭)
几个实践中有效的做法:
- 前台 Service + 常驻通知,降低被 LMK 杀掉时的优先级
onTaskRemoved()里保存状态,App 重新拉起时恢复播放进度- 别在 PiP 窗口上做过场动画——SystemUI 的
PipOverlayController对自定义窗口的限制非常严格,改透明度都可能被截断
PiP 开发说到底是搞清楚几条边界线:生命周期串行,SurfaceView 有连续性,RemoteAction 走跨进程广播,MediaSession 是唯一状态源。边界守住了,大部分问题变成配置问题,不再是架构问题。