iOS技术点v2panda的技术专题ios进阶

iOS仿微信小视频功能开发优化记录

2016-07-11  本文已影响10452人  pepsikirk

小视频是微信的一个重大创新功能,而在开发小视频时,由于这个功能比较新,需求也没那么多,查阅了大量资料,包括查看各种官方文档、下载所有的视频官方Demo和去GitHub上面查看各种视频库,也踩了很多坑才完成了这个功能。这也是我在完成以后,想要做这样一个小视频的开源库PKShortVideo的原因。
GitHub链接:https://github.com/pepsikirk/PKShortVideo,欢迎star和提issue。

gif.gif

小视频的录制

录制的第一种方案

录制视频最开始用的是网上找的案例 AVCaptureSession + AVCaptureMovieFileOutput 来录制视频,然后用 AVAssetExportSeeion 来转码压缩视频。这就遇到了问题,那就是

压缩后视频的分辨率以及保证预览拍摄视频与最终生成视频图像一致。

根据 AVFoundation Programming Guide 的 Still and Video Media Capture 部分,AVCaptureSession的分辨率所输出的视频分辨率是固定的由AVCaptureSessionPreset参数决定,无法达到需求所需要的分辨率(微信的小视频分辨率为320 X 240)。所以先根据微信小视频的分辨率选择了一个最为接近的AVCaptureSessionPresetMedium(分辨率竖屏情况下为360 X 480)。

预览Layer的 videoGravity 模式我出于摄像头位置据居中考虑使用的是 ResizeAspectFill :

AVCaptureVideoPreviewLayer *previewLayer = [self.recorder previewLayer];
previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;

所以在保持长宽比的前提下,会缩放图片,使图片充满容器View。这样需要截取的视频就为去掉上下两端多余最中间的部分。

我通过查找以后找到的解决方案就是压缩以后进行处理,而AVAssetExportSeeion设定压缩输出后的质量与AVCaptureSession类似,也是通过一个字符串类型AVAssetExportPreset来确定的,也并不能自定义分辨率。
按照这个思路寻找答案,后来经过多番查找,发现 AVAssetExportSeeionyou 有着这样的一个接口提供自定义的设置。

/* Indicates whether video composition is enabled for export and supplies the instructions for video composition.  Ignored when export preset is AVAssetExportPresetPassthrough. */
@property (nonatomic, copy, nullable) AVVideoComposition *videoComposition;

最终代码如下:(此代码年久失修,不确定是否还能用,这里的只提供思路)

    AVAsset *asset = [AVAsset assetWithURL:mediaURL];
    CMTime assetTime = [asset duration];

    AVAssetTrack *assetTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0];

    AVMutableComposition *composition = [AVMutableComposition composition];

    AVMutableCompositionTrack *compositionTrack = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
    
    [compositionTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, assetTime)
                                       ofTrack:assetTrack
                                        atTime:kCMTimeZero error:nil];
    
    AVMutableVideoCompositionLayerInstruction *videoCompositionLayerInstruction = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:assetTrack];
    CGAffineTransform transform = CGAffineTransformMake(0, 8/9.0, -8/9.0, 0, 320, -93.3333333);
    [videoCompositionLayerInstruction setTransform:transform atTime:kCMTimeZero];
    
    AVMutableVideoCompositionInstruction *videoCompositionInstruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
    [videoCompositionInstruction setTimeRange:CMTimeRangeMake(kCMTimeZero, [composition duration])];
    videoCompositionInstruction.layerInstructions = [NSArray arrayWithObject:videoCompositionLayerInstruction];
    
    AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition];
    videoComposition.renderSize = CGSizeMake(320.0f, 240.0f);
    videoComposition.frameDuration = CMTimeMake(1, 30);
    videoComposition.instructions = [NSArray arrayWithObject:videoCompositionInstruction];
    
    AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:asset presetName:AVAssetExportPresetMediumQuality];
    exportSession.outputURL = [NSURL fileURLWithPath:outputPath];
    exportSession.outputFileType = AVFileTypeMPEG4;
    exportSession.shouldOptimizeForNetworkUse = YES;
    [exportSession setVideoComposition:videoComposition];
    [exportSession exportAsynchronouslyWithCompletionHandler:^(void) {
        if (exportSession.status == AVAssetExportSessionStatusCompleted) {
            //压缩完成
        }
    }];

