ReplayKit2 直播

1. iOS 游戏直播方案简介

  • iOS 9 以前能够经过私有框架 CoreSurface.framework 来实现录制屏幕。由于用了私有框架,只能经过企业包的形式安装到用户设备中,这种方法的优势是效率很高,可是没法获取游戏声音,只能经过麦克风录制外放的声音。html

  • iOS 9 之后,苹果去掉了 CoreSurface, 所以,上面的方法完全失效。iOS 9 发布之后的很长一段时间,都没有办法录屏直播。 后来你们另辟蹊径,经过破解 AirPlay 投屏协议的方式实现,在手机上虚拟一个 AirPlay Server 来接受屏幕镜像, 而后解码后再直播。目前 大部分直播平台都是直接接入第三方 SDK,如乐播, xindawn。 这种方案的缺点是 每次 iOS 系统升级,对应的 Airplay Mirroring协议会更新,破解成本高,技术门槛比较高。git

  • iOS 10 中苹果提供 ReplayKit 了,能够在游戏中实现录屏直播,可是须要游戏厂商支持通用性很低。因此基本上各大厂商基本上仍是采用 Airplay的模式。github

  • iOS 11 苹果加强为ReplayKit2 提供了更通用的桌面级录屏方案,本文接下来会着重介绍这种方案。缓存

2. ReplayKit2 概述

录屏功能是 iOS 10 新推出的特性,苹果在 iOS 9 的 ReplayKit 保存录屏视频的基础上,增长了视频流实时直播功能,官方介绍见 Go Live with ReplayKit。iOS 11 加强为 ReplayKit2,进一步提高了 Replaykit 的易用性和通用性,而且能够对整个手机实现屏幕录制,而非某些作了支持ReplayKit功能的App,所以录屏推流建议直接使用iOS11的ReplayKit2屏幕录制方式。系统录屏采用的是扩展方式,扩展程序有单独的进程,iOS 系统为了保证系统流畅,给扩展程序的资源相对较少,扩展程序内存占用过大也会被 Kill 掉。bash

3. 部分关键功能实现

# 系统回调处理
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
    @synchronized(self) {
        KSYRKStreamerKit* kit = [KSYRKStreamerKit sharedInstance];
        switch (sampleBufferType) {
            case RPSampleBufferTypeVideo: {
                if (!CMSampleBufferIsValid(sampleBuffer) || !sampleBuffer)
                    return;
                if (tempVideoTimeStamp && (CFAbsoluteTimeGetCurrent() - tempVideoTimeStamp < 0.025)) {
#ifdef DEBUG
                    NSLog(@"帧数传入过快,丢帧处理");
#endif
                    return;
                }
                if (tempVideoPixelBuffer) {
                    CFRelease(tempVideoPixelBuffer);
                    tempVideoPixelBuffer = NULL;
                }
                CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
                //11.1以上支持自动旋转
                if (UIDevice.currentDevice.systemVersion.floatValue > 11.1) {
                    CGImagePropertyOrientation oritation = ((__bridge NSNumber*)CMGetAttachment(sampleBuffer, (__bridge CFStringRef)RPVideoSampleOrientationKey , NULL)).unsignedIntValue;
                    pixelBuffer = [kit resizeAndRotatePixelBuffer:pixelBuffer withOrientation:oritation];
                } else {
                    // 0为unknown,走默认横屏纵屏处理
                    pixelBuffer = [kit resizeAndRotatePixelBuffer:pixelBuffer withOrientation:0];
                }
                CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
                [kit.streamerBase processVideoPixelBuffer:pixelBuffer timeInfo:pts];
                tempVideoTimeStamp = CFAbsoluteTimeGetCurrent();
                tempVideoPts = pts;
                tempVideoPixelBuffer = pixelBuffer;
                CFRetain(tempVideoPixelBuffer);
            }
                break;
            case RPSampleBufferTypeAudioApp: {
                [kit mixAudio:sampleBuffer to:kit.appTrack];
            }
                break;
            case RPSampleBufferTypeAudioMic:
                [kit mixAudio:sampleBuffer to:kit.micTrack];
                break;
            default:
                break;
        }
    }
}
复制代码

