iOS 底层原理:界面优化

界面优化无非就是解决卡顿问,优化界面流畅度,以下就通过先分析卡顿的原因,然后再介绍具体的优化方案,来分析如何做界面优化

  • 界面渲染流程

    具体流程可以参考图片渲染初探[1]这里就大概讲一下图片渲染的流程,大体上可以分为三个阶段就是 CPU处理阶段 GPU处理阶段和视频控制器显示阶段。

    大致流程图解如下:

    图片

    苹果为了解决图片撕裂的问题使用了 VSync + 双缓冲区的形式,就是显示器显示完成一帧的渲染的时候会向 发送一个垂直信号 VSync,收到这个这个垂直信号之后显示器开始读取另外一个帧缓冲区中的数据而 App接到垂直信号之后开始新一帧的渲染。

  1. CPU主要是计算出需要渲染的模型数据
  2. GPU主要是根据 CPU提供的渲染模型数据渲染图片然后存到帧缓冲区
  3. 视频控制器冲帧缓冲区中读取数据最后成像
  • 卡顿原理

    通过上文张的界面渲染流程知道,在图一帧渲染完成之后会发送一个垂直信号此时开始读取另外一个帧缓冲区中的数据,加入此时 CPUGPU的工作还没有完成,也就是另外一个帧缓冲区还是加锁状态没有数据的时候,此时显示器显示的还是上一帧的图像那么这种情况就会一直等待下一帧绘制完成然后视频控制器再读取另外一个帧缓冲区中的数据然后成像,中间这个等待的过程就造成了掉帧,也就是会卡顿。
    卡顿图解如下:

    图片

    这种情况随会造成卡顿

  • 卡顿检测

  1. FPS监控

苹果的iPhone推荐的刷新率是60Hz,也就是每秒中刷新屏幕60次,也就是每秒中有60帧渲染完成,差不多每帧渲染的时间是1000/60 = 16.67毫秒整个界面会比较流畅,一般刷新率低于45Hz的就会出现明显的卡顿现象。这里可以通过YYFPSLabel来实现FPS的监控,该原理主要是依靠 CADisplayLink来实现的,通过CADisplayLink来监听每次屏幕刷新并获取屏幕刷新的时间,然后使用次数(也就是1)除以每次刷新的时间间隔得到FPS,具体源码如下:

#import "YYFPSLabel.h"
#import "YYKit.h"

#define kSize CGSizeMake(55, 20)

@implementation YYFPSLabel {
  CADisplayLink *_link;
  NSUInteger _count;
  NSTimeInterval _lastTime;
  UIFont *_font;
  UIFont *_subFont;

  NSTimeInterval _llll;
}

- (instancetype)initWithFrame:(CGRect)frame {
  if (frame.size.width == 0 && frame.size.height == 0) {
      frame.size = kSize;
  }
  self = [super initWithFrame:frame];

  self.layer.cornerRadius = 5;
  self.clipsToBounds = YES;
  self.textAlignment = NSTextAlignmentCenter;
  self.userInteractionEnabled = NO;
  self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];

  _font = [UIFont fontWithName:@"Menlo" size:14];
  if (_font) {
      _subFont = [UIFont fontWithName:@"Menlo" size:4];
  } else {
      _font = [UIFont fontWithName:@"Courier" size:14];
      _subFont = [UIFont fontWithName:@"Courier" size:4];
  }

  //YYWeakProxy 这里使用了虚拟类来解决强引用问题
  _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
  [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
  return self;
}

- (void)dealloc {
  [_link invalidate];
}

- (CGSize)sizeThatFits:(CGSize)size {
  return kSize;
}

- (void)tick:(CADisplayLink *)link {
  if (_lastTime == 0) {
      _lastTime = link.timestamp;
      NSLog(@"sdf");
      return;
  }

  //次数
  _count++;
  //时间
  NSTimeInterval delta = link.timestamp - _lastTime;
  if (delta < 1) return;
  _lastTime = link.timestamp;
  float fps = _count / delta;
  _count = 0;

  CGFloat progress = fps / 60.0;
  UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];

  NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
  [text setColor:color range:NSMakeRange(0, text.length - 3)];
  [text setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
  text.font = _font;
  [text setFont:_subFont range:NSMakeRange(text.length - 4, 1)];

  self.attributedText = text;
}

@end