AVAssetTrack 、AVMutableComposition 、 AVMutableCompositionTrack、 AVMutableVideoCompositionLayerInstruction、 AVMutableVideoCompositionInstruction、 AVMutableVideoComposition 相信大家看到一下子这么多搞不清什么区别命名什么的又差不多的类都晕了吧 ,在这里就不细谈了,有兴趣的可以自行了解。

关键在于这段代码

CGAffineTransform transform = CGAffineTransformMake(0, 8/9.0, -8/9.0, 0, 320, -93.3333333);
    [videoCompositionLayerInstruction setTransform:transform atTime:kCMTimeZero];


通过设置transform可以变换各种视频输出样式,包括只截取视频某一部分和各种变换,CGAffineTransform可以好好理解一下,做动画也经常可以用到(我这里是写死了只能截取原视频分辨率为360 X 480 情况下截取中间部分 320 X 240的分辨率的情况,而且后来想再试试发现好像已经不能用了,后来换了别的方式也没有再改了)。

录制第一种方案的缺点

如微信官方开发分享的iOS微信小视频优化心得里写的:

于是用AVCaptureMovieFileOutput(640*480)直接生成视频文件,拍视频很流畅。然而录制的6s视频大小有2M+,再用MMovieDecoder+MMovieWriter压缩至少要7~8s,影响聊天窗口发小视频的速度。

这个方案需要拍摄完成以后再进行转码压缩,速度比较慢,影响用户体验,那有没有一种方式可以直接在拍摄时就直接进行转码压缩呢?下面来看方案二。

录制的第二种方案

再次经过多番查找(最开始开发时并不知道微信官方分享的文章,顺便吐槽一下微信订阅号文章不会被搜索到),翻找官方demo和搜索,并没有找到一个很好的录制思路,后来想起来喵神主导的ObjC中国好像在之前有过一个视频期刊模块的分享,于是去看了一下就找到了这篇文章在 iOS 上捕获视频,看完之后大有裨益,强烈推荐大家也去看看(ObjC中国里的文章都很棒,感谢喵神主导的翻译组)。并直接在上面找到VideoCaptureDemo。我最终的实现方案也是由这个改写而成。
这里面也包括含有了UIImagePickerController,AVCaptureSession + AVMovieFileOutput,AVCaptureSession + AVAssetWriter 三种方案,UIImagePickerController就是最基本的系统拍摄照片和录制视频的库了,一般普通视频和拍照时会用到。第二种就是我上面说的第一个录制方案,最后一种就是我们想要的定制性最强的录制方案了。
这个方案借用在 iOS 上捕获视频的一张图和话:

图1图1
如果你想要对影音输出有更多的操作,你可以使用 AVCaptureVideoDataOutput 和 AVCaptureAudioDataOutput 而不是我们上节讨论的 AVCaptureMovieFileOutput。 这些输出将会各自捕获视频和音频的样本缓存,接着发送到它们的代理。代理要么对采样缓冲进行处理 (比如给视频加滤镜),要么保持原样传送。使用 AVAssetWriter 对象可以将样本缓存写入文件:
这个方案就相当于自己对每一帧图像都可以进行处理,SCRecorder就是用类似的方式做的。这种方案在iPhone4上不会出现iOS微信小视频优化心得中说的:
在4s以上的设备拍摄小视频挺流畅,帧率能达到要求。但是在iPhone4,录制的时候特别卡,录到的视频只有6~8帧/秒。尝试把录制视频时的界面动画去掉,稍微流畅些,帧率多了3~4帧/秒,还是不满足需求。
这个的问题。由于微信方面没有开源代码,也无法对比,不过也就没有其后面写的其它问题了。

同样的,这个方案也需要考虑压缩后视频的分辨率以及保证预览拍摄视频与最终生成视频图像一致

NSInteger numPixels = self.outputSize.width * self.outputSize.height;
//每像素比特
CGFloat bitsPerPixel = 6.0;
NSInteger bitsPerSecond = numPixels * bitsPerPixel;
    
// 码率和帧率设置
NSDictionary *compressionProperties = @{ AVVideoAverageBitRateKey : @(bitsPerSecond),
                                AVVideoExpectedSourceFrameRateKey : @(30),
                                         AVVideoAverageBitRateKey : @(30) };
    
NSDictionary *videoCompressionSettings = @{ AVVideoCodecKey : AVVideoCodecH264,
                             AVVideoScalingModeKey : AVVideoScalingModeResizeAspectFill,
                                   AVVideoWidthKey : @(self.outputSize.height),
                                  AVVideoHeightKey : @(self.outputSize.width),
                   AVVideoCompressionPropertiesKey : compressionProperties };
                       
