Flutter iOS 实现屏幕采集

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

一般来说,实时屏幕共享时,共享发起端以固定采样频率(一般 8 – 15帧足够)抓取到屏幕中指定源的画面(包括指定屏幕、指定区域、指定程序等),经过视频编码压缩(应选择保持文本/图形边缘信息不失真的方案)后,在实时网络上以相应的帧率分发。 因此,屏幕采集是实现实时屏幕共享的基础,它的应用场景也是非常广泛的。 现如今 Flutter 的应用越来越广泛,纯 Flutter 项目也越来越多,那么本篇内容我们主要分享的是 Flutter 的屏幕采集的实现

Flutter 屏幕采集实现流程

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

  • iOS 11.0 提供了  ReplayKit 2用于采集跨 App 的全局屏幕内容,但仅能通过控制中心启动;iOS 12.0 则在此基础上提供了从 App 内启动 ReplayKit 的能力。

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

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

虽然无法避免原生代码,但我们可以尽量以最少的原生代码来实现 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 实现流程图示


实战示例

下面为大家准备了一个实现了 iOS/Android 屏幕采集并使用 ZEGO RTC Flutter SDK 进行推流直播的示例 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];
}

本文为原创稿件,版权归作者所有,如需转载,请注明出处:https://www.nxrte.com/jishu/17803.html

(0)

相关推荐

发表回复

登录后才能评论