关于flutter:Flutter-屏幕采集实战分享

8次阅读

共计 9646 个字符,预计需要花费 25 分钟才能阅读完成。

一、概述

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

一般来说,实时屏幕共享时,共享发动端以固定采样频率(个别 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 仅须要将 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 在桌面端上的利用,敬请期待!

正文完
 0