Smallfan

逝岁流年,流年无恙

有趣的技术,有趣的世界


浅析iOS UI方面一些机制&原理

本文系 Smallfan 原创内容,转载请在文章开头显眼处注明作者和出处。

一、从图像显示原理说起

1.1 屏幕刷新率

计算机图形学涵盖了太多关于软、硬件的知识,这里只简单阐述开发时所需要关心的一些基本机制。

首先需要明确一个概念,什么是屏幕刷新率,下面引用一段简介:

电子枪从屏幕的左上角的第一行(行的多少根据显示器当时的分辨率所决定,比如800X600分辨率下,电子枪就要扫描600行)开始,从左至右逐行扫描,第一行扫描完后再从第二行的最左端开始至第二行的最右端,一直到扫描完整个屏幕后再从屏幕的左上角开始,这时就完成了一次对屏幕的刷新,周而复始。这样我们就能够理解,为什么显示器的分辨率越高,其所能达到的刷新率最大值就越低。一般来讲,屏幕的刷新率要达到75HZ以上,人眼才不易感觉出屏幕的闪烁,CRT显示器的刷新率是由其行频和当时的分辨率决定的,行频越高,同一分辨率下的刷新率就越高;而行频一定的情况下,分辨率越高则它所能达到的刷新率越低。

不同的屏幕刷新率,对于人眼的直接影响如下:

对于传统显示器来讲,刷新频率越低,图像闪烁和抖动的就越厉害,眼睛疲劳得就越快。有时会引起眼睛酸痛,头晕目眩等症状。因为60Hz正好与日光灯的刷新频率相近,所以当显示器处于60Hz的刷新频率时会产生令人难受的频闪效应。而当采用70Hz以上的刷新频率时可基本消除闪烁。因此,70Hz的刷新频率是在显示器稳定工作时的最低要求。 [4] 此外还有一个常见的显示器性能参数是行频,即水平扫描频率,是指电子枪每秒在屏幕上扫描过的水平点数,以KHz为单位。它的值也是越大越好,至少要达到50KHz。

也就是说,对于一般显示器而言,垂直刷新率趋近或超过60Hz,人眼的适应性更强,在大脑中将可以呈现连续不断的影像记忆,这也是主流的计算机显示器的标准刷新频率。越高的屏幕刷新率,所带来的直接好处是越流畅的画面,这对于动画方面(尤其是游戏、电影领域)影响极大。目前iPhone 11 Pro的官方标定垂直刷新率为60Hz。

如上图,对于垂直同步信号Vsync每一次刷新所产生的静止画面,称为一“帧”。在iOS系统中,比如打开系统桌面,不进行任何屏幕操作,人眼所看到的其实就是多帧画面按照屏幕刷新率连续不断地呈现。以此可知,如果屏幕刷新率为60hz(60次一秒),那么每一帧的处理时间即为1s除以60HZ约等于16ms。如此,正常情况下,要达到屏幕动画运作顺畅,系统需要在每16ms产生一帧画面。

而对于某些情况下导致系统无法在16ms内产生下一帧的情况,便称之为“掉帧”,所带来的直接体验就是画面卡顿不流畅

1.2 帧画面的产生过程

针对iOS设备,帧画面的产生实际主要由CPU、GPU承担。

  • CPU主要负责:
  1. 对象的创建、销毁
  2. 布局计算(layout)
  3. 文本属性计算(如size)
  4. 图形绘制(如drawRect:)
  5. 图片编解码
  6. bitmap绘制、提交
  • GPU主要负责:
  1. 顶点着色
  2. 图元装配
  3. 光栅化
  4. 片段着色
  5. 片段处理

在完整的帧画过程,先是在VSync信号到来后,系统图形服务会通过CADisplayLink等机制通知App;App主线程开始在CPU创建UIKit中的视图对象并进行相应地布局计算以及图形绘制,产生出GPU所能识别的bitmap对象,经由总线传输到GPU进行下一步处理;GPU接收到bitmap对象后,通过OpenGL(ES)的API对图像进行图层渲染以及纹理合成,再将结果存放到帧缓冲区(Frame Buffer)中;而后视频控制器会根据Vsync信号,在指定时间内到帧缓冲区中获取内容,显示到显示器中。

1.3 为什么会卡顿

