基于ReplayKit實現屏幕錄制

前言

近期項目中需要完成一個實現屏幕錄制(包含畫面、麥克風、app內聲音)功能,并壓縮上傳服務器,因此對iOS系統的replaykit進行了初步的研究,現分享一下結果:

截屏2021-06-25 下午11.08.22.png

概述

基于目前項目的快速迭代要求,首先想到的是官方ReplayKit框架,初步調研發現ReplayKit框架最低要求是iOS9.0,且支持屏幕、麥克風、app聲音的錄制,滿足技術可行性,因此決定直接采用ReplayKit實施。

ReplayKit介紹

ReplayKit在WWDC15的時候隨iOS9.0推出。當時的目的是給游戲開發者錄制玩游戲的視頻,進行社交分享使用。 除了錄制和共享外,ReplayKit還包括一個功能齊全的用戶界面,玩家可以用來編輯其視頻剪輯。

Replaykit功能介紹視頻 WWDC15

ReplayKit除了實現屏幕錄制以外,還能夠將錄制的音視頻流實時廣播出去,對于iOS端,需要兩個關鍵技術:屏幕內容采集和媒體流廣播。前者需要系統提供相關權限,可以讓開發者采集到app或者整個系統層面的屏幕上的內容,后者需要系統提供采集到實時的視頻流和音頻流,這樣才能通過推流到服務器,實現媒體流的廣播。

錄制

iOS9.0

//頭文件
#import <ReplayKit/ReplayKit.h>

//啟動錄制
- (void)startRecordingWithMicrophoneEnabled:(BOOL)microphoneEnabled handler:(nullable void (^)(NSError *_Nullable error))handler API_DEPRECATED("Use microphoneEnabled property", ios(9.0, 10.0)) API_UNAVAILABLE(macOS);

//停止錄制
- (void)stopRecordingWithHandler:(nullable void (^)(RPPreviewViewController *_Nullable previewViewController, NSError *_Nullable error))handler;

通過stopRecordingWithHandler的api,回調previewViewController(預覽頁面),通過presentViewController推出預覽頁,可以:裁剪、分享、保存相冊

[self presentViewController:previewViewController animated:YES completion:^{}];

預覽頁監聽操作結果

#pragma mrak - RPPreviewViewControllerDelegate

- (void)previewController:(RPPreviewViewController *)previewController didFinishWithActivityTypes:(NSSet <NSString *> *)activityTypes
{
    if ([activityTypes containsObject:@"com.apple.UIKit.activity.SaveToCameraRoll"]) {
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"保存成功");
        });
    }
    if ([activityTypes containsObject:@"com.apple.UIKit.activity.CopyToPasteboard"]) {
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"復制成功");

        });
    }
}

- (void)previewControllerDidFinish:(RPPreviewViewController *)previewController
{
    [previewController dismissViewControllerAnimated:YES completion:^{
        
    }];
}

通過攔截RPPreViewController,打印錄制視頻的地址:videoUrl = file:///private/var/mobile/Library/ReplayKit/ReplaykitDemo_06-28-2021%2015-51-13_1.mp4 可以發現文件存在于系統的位置,所以無法直接獲取

總結

優點:
高度封裝,操作簡單,能夠快速的實現屏幕錄制功能。

缺點:

  1. 不能獲取到視頻錄制時的數據,只能在停止錄制視頻的時候獲取到蘋果已經處理合成好的MP4文件
  2. 不能直接獲取錄制好的視頻文件,需要先通過用戶存儲到相冊,你才能通過相冊去訪問到該文件、
  3. 停止錄制的時候需要彈出一個視頻的預覽窗口,你可以在這個窗口進行保存或者取消或者分享該視頻文件、你還可以直接編輯該視頻
  4. 由于上面的限制,你只能在用戶存儲錄制的視頻保存到相冊你才能訪問。想要上傳該視頻到服務器,你還需要把相冊的那個視頻先想辦法copy到沙盒中,然后再開始上傳服務器。
  5. 無法配置屏幕錄制參數

iOS10.0

優化內容:

//新增啟動錄制
- (void)startRecordingWithHandler:(nullable void (^)(NSError *_Nullable error))handler API_AVAILABLE(ios(10.0), tvos(10.0), macos(11.0));

//通過microphoneEnabled 控制是否開啟麥克風
@property (nonatomic, getter = isMicrophoneEnabled) BOOL microphoneEnabled API_UNAVAILABLE(tvOS);

//結束錄制以及錄制完成后跳轉預覽頁做編輯操作同iOS9.0保持一致

總結:同iOS9.0

