iOS录屏在之前一直是个难题,但是在官方推出ReplayKit之后,iOS进行录屏方便了很多。
业务层面上,进行游戏直播,屏幕共享,远程协助等等。
而目前App Store中相关的App也一抓一大把,主要分为以下两类:
- 远程屏幕直播类
- 本地录屏保存类
具体的工程实现时,ReplayKit2采取了Extension子进程的方式,但是系统给了50M内存限制,一旦超过50M,录屏的子进程就会崩溃。
就是由于这个限制,业界相似的处理方案都会限制其视频质量不超过720P,或者视频帧数在30之内。例如腾讯的直播SDK。
为了解决此问题,戴着镣铐舞蹈。
我们首先来看看子进程中都做了什么:
@implementation SampleHandler
- (void)broadcastStartedWithSetupInfo:(NSDictionary *)setupInfo {
// User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional.
}
- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
switch (sampleBufferType) {
case RPSampleBufferTypeVideo:
// Handle video sample buffer
break;
case RPSampleBufferTypeAudioApp:
// Handle audio sample buffer for app audio
break;
case RPSampleBufferTypeAudioMic:
// Handle audio sample buffer for mic audio
break;
default:
break;
}
}
@end
重要函数只有两个:
-
broadcastStartedWithSetupInfo:(NSDictionary
*)setupInfo 子进程开启回调
-
processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType
视频/音频的数据回调
从函数中可以看出来,回调的数据类型为CMSampleBufferRef
,其本身几乎不占用内存。
但是我们将其转化为位图信息时,尤其是直接转化为二进制流信息时,才会相对耗费内存。
所以,为了保证内存消耗,我们的思路是,将子进程中的数据发送到主进程中,对于图像或者其他的操作都由主进程进行。
由此,我们引入了“进程通信”。
可以满足我们要求的,使子进程和主进程可以通信的方式有:
-
CFMachPort
iOS7之后不再可用
-
CFNotificationCenterRef
只能发送简单点的字符串数据。
如果发送复杂数据,对于数据组装要求较高。可以使用三方封装库MMWormhole实现。其原理是将数据归档到文件,然后在进程间发送文件标识,在接收端读取文件。效率比较低
-
Local Socket
在进程间建立本地Socket,进程TCP通信。
使用灵活,效率高。
我们使用
GCDAsyncSocket
实现,可以直接传输NSData数据流。
进程间通信的传输方式,我们最终决定使用本地Socket实现。
接下来我们需要考虑如何组装数据。
从系统API可以看到,回调函数中系统为我们提供的数据类型是CMSampleBufferRef
。
其实每一帧的视频数据,并且它是一种压缩过的,用于存储媒体文件属性的数据结构,它的组成部分如下:
CMTime:64位的value,32位的scale, media的时间格式
CMVideoFormatDesc:video的格式,包括宽高、颜色空间、编码格式、SPS、PPS
CVPixelBuffer: 包含未压缩的像素格式,宽高
CMBlockBuffer: 压缩的的图像数据
CMSampleBuffer: 存放一个或多个压缩或未压缩的媒体文件
如果可以将其发送到主进程再好不过,但是在不对其进行解码的情况下,目前还没有办法进行数据格式的转换,从而进行通信发送。
因此,进一步我们需要解决的问题的是,如何高效轻量的解码。
首先,直接转换成位图不可行,因为在比较大的屏幕分辨率下,每一帧都很吃内存。
所以,我们需要一种中间数据结构,来进行传输,它需要满足以下几个条件:
- 能够从
CMSampleBufferRef
中获取到图像信息,但是比imageData本身要轻量 - 从子进程传输到主进程后,可以将其还原为图片信息,并且可以再针对图片进行旋转,裁剪,压缩等操作
当然,解码的选择我们也有很多,无论是硬解,软解,YUV还是RGB。
但是无论怎样,我们都需要先解码。
曾经也想过,是否可以在CMSampleBufferRef
本身上直接进行图片压缩等操作,但是最后放弃了。
基于以上,我们最终参考了网易云通信屏幕共享的处理方式,使用了YUV解码,与它的NTESI420Frame
中间数据结构,来承载CMSampleBufferRef
,就像载波信号一样。
其转换源码如下:
+ (NTESI420Frame *)pixelBufferToI420:(CVImageBufferRef)pixelBuffer
withCrop:(float)cropRatio
targetSize:(CGSize)size
andOrientation:(NTESVideoPackOrientation)orientation
{
if (pixelBuffer == NULL) {
return nil;
}
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
OSType sourcePixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer);
size_t bufferWidth = 0;
size_t bufferHeight = 0;
size_t rowSize = 0;
uint8_t *pixel = NULL;
if (CVPixelBufferIsPlanar(pixelBuffer)) {
int basePlane = 0;
pixel = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, basePlane);
bufferHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, basePlane);
bufferWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, basePlane);
rowSize = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, basePlane);
} else {
pixel = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
bufferWidth = CVPixelBufferGetWidth(pixelBuffer);
bufferHeight = CVPixelBufferGetHeight(pixelBuffer);
rowSize = CVPixelBufferGetBytesPerRow(pixelBuffer);
}
NTESI420Frame *convertedI420Frame = [[NTESI420Frame alloc] initWithWidth:(int)bufferWidth height:(int)bufferHeight];
int error = -1;
if (kCVPixelFormatType_32BGRA == sourcePixelFormat) {
error = libyuv::ARGBToI420(
pixel, (int)rowSize,
[convertedI420Frame dataOfPlane:NTESI420FramePlaneY], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneY],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneU], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneU],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneV], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneV],
(int)bufferWidth, (int)bufferHeight);
} else if (kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange == sourcePixelFormat || kCVPixelFormatType_420YpCbCr8BiPlanarFullRange == sourcePixelFormat) {
error = libyuv::NV12ToI420(
pixel,
(int)rowSize,
(const uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1),
(int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1),
[convertedI420Frame dataOfPlane:NTESI420FramePlaneY],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneY],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneU],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneU],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneV],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneV],
(int)bufferWidth,
(int)bufferHeight);
}
if (error) {
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
NSLog(@"error convert pixel buffer to i420 with error %d", error);
return nil;
} else {
rowSize = [convertedI420Frame strideOfPlane:NTESI420FramePlaneY];
pixel = convertedI420Frame.data;
}
CMVideoDimensions inputDimens = { (int32_t)bufferWidth, (int32_t)bufferHeight };
CMVideoDimensions outputDimens = [NTESVideoUtil outputVideoDimensEnhanced:inputDimens crop:cropRatio];
// CMVideoDimensions outputDimens = {(int32_t)738,(int32_t)1312};
CMVideoDimensions sizeDimens = { (int32_t)size.width, (int32_t)size.height };
CMVideoDimensions targetDimens = [NTESVideoUtil outputVideoDimensEnhanced:sizeDimens crop:cropRatio];
int cropX = (inputDimens.width - outputDimens.width) / 2;
int cropY = (inputDimens.height - outputDimens.height) / 2;
if (cropX % 2) {
cropX += 1;
}
if (cropY % 2) {
cropY += 1;
}
float scale = targetDimens.width * 1.0 / outputDimens.width;
NTESI420Frame *croppedI420Frame = [[NTESI420Frame alloc] initWithWidth:outputDimens.width height:outputDimens.height];
error = libyuv::ConvertToI420(pixel, bufferHeight * rowSize * 1.5,
[croppedI420Frame dataOfPlane:NTESI420FramePlaneY], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneY],
[croppedI420Frame dataOfPlane:NTESI420FramePlaneU], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneU],
[croppedI420Frame dataOfPlane:NTESI420FramePlaneV], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneV],
cropX, cropY,
(int)bufferWidth, (int)bufferHeight,
croppedI420Frame.width, croppedI420Frame.height,
libyuv::kRotate0, libyuv::FOURCC_I420);
if (error) {
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
NSLog(@"error convert pixel buffer to i420 with error %d", error);
return nil;
}
NTESI420Frame *i420Frame;
if (scale == 1.0) {
i420Frame = croppedI420Frame;
} else {
int width = outputDimens.width * scale;
width &= 0xFFFFFFFE;
int height = outputDimens.height * scale;
height &= 0xFFFFFFFE;
i420Frame = [[NTESI420Frame alloc] initWithWidth:width height:height];
libyuv::I420Scale([croppedI420Frame dataOfPlane:NTESI420FramePlaneY], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneY],
[croppedI420Frame dataOfPlane:NTESI420FramePlaneU], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneU],
[croppedI420Frame dataOfPlane:NTESI420FramePlaneV], (int)[croppedI420Frame strideOfPlane:NTESI420FramePlaneV],
croppedI420Frame.width, croppedI420Frame.height,
[i420Frame dataOfPlane:NTESI420FramePlaneY], (int)[i420Frame strideOfPlane:NTESI420FramePlaneY],
[i420Frame dataOfPlane:NTESI420FramePlaneU], (int)[i420Frame strideOfPlane:NTESI420FramePlaneU],
[i420Frame dataOfPlane:NTESI420FramePlaneV], (int)[i420Frame strideOfPlane:NTESI420FramePlaneV],
i420Frame.width, i420Frame.height,
libyuv::kFilterBilinear);
}
int dstWidth, dstHeight;
libyuv::RotationModeEnum rotateMode = [NTESYUVConverter rotateMode:orientation];
if (rotateMode != libyuv::kRotateNone) {
if (rotateMode == libyuv::kRotate270 || rotateMode == libyuv::kRotate90) {
dstWidth = i420Frame.height;
dstHeight = i420Frame.width;
} else {
dstWidth = i420Frame.width;
dstHeight = i420Frame.height;
}
NTESI420Frame *rotatedI420Frame = [[NTESI420Frame alloc]initWithWidth:dstWidth height:dstHeight];
libyuv::I420Rotate([i420Frame dataOfPlane:NTESI420FramePlaneY], (int)[i420Frame strideOfPlane:NTESI420FramePlaneY],
[i420Frame dataOfPlane:NTESI420FramePlaneU], (int)[i420Frame strideOfPlane:NTESI420FramePlaneU],
[i420Frame dataOfPlane:NTESI420FramePlaneV], (int)[i420Frame strideOfPlane:NTESI420FramePlaneV],
[rotatedI420Frame dataOfPlane:NTESI420FramePlaneY], (int)[rotatedI420Frame strideOfPlane:NTESI420FramePlaneY],
[rotatedI420Frame dataOfPlane:NTESI420FramePlaneU], (int)[rotatedI420Frame strideOfPlane:NTESI420FramePlaneU],
[rotatedI420Frame dataOfPlane:NTESI420FramePlaneV], (int)[rotatedI420Frame strideOfPlane:NTESI420FramePlaneV],
i420Frame.width, i420Frame.height,
rotateMode);
i420Frame = rotatedI420Frame;
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
return i420Frame;
}
这个函数中,主要进行了针对原始图像信息的YUV解码,解码后再进行裁剪,压缩,旋转。
从代码量就能看出来,此函数对于我们来说很有很多冗余,我们的目的是尽可能减少子进程中的任何处理,以及内存使用,所以,我们只保留其解码功能,其他剔除,如下:
+ (NTESI420Frame *)pixelBufferToI420:(CVImageBufferRef)pixelBuffer {
if (pixelBuffer == NULL) {
return nil;
}
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
OSType sourcePixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer);
size_t bufferWidth = 0;
size_t bufferHeight = 0;
size_t rowSize = 0;
uint8_t *pixel = NULL;
if (CVPixelBufferIsPlanar(pixelBuffer)) {
int basePlane = 0;
pixel = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, basePlane);
bufferHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, basePlane);
bufferWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, basePlane);
rowSize = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, basePlane);
} else {
pixel = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
bufferWidth = CVPixelBufferGetWidth(pixelBuffer);
bufferHeight = CVPixelBufferGetHeight(pixelBuffer);
rowSize = CVPixelBufferGetBytesPerRow(pixelBuffer);
}
NTESI420Frame *convertedI420Frame = [[NTESI420Frame alloc] initWithWidth:(int)bufferWidth height:(int)bufferHeight];
int error = -1;
if (kCVPixelFormatType_32BGRA == sourcePixelFormat) {
error = libyuv::ARGBToI420(
pixel, (int)rowSize,
[convertedI420Frame dataOfPlane:NTESI420FramePlaneY], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneY],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneU], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneU],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneV], (int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneV],
(int)bufferWidth, (int)bufferHeight);
} else if (kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange == sourcePixelFormat || kCVPixelFormatType_420YpCbCr8BiPlanarFullRange == sourcePixelFormat) {
error = libyuv::NV12ToI420(
pixel,
(int)rowSize,
(const uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1),
(int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1),
[convertedI420Frame dataOfPlane:NTESI420FramePlaneY],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneY],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneU],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneU],
[convertedI420Frame dataOfPlane:NTESI420FramePlaneV],
(int)[convertedI420Frame strideOfPlane:NTESI420FramePlaneV],
(int)bufferWidth,
(int)bufferHeight);
}
if (error) {
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
NSLog(@"error convert pixel buffer to i420 with error %d", error);
return nil;
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
return convertedI420Frame;
}
我们现在有了中间的数据载体,接下来就要考虑如何进行传输。
进行Socket通信之前,我们需要对上面获取到的数据结构进行二进制转换,网易的源码如下:
//NTESI420Frame.m
- (NSData *)bytes {
int structSize = sizeof(self.width) + sizeof(self.height) + sizeof(self.i420DataLength) + sizeof(self.timetag);
void *buffer = malloc(structSize + self.i420DataLength);
memset(buffer, 0, structSize + self.i420DataLength);
int offset = 0;
memcpy(buffer + offset, &_width, sizeof(_width));
offset += sizeof(_width);
memcpy(buffer + offset, &_height, sizeof(_height));
offset += sizeof(_height);
memcpy(buffer + offset, &_i420DataLength, sizeof(_i420DataLength));
offset += sizeof(_i420DataLength);
memcpy(buffer + offset, &_timetag, sizeof(_timetag));
offset += sizeof(_timetag);
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneY], [self strideOfPlane:NTESI420FramePlaneY] * self.height);
offset += [self strideOfPlane:NTESI420FramePlaneY] * self.height;
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneU], [self strideOfPlane:NTESI420FramePlaneU] * self.height / 2);
offset += [self strideOfPlane:NTESI420FramePlaneU] * self.height / 2;
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneV], [self strideOfPlane:NTESI420FramePlaneV] * self.height / 2);
offset += [self strideOfPlane:NTESI420FramePlaneV] * self.height / 2;
NSData *data = [NSData dataWithBytes:buffer length:offset];
free(buffer);
return data;
}
从函数本身来看,没有任何问题,将数据结构中包含的所有信息打包成一个NSData二进制流,最后组成Socket的一帧进行发送就好。
但是,不要忘记,这些操作我们都是在子进程中进行的,在分辨率过高尺寸过大的设备上,一旦图片中信息本身就很丰富的情况下,CPU来不及处理释放这些临时变量时,依然很容易导致内存暴增,最终超过50M,导致录屏进程崩溃。
就像一条河,水量过大,流速太慢,河道本身太窄,都会导致河堤的崩溃。
因此,我们的处理方向可以集中在以下三点:
-
减少水流
a. 利用
NTESI420Frame
来承载图片信息,而不是位图本身的二进制流信息b. 减少临时变量的使用
c. 拆分数据,大数据拆开成小数据进行处理
-
加快流速
a. 加快子进程中处理信息速度,这一条是在“减少水流”的基础上,数据越小,处理越快
b. 加快进程通信间的传输速度。使用本地Socket,而不是CFNotificationCenterRef。
c. 多任务处理数据
d. 多通道传输数据
-
扩宽河道
由于系统限制50M,我们针对此条无法做处理。
基于以上,我们针对NTESI420Frame
的byte
方法进行了以下优化:
- (void)getBytesQueue:(void (^)(NSData *data,NSInteger index))complete {
int offset = 0;
{
int structSize = sizeof(self.width) + sizeof(self.height) + sizeof(self.i420DataLength) + sizeof(self.timetag);
void *buffer = malloc(structSize + self.i420DataLength);
memset(buffer, 0, structSize + self.i420DataLength);
memcpy(buffer + offset, &_width, sizeof(_width));
offset += sizeof(_width);
memcpy(buffer + offset, &_height, sizeof(_height));
offset += sizeof(_height);
memcpy(buffer + offset, &_i420DataLength, sizeof(_i420DataLength));
offset += sizeof(_i420DataLength);
memcpy(buffer + offset, &_timetag, sizeof(_timetag));
offset += sizeof(_timetag);
NSData *data = [NSData dataWithBytes:buffer length:offset];
if (complete) {
complete(data,0);
}
free(buffer);
data = NULL;
}
{
void *buffer = malloc([self strideOfPlane:NTESI420FramePlaneY] * self.height);
offset = 0;
memset(buffer, 0, [self strideOfPlane:NTESI420FramePlaneY] * self.height);
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneY], [self strideOfPlane:NTESI420FramePlaneY] * self.height);
offset += [self strideOfPlane:NTESI420FramePlaneY] * self.height;
NSData *data = [NSData dataWithBytes:buffer length:offset];
if (complete) {
complete(data,0);
}
free(buffer);
data = NULL;
}
{
void *buffer = malloc([self strideOfPlane:NTESI420FramePlaneU] * self.height / 2);
offset = 0;
memset(buffer, 0, [self strideOfPlane:NTESI420FramePlaneU] * self.height / 2);
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneU], [self strideOfPlane:NTESI420FramePlaneU] * self.height / 2);
offset += [self strideOfPlane:NTESI420FramePlaneU] * self.height / 2;
NSData *data = [NSData dataWithBytes:buffer length:offset];
if (complete) {
complete(data,1);
}
free(buffer);
data = NULL;
}
{
void *buffer = malloc([self strideOfPlane:NTESI420FramePlaneV] * self.height / 2);
offset = 0;
memset(buffer, 0, [self strideOfPlane:NTESI420FramePlaneV] * self.height / 2);
memcpy(buffer + offset, [self dataOfPlane:NTESI420FramePlaneV], [self strideOfPlane:NTESI420FramePlaneV] * self.height / 2);
offset += [self strideOfPlane:NTESI420FramePlaneV] * self.height / 2;
NSData *data = [NSData dataWithBytes:buffer length:offset];
if (complete) {
complete(data,2);
}
free(buffer);
data = NULL;
}
}
此函数在之前基础上,将一大块数据拆成了四份:
- 图片头部信息
- Y通道信息
- U通道信息
- V通道信息
转换一条数据,就发送一条,减少数据量,提高数据处理速度,尽快释放临时变量,保持内存值一直处于一个平均水平。
此时,数据已经准备好,接下来,在Socket传输中,我们如何组织它们呢?
由于以上的操作,我们将一张图片分成了四部分:
- 图片头部信息
- Y通道信息
- U通道信息
- V通道信息
我们从子进程中分别发送每一条数据到主进程,等到主进程收到一个完成图片信息时再进行后续处理。
虽然是分开发送的,但是我们需要将这四部分数据,在Socket传输中,组成一个完整的帧,这样子,主进程才能知道它得到了一张完整的图片信息。
因此,我们将这四部分数据分别发送之后,最后向子程序发送一个类似HTTP header的数据帧,告诉主进程一张图片信息结束。
- (void)sendVideoBufferToHostApp:(CMSampleBufferRef)sampleBuffer {
if (!self.socket) {
return;
}
if (self.frameCount > 0) {
//每次只处理1帧画面
return;
}
long curMem = [self getCurUsedMemory];
NSLog(@"curMem:%@", @(curMem / 1024.0 / 1024.0));
if (evenlyMem > 0
&& ((curMem - evenlyMem) > (5 * 1024 * 1024)
|| curMem > 45 * 1024 * 1024)) {
//当前内存暴增5M以上,或者总共超过45M,则不处理
return;
}
self.frameCount++;
CFRetain(sampleBuffer);
dispatch_async(self.videoQueue, ^{ // queue optimal
@autoreleasepool {
// To data
NTESI420Frame *videoFrame = [NTESYUVConverter pixelBufferToI420:CMSampleBufferGetImageBuffer(sampleBuffer)];
CFRelease(sampleBuffer);
// To Host App
if (videoFrame) {
__block NSUInteger length = 0;
[videoFrame getBytesQueue:^(NSData *data, NSInteger index) {
length += data.length;
[self.socket writeData:data withTimeout:5 tag:0];
}];
@autoreleasepool {
NSData *headerData = [NTESSocketPacket packetWithBufferLength:length];
[self.socket writeData:headerData withTimeout:5 tag:0];
}
}
};
if (self->evenlyMem <= 0) {
self->evenlyMem = [self getCurUsedMemory];
NSLog(@"平均内存:%@", @(self->evenlyMem));
}
self.frameCount--;
});
}
以上,基本解决了50M的系统限制问题。
测试机型:
- iPhone 5s
- iPhone 6s Plus
- iPhone 7
- iPad mini4
- iPad Air2
尽量使用图像复杂,变化快的方式进行暴力测试。
总结来看,依然是那条河的问题,映射到我们的计算机世界来看,就是处理数据过大,CPU处理不过来,内存释放不及时。
源码Demo可以参考:https://github.com/yifriday/ReplayKitDemo
Let's think!