在语音社交app开发中经常会出现卡顿的现象(丢帧),给用户的感觉很不好。那么这个现象是怎样产生的,如何检测到掉帧,要怎样去优化呢?本文将针对这几个问题进行分析
界面渲染流程
在语音社交app开发的界面的渲染过程中CPU和GPU起了比较重要的作用
CPU与GPU
CPU全名是Central Processing Unit(中央处理器),语音社交app开发在加载资源、对象的创建和销毁、对象属性的调整、布局计算、Autolayout、文本渲染,文本的计算和排版、图片格式转码和解码、图像的绘制(Core Graphics)时,都需要依赖CPU来执行
GPU全名是Graphics Processing Unit(图像处理器),它是一个专门为图形高并发计算而量身定做的处理单元,比CPU使用更少的电来完成工作并且GPU的浮点计算能力要超出CPU很多。
在语音社交app开发中,相对于CPU来说,GPU能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合(合成)并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。GPU的渲染性能要比CPU高效很多,同时对系统的负载和消耗也更低一些
在语音社交app开发中,我们应该尽量让CPU负责主线程的UI调动,把图形显示相关的工作交给GPU来处理,当涉及到光栅化等一些工作时,CPU也会参与进来。
渲染过程
在讲渲染原理之前先介绍下CRT显示器原理。
CRT的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。
当电子枪换行进行扫描时,显示器会发出一个 水平同步信号(horizonal synchronization),简称HSync;
而当语音社交app开发中一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个 垂直同步信号(vertical synchronization),简称VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。
CRT的电子枪扫描过程如下图所示:
虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。
界面渲染的流程如下:CPU计算 -> GPU渲染 -> 帧缓冲区 -> 视频控制器读取帧缓冲区的数据 -> 显示器,如下图:
双缓冲机制+VSync
如果GPU渲染后,因为某些原因没有存入语音社交app开发的帧缓冲区,这样就形成了等待,为了解决了这个问题,就产生了双缓冲机制,也就是前帧和后帧。
当GPU渲染完一帧后就会存入帧缓存区,然后视频控制器去读取缓冲帧缓存,同时GPU去渲染另一帧并存入另一个帧缓存区,这样来回的切换读取来显示界面,如下图:
这个切换也不是任意时间切的,我们常见的都是一秒渲染60帧,也就是VSync每隔16.67ms发送一次信号
所以,语音社交app开发中的视频控制器会按照VSync信号逐帧读取帧缓冲区的数据
卡顿
卡顿产生原理
我们知道VSync每隔16.67ms发送一次信号,两次发送信号之间cpu进行了计算,gpu渲染后存入帧缓冲区,那么如果计算的步骤花的时间比较长,那么存入帧缓存的渲染部分就比较少了,当视频控制器读取帧缓存时没有读全,此时语音社交app开发就产生了丢帧,也就是卡顿。
卡顿过程如下图:
卡顿检测
每秒渲染帧数用FPS(Frames Per Second)来表示,通常60fps为最佳,我们可以检测语音社交app开发的FPS来观察语音社交app开发是否流畅。
可以使用以下几种方式检测语音社交app开发的FPS:
CADisplayLink
语音社交app开发系统在每次发送VSync时,就会触发CADisplayLink,我们可以统计每秒发送VSync的数量来查看App的FPS是否稳定,主要代码如下:
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *link;
@property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间
@property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量
@end
self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkAction:)];
[_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
- (void)linkAction: (CADisplayLink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
return;
}
_count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
float fps = _count / delta;
_count = 0;
NSLog(@"🎈 fps : %f ", fps);
}
RunLoop
在 Runloop原理 中,我们详细的分析了Runloop,它的退出和进入实质都是Observer的通知,我们可以监听Runloop的状态,并在相关回调里发送信号,如果在语音社交app开发设定的时间内能够收到信号说明是流畅的;如果在设定的时间内没有收到信号,说明发生了卡顿。具体实现如下:
@interface WSBlockMonitor () {
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
- (void)registerObserver{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
//NSIntegerMax : 优先级最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
WSBlockMonitor *monitor = (__bridge WSBlockMonitor *)info;
monitor->activity = activity;
// 发送信号
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)startMonitor {
// 创建信号
_semaphore = dispatch_semaphore_create(0);
// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
if (st != 0)
{
if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting) // 即将处理sources,即将进入休眠
{
if (++self->_timeoutCount < 2) {
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
// 可在此处记录卡顿堆栈信息,进行排查
NSLog(@"检测到超过两次连续卡顿");
}
}
self->_timeoutCount = 0;
}
});
}
// 调用方法
- (void)start{
[self registerObserver];
[self startMonitor];
}
主要在主线程监听Runloop即将处理事物和即将休眠两种状态,子线程监控时长,如果连续两次1秒内没有收到信号,说明发生了卡顿,此时可以记录卡顿的堆栈以便于排查。
微信matrix
微信的matrix也是借助runloop实现的,大体流程上面Runloop相同,它使用退火算法优化捕获卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近的20个主线程堆栈信息,获取最近最耗时堆栈。所以需要准确的分析语音社交app开发卡顿原因可以借助微信matrix来分析卡顿。
滴滴DoraemonKit
DoraemonKit的卡顿检测方案并没有对runloop操作,它也是while循环中根据一定的状态判断,通过语音社交app开发的主线程中不断对发送信号semaphore,循环中等待信号的时间为5秒,等待超时则说明主线程卡顿,并进行相关上报。
优化方案
在检测到语音社交app开发卡顿后,接下来就应该去进行相关的优化,我们可以通过以下几种方案
预排版
在语音社交app开发中,布局我们通常选择用Masonry或SnapKit,他们都是基于AutoLayout来实现的,自动布局通常在赋值过后才决定UI控件的大小。而根据苹果的介绍,相对于AutoLayout,frame产生的消耗要小的多
例如在复杂结构的UITableViewCell中,赋值过后会产生UI控件的大小,如果cell比较多会进行频繁的计算,这样就会消耗性能。这种情况我们可以在请求完数据时,就计算好各个控件的Rect,然后在数据赋值时,也将各个控件的frame进行赋值,这样会大大减少计算。这个方案也叫做预排版,具体代码如下:
数据DataModel代码
@interface DataModel : NSObject
@property (nonatomic, strong) NSString *name;
@end
布局LayoutModel代码
// .h
@interface LayoutModel : NSObject
@property (nonatomic, assign) CGRect nameRect;
@property (nonatomic, strong) DataModel *data;
@property (nonatomic, assign) CGFloat height;
- (instancetype)initWithModel:(DataModel *)model; // 初始化代码
@end
// .m
- (instancetype)initWithModel:(DataModel *)model {
self = [super init];
if (self) {
self.data = model;
// 根据数据计算相关控件的size
CGSize size = [self getSizeWithContent:model.name];
self.nameRect = CGRectMake(15, 100, size.width, size.height);
self.height = 200; // 计算cell高度
}
return self;
}
- (CGSize)getSizeWithContent: (NSString *)content {
//根据文字计算size ...
return CGSizeMake(100, 40);
}
layoutModel初始化时,要传入对应的model,然后根据model中的相关字段计算相应的size,然后讲相应的rect赋值给layoutModel中的相关字段。
cell代码
// .h
- (void)configCellWithModel: (LayoutModel *)model;
// .m
@interface TestCell ()
@property (nonatomic, strong) UILabel *nameLbl;
@end
@implementation TestCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.nameLbl = [UILabel new];
[self.contentView addSubview:_nameLbl];
}
return self;
}
- (void)configCellWithModel:(LayoutModel *)model {
self.nameLbl.frame = model.nameRect; // frame 赋值
self.nameLbl.text = model.data.name; // 数据赋值
}
cell中的控件创建完后设置相关的颜色字体,然后在数据赋值时同时对frame进行赋值
VC代码
@property (nonatomic, strong) NSMutableArray<LayoutModel *> *dataSource;
// 模拟网络请求
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSDictionary *dataDic; // 网络请求数据
NSMutableArray<DataModel *> *modelArray = [NSMutableArray array];
for (NSDictionary *dic in dataDic[@"data"]) {
DataModel *model = [DataModel yyModel: dic]; // 相关的json转model方法
[modelArray addObject:model];
}
self.dataSource = [NSMutableArray arrayWithCapacity:modelArray.count];
for (DataModel *model in modelArray) {
LayoutModel *layout = [[LayoutModel alloc] initWithModel:model]; // 根据数据model,初始化layoutModel,并进行控件的layout计算
[self.dataSource addObject:layout];
}
// 计算完成后reloadData
dispatch_async(dispatch_get_main_queue(), ^{
[self.tableView reloadData];
});
});
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return _dataSource.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *identifier = @"cellID";
TestCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[TestCell alloc] initWithStyle:(UITableViewCellStyleDefault) reuseIdentifier:identifier];
}
[cell configCellWithModel:self.dataSource[indexPath.row]];
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
LayoutModel *model = self.dataSource[indexPath.row];
return model.height;
}
vc中主要是网络请求后创建数据model后,然后根据将dataModel创建layoutModel并进行相关计算,完成后再reloadData,这样就一次性将相关的布局计算好,滑动cell时只是进行赋值,无需额外的布局耗时计算
预解码&预渲染
预解码主要是对图像视频类进行优化,例如UIImage,它的加载流程如下:
Data Buffer(数据缓冲区)解码后缓存到Image Buffer(影响缓冲区),然后存入帧缓冲区再进行渲染。
语音社交app开发解码的过程是比较消耗资源的,所以可以将解码工作放到子线程,提前做好一些解码工作
图片解码具体的做法可以参考SDWebImage中的处理,而音视频的解码可以参考FFmpeg
按需加载
按需加载顾名思义就是语音社交app开发需要时再加载,例如TableView,在滑动时每出现一个cell就会走cellForRow里的赋值方法,有些cell刚出现后又马上在界面消失,像这种可以监听滑动的状态,当滑动停止时根据tableView的visibleCells获取当前可见cell,然后对这些cell进行赋值,这样也节省了很多的开销。
异步渲染
异步渲染就是在语音社交app开发中子线程把需要绘制的图形提前处理好,然后将处理好的图像数据直接返给主线程使用,这样可以降低主线程的压力。
异步渲染操作的是layer层,将展示的内容通过UIGraphics画成一张image然后展示在layer.content上
我们知道绘制会执行drawRect:方法,在方法中查看堆栈得知:
在堆栈中得知CALayer在调用display方法后回去调用绘制相关的方法,根据流程我们来实现一个简单的绘制:
// WSLyer.m
- (void)display {
// 创建context
CGContextRef context = (__bridge CGContextRef)[self.delegate performSelector:@selector(createContext)];
[self.delegate layerWillDraw:self]; // 绘制的准备工作
[self drawInContext:context]; //绘制
[self.delegate displayLayer:self]; // 展示位图
[self.delegate performSelector:@selector(closeContext)]; // 结束绘制
}
// WSView.m
- (void)drawRect:(CGRect)rect {
// Drawing code
}
+ (Class)layerClass {
return [WsLayer class];
}
// 创建context
- (CGContextRef)createContext {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
CGContextRef context = UIGraphicsGetCurrentContext();
return context;
}
- (void)layerWillDraw:(CALayer *)layer {
// 绘制的准备工作
}
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
[super drawLayer:layer inContext:ctx];
// 形状
CGContextMoveToPoint(ctx, self.bounds.size.width / 2- 20, 20);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 20, 20);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 40, 60);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 60);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20);
CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor);
CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边
CGContextDrawPath(ctx, kCGPathFillStroke);
// 文字
[@"无双" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 70, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:15],NSForegroundColorAttributeName: UIColor.blackColor}];
// 图片
[[UIImage imageNamed:@"buou"] drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 60, 50)];
}
// 主线程渲染
- (void)displayLayer:(CALayer *)layer {
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
layer.contents = (__bridge id)(image.CGImage);
});
}
// 结束context
- (void)closeContext {
UIGraphicsEndImageContext();
}
在VC中只需要添加view对象即可
// ViewController.m
WsView *view = [[WsView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
view.backgroundColor = UIColor.yellowColor;
[self.view addSubview:view];
运行结果和层级关系如下:
在语音社交app开发中异步渲染处理起来相对要复杂些,具体的实践可以参照其他异步渲染框架。