Flutter 螢幕採集實戰分享

ZEGO即構科技發表於2022-02-16

一、概述

在視訊會議、線上課堂、遊戲直播等場景,螢幕共享是一個最常見的功能。螢幕共享就是對螢幕畫面的實時共享,端到端主要有幾個步驟:錄屏採集、視訊編碼及封裝、實時傳輸、視訊解封裝及解碼、視訊渲染。

一般來說,實時螢幕共享時,共享發起端以固定取樣頻率(一般 8 - 15幀足夠)抓取到螢幕中指定源的畫面(包括指定螢幕、指定區域、指定程式等),經過視訊編碼壓縮(應選擇保持文字/圖形邊緣資訊不失真的方案)後,在實時網路上以相應的幀率分發。

因此,螢幕採集是實現實時螢幕共享的基礎,它的應用場景也是非常廣泛的。

現如今 Flutter 的應用越來越廣泛,純 Flutter 專案也越來越多,那麼本篇內容我們主要分享的是 Flutter 的螢幕採集的實現。

二、實現流程

在詳細介紹實現流程前,我們先來看看原生系統提供了哪些能力來進行螢幕錄製。

1、iOS 11.0 提供了 ReplayKit 2 用於採集跨 App 的全域性螢幕內容,但僅能通過控制中心啟動;iOS 12.0 則在此基礎上提供了從 App 內啟動 ReplayKit 的能力。

2、Android 5.0 系統提供了 MediaProjection 功能,只需彈窗獲取使用者的同意即可採集到全域性螢幕內容。

我們再看一下 Android / iOS 的螢幕採集能力有哪些區別。

1、iOS 的 ReplayKit 是通過啟動一個 Broadcast Upload Extension 子程式來採集螢幕資料,需要解決主 App 程式與螢幕採集子程式之間的通訊互動問題,同時,子程式還有諸如執行時記憶體最大不能超過 50M 的限制。

2、Android 的 MediaProjection 是直接在 App 主程式內執行的,可以很容易獲取到螢幕資料的 Surface。

雖然無法避免原生程式碼,但我們可以儘量以最少的原生程式碼來實現 Flutter 螢幕採集。將兩端的螢幕採集能力抽象封裝為通用的 Dart 層介面,只需一次部署完成後,就能開心地在 Dart 層啟動、停止螢幕採集了。

接下來我們分別介紹一下 iOS 和 Android 的實現流程。

1、iOS

開啟 Flutter App 工程中iOS 目錄下的 Runner Xcode Project,新建一個 Broadcast Upload Extension Target,在此處理 ReplayKit 子程式的業務邏輯。

首先需要處理主 App 程式與 ReplayKit 子程式的跨程式通訊問題,由於螢幕採集的 audio/video buffer 回撥非常頻繁,出於效能與 Flutter 外掛生態考慮,在原生側處理音視訊 buffer 顯然是目前最靠譜的方案,那剩下要解決的就是啟動、停止信令以及必要的配置資訊的傳輸了。

對於啟動 ReplayKit 的操作,可以通過 Flutter 的 MethodChannel 在原生側 new 一個 RPSystemBroadcastPickerView,這是一個系統提供的 View,包含一個點選後直接彈出啟動螢幕採集視窗的 Button。通過遍歷 Sub View 的方式找到 Button 並觸發點選操作,便解決了啟動 ReplayKit 的問題。

static Future<bool?> launchReplayKitBroadcast(String extensionName) async {
    return await _channel.invokeMethod(
        'launchReplayKitBroadcast', {'extensionName': extensionName});
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if ([@"launchReplayKitBroadcast" isEqualToString:call.method]) {
        [self launchReplayKitBroadcast:call.arguments[@"extensionName"] result:result];
    } else {
        result(FlutterMethodNotImplemented);
    }
}
​
- (void)launchReplayKitBroadcast:(NSString *)extensionName result:(FlutterResult)result {
    if (@available(iOS 12.0, *)) {
        RPSystemBroadcastPickerView *broadcastPickerView = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)];
        NSString *bundlePath = [[NSBundle mainBundle] pathForResource:extensionName ofType:@"appex" inDirectory:@"PlugIns"];
        if (!bundlePath) {
            NSString *nullBundlePathErrorMessage = [NSString stringWithFormat:@"Can not find path for bundle `%@.appex`", extensionName];
            NSLog(@"%@", nullBundlePathErrorMessage);
            result([FlutterError errorWithCode:@"NULL_BUNDLE_PATH" message:nullBundlePathErrorMessage details:nil]);
            return;
        }
​
        NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];
        if (!bundle) {
            NSString *nullBundleErrorMessage = [NSString stringWithFormat:@"Can not find bundle at path: `%@`", bundlePath];
            NSLog(@"%@", nullBundleErrorMessage);
            result([FlutterError errorWithCode:@"NULL_BUNDLE" message:nullBundleErrorMessage details:nil]);
            return;
        }
