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

小视频是微信的一个重大创新功能,而在开发小视频时,由于这个功能比较新,需求也没那么多,查阅了大量资料,包括查看各种官方文档、下载所有的视频官方 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 来确定的,也并不能自定义分辨率。

按照这个思路寻找答案,后来经过多番查找,发现 AVAssetExportSeeion 有着这样的一个接口提供自定义的设置。

/* 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,影响聊天窗口发小视频的速度。

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

录制的第二种方案

再次经过多番查找(最开始开发时并不知道微信官方分享的文章,顺便吐槽一下微信订阅号文章不能在 Google 搜索到),翻找官方 demo 和搜索,并没有找到一个很好的录制思路。后来想起来喵神主导的ObjC中国
)好像在之前有过一个视频期刊模块的分享,于是去看了一下就找到了这篇文章在 iOS 上捕获视频

看完之后大有裨益,强烈推荐大家也去看看(ObjC 中国里的文章都很棒,感谢喵神主导的翻译组)。并直接在上面找到VideoCaptureDemo。我最终的实现方案也是由这个改写而成。

这里面也包括含有了UIImagePickerController,AVCaptureSession + AVMovieFileOutput,AVCaptureSession + AVAssetWriter 三种方案,UIImagePickerController 就是最基本的系统拍摄照片和录制视频的库了,一般普通视频和拍照时会用到。第二种就是我上面说的第一个录制方案,最后一种就是我们想要的定制性最强的录制方案了。

这个方案借用在 iOS 上捕获视频的一张图和话:

1.png

如果你想要对影音输出有更多的操作,你可以使用 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 。或官方 demo AVPlayerDemo

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

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

最初我的实现方案就是通过 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容