一、概述
在視訊會議、線上課堂、遊戲直播等場景,螢幕共享是一個最常見的功能。螢幕共享就是對螢幕畫面的實時共享,端到端主要有幾個步驟:錄屏採集、視訊編碼及封裝、實時傳輸、視訊解封裝及解碼、視訊渲染。
一般來說,實時螢幕共享時,共享發起端以固定取樣頻率(一般 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");
}
}];
}
(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 僅需要將 VirtualDisplay
和 MediaProjection
例項物件釋放即可。
三、實戰示例
下面為大家準備了一個實現了 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 在桌面端上的應用,敬請期待!