FPS只用在开发阶段的辅助性的数值,因为他会频繁唤醒 runloop如果 runloop在闲置的状态被 CADisplayLink唤醒则会消耗性能。

  1. 通过RunLoop检测卡顿

通过监听主线程 Runloop一次循环的时间来判断是否卡顿,这里需要配合使用 GCD的信号量来实现,设置初始化信号量为0,然后开一个子线程等待信号量的触发,也是就是在子线程的方法里面调用 dispatch_semaphore_wait方法设置等待时间是1秒,然后主线程的 RunloopObserver回调方法中发送信号也就是调用 dispatch_semaphore_signal方法,此时时间可以置为0了,如果是等待时间超时则看此时的 Runloop的状态是否是 kCFRunLoopBeforeSources或者是 kCFRunLoopAfterWaiting,如果在这两个状态下两秒则说明有卡顿,详细代码如下:(代码中也有相关的注释)

#import "LGBlockMonitor.h"

@interface LGBlockMonitor (){
  CFRunLoopActivity activity;
}

@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;

@end

@implementation LGBlockMonitor

+ (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];
}

static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
  LGBlockMonitor *monitor = (__bridge LGBlockMonitor *)info;
  monitor->activity = activity;
  // 发送信号
  dispatch_semaphore_t semaphore = monitor->_semaphore;
  dispatch_semaphore_signal(semaphore);
}

- (void)registerObserver{
  CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
  //NSIntegerMax : 优先级最小
  CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                          kCFRunLoopAllActivities,
                                                          YES,
                                                          NSIntegerMax,
                                                          &CallBack,
                                                          &context);
  CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}

- (void)startMonitor{
  // 创建信号c
  _semaphore = dispatch_semaphore_create(0);
  // 在子线程监控时长
  dispatch_async(dispatch_get_global_queue(0, 0), ^{
      while (YES)
      {
          // 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
          // 没有接收到信号底层会先对信号量进行减减操作,此时信号量就变成负数
          // 所以开始进入等到,等达到了等待时间还没有收到信号则进行加加操作复原信号量
          // 执行进入等待的方法dispatch_semaphore_wait会返回非0的数
          // 收到信号的时候此时信号量是1  底层是减减操作,此时刚好等于0 所以直接返回0
          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)
              {
                  //如果一直处于处理source0或者接受mach_port的状态则说明runloop的这次循环还没有完成
                  if (++self->_timeoutCount < 2){
                      NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
                      continue;
                  }
                  // 如果超过两秒则说明卡顿了
                  // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
                  NSLog(@"检测到超过两次连续卡顿");
              }
          }
          self->_timeoutCount = 0;
      }
  });
}

@end
  1. 微信matrix

此方案也是借助 runloop实现的大体流程和方案三相同,不过微信加入了堆栈分析,能够定位到耗时的方法调用堆栈,所以需要准确的分析卡顿原因可以借助微信matrix来分析卡顿。当然也可以在方案2中使用 PLCrashReporter这个开源的第三方库来获取堆栈信息

  1. 滴滴DoraemonKit

实现方案大概就是在子线程中一直 ping主线程,在主线程卡顿的情况下,会出现断在的无响应的表现,进而检测卡顿

  • 优化方案

    上文中分析卡顿的原因我们知道主要就是在 CPUGPU阶段占用时间太长导致了掉帧卡顿,所以界面优化主要工作就是给 CPUGPU减负

  • 预排版

    预排版主要是对 CPU进行减负。
    假设现在又个 TableView其中需要根据每个 cell的内容来定 cell的高度。我们知道 TableView有重用机制,如果复用池中有数据,即将滑入屏内的 cell就会使用复用池内的 cell,做到节省资源,但是还是要根据新数据的内容来计算 cell的高度,重新布局新 cell中内容的布局 ,这样反复滑动 TableView相同的 cell就会反复计算其 frame,这样也给 CPU带来了负担。如果在得到数据创建模型的时候就把 cell frame算出,TableView返回模型中的 frame这样的话同样的一条 cell就算来回反复滑动 TableView,计算 frame这个操作也就仅仅只会执行一次,所以也就做到了减负的功能,如下图:一个 cell的组成需要 modal找到数据,也需要 layout找到这个 cell如何布局:

    图片

  • 预解码 & 预渲染

    图片的渲染流程,在 CPU阶段拿到图片的顶点数据和纹理之后会进行解码生产位图,然后传递到 GPU进行渲染主要流程图如下

    图片

    如果图片很多很大的情况下解码工作就会占用主线程 RunLoop导致其他工作无法执行比如滑动,这样就会造成卡顿现象,所以这里就可以将解码的工作放到异步线程中不占用主线程,可能有人会想只要将图片加载放到异步线程中在异步线程中生成一个 UIImage或者是 CGImage然后再主线程中设置给 UIImageView,此时可以写段代码使用 instrumentsTime Profiler查看一下堆栈信息

    图片

    发现图片的编解码还是在主线程。针对这种问题常见的做法是在子线程中先将图片绘制到CGBitmapContext,然后从Bitmap 直接创建图片,例如SDWebImage三方框架中对图片编解码的处理。这就是Image的预解码,代码如下:

