导语:为了满足支持更多的内容形式,提升用户的内容浏览体验,资讯页面引入一个实现视频播放功能的新Native页面。本文要详细得给大家分享一下iOS客户端视频播放器的具体实现方式。
首先,为了满足58同城App全站https的需求,视频播放也要支持https。另外还要考虑网络视频的播放,视频的格式,扩展功能以及开发时间等多个问题,我们最终选择了第三方ijkplayer框架来实现视频功能。
为什么选择ijkplayer框架?
可以实现视频播放的iOS系统的框架有MediaPlayer,AVFoundation, AVKit等。
1、MediaPlayer框架中的MPMediaPlayerController已经在iOS9上被废弃掉,替代为AVPlayerViewController。因此放弃采用MediaPlayer框架。
2、AVPlayerViewController属于AVKit框架,只有iOS8系统以上才能支持。当时我们引入视频时,iOS端58同城App需要兼容iOS7,同样也放弃采用AVKit框架。
3、AVFoundation框架的AVPlayer功能强大,不受版本限制。但是支持的格式有限,并且需要自己封装,开发成本比较高,对开发时间有一定的影响。
ijkplayer是B站开源的一款基于ffmpeg的轻量级视频播放器。支持Android和iOS双平台,支持播放本地和网络视频,也支持流媒体播放。目前使用ijkplayer框架的有斗鱼,美拍。它支持iOS7.0以上的系统,API易于集成,编译配置可裁剪,方便控制安装包大小。支持硬件加速解码,更加省电。ffmpeg支持的格式可参考以下两个文档。
https://ffmpeg.org/ffmpeg-formats.html
https://ffmpeg.org/ffmpeg-protocols.html
如何集成ijkplayer?
集成ijkplayer,首先需要配置环境,安装homebrew,git,yasm。接下来集成到工程中的具体步骤如下:
1、 在github上下载ijkplayer工程。
2、 在ijkplayer工程中下载ffmpeg。因ijkplayer默认不支持https,通过编译ssl依赖库支持https。
./init-ios-openssl.sh
./init-ios.sh
cd ios
./compile-openssl.sh clean
./compile-ffmpeg.sh clean
./compile-openssl.sh all
./compile-ffmpeg.sh all
编译完成之后可以发现工程IJKMediaPlayer中依赖的库比默认只支持http的工程新增了ssl库。
3、 生成一个framework
iOS端58同城App采用的是cocoapods进行的工程管理,因此需要通过pod把ijkplayer集成到工程中。ijkplayer本身没有提供cocoapods的支持,所以需要手动写pod。
打开ijkplayer工程生成framework。因XCode在编译生成framework时会针对target的类型(真机,模拟器)编译生成不同格式的包。为了减少重复的动作和手动合并的工作量,对工程进行改造增加编译脚本设置。在每次编译时会直接将模拟器和真机的包都打出来并通过脚本合并。生成包之后需要自动跳到framework目录,将最后的open注释解开。
打好framework后发现支持https的framework 的包大小会超过100M。但是在最终编译打包成ipa的时候并不会增加太多。在采用默认格式并且支持https的framework下,通过对比集成分支ipa包(49.54M)和视频需求分支ipa包(52.34M),发现包大小只增加了2.8M,在可接受范围内。
*除了默认的视频格式之外,若需要更多的视频格式支持,执行以下命令。
cd config
rm module.sh
ln -smodule-default.sh module.sh
cdandroid/contrib
# cd ios
shcompile-ffmpeg.sh clean
4、集成到工程中。
编写podspec,给framework指定文件路径和属性并集成到58同城的工程中。
s.subspec 'ijkplayerframework' do |ijk|
#ijk.source_files = 'wb3rdcomponent/ijkplayerframework/IJKMediaFramework.framework/Headers/*.h'
ijk.requires_arc = true
ijk.ios.vendored_frameworks ='wb3rdcomponent/ijkplayerframework/IJKMediaFramework.framework'
ijk.xcconfig = { 'FRAMEWORK_SEARCH_PATHS' => '$(inherited)' }
ijk.preserve_paths = 'wb3rdcomponent/ijkplayerframework/IJKMediaFramework.framework'
end
视频的开发
封装一个视频播放器VideoPlayer,添加负责处理交互的控制界面controlView和负责播放视频的视频player。
@interface WBVideoPlayer()
@property(atomic,strong) IJKFFMoviePlayerController<IJKMediaPlayback> *player;
@property(nonatomic,strong) WBVideoControlView * controlView;
@end
controlView是一个控制界面,包括所有视图控件,并负责处理用户的所有交互行为。
@interface WBVideoControlView()
@property (weak,nonatomic) IBOutlet UIButton *screenModeChangeButton;//切换全屏
@property (weak,nonatomic) IBOutlet UIButton *playOrPauseButton;//播放按钮
@property (weak,nonatomic) IBOutlet UIImageView *loadView;//加载中图片
@property (weak,nonatomic) IBOutlet UIPanGestureRecognizer *screenGesture;//左右滑动响应事件
@property (weak,nonatomic) IBOutlet UITapGestureRecognizer *tapGesture;
… …
@end
player是视频视图,负责播放视频,也就是ijkplayer中的player。
//创建player
-(id<IJKMediaPlayback>)player{
if (!_player) {
IJKFFOptions *options = [IJKFFOptionsoptionsByDefault];
_player = [[IJKFFMoviePlayerControlleralloc] initWithContentURLString:_model.url withOptions:options];
_player.view.autoresizingMask = UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;//创建player
_player.view.frame = CGRectMake(0, 0,self.frame.size.width, self.frame.size.height);//设置布局
_player.scalingMode = IJKMPMovieScalingModeAspectFit;//设置缩放模式
[self insertSubview:_player.viewbelowSubview:self.controlView];//添加到控制界面下方
[self installMovieNotificationObservers];//添加通知
//wifi状态下自动播放
if ([WBReachability currentNetworkAvailable]&& [WBReachability usingWIFI] &&_model.autoplay) {//判断是否要自动播放
self.controlView.playerStatus =WBVideoPlayerStatusPlaying;//修改播放状态
[self videoPlayOrPausePlayer:nil];//模拟点击播放
}
}
return _player;
}
当用户触发一个点击事件时,控制界面controlView以代理的方式通知播放器VideoPlayer,并播放器处理视频player的变化。当player播放状态发生变化时(如:player因网络问题被暂停,player播放结束等), VideoPlayer添加回调方法响应player发出的通知,并VideoPlayer处理controlView的变化。
例1,用户点击重新加载按钮的处理方式如下:
//控制界面controlView响应用户点击的重新加载事件。
-(void)hideLoadFailed:(UIButton*)sender{
[self loadViewAddAnimation];//添加加载中动画
self.playerStatus = WBVideoPlayerStatusPlaying;//修改播放状态
//代理为VideoPlayer
if ([self.delegaterespondsToSelector:@selector(videoReLoadPlayer)]) {
[self.delegate videoReLoadPlayer];
}
}
通过代理的方式通知视频播放器VideoPlayer,Videoplayer刷新视频player。
-(void)videoReLoadPlayer{
[self.player shutdown];//将播放器关掉
[_player.view removeFromSuperview];//移除player
_player = nil;
[self removeMovieNotificationObservers];//移除所有通知
[_timer invalidate];//计时器失效
_timer = nil;
[self.player prepareToPlay];//通过懒加载方式重新创建一个player
[self bringSubviewToFront:self.controlView]; //保证控制界面在player的上面
}
例2,视频player因网络原因被暂停,需要显示正在加载中的动画的处理方式如下:
//视频播放器Videoplayer中添加回调方法
-(void)addNotification{
[[NSNotificationCenterdefaultCenter] addObserver:self
selector:@selector(loadStateDidChange:)
name:IJKMPMoviePlayerLoadStateDidChangeNotification
object:_player];//当前视频的状态
}
-(void)loadStateDidChange:(NSNotification*)notification
{
IJKMPMovieLoadState loadState = _player.loadState;
if ((loadState &IJKMPMovieLoadStatePlaythroughOK) != 0) {//视频开始播放
[self.controlViewloadViewHideAnimation];
} else if ((loadState &IJKMPMovieLoadStateStalled) != 0) {//视频被暂停
[self.controlViewloadViewAddAnimation];
} else {
}
}
控制界面controlView处理添加加载中动画,隐藏加载中动画。
-(void)loadViewAddAnimation{
self.loadView.hidden = NO;//显示加载中视图
[self hideControlView:YES];//隐藏控制界面
_screenGesture.enabled = NO;//设置手势无效
//添加动画
CABasicAnimation* rotationAnimation;
rotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];//旋转方向
rotationAnimation.toValue = [NSNumbernumberWithFloat: 2.0*M_PI ];
rotationAnimation.duration = 1;//1s转一圈
rotationAnimation.repeatCount = 1000;
[self.loadView.layer addAnimation:rotationAnimationforKey:@"rotationAnimation"];
}
-(void)loadViewHideAnimation{
[self.loadView.layer removeAllAnimations];//移除动画
self.loadView.hidden = YES;//隐藏加载中视图
[self hideControlView:NO];//显示控制界面
_screenGesture.enabled = YES;//手势起效
}
因此播放器内的事件都由视频播放器VideoPlayer控制,调用视频的页面无需添加任何处理方式,只需要创建VideoPlayer并设置布局和视频URL。
//创建VideoPlayer
-(void)prepareVideoPlayer{
WBVideoPlayer * videoPlayer =[[WBVideoPlayer alloc]init];
videoPlayer.frame = CGRectMake(0,self.videoPlayerY, kMAIN_SCREEN_WIDTH, kPLAYER_HEIGHT);
videoPlayer.delegate = self;
_videoPlayer = videoPlayer;
[self.view addSubview:videoPlayer];
}
视频的开发中遇到的坑?
接下来详细得讲一下在开发过程中遇到的难点并采用的解决方案。
1、全屏问题:
之前iOS端58同城的所有页面不支持横屏模式,而视频播放的全屏需要支持横屏模式。如果将工程中的的屏幕旋转开关直接打开,会导致其他的界面的旋转。那我们该如何保证只有在视频的全屏模式下支持横屏并且不影响其他的所有界面呢?
[WBPrefssharePrefs].wbAutoRotate = YES;
[WBPrefssharePrefs].wbOrientationMask = UIInterfaceOrientationMaskLandscapeRight;
添加全局的布尔变量和界面方向变量,初始值为NO和垂直,并且所有页面的自动旋转和屏幕方向都取决于该全局变量。在点击全屏时将布尔变量修改为YES,界面方向修改为右向。此时系统会重新调用autorotate方法自动旋转。当回到小屏播放时自动旋转的全局变量设为NO,界面方向设置为垂直,避免影响一些其他的界面。
解决旋转问题后,旋转到横屏的动画和效果比较正常。但是发现在iOS7系统的iPod在旋转过程中出现页面的布局有误的情况。调试过程中发现原因在于iOS8以后系统对UIScreen mainscreen的bounds进行了修改。假设竖屏状态下宽度为W,高度为H。iOS8以后的系统,旋转后横屏状态下宽度变为H,高度变为W。但是在iOS8之前的系统中,旋转后不会修改mainScreen的bounds,宽度和高度还是分别为W,H,导致布局计算出错。
最终解决的方案是针对系统进行判断,对 iOS8之前系统在横屏状态下对宽度和高度的值进行互换。后期更改方案为升级宏定义,在宏定义函数中进行判断。
BOOL iOS8OrLater = kCFCoreFoundationVersionNumber >= kCFCoreFoundationVersionNumber_iOS_8_0;
if (!iOS8OrLater) {
//ios8之前旋转UIScreen的bounds并会改变
_videoPlayer.frame = CGRectMake(0,self.videoPlayerY, kMAIN_SCREEN_HEIGHT, playerHeight);
} else {
_videoPlayer.frame = CGRectMake(0,self.videoPlayerY, kMAIN_SCREEN_WIDTH, playerHeight);
}
2、当前时间错误问题
播放视频过程中,让视频先暂停一会儿再进行播放,这一瞬间获取的当前时间currentPlayBackTime有误。举例:当视频已经播放到32秒,暂停一会儿再进行播放,当前时间currentPlayBackTime突然变成68秒,再回到32秒继续播放。
当前时间是根据player的sync_type获取currentPlayBackTime的值,而sync_type默认是audiotype,获取的主Clock一直都是音频时钟audioclock。ijkplayer内部获取时钟的具体方式如下:
player.currentPlayBackTime = ijk_get_current_position(player);
long ijk_get_current_position (IJKplayer *player){
return ffplay_get_current_position(player.ffplayer);
}
long ffplay_get_current_position(FFplayer *ffplayer ){
… …
double clock = get_master_clock(ffplayer.videoState);
… …
}
double get_master_clock(VideoState * videostate){
switch(videostate.synctype)
case videotype:
return get_clock(videoclock);break;
case audiotype:
return get_clock(audioclock);break;
default:
return get_clock(externclock);break;
}
这时,先把视频暂停后,点击重新播放,ijkplayer会将所有时钟设为非暂停状态,并更新视频时钟和extern时钟,音频时钟等待音频的回调再触发更新。如下:
pause_on(ffplay,pause_on){
… …
set_clock(videoclock,get_clock(videoclock), videoclock);
set_clock(externclock,get_clock(externclock), externclock);
… …
videoState.paused= videoState.audioclock.paused = videoState. videoclock.paused = videoState.externclock.paused= pause_on;
aoutPauseAudio(aout,pause_on);
}
static voidaudio_callback(){
set_clock_at(&audiclock,audioclock,… …);
}
因此导致音频时钟过早设置为非暂停状态。此时get_clock的值为当前时间减去时钟的最后更新时间,其中最后更新时间是音频时钟更新之前的,也就是暂停之前的时间,导致get_clock的取值偏大。
我们的解决方法是修改源代码将时钟更改为视频时钟,减少了这种现象的出现。其实这种方法不能把从根本上彻底解决问题。官方说明此问题与ffplay的异步设计有关,除非ijk放弃ffplay此问题暂时无解。
3、视频暂停状态下的缓存问题
当用户在播放视频时,网络环境从WiFi切换到移动数据网络比如4G,此时的策略是需要暂停视频并弹窗提示用户是否继续使用移动数据播放视频。
优先考虑的方案就是暂停(pause)当前的播放,然后用户选择继续播放时直接调用play方法继续播放。但是此时调用暂停方法只是暂停视频,缓存还是会继续进行下载花费用户的流量,导致对用户的提示弹框没有什么意义。如果通过调用stop方式暂停视频,stop方法会将视频结束掉,用户选择继续播放时需要重新加载当前视频,也会浪费用户的流量。因此暂停视频的方式不能解决上述的问题。
最终,我们采用的方案是将缓存大小由默认的15M更改为1M。同样用户选择暂停播放后,缓存还会进行下载。当缓存的大小达到1M时,缓存的下载会暂停不再继续花费流量。也就是说用户选择暂停后只花费用户的1M流量来减少用户的流量损失。
代码如下:
#define maxsize1*1024*1024 //1M大小
//参数分别为默认大小,最小大小,最大大小
max_buffer_size= (maxsize , 0 , maxsize);
总结
以上描述了iOS端58同城App中接入视频的具体实现,以及相关的一些主要问题。目前视频功能已经接入到部分资讯页面和房产的详情页。希望本文的分享能够帮助大家对移动端视频开发的了解。