写点什么

ios webRTC 实现屏幕共享功能

用户头像
侠客行
关注
发布于: 2021 年 06 月 16 日
ios webRTC实现屏幕共享功能

杂谈:

使用 webRTC 开发音视频通话,与会议功能,已经有一年时间,一年时间从技术调研,到方案实施,我所起到的作用不是很大,技术方向主导依然是技术总监,在这里也对总监的技术表示钦佩,佩服他的敬业精神,以及对技术的追求,在音视频编解码这一块也是很有话语权的,40 多岁的人,每天依然可以在公司工作到 9 点下班,这种精神头,年轻人也不一定有.

通过对 webRTC 在 ios 端的实现与在 web 端的实现(我负责公司 ios 端功能开发并兼职负责 webRTC, web 端的开发功能),收获了很多,对比两端实现,大致基本相同,在实现[屏幕共享功能上,这一点差距和思想上有很大的区别也有很大的相同点.接下来我想先讲 iOS 端的实现,在下一篇文章中将 web 端的实现,避免混淆,两篇做一个对比比较清晰


前言:

实现 ios 端屏幕共享功能,随着直播的兴起,录播显得尤为重要,ios9 以后苹果终于开放了 replaykit 框架,用于实现应用内的录制,可以实现应用内屏幕直播,但是不能实现系统的录制功能,在 ios12 后苹果终于推出了 replaykit2 实现系统屏幕录制的功能,但是要实现系统内的录制功能,就需要创建 extensionApp 在另一个进程中录制屏幕数据,在主 app 与扩展 app 通信的过程中就涉及到进程间的通信,如果在项目中应用到三方或者逻辑组封装好的编码接口,可以直接调用传数据进行编码,实现将录制数据传到远端,如果需要将录制数据回传到主 app 进行编解码,那就需要涉及到进程间的通信,这就是这篇文章的主要内容


主要解决问题:

  1. 实现录制功能的流程

  2. 解决进程间的通信

  3. 解决替换 webRTC 视频源

  4. 解决录制进程 50M 内存限制


实现录制功能的流程:

1.创建工程,在编辑器下方点击+



出现如下图所示点击图中选中的内容下一步填写名称(随便写,更具项目命名规则来就行),其他内容都是用默认设置就可以


Finish 后项目中多了几个文件,我们关心的只是名为 SampleHandler.h .m 的文件



2.进入到 Project 看到 extensionApp 的配置项,这里有一个知识点就是 ExtensionApp 的 bundle ID 需要与主 app 的 bundle ID,前缀相同,如下

主app Bundle ID为: HeLi.LTD.screen.comextension  Bundle ID :  HeLi.LTD.screen.com.upload   extension UI  Bundle ID :  HeLi.LTD.screen.com.setUpUI  在创建证书的时候需要注意这一点
复制代码

以上内容便是创建 extensionApp 的内容,


2.解决进程间的通信问题

进程间的通信 ios 下有多种,socket,剪切板,共享内存, 一共有 9 种好像,具体的没有过多研究,这里主要讲 socket 与进程间的通知(notificationcenter)实现通信功能,

  1. socket 实现进程间的通信功能,使用 GCDAsySocket 实现 tcp,用于将录制的数据回传到主 app,进行替换视频源,传到远端

在主 app 中启动 socket 服务端(server),在 extension 端实现客户端(client),服务端在应用调用视频时便启动 socket 连接,待客户端回传数据(并不一定会启动屏幕共享,可以优化)


在 extensionApp 中开始共享时创建 socket 连接


- (void)setupSocket{ if (self.socket.isConnected) { return; } self.sockets = [NSMutableArray array]; self.recvBuffer = (NTESTPCircularBuffer *)malloc(sizeof(NTESTPCircularBuffer)); // 需要释放 NTESTPCircularBufferInit(self.recvBuffer, kRecvBufferMaxSize); // self.queue = dispatch_queue_create("com.netease.edu.rp.server", DISPATCH_QUEUE_SERIAL); self.queue = dispatch_get_main_queue(); self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:self.queue]; self.socket.IPv6Enabled = NO; NSError *error; // [self.socket acceptOnUrl:[NSURL fileURLWithPath:serverURL] error:&error]; [self.socket acceptOnPort:8999 error:&error]; [self.socket readDataWithTimeout:-1 tag:0]; if (error == nil) { NSLog(@"开启成功");// [[NSRunLoop mainRunLoop]run];//目的让服务器不停止 [self setTimer]; } else { NSLog(@"开启失败"); [self.socket disconnect]; [self setupSocket]; } NSNotificationCenter *center =[NSNotificationCenter defaultCenter]; [center addObserver:self selector:@selector(defaultsChanged:) name:NSUserDefaultsDidChangeNotification object:nil];}
复制代码


客户端的建立



- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo { if (!self.connected) { [self.socket disconnect]; } if (!self.socket.isConnected) { [self setupSocket]; } }
- (void)setupSocket{ _recvBuffer = (NTESTPCircularBuffer *)malloc(sizeof(NTESTPCircularBuffer)); // 需要释放 NTESTPCircularBufferInit(_recvBuffer, kRecvBufferMaxSize); self.queue = dispatch_queue_create("com.netease.edu.rp.client", DISPATCH_QUEUE_SERIAL); self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:self.queue]; // self.socket.IPv6Enabled = NO; // [self.socket connectToUrl:[NSURL fileURLWithPath:serverURL] withTimeout:5 error:nil]; NSError *error; [self.socket connectToHost:_ip onPort:8999 error:&error]; [self.socket readDataWithTimeout:-1 tag:0]; NSLog(@"setupSocket:%@",error); if (error == nil) { NSLog(@"====开启成功"); } else { NSLog(@"=====开启失败"); }}
复制代码


发送数据 在获取到录制的数据流后将流编码后发送


- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType { switch (sampleBufferType) { case RPSampleBufferTypeVideo: { if (!self.connected) { return; } // if(self.connected){ if(_finish){ NSError *error = [NSError errorWithDomain:NSStringFromClass(self.class) code:0 userInfo:@{ NSLocalizedFailureReasonErrorKey:@"屏幕共享已结束" }]; [self finishBroadcastWithError:error]; } if( CMSampleBufferDataIsReady(sampleBuffer) || CMSampleBufferIsValid(sampleBuffer) || CMSampleBufferGetNumSamples(sampleBuffer)){ [self sendVideoBufferToHostApp:sampleBuffer]; }
} break; case RPSampleBufferTypeAudioApp: // Handle audio sample buffer for app audio break; case RPSampleBufferTypeAudioMic: // Handle audio sample buffer for mic audio break; default: break; }}

- (void)sendVideoBufferToHostApp:(CMSampleBufferRef)sampleBuffer { if (!self.socket) { return; } CFRetain(sampleBuffer); long curMem = [self getCurrentMemory]; if ((self.eventMemory > 0 && ((curMem - self.eventMemory) > 5)) || curMem > 35) { //当前内存暴增5M以上,或者总共超过45M,则不处理 CFRelease(sampleBuffer); return; }; dispatch_async(self.videoQueue, ^{ // queue optimal @autoreleasepool { if (self.frameCount > 0) { CFRelease(sampleBuffer); return; } self.frameCount ++ ; CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); CFStringRef RPVideoSampleOrientationKeyRef = (__bridge CFStringRef)RPVideoSampleOrientationKey; NSNumber *orientation = (NSNumber *)CMGetAttachment(sampleBuffer, RPVideoSampleOrientationKeyRef,NULL); switch ([orientation integerValue]) { case 1: self.orientation = NTESVideoPackOrientationPortrait; break; case 6: self.orientation = NTESVideoPackOrientationLandscapeRight; break; case 8: self.orientation = NTESVideoPackOrientationLandscapeLeft; break; default: break; } // To data NTESI420Frame *videoFrame = nil; videoFrame = [NTESYUVConverter pixelBufferToI420:pixelBuffer withCrop:self.cropRate targetSize:self.targetSize andOrientation:self.orientation]; CFRelease(sampleBuffer); // To Host App if (videoFrame){ NSData *raw = [videoFrame bytes]; //NSData *data = [NTESSocketPacket packetWithBuffer:raw]; NSData *headerData = [NTESSocketPacket packetWithBuffer:raw]; if (!_enterBack) { if (self.connected) { [self.socket writeData:headerData withTimeout:-1 tag:0]; [self.socket writeData:raw withTimeout:-1 tag:0]; } } } self.frameCount --; }; }); self.eventMemory = [self getCurrentMemory];}
复制代码

主要代码就是上面的实现过程,具体的代码,我会放在 demo 中


2.CFNotificationCenterRef 进程的通知中心

在 SampleHandler.m 中重写 init 方法订阅通知中心

- (instancetype)init {    if(self = [super init]) {                _targetSize = CGSizeMake(414, 812);        _cropRate = 15;        _orientation = NTESVideoPackOrientationPortrait;                _ip = @"127.0.0.1";        _serverPort = @"8999";        _clientPort = [NSString stringWithFormat:@"%d", arc4random()%9999];        _videoQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);        CFStringRef name = CFSTR("customName");        CFNotificationCenterRef center = CFNotificationCenterGetDarwinNotifyCenter();        CFNotificationCenterAddObserver(center,                                        (const void *)self,                                        Callback,                                        name,                                        NULL,                                        kCFNotificationDeliverImmediately);        CFStringRef finishName = CFSTR("StopScreen");        CFNotificationCenterRef finishCenter = CFNotificationCenterGetDarwinNotifyCenter();                CFNotificationCenterAddObserver(finishCenter,                                        (const void *)self,                                        FinishCallback,                                        finishName,                                        NULL,                                        kCFNotificationDeliverImmediately);                    }    return self;}

BOOL _enterBack = false;static void Callback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo){ _enterBack = true; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ _enterBack = false; }); }BOOL _finish = false;static void FinishCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo){ _finish = true; }
复制代码

在发送端(主 app,),发送通知消息

    CFStringRef finishName = CFSTR("StopShare");    CFNotificationCenterRef finishCenter = CFNotificationCenterGetDarwinNotifyCenter();               CFNotificationCenterAddObserver(finishCenter,                                    (const void *)self,                                    FinishCallback,                                    finishName,                                    NULL,                                    kCFNotificationDeliverImmediately);                                                                                                            
BOOL _finish = false;static void FinishCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo){ _finish = true;}
复制代码


3.解决替换 webRTC 视频源

在主 app 中接收到 socket 发送来的消息后,将数据转为 CMSampleBufferRef,GCDAsySocket 只能发送 NSData 的数据,所以需要一个转换的过程,将 yuv 数据转为 nsdata 数据在转回 CMSampleBufferRef

- (void)onRecvData:(NSData *)data{    dispatch_async(dispatch_get_main_queue(), ^{    	//将数据zhuanweiI420,yuv数据 4:2:0        NTESI420Frame *frame = [NTESI420Frame initWithData:data];        CMSampleBufferRef sampleBuffer = [frame convertToSampleBuffer];        if (sampleBuffer == NULL) {            return;        }        if(!_isStart){            if (_startScreenBlock) {                _startScreenBlock(true);                            }//            if(!_StartScreen){////            }else{////               if (_startScreenBlock) {//                    _startScreenBlock(false);//                }//            }            _isStart = true;            _Running = false;            [_capture  stopCapture]; //收到数据代表录制打开,需要暂停本地摄像头的数据                                         }else{                   }                if([UIScreen mainScreen].isCaptured){            if(_Running){                _isStart = true;                _Running = false;                [_capture  stopCapture];                if (_startScreenBlock) {                    _startScreenBlock(true);                }            }                   }              
// if (self.StartScreen) { int64_t timeStampNs = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) * NSEC_PER_SEC; //NSEC_PER_SEC 这个很关键, CVPixelBufferRef rtcPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); RTCCVPixelBuffer *cpvX = [[RTCCVPixelBuffer alloc]initWithPixelBuffer:rtcPixelBuffer]; RTCVideoFrame *aframe = [[RTCVideoFrame alloc]initWithBuffer:cpvX rotation:self.rotation timeStampNs:timeStampNs]; //替换数据源 [_videoSource capturer:_capture didCaptureVideoFrame:aframe];
// } CFRelease(sampleBuffer); });}
复制代码

5.解决录制进程 50M 内存限制

由于系统的限制 extensionApp 只要 50M 的运行空间,超过就会被系统杀死,在 CMSampleBufferRef 提取 yuv 数据转 I420 数据的过程中,耗时较多,在数据刷新快的时候会造成内存开销变大,导致录制进程崩溃问题,这里补充一个知识点

录制的数据是根据屏幕的刷新率来确定截取的帧数,屏幕没有变化,则不会有视频流被捕获,只有屏幕内容变化的时候才会得到录制的数据,屏幕刷新快相应的得到更多的数据,造成瞬时需要处理更多的数据,所以解决内存限制的思路是减少处理数据的量,从而限制内存的占用,

首先我们通过监控内存的数据变化,来确定是否接收处理录制的视频数据

//获取当前进程所占的内存- (double)getCurrentMemory{  task_basic_info_data_t taskInfo;  mach_msg_type_number_t infoCount = TASK_BASIC_INFO_COUNT;  kern_return_t kernReturn = task_info(mach_task_self(),                                       TASK_BASIC_INFO,                                       (task_info_t)&taskInfo,                                       &infoCount);
if (kernReturn != KERN_SUCCESS ) { return NSNotFound; } NSLog(@"%f",taskInfo.resident_size / 1024.0 / 1024.0); return taskInfo.resident_size / 1024.0 / 1024.0;}

//在次方法中调用- (void)sendVideoBufferToHostApp:(CMSampleBufferRef)sampleBuffer { long curMem = [self getCurrentMemory]; CFRetain(sampleBuffer); if ((self.eventMemory > 0 && ((curMem - self.eventMemory) > 5)) || curMem > 35) { //当前内存暴增5M以上,或者总共超过35M,则不处理 //这里写35M是为本次计算后的数据处理留出空间,超过将不再处理数据,直接释放流信息 CFRelease(sampleBuffer);//释放数据 return; }; //此处处理数据的编码转换}
复制代码

以上代码只是为说明关键点的处理,只是片段,变量,方法并没有全部包括在内,具体的处理需要在 demo 中才能详细的了解,https://github.com/githupchenqiang/ScreenShare


发布于: 2021 年 06 月 16 日阅读数: 19
用户头像

侠客行

关注

一叶孤舟 2020.01.06 加入

主业iOS开发,偶尔写JS项目,业余学习python

评论

发布
暂无评论
ios webRTC实现屏幕共享功能