在前面已经介绍过什么是“掉帧”。根据对帧画面产生过程的了解,CPU+GPU对于每一帧的正常处理时长应该在16ms以内,如果在这个过程中,CPU或GRU处理任务过重导致时长超过16ms,在Vsync信号到来时无法取到对应的画面内容,在屏幕上的呈现就是丢失这一帧画面,所带来的直观体验就是卡顿,掉帧越多,卡顿越明显。而对于此过程中如何避免CPU或GPU处理超时,下面会针对不同场景及控件针对性讨论。

二、iOS应用的视图

了解了iOS图像显示原理,下一步便可对App视图进行分析,下面将以点带面的方式,从外到内谈起。

2.1 视图相关对象

对于视图相关的类,可以先看看如下继承结构:

可见UIApplication、UIView、UIViewController以及由UIView派生的UIWindow、UIViewController派生的UINavigationController,继承于UIResponder,而UIResponder主要负责对屏幕点击事件传递及响应链的管理,所以上述五个类具备对屏幕事件的处理能力;而UIScreen则维护一般性的屏幕属性(如width、height等)。

  • UIApplication: 代表一个应用程序,程序启动后创建的第一个对象,且为单例;可利用其进行如设置应用红色提醒数字(飘数)、UIStatus状态栏管理、openURL跨应用打开等
  • UIView: 代表一个视图,负责在屏幕上定义一个矩形区域,同时处理该区域的绘制和触屏事件。视图可以嵌套,也可以像图层一样进行叠加。
  • UIWindow: 继承自UIView,负责管理和协调应用程序的显示,但通常不直接操作UIWindow对象中与视图相关的属性变量。iOS程序启动完毕后,创建的第一个视图控件就是UIWindow(创建的第一个对象是 UIApplication),接着创建控制器的view,最后将控制器的view添加到UIWindow上(系统根据视图控制器容器或视图控制器,在addSubview与UIWindow.rootViewController后完成添加),于是控制器的view就显示在屏幕上了。一般情况下,应用程序只有一个UIWindow对象,即使有多个,也只有一个UIWindow可以接受到用户的触屏事件
  • UIViewController: 代表一个视图控制器,负责管理UIView实例的生命周期及资源的加载与释放、处理由于设备旋转导致的界面旋转、以及和用于构建复杂用户界面的高级导航对象(UINavigationController)进行交互。
  • UINavigationController: 视图控制器容器的一种,一个App可能有很多的UIViewController组成,这时需要一个容器来对这些UIViewController进行管理。大部分的容器也是一个UIViewController,如UINavigationController、UITabBarController,也有少数不是UIViewController,比如UIPopoverController等。通常新建的工程是直接将UIViewController添加到UIWindow上的,如果添加了UINavigationController则以后就多了一层,即将UIViewController添加到UINavigationController。
  • UIScreen: 负责维护一些关于屏幕的信息,通常用来获取屏幕尺寸等。

下图展示UIView、UIWindow、UIScreen、UIViewController之间的层级关系:

在每个应用的AppDelegate.m中,必须为App初始化一个默认的UIWindow对象,并为其指定rootViewController,以下是iOS13以前使用单场景的应用视图初始化流程

// 在iOS13以前
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    // 通过一个强引用创建UIWindow,设置Window的frame为屏幕的bounds
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    
    // 设置window背景色
    self.window.backgroundColor = [UIColor whiteColor];

    // 设置window的根控制器
    UIViewController *viewController = [[UIViewController alloc] init];
    self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:viewController];

    // 将window作为主窗口并且显示到界面上
    [self.window makeKeyAndVisible];
}

iOS13以后关于Scene Delegate,将择机另起篇幅讲述。

2.2 应用启动流程

作为视图加载的前奏,以一张图了解应用启动流程:

2.3 UIViewController生命周期

// 默认初始化
-[ViewController init];
// 从指定xib文件初始化
-[ViewController initWithNibName:bundle:];

// 加载视图(默认从nib),创建viewController的默认view
-[ViewController loadView];

// 视图加载完成,viewController自带的view加载完成,一般用来自定义视图,一般仅被调用一次
-[ViewController viewDidLoad];
// 视图控制器内存不足时,系统可能会释放viewController的view,将view赋值为nil,并且调用该方法
-[ViewController viewDidUnload];

