前提条件与实现步骤
1. 环境要求
| 平台 | 最低版本 | 特殊要求 |
|---|---|---|
| Android | API 26 (Android 8.0) | 需要 android:supportsPictureInPicture="true" |
| iOS | iOS 15+ | 需要 AVKit 框架,仅支持 iPad(iPhone iOS 17+ 也开始支持) |
2. 实现步骤清单
Android 端配置
- AndroidManifest.xml 中添加 PiP 支持
- MainActivity.kt 中配置 PiP 参数(aspect ratio、actions 等)
- 处理生命周期回调(
onUserLeaveHint自动进入 PiP)
iOS 端配置
- Info.plist 添加 Background Mode -> Audio, AirPlay, and Picture in Picture
- AppDelegate.swift 配置
AVAudioSession - 使用
AVPlayerViewController或自定义AVPictureInPictureController
Flutter 端
- 添加依赖:
pip_flutter或flutter_pip或原生通道 - 实现平台通道与原生通信
- 处理 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
- 生命周期处理:
onUserLeaveHint()是进入 PiP 的关键时机 - 宽高比限制: 必须在 [1/2.39, 2.39] 范围内
- 多窗口冲突: PiP 与分屏模式互斥
iOS
- 必须配置 Audio Session: 否则 PiP 无法工作
- 必须使用 AVPlayerLayer: 自定义渲染不支持 PiP
- iPhone 限制: iOS 16 之前仅 iPad 支持
Flutter
- 平台判断: 使用
Platform.isAndroid/Platform.isIOS - UI 适配: PiP 模式下应避免复杂交互
- 状态同步: 通过 EventChannel 保持状态一致
6. 进阶功能
自定义 PiP 内容(Android 12+)
// 使用 setSourceRectHint 实现无缝切换
pipParamsBuilder?.setSourceRectHint(sourceRect)
iOS 自定义播放器控件
// 实现 customPlayerController 协议
// 提供自定义的播放控制界面
后台音频播放
// 配合 audio_service 插件
// 实现 PiP + 后台音频完整方案
参考资源
---
## 快速开始建议
1. **简单场景**: 直接使用 `pip_flutter` 插件,30 分钟集成
2. **生产环境**: 使用上述原生通道方案,完全可控
3. **视频应用**: 配合 `video_player` + 自定义原生层,体验最佳