小視頻是微信6.0版本重大功能之一,在開發過程中遇到不少問題。本文先敘述小視頻的產品需求,介紹了幾個實現方案,分析每個方案的優缺點,最后總結出最優的解決方案。
小視頻播放需求
-
可以同時播放多個視頻
-
用戶操作界面時視頻可以繼續播放
-
播放時不能卡住界面,視頻滑進界面內后要立即播放
-
視頻在列表內播放是靜音播放,點擊放大是有聲播放
小視頻播放方案
1. MPMoviePlayerController
MPMoviePlayerController是一個簡單易用的視頻播放控件,可以播放本地文件和網絡流媒體,支持mov、mp4、mpv、3gp等H.264和MPEG-4視頻編碼格式,支持拖動進度條、快進、后退、暫停、全屏等操作,並為開發者提供了一系列播放狀態事件通知。使用時先設置URL,然后把它的view add到某個parent view里,再調用play即可。
但這方案的缺點是,同一時間只能有一個MPMoviePlayerController對象播放,不滿足同時多個播放的需求;而且也不支持靜音播放。MPMoviePlayerController適合於全屏播放視頻的場景。
2. AVPlayer
AVPlayer是AVFoundation.Framework提供的偏向於底層的視頻播放控件,用起來復雜,但功能強大。單獨使用AVPlayer是無法顯示視頻的,要把它添加到AVPlayerLayer里才行。另外它需要配合AVPlayerItem使用,AVPlayerItem類似於MVC里的Model層,負責資源加載、視頻播放設置及播放狀態管理(通過KVO方式來觀察狀態)。它們關系如下:
首先創建一個AVPlayerItem對象:
NSURL* videoUrl = [NSURL fileURLWithPath:m_path isDirectory:NO]; m_playItem = [AVPlayerItem playerItemWithURL:videoUrl]; // 監聽playItem的status屬性 [m_playItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
接下來是創建AVPlayer和AVPlayerLayerView對象。AVPlayerLayerView是自定義的UIView,用於AVPlayer播放,其layerClass是AVPlayerLayer:
// AVPlayer m_player = [AVPlayer playerWithPlayerItem:m_playItem]; m_player.actionAtItemEnd = AVPlayerActionAtItemEndNone; // AVPlayerLayerView m_playerView = [[AVPlayerLayerView alloc] initWithFrame:self.bounds]; [self addSubview:m_playerView]; // 把AVPlayer添加到AVPlayerLayer [(AVPlayerLayer*)[m_playerView layer] setPlayer:m_player]; // 觀察AVPlayerItem播放結束的通知 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(itemPlayEnded:) name:AVPlayerItemDidPlayToEndTimeNotification object:m_playItem];
AVPlayerItem的status屬性有三種狀態:AVPlayerStatusUnknown、AVPlayerStatusReadyToPlay及AVPlayerStatusFailed。當status=AVPlayerStatusReadyToPlay時,就代表視頻能播放了,此時調用AVPlayer的play方法就能播放視頻了。
相比MPMoviePlayerController,AVPlayer有最多可以同時播放16個視頻。另外AVPlayer在使用時會占用AudioSession,這個會影響用到AudioSession的地方,如聊天窗口開啟小視頻功能。還有AVPlayer釋放時最好先把AVPlayerItem置空,否則會有解碼線程殘留着。最后是性能問題,如果聊天窗口連續播放幾個小視頻,列表滑動時會非常卡。通過Instrument測試性能,看不出哪里耗時,懷疑是視頻播放互相搶鎖引起的。
3. AVAssetReader+AVAssetReaderTrackOutput
既然AVPlayer在播放視頻時會有性能問題,我們不如做自己的播放器。AVAssetReader可以從原始數據里獲取解碼后的音視頻數據。結合AVAssetReaderTrackOutput,能讀取一幀幀的CMSampleBufferRef。CMSampleBufferRef可以轉化成CGImageRef。為此,我們可以寫個MMovieDecoder的類,負責視頻解碼,每讀出一個SampleBuffer就往上層回調:
AVAssetReader* reader = [[AVAssetReader alloc] initWithAsset:m_asset error:&error]; NSArray* videoTracks = [m_asset tracksWithMediaType:AVMediaTypeVideo]; AVAssetTrack* videoTrack = [videoTracks objectAtIndex:0]; // 視頻播放時,m_pixelFormatType=kCVPixelFormatType_32BGRA // 其他用途,如視頻壓縮,m_pixelFormatType=kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange NSDictionary* options = [NSDictionary dictionaryWithObject:[NSNumber numberWithInt: (int)m_pixelFormatType] forKey:(id)kCVPixelBufferPixelFormatTypeKey]; AVAssetReaderTrackOutput* videoReaderOutput = [[AVAssetReaderTrackOutput alloc] initWithTrack:videoTrack outputSettings:options]; [reader addOutput:videoReaderOutput]; [reader startReading]; // 要確保nominalFrameRate>0,之前出現過android拍的0幀視頻 while ([reader status] == AVAssetReaderStatusReading && videoTrack.nominalFrameRate > 0) { // 讀取video sample CMSampleBufferRef videoBuffer = [videoReaderOutput copyNextSampleBuffer]; [m_delegate mMovieDecoder:self onNewVideoFrameReady:videoBuffer); CFRelease(videoBuffer); // 根據需要休眠一段時間;比如上層播放視頻時每幀之間是有間隔的 [NSThread sleepForTimeInterval:sampleInternal]; } // 告訴上層視頻解碼結束 [m_delegate mMovieDecoderOnDecodeFinished:self];
另一個是MVideoPlayerView,負責視頻的顯示,它接收MMovieDecoder回調的CMSampleBufferRef后,把它轉為CGImageRef,然后設置layer.contents為這個CGImageRef對象。創建CGImageRef不會做圖片數據的內存拷貝,它只會當Core Animation執行Transaction::commit()觸發layer -display時,才把圖片數據拷貝到layer buffer里。
AVAssetReader也能decode音頻的SampleBuffer,不過本人還沒想到如何播放CMSampleBufferRef的音頻,目前只能靜音播放。
4. 方案對比
對方案二、三做了滑動性能對比和耗電對比,測試條件分別是
滑動:在iPhone4的聊天窗口,有30個小視頻,來回做4次列表滑動
耗電:在iPhone5s,屏幕亮度調到最大,禁止自動鎖屏,開啟飛行模式,聊天窗口同時播放着3個小視頻,10分鍾
方案三無論滑動性能和耗電均優於方案二,由於方案三只能靜音播放,所以方案三用於聊天窗口和朋友圈列表播放,方案二用於點擊放大時的有聲播放。
小視頻錄制需求
-
支持白平衡、對焦、縮放
-
錄制視頻長度6秒,30幀/秒,盡量不丟幀
-
能錄制不同尺寸和碼率的視頻
小視頻錄制方案
對於需求1,AVFoundation有API可以支持,這里不多說。這里重點說說需求2、3的實現方案。
前期錄制方案如下:
-
創建AVCaptureSession,設置拍攝分辨率
-
添加AVCaptureInput,如攝像頭和麥克風
-
添加AVCaptureOutput,如AVCaptureVideoDataOutput、AVCaptureAudioDataOutput。這里AVCaptureAudioDataOutput建議在Session -startRunning后才添加,避免影響攝像頭啟動時間
-
添加AVCaptureVideoPreviewLayer,為用戶提供拍攝預覽界面
-
創建MMovieWriter,里面包含AVAssetWriter對象,用於寫視頻
-
開始捕捉-startRunning
-
AVCaptureVideoDataOutput和AVCaptureAudioDataOutput不停地往MMovieWriter傳遞VideoSampleBuffer和AudioSampleBuffer,MMovieWriter對VideoSampleBuffer做分辨率壓縮,以及對AudioSampleBuffer做碼率壓縮
-
結束捕捉-stopRunning,MMovieWriter停止寫視頻,把生成的視頻文件拋給上層
在4s以上的設備拍攝小視頻挺流暢,幀率能達到要求。但是在iPhone4,錄制的時候特別卡,錄到的視頻只有6~8幀/秒。嘗試把錄制視頻時的界面動畫去掉,稍微流暢些,幀率多了3~4幀/秒,還是不滿足需求。通過Instrument檢測,發現跟寫音頻時的壓縮有關,寫音頻時阻塞了AVFoundation的線程,引起后續的丟幀。網上也有人反饋類似問題 http://stackoverflow.com/questions/16686076/performance-issues-with-avassetwriterinput-audio-and-single-core-devices。把寫音頻去掉后,幀率果然上去了。但是系統相機的拍攝視頻是非常流暢的。於是用AVCaptureMovieFileOutput(640*480)直接生成視頻文件,拍視頻很流暢。然而錄制的6s視頻大小有2M+,再用MMovieDecoder+MMovieWriter壓縮至少要7~8s,影響聊天窗口發小視頻的速度。
綜上所述,要想拍視頻不卡,就要在錄制過程中盡量不做CPU耗時操作,而且AVCaptureOutput傳遞數據給上層時不能卡住AV線程。最終想到個方案,加個Cache層,先把AVCaptureOutput傳遞的SampleBuffer緩存下來,不在AV的線程寫視頻;等CPU空閑時,再喚起movieWriter線程寫視頻。流程如下圖所示:
通過這樣處理,拍視頻流暢度跟系統相機接近了,只是剛拍的前1s幀數只有18幀,后面穩定到30幀/秒左右了。而且用戶松手拍完后,最多等1s就能把視頻寫完文件了;也優化了之前的視頻截圖生成接口,減少200ms。不過拍攝穩定性不夠好,經常出現下面的寫失敗錯誤,頻率大概是6次/100次:
[GL] <MMovieWriter.mm:476::-[MMovieWriter appendAudioSampleBufferInternal:]> INFO: audio writer status 3, desc Error Domain=AVFoundationErrorDomain Code=-11800 "這項操作無法完成" UserInfo=0x11495910 {NSLocalizedDescription=這項操作無法完成, NSUnderlyingError=0x1146e8d0 "The operation couldn’t be completed. (OSStatus error -12633.)", NSLocalizedFailureReason=發生未知錯誤(-12633)}
通過google搜索,網上說這錯誤原因是同一個FrameTime寫入了兩幀。但是FrameTime是從SampleBuffer里取的,理論上不會時間重合(我沒打log驗證);而且老方案沒出現這種錯誤,新方案延后處理才會出現的。經過多次試驗,把Buffer Cache設置上限,當Buffer數達到一定數量后強制讓MovieWriter寫入文件,同時把下面這行代碼注釋,錯誤不再出現了:
//m_writer.movieFragmentInterval = CMTimeMakeWithSeconds(1.0, 1000); // AVAssetWriter
方案對比:
在iPhone4聊天窗口拍攝若干個6s視頻10次,算平均值