深入 Android 画中画 (PiP) 模式全链路:从 Activity 生命周期切换到 SurfaceView 无缝过渡的窗口管理架构解析

做视频播放 App 时踩过一个坑:用户按下 Home 键进入 PiP,画面切过去了但声音还在播,过了 3 秒 ANR。日志显示 onStop() 里有个耗时操作阻塞了主线程,而 PiP 进入流程卡在 onPause() 之后一直等不到 onStop() 完成。

这个问题本质上是整个 PiP 窗口管理链路的问题。

PiP 进入流程:生命周期是串行的

调用 enterPictureInPictureMode() 后,系统并不是直接把 Activity 缩小。实际的窗口过渡由 SystemUIWindowManagerService(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 生命周期中断影响。前提是两件事:

  1. 不要在 onStop() 里调用 mediaPlayer.pause()
  2. 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 方法。这意味着几件事:

  1. App 进程被杀后,PendingIntent 唤醒的是 BroadcastReceiver,你需要在那里重建播放状态
  2. BroadcastReceiver 的 onReceive() 必须在 10 秒内返回,否则 ANR
  3. 不要在 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 分钟自动关闭)

几个实践中有效的做法:

  1. 前台 Service + 常驻通知,降低被 LMK 杀掉时的优先级
  2. onTaskRemoved() 里保存状态,App 重新拉起时恢复播放进度
  3. 别在 PiP 窗口上做过场动画——SystemUI 的 PipOverlayController 对自定义窗口的限制非常严格,改透明度都可能被截断

PiP 开发说到底是搞清楚几条边界线:生命周期串行,SurfaceView 有连续性,RemoteAction 走跨进程广播,MediaSession 是唯一状态源。边界守住了,大部分问题变成配置问题,不再是架构问题。