self.videoInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeVideo outputSettings:videoCompressionSettings sourceFormatHint:videoFormatDescription];
self.videoInput.transform = CGAffineTransformMakeRotation(M_PI_2);//人像方向;
    

AVVideoHeightKey和AVVideoHeightKey分别是高和宽赋值是相反的。因为一般以人观看的方向做为参考标准来说小视频的分辨率 宽 X 高 是 320 X 240,而设备默认的方向是Landscape Left,即设备向左偏移90度,所以实际的视频分辨率就是240 X 320与一般认为的相反。
由于小视频是只支持竖屏拍摄即设备方向为Portrait,就可以固定设置self.videoInput.transform = CGAffineTransformMakeRotation(M_PI_2)固定向右偏移90°。通过MediaInfo查看出相当于给输出视频添加了一个90°的角度信息,这样在播放时就能通过角度信息对视频进行播放纠正。
AVVideoScalingModeResizeAspectFill 也是非常重要的参数,对应着 AVLayerVideoGravityResizeAspectFill 就可以统一截取中间部分,不会变形并且与预览图一致。达到可以自定义分辨率不会变形的功能。

小视频的播放

小视频点击放大播放

小视频点击放大以后播放比较简单,基本使用MPMoviePlayerController(无法定制UI)和AVPlayer(可以定制UI)可以解决。代码可参考我的PKShortVideo。或官方demoAVPlayerDemo

小视频在聊天界面播放第一种方案

聊天页面的播放比较特殊,原因是需要能够同时播放多个小视频,并且在播放时滚动界面也需要一定的流畅性,对性能要求比较高。

最初我的实现方案就是通过AVPlayer在聊天界面直接创建播放。但是很快就遇到了问题:

  1. 第一个是播放起来比较卡顿。后来通过测试,微信是有着立刻当前显示列表就停止播放,滚动停止后才开始播放的优化,使用以后流畅了很多。
  2. 第二就是AVPlayer最多只能够创建16个播放的视频,这个问题我后来通过一个单例管理类用简单的算法来解决此问题。
- (AVPlayer *)getAVQueuePlayWithPlayerItem:(AVPlayerItem *)item messageID:(NSString *)messageID {
    //通过messageID取Player对象
    AVPlayer *player = self.playerDict[messageID];
    if (player) {
        //对象不等时替换player对象的item
        if (player.currentItem != item) {
            [player replaceCurrentItemWithPlayerItem:item];
        }
        return player;
    } else {
        //未在界面创建小视频时返回nil
        if (!self.playerArray.count) {
            return nil;
        }
        //按顺序平均分配player数组里面的player
        AVPlayer *player = self.playerArray[_playerIndex];
        if (_playerIndex == PlayerCount - 1) {
            _playerIndex = 0;
        } else {
            _playerIndex = _playerIndex + 1;
        }
        [player replaceCurrentItemWithPlayerItem:item];
        //缓存play可以快速获取对应的player
        [self.playerDict setObject:player forKey:messageID];

        return player;
    }
}

//在进入聊天界面时创建player对象
- (void)creatMessagePlayer {
    if (self.playerArray.count > 0) {
        return;
    }
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        for (NSInteger i = 0; i < PlayerCount ; i++) {
            AVPlayer *player = [AVPlayer new];
            //小视频聊天界面播放无声
            player.volume = 0;
            [self.playerArray addObject:player];
        }
    });
}

//离开聊天界面时清除所有AVPlayer
- (void)removeAllPlayer {
    [self.playerDict removeAllObjects];
    for (AVPlayer *player in self.playerArray) {
        [player pause];
        [player.currentItem cancelPendingSeeks];
        [player.currentItem.asset cancelLoading];
        [player replaceCurrentItemWithPlayerItem:nil];
    }
    [self.playerArray removeAllObjects];
}

这个方案经过较长时间使用能够保持稳定,没有出现什么明显问题。

小视频在聊天界面播放第二种方案

直到看到iOS微信小视频优化心得中:
另外AVPlayer在使用时会占用AudioSession,这个会影响用到AudioSession的地方,如聊天窗口开启小视频功能。还有AVPlayer释放时最好先把AVPlayerItem置空,否则会有解码线程残留着。最后是性能问题,如果聊天窗口连续播放几个小视频,列表滑动时会非常卡。通过Instrument测试性能,看不出哪里耗时,怀疑是视频播放互相抢锁引起的。

