iOS拾遗——为何必须在主线程操做UI

在开发过程当中,咱们或多或少会不经意在后台线程中调用了UIKit框架的内容,多是在网络回调时直接imageView.image = anImage,也有多是不当心在后台线程中调用了UIApplication.sharedApplication。而这个时候编译器会报出一个runtime错误,咱们也会迅速的对其进行修正。html

但仔细去思考,究竟为何必定要在主线程操做UI呢?若是在后台线程对UI进行操做会发生什么?在后台线程对UI进行操做不是能够更好的避免卡顿吗?这篇文章就是基于这样一些疑问而产生的。node

太长不看版:ios

UIKit并非一个 线程安全 的类,UI操做涉及到渲染访问各类View对象的属性,若是异步操做下会存在读写问题,而为其加锁则会耗费大量资源并拖慢运行速度。另外一方面由于整个程序的起点UIApplication是在主线程进行初始化,全部的用户事件都是在主线程上进行传递(如点击、拖动),因此view只能在主线程上才能对事件进行响应。而在渲染方面因为图像的渲染须要以60帧的刷新率在屏幕上 同时 更新,在非主线程异步化的状况下没法肯定这个处理过程可以实现同步更新。git


从UIKit线程不安全提及

在UIKit中,不少类中大部分的属性都被修饰为nonatomic,这意味着它们不能在多线程的环境下工做,而对于UIKit这样一个庞大的框架,将其全部属性都设计为线程安全是不现实的,这可不只仅是简单的将nonatomic改为atomic或者是加锁解锁的操做,还涉及到不少的方面:github

  • 假设可以异步设置view的属性,那咱们到底是但愿这些改动可以同时生效,仍是按照各自runloop的进度去改变这个view的属性呢?
  • 假设UITableView在其余线程去移除了一个cell,而在另外一个线程却对这个cell所在的index进行一些操做,这时候可能就会引起crash。
  • 若是在后台线程移除了一个view,这个时候runloop周期尚未完结,用户在主线程点击了这个“将要”消失的view,那么究竟该不应响应事件?在哪条线程进行响应?

仔细思考,彷佛可以多线程处理UI并无给咱们开发带来更多的便利,假如你代入了这些情景进行思考,你很容易得出一个结论: “我在一个串行队列对这些事件进行处理就能够了。” 苹果也是这样想的,因此UIKit的全部操做都要放到主线程串行执行。编程

Thread-Safe Class Design一文提到:安全

It’s a conscious design decision from Apple’s side to not have UIKit be thread-safe. Making it thread-safe wouldn’t buy you much in terms of performance; it would in fact make many things slower. And the fact that UIKit is tied to the main thread makes it very easy to write concurrent programs and use UIKit. All you have to do is make sure that calls into UIKit are always made on the main thread.网络

大意为把UIKit设计成线程安全并不会带来太多的便利,也不会提高太多的性能表现,甚至会由于加锁解锁而耗费大量的时间。事实上并发编程也没有由于UIKit是线程不安全而变得困难,咱们所须要作的只是要确保UI操做在主线程进行就能够了。多线程


好吧,那假设咱们用黑魔法祝福了UIKit,这个UIKit可以完美的解决咱们上面提到的问题,并可以按照开发者的想法随意展示不一样的形态。那这个时候咱们能够在后台线程操做UI了嘛?并发

很惋惜,仍是不行。

Runloop 与绘图循环

道理咱们都懂,那这个究竟跟咱们不能在后台线程操做UI有什么关系呢?

UIApplication在主线程所初始化的Runloop咱们称为Main Runloop,它负责处理app存活期间的大部分事件,如用户交互等,它一直处于不断处理事件和休眠的循环之中,以确保能尽快的将用户事件传递给GPU进行渲染,使用户行为可以获得响应,画面之因此可以获得不断刷新也是由于Main Runloop在驱动着。

而每个view的变化的修改并非马上变化,相反的会在当前run loop的结束的时候统一进行重绘,这样设计的目的是为了可以在一个runloop里面处理好全部须要变化的view,包括resize、hide、reposition等等,全部view的改变都能在同一时间生效,这样可以更高效的处理绘制,这个机制被称为绘图循环(View Drawing Cycle)

假设这个时候咱们应用了咱们的魔法UIKit,并愉快的在一条后台线程操做UI,但当咱们须要对设备进行旋转并从新布局的时候,问题来了,由于各个线程之间不一样步,这时候各个view修改的请求时机是零碎的,因此全部的旋转变化并不能在Main Runloop的一个runloop里面处理完,这就致使设备旋转以后还有一些view迟迟没有旋转。

