在视频会议、线上课堂、游戏直播等场景,屏幕共享是一个最常见的功能。屏幕共享就是对屏幕画面的实时共享,端到端主要有几个步骤:录屏采集、视频编码及封装、实时传输、视频解封装及解码、视频渲染。

一般来说,实时屏幕共享时,共享发起端以固定采样频率(一般 8 - 15帧足够)抓取到屏幕中指定源的画面(包括指定屏幕、指定区域、指定程序等),经过视频编码压缩(应选择保持文本/图形边缘信息不失真的方案)后,在实时网络上以相应的帧率分发。

因此,屏幕采集是实现实时屏幕共享的基础,它的应用场景也是非常广泛的。

现如今 Flutter 的应用越来越广泛,纯 Flutter 项目也越来越多,那么本篇内容我们主要分享的是 Flutter 的屏幕采集的实现。

在详细介绍实现流程前,我们先来看看原生系统提供了哪些能力来进行屏幕录制。

  • iOS 11.0 提供了  ReplayKit 2用于采集跨 App 的全局屏幕内容,但仅能通过控制中心启动;iOS 12.0 则在此基础上提供了从 App 内启动 ReplayKit 的能力。
  • Android 5.0 系统提供了 MediaProjection  功能,只需弹窗获取用户的同意即可采集到全局屏幕内容。

我们再看一下 Android / iOS 的屏幕采集能力有哪些区别。

  • iOS 的ReplayKit  是通过启动一个Broadcast Upload Extension子进程来采集屏幕数据,需要解决主 App 进程与屏幕采集子进程之间的通信交互问题,同时,子进程还有诸如运行时内存最大不能超过 50M 的限制。
  • Android 的 MediaProjection  是直接在 App 主进程内运行的,可以很容易获取到屏幕数据的Surface。

虽然无法避免原生代码,但我们可以尽量以最少的原生代码来实现 Flutter 屏幕采集。将两端的屏幕采集能力抽象封装为通用的 Dart 层接口,只需一次部署完成后,就能开心地在 Dart 层启动、停止屏幕采集了。

接下来我们已 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/Android 屏幕采集并使用 ZEGO RTC Flutter SDK (https://pub.dev/packages/zego_express_engine)进行推流直播的示例 Demo。

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 在桌面端上的应用,敬请期待!