新增內容

iOS 10 系統在 iOS 9 系統的 ReplayKit保存錄屏視頻的基礎上,增加了視頻流實時直播功能(streaming live),可以將廣播出來的直播流進行分發和直播。具體實現是通過增加ReplayKit的擴展分別為Broadcast Upload Extension 和 Broadcast Setup UI Extension,
Broadcast Upload Extension 是處理捕捉到App屏幕錄制的數據的
Broadcast Setup UI Extension一些關于屏幕捕捉的UI交互

步驟:

  1. 添加擴展插件file->new->target->Broadcast upload Extension
    系統會生成兩個target,兩個對應的目錄以及4個文件分別:
  • SampleHandler.h
  • SampleHandler.m
  • BroadcastSetupViewController.h
  • BroadcastSetupViewController.m

SampleHandler主要處理流數據RPSampleBufferTypeVideo、RPSampleBufferTypeAudioApp、RPSampleBufferTypeAudioMicBroadcastSetupViewController作為啟動進程間插入的交互頁面,可以用于用戶輸入信息鑒權,或者自定義其他界面

  1. 啟動備選界面
//啟動備選界面
+ (void)loadBroadcastActivityViewControllerWithHandler:(void (^)(RPBroadcastActivityViewController *_Nullable broadcastActivityViewController, NSError *_Nullable error))handler;

[RPBroadcastActivityViewController loadBroadcastActivityViewControllerWithHandler:^(RPBroadcastActivityViewController * _Nullable broadcastActivityViewController, NSError * _Nullable error) {
    if (error) {
        NSLog(@"RPBroadcast err %@", [error localizedDescription]);
    }
    broadcastActivityViewController.delegate = self;
    [self presentViewController:broadcastActivityViewController animated:YES completion:nil];
}];

  1. 通過代理回調,啟動錄制進程
#pragma mark - Broadcasting

- (void)broadcastActivityViewController:(RPBroadcastActivityViewController *) broadcastActivityViewController
       didFinishWithBroadcastController:(RPBroadcastController *)broadcastController
                                  error:(NSError *)error {
    
    [broadcastActivityViewController dismissViewControllerAnimated:YES completion:nil];
                                                        
    self.broadcastController = broadcastController;
    self.broadcastController.delegate = self;
    if (error) {
        return;
    }

    //啟動廣播
    [broadcastController startBroadcastWithHandler:^(NSError * _Nullable error) {
        if (!error) {
            NSLog(@"-----start success----");
            // 這里可以添加camerPreview
        } else {
            NSLog(@"startBroadcast:%@",error.localizedDescription);
        }
    }];
}
  1. UI交互配置
- (void)userDidFinishSetup {
    NSURL *broadcastURL = [NSURL URLWithString:@"http://apple.com/broadcast/streamID"];
    NSDictionary *setupInfo = @{ @"broadcastName" : @"example" };
    // Tell ReplayKit that the extension is finished setting up and can begin broadcasting
    [self.extensionContext completeRequestWithBroadcastURL:broadcastURL setupInfo:setupInfo];
}

- (void)userDidCancelSetup {
    [self.extensionContext cancelRequestWithError:[NSError errorWithDomain:@"YourAppDomain" code:-1 userInfo:nil]];
}

- (void)viewWillAppear:(BOOL)animated
{
    [self userDidFinishSetup];
}
  1. 數據流的接收與處理
- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
    // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. 
}
- (void)broadcastPaused {
    // User has requested to pause the broadcast. Samples will stop being delivered.
}
- (void)broadcastResumed {
    // User has requested to resume the broadcast. Samples delivery will resume.
}
- (void)broadcastFinished {
    // User has requested to finish the broadcast.
}
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    
    switch (sampleBufferType) {
        case RPSampleBufferTypeVideo:
            // Handle video sample buffer
            break;
        case RPSampleBufferTypeAudioApp:
            // Handle audio sample buffer for app audio
            break;
        case RPSampleBufferTypeAudioMic:
            // Handle audio sample buffer for mic audio
            break;
        default:
            break;
    }
}

processSampleBuffer方法就是最終采集到的音頻、視頻原始數據。其中音頻未做混音,包括麥克音頻pcm和app音頻pcm,而視頻輸出為yuv數據。

總結

優點:

  1. 除了錄屏以外,新增直播特性,功能更加強大
  2. 能夠拿到音視頻原始流數據,滿足一些需要做音視頻特效的需求

缺點:

  1. 增加用戶交互成本,需要拉起錄制列表,然后用戶點擊選擇對應的錄制程序,操作成功相對高一些
  2. 集成難度相比于iOS9.0加大,處理原始數據難度比較大

