iOS ReplayKit 與 螢幕錄製

雲音樂技術團隊發表於2023-04-03
本文作者: clc

0x00 引言

在客戶端開發的生涯裡,有時會遇到這樣一些場景,需要對使用者在應用內的操作做進行螢幕錄製,甚至是系統層級的跨應用螢幕錄製來實現某種特殊需求,例如線上監考、應用問題反饋、遊戲直播等。
蘋果提供了 ReplayKit Framework 來滿足這些需求,目前雲音樂 LOOK 直播客戶端內就是採用這個系統框架來實現跨應用錄屏直播的。

0x01 ReplayKit簡史

ReplayKit 的故事要從 iOS 9 說起。

ReplayKit 結構

iOS 9 提供了 ReplayKit Extension 進行應用內的錄製以及應用聲音採集。主要涉及兩個類:一個是 RPScreenRecorder,作為錄製 Task 的管理者;另一個是 RPPreviewViewController,錄製狀態的視覺反饋。應用內直接呼叫 ReplayKit API 來控制開始與停止,在 Extension 中將捕獲的音訊/影片流推向伺服器,這就是應用內錄製(In-App Boardcast)。
WWDC 課程:Going Social with ReplayKit and Game Center

In-App Boardcast

在 iOS 11 中,ReplayKit 提供了更強大的能力:將系統作為一個整體進行直播。使用者在控制中心內開啟螢幕錄製後,ReplayKit2 Extension 可以獲取到整個系統級的螢幕畫面、以及裝置所產生的所有音訊,實現跨應用錄屏(iOS System Boardcast),同時 ReplayKit 也提供了麥克風採集。這種系統級的直播在應用間切換時也不會停止。(注意提醒你的使用者保護好自己的隱私!)。
音影片資料依然是在 Extension 內直接獲取並上傳至伺服器,文章的後面將重點聊一下這塊內容在 LOOK 直播中的實踐。
WWDC 課程:Live Screen Broadcast with ReplayKit

iOS System Boardcast

在 iOS 15之後,ReplayKit 提供了 Loop Buffer 功能,根據 WWDC 的描述,在應用內開啟 Loop Buffer 後 ReplayKit 會建立一個最長 15 秒的 Buffer 並開始持續錄製,應用可以隨時呼叫 API 將這一部分匯出(對直播應用而言,這可以用來隨時截獲精彩瞬間,很酷)。這一部分不需要建立 Extension,直接在應用內實現。
WWDC 課程:Discover rolling clips with ReplayKit

0x02 系統級錄製的流程簡述

  1. 使用者在 App 內做好前置準備(例如:開播)。
  2. 使用者從控制中心啟動 ReplayKit。
  3. ReplayKit Extension 開始接受錄屏影片流、App音訊流,同時開始向伺服器推流。
  4. 使用者主動從控制中心關閉錄製,流程結束。

0x03 建立並接入ReplayKit Extension

下面我們在 Xcode 14.1 中演示一下如何接入 ReplayKit。
首先,在 Xcode 中新建一個 Target,選擇 Broadcast Upload Extension。

由於系統錄製不需要 UI Extension,所以取消勾選 Include UI Extension 這一預設選項。

生成的檔案很簡單,只有一對 SampleHandler.h 和 SampleHandler.m。

在 SampleHandler.m 中,包含了錄製事件的各種回撥方法。

- (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.
}

接下來就是接收系統的音影片幀回撥了,在這裡對音影片幀進行處理和推流就可以了。其中,系統提供的音影片幀一共分為三類:

  • RPSampleBufferTypeVideo:系統影片幀,包含了整個螢幕的影片內容,無任何刪減。
  • RPSampleBufferTypeAudioApp:系統內錄音訊幀,包含了系統實施播放的聲音。
  • RPSampleBufferTypeAudioMic:麥克風音訊幀,使用者開啟了麥克風錄製按鈕後開始回撥。

回撥方法如下:

// 音影片回撥    
- (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;
    }
}