开始重新开发文中提到的 AVAssetReader + AVAssetReaderTrackOutput 的方案,代码在我的DevelopPlayerDemo里面。
由于文中代码不够完整,我自己实现了一套类似的,区别在于简单的使用定时器来获取 CMSampleBufferRef

    //定时器按照帧率获取
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(1.0/self.frameRate) target:self selector:@selector(captureLoop) userInfo:nil repeats:YES];
    
    - (void)captureLoop {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        [self captureNext];
    });
}

- (void)captureNext {
    [self.lock lock];
    
    [self processForDecoding];
    
    [self.lock unlock];
}

- (void)processForDecoding {
    if( self.assetReader.status != AVAssetReaderStatusReading ){
        if(self.assetReader.status == AVAssetReaderStatusCompleted ){
            if(!self.loop ){
                [self.timer invalidate];
                self.timer = nil;
                
                self.resetFlag = YES;
                self.currentTime = 0;
                [self releaseReader];
                return;
            } else {
                self.currentTime = 0;
                [self initReader];
            }
            if (self.delegate && [self.delegate respondsToSelector:@selector(videoDecoderDidFinishDecoding:)]) {
                [self.delegate videoDecoderDidFinishDecoding:self];
            }
        }
    }
    
    CMSampleBufferRef sampleBuffer = [self.assetReaderOutput copyNextSampleBuffer];
    if(!sampleBuffer ){
        return;
    }
    self.currentTime = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer));
    CVImageBufferRef pixBuff = CMSampleBufferGetImageBuffer(sampleBuffer);
    
    if (self.delegate && [self.delegate respondsToSelector:@selector(videoDecoderDidDecodeFrame:pixelBuffer:)]) {
        [self.delegate videoDecoderDidDecodeFrame:self pixelBuffer:pixBuff];
    }
    
    CMSampleBufferInvalidate(sampleBuffer);
}

播放和录制视频的CPU占用,并不单单只是 Debug Session 的 CPU Report 里直接写的CPU占用,还包括了系统进程 mediaserverd 对视频解码的处理,可以通过 Useage Comparison里面的 Other Processes 看到,或者可以直接用 instruments 里面的 Activity Monitor查看。

后来监测CPU性能发现与APlayer相去甚远,占用率提高了超过100%。通过 CPU Report 用5S对比测试,AVPlayer的进程CPU基本都是0%,Other Processes 在60%左右。而自己的两项数据大概是20%,100%左右。于是寻求更好的解决方案,希望能够找到能够GPU加速的方法。

后来经过一番查找想到了使用 GPUImage 里面的给通过给视频加入滤镜中使用 OpenGL ES 播放视频的方案。添加修改完成以后再次测试发现性能上也并没有质的提高,于是百思百思不得其解。直到后来突发奇想觉得有可能是AVPlayer 对视频输出分辨率和质量会根据输出的窗口大小进行一定程度上的压缩。于是试了试放大了 AVPlayerLayer 的 size,发现果然CPU的占用率提高了,这也确认了我这个猜想。

于是给 AVAssetReaderTrackOutput 增加了 outputSettings 参数。

NSError *error = nil;
AVAssetReader *assetReader = [AVAssetReader assetReaderWithAsset:self.asset error:&error];
AVAssetTrack *assetTrack = [[self.asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0];
    
CGSize outputSize = CGSizeZero;
if (self.size.width > assetTrack.naturalSize.width) {
    outputSize = assetTrack.naturalSize;
} else {
    outputSize= self.size;
}
   
NSDictionary *outputSettings = @{
                                 (id)kCVPixelBufferPixelFormatTypeKey:@(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange),
                                 (id)kCVPixelBufferWidthKey:@(outputSize.width),
                                 (id)kCVPixelBufferHeightKey:@(outputSize.height),
                                   };
    
AVAssetReaderTrackOutput *readerVideoTrackOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:assetTrack outputSettings:outputSettings];

最后就发现确实CPU占用率已经降到了跟 AVPlayer 一个水平线上。只是本进程的 CPU 还是需要占用10%左右,这个无法避免。

相关连接

iOS微信小视频优化心得
在 iOS 上捕获视频
Core Image 和视频
GPUImage
SCRecorder
AVPlayerDemo
AVFoundation Programming Guide

最后打个小广告,我最近想换个新工作环境,坐标深圳,有兴趣的可以微博私信我或者直接发email给我,谢谢!
Weibo: @-湛蓝_
Email: pepsikirk@gmail.com

上一篇下一篇

猜你喜欢

热点阅读