屏幕共享
更新时间: 2024/09/18 16:26:13
在大型会议或在线教育等场景中,为了满足提升沟通效率的需求,主讲人或老师需要将本端的屏幕内容分享给远端参会者或在线学生观看。NERTC 支持屏幕共享功能,帮助您实时分享本端设备的屏幕内容。
功能介绍
通过 NERTC SDK 可以在视频通话或互动直播过程中实现屏幕共享,主播或连麦者可以将自己的屏幕内容,以视频的方式分享给远端参会者或在线观众观看,从而提升沟通效率,一般适用于多人视频聊天、在线会议以及在线教育场景。
-
视频会议场景中,参会者可以在会议中将本地的文件、数据、网页、PPT 等画面分享给其他与会者,让其他与会者更加直观的了解讨论的内容和主题。
-
在线课堂场景中,老师可以通过屏幕共享将课件、笔记、教学内容等画面展示给远端的其他学生观看,降低传统教学模式下的沟通成本,提升教育场景的用户体验。
NERTC SDK 以辅流的形式实现屏幕共享,即单独为屏幕共享开启一路上行的视频流,摄像头的视频流作为主流,屏幕共享的视频流作为辅流,两路视频流并行,主播同时上行摄像头画面和屏幕画面两路画面。
音频共享功能基于原先的 NERTC SDK 可以实现,需要您在应用开发中自行编码支持。如有相关需要,您可以联系技术支持,索取 iOS 相关 Demo 的 SampleCode,参考 Demo 在您的开发环境中完成编码。
自 V4.6.20 起,屏幕共享功能以插件化方式提供,对应的屏幕共享库为 NERtcReplayKit.framework
,可以与核心 SDK 搭配使用,具体集成方式请参考集成 SDK。
示例项目
网易云信提供 ScreenShare 示例项目源码,您可以参考该源码实现屏幕共享。
注意事项
- NERTC Android、iOS、Windows 和 macOS SDK V3.9.0 及以上版本,Web SDK V4.1.0 及以上版本支持通过辅流实现屏幕共享。如果使用辅流的屏幕共享方案,请保证房间内所有成员均升级到支持版本以上,否则互相通信时会因同时发送主流和辅流造成通话异常等问题。
- 如果您的 App 无法针对所有端进行强制升级,屏幕共享场景中仅部分端使用 V3.9.0 及以上版本,为避免上述通话异常问题,必须保证通话过程中单人同时只有一路上行视频流。当需要将视频流切换为屏幕共享流时,请先通过
enableLocalVideo
关闭视频流,再通过startScreenCapture
启动屏幕共享流。反向切换同理。 - 在开始屏幕共享前,请确保已在你的项目中实现基本的实时音视频功能。
- ReplayKit 仅支持iOS 11.0 以上共享系统屏幕。Sample 工程支持 iOS 12 及以上唤起系统录屏能力,若系统版本低于iOS 12,需手动唤起系统录屏。
- 主 App 和系统录屏需使用相同的 App Group 名。
本地共享屏幕
技术原理
基于 iOS 系统的屏幕共享功能,需要在 App Extension 中通过 iOS 原生的 ReplayKit 特性实现录制扩展进程,接收系统采集的屏幕图像,并将其发送给 SDK 以配合主 App 进程进行视频流数据的传输。
实现流程
添加 ReplayKit
步骤一(可选)创建 App Group
创建一个 App Group,用于在主 App 进程和扩展程序之间之间进行视频数据和控制指令的传输。具体步骤如下:
-
在 Certificates, Identifiers & Profiles 页面中注册 App Group。
操作步骤请参考注册 App Group。
-
为您的 App ID 启用 App Group 功能。
操作步骤请参考启用 App Group。
-
重新下载 Provisioning Profile 并配置到 XCode 中。
步骤二 创建 Extension 录屏进程
创建一个类型为 Broadcast Upload Extension 的Target,用于存放屏幕共享功能的实现代码。
-
在 Xcode 中打开项目的工程文件。
-
在菜单中选择 Editor > Add Target...。
-
在 iOS 页签中 选择 Broadcast Upload Extension,并单击 Next。
-
在 Product Name 中为 Extension 命名,单后单击 Finish。
步骤三 创建 App Group 数据池
数据池用于扩展 ReplayKit 程序和主工程之间通信。
- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
self.userDefautls = [[NSUserDefaults alloc] initWithSuiteName:<#kAppGroupName#>];
}
步骤四 通过 ReplayKit 实现屏幕共享
压缩裁剪采集图片,发送到宿主App,并通过 ReplayKit 实现屏幕共享。具体步骤如下:
-
采集到的屏幕视频数据通过
processSampleBuffer:withType:
给用户。使用云信 SDK 音频采集,忽略音频数据回调。示例代码如下:
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType { switch (sampleBufferType) { case RPSampleBufferTypeVideo: { @autoreleasepool { CVImageBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); NSDictionary *frame = [self createI420VideoFrameFromPixelBuffer:pixelBuffer]; [self.userDefautls setObject:frame forKey:<#KeyPath#>]; [self.userDefautls synchronize]; } break; } case RPSampleBufferTypeAudioApp: // Handle audio sample buffer for app audiobreak; case RPSampleBufferTypeAudioMic: // Handle audio sample buffer for mic audiobreak; default: break; } }
-
将视频数据压缩后存入共享内存。
其中数据压缩采用的是 libyuv 第三方工具。
objc
- (NSDictionary *)createI420VideoFrameFromPixelBuffer:(CVPixelBufferRef)pixelBuffer { CVPixelBufferLockBaseAddress(pixelBuffer, 0); // 转I420 int psrc_w = (int)CVPixelBufferGetWidth(pixelBuffer); int psrc_h = (int)CVPixelBufferGetHeight(pixelBuffer); uint8 *src_y = (uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0); uint8 *src_uv = (uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1); int y_stride = (int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0); int uv_stride = (int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1); uint8 *i420_buf = (uint8 *)malloc((psrc_w * psrc_h * 3) >> 1); libyuv::NV12ToI420(&src_y[0], y_stride, &src_uv[0], uv_stride, &i420_buf[0], psrc_w, &i420_buf[psrc_w * psrc_h], psrc_w >> 1, &i420_buf[(psrc_w * psrc_h * 5) >> 2], psrc_w >> 1, psrc_w, psrc_h); // 缩放至720 int pdst_w = 720; int pdst_h = psrc_h * (pdst_w/(double)psrc_w); libyuv::FilterMode filter = libyuv::kFilterNone; uint8 *pdst_buf = (uint8 *)malloc((pdst_w * pdst_h * 3) >> 1); libyuv::I420Scale(&i420_buf[0], psrc_w, &i420_buf[psrc_w * psrc_h], psrc_w >> 1, &i420_buf[(psrc_w * psrc_h * 5) >> 2], psrc_w >> 1, psrc_w, psrc_h, &pdst_buf[0], pdst_w, &pdst_buf[pdst_w * pdst_h], pdst_w >> 1, &pdst_buf[(pdst_w * pdst_h * 5) >> 2], pdst_w >> 1, pdst_w, pdst_h, filter); free(i420_buf); CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); NSUInteger dataLength = pdst_w * pdst_h * 3 >> 1; NSData *data = [NSData dataWithBytesNoCopy:pdst_buf length:dataLength]; NSDictionary *frame = @{ @"width": @(pdst_w), @"height": @(pdst_h), @"data": data, @"timestamp": @(CACurrentMediaTime() * 1000) }; return frame; }
-
主程序监测到视频数据变更后,通过 SDK 自定义视频数据 进行发送。
屏幕共享主程序
步骤一 创建外部视频源
初始化 SDK,配置允许使用外部视频源,确保视频通话功能正常。
调用 setExternalVideoSource
开启外部视频源输入时,请设置 streamType
为 kNERtcStreamChannelTypeSubStream
,否则屏幕共享不会生效。
示例代码如下:
//开启外部视频源,并将外部视频源配置为屏幕共享
NERtcEngine *coreEngine = [NERtcEngine sharedEngine];
[coreEngine enableLocalAudio:YES];
[[[NTESDemoLogic sharedLogic] getCoreEngine] setExternalVideoSource:YES streamType:kNERtcStreamChannelTypeSubStream];
NERtcEngineContext *context = [[NERtcEngineContext alloc] init];
context.engineDelegate = self;
context.appKey = <#请输入您的AppKey#>;
[coreEngine setupEngineWithContext:context];
步骤二 在主程序中添加扩展程序
在 RPSystemBroadcastPickerView 中添加扩展程序。
示例代码如下:
- (void)addSystemBroadcastPickerIfPossible
{
if (@available(iOS 12.0, *)) {
// Not recommend
RPSystemBroadcastPickerView *picker = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 120, 64)];
picker.showsMicrophoneButton = NO;
picker.preferredExtension = <#扩展程序的BundleId#>;
[self.view addSubview:picker];
picker.center = self.view.center;
UIButton *button = [picker.subviews filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {
return [evaluatedObject isKindOfClass:UIButton.class];
}]].firstObject;
[button setImage:nil forState:UIControlStateNormal];
[button setTitle:@"Start Share" forState:UIControlStateNormal];
[button setTitleColor:self.navigationController.navigationBar.tintColor forState:UIControlStateNormal];
UIBarButtonItem *leftItem = [[UIBarButtonItem alloc] initWithCustomView:picker];
self.navigationItem.leftBarButtonItem = leftItem;
}
}
步骤三 添加事件监听
监听视频帧接收事件。
示例代码如下:
- (void)setupUserDefaults
{
// 通过UserDefaults建立数据通道,接收Extension传递来的视频帧self.userDefaults = [[NSUserDefaults alloc] initWithSuiteName:<#AppGroupName#>];
[self.userDefaults addObserver:self forKeyPath:<#KeyPath#> options:NSKeyValueObservingOptionNew context:KVOContext];
}
步骤四 推送外部视频帧
监听到数据帧变化,校验后推送外部视频帧到 SDK。
示例代码如下:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if ([keyPath isEqualToString:<#KeyPath#>]) {
if (self.currentUserID) {
NSDictionary *i420Frame = change[NSKeyValueChangeNewKey];
NERtcVideoFrame *frame = [[NERtcVideoFrame alloc] init];
frame.format = kNERtcVideoFormatI420;
frame.width = [i420Frame[@"width"] unsignedIntValue];
frame.height = [i420Frame[@"height"] unsignedIntValue];
frame.buffer = (void *)[i420Frame[@"data"] bytes];
frame.timestamp = [i420Frame[@"timestamp"] unsignedLongLongValue];
int ret = [NERtcEngine.sharedEngine pushExternalVideoFrame:frame]; // 推送外部视频帧到SDKif (ret != 0) {
NSLog(@"发送视频流失败:%d", ret);
return;
}
}
}
}
步骤五 开始屏幕共享
设置视频回放画布,并开启屏幕共享。屏幕共享内容以辅流形式发送。
- 通过
setupLocalSubStreamVideoCanvas
设置本端的辅流视频画布。 - 加入房间后,通过
startScreenCapture
开启屏幕共享,屏幕共享内容以辅流形式发送。 - 若有需要,可以通过
setLocalRenderSubStreamScaleMode
设置本端的辅流渲染缩放模式。
示例代码如下:
//设置本端的辅流视频画布
NERtcVideoCanvas *subStreamCanvas = nil;
if([NTESDemoSettings boolForKey:keyNRTCDemoLocalSubStreamExternalRender])
{
NTESExternalRenderView *externalview = [[NTESExternalRenderView alloc] initWithFrame:CGRectZero format:SDL_FCC_I420];
subStreamCanvas = [NERtcVideoCanvas localCanvasWithExternalRender:externalview];
[NTESDemoLogic sharedLogic].userManager.me.screenRenderView = externalview;
}else{
UIView *view = [[UIView alloc] initWithFrame:CGRectZero];
subStreamCanvas = [NERtcVideoCanvas localSubStreamCanvasWithView:view];
[NTESDemoLogic sharedLogic].userManager.me.screenRenderView = (NTESExternalRenderView *)view;
}
[[[NTESDemoLogic sharedLogic] getCoreEngine] setupLocalSubStreamVideoCanvas:subStreamCanvas];
//设置本端的辅流渲染缩放模式
NSString *key = keyNRTCDemoLocalSubStreamRenderScaleMode;
if (settings[key]) {
NERtcVideoRenderScaleMode renderMode = (NERtcVideoRenderScaleMode)[settings jsonInteger:key];
[[[NTESDemoLogic sharedLogic] getCoreEngine] setLocalRenderSubStreamScaleMode:renderMode];
}
//屏幕共享开启和关闭
- (void)onMenuMySubStream:(id)sender {
NTESUser *me = findMe();
int result = 0;
BOOL toStart = !me.screenConnected;
if (toStart) {
NERtcVideoSubStreamEncodeConfiguration *config = [[NERtcVideoSubStreamEncodeConfiguration alloc] init];
if([NTESDemoSettings objectForKey:keyNRTCDemoLocalVideoSubStreamProfileType]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalVideoSubStreamProfileType];
config.maxProfile = (NERtcVideoProfileType)value;
}
if ([NTESDemoSettings objectForKey:keyNRTCDemoLocalSubStreamEncodeFrameRate]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalSubStreamEncodeFrameRate];
config.frameRate = value;
}
if ([NTESDemoSettings objectForKey:keyNRTCDemoLocalSubStreamEncodeMinFrameRate]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalSubStreamEncodeMinFrameRate];
config.minFrameRate = value;
}
if ([NTESDemoSettings objectForKey:keyNRTCDemoLocalSubStreamEncodeBitrate]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalSubStreamEncodeBitrate];
config.bitrate = value;
}
if ([NTESDemoSettings objectForKey:keyNRTCDemoLocalSubStreamEncodeMinBitrate]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalSubStreamEncodeMinBitrate];
config.minBitrate = value;
}
if ([NTESDemoSettings objectForKey:keyNRTCDemoLocalSubStreamEncodeContentPrefer]) {
NSInteger value = [NTESDemoSettings integerForKey:keyNRTCDemoLocalSubStreamEncodeContentPrefer];
config.contentPrefer = value;
}
//屏幕共享开启
result = [[[NTESDemoLogic sharedLogic] getCoreEngine] startScreenCapture:config];
}else{
//屏幕共享关闭
result = [[[NTESDemoLogic sharedLogic] getCoreEngine] stopScreenCapture];
}
NTESCheckResultAndReturn(result, nil);
me.screenConnected = toStart;
if (self.eventDelegate && [self.eventDelegate respondsToSelector:@selector(handlerEventSubStreamStart:)]) {
[self.eventDelegate handlerEventSubStreamStart:me.screenConnected];
}
}
//不使用该功能时,需要移除观察者,并关闭屏幕共享。
[self.userDefaults removeObserver:self forKeyPath:<#KeyPath#>];
[[[NTESDemoLogic sharedLogic] getCoreEngine] stopScreenCapture];
观看远端屏幕共享
API 调用时序
实现方法
- 远端用户加入房间。
- 收到
onNERtcEngineUserSubStreamDidStartWithUserID
其他用户开启屏幕共享辅流通道的回调。 - 通过
setupRemoteSubStreamVideoCanvas
设置远端的辅流视频回放画布 设置远端的辅流视频回放画布。 - 通过
subscribeRemoteSubStreamVideo
订阅或取消订阅远端的屏幕共享辅流视频,订阅之后才能接收远端的辅流视频数据。 - 管理屏幕共享任务。
- 通过
setRemoteRenderSubStreamVideoScaleMode
设置远端的屏幕共享辅流视频渲染缩放模式。 - 通过
subscribeRemoteSubStreamVideo
取消订阅远端的屏幕共享辅流视频。
- 通过
- 收到
onNERtcEngineUserSubStreamDidStop
其他用户关闭辅流的回调,结束屏幕共享。
示例代码
//其他用户开启屏幕共享辅流通道的回调
- (void)onNERtcEngineUserSubStreamDidStartWithUserID:(uint64_t)userID subStreamProfile:(NERtcVideoProfileType)profile {
NTESUser *user = [[NTESDemoLogic sharedLogic].userManager userWithID:userID];
if (!user || user.isMe) {
return;
}
//设置远端的辅流视频回放画布
NERtcVideoCanvas *subCanvas = nil;
if([NTESDemoSettings boolForKey:keyNRTCDemoRemoteSubStreamExternalRender]){
NTESExternalRenderView *externalview = [[NTESExternalRenderView alloc] initWithFrame:CGRectZero format:SDL_FCC_I420];
subCanvas = [NERtcVideoCanvas remoteCanvasWithExternalRender:externalview];
user.screenRenderView = externalview;
}
else{
VIEW_CLASS *view = [[VIEW_CLASS alloc] initWithFrame:CGRectZero];
subCanvas = [NERtcVideoCanvas remoteSubStreamCanvasWithView:view];
user.screenRenderView = (NTESExternalRenderView *)view;
}
[[[NTESDemoLogic sharedLogic] getCoreEngine] setupRemoteSubStreamVideoCanvas:subCanvas forUserID:userID];
VideoUserCell *cell = [self.mainView cellForUserID:userID];
cell.screenRenderView = (UIView *)user.screenRenderView;
if ([NTESDemoSettings boolForKey:keyNRTCDemoChanelEnableMeetingScene defaultVal:NO] && [self.mainView cellForUserID:userID]) {
[self subscribeSubStreamWithUserID:userID];
return;
}
// screen start之前就操作过订阅了
if (user.isScreenSubscribed) {
return;
}
BOOL autoSubscribe = [NTESDemoSettings boolForKey:keyNRTCDemoAutoSubscribeRemoteSubStream defaultVal:YES];
if (autoSubscribe && [self.mainView cellForUserID:userID]) {
//订阅远端的屏幕共享辅流视频
[self subscribeSubStreamWithUserID:userID];
}
}
//其他用户关闭辅流的回调
- (void)onNERtcEngineUserSubStreamDidStop:(uint64_t)userID {
NTESUser *user = [[NTESDemoLogic sharedLogic].userManager userWithID:userID];
if (!user || user.isMe) {
return;
}
VideoUserCell *cell = [self.mainView cellForUserID:userID];
cell.screenRenderView = nil;
//SubStream
NERtcVideoCanvas *subCanvas = [NERtcVideoCanvas remoteSubStreamCanvasWithView:nil];
[[[NTESDemoLogic sharedLogic] getCoreEngine] setupRemoteSubStreamVideoCanvas:subCanvas forUserID:userID];
}
//设置远端的屏幕共享辅流视频渲染缩放模式
NSString *key = keyNRTCDemoRemoteSubStreamRenderScaleMode;
if (settings[key]) {
NERtcVideoRenderScaleMode renderMode = (NERtcVideoRenderScaleMode)[settings jsonInteger:key];
NSArray *users = [NTESDemoLogic sharedLogic].userManager.users;
for (NTESUser *user in users) {
if (user.userID != [NTESUser selfID]) {
[[[NTESDemoLogic sharedLogic] getCoreEngine] setRemoteRenderSubStreamVideoScaleMode:renderMode forUserID:user.userID];
}
}
}
API 参考
方法 | 功能描述 |
---|---|
setExternalVideoSource |
开启外部视频源输入。 |
startScreenCapture |
开启屏幕共享。 |
setupLocalSubStreamVideoCanvas |
设置本端的辅流视频画布。 |
setLocalRenderSubStreamScaleMode |
设置本端的辅流渲染缩放模式。 |
stopScreenCapture |
关闭屏幕共享。 |
setupRemoteSubStreamVideoCanvas |
设置远端的辅流视频回放画布。 |
onNERtcEngineUserSubStreamDidStartWithUserID |
通知本端关于远端用户开启屏幕共享辅流通道的回调。 |
subscribeRemoteSubStreamVideo |
订阅远端的屏幕共享辅流视频 |
onNERtcEngineUserSubStreamDidStop |
通知本端关于远端用户关闭屏幕共享辅流通道的回调。 |