iOS11.0

新增內容

新增api,跳過iOS10的中間列表sheet在點擊選擇的過程,但是還是只能錄制app內的內容。

+ (void)loadBroadcastActivityViewControllerWithPreferredExtension:(NSString * _Nullable)preferredExtension handler:(nonnull void(^)(RPBroadcastActivityViewController * _Nullable broadcastActivityViewController, NSError * _Nullable error))handler API_AVAILABLE(ios(11.0)) API_UNAVAILABLE(tvOS);

處理的流程同iOS10的擴展插件

新增開啟屏幕捕捉

開啟捕捉回調sampleBuffer
- (void)startCaptureWithHandler:(nullable void (^)(CMSampleBufferRef sampleBuffer, RPSampleBufferType bufferType, NSError *_Nullable error))captureHandler completionHandler:(nullable void (^)(NSError *_Nullable error))completionHandler API_AVAILABLE(ios(11.0), tvos(11.0), macos(11.0));

可以直接調用接口捕捉到sampleBuffer,省去了iOS10的擴展插件環節,可以直接拿到想要的buffer裸數據,無需中間交互環節,完成滿足最上面所說的項目要求

總結:

優點:

  1. 調用方法簡單,易于集成
  2. 無中間用戶交互環節,用戶交互成本低
  3. 直接獲取到音視頻裸數據

缺點:
裸數據處理難度稍大

補充

音視頻裸數據編碼合成mp4寫入本地沙盒

  1. iOS端編碼合成采用AVAssetWriter,配套AVAssetWriterInput使用
//writer
@property (nonatomic, strong) AVAssetWriter *assetWriter;
//視頻輸入
@property (nonatomic, strong) AVAssetWriterInput *assetWriterVideoInput;
//音頻輸入
@property (nonatomic, strong) AVAssetWriterInput *assetWriterAudioInput;
//app內音頻輸入
@property (nonatomic, strong) AVAssetWriterInput *assetWriterAppAudioInput;

//初始化
self.assetWriter = [AVAssetWriter assetWriterWithURL:[NSURL fileURLWithPath:videoOutPath] fileType:AVFileTypeMPEG4 error:&error];

2.視頻編碼配置

    //視頻的配置
    NSDictionary *compressionProperties = @{
        AVVideoProfileLevelKey : AVVideoProfileLevelH264HighAutoLevel,
        AVVideoH264EntropyModeKey      : AVVideoH264EntropyModeCABAC,
        AVVideoAverageBitRateKey       : @(DEVICE_WIDTH * DEVICE_HEIGHT * 6.0),
        AVVideoMaxKeyFrameIntervalKey  : @15,
        AVVideoExpectedSourceFrameRateKey : @(15),
        AVVideoAllowFrameReorderingKey : @NO};
        
    NSNumber* width= [NSNumber numberWithFloat:DEVICE_WIDTH];
    NSNumber* height = [NSNumber numberWithFloat:DEVICE_HEIGHT];

    NSDictionary *videoSettings = @{
            AVVideoCompressionPropertiesKey :compressionProperties,
            AVVideoCodecKey :AVVideoCodecTypeH264,
            AVVideoWidthKey : width,
            AVVideoHeightKey: height
    };

    self.assetWriterVideoInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];

3.音頻編碼配置

    // 音頻設置
    NSDictionary * audioCompressionSettings = @{                       AVEncoderBitRatePerChannelKey : @(28000),
        AVFormatIDKey : @(kAudioFormatMPEG4AAC),
        AVNumberOfChannelsKey : @(1),
        AVSampleRateKey : @(22050) };

    self.assetWriterAudioInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio outputSettings:audioCompressionSettings];

4.input添加writer

    //視頻
    [self.assetWriter addInput:self.assetWriterVideoInput];
    [self.assetWriterVideoInput setMediaTimeScale:60];
    [self.assetWriterVideoInput setExpectsMediaDataInRealTime:YES];
        
    [self.assetWriter setMovieTimeScale:60];
        
    //音頻
    [self.assetWriter addInput:self.assetWriterAudioInput];
    self.assetWriterAudioInput.expectsMediaDataInRealTime = YES;
    
    //app內聲音
    [self.assetWriter addInput:self.assetWriterAppAudioInput];
    self.assetWriterAppAudioInput.expectsMediaDataInRealTime = YES;