另外一方面,由于咱们的魔法UIKit并非在主线程,因此Main Runloop中的事件须要跨线程进行传输,这样会致使显示与用户事件并不一样步。试想一下咱们用咱们的魔法UIKit写了一个游戏,用户若是在图片尚未加载出来的时候按下了按钮,他们就能胜利,因而咱们写出了这样的代码:

game.m

- (void)didClickButton:(UIButton *)button
{
	if (self.imageView.image != nil) {
		// User lose!
	} else {
		// User Win!
	}
}

- (void)loadImageInBackgroundThread
{
	dispatch_async(dispatch_queue_create("BackgroundQueue", NULL), ^{
		self.imageView.image = [self downloadedImage];
	};
}

复制代码

由于咱们完美的魔法UIKit,在后台执行imageView.image = xxx并不会产生任何问题。游戏上线,在你还为后台处理UI而沾沾自喜的时候,用户投诉了他们明明没有看到图片显示,点击的时候仍是告诉他们输了,因而你的产品就这样扑街了。

这是由于点击等事件是由系统传递给UIApplication中,并在Main Runloop中进行处理与响应,可是因为UI在后台线程中进行处理,因此他跟事件响应并不一样步。即便在UI所在的后台线程也本身维护了一个Runloop,在Runloop结束时候进行渲染,但可能用户已经进行了点击操做并开始辱骂你的游戏了。


好吧,那假设我天赋异禀,把整套UIApplication的机制全都重写了,也用黑魔法祝福了个人新UIApplication,这个时候它能完美的解决线程同步的问题,这个时候我能够在后台操做UI了吗?

……

……

很惋惜,仍是不能。

理解iOS的渲染流程

要回答这个问题,咱们要先从最底层的渲染提及。

渲染系统框架

  • UIKit: 包含各类控件,负责对用户操做事件的响应,自己并不提供渲染的能力
  • Core Animation: 负责全部视图的绘制、显示与动画效果
  • OpenGL ES: 提供2D与3D渲染服务
  • Core Graphics: 提供2D渲染服务
  • Graphics Hardware: 指GPU

因此在iOS中,全部视图的现实与动画本质上是由 Core Animation 负责,而不是UIKit。

Core Animation Pipeline 流水线

Core Animation的绘制是经过Core Animation Pipeline实现,它以流水线的形式进行渲染,具体分为四个步骤:

  • Commit Transaction:

    能够细分为

    • Layout: 构建视图布局如addSubview等操做
    • Display: 重载drawRect:进行时图绘制,该步骤使用CPU与内存
    • Prepare: 主要处理图像的解码与格式转换等操做
    • Commit: 将Layer递归打包并发送到Render Server
  • Render Server:

    负责渲染工做,会解析上一步Commit Transaction中提交的信息并反序列化成渲染树(render tree),随后根据layer的各类属性生成绘制指令,并在下一次VSync信号到来时调用OpenGL进行渲染。

  • GPU:

    GPU会等待显示器的VSync信号发出后才进行OpenGL渲染管线,将3D几何数据转化成2D的像素图像和光栅处理,随后进行新的一帧的渲染,并将其输出到缓冲区。

  • Dispaly:

    从缓冲区中取出画面,并输出到屏幕上。

知识补充:iOS的VSync与双缓冲机制

VSync:

VSync(vertical sync)是指垂直同步,在玩游戏的时候在设置的时候应该会看见过这个选项,这个机制可以让显卡和显示器保持在一个相同的刷新率从而避免画面撕裂。在iOS中,屏幕具备60Hz的刷新率,这意味着它每秒须要显示60张不一样的图片(帧),但GPU并无一个肯定的刷新率,在某些时候GPU可能被要求更强力的数据输出来确保渲染能力,这时候他们可能比屏幕刷新率(60Hz)更快,就会致使屏幕不能完整的渲染全部GPU给他的数据,由于它不够快,屏幕的上一帧还没渲染完,下一帧就已经到来了,这就致使画面的撕裂。

这个时候咱们就要引入VSync了,简单来讲它就是让显卡保持他的输出速率不高于屏幕的刷新率,启用了VSync后,GPU再也不会给你可怜的60Hz屏幕每秒发送100帧了,它会增长每一帧的发送间隔,确保显示器可以有充足的时间去处理每一帧。

双缓冲机制:

双缓冲机制是用于避免或减小画面闪烁的问题,在单缓冲的状况下,GPU输出了一帧画面,缓冲区就须要立刻获取这个画面,并交给显示屏去显示,而这段时间GPU输出的画面就全都丢失了,由于没有缓冲区去承载这些画面,就会形成画面的闪烁。

而在双缓冲机制下有一个Back Frame Buffer和一个Front Frame Buffer,在GPU绘制完成后,它会将图像先保存到Back Frame Buffer中,操做完毕后,会调用一个交换函数,让绘制完成的Back Frame Buffer上的图像交换到Front Frame Buffer上。因为双缓冲利用了更多显存与CPU消耗时间,从而避免了画面的闪烁。

So?

相信你们都会遇到过应用卡顿,卡顿的缘由就是由于两帧的刷新时间间隔大于60帧每秒(约16.67ms),致使用户感受点击或者滑动时,界面没有及时的响应。

前面提到Core Animation Pipeline是以流水线的形式工做的,在理想的情况下咱们但愿它可以在1/60s内完成图层树的准备工做并提交给渲染进程,而渲染进程在下一次VSync信号到来的时候提交给GPU进行渲染,并在1/60s内完成渲染,这样就不会产生任何的卡顿。

可是因为咱们使用了咱们的魔法UIKit,因此咱们在许多后台线程进行了UI操做,在runloop的结尾准备进行渲染的时候,不一样线程提交了不一样的渲染信息,因而咱们就拥有了更多的绘制事务,这个时候Core Animation Pipeline会不断将信息提交,让GPU进行渲染,因为绘制事件的不一样步致使了GPU渲染的不一样步,可能在上一帧是须要渲染一个label消失的画面,下一帧却又须要渲染这个label改变了文字,最终致使的是界面的不一样步。

(若是你真的想要这样的效果,能够尝试一下使用个人DWAnimatedLabel

另外一方面,在VSync和双缓冲机制咱们能够看出渲染实际上是一个十分消耗系统资源的操做(占用显存与CPU),因此可能会由于大量的事务和线程之间频繁的上下文切换致使了GPU没法处理,反而影响了性能,从而致使在1/60s中没法完成图层树的提交,致使了严重的卡顿。


但我真的很想在后台线程操做UI,我能再用黑魔法吗?

……

……

……

……

好吧,实际上是有办法的。

Texture or ComponentKit

AsyncDisplayKit(现命名为Texture) 是Facebook开源的一个用于保持iOS界面流畅的框架。

ComponentKit是Facebook开源的一个基于React思想的iOS原生UI开发框架。它经过函数式和声明的方式构建UI。

让咱们撤销掉咱们对UIKit施展的各类魔法,回到这个UI只能在主线程进行操做的世界吧。这两个框架其实并非真正的在后台线程操做UI,而是用了更巧妙的方法将一些耗时的操做异步执行,从而绕开了UIKit只能在主线程操做的限制。

好比Texture建立了各种Node,在node中包含了UIView,而Node自己是线程安全的,因此容许在后台线程对Node进行修改,随后在第一次主线程访问View的时候它才会在内部生成对应的View,当node的属性发生改变的时候,他也不会立刻进行修改,而是在适当的时机一次性的在主线程为内部的View进行设置。(有点相似于绘图循环)

而ComponentKit则是经过建立Component来描述UI,它也是一个线程安全的类。能够将Component认为是一个刻板,而UIView是刻板下的一张纸,渲染则是喷墨的过程。当咱们生成了一个Component的时候,就等于生成了一个View的模版,在进行渲染的时候只要按照模版进行绘制就能够了。复杂的界面能够经过各类简单的Component来组成。(相似于Flutter的widget)


可是我……

闭嘴吧你

总结

UIKit不能在主线程进行操做,这一个铁律只要是熟悉iOS开发的都会有所耳闻,可是往深一层其实这个涉及到不少的东西,包括软件、总体UIKit框架的实现、硬件等等,不少细节的东西每每是咱们在日常有所忽略的。可能咱们知道不能在主线程操做,殊不知道其内在缘由;可能咱们知道怎么排查、处理卡顿,殊不知道其真正的成因;可能咱们知道drawRect:方法会致使CPU飙升,殊不知道缘由是上下文的切换致使……

写代码历来都不是一件简单而显而易见的事情。

更多的内容能够查看个人博客

参考资料

相关文章
相关标签/搜索