4. 部分已知问题及解决方案

4.1 屏幕帧方向

系统回调回来的视频帧都是竖屏的全尺寸图像,咱们须要对其进行处理markdown

/**
 * 缩放旋转
 */
- (CVPixelBufferRef)resizeAndRotatePixelBuffer:(CVPixelBufferRef)sourcePixelBuffer withOrientation:(CGImagePropertyOrientation)orientation {
    @synchronized(self) {
        CIImage *outputImage;
        if (self.privacyMode) {
            if (_privacyImage && outputPixelBuffer) {
                return outputPixelBuffer;
            }
            outputImage = self.privacyImage;
        } else {
            if (_privacyImage) {
                _privacyImage = nil;
            }
            CIImage *sourceImage = [CIImage imageWithCVPixelBuffer:sourcePixelBuffer];
            if (lastSourceOritation != orientation) {
                CGFloat outputWidth  = self.videoSize.width;
                CGFloat outputHeight = self.videoSize.height;
                CGFloat inputWidth = sourceImage.extent.size.width;
                CGFloat inputHeight = sourceImage.extent.size.height;
                // 若是是横屏且输入源为横屏(iPad Pro)或者 竖屏且输入源为竖屏
                if ((inputWidth > inputHeight && self.isLandscape) || (inputWidth <= inputHeight && !self.isLandscape)) {
                    if (orientation == kCGImagePropertyOrientationUp) {
                        lastRotateOritation = kCGImagePropertyOrientationUp;
                    } else if (orientation == kCGImagePropertyOrientationDown) {
                        lastRotateOritation = kCGImagePropertyOrientationDown;
                    }
                    lastRotateTransform = CGAffineTransformMakeScale(outputWidth/inputWidth, outputHeight/inputHeight);
                } else {
                    // 须要进行旋转
                    if (orientation == kCGImagePropertyOrientationLeft) {
                        lastRotateOritation = kCGImagePropertyOrientationRight;
                    } else if (orientation == kCGImagePropertyOrientationRight) {
                        lastRotateOritation = kCGImagePropertyOrientationLeft;
                    } else {
                        lastRotateOritation = kCGImagePropertyOrientationLeft;
                    }
                    lastRotateTransform = CGAffineTransformMakeScale(outputWidth/inputHeight, outputHeight/inputWidth);
                }
            }
            sourceImage = [sourceImage imageByApplyingOrientation:lastRotateOritation];
            outputImage = [sourceImage imageByApplyingTransform:lastRotateTransform];
            lastSourceOritation = orientation;
        }
        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;
            }
        }
        MTIImage *mtiImage = [[MTIImage alloc] initWithCIImage:outputImage];
        if (cicontext) {
            NSError *error;
            [cicontext renderImage:mtiImage toCVPixelBuffer:outputPixelBuffer error:&error];
            NSAssert(error == nil, @"渲染失败");
        }
        return outputPixelBuffer;
    }
}

复制代码

4.2 隐私模式的实现

在直播过程当中好比要切换到QQ,或者输入密码等操做,不方便给观众看到,就须要用到隐私模式,用一张或多张图片来代替屏幕截屏。session

# UIImage 图片转成 CIImage 而后能够调整大小和方向,直接经过 CIContext 渲染到 CVPixelBufferRef 中
UIImage *privacyImage = [UIImage imageNamed:privacyImageName];
CIImage *sourceImage = [[CIImage alloc] initWithImage:privacyImage];
复制代码

4.3 某些状况下视频帧不回调

缓存上一个视频帧,根据推流的fps适当的补帧app

4.4 弹幕和礼物信息的显示

弹幕和礼物信息的显示有两种方案:框架

一、是在主 App 中,创建 Socket 链接,收到消息后,建立本地通知,显示礼物和弹幕,大部分直播应用采用这种方式比较多,对原来弹幕系统的改造比较小。ide

二、相似企鹅电竞的作法,经过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. 参考

  1. 腾讯云文档-游戏录屏(ReplayKit)
  2. replaykit2 直播踩坑总结
  3. iOS11 ReplayKit2 问题总结
相关文章
相关标签/搜索