// 视图将要出现
-[ViewController viewWillAppear:];
// view即将布局其Subviews,类似view的bounds改变情况(如:状态栏从不显示到显示,视图方向变化),要调整Subviews的位置即会被调用
-[ViewController viewWillLayoutSubviews:];
// view已经布局其Subviews
-[ViewController viewWillLayoutSubviews:];
// 视图已经出现
-[ViewController viewDidAppear:];

// 视图将要消失
-[ViewController viewWillDisappear:];
// 视图已经消失
-[ViewController viewDidDisappear:];

// 视图被销毁,ARC中请勿自行调用,原因详见内存管理部分
-[ViewController dealloc:];
// 视图控制器内存不足的警告 如果内存不够,一些没有正在显示的viewController就会收到内存不足的警告,然后就会释放自己拥有的视图,以达到释放内存的目的。但是系统只会释放内存,并不会释放对象的所有权,通常需要在这里将不需要显示在内存中保留的对象释放它的所有权,将其指针置nil
-[ViewController didReceiveMemoryWarning:];

三、解剖UIView

了解了iOS视图是由视图控制器+多个视图嵌套而成后,即可以对视图类UIView进行分析。

3.1 frame和bounds

UIView中关于位置和大小,有两个重要属性,下面逐一解释。

frame

代表的是当前视图的位置和大小,没有设置它时当前视图将不可见,frame的位置采用iOS特有的坐标系:

  • 在iOS坐标系中以左上角为坐标原点,往右为X正方向,往下是Y正方向
  • frame中的位置是以父视图的坐标系为标准来确定当前视图的位置
  • 同样的默认情况下,本视图的左上角就是子视图的坐标原点
  • 更改frame中位置,则当前视图的位置会发生改变
  • 更改frame的大小,则当前视图以当前视图的左上角为基准的进行大小的修改

bounds

同样代表位置和大小,每个视图都有自己的坐标系,且这个坐标系默认以自身的左上角为坐标原点,所有子视图以这个坐标系的原点为基准点。bounds的位置代表的是子视图看待当前视图左上角的位置;bounds的大小代表当前视图的大小。

  • 更改bounds中的位置对于当前视图没有影响,相当于更改了当前视图的坐标系,对于子视图来说当前视图的左上角已经不再是(0,0), 而是改变后的坐标,坐标系改了,那么所有子视图的位置也会跟着改变
  • 更改bounds的大小,bounds的大小代表当前视图的长和宽,修改长宽后,中心点继续保持不变, 长宽进行改变;通过bounds修改长宽看起来就像是以中心点为基准点对长宽两边同时进行缩放

下面使用代码分析frame和bounds的区别

    // viewA的frame位置为0,64,宽高为200,100
    UIView *viewA = [[UIView alloc] initWithFrame:CGRectMake(0, 64, 200, 100)];
    viewA.backgroundColor = [UIColor blueColor];
    [self.view addSubview:viewA];

    // viewB的frame位置为100,50,宽高为100,50
    UIView *viewB = [[UIView alloc] initWithFrame:CGRectMake(100, 50, 100, 50)];
    viewB.backgroundColor = [UIColor redColor];
    [viewA addSubview:viewB];

运行结果如下:

修改viewA的bounds后:

    UIView *viewA = [[UIView alloc] initWithFrame:CGRectMake(0, 64, 200, 100)];
    viewA.backgroundColor = [UIColor blueColor];
    [self.view addSubview:viewA];

    UIView *viewB = [[UIView alloc] initWithFrame:CGRectMake(100, 50, 100, 50)];
    viewB.backgroundColor = [UIColor redColor];
    [viewA addSubview:viewB];

    // 仅修改viewA的bounds位置,大小不变
    viewA.bounds = CGRectMake(100, 50, viewA.bounds.size.width, viewA.bounds.size.height);

运行结果如下:

可见:当viewA的bounds发生改变后,viewB看待viewA的左上角就已经发生改变了;此时viewB看待viewA的左上角就不是坐标原点了,而是通过bounds设置后的坐标也就是(100, 50)。

综上:

  1. frame不管对于位置还是大小,改变的都是自己本身
  2. frame的位置是以父视图的坐标系为参照,从而确定当前视图在父视图中的位置
  3. frame的大小改变时,当前视图的左上角位置不会发生改变,只是大小发生改变
  4. bounds改变位置时,改变的是子视图的位置,自身没有影响;其实就是改变了本身的坐标系原点,默认本身坐标系的原点是左上角
  5. bounds的大小改变时,当前视图的中心点不会发生改变,当前视图的大小发生改变,看起来效果就像缩放一样

