简介
画中画(Picture in Picture, PiP)是一项允许用户在使用其他应用时继续观看视频内容的功能。本文将详细介绍如何在 iOS 应用中实现 PiP 功能,包括自定义内容渲染和控制系统控件的显示。
效果展示
功能特性
已完成功能
- ✅ 基础 PiP 接口实现(设置、启动、停止、释放等)
- ✅ 支持自定义内容渲染,将 PiP 窗口渲染内容与插件分离
- ✅ 支持 PiP 窗口控制样式(显示/隐藏系统控件)
- ✅ 支持后台自动进入 PiP 模式
- ✅ 支持调整 PiP 窗口大小和比例
- ✅ 提供自定义窗口渲染内容 Demo(UIView 循环播放图片)
待实现功能
- ⏳ 播放事件监听与资源优化
- ⏳ 根据系统版本和应用类型自动切换实现方式
- ⏳ 通过 MPNowPlayingSession 更新播放信息
- ⏳ 细节优化和最佳实践示例
实现原理
Apple 官方文档主要描述了基于 AVPlayer 的 PiP 实现和 VOIP PiP,对于自定义渲染和控制样式等高级功能描述较少。本文结合实践经验,提供完整的实现方案。
核心思路
-
PiP 窗口显示
核心是将 UIView(AVSampleBufferDisplayLayer) 插入到指定的 contentSourceView 中,并渲染透明图像。这样既不影响原有内容,又能实现 PiP 功能。
-
自定义内容渲染
通过动态添加自定义 UIView 到 PiP 窗口实现,而不是使用标准的视频帧显示方式。这种方式更灵活,便于封装。
技术要点
关键注意事项
-
音频会话设置
即使视频没有声音,也需要设置 audio session 为 movie playback,否则应用进入后台时 PiP 窗口不会打开。
-
控件显示控制
除了
requiresLinearPlayback
可以控制快进/后退按钮外,其他控件(如播放/暂停按钮、进度条)需要通过 KVO 设置controlStyle
。 -
视图控制器访问
无法直接访问 PiP 窗口的 ViewController,目前有两种方案:
- 获取当前 activate window 添加视图
- 通过反射获取 Controller 的私有属性 viewController
注意:使用私有 API 可能有上架风险,建议寻找更稳定的替代方案。
实现步骤
1. 创建 PipView
PipView.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class AVSampleBufferDisplayLayer;
@interface PipView : UIView
@property (nonatomic) AVSampleBufferDisplayLayer *sampleBufferDisplayLayer;
- (void)updateFrameSize:(CGSize)frameSize;
@end
NS_ASSUME_NONNULL_END
PipView.m
#import "PipView.h"
#import <AVFoundation/AVFoundation.h>
@implementation PipView
+ (Class)layerClass {
return [AVSampleBufferDisplayLayer class];
}
- (AVSampleBufferDisplayLayer *)sampleBufferDisplayLayer {
return (AVSampleBufferDisplayLayer *)self.layer;
}
- (instancetype)init {
self = [super init];
if (self) {
self.alpha = 0;
}
return self;
}
- (void)updateFrameSize:(CGSize)frameSize {
CMTimebaseRef timebase;
CMTimebaseCreateWithSourceClock(nil, CMClockGetHostTimeClock(), &timebase);
CMTimebaseSetTime(timebase, kCMTimeZero);
CMTimebaseSetRate(timebase, 1);
self.sampleBufferDisplayLayer.controlTimebase = timebase;
if (timebase) {
CFRelease(timebase);
}
CMSampleBufferRef sampleBuffer =
[self makeSampleBufferWithFrameSize:frameSize];
if (sampleBuffer) {
[self.sampleBufferDisplayLayer enqueueSampleBuffer:sampleBuffer];
CFRelease(sampleBuffer);
}
}
- (CMSampleBufferRef)makeSampleBufferWithFrameSize:(CGSize)frameSize {
size_t width = (size_t)frameSize.width;
size_t height = (size_t)frameSize.height;
const int pixel = 0xFF000000; // {0x00, 0x00, 0x00, 0xFF};//BGRA
CVPixelBufferRef pixelBuffer = NULL;
CVPixelBufferCreate(NULL, width, height, kCVPixelFormatType_32BGRA,
(__bridge CFDictionaryRef)
@{(id)kCVPixelBufferIOSurfacePropertiesKey : @{}},
&pixelBuffer);
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
int *bytes = CVPixelBufferGetBaseAddress(pixelBuffer);
for (NSUInteger i = 0, length = height *
CVPixelBufferGetBytesPerRow(pixelBuffer) / 4;
i < length; ++i) {
bytes[i] = pixel;
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
CMSampleBufferRef sampleBuffer =
[self makeSampleBufferWithPixelBuffer:pixelBuffer];
CVPixelBufferRelease(pixelBuffer);
return sampleBuffer;
}
- (CMSampleBufferRef)makeSampleBufferWithPixelBuffer:
(CVPixelBufferRef)pixelBuffer {
CMSampleBufferRef sampleBuffer = NULL;
OSStatus err = noErr;
CMVideoFormatDescriptionRef formatDesc = NULL;
err = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault,
pixelBuffer, &formatDesc);
if (err != noErr) {
return nil;
}
CMSampleTimingInfo sampleTimingInfo = {
.duration = CMTimeMakeWithSeconds(1, 600),
.presentationTimeStamp =
CMTimebaseGetTime(self.sampleBufferDisplayLayer.timebase),
.decodeTimeStamp = kCMTimeInvalid};
err = CMSampleBufferCreateReadyWithImageBuffer(
kCFAllocatorDefault, pixelBuffer, formatDesc, &sampleTimingInfo,
&sampleBuffer);
if (err != noErr) {
return nil;
}
CFRelease(formatDesc);
return sampleBuffer;
}
@end
2. 配置 PiP 控制器
// 创建 PipView
PipView *pipView = [[PipView alloc] init];
pipView.translatesAutoresizingMaskIntoConstraints = NO;
// 添加到源视图
[currentVideoSourceView insertSubview:pipView atIndex:0];
[pipView updateFrameSize:CGSizeMake(100, 100)];
// 创建内容源
AVPictureInPictureControllerContentSource *contentSource =
[[AVPictureInPictureControllerContentSource alloc]
initWithSampleBufferDisplayLayer:pipView.sampleBufferDisplayLayer
playbackDelegate:self];
// 创建 PiP 控制器
AVPictureInPictureController *pipController =
[[AVPictureInPictureController alloc] initWithContentSource:contentSource];
pipController.delegate = self;
pipController.canStartPictureInPictureAutomaticallyFromInline = YES;
3. 设置控制样式
// 控制快进/后退按钮
pipController.requiresLinearPlayback = YES;
// 控制其他控件
[pipController setValue:@(1) forKey:@"controlsStyle"]; // 隐藏前进/后退、播放/暂停按钮和进度条
// [pipController setValue:@(2) forKey:@"controlsStyle"]; // 隐藏所有系统控件
4. 处理播放代理
- (CMTimeRange)pictureInPictureControllerTimeRangeForPlayback:
(AVPictureInPictureController *)pictureInPictureController {
return CMTimeRangeMake(kCMTimeZero, kCMTimePositiveInfinity);
}
5. 管理自定义视图
// 添加自定义视图
- (void)pictureInPictureControllerDidStartPictureInPicture:
(AVPictureInPictureController *)pictureInPictureController {
[pipViewController.view insertSubview:contentView atIndex:0];
[pipViewController.view bringSubviewToFront:contentView];
// 设置约束
contentView.translatesAutoresizingMaskIntoConstraints = NO;
[pipViewController.view addConstraints:@[
[contentView.leadingAnchor constraintEqualToAnchor:pipViewController.view.leadingAnchor],
[contentView.trailingAnchor constraintEqualToAnchor:pipViewController.view.trailingAnchor],
[contentView.topAnchor constraintEqualToAnchor:pipViewController.view.topAnchor],
[contentView.bottomAnchor constraintEqualToAnchor:pipViewController.view.bottomAnchor],
]];
}
// 移除自定义视图
- (void)pictureInPictureControllerDidStopPictureInPicture:
(AVPictureInPictureController *)pictureInPictureController {
[contentView removeFromSuperview];
}
参考资源
- Adopting Picture in Picture in a Custom Player
- 在 iOS App 上添加"画中画(PiP)"功能
- iOS 使用AVPictureInPictureController画中画实现自定义歌词
- 一文学会iOS画中画浮窗
- How to hide system controls on AVPictureInPictureController’s float window?
- PiPBugDemo
项目地址
欢迎 Star 支持!