iOS开发底层探索界面优化示例详解
1、卡顿原理
1.1、界面显示原理
- CPU:Layout UI布局、文本计算、Display绘制、Prepare图片解码、Commit提交位图给 GPU
- GPU:用于渲染,将结果放入 FrameBuffer
- FrameBuffer:帧缓冲
- Video Controller:根据Vsync(垂直同步)信号,逐行读取 FrameBuffer 中的数据,经过数模转换传递给 Monitor
- Monitor:显示器,用于显示;对于显示模块来说,会按照手机刷新率以固定的频率:1 / 刷新率 向 FrameBuffer 索要数据,这个索要数据的命令就是 垂直同步信号Vsync(低刷60帧为16.67毫秒,高刷120帧为 8.33毫秒,下边举例主要以低刷16.67毫秒为主)
1.2、界面撕裂
显示端每16.67ms从 FrameBuffer(帧缓存区)读取一帧数据,如果遇到耗时操作交付不了,那么当前画面就还是旧一帧的画面,但显示过程中,下一帧数据准备完毕,导致部分显示的又是新数据,这样就会造成屏幕撕裂
1.3、界面卡顿
为了解决界面撕裂,苹果使用双缓冲机制 + 垂直同步信号,使用 2个FrameBuffer 存储 GPU 处理结果,显示端交替从这2个FrameBuffer中读取数据,一个被读取时另一个去缓存;但解决界面撕裂的问题也带来了新的问题:掉帧
如果遇到画面带马赛克等情况,导致GPU渲染能力跟不上,会有2种掉帧情况;
如图,FrameBuffer2 未渲染完第2帧,下一个16.67ms去 FrameBuffer1 中拿第3帧:
- 掉帧情况1:第3帧渲染完毕,接下来需要第4帧,第2帧被丢弃
- 掉帧情况2:第3帧未渲染完,再一个16.67ms去 FrameBuffer2 拿到第2帧,但第1帧多停留了16.67*2毫秒
小结
固定的时间间隔会收到垂直同步信号(Vsync),如果 CPU 和 GPU 还没有将下一帧数据放到对应的帧 FrameBuffer缓冲区,就会出现 掉帧
2、卡顿检测
2.1、CADisplayLink
系统在每次发送 VSync 时,就会触发CADisplayLink,通过统计每秒发送 VSync 的数量来查看 App 的 FPS 是否稳定
#import "ViewController.h"
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *link;
@property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间
@property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
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);
}
@end
2.2、RunLoop检测
RunLoop 的退出和进入实质都是Observer的通知,我们可以监听Runloop的状态,并在相关回调里发送信号,如果在设定的时间内能够收到信号说明是流畅的;如果在设定的时间内没有收到信号,说明发生了卡顿。
#import "LZBlockMonitor.h"
@interface LZBlockMonitor (){
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
@implementation LZBlockMonitor
+ (instancetype)sharedInstance {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (void)start{
[self registerObserver];
[self startMonitor];
}
- (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)
{
LZBlockMonitor *monitor = (__bridge LZBlockMonitor *)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)
{
if (++self->_timeoutCount < 2){
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
NSLog(@"检测到超过两次连续卡顿");
}
}
self->_timeoutCount = 0;
}
});
}
@end
- 主线程监听 kCFRunLoopBeforeSources(即将处理事件)和kCFRunLoopAfterWaiting(即将休眠),子线程监控时长,若连续两次 1秒 内没有收到信号,说明发生了卡顿
2.3、微信matrix
- 微信的matrix也是借助 runloop 实现,大体流程与上面 Runloop 方式相同,它使用退火算法优化捕获卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近的20个主线程堆栈信息,获取最近最耗时堆栈
2.4、滴滴DoraemonKit
- DoraemonKit的卡顿检测方案不使用 RunLoop,它也是while循环中根据一定的状态判断,通过主线程中不断发送信号semaphore,循环中等待信号的时间为5秒,等待超时则说明主线程卡顿,并进行相关上报
3、优化方法
平时简单的方案有:
- 避免使用 透明UIView
- 尽量使用PNG图片
- 避免离屏渲染(圆角使用贝塞尔曲线等)
3.1、预排版
- 就是常规的在Model层请求数据后提前将cell高度算好
3.2、预编码 / 解码
UIImage 是一个Model,二进制流数据 存储在DataBuffer中,经过decode解码,加载到imageBuffer中,最终进入FrameBuffer才能被渲染
- 当使用 UIImage 或CGImageSource的方法创建图片时,图片的数据不会立即解码,而是在设置UIImageView.image时解码
- 将图片设置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU渲染前,CGImage中的数据才进行解码
- 如果任由系统处理,这一步则无法避免,并且会发生在主线程中。如果想避免这个机制,在子线程先将图片绘制到CGBitmapContext,然后从Bitmap中创建图片
3.3、按需加载
如果目标行与当前行相差超过指定行数,只加载目标滚动范围的前后指定3行
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
[needLoadArr removeAllObjects];
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject];
NSInteger skipCount = 8;
if (labs(cip.row-ip.row)>skipCount) {
NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)];
NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
if (velocity.y<0) {
NSIndexPath *indexPath = [temp lastObject];
if (indexPath.row+3<datas.count) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]];
}
} else {
NSIndexPath *indexPath = [temp firstObject];
if (indexPath.row>3) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]];
}
}
[needLoadArr addObjectsFromArray:arr];
}
}
在滑动结束时进行 Cell 的渲染
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView{
scrollToToping = YES;
return YES;
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView{
scrollToToping = NO;
[self loadContent];
}
- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView{
scrollToToping = NO;
[self loadContent];
}
//用户触摸时第一时间加载内容
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
if (!scrollToToping) {
[needLoadArr removeAllObjects];
[self loadContent];
}
return [super hitTest:point withEvent:event];
}
- (void)loadContent{
if (scrollToToping) {
return;
}
if (self.indexPathsForVisibleRows.count<=0) {
return;
}
if (self.visibleCells && self.visibleCells.count>0) {
for (id temp in [self.visibleCells copy]) {
VVeboTableViewCell *cell = (VVeboTableViewCell *)temp;
[cell draw];
}
}
}
- 这种方式会导致滑动时有空白内容,因此要做好占位内容
3.4、异步渲染
- 异步渲染 就是在子线程把需要绘制的图形提前处理好,然后将处理好的图像数据直接返给主线程使用
- 异步渲染操作的是layer层,将多层堆叠的控件们通过UIGraphics画成一张位图,然后展示在layer.content上
3.4.1、CALayer
- CALayer基于CoreAnimation进而基于QuartzCode,只负责显示,且显示的是位图,不能处理用户的触摸事件
- 不需要与用户交互时,使用 UIView 和 CALayer 都可以,甚至 CALayer 更简洁高效
3.4.2、异步渲染实现
- 异步渲染的框架推荐:Graver、YYAsyncLayer
- CALayer 在调用display方法后回去调用绘制相关的方法,绘制会执行drawRect:方法
简单例子
继承 CALayer
#import "LZLayer.h"
@implementation LZLayer
//前面断点调用写下的代码
- (void)layoutSublayers{
if (self.delegate && [self.delegate respondsToSelector:@selector(layoutSublayersOfLayer:)]) {
//UIView
[self.delegate layoutSublayersOfLayer:self];
}else{
[super layoutSublayers];
}
}
//绘制流程的发起函数
- (void)display{
// Graver 实现思路
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)];
}
@end
继承 UIView
// - (CGContextRef)createContext 和 - (void)closeContext要在.h中声明
#import "LZView.h"
#import "LZLayer.h"
@implementation LZView
- (void)drawRect:(CGRect)rect {
// Drawing code, 绘制的操作, BackingStore(额外的存储区域产于的) -- GPU
}
//子视图的布局
- (void)layoutSubviews{
[super layoutSubviews];
}
+ (Class)layerClass{
return [LZLayer class];
}
//
- (void)layoutSublayersOfLayer:(CALayer *)layer{
[super layoutSublayersOfLayer:layer];
[self layoutSubviews];
}
- (CGContextRef)createContext{
UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
CGContextRef context = UIGraphicsGetCurrentContext();
return context;
}
- (void)layerWillDraw:(CALayer *)layer{
//绘制的准备工作,do nontihing
}
//绘制的操作
- (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, 80);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 100);
CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20);
CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor);
CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边
CGContextDrawPath(ctx, kCGPathFillStroke);
// 画个红色方块
[[UIColor redColor] set];
//Core Graphics
UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(self.bounds.size.width / 2- 20, self.bounds.size.height / 2- 20, 40, 40)];
CGContextAddPath(ctx, path.CGPath);
CGContextFillPath(ctx);
// 文字
[@"LZ" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:20],NSForegroundColorAttributeName: UIColor.blueColor}];
// 图片
[[UIImage imageWithContentsOfFile:@"/Volumes/Disk_D/test code/Test/Test/yasuo.png"] drawInRect:CGRectMake(10, self.bounds.size.height/2, self.bounds.size.width - 20, self.bounds.size.height/2 -10)];
}
//layer.contents = (位图)
- (void)displayLayer:(CALayer *)layer{
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
layer.contents = (__bridge id)(image.CGImage);
});
}
- (void)closeContext{
UIGraphicsEndImageContext();
}
控件们被绘制成了一张图
此外,虽然将控件画到一张位图上,但是还有问题,就是控件的交互事件,内容较多建议钻研一下graver的源码
以上就是iOS开发底层探索界面优化示例详解的详细内容,更多关于iOS开发界面优化的资料请关注编程网其它相关文章!
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341