3.2 UIView和CALayer

UIView: 包含CALayer,主要提供内容,以及负责处理触摸等事件,参与响应链(基于UIResponder)
CALayer: 包含contents部分,负责绘制和显示内容

UIView与CALayer的关系为:View持有Layer用于显示,View中大部分显示属性实际是从Layer映射而来;Layer的delegate是View,当其属性改变、动画产生时,View能够得到通知。UIView和CALayer不是线程安全的,并且只能在主线程创建、访问和销毁。

3.3 绘制原理

当编写好UIView中图形对象的创建及布局之后需要系统进行绘制时,首先需要先调用[UIView setNeedsDisplay]方法,而实质上该方法将调用[view.layer setNeedsDisplay]方法,此时系统会标记该layer;当主线程中当前Runloop即将结束时,调用[CALayer display]方法来进行绘制工作。

[CALayer display]方法被触发时,CALayer首先会创建一个称为当前视图的上下文的backing store(CGContextRef),会先判断当前[layer.delegate respondsToSelector:@selector(displayLayer:)]是否响应,如果是则进入异步绘制流程,否则调用[CALayer drawInContext:], 进入系统绘制流程

系统绘制(默认): 系统的UIView实际上实现了displayLayer:方法,但是它在里面又调用了[CALayer drawInContext:]方法,所以UIView走的还是CALayer的绘制。在UIView中完成以上方法后还会调用[UIView drawRect:]方法,支持重绘工作。

异步绘制: 是CPU性能优化部分常用手段之一,基于[layer.delegate drawLayer:inContext:]方法,该方法主要负责:

  • 代理生成对于的bitmap
  • 设置该bitmap为layer.contents属性的值

简略地异步绘制流程如下:

可见,当[layer.delegate drawLayer:inContext:]被调用之后,可以在Global queue并发队列中完成CoreGraphic的绘制工作,当绘制完成再由主线程负责后续工作。关于异步绘制,可详细了解YYAsyncLayer。

3.4 事件传递&响应链

对于用户手指对屏幕的操作事件,系统通过视图事件传递机制来找到响应此事件对应的视图。根据上述UIResponder功能阐述可知,UIView等视图类具备事件响应能力,关于事件传递机制,主要和两个方法有关:

// 当事件传递给控件的时候,就会调用控件的这个方法,去寻找最合适的view
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

// 判断当前这个点在不在方法调用者(控件)上
- (BOOL)pointInside(CGPoint)point withEvent:(UIEvent *)event;

当用户点击屏幕某一位置时,事件的传递流程如下:

从应用的最外层继承于UIResponder的视图类开始,一层层判断,对于subviews的判断,按照逆序也就是最后加入的最先判断原则。而对于每一个视图的具体判断流程如下:

对于每一个视图响应类,从UIWindow开始系统调用hitTest:withEvent:方法,首先判断当前视图(alpha>0.01 ishidden==false userInteractionEnabled=true)这些都成立,再调用其对象方法pointInside:withEvent:,判断当前这个点在不在方法调用者(控件)上;如果是则倒序遍历调用hitTest:withEvent:方法,递归执行,执行结果为nil时,即接收到事件的为当前视图,否则为对应返回视图。

事件完成传递以后,接下来需要确定由哪个视图来响应,这个将由视图自己来控制,相关方法如下:

// 一根或者多根手指开始触摸view
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指在view上移动
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指离开view
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event

UITouch对象: 当用户用一根手指触摸屏幕时,会创建一个与手指相关联的UITouch对象,一根手指对应一个UITouch对象。

UITouch的作用:

  • 保存着跟手指相关的信息,比如触摸的位置、时间、阶段
  • 当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置
  • 当手指离开屏幕时,系统会销毁相应的UITouch对象

UIEvent对象: 每产生一个事件,就会产生一个UIEvent对象,记录事件产生的时刻和类型

事件的产生和传递主要包含:

  1. 发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中
  2. UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口(keyWindow)
  3. 主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步
  4. 找到合适的视图控件后,就会调用视图控件的touches方法来作具体的事件处理
  5. 这些touches方法的默认做法是将事件顺着响应者链条向上传递(不实现touches方法,系统会自动向上一个响应者传递),将事件交给上一个响应者进行处理
  6. 如果一个事件既想自己处理也想交给上一个响应者处理,那么自己实现touches方法,并且调用[super touches…]