​
        broadcastPickerView.preferredExtension = bundle.bundleIdentifier;
        for (UIView *subView in broadcastPickerView.subviews) {
            if ([subView isMemberOfClass:[UIButton class]]) {
                UIButton *button = (UIButton *)subView;
                [button sendActionsForControlEvents:UIControlEventAllEvents];
            }
        }
        result(@(YES));
    } else {
        NSString *notAvailiableMessage = @"RPSystemBroadcastPickerView is only available on iOS 12.0 or above";
        NSLog(@"%@", notAvailiableMessage);
        result([FlutterError errorWithCode:@"NOT_AVAILIABLE" message:notAvailiableMessage details:nil]);
    }
}

然後是配置資訊的同步問題:

方案一是使用 iOS 的 App Group 能力,通過 NSUserDefaults 持久化配置在程式間共享配置資訊,分別在 Runner Target 和 Broadcast Upload Extension Target 內開啟 App Group 能力並設定同一個 App Group ID,然後就能通過 -[NSUserDefaults initWithSuiteName] 讀寫此 App Group 內的配置了。

Future<void> setParamsForCreateEngine(int appID, String appSign, bool onlyCaptureVideo) async {
    await SharedPreferenceAppGroup.setInt('ZG_SCREEN_CAPTURE_APP_ID', appID);
    await SharedPreferenceAppGroup.setString('ZG_SCREEN_CAPTURE_APP_SIGN', appSign);
    await SharedPreferenceAppGroup.setInt("ZG_SCREEN_CAPTURE_SCENARIO", 0);
    await SharedPreferenceAppGroup.setBool("ZG_SCREEN_CAPTURE_ONLY_CAPTURE_VIDEO", onlyCaptureVideo);
}
- (void)syncParametersFromMainAppProcess {
    // Get parameters for [createEngine]
    self.appID = [(NSNumber *)[self.userDefaults valueForKey:@"ZG_SCREEN_CAPTURE_APP_ID"] unsignedIntValue];
    self.appSign = (NSString *)[self.userDefaults valueForKey:@"ZG_SCREEN_CAPTURE_APP_SIGN"];
    self.scenario = (ZegoScenario)[(NSNumber *)[self.userDefaults valueForKey:@"ZG_SCREEN_CAPTURE_SCENARIO"] intValue];
}

方案二是使用跨程式通知 CFNotificationCenterGetDarwinNotifyCenter 攜帶配置資訊來實現程式間通訊。

接下來是停止 ReplayKit 的操作。也是使用上述的 CFNotification 跨程式通知,在 Flutter 主 App 發起結束螢幕採集的通知,ReplayKit 子程式接收到通知後呼叫 -[RPBroadcastSampleHandler finishBroadcastWithError:] 來結束螢幕採集。

static Future<bool?> finishReplayKitBroadcast(String notificationName) async {
    return await _channel.invokeMethod(
        'finishReplayKitBroadcast', {'notificationName': notificationName});
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if ([@"finishReplayKitBroadcast" isEqualToString:call.method]) {
        NSString *notificationName = call.arguments[@"notificationName"];
        CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (CFStringRef)notificationName, NULL, nil, YES);
        result(@(YES));
    } else {
        result(FlutterMethodNotImplemented);
    }
}

// Add an observer for stop broadcast notification
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
                                (__bridge const void *)(self),
                                onBroadcastFinish,
                                (CFStringRef)@"ZGFinishReplayKitBroadcastNotificationName",
                                NULL,
                                CFNotificationSuspensionBehaviorDeliverImmediately);

// Handle stop broadcast notification from main app process
static void onBroadcastFinish(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
​
    // Stop broadcast
    [[ZGScreenCaptureManager sharedManager] stopBroadcast:^{
        RPBroadcastSampleHandler *handler = [ZGScreenCaptureManager sharedManager].sampleHandler;
        if (handler) {
            // Finish broadcast extension process with no error
            #pragma clang diagnostic push
            #pragma clang diagnostic ignored "-Wnonnull"
            [handler finishBroadcastWithError:nil];
            #pragma clang diagnostic pop
        } else {
            NSLog(@"⚠️ RPBroadcastSampleHandler is null, can not stop broadcast upload extension process");
        }
    }];
}

image.png

                        (iOS 實現流程圖示)

2、Android

Android 的實現相對 iOS 比較簡單,在啟動螢幕採集時,可以直接使用 Flutter 的 MethodChannel 在原生側通過 MediaProjectionManager 彈出一個向使用者請求螢幕採集許可權的彈窗,收到確認後即可呼叫 MediaProjectionManager.getMediaProjection() 函式拿到 MediaProjection 物件。

需要注意的是,由於 Android 對許可權管理日漸收緊,如果你的 App 的目標 API 版本 (Target SDK) 大於等於 29,也就是 Android Q (10.0) 的話,還需要額外啟動一個前臺服務。根據 Android Q 的遷移文件顯示,諸如 MediaProjection 等需要使用前臺服務的功能,必須在獨立的前臺服務中執行。