到此整個框架就搭建完成了,接下來執行 Extension,長按控制中心裡的螢幕錄製開關(如果沒有,則需要在“設定”=>“控制中心”中手動新增。

然後選中對應應用,就可以開始了!

0x04 LOOK 直播內的實踐

1.Extension 中的功能整合
在 Extension 中,除了音影片處理和推流功能外,其他功能應該儘量少,來保證記憶體在一個穩定值,我們整合的幾個主要的能力是:

  1. 網路請求能力,主要負責直播心跳保活,保證在宿主App被殺死後,直播依然能正常進行。以及一系列需要根據介面進行的判斷和校驗。
  2. IM 長連線能力,確保風控可以及時透過 IM 訊息來中斷有風險的直播內容,在一些場景下接收房間使用者的彈幕與送禮訊息。
  3. 本地 Push(可選開關,由宿主 App 控制),作為主播與觀眾進行彈幕/禮物互動的主要媒介,也是風控警告等通知的能力的提示手段。在靈動島推出後,可以選擇將這部分能力向靈動島遷移。
  4. Local Socket 加上 AppGroup 兩者合作實現了宿主 App 和 Extension 間的資料同步,在下一章節會對資料通訊展開講解。

經過測試與線上驗證,目前這些功能的總記憶體佔用大約在 20Mb 左右,佔 Extension 記憶體上限大約一半,但不可避免的是在小部分情況下系統會將 Extension 執行緒阻塞,發生音影片幀記憶體擠壓超過閾值,導致 Extension 被殺死。

2.宿主App與 Extension 間的通訊

App間的程式間通訊方式主要有兩種,一種是透過建立 Local Socket 互發資料。

另一種是透過 AppGroup 進行資源共享(簡單的說,透過 AppGroup,宿主應用和 Extension 可以訪問到同一份 UserDefault)。

在技術方案選型時,我們考慮過單獨使用 Local Socket 或是單獨使用 AppGroup 來實現通訊,但發現兩者都有弊端:

  1. 僅使用 Local Socket 通訊時,考慮到宿主和 Extension 各自的重開場景以及部分資料需要持久化,資料同步會較為複雜。
  2. 僅使用 AppGroup 時,雙方需要透過輪詢來進行資料同步,包含檔案讀寫操作,有效率問題。

於是最終選擇兩者並用,一方改寫 UserDefault 資料後,透過 Local Socket 通知另一方,進行同步。

3.引導使用者開啟錄製

ReplayKit2 的開啟流程比較繁瑣,對使用者不友好:回憶上文,開啟螢幕錄製需要使用者中斷在應用中的操作流程,到控制中心長按“螢幕錄製”按鈕,選中你的應用,點選開始;如果使用者還沒有向控制中心新增“螢幕錄製”按鈕,則需要引導使用者到“設定”中新增。

LOOK 直播在設計開播流程時,首先想到的是放置一個引導影片進行引導:透過 Local Socket 輪詢 Extension 狀態,如果還沒有開啟,則放置一塊播放區域,迴圈播放開啟引導影片。這樣雖然和使用者講明白瞭如何開啟,但還是無法避免複雜的流程,我們有沒有辦法在流程上簡化使用者操作呢?

答案是有,在 iOS 13 開始,Replaykit2 提供了 RPSystemBroadcastPickerView 系統控制元件,透過點選控制元件,使用者可以直接喚起本應由長按“螢幕錄製”喚起的系統介面,並只包含你指定的選項了

這樣,整個流程就變成線性的了,不需要使用者再離開你的開播流程去作業系統控制中心。

那麼問題結束了嗎?還沒有, RPSystemBroadcastPickerView 是一個系統控制元件,出於隱私保護的前提,系統並不想讓這個控制元件可以被隨意的修改樣式。在不修改樣式的情況下,它長這樣:

遺憾的是,這個樣式和 LOOK 直播的開播介面視覺格格不入。透過分析層級,發現這是一個 UIView 上帶了一個 UIControl,所以我們可以透過遍歷 subviews 並傳遞一個事件的方法主動觸發 touchUpInside 來彈起系統的錄製入口。


if (@available(iOS 12.0, *)) {
    // 建立一個按鈕
    RPSystemBroadcastPickerView *picker = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 1, 1)];
    
    //指定要開啟的錄製選項
    NSString *bundleId = [NSBundle thisBundle_bundleId];
    picker.preferredExtension = [bundleId stringByAppendingString:@".broadcast"];
    picker.showsMicrophoneButton = YES;
    
    //遍歷找到按鈕,點他!
    for (id subview in picker.subviews) {
        if ([subview isKindOfClass:[UIButton class]]) {
            [(UIButton *)subview sendActionsForControlEvents:UIControlEventTouchUpInside];
        }
    }
}

這樣我們就將 RPSystemBroadcastPickerView 的點選行為包裝出來了,可以處理成自動喚起或是由自定義控制元件喚起了。

讓我們來看一下實現的效果

4.隱私保護