根据上述事件传递机制可知,事件一般会传递到最合适的(也就是事件传递子孙结点)视图;当该视图接收到事件后则通过touches方法来响应事件;如果不响应则会顺着响应链(也就是事件传递的逆序)向上传递,将事件交给上一个响应者处理;如果一直没有响应者处理,则事件在传递到UIApplicationDelegate后最终将被忽略。

四、性能分析&优化

根据上面对图像显示原理的了解可知“掉帧”的概念。而掉帧的实质是CPU或者GPU的处理时长过长,导致下一个VSync信号来临无法获取到需要显示的内容,这在页面滑动场景中会非常明显出现不流畅。据此,下面将具体分析下可能存在的处理超时的工作有哪些,以及对应的优化方案。

4.1 离屏渲染

在谈及优化方案之前,先来了解一个概念叫离屏渲染。这是GPU渲染过程中的一个概念,与之对应的称为在屏渲染。

  • 在屏渲染: 意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行
  • 离屏渲染: 指的是GPU的渲染操作是在当前屏幕缓冲区以外新开辟一个缓冲区进行

离屏渲染主要存在的场景:当指定某些图层属性在未被合成之前不能直接用于显示的,则触发离屏渲染方式。

离屏渲染触发操作:

  • 光栅化,layer.shouldRasterize = YES
  • 遮罩,layer.mask
  • 圆角,同时设置layer.masksToBounds = YES、layer.cornerRadius大于0
  • 阴影,layer.shadowXXX,注意:如果设置了layer.shadowPath就不会产生离屏渲染

而实际上,因为离屏渲染首先需要切换上下文环境到离屏工作中,创建新的渲染缓冲区,而后触发OpenGL多通道管线,最终把多通道渲染结果进行合成,这产生了大量的额外开销,导致GPU工作量增加,最终则可能导致掉帧,所以应尽量避免

4.2 CPU分析&优化

在上述图像显示原理中提到,CPU在UI方面主要负责:

  1. 对象的创建、销毁
  2. 布局计算(layout)
  3. 文本属性计算(如size)
  4. 图形绘制(如drawRect:)
  5. 图片编解码
  6. bitmap绘制、提交
  7. 事件响应

接下来可以针对性谈谈优化思路:

  • 对于对象的管理这部分,可以考虑将这个过程交由子线程来处理
  • 对于布局计算、文本计算等,交由子线程处理,此称预排版
  • 对于复杂图形采用异步绘制的方式,对于图片编解码及绘制交由子线程,此称预渲染

而在开发中实际上应该注意的点:

  • 不要频繁地调用UIView的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的修改
  • 尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用CALayer取代UIView
  • 尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性
  • 如果视图绘制超出GPU支持的4096x4096尺寸的纹理,就必须要用CPU在图层每次显示之前对图片预处理,尽量避免大图片
  • Autolayout会比直接设置frame消耗更多的CPU资源
  • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

4.3 GPU分析&优化

GPU主要负责:

  1. 纹理渲染
  2. 视图混合

针对性优化思路:

  • 对于纹理渲染部分,避免离屏渲染,同时采用CPU异步绘制机制
  • 对于视图混合部分,减少过多的视图数量和层次

开发中实际上应该注意的点:

  • 减少透明的视图(alpha<1),不透明的就设置opaque为YES
  • 尽量减少视图数量和层次,多用drawRect绘制元素,替代用view显示
  • 不要加载使用尺寸过大的图片
  • 尽量避免CALayer特效
  • 针对设置圆角,可使用以下两种方法
    1. 贝塞尔曲线UIBezierPath和Core Graphics框架
    2. CAShapeLayer和UIBezierPath(推荐)
  • shadow优化,可通过设置shadowPath来优化性能
  • 可以通过设置shouldRasterize属性值为YES来强制开启离屏渲染。其实就是光栅化(Rasterization)。当一个图像混合了多个图层,每次移动时,每一帧都要重新合成这些图层,十分消耗性能。开启光栅化后,会在首次产生一个位图缓存,当再次使用时候就会复用这个缓存。但是如果图层发生改变的时候就会重新产生位图缓存。所以这个功能一般不能用于UITableViewCell中,cell的复用反而降低了性能。最好用于图层较多的静态内容的图形。而且产生的位图缓存的大小是有限制的,一般是2.5个屏幕尺寸。在100ms之内不使用这个缓存,缓存也会被删除,因此需根据使用场景而定。

