1. iOS 游戏直播方案简介
iOS 9 之前可以通过私有框架 CoreSurface.framework 来实现录制屏幕。因为用了私有框架,只能通过企业包的形式安装到用户设备中,这种方法的优点是效率很高,但是无法获取游戏声音,只能通过麦克风录制外放的声音。
iOS 9 以后,苹果去掉了 CoreSurface, 因此,上面的方法彻底失效。iOS 9 发布以后的很长一段时间,都没有办法录屏直播。 后来大家另辟蹊径,通过破解 AirPlay 投屏协议的方式实现,在手机上虚拟一个 AirPlay Server 来接受屏幕镜像, 然后解码后再直播。目前 大部分直播平台都是直接接入第三方 SDK,如乐播, xindawn。 这种方案的缺点是 每次 iOS 系统升级,对应的 Airplay Mirroring协议会更新,破解成本高,技术门槛比较高。
iOS 10 中苹果提供 ReplayKit 了,可以在游戏中实现录屏直播,但是需要游戏厂商支持通用性很低。所以基本上各大厂商基本上还是采用 Airplay的模式。
iOS 11 苹果增强为ReplayKit2 提供了更通用的桌面级录屏方案,本文接下来会着重介绍这种方案。
2. ReplayKit2 概述
录屏功能是 iOS 10 新推出的特性,苹果在 iOS 9 的 ReplayKit 保存录屏视频的基础上,增加了视频流实时直播功能,官方介绍见 Go Live with ReplayKit。iOS 11 增强为 ReplayKit2,进一步提升了 Replaykit 的易用性和通用性,并且可以对整个手机实现屏幕录制,而非某些做了支持ReplayKit功能的App,因此录屏推流建议直接使用iOS11的ReplayKit2屏幕录制方式。系统录屏采用的是扩展方式,扩展程序有单独的进程,iOS 系统为了保证系统流畅,给扩展程序的资源相对较少,扩展程序内存占用过大也会被 Kill 掉。
3. 部分关键功能实现
# 系统回调处理
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
KSYRKStreamerKit* kit = [KSYRKStreamerKit sharedInstance];
switch (sampleBufferType) {
case RPSampleBufferTypeVideo: {
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
pixelBuffer = [kit resizeAndRotatePixelBuffer:pixelBuffer];
CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
if (sampleBuffer) {
[kit.streamerBase processVideoPixelBuffer:pixelBuffer timeInfo:pts];
_tempVideoPts = pts;
_tempVideoPixelBuffer = pixelBuffer;
_tempVideoTimeStamp = [[NSDate date] timeIntervalSince1970];
}
}
break;
case RPSampleBufferTypeAudioApp:
[kit mixAudio:sampleBuffer to:kit.appTrack];
break;
case RPSampleBufferTypeAudioMic:
[kit mixAudio:sampleBuffer to:kit.micTrack];
kit.streamerBase.bWithAudio = YES;
self.hasMicVoice = YES;
break;
default:
break;
}
}
4. 部分已知问题及解决方案
4.1 屏幕帧方向
系统回调回来的视频帧都是竖屏的全尺寸图像,我们需要对其进行处理
- (CVPixelBufferRef)resizeAndRotatePixelBuffer:(CVPixelBufferRef)sourcePixelBuffer {
CIImage *outputImage;
if (self.privacyMode) {
outputImage = self.privacyImage;
} else {
//11.1以上支持自动旋转
#ifdef __IPHONE_11_1
if (UIDevice.currentDevice.systemVersion.floatValue > 11.1) {
CGImagePropertyOrientation oritation = ((__bridge NSNumber*)CMGetAttachment(sampleBuffer, (__bridge CFStringRef)RPVideoSampleOrientationKey , NULL)).unsignedIntValue;
if (oritation != self.lastOritation) {
self.lastOritation = oritation;
if (oritation == kCGImagePropertyOrientationLeft) {
[kit setVideoOrientation:UIDeviceOrientationLandscapeLeft];
} else if (oritation == kCGImagePropertyOrientationRight) {
[kit setVideoOrientation:UIDeviceOrientationLandscapeRight];
}
}
}
#endif
CIImage *sourceImage = [CIImage imageWithCVPixelBuffer:sourcePixelBuffer];
sourceImage = [sourceImage imageByApplyingOrientation:cvMobileRotate];
CGFloat outputWidth = self.videoSize.width;
CGFloat outputHeight = self.videoSize.height;
CGFloat inputWidth = sourceImage.extent.size.width;
CGFloat inputHeight = sourceImage.extent.size.height;
// float scale = MIN(outputWidth/inputWidth, outputHeight/inputHeight);
CGAffineTransform tranfrom = CGAffineTransformMakeScale(outputWidth/inputWidth, outputHeight/inputHeight);
outputImage = [sourceImage imageByApplyingTransform:tranfrom];
}
if (!outputPixelBuffer) {
//推流
NSDictionary* pixelBufferOptions = @{
(NSString*) kCVPixelBufferWidthKey : @(self.videoSize.width),
(NSString*) kCVPixelBufferHeightKey : @(self.videoSize.height),
(NSString*) kCVPixelBufferOpenGLESCompatibilityKey : @YES,
(NSString*) kCVPixelBufferIOSurfacePropertiesKey : @{}
};
CVReturn ret = CVPixelBufferCreate(kCFAllocatorDefault, self.videoSize.width, self.videoSize.height, kCVPixelFormatType_32BGRA, (__bridge CFDictionaryRef)pixelBufferOptions, &outputPixelBuffer);
if (ret!= noErr) {
NSLog(@"创建streamer buffer失败");
outputPixelBuffer = nil;
return outputPixelBuffer;
}
}
if (cicontext) {
[cicontext render:outputImage toCVPixelBuffer:outputPixelBuffer bounds:outputImage.extent colorSpace:CGColorSpaceCreateDeviceRGB()];
}
return outputPixelBuffer;
}
4.2 隐私模式的实现
在直播过程中比如要切换到QQ,或者输入密码等操作,不方便给观众看到,就需要用到隐私模式,用一张或多张图片来代替屏幕截屏。
# UIImage 图片转成 CIImage 然后可以调整大小和方向,直接通过 CIContext 渲染到 CVPixelBufferRef 中
UIImage *privacyImage = [UIImage imageNamed:privacyImageName];
CIImage *sourceImage = [[CIImage alloc] initWithImage:privacyImage];
4.3 某些情况下视频帧不回调
缓存上一个视频帧,根据推流的fps适当的补帧
4.4 弹幕和礼物信息的显示
弹幕和礼物信息的显示有两种方案:
1、是在主 App 中,建立 Socket 连接,收到消息后,创建本地通知,显示礼物和弹幕,大部分直播应用采用这种方式比较多,对原来弹幕系统的改造比较小。
2、类似企鹅电竞的做法,通过Apns 远程推送通知的方式,实现弹幕礼物通知。
4.5 后台保活
采用第一种弹幕就涉及到主App的后台保活问题:
常用的几种后台保活方式:VOIP,后台定位,播放空白声音。
考虑到耗电和上线审核的问题,我们目前采用的是利用background task 播放空白声音。
# 创建 background task
self.taskIdentifier = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
[[UIApplication sharedApplication] endBackgroundTask:weakSelf.taskIdentifier];
weakSelf.taskIdentifier = UIBackgroundTaskInvalid;
}];
# 播放背景音乐
self.taskTimer = [NSTimer scheduledTimerWithTimeInterval:20.0f repeats:YES block:^(NSTimer * _Nonnull timer) {
if ([[UIApplication sharedApplication] backgroundTimeRemaining] < 61.f) {
//创建播放器
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setActive:YES error:nil];
[session setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:nil];
AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:weakSelf.musicUrl error:nil];
[audioPlayer prepareToPlay];
[audioPlayer play];
[[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil];
}
}];
4.6 部分游戏无声音
腾讯系的游戏,例如王者荣耀,刺激战场,在 先开游戏,再开直播的情况下,会出现主播无法听到游戏声音的问题。
原因是我们利用 AVAudioPlayer 后台保活,我们设置了 AVAudioSession 的 Option 为 AVAudioSessionCategoryOptionMixWithOthers 保证能和其他应用共用扬声器,但是这属性会导致 已经在播放的非Mix的声音被停止。
解决的方案只能是告知用户,先打开直播,再进入游戏,这样后播放的声音才不会有问题。
4.7 锁屏后录屏断开
这个问题没有很好的解决办法,只能在断开以后创建通知告知用户。
5. 参考
- 腾讯云文档-游戏录屏(ReplayKit)
- replaykit2 直播踩坑总结
- iOS11 ReplayKit2 问题总结