系统级画中画(Picture-in-Picture, PiP)的完整实现方案

方木先生
分享互动规则

前提条件与实现步骤

1. 环境要求

平台最低版本特殊要求
AndroidAPI 26 (Android 8.0)需要 android:supportsPictureInPicture="true"
iOSiOS 15+需要 AVKit 框架,仅支持 iPad(iPhone iOS 17+ 也开始支持)

2. 实现步骤清单

Android 端配置

  1. AndroidManifest.xml 中添加 PiP 支持
  2. MainActivity.kt 中配置 PiP 参数(aspect ratio、actions 等)
  3. 处理生命周期回调(onUserLeaveHint 自动进入 PiP)

iOS 端配置

  1. Info.plist 添加 Background Mode -> Audio, AirPlay, and Picture in Picture
  2. AppDelegate.swift 配置 AVAudioSession
  3. 使用 AVPlayerViewController 或自定义 AVPictureInPictureController

Flutter 端

  1. 添加依赖:pip_flutterflutter_pip 或原生通道
  2. 实现平台通道与原生通信
  3. 处理 PiP 状态监听与 UI 适配

完整实现方案

方案一:使用 pip_flutter 插件(推荐,简单场景)

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  pip_flutter: ^1.0.0  # 或最新版本
  video_player: ^2.8.1

方案二:原生通道实现(完整控制,推荐用于生产环境)

以下是完整的 Skill.md 格式,你可以直接保存给 Cursor 使用:


# Flutter Picture-in-Picture (PiP) 实现技能文档

## 概述
实现 Flutter 跨平台系统级画中画功能,支持 Android 8.0+ 和 iOS 15+。

## 前置条件

### Android
- minSdkVersion: 26
- 目标 Activity 必须支持 PiP 模式

### iOS
- iOS 15.0+
- 需要 Background Mode 权限
- 推荐使用真机测试(模拟器支持有限)

---

## 目录结构

lib/ ├── main.dart ├── pip/ │ ├── pip_manager.dart # PiP 管理器 │ └── pip_platform_channel.dart # 平台通道 android/ ├── app/src/main/ │ ├── AndroidManifest.xml # 配置 supportsPictureInPicture │ └── kotlin/.../MainActivity.kt # PiP 参数配置 ios/ ├── Runner/ │ ├── Info.plist # Background Modes │ └── AppDelegate.swift # AVAudioSession 配置


---

## 1. Flutter 层实现

### pip_manager.dart

```dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';

/// PiP 状态枚举
enum PipStatus {
  /// 未启动
  inactive,
  /// 正在进入
  entering,
  /// 已进入画中画
  active,
  /// 正在退出
  exiting,
}

/// PiP 配置
class PipConfiguration {
  /// 宽高比 (Android)
  final Rational aspectRatio;
  
  /// 是否自动进入 PiP(当用户离开应用时)
  final bool autoEnterEnabled;
  
  /// 是否开启无缝切换(Android S+)
  final bool seamlessResizeEnabled;
  
  /// 自定义操作按钮(Android)
  final List<PipAction>? actions;

  const PipConfiguration({
    this.aspectRatio = const Rational(16, 9),
    this.autoEnterEnabled = true,
    this.seamlessResizeEnabled = true,
    this.actions,
  });
}

/// 宽高比
class Rational {
  final int numerator;
  final int denominator;
  
  const Rational(this.numerator, this.denominator);
  
  Map<String, dynamic> toMap() => {
    'numerator': numerator,
    'denominator': denominator,
  };
}

/// 操作按钮(Android)
class PipAction {
  final String id;
  final String title;
  final IconData icon;
  
  PipAction({
    required this.id,
    required this.title,
    required this.icon,
  });
  
  Map<String, dynamic> toMap() => {
    'id': id,
    'title': title,
    'iconCodePoint': icon.codePoint,
  };
}

/// PiP 管理器
class PipManager {
  static final PipManager _instance = PipManager._internal();
  factory PipManager() => _instance;
  PipManager._internal();

  static const MethodChannel _channel = MethodChannel('pip_channel');
  static const EventChannel _eventChannel = EventChannel('pip_events');

  Stream<PipStatus>? _pipStatusStream;
  PipConfiguration? _currentConfig;

  /// PiP 状态流
  Stream<PipStatus> get pipStatusStream {
    _pipStatusStream ??= _eventChannel
        .receiveBroadcastStream()
        .map((dynamic event) => _parseStatus(event as String));
    return _pipStatusStream!;
  }

  /// 初始化
  Future<void> initialize() async {
    _channel.setMethodCallHandler(_handleMethodCall);
  }

  /// 配置 PiP
  Future<bool> configure(PipConfiguration config) async {
    _currentConfig = config;
    
    if (Platform.isAndroid) {
      final result = await _channel.invokeMethod<bool>('configure', {
        'aspectRatio': config.aspectRatio.toMap(),
        'autoEnterEnabled': config.autoEnterEnabled,
        'seamlessResizeEnabled': config.seamlessResizeEnabled,
        'actions': config.actions?.map((a) => a.toMap()).toList(),
      });
      return result ?? false;
    }
    
    // iOS 配置在原生层处理
    return true;
  }

  /// 进入画中画
  Future<bool> enterPip() async {
    if (!await isSupported()) {
      debugPrint('PiP not supported on this device');
      return false;
    }
    
    try {
      final result = await _channel.invokeMethod<bool>('enterPip');
      return result ?? false;
    } catch (e) {
      debugPrint('Error entering PiP: $e');
      return false;
    }
  }

  /// 退出画中画(Android 通常不需要手动调用)
  Future<bool> exitPip() async {
    if (Platform.isAndroid) {
      final result = await _channel.invokeMethod<bool>('exitPip');
      return result ?? false;
    }
    // iOS 由系统控制退出
    return false;
  }

  /// 检查是否支持 PiP
  Future<bool> isSupported() async {
    if (Platform.isIOS) {
      // iOS 15+ 支持
      // 实际检查应在原生层
      return true;
    }
    
    final result = await _channel.invokeMethod<bool>('isSupported');
    return result ?? false;
  }

  /// 检查当前是否在 PiP 模式
  Future<bool> isInPipMode() async {
    final result = await _channel.invokeMethod<bool>('isInPipMode');
    return result ?? false;
  }

  /// 更新 PiP 宽高比(动态调整)
  Future<void> updateAspectRatio(Rational ratio) async {
    if (Platform.isAndroid) {
      await _channel.invokeMethod('updateAspectRatio', ratio.toMap());
    }
  }

  /// 处理原生层调用
  Future<dynamic> _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'onPipAction':
        final actionId = call.arguments as String;
        _handlePipAction(actionId);
        break;
      case 'onPipEnter':
        debugPrint('PiP entered');
        break;
      case 'onPipExit':
        debugPrint('PiP exited');
        break;
    }
  }

  void _handlePipAction(String actionId) {
    // 处理自定义操作
    debugPrint('PiP action received: $actionId');
  }

  PipStatus _parseStatus(String status) {
    switch (status) {
      case 'entering':
        return PipStatus.entering;
      case 'active':
        return PipStatus.active;
      case 'exiting':
        return PipStatus.exiting;
      default:
        return PipStatus.inactive;
    }
  }
}

pip_platform_channel.dart

import 'package:flutter/services.dart';

/// 平台通道常量
class PipChannel {
  static const MethodChannel channel = MethodChannel('pip_channel');
  static const EventChannel eventChannel = EventChannel('pip_events');
  
  // 方法名
  static const String configure = 'configure';
  static const String enterPip = 'enterPip';
  static const String exitPip = 'exitPip';
  static const String isSupported = 'isSupported';
  static const String isInPipMode = 'isInPipMode';
  static const String updateAspectRatio = 'updateAspectRatio';
  
  // 事件名
  static const String statusChanged = 'statusChanged';
  static const String actionTriggered = 'actionTriggered';
}

2. Android 原生实现

AndroidManifest.xml

<activity
    android:name=".MainActivity"
    android:launchMode="singleTop"
    android:supportsPictureInPicture="true"
    android:configChanges="
        screenSize|smallestScreenSize|
        screenLayout|orientation|
        keyboardHidden|keyboard|
        navigation"
    android:theme="@style/LaunchTheme"
    android:exported="true">
    
    <!-- 配置 PiP 默认参数(可选) -->
    <layout 
        android:defaultHeight="200dp"
        android:defaultWidth="350dp"
        android:gravity="top|end"
        android:minimalHeight="150dp"
        android:minimalWidth="250dp" />
    
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

MainActivity.kt

package com.example.pip_demo

import android.app.PictureInPictureParams
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "pip_channel"
    private val EVENT_CHANNEL = "pip_events"
    
    private var pipParamsBuilder: PictureInPictureParams.Builder? = null
    private var eventSink: EventChannel.EventSink? = null
    private var isInPipMode = false
    
    // 配置状态
    private var currentAspectRatio = Rational(16, 9)
    private var autoEnterEnabled = true
    private var actions: List<Map<String, Any>>? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        
        // Method Channel
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "configure" -> {
                        configurePip(call.arguments as Map<String, Any>)
                        result.success(true)
                    }
                    "enterPip" -> {
                        result.success(enterPipMode())
                    }
                    "exitPip" -> {
                        result.success(exitPipMode())
                    }
                    "isSupported" -> {
                        result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
                    }
                    "isInPipMode" -> {
                        result.success(isInPipMode)
                    }
                    "updateAspectRatio" -> {
                        val args = call.arguments as Map<String, Int>
                        updateAspectRatio(
                            Rational(args["numerator"]!!, args["denominator"]!!)
                        )
                        result.success(null)
                    }
                    else -> result.notImplemented()
                }
            }
        
        // Event Channel
        EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
            .setStreamHandler(object : EventChannel.StreamHandler {
                override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                    eventSink = events
                }
                override fun onCancel(arguments: Any?) {
                    eventSink = null
                }
            })
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun configurePip(args: Map<String, Any>) {
        val ratioMap = args["aspectRatio"] as Map<String, Int>
        currentAspectRatio = Rational(ratioMap["numerator"]!!, ratioMap["denominator"]!!)
        autoEnterEnabled = args["autoEnterEnabled"] as Boolean
        
        // 保存操作按钮配置
        @Suppress("UNCHECKED_CAST")
        actions = args["actions"] as? List<Map<String, Any>>
        
        updatePipParams()
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun updatePipParams() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            pipParamsBuilder = PictureInPictureParams.Builder()
                .setAspectRatio(currentAspectRatio)
            
            // Android 12+ 功能
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                pipParamsBuilder?.setAutoEnterEnabled(autoEnterEnabled)
                pipParamsBuilder?.setSeamlessResizeEnabled(
                    arguments?.get("seamlessResizeEnabled") as? Boolean ?: true
                )
            }
            
            // 设置操作按钮
            actions?.let { actionList ->
                val remoteActions = actionList.map { action ->
                    // 创建 RemoteAction
                    // 需要导入 android.app.RemoteAction
                    // 这里简化处理,实际应创建图标和 PendingIntent
                }
                // pipParamsBuilder?.setActions(remoteActions)
            }
            
            setPictureInPictureParams(pipParamsBuilder!!.build())
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun enterPipMode(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            updatePipParams()
            enterPictureInPictureMode(pipParamsBuilder!!.build())
        } else {
            false
        }
    }

    private fun exitPipMode(): Boolean {
        // Android 不允许程序化退出 PiP,用户需手动关闭
        // 可以发送广播或事件通知 Flutter 层
        return false
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun updateAspectRatio(ratio: Rational) {
        currentAspectRatio = ratio
        if (isInPipMode) {
            updatePipParams()
        }
    }

    // 当用户点击 Home 键时自动进入 PiP(如果启用)
    override fun onUserLeaveHint() {
        super.onUserLeaveHint()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && autoEnterEnabled) {
            enterPipMode()
        }
    }

    // PiP 模式变化回调
    override fun onPictureInPictureModeChanged(
        isInPictureInPictureMode: Boolean,
        newConfig: Configuration?
    ) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
        isInPipMode = isInPictureInPictureMode
        
        val status = if (isInPictureInPictureMode) "active" else "inactive"
        eventSink?.success(status)
        
        // 通知 Flutter 层
        if (isInPictureInPictureMode) {
            // 进入 PiP,应简化 UI
        } else {
            // 退出 PiP,恢复正常 UI
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // 处理从 PiP 返回的 Intent
    }
}

3. iOS 原生实现

Info.plist

<!-- 添加 Background Modes -->
<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
    <string>processing</string>
</array>

<!-- iOS 16+ 需要 -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.example.pipdemo.backgroundtask</string>
</array>

AppDelegate.swift

import UIKit
import Flutter
import AVKit
import AVFoundation

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    private var pipController: AVPictureInPictureController?
    private var playerLayer: AVPlayerLayer?
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // 配置 Audio Session(必须)
        configureAudioSession()
        
        // 注册 Flutter 插件
        GeneratedPluginRegistrant.register(with: self)
        
        // 配置 Method Channel
        setupPipChannel()
        
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    private func configureAudioSession() {
        do {
            let audioSession = AVAudioSession.sharedInstance()
            try audioSession.setCategory(.playback, mode: .moviePlayback)
            try audioSession.setActive(true)
        } catch {
            print("Failed to set audio session category: \(error)")
        }
    }
    
    private func setupPipChannel() {
        guard let controller = window?.rootViewController as? FlutterViewController else { return }
        
        let channel = FlutterMethodChannel(
            name: "pip_channel",
            binaryMessenger: controller.binaryMessenger
        )
        
        channel.setMethodCallHandler { [weak self] call, result in
            switch call.method {
            case "configure":
                self?.configurePip(call.arguments as? [String: Any])
                result(true)
            case "enterPip":
                result(self?.enterPip())
            case "isSupported":
                result(self?.isPipSupported())
            case "isInPipMode":
                result(self?.pipController?.isPictureInPictureActive ?? false)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
    }
    
    private func isPipSupported() -> Bool {
        return AVPictureInPictureController.isPictureInPictureSupported()
    }
    
    private func configurePip(_ args: [String: Any]?) {
        // iOS PiP 配置主要在初始化时完成
        // 可以通过参数调整行为
    }
    
    private func enterPip() -> Bool {
        guard let pipController = pipController else {
            // 如果没有现成的 controller,需要创建一个
            // 通常与视频播放器一起使用
            return false
        }
        
        if pipController.isPictureInPicturePossible {
            pipController.startPictureInPicture()
            return true
        }
        return false
    }
    
    // 供 Flutter 视频播放器插件调用
    func setupPip(for playerLayer: AVPlayerLayer) {
        guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
        
        self.playerLayer = playerLayer
        pipController = AVPictureInPictureController(playerLayer: playerLayer)
        pipController?.delegate = self
    }
}

// MARK: - AVPictureInPictureControllerDelegate
extension AppDelegate: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        // 通知 Flutter
        print("PiP will start")
    }
    
    func pictureInPictureControllerDidStartPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        print("PiP did start")
    }
    
    func pictureInPictureControllerWillStopPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        print("PiP will stop")
    }
    
    func pictureInPictureControllerDidStopPictureInPicture(
        _ pictureInPictureController: AVPictureInPictureController
    ) {
        print("PiP did stop")
    }
    
    func pictureInPictureController(
        _ pictureInPictureController: AVPictureInPictureController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
        completionHandler: @escaping (Bool) -> Void
    ) {
        // 恢复 UI
        completionHandler(true)
    }
}

4. 使用示例

main.dart

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'pip/pip_manager.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PipManager().initialize();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PiP Demo',
      theme: ThemeData.dark(),
      home: const VideoPlayerScreen(),
    );
  }
}

class VideoPlayerScreen extends StatefulWidget {
  const VideoPlayerScreen({super.key});

  @override
  State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}

class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
  late VideoPlayerController _controller;
  final PipManager _pipManager = PipManager();
  PipStatus _pipStatus = PipStatus.inactive;

  @override
  void initState() {
    super.initState();
    _initVideo();
    _initPip();
  }

  Future<void> _initVideo() async {
    _controller = VideoPlayerController.network(
      'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
    );
    await _controller.initialize();
    setState(() {});
  }

  Future<void> _initPip() async {
    // 监听 PiP 状态
    _pipManager.pipStatusStream.listen((status) {
      setState(() => _pipStatus = status);
      
      // 根据状态调整 UI
      if (status == PipStatus.active) {
        // 进入 PiP,暂停主界面更新或简化 UI
      }
    });

    // 配置 PiP
    await _pipManager.configure(const PipConfiguration(
      aspectRatio: Rational(16, 9),
      autoEnterEnabled: true,
      actions: [
        PipAction(id: 'play_pause', title: 'Play/Pause', icon: Icons.play_arrow),
        PipAction(id: 'close', title: 'Close', icon: Icons.close),
      ],
    ));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isInPip = _pipStatus == PipStatus.active;

    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: Column(
          children: [
            // 视频区域
            AspectRatio(
              aspectRatio: _controller.value.aspectRatio,
              child: Stack(
                alignment: Alignment.center,
                children: [
                  VideoPlayer(_controller),
                  // 播放控制(非 PiP 模式显示)
                  if (!isInPip) ...[
                    GestureDetector(
                      onTap: () {
                        setState(() {
                          _controller.value.isPlaying
                              ? _controller.pause()
                              : _controller.play();
                        });
                      },
                      child: Container(
                        color: Colors.transparent,
                        child: Icon(
                          _controller.value.isPlaying
                              ? Icons.pause_circle_outline
                              : Icons.play_circle_outline,
                          size: 64,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ],
                ],
              ),
            ),
            
            // 控制栏(非 PiP 模式显示)
            if (!isInPip) ...[
              const SizedBox(height: 20),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton.icon(
                    onPressed: _enterPip,
                    icon: const Icon(Icons.picture_in_picture),
                    label: const Text('进入画中画'),
                  ),
                ],
              ),
              const SizedBox(height: 20),
              // 视频信息
              const Padding(
                padding: EdgeInsets.all(16),
                child: Text(
                  '视频标题',
                  style: TextStyle(fontSize: 18),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Future<void> _enterPip() async {
    final success = await _pipManager.enterPip();
    if (!success && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('无法进入画中画模式')),
      );
    }
  }
}

5. 关键注意事项

Android

  1. 生命周期处理: onUserLeaveHint() 是进入 PiP 的关键时机
  2. 宽高比限制: 必须在 [1/2.39, 2.39] 范围内
  3. 多窗口冲突: PiP 与分屏模式互斥

iOS

  1. 必须配置 Audio Session: 否则 PiP 无法工作
  2. 必须使用 AVPlayerLayer: 自定义渲染不支持 PiP
  3. iPhone 限制: iOS 16 之前仅 iPad 支持

Flutter

  1. 平台判断: 使用 Platform.isAndroid / Platform.isIOS
  2. UI 适配: PiP 模式下应避免复杂交互
  3. 状态同步: 通过 EventChannel 保持状态一致

6. 进阶功能

自定义 PiP 内容(Android 12+)

// 使用 setSourceRectHint 实现无缝切换
pipParamsBuilder?.setSourceRectHint(sourceRect)

iOS 自定义播放器控件

// 实现 customPlayerController 协议
// 提供自定义的播放控制界面

后台音频播放

// 配合 audio_service 插件
// 实现 PiP + 后台音频完整方案

参考资源


---

## 快速开始建议

1. **简单场景**: 直接使用 `pip_flutter` 插件,30 分钟集成
2. **生产环境**: 使用上述原生通道方案,完全可控
3. **视频应用**: 配合 `video_player` + 自定义原生层,体验最佳
评论 0

支持 @用户名 提醒对方(需为站内已注册用户名);回复仅支持一层楼中楼。

登录后发表评论、回复与 @ 提及。

举报

举报会匿名发送给管理员审核。

  • 暂无评论,来发表第一条。

码谱 · The Digital Atelier · 技术内容社区