4.4 卡顿检测

一个应用的好坏很大程度上取决于使用是否流畅,所以明白造成应用卡顿的原因及解决思路显的至关重要。

检测的方案根据线程是否相关分为两大类:

  • 执行耗时任务会导致CPU短时间无法响应其他任务,检测任务耗时来判断是否可能导致卡顿
  • 由于卡顿直接表现为操作无响应,界面动画迟缓,检测主线程是否能响应任务来判断是否卡顿

其中有一个关键指标为FPS。FPS (Frames Per Second) 是图像领域中的定义,表示每秒渲染帧数,通常用于衡量画面的流畅度,每秒帧数越多,则表示画面越流畅,60fps 最佳,一般APP的FPS只要保持在50-60之间,用户体验都是比较流畅的。FPS监测是找出卡顿场景的常用前置手段。

4.4.1 FPS监测

通常情况下,屏幕会保持60hz/s的刷新速度,每次刷新时会发出一个屏幕刷新信号,CADisplayLink允许开发者注册一个与刷新信号同步的回调处理。可以通过屏幕刷新机制来展示fps值。

方法一

@implementation ViewController {
    CADisplayLink *_link;
    NSTimeInterval _lastTime;
    float _fps;
}

- (void)startMonitoring {
    if (_link) {
        [_link removeFromRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
        [_link invalidate];
        _link = nil;
    }
    _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsDisplayLinkAction:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)fpsDisplayLinkAction:(CADisplayLink *)link {
    if (_lastTime == 0) {
        _lastTime = link.timestamp;
        return;
    }
    
    self.count++;
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
    _lastTime = link.timestamp;
    _fps = _count / delta;
    NSLog(@"count = %d, delta = %f,_lastTime = %f, _fps = %.0f",_count, delta, _lastTime, _fps);
    self.count = 0;
    String fpsStr = [NSString stringWithFormat:@"FPS:%.0f",_fps];
}

分析:

  1. 每次刷新时会发出一个屏幕刷新信号,则与刷新信号同步的回调方法fpsDisplayLinkAction:会调用,然后count加一。
  2. 每隔一秒计算一次 fps 值,用一个变量_lastTime记录上一次计算 fps 值的时间,然后将 count 的值除以时间间隔,就得到了 fps 的值,在将_lastTime重新赋值,_count置成零。
  3. 正常情况下,屏幕会保持60hz/s的刷新速度,所以1秒内fpsDisplayLinkAction:方法会调用60次。fps 计算的值为0,就不卡顿,流畅。
  4. 如果1秒内fpsDisplayLinkAction:只回调了50次,计算出来的fps就是 _count / delta(时间间隔) 。

方法二

- (void)startFpsMonitoring {
    _link = [CADisplayLink displayLinkWithTarget: self selector: @selector(displayFps:)];
    [_link addToRunLoop: [NSRunLoop mainRunLoop] forMode: NSRunLoopCommonModes];
}

- (void)displayFps: (CADisplayLink *)fpsDisplay {
    self.count++;
    CFAbsoluteTime threshold = CFAbsoluteTimeGetCurrent() - _lastTime;
    if (threshold >= 1.0) {
        _fps = (_count / threshold);
        _lastTime = CFAbsoluteTimeGetCurrent();
        _fpsLbe.text = [NSString stringWithFormat:@"FPS:%.0f",_fps];
        self.count = 0;
        NSLog(@"count = %d,_lastTime = %f, _fps = %.0f",_count, _lastTime, _fps);
    }
}

4.4.2 RunLoop

通过RunLoop主要监控其状态来判断是否出现卡顿,需要监测的状态有两个:RunLoop在进入睡眠之前和唤醒后的两个loop状态定义的值,分别是kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting ,也就是要触发Source0回调和接收mach_port消息两个状态。

具体实现择机另作,暂不阐述。

五、聊聊UITableView

谈及滑动优化,不得不涵盖使用场景最多的UITableView。

当tableview加载的时候,会调用UITableView的UITableViewDelegate和UITableViewDataSource里面对应的方法:

// 首先:有三轮的调用下面的方法(声明:实现这些方法才会去调用)
// 第一轮:
numberOfSectionsInTableView: (调用一次)
tableView:heightForHeaderInSection: (调用两次)
tableView:heightForFooterInSection: (调用两次)
tableView:numberOfRowsInSection: (调用一次)
tableView:heightForRowAtIndexPath: (调用次数和这组的行数一样)
// 以上是一组cell的调用结果,如果有多组,会重复上面的步骤
// 第二轮和第三轮的调用结果和第一轮一样

// 紧接着:会调用以下方法(声明:实现这些方法才会去调用)
tableView:cellForRowAtIndexPath: (调用一次)
tableView:heightForRowAtIndexPath: (调用一次)
tableView:accessoryTypeForRowWithIndexPath:(调用一次)
tableView:canEditRowAtIndexPath:(调用一次)
tableView:willDisplayCell:forRowAtIndexPath:(调用一次)
// 以上这几个方法,会循环调用多次,调用的次数和和当前tableview里面可见的cell个数一样

tableView:viewForHeaderInSection:
tableView:titleForHeaderInSection:
tableView:willDisplayHeaderView:forSection:
tableView:viewForFooterInSection:
tableView:titleForFooterInSection:
tableView:willDisplayFooterView:forSection:
// 以上这几个方法,会循环调用多次,调用的次数和和当前tableview里面可见的header和footer个数一样

// 当cell有附加按钮(就是左滑出来的按钮),且cell是可编辑的:会调用以下方法(声明:实现这些方法才会去调用)
tableView:editingStyleForRowAtIndexPath:
tableView:canEditRowAtIndexPath:
tableView:editingStyleForRowAtIndexPath:
tableView:canEditRowAtIndexPath:
tableView:willBeginEditingRowAtIndexPath:
tableView:accessoryTypeForRowWithIndexPath:
tableView:canEditRowAtIndexPath:
tableView:editingStyleForRowAtIndexPath:
tableView:canMoveRowAtIndexPath:
tableView:titleForDeleteConfirmationButtonForRowAtIndexPath:

// 当点击cell有附加按钮(就是左滑出来的按钮)时:会调用以下方法(声明:实现这些方法才会去调用)
tableView:commitEditingStyle:forRowAtIndexPath:

// 当cell有附加按钮(就是左滑出来的按钮)消失的时候:会调用以下方法(声明:实现这些方法才会去调用)
tableView:didEndEditingRowAtIndexPath:
tableView:accessoryTypeForRowWithIndexPath:
tableView:canEditRowAtIndexPath:
tableView:didEndEditingRowAtIndexPath:

// 当首次点击cell时(tableview第一次显示的时候):会调用一下方法(声明:实现这些方法才会去调用)
tableView:shouldHighlightRowAtIndexPath:
tableView:didHighlightRowAtIndexPath:
tableView:didUnhighlightRowAtIndexPath:
tableView:willSelectRowAtIndexPath:
tableView:didSelectRowAtIndexPath:

// 当再次单击不同于当前的cell时:会调用以下方法(声明:实现这些方法才会去调用)
tableView:shouldHighlightRowAtIndexPath:
tableView:didHighlightRowAtIndexPath:
tableView:didUnhighlightRowAtIndexPath:
tableView:willSelectRowAtIndexPath:
tableView:willDeselectRowAtIndexPath:
tableView:willDeselectRowAtIndexPath:
tableView:didDeselectRowAtIndexPath:
tableView:didSelectRowAtIndexPath:

// 当长按cell并离开时:会调用以下方法(声明:实现这些方法才会去调用)
tableView:shouldHighlightRowAtIndexPath:
tableView:didHighlightRowAtIndexPath:
tableView:shouldShowMenuForRowAtIndexPath:
tableView:canPerformAction:forRowAtIndexPath:withSender:(会调用很多次)
tableView:didUnhighlightRowAtIndexPath:

5.1 cell复用

关于cell的复用,主要涉及以下两个方法:

- (id)dequeueReusableCellWithIdentifier:(NSString *)identifier;  
- (id)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(6_0);

在iOS 6中dequeueReusableCellWithIdentifier:被dequeueReusableCellWithIdentifier:forIndexPath:所取代。如此一来,在表格视图中创建并添加UITableViewCell对象会变得更为精简而流畅。而且使用dequeueReusableCellWithIdentifier:forIndexPath:一定会返回cell,系统在默认没有cell可复用的时候会自动创建一个新的cell出来。

使用dequeueReusableCellWithIdentifier:forIndexPath:的话,必须和下面的两个配套方法配合起来使用:

- (void)registerNib:(UINib *)nib forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(5_0);
- (void)registerClass:(Class)cellClass forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(6_0);
  1. 如果是用NIB自定义了一个Cell,那么就调用registerNib:forCellReuseIdentifier:
  2. 如果是用代码自定义了一个Cell,那么就调用registerClass:forCellReuseIdentifier:

以上这两个方法可以在创建UITableView的时候进行调用,这样在tableView:cellForRowAtIndexPath:方法中就可以省掉下面这些代码:

static NSString *CellIdentifier = @"Cell";  
if (cell == nil) {
    cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];  
}