5.合并代碼

    [[RPScreenRecorder sharedRecorder] startCaptureWithHandler:^(CMSampleBufferRef  _Nonnull sampleBuffer, RPSampleBufferType bufferType, NSError * _Nullable error) {
            
        if (CMSampleBufferDataIsReady(sampleBuffer)) {
                
            if (self.assetWriter.status == AVAssetWriterStatusUnknown && bufferType == RPSampleBufferTypeVideo) {
                    [self.assetWriter startWriting];
                    [self.assetWriter startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
                }

            if (self.assetWriter.status == AVAssetWriterStatusFailed) {
                    NSLog(@"An error occured.");
                    [self writeDidOccureError:self.assetWriter.error callBack:handler];
                    return;
                }
            
                            if (bufferType == RPSampleBufferTypeVideo) {
                    if (self.assetWriterVideoInput.isReadyForMoreMediaData) {
                        [self.assetWriterVideoInput appendSampleBuffer:sampleBuffer];
                    }
                }else if (bufferType == RPSampleBufferTypeAudioMic)
                {
                    if (self.assetWriterAudioInput.isReadyForMoreMediaData) {
                        [self.assetWriterAudioInput appendSampleBuffer:sampleBuffer];
                        [self sampleBuffer2PcmData:sampleBuffer];
                    }
                }else if (bufferType == RPSampleBufferTypeAudioApp)
                {
                    if (self.assetWriterAppAudioInput.isReadyForMoreMediaData) {
                        [self.assetWriterAppAudioInput appendSampleBuffer:sampleBuffer];
                    }
                } 
            
        } completionHandler:^(NSError * _Nullable error) {
            if (!error) {
                // Start recording
                NSLog(@"Recording started successfully.");
                
            }else{
                //show alert
            }
        }];

音頻解碼獲取聲音大小

關鍵的代碼

/// buffer轉pcm
/// @param audiobuffer
- (void)sampleBuffer2PcmData:(CMSampleBufferRef)audiobuffer
{
    CMSampleBufferRef ref = audiobuffer;
    if(ref==NULL){
        return;
    }
    
    //copy data to file
    //read next one
    AudioBufferList audioBufferList;
    NSMutableData *data=[[NSMutableData alloc] init];
    CMBlockBufferRef blockBuffer;
    
    CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(ref, NULL, &audioBufferList, sizeof(audioBufferList), NULL, NULL, 0, &blockBuffer);
 
    for( int y=0; y<audioBufferList.mNumberBuffers; y++ )
    {
        AudioBuffer audioBuffer = audioBufferList.mBuffers[y];
        Float32 *frame = (Float32*)audioBuffer.mData;
        [data appendBytes:frame length:audioBuffer.mDataByteSize];
   
    }
    
    [self volumeFromPcmData:data] ;
    CFRelease(blockBuffer);
    blockBuffer=NULL;
}

/// 通過pcmdata獲取聲音分貝
/// @param pcmData pcm
-(void)volumeFromPcmData:(NSData *)pcmData
{
    if (pcmData == nil)
    {
        if ([self.delegate respondsToSelector:@selector(screenRecord:micVolume:)]) {
            [self.delegate screenRecord:self micVolume:0];
        }
        return;
    }
    
    long long pcmAllLenght = 0;
    short butterByte[pcmData.length/2];
    memcpy(butterByte, pcmData.bytes, pcmData.length);//frame_size * sizeof(short)
    
    // 將 buffer 內容取出,進行平方和運算
    for (int i = 0; i < pcmData.length/2; I++)
    {
        pcmAllLenght += butterByte[i] * butterByte[I];
    }
    // 平方和除以數據總長度,得到音量大小。
    double mean = pcmAllLenght / (double)pcmData.length;
    double volume =10*log10(mean);//volume為分貝數大小
    
    /*
     *0-20 很靜 幾乎感覺不到
     20-40 安靜
     40-60一般室內談話
     60-70吵鬧
     70-90很吵、神經細胞受到破壞
     90-100吵鬧家具 聽力受損
     */
    if ([self.delegate respondsToSelector:@selector(screenRecord:micVolume:)]) {
        [self.delegate screenRecord:self micVolume:volume];
    }
}

總結

通過以上各個系統版本的對比,最終項目采用了iOS11的startCaptureWithHandler接口實現屏幕錄制數據采集,然后通過AVAssetWriter進行編碼合成mp4文件以及通過音頻裸數據提取聲音,最終完成該需求。以上均為代碼的片段,還需要集合業務考慮各種異常情況的處理,以及視頻、音頻的編碼配置需要進一步研究,通過優化配置參數,能夠進一步提升錄制視頻的體驗,整個過程坑點有點進一步補充。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容