dispatch_async(queue, ^{
    CGImageRef cgImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:self]]].CGImage;
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;

    BOOL hasAlpha = NO;
    if (alphaInfo == kCGImageAlphaPremultipliedLast ||
        alphaInfo == kCGImageAlphaPremultipliedFirst ||
        alphaInfo == kCGImageAlphaLast ||
        alphaInfo == kCGImageAlphaFirst) {
        hasAlpha = YES;
    }

    CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
    bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;

    size_t width = CGImageGetWidth(cgImage);
    size_t height = CGImageGetHeight(cgImage);

    CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
    cgImage = CGBitmapContextCreateImage(context);

    UIImage * image = [[UIImage imageWithCGImage:cgImage] cornerRadius:width * 0.5];
    CGContextRelease(context);
    CGImageRelease(cgImage);
    completion(image);
   });
  • 按需加载

    顾名思义需要显示的加载出来,不需要显示的加载,例如 TableView中的图片滑动的时候不加载,在滑动停止的时候加载(可以使用Runloop,图片绘制设置 defaultModal就行)

  • 异步渲染

    再说异步渲染之前先了解一下 UIViewCALayer的关系:

  1. UIView是基于 UIKit框架的,能够接受点击事件,处理用户的触摸事件,并管理子视图
  2. CALayer是基于 CoreAnimation,而CoreAnimation是基于QuartzCode的。所以CALayer只负责显示,不能处理用户的触摸事件
  3. UIView是直接继承 UIResponder的,CALayer是继承 NSObject
  4. UIVIew 的主要职责是负责接收并响应事件;而 CALayer 的主要职责是负责显示 UIUIView 依赖于 CALayer 得以显示

总结:UIView主要负责时间处理,CALayer主要是视图显示 异步渲染的原理其实也就是在子线程将所有的视图绘制成一张位图,然后回到主线程赋值给 layercontents,例如 Graver框架的异步渲染流程如下:

图片

核心源码如下:

if (drawingFinished && targetDrawingCount == layer.drawingCount)
{
  CGImageRef CGImage = context ? CGBitmapContextCreateImage(context) : NULL;
  {
      // 让 UIImage 进行内存管理
      // 最终生成的位图  
      UIImage *image = CGImage ? [UIImage imageWithCGImage:CGImage] : nil;
      void (^finishBlock)(void) = ^{
          // 由于block可能在下一runloop执行,再进行一次检查
          if (targetDrawingCount != layer.drawingCount)
          {
              failedBlock();
              return;
          }
          //主线程中赋值完成显示
          layer.contents = (id)image.CGImage;
          // ...
      }
      if (drawInBackground) dispatch_async(dispatch_get_main_queue(), finishBlock);
      else finishBlock();
  }

  // 一些清理工作: release CGImageRef, Image context ending
}

最终效果图如下:


图片

也可以使用 YYAsyncLayer

  • 其他
  1. 减少图层的层级
  2. 减少离屏渲染
  3. 图片显示的话图片的大小设置(不要太大)
  4. 少使用addViewcell动态添加view
  5. 尽量避免使用透明view,因为使用透明view,会导致在GPU中计算像素时,会将透明view下层图层的像素也计算进来,即颜色混合处理(当有两个图层的时候一个是半透明一个是不透明如果半透明的层级更高的话此时就会触发颜色混合,底层的混合并不是仅仅的将两个图层叠加而是会将两股颜色混合计算出新的色值显示在屏幕中)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,919评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,567评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,316评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,294评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,318评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,245评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,120评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,964评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,376评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,592评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,764评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,460评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,070评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,697评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,846评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,819评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,665评论 2 354

推荐阅读更多精彩内容