取而代之:

UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

而cell在滑出屏幕进入可复用池时,可通过重写[UITableViewCell prepareForReuse]方法,进行内容reset及其他操作(如UIImage内容重新指派等)。注意这里重写方法的时候,一定要调用父类方法[super prepareForReuse]。

调用cellForRowAtIndexPath:的时候cell还没有被显示出来,为了提高效率应把数据绑定的操作放在cell显示出来后再执行,可以在tableView:willDisplayCell:forRowAtIndexPath:方法中绑定数据。但因willDisplayCell在cell在tableview展示之前就会调用,此时cell实例已经生成,所以不能更改cell的结构,只能是改动cell上的UI的一些属性(例如label的内容等)。

5.2 性能优化点

UITableView的性能优化,基本涵盖上述CPU、GPU的优化思路,以下是一些具体方法:

  1. cell复用
  2. cell渲染(预渲染、避免离屏渲染)

具体化一些就是:

  1. 当有图像时,预渲染图像,在bitmap context先将其画一遍,导出成UIImage对象,然后再绘制到屏幕,这会大大提高渲染速度
  2. 渲染最耗时操作之一是混合(blending),尽量不要使用透明背景,将cell的opaque值设为Yes,背景色不要使用clearColor,尽量不要使用阴影渐变等
  3. 由于混合操作是使用GPU来执行,可以用CPU来渲染,这样混合操作就不再执行。可以在UIView的drawRect方法中自定义绘制
  4. 减少subviews的个数和层级。子控件的层级越深,渲染到屏幕上所需要的计算量就越大;如多用drawRect绘制元素,替代用view显示
  5. 少用subviews的透明图层。对于不透明的View,设置opaque为YES,这样在绘制该View时,就不需要考虑被View覆盖的其他内容(尽量设置Cell的view为opaque,避免GPU对Cell下面的内容也进行绘制)
  6. 避免CALayer特效。给cell中View加阴影会引起性能问题,如下面代码会导致滚动时有明显的卡顿:
            view.layer.shadowColor= color.CGColor;
            view.layer.shadowOffset= offset;
            view.layer.shadowOpacity=1;
            view.layer.shadowRadius= radius;
    
  7. 在cell上添加系统控件的时候,实际上系统都会调用底层的接口进行绘制,大量添加控件时,会消耗很大的资源并且也会影响渲染的性能。当使用默认的UITableViewCell并且在它的contentView上面添加控件时会相当消耗性能。所以目前最佳的方法还是继承UITableViewCell,并重写drawRect方法
  8. 在实现drawRect方法的时候,它的参数rect即为需要绘制的区域,在rect范围之外不需要进行绘制,否则会消耗相当大的资源
  9. 不要给cell动态添加subView,在初始化cell的时候就将所有需要展示的添加完毕,然后根据需要来设置hide属性显示和隐藏
  10. 异步化UI,不要阻塞主线程
  11. 滑动时按需加载对应的内容

六、总结

篇幅较长超出预期,好多点只是简单分析,后续再进行专项讨论,未完待续。

更早的文章

基于 LocalWebServer 实现 WKWebView 离线资源加载

本文系 Smallfan 原创内容,转载请在文章开头显眼处注明作者和出处。背景笔者在《WKWebView》一文中提到过,WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol 无法拦截请求。所以如果需要使用到拦截请求,有种可行地方案是使用苹果开源的 Webkit2 源码暴露的私有API(详见原文第3小节:NSURLProtocol问题)。但使用私有API,必然带来以下几个问题: 审...…

iOS-Webview继续阅读