首先需要自己實現一個繼承 android.app.Service 類,在 onStartCommand 回撥中呼叫上述的 getMediaProjection() 函式獲取 MediaProjection 物件。

@Override
public int onStartCommand(Intent intent, int flags, int startId) {

    int resultCode = intent.getIntExtra("code", -1);
    Intent resultData = intent.getParcelableExtra("data");

    String notificationText = intent.getStringExtra("notificationText");
    int notificationIcon = intent.getIntExtra("notificationIcon", -1);
    createNotificationChannel(notificationText, notificationIcon);

    MediaProjectionManager manager = (MediaProjectionManager)getSystemService(Context.MEDIA_PROJECTION_SERVICE);
    MediaProjection mediaProjection = manager.getMediaProjection(resultCode, resultData);
    RequestMediaProjectionPermissionManager.getInstance().onMediaProjectionCreated(mediaProjection, RequestMediaProjectionPermissionManager.ERROR_CODE_SUCCEED);

    return super.onStartCommand(intent, flags, startId);
}

然後還需要在 AndroidManifest.xml 中註冊這個類。

<service
    android:name=".internal.MediaProjectionService"
    android:enabled="true"
    android:foregroundServiceType="mediaProjection"
/>

然後在啟動螢幕採集時判斷系統版本,如果執行在 Android Q 以及更高版本的系統中,則啟動前臺服務,否則可以直接獲取 MediaProjection 物件。

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
private void createMediaProjection(int resultCode, Intent intent) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        service = new Intent(this.context, MediaProjectionService.class);
        service.putExtra("code", resultCode);
        service.putExtra("data", intent);
        service.putExtra("notificationIcon", this.foregroundNotificationIcon);
        service.putExtra("notificationText", this.foregroundNotificationText);
        this.context.startForegroundService(service);
    } else {
        MediaProjectionManager manager = (MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
        MediaProjection mediaProjection = manager.getMediaProjection(resultCode, intent);
        this.onMediaProjectionCreated(mediaProjection, ERROR_CODE_SUCCEED);
    }
}

緊接著,根據業務場景需求從螢幕採集 buffer 的消費者拿到 Surface,例如,要儲存螢幕錄製的話,從 MediaRecoder 拿到 Surface,要錄屏直播的話,可呼叫音視訊直播 SDK 的介面獲取 Surface。

有了 MediaProjection 和消費者的 Surface,接下來就是呼叫 MediaProjection.createVirtualDisplay() 函式傳入 Surface 來建立 VirtualDisplay 例項,從而獲取到螢幕採集 buffer。

VirtualDisplay virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", width, height, 1,

DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, handler);

最後是結束螢幕採集,相比 iOS 複雜的操作,Android 僅需要將 VirtualDisplayMediaProjection 例項物件釋放即可。

三、實戰示例

下面為大家準備了一個實現了 iOS/Android 螢幕採集並使用 Zego RTC Flutter SDK 進行推流直播的示例 Demo。

下載連結:https://github.com/zegoim/zego-express-example-screen-capture-flutter

Zego RTC Flutter SDK 在原生側提供了視訊幀資料的對接入口,可以將上述流程中獲取到的螢幕採集 buffer 傳送給 RTC SDK 從而快速實現螢幕分享、推流。

iOS 端在獲取到系統給的 SampleBuffer 後可以直接傳送給 RTC SDK,SDK 能自動處理視訊和音訊幀。

- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    [[ZGScreenCaptureManager sharedManager] handleSampleBuffer:sampleBuffer withType:sampleBufferType];
}

Android 端需要先向 RTC SDK 獲取一個 SurfaceTexture 並初始化所需要的 Surface, Handler 然後通過上述流程獲取到的 MediaProjection 物件建立一個 VirtualDisplay 物件,此時 RTC SDK 就能獲取到螢幕採集視訊幀資料了。

SurfaceTexture texture = ZegoCustomVideoCaptureManager.getInstance().getSurfaceTexture(0);
texture.setDefaultBufferSize(width, height);
Surface surface = new Surface(texture);
HandlerThread handlerThread = new HandlerThread("ZegoScreenCapture");
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper());

VirtualDisplay virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", width, height, 1,
    DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, handler);

四、總結與展望

最後,我們來總結一下 Flutter 螢幕採集實現的主要內容。

首先從原理上要了解 iOS / Android 原生提供的螢幕採集能力,其次介紹了 Flutter 與原生之間的互動,如何在 Flutter 側控制螢幕採集的啟動與停止。最後示例瞭如何對接 Zego RTC SDK 實現螢幕分享推流。

目前,Flutter on Desktop 趨於穩定,Zego RTC Flutter SDK 已經提供了 Windows 端的初步支援,我們將持續探索 Flutter 在桌面端上的應用,敬請期待!

相關文章