在数字时代,视频已成为我们日常沟通和娱乐不可或缺的一部分。从简短的社交媒体剪辑到全长电影,我们对流畅、高质量视频播放的需求从未如此强烈。对于开发者来说,为iOS用户提供无瑕疵的视频体验是一项挑战,也是一种艺术。本篇博客将带你深入了解苹果生态系统中视频播放的核心——AV Kit和AV Foundation。
我们将首先探讨AV Kit的强大功能,这是一个为iOS开发者设计的高级视频播放框架。它封装了复杂的底层技术,提供了易于使用的界面,允许你迅速地集成视频播放功能到你的应用中。从管理播放控制和状态,到处理各种屏幕大小和设备方向,AV Kit让一切变得简单。
AV Kit从iOS 8开始被引入到iOS平台,是一个非常简单的标准框架,只包含了一个AVPlayerViewContoller类。它继承自UIViewController,用于展示和控制AVPlayer实例的播放。AVPlayerViewController具有一个很简单的界面,并提供了许多属性,下面列举几个常用的属性:
上面的页面几乎已经包括了视频播放的所有功能,播放、暂停、快进、音量,甚至还支持了AirPlay,我们来看一下它的实现有多么简单。
NSURL * url = [[NSBundle mainBundle] URLForResource:@"video" withExtension:@".mov"];
AVPlayerViewController * playerViewController = [[AVPlayerViewController alloc] init];
playerViewController.player = [[AVPlayer alloc] initWithURL:url];
playerViewController.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:playerViewController animated:YES completion:nil];
然而,每一位热衷于精细控制和定制播放体验的开发者都知道,有时“标准”是不够的。这就是我们转向AV Foundation的地方。在这个强大的框架下,你将揭开视频播放的神秘面纱,学习如何构建一个高级播放控制的完全自定义播放器。无论你是想要优化性能、处理复杂的用户交互,还是只是为了给你的应用那独特的个性化触摸,AV Foundation都为你提供了必要的工具。
使用AV Foundation实现视频播放的方式,最终是通过使用AVPlayer来播放AVAsset,但是AVAsset模型只包含媒体资源的静态信息,这些不变的属性用来描述对象的静态状态。这就意味着仅适用AVAsset对象时无法实现播放功能的。当我们需要对一个资源及其相关曲目进行播放时,首先需要通过AVPlayerItem和AVPlayerItemTrack类构件相应的动态内容。
AVAsset是一个抽象类和不可变类,定义了媒体资源混合呈现的方式,将媒体资源的静态属性模块化成一个整体,比如它们的标题、时长和元数据等。
AVPlayerItem会建立媒体资源动态视角的数据模型并保存AVPlayer在播放资源时的呈现状态。在这个类中我们会看到诸如seekToTime:的方法以及访问currentTime和presentationSize的属性。
AVPlayer是一个用来播放基于时间的视听媒体的控制器对象。支持播放本地、分步下载或通过HTTP Live Streaming协议得到的流媒体,并在多种播放场景中播放这些视频资源。
AVPlayerLayer构建于Core Animation之上,AVPlayerLayer扩展了Core Animation的CALayer类,并通过框架在屏幕上显示视频内容。这一图层并不提供任何可视化空间或其他附件(根据开发者需求搭建的),但是它用作是内容的渲染面。创建AVPlayerLayer需要一个指向AVPlayer实例的指针,这就将图层和播放器紧密绑定在一起,保证了当播放器基于时间的方法出现时使二者保持同步。
使用上面的关键类,实现一个非常简单的视频播放功能,获取资源,播放资源,将播放图层添加到当前控制器图层中。
#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>
@interface ViewController ()
@property(nonatomic,strong)AVPlayer * player;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
//1.Define the asset URL
NSURL * assetURL = [[NSBundle mainBundle] URLForResource:@"waves" withExtension:@"mp4"];
//2.Create an instance of AVAsset
AVAsset * asset = [AVAsset assetWithURL:assetURL];
//3.Create an AVPlayerItem with a pointer to the asset to play
AVPlayerItem * playerItem = [AVPlayerItem playerItemWithAsset:asset];
//4.Create an instance of AVPlayer with a pointer to the player item
self.player = [AVPlayer playerWithPlayerItem:playerItem];
//5.create a player layer to driect the video content
AVPlayerLayer * playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
//6.attach layer into layer hierarchy
[self.view.layer addSublayer:playerLayer];
}
下面我们使用以上的关键类,来实现一个相对较为负责,功能较为全面的播放器,需要包括播放,暂停,播放进度显示,已经拖拽进度条等功能。
我们将实现分散到三个不同类当中THTransport,THOverlayView,THPlayerView,THPlayerController。
代理,接口如下:
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@protocol THTransportDelegate <NSObject>
- (void)play;
- (void)pause;
- (void)stop;
- (void)scrubbingDidStart;
- (void)scrubbedToTime:(NSTimeInterval)time;
- (void)scrubbingDidEnd;
- (void)jumpedToTime:(NSTimeInterval)time;
@end
@protocol THTransport <NSObject>
@property(nonatomic,weak)id <THTransportDelegate> delegate;
- (void)setTitle:(NSString *)title;
- (void)setCurrentTime:(NSTimeInterval)time duration:(NSTimeInterval)duration;
- (void)setScrubbingTime:(NSTimeInterval)time;
- (void)playbackComplete;
@end
NS_ASSUME_NONNULL_END
与用户交互的视图图层,上面绘制了播放,暂停按钮, 进度条,时间显示等等。接口如下
#import <UIKit/UIKit.h>
#import "THTransport.h"
NS_ASSUME_NONNULL_BEGIN
@interface THOverlayView : UIView<THTransport>
@property(nonatomic,weak)id <THTransportDelegate> delegate;
- (void)setCurrentTime:(NSTimeInterval)time;
@end
NS_ASSUME_NONNULL_END
遵循了THTransport协议,并且有一个遵循THTransportDelegate的delegate属性,和设置当前时间的方法。
实现如下:
#import "THOverlayView.h"
#import "UIView+THAdditions.h"
@interface THOverlayView ()
@property(nonatomic,assign)BOOL controlsHidden;
@property(nonatomic,strong)UINavigationBar * navBar;
@property(nonatomic,strong)UIButton * showButton;
@property(nonatomic,strong)UIToolbar * toolBar;
@property(nonatomic,strong)UIButton * playButton;
@property(nonatomic,strong)UISlider * slider;
@property(nonatomic,strong)UILabel * currentTimeLabel;
@property(nonatomic,strong)UILabel * durationTimeLabel;
@property(nonatomic,assign)BOOL scrubbing;
@property(nonatomic,assign)CGFloat lastPlaybackRate;
@property(nonatomic,strong)NSTimer * timer;
@end
@implementation THOverlayView
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self setupView];
}
return self;
}
- (void)setupView{
[self addNavBar];
[self addToolBar];
[self addGestureRecognizer];
[self resetTimer];
}
- (void)addNavBar{
self.navBar = [[UINavigationBar alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, 44)];
[self addSubview:self.navBar];
UINavigationItem * navigationItem = [[UINavigationItem alloc] init];
UIBarButtonItem * downItemButton = [[UIBarButtonItem alloc] initWithTitle:@"Down" style:UIBarButtonItemStyleDone target:self action:@selector(down)];
self.showButton = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 40, 20)];
[self.showButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[self.showButton setTitle:@"Show" forState:UIControlStateNormal];
[self.showButton setTitle:@"Hide" forState:UIControlStateSelected];
[self.showButton addTarget:self action:@selector(showOnclick:) forControlEvents:UIControlEventTouchUpInside];
UIBarButtonItem * showItemButton = [[UIBarButtonItem alloc] initWithCustomView:self.showButton];
navigationItem.leftBarButtonItem = downItemButton;
navigationItem.rightBarButtonItem = showItemButton;
[self.navBar pushNavigationItem:navigationItem animated:YES];
}
- (void)addToolBar{
self.toolBar = [[UIToolbar alloc] initWithFrame:CGRectMake(0, [UIScreen mainScreen].bounds.size.height - 44, [UIScreen mainScreen].bounds.size.width, 44)];
[self addSubview:self.toolBar];
UIBarButtonItem * spaceItem0 = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
self.playButton = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 33, 33)];
self.playButton.selected = YES;
[self.playButton setImage:[UIImage imageNamed:@"play_button"] forState:UIControlStateNormal];
[self.playButton setImage:[UIImage imageNamed:@"pause_button"] forState:UIControlStateSelected];
[self.playButton addTarget:self action:@selector(playButtonOnclick:) forControlEvents:UIControlEventTouchUpInside];
UIBarButtonItem * playItem = [[UIBarButtonItem alloc] initWithCustomView:self.playButton];
UIBarButtonItem * spaceItem1 = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
self.currentTimeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 80, 20)];
self.currentTimeLabel.textAlignment = NSTextAlignmentCenter;
self.currentTimeLabel.font = [UIFont systemFontOfSize:15];
self.currentTimeLabel.textColor = [UIColor darkTextColor];
self.currentTimeLabel.text = @"00:00";
UIBarButtonItem * currentItem = [[UIBarButtonItem alloc] initWithCustomView:self.currentTimeLabel];
self.slider = [[UISlider alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width - 290, 0)];
[self.slider addTarget:self action:@selector(showPopupUI) forControlEvents:UIControlEventValueChanged];
[self.slider addTarget:self action:@selector(hidePopupUI) forControlEvents:UIControlEventTouchUpInside];
[self.slider addTarget:self action:@selector(unhidePopupUI) forControlEvents:UIControlEventTouchDown];
UIBarButtonItem * sliderItem = [[UIBarButtonItem alloc] initWithCustomView:self.slider];
self.durationTimeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 80, 20)];
self.durationTimeLabel.textAlignment = NSTextAlignmentCenter;
self.durationTimeLabel.font = [UIFont systemFontOfSize:15];
self.durationTimeLabel.textColor = [UIColor darkTextColor];
self.durationTimeLabel.text = @"00:00";
UIBarButtonItem * durationItem = [[UIBarButtonItem alloc] initWithCustomView:self.durationTimeLabel];
UIBarButtonItem * spaceItem2 = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
UIBarButtonItem * subtitlesItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"subtitles"] style:UIBarButtonItemStyleDone target:self action:@selector(showSubtitleViewController)];
UIBarButtonItem * spaceItem3 = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
self.toolBar.items = @[spaceItem0,playItem,spaceItem1,currentItem,sliderItem,durationItem,spaceItem2,subtitlesItem,spaceItem3];
}
- (void)addGestureRecognizer{
self.userInteractionEnabled = YES;
UITapGestureRecognizer * tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(recognizer)];
[self addGestureRecognizer:tapGestureRecognizer];
}
//MARK:播放按钮
- (void)playButtonOnclick:(UIButton *)button{
button.selected = !button.selected;
if (self.delegate) {
if (button.selected) {
[self.delegate play];
}else{
[self.delegate pause];
}
}
}
//MARK:按下搓擦条
- (void)unhidePopupUI{
self.scrubbing = YES;
[self resetTimer];
[self.delegate scrubbingDidStart];
}
//MARK:搓擦条值改变
- (void)showPopupUI{
self.currentTimeLabel.text = @"--:--";
[self setScrubbingTime:self.slider.value];
[self.delegate scrubbedToTime:self.slider.value];
}
//MARK:手指离开搓擦条
- (void)hidePopupUI{
self.scrubbing = NO;
[self.delegate scrubbingDidEnd];
}
//MARK:点击屏幕显示或隐藏控制栏
- (void)recognizer{
[UIView animateWithDuration:0.35 animations:^{
if (!self.controlsHidden) {
self.navBar.frameY -= self.navBar.frameHeight;
self.toolBar.frameY += self.toolBar.frameHeight;
}else{
self.navBar.frameY += self.navBar.frameHeight;
self.toolBar.frameY -= self.toolBar.frameHeight;
[self resetTimer];
}
self.controlsHidden = !self.controlsHidden;
}];
}
//MARK:重新开始定时器
- (void)resetTimer{
[self.timer invalidate];
if (!self.scrubbing) {
self.timer = [NSTimer scheduledTimerWithTimeInterval:5 repeats:NO block:^(NSTimer * _Nonnull timer) {
if (self.timer.isValid && !self.controlsHidden) {
[self recognizer];
}
}];
}
}
//MARK:指定时间播放
- (void)setCurrentTime:(NSTimeInterval)time{
[self.delegate jumpedToTime:time];
}
//MARK:THTransport 设置时间及进度条
- (void)setCurrentTime:(NSTimeInterval)time duration:(NSTimeInterval)duration{
NSInteger currentSeconds = ceilf(time);
// double remainingTime = duration - time;
self.currentTimeLabel.text = [self formatSeconds:currentSeconds];
self.durationTimeLabel.text = [self formatSeconds:duration];
self.slider.minimumValue = 0.0f;
self.slider.maximumValue = duration;
self.slider.value = time;
}
//MARK:THTransport 设置时间
- (void)setScrubbingTime:(NSTimeInterval)time{
self.currentTimeLabel.text = [self formatSeconds:time];
}
//MARK:THTransport 播放完成
- (void)playbackComplete{
self.slider.value = 0.0f;
self.playButton.selected = NO;
}
//MARK:返回
- (void)down{
[self.delegate stop];
[self.window.rootViewController dismissViewControllerAnimated:YES completion:nil];
}
- (NSString *)formatSeconds:(NSInteger)value {
NSInteger seconds = value % 60;
NSInteger minutes = value / 60;
return [NSString stringWithFormat:@"%02ld:%02ld", (long) minutes, (long) seconds];
}
@end
播放视图,作用相当于是AVPlayerLayer,接口如下:
#import <UIKit/UIKit.h>
#import "THTransport.h"
@class AVPlayer;
NS_ASSUME_NONNULL_BEGIN
@interface THPlayerView : UIView
- (id)initWithPlayer:(AVPlayer *)player;
@property(nonatomic,readonly) id <THTransport> transport;
@end
NS_ASSUME_NONNULL_END
接口比较简单,有一个自定义的初始化方法,和一个遵循THTransport的transport。
实现如下:
#import "THPlayerView.h"
#import <AVFoundation/AVFoundation.h>
#import "THOverlayView.h"
@interface THPlayerView ()
@property(nonatomic,strong)THOverlayView * overlayView;
@end
@implementation THPlayerView
+ (Class)layerClass{
return [AVPlayerLayer class];
}
- (id)initWithPlayer:(AVPlayer *)player{
self = [super initWithFrame:CGRectZero];
if (self) {
self.backgroundColor = [UIColor blackColor];
self.autoresizingMask = UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;
[(AVPlayerLayer *)[self layer] setPlayer:player];
[self addOverlayView];
}
return self;
}
- (void)addOverlayView{
self.overlayView = [[THOverlayView alloc] initWithFrame:CGRectZero];
[self addSubview:self.overlayView];
}
- (void)layoutSubviews{
[super layoutSubviews];
self.overlayView.frame = self.bounds;
}
- (id <THTransport>)transport{
return self.overlayView;
}
@end
控制器,但不是视图控制器,主要负责播放器的播放,暂停,快进等功能,是我们处理核心播放API方法的地方。
该类的接口比较简单,只有一个初始化方法和一个只读的view属性。
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface THPlayerController : NSObject
- (id)initWithURL:(NSURL *)assetURL;
@property(nonatomic,strong,readonly)UIView * view;
@end
NS_ASSUME_NONNULL_END
因为它是一个比较核心的类,所以实现较为复杂,我们可以差分开来进行说明,首先我们来看一下它的扩展
#import "THPlayerController.h"
#import <AVFoundation/AVFoundation.h>
#import "THTransport.h"
#import "THPlayerView.h"
#define STATUS_KEYPATH @"status"
#define REFESH_INTERVAL 0.5f
static const NSString * PlayerItemStatusContext;
@interface THPlayerController ()<THTransportDelegate>
@property(nonatomic,strong)AVAsset * asset;
@property(nonatomic,strong)AVPlayerItem * playerItem;
@property(nonatomic,strong)AVPlayer * player;
@property(nonatomic,strong)THPlayerView * playerView;
@property(nonatomic,weak)id <THTransport> transport;
@property(nonatomic,strong)id timeObserver;
@property(nonatomic,strong)id itemEndObserver;
@property(nonatomic,assign)float lastPlaybackRate;
@end
里面定义了前三个属性,就是我们提到的关键类中的三个,而THPlayerView上面我们已经提到是用来显示播放画面的一个视图。
接下来看一下它的实现
@implementation THPlayerController
- (id)initWithURL:(NSURL *)assetURL{
self = [super init];
if (self) {
_asset = [AVAsset assetWithURL:assetURL];
[self prepareToPlay];
}
return self;
}
- (void)prepareToPlay{
NSArray * keys = @[@"tracks",@"duration",@"commonMetadata"];
self.playerItem = [AVPlayerItem playerItemWithAsset:self.asset automaticallyLoadedAssetKeys:keys];
[self.playerItem addObserver:self
forKeyPath:STATUS_KEYPATH
options:0
context:&PlayerItemStatusContext];
self.player = [AVPlayer playerWithPlayerItem:self.playerItem];
self.playerView = [[THPlayerView alloc] initWithPlayer:self.player];
self.transport = self.playerView.transport;
self.transport.delegate = self;
}
首先是一个自定义的初始化方法,传入资源的URL,开始准备播放的播放工作。
创建AVPlayerItem的时候使用新的初始化方法initWithAsset:automaticallyLoadedAssetKeys:方法并传入了一个keys作为参数,使用这个方法会让AVPlayerItem在初始化队列过程中载入tracks、duration和commonMetadata属性。
监听添AVPlayerItem的status属性,当它转换为AVPlayerItemStatusReadyToPlay时开始执行播放。
下面我们来看一下监听状态的实现
//MARK:监听回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if (context == &PlayerItemStatusContext) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.playerItem removeObserver:self forKeyPath:STATUS_KEYPATH];
if (self.playerItem.status == AVPlayerItemStatusReadyToPlay) {
//设置时间监听器
[self addPlayerItemTimeObserver];
[self addItemEndObserverForPlayerItem];
CMTime duration = self.playerItem.duration;
//同步显示时间
[self.transport setCurrentTime:CMTimeGetSeconds(kCMTimeZero) duration:CMTimeGetSeconds(duration)];
[self.player play];
}else{
NSLog(@"加载视频失败");
}
});
}
}
AVPlayer提供了一个addPeriodicTimeObserverForInterval:queue:usingBlock:方法来让我们实现播放进度的监听。
- (void)addPlayerItemTimeObserver{
//创建0.5秒刷新
CMTime interval = CMTimeMakeWithSeconds(REFESH_INTERVAL, NSEC_PER_SEC);
//主队列
dispatch_queue_t queue = dispatch_get_main_queue();
__weak THPlayerController * weakSelf = self;
//添加观察
self.timeObserver = [self.player addPeriodicTimeObserverForInterval:interval
queue:queue
usingBlock:^(CMTime time) {
NSTimeInterval currentTime = CMTimeGetSeconds(time);
NSTimeInterval duration = CMTimeGetSeconds(weakSelf.playerItem.duration);
[weakSelf.transport setCurrentTime:currentTime duration:duration];
}];
}
- (void)addItemEndObserverForPlayerItem{
NSString * name = AVPlayerItemDidPlayToEndTimeNotification;
NSOperationQueue * queue = [NSOperationQueue mainQueue];
__weak THPlayerController * weakSelf = self;
self.itemEndObserver = [[NSNotificationCenter defaultCenter] addObserverForName:name
object:self.playerItem
queue:queue usingBlock:^(NSNotification * _Nonnull note) {
[weakSelf.player seekToTime:kCMTimeZero completionHandler:^(BOOL finished) {
[weakSelf.transport playbackComplete];
}];
}];
}
- (void)dealloc{
if (self.itemEndObserver) {
NSNotificationCenter * nc = [NSNotificationCenter defaultCenter];
[nc removeObserver:self.itemEndObserver
name:AVPlayerItemDidPlayToEndTimeNotification
object:self.player.currentItem];
self.itemEndObserver = nil;
}
}
//MARK:播放
- (void)play {
[self.player play];
}
//MARK:暂停
- (void)pause {
self.lastPlaybackRate = self.player.rate;
[self.player pause];
}
//MARK:停止
- (void)stop {
[self.player setRate:0.0f];
[self.transport playbackComplete];
}
//MARK:跳转到
- (void)jumpedToTime:(NSTimeInterval)time {
[self.player seekToTime:CMTimeMakeWithSeconds(time, NSEC_PER_SEC)];
}
- (void)scrubbingDidStart {
self.lastPlaybackRate = self.player.rate;
[self.player pause];
[self.player removeTimeObserver:self.timeObserver];
}
//MARK:拖拽结束
- (void)scrubbingDidEnd {
[self addPlayerItemTimeObserver];
if (self.lastPlaybackRate > 0.0f) {
[self.player play];
}
}
//MARK:拖拽到
- (void)scrubbedToTime:(NSTimeInterval)time {
[self.playerItem cancelPendingSeeks];
[self.player seekToTime:CMTimeMakeWithSeconds(time, NSEC_PER_SEC)];
}
本博客将通过分析两个框架的特点和区别,带你走在打造完美iOS视频播放体验的前沿。我们将从AV Kit的简单集成开始,然后深入AV Foundation的定制深渊,揭示那些令你的应用在竞争激烈的App Store中脱颖而出的秘密。