UI操作为什么不应该在子线程中操作
前言
讲道理,都9021年了,不应该在写这个问题了,不过最近恰好想要整理一下自己的思绪,就趁着还关注的这个问题,就写下来,权当记录自己的想法。文章比较务虚,大多数是自己的想法,可能会比较片面。
本来想把题目写成:“UI 操作为什么要在主线程中操作”,但是想来,这么起标题有些不严谨,就换个相对严谨的标题。
从我们开始做 iOS 开发的那一天开始,就经常听到关于 UI 不能在子线程操作的警告。但是这种事情还是会时有发生,最典型的错误之一,就是在子线程里直接去更新图片相关的 UI 对象。
UI 线程不安全
我们其实都明白,UI 其实是一个比较笼统的概念,我们能看到的所有东西都可以称之为 UI。换句话说,UI 是使用者直接接触、操作的东西,算得上用户体验的第一线。如果在 UI 上出了问题,会极大地打击用户的使用感受,造成大问题。而 UI 的线程安全与否,就是我们直接需要关注的事,因为这里实在是太容易出问题了。
我们一般创建 UI 的时候会使用属性,比如这样:
@property (nonatomic, strong) UILabel *label;
我们一般都是使用 nonatomic 来修饰 UI,这确实说明 UIKit 并没有打算在“属性访问”这一层帮我们处理好多线程同步问题。但这只是表象,真正的原因还是 UIKit 整体就不是按并发访问来设计的。
在线程安全类的设计这篇老文章中,直接说明了一个简单的答案:
- 对于一个像 UIKit 这样的大型框架,确保它的线程安全将会带来巨大的工作量和成本。将 non-atomic 的属性变为 atomic 的属性只不过是需要做的变化里的微不足道的一小部分。通常来说,你需要同时改变若干个属性,才能看到它所带来的结果。为了解决这个问题,苹果可能不得不提供像 Core Data 中的
performBlock:和performBlockAndWait:那样类似的方法来同步变更。另外你想想看,绝大多数对 UIKit 类的调用其实都是以配置为目的的,这使得将 UIKit 改为线程安全这件事情更显得毫无意义了。
下面我们从多个方向,理解一下UI在多线程上的问题。
线程消耗
我们要先说明一个问题: dealloc 方法其实是可以在子线程中调用的。
为了搭建一个不错的 UI 页面和效果,我们可能会频繁地创建 UI、使用 addSubview 方法、销毁 UI。这里的创建、销毁都涉及对象生命周期和内存操作。如果我们在销毁一个 UI 的时候,又在另一个线程里做了别的修改(比如改颜色、改布局),那问题就来了:UI 都没了,还怎么继续操作?这种情况下非常容易直接 crash。那么为了保障 UI 在不同线程下的安全操作,还需要去加锁。好吧,这个消耗就已经很大了。
而且,线程的创建和通信并非毫无消耗的。在子线程上操作UI,你需要频繁的进行线程的切换,尤其是一个控件上的子控件,操作不在一个线程上,可以想象这会造成多大的性能浪费。
渲染
我们的 iOS,传统上常以 60 FPS 作为流畅目标,也就是说每一帧大约只有 16.67 ms 的预算时间。在这么短的时间里,需要完成一个 UI 页面的整个展示过程。
而一个页面 UI 的展示,是需要 CPU 和 GPU 协同合作的。CPU 负责显示内容的创建、布局计算、图片解码、文本绘制,然后再将计算好的内容提交给 GPU,让 GPU 去进行合成渲染;接着,GPU 会将渲染结果提交到帧缓冲区,等待展示到屏幕上。
而如果在子线程上操作了 UI,最有可能出现两种情况:
会导致时间的不同步,页面错乱。本该在这个时间创建出来的 UI,结果在下一个渲染区间才出现。 多个线程都提出了 UI 的操作,CPU 需要从多个线程去获取将要渲染内容的各种计算,然后提交给 GPU 统一处理,这个本身就很难同步。
CPU 和 GPU 渲染一个页面已经很累了,就不要再让它们干多余的事了。
事件循环和传递
在 Cocoa Touch 中,应用主线程承载着 UIApplication、主 RunLoop 以及事件分发的核心链路。当我们点击某个控件的时候,产生的点击事件,本身就是要通过主线程的 RunLoop 去传递和驱动的。
我们可以设想这个这样的情况,有多个子线程去创建了 button。而 button 的点击事件是需要传递到 Runloop 上才能继续的去传递。如果真的要进行了跨线程的事件传递,平白多耗费时间在切换线程上,会让事件延迟。
并且,我们都知道,UI 的变化其实并不是瞬间的变化。如果真要这样,UI 一旦多了起来,光互相传递 UI 操作,让其他 UI 响应的信息就会随着 UI 数量的上升而指数型上升。
这里打一个现实的比方。从中国到美国的货运飞机,相当大的一部分,是从中国少数几个起点城市,比如说北京、深圳等起飞,让后降落在少数几个美国城市,比如说西雅图和纽约(你可以从北极点上看地球,你就会发现,为什么会这么飞了)。为什么不让每个发货地都直飞收货地呢? 相信你稍微算一下,就会明白了。
所以,存在一个总线是有利于节约资源的,我们可以把主线程的 Runloop 当成事件的总线,所有的事件,不应该去跨越多个线程再传递到 Runloop 上,直接在统一的单独 Runloop 上收发,会节约 CPU 的资源。
在主线程上就一定没问题吗?
那可不一定哦!
这里有一个很经典的 issue,它真正提醒我们的其实不是“主线程操作 UI 也不安全”,而是:工程上你怎么判断和调度到主队列,本身也可能有坑。
在我们不知道这个问题之前,我们常常这么写。
#define dispatch_main_sync_safe(block)\
if ([NSThread isMainThread]) {\
block();\
} else {\
dispatch_sync(dispatch_get_main_queue(), block);\
}
但是这么写,还是无法防住上面那种特殊 bug。那么我们怎么办呢?换个写法。
#ifndef dispatch_queue_async_safe
#define dispatch_queue_async_safe(queue, block)\
if (strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), dispatch_queue_get_label(queue)) == 0) {\
block();\
} else {\
dispatch_async(queue, block);\
}
#endif
#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block) dispatch_queue_async_safe(dispatch_get_main_queue(), block)
#endif
strcmp() 是 C 语言里的字符串比较函数。strcmp(s1, s2) 用来判断两个字符串是否相同,相同则返回 0;如果当前队列已经是主队列了,那么就直接运行;如果当前队列不是主队列,就调用 dispatch_async(dispatch_get_main_queue(), block)。通过这样的判断,可以尽量避免把本来就在目标队列上的任务再次异步派发。
这个方法是不是很眼熟?没错,这两个都是 SDWebImage 的宏,只不过上面的是旧的,下面的是新的。
在 iOS 里,主队列的任务会绑定在主线程上执行。这个实际上和 RunLoop 有关系,这篇文章 提出了一个想法,我也比较认可。
- 主队列的 Runloop 一旦启动,就只会被该线程执行任务
- 子队列的 Runloop 无法绑定队列和线程的执行关系
如何防范子线程 UI
在实际操作中,子线程操作 UI 还是偶有发生的一个重要原因就是————如果你真的在子线程进行了 UI 操作,它不是必然崩溃的!很多时候写着写着就这么过去了。
为了防止这个问题,实际上有两种解决办法。
- hook 所有的 UIView 的
setNeedsLayout、setNeedsDisplay、setNeedsDisplayInRect:的方法,让它们直接在开发阶段就发出提醒。- hook 所有的 UIView 的
setNeedsLayout、setNeedsDisplay、setNeedsDisplayInRect:的方法,在线上做好防护,让所有子线程的 UI 操作强制回到主线程上去。
在这里,我强烈推荐第一种方法。这里引用DoraemonKit的方法。
@implementation UIView (Doraemon)
+ (void)load{
[[self class] doraemon_swizzleInstanceMethodWithOriginSel:@selector(setNeedsLayout) swizzledSel:@selector(doraemon_setNeedsLayout)];
[[self class] doraemon_swizzleInstanceMethodWithOriginSel:@selector(setNeedsDisplay) swizzledSel:@selector(doraemon_setNeedsDisplay)];
[[self class] doraemon_swizzleInstanceMethodWithOriginSel:@selector(setNeedsDisplayInRect:) swizzledSel:@selector(doraemon_setNeedsDisplayInRect:)];
}
- (void)doraemon_setNeedsLayout{
[self doraemon_setNeedsLayout];
[self uiCheck];
}
- (void)doraemon_setNeedsDisplay{
[self doraemon_setNeedsDisplay];
[self uiCheck];
}
- (void)doraemon_setNeedsDisplayInRect:(CGRect)rect{
[self doraemon_setNeedsDisplayInRect:rect];
[self uiCheck];
}
- (void)uiCheck{
if([[DoraemonCacheManager sharedInstance] subThreadUICheckSwitch]){
if(![NSThread isMainThread]){
NSString *report = [BSBacktraceLogger bs_backtraceOfCurrentThread];
NSDictionary *dic = @{
@"title":[DoraemonUtil dateFormatNow],
@"content":report
};
[[DoraemonSubThreadUICheckManager sharedInstance].checkArray addObject:dic];
}
}
}
@end
我当初的做法没有这样精细,比较简单粗暴,就是直接让程序闪退,以达到警告的效果。
至于我为什么不采用第二种的方法,是因为我有这样的一个观点:
- hook 不应该被滥用,尤其不应该在线上代码里滥用。如果能不使用 hook 就解决问题,那就尽量不要用。这个东西是把双刃剑,非常容易伤到自己。