由於 Replaykit 是系統級的錄製,使用者在進行直播時所有的操作都會被觀眾看到,如果主播操作不當,一些比較隱私的資訊(例如簡訊驗證碼、通訊錄、聊天記錄和相簿)就會被洩漏出去,這是主播和平臺方都不希望發生的。

在 LOOK 直播內,我們提供了“隱私模式”這一功能。在隱私模式下,系統提供的影片幀將被捨棄,推流元件從一張本地圖片中取幀,並持續向伺服器推送,這樣觀眾端就看不到主播的隱私內容操作了。

隱私模式只針對畫面,音訊方面,由主播自己控制是否靜音(部分主播需要在隱私模式下保持觀眾互動,避免直播間人數流失)。

我們無法識別應用外的使用者操作和介面停留,只能文字提醒使用者注意。而在應用內,我們可以人工劃分出哪些介面是涉及使用者隱私的,例如直播間背景選擇頁,需要在應用內訪問系統相簿。

所以我們設計了兩種觸發方式,從應用的角度來看,分為主動觸發和被動觸發。主動觸發指的是主播在應用內進入包含隱私資訊的介面時,應用主動進入隱私模式,退出介面時關閉隱私模式。被動觸發則是由主播操作直播間內的“開啟隱私模式”按鈕來開啟和關閉隱私模式。

0x05 困難與挑戰

正如前文所說,iOS Extension 中有 50Mb 的最大記憶體閾值,如果超過了,將會被系統收回。如果因為頻繁到達記憶體閾值而導致 Extension 被系統強制關閉回收就得不償失了,所以對於 50 Mb 的邊界情況就必須小心應對。

開發過程中,由於模擬器中沒有控制中心,我們只能用真機裝置開發除錯。由於 Xcode 的斷點最佳化並不好,在開發過程中經常會遇到斷點導致程式阻塞,引發記憶體超過閾值的情況,排查一些偶現的問題十分痛苦,所以需要做好 Debug 日誌列印,確保在記憶體超限的情況下也能有足夠的日誌來分析問題。

記憶體限制對音影片處理也是一個挑戰。如果網路不佳,推流阻塞,這時對音影片幀的消費速度遠不及系統吐幀的生產速度,編碼後的音影片資料無法及時消耗,很容易就會達到記憶體上線。因此團隊中負責音影片處理及推流開發的小夥伴要注意進行記憶體監控,在記憶體達到一個危險值的時候,及時捨棄一部分資料來保護整體的記憶體使用量遠離臨界值,避免程式被殺死。

對於 Extension 內的記憶體控制沒有自信的團隊,可以考慮將 Extension 中獲取到的系統音訊、影片幀透過 Local Socket 方式將資料傳送至宿主 App 內,由在後臺保活的宿主進行音影片處理及推流等操作。宿主保活的情況下,心跳、本地 Push、IM長連線 都可以在宿主 App 中實現, Extension 中僅保留影片資料編碼一項能力,進一步壓低記憶體開銷。

0x06 注意事項

  1. 儘量控制記憶體佔用,最好永遠不要碰到 50 Mb 導致 Extension 被回收。
  2. 在不同系統版本中,回撥吐出的音影片幀格式有差異,注意相容。
  3. 呼叫 finishBroadcastWithError: 主動結束錄製時,要設定好 NSError userInfo 中的 NSLocalizedFailureReasonErrorKey,確保在系統alert中能正確的告知使用者結束原因。
    - (void)networkingErrorNotificationHandler {
        NSError *error = [NSError errorWithDomain:@"replaykitDomin" code:1234 userInfo:@{NSLocalizedFailureReasonErrorKey : @"網路無法連線,請重新開啟螢幕錄製"}];
        [self finishBroadcastWithError:error];
    }

0x07 結語

ReplayKit 問世已經多年,從最初的應用內錄製到系統螢幕錄製,再到 Loop Buffer 滾動剪輯,功能在不斷的增加。但出於隱私保護的初衷,蘋果對開啟錄製行為的設定依然繁瑣,在使用者互動方面必須要做好引導,降低使用者學習成本。

最後,祝大家在實現相關功能時,不被 50 Mb 記憶體上線和 Extension 的除錯困難絆倒,優雅的完成螢幕錄製功能。

相關知識傳送門:
Apple 文件:https://developer.apple.com/documentation/replaykit
WWDC 2021 Loop Buffer https://developer.apple.com/videos/play/wwdc2021/10101/
WWDC 2018 Screen Broadcast https://developer.apple.com/videos/play/wwdc2018/601
WWDC 2015 In-App Boardcast https://developer.apple.com/videos/play/wwdc2015/605

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章