作者简介:
彭飞,58 同城 iOS 客户端架构师。专注于新技术的研发,主要负责 App 端组件化架构以及性能优化,并已推广 React Native 在 58 同城 App 中业务场景的应用。
在 iOS 开发中,一谈到线程管理,肯定离不开 GCD(Grand Central Dispatch)与 NSOperation/NSOperationQueue 技术选型上的争论。关于这两者普遍的观点为:GCD 较轻量,使用起来较灵活,但在任务依赖、并发数控制、任务状态控制(线程取消/挂起/恢复/监控)等方面存在先天不足;NSOperation/NSOperationQueue 基于 GCD 做的封装,使用较重,在某些情景下性能不如 GCD,但在并发环境下复杂任务处理能很好地满足一些特性,业务扩展性较好。
但是 RN 在线程管理是如何选用 GCD 和 NSOperation 的?带着此问题,一起从组件中的线程、JSBundle 加载中的线程以及图片组件中的线程三个方面,逐步看看其中的线程管理细节。
组件中的线程
组件中的线程交互
RN 的本质是利用 JS 来调用 Native 端的组件,从而实现相应的功能。由于 RN 的 JS 端只具备单线程操作的能力,而 Native 端支持多线程操作,所以如何将 JS 的单线程与 Native 端的多线程结合起来,是 JS 与 Native 端交互的一个重要问题。图 1,直观展示了 RN 是如何处理的。
图 1 JS 调用 Native 端
先从 JS 端看起,如图 1 所示,JS 调用 Native 的逻辑在
MessageQueue.js
的
_nativeCall
方法中。在最小调用间隔(
MIN_TIME_BETWEEN_FLUSHES_MS=5ms
)内,JS 端会将调用信息存储在
_queue
数组中,通过
global. nativeFlushQueueImmediate
方法来调用 Native 端的功能。
global.nativeFlushQueueImmediate
方法在 iOS 端映射的是一个全局的 Block,如图 2 所示。
图 2
global.nativeFlushQueueImmediate
方法映射
nativeFlushQueueImmediate
在这里只是做了一个中转,功能的实现是通过调用
RCTBatchedBridge.m
中的
handleBuffer
方法,具体代码如图 3 所示。在
handleBuffer
中针对每个组件使用一个
queue
来处理对应任务。其中,这个
queue
是组件数据
RCTModuleData
中的属性
methodQueue
,后文会详细介绍。
图 3
handleBuffer
方法调用
从上面的代码追踪可以看出,虽然 JS 只具备单线程操作的能力,但通过利用 Native 端多线程处理能力,仍可以很好地处理 RN 中的任务。回到刚开始抛出的议题,RN 在这里用 GCD 而非 NSOperationQueue 来处理线程,笔者认为主要原因有:
-
GCD 更加轻量,更方便与 Block 结合起来进行线程操作,性能上优于 NSOperationQueue 的执行;
-
虽然 GCD 在控制线程数上有缺陷,不如 NSOperationQueue 有直接的 API 可以控制最大并发数,但由于 JS 是单线程发起任务,在 5ms 内会积累的任务数创造的并发不高,不用考虑最大并发数带来的 CPU 性能问题。
-
关于线程依赖的处理,由于 JS 端是在同一个线程顺序执行任务的,而在 Native 端对这些任务进行了分类(后文会有叙述),针对同类别任务在同一个 FIFO 队列中执行。这样的应用场景及 Native 端对任务的分类处理,规避了线程依赖的复杂处理。
组件中线程自定义
前文提到了 Native(iOS)端处理并发任务的线程是 RCTModuleData 中的属性
methodQueue
。RCTModuleData 是对组件对象的实例(instance)、方法(methods)、所属线程(methodQueue)等方面的描述。每一个 module 都有个独立的线程来管理,具体线程的初始化在 RCTModuleData 的
setUpMethodQueue
中进行设置,详细代码可见图 4。
图 4 线程自定义
图 4 中的 174 行至 177 行是开放给组件自定义线程的接口。如果组件实现了 methodQueue 方法,则获取此方法中设置的
queue
;否则默认创建一个子线程。问题来了,既然可以自定义线程,那 RN 中内置组件是如何定义的,对开发过程中的自定义组件在设置线程的时候需要注意什么?
图 5 是本地项目中实现
methodQueue
的组件,除去以 RCTWB 开头的自定义组件,其它都是系统自带的。通过查看每一个组件
methodQueue
方法的实现,发现有的是在主线程执行,有的是在 RCTJSThread 中执行,表 1 所示的是其中主要系统组件的具体情况。
表 1 RCTJSThread 中的主要系统组件一览果
图 5 本地项目中实现 methodQueue
RCTJSThread
RCTJSThread 是在 RCTBridge 中定义的一个私有属性,如图 6 所示。
图 6 RCTJSThread 定义
RCTJSThread 的类型是
dispatch_queue_t
,它是 GCD 中管理任务的队列,与 block 联合起来使用。一个
block
封装一个特定的任务,
dispatch_queue_t
一次执行一个
block
,相互独立的
dispatch_queue_t
可以并发执行
block
。
RCTJSThread 的初始化比较有意思,并没有采用
dispatch_queue_create
来创建一个 queue 实例,而是指向 KCFNull。我在整个源代码里全局搜了一下,没有其他的地方对 RCTJSThread 进行初始化。事实上,RCTJSThread 在设计上不是用来执行任务的,而是用来进行比较的,看图 7 中的代码。
图 7 RCTJSThread 设计
RCTBatchedBridge.m
中的
handleBuffer
是处理 JS 向 Native 端的事件请求的。在第 928 行,如果一个组件中定义的 queue 是 RCTJSThread,则在 JSExecutor 中执行
executeBlockOnJavaScriptQueue:
方法,具体执行代码如图 8 所示。
图 8 执行
executeBlockOnJavaScriptQueue:
方法
_javaScriptThread 是一个 NSThread 对象,看到这里才知道真正具备执行任务的是这里的 JavaScriptThread,而不是前面的 RCTJSThread。在 handBuffer 方法中之所以用 RCTJSThread,而不用 nil 替代,我的看法是为了可读性和扩展性。可读性是指如果在各个组件中将当前线程对象设置为 nil,使用者会比较迷惑;扩展性是指如果后面业务有扩展,发现根据 nil 比较不能满足需求,只需修改 RCTJSThread 初始化的地方,业务调用的地方完全没有感知。
RCTUIManagerQueue
RN 的 UI 组件调用都是在 RCTUIManagerQueue 完成的,关于它的创建如图 9 所示。
图 9 创建 RCTUIManagerQueue 代码
由于苹果在 iOS 8.0 之后引入了 NSQualityOfService,淡化了原有的线程优先级概念,所以 RN 在这里优先使用了 8.0 的新功能,而对 8.0 以下的沿用原有的方式。但不论用哪种方式,都保证 RCTUIManagerQueue 在并发队列中优先级是最高的。到这里或许有疑问了,UI 操作不是要在主线程里操作吗,这里为什么是在一个子线程中操作?其实在此执行的是 UI 的准备工作,当真正需要把 UI 元素加入主界面,开始图形绘制时,才需要在主线程里操作,具体代码见图 10。
图 10 UI 操作代码
这里 RCTUIManagerQueue 是一个先进先出的顺序队列,保证了 UI 的顺序执行不出错,但这里是把 UI 的一些需要准备的工作(比如计算 frame)放在一个子线程里面操作完成后,再统一提交给主线程进行操作的。这个过程是阻塞的,针对一些低端机型渲染复杂界面,会出现打开 RN 页面的一段空白页面的情况,这是 RN 需要优化的一个地方。
前面介绍了组件中线程的相关情况,针对平常开发中的自定义组件,有以下两点需要关注:
JSBundle 加载中的线程操作
前面叙述的组件相关的线程情况,从业务场景方面来看,略显简单,下面将介绍一下场景复杂点的线程操作。
React Native 中加载过程业务逻辑比较多,需要先将 JSBundle 资源文件加载进内存,同时解析 Native 端组件,将组件相关配置信息加载进内存,然后再执行 JS 代码。图 11 所示的 Native 端加载过程代码,在 RCTBatchedBridge.m 的 start 方法中。其中片段 1 是将 JSBundle 文件加载进内存,片段 2 是初始化 RN 在 Native 端组件,片段 3 是设置 JS 执行环境以及初始化组件的配置,片段 4 是执行 JS 代码。这 4 个代码片段对应 4 个任务,其中任务 4 依赖任务 1/2/3,需要在它们全部执行完毕后才能执行。任务 1/3 可以并行,没有依赖关系。任务 3 依赖任务 2,需要任务 2 执行完毕后才能开始执行。
图 11 Native 端加载过程代码
为控制任务 4 和任务 1/2/3 之间的依赖关系,定义了
dispatch_group_t initModulesAndLoadSource
管理依赖;而任务 3 依赖任务 2 是采取阻塞的方式。下面分别看各个任务中的处理情况。
先看片段 1 的代码,如图 12 所示。
图 12 片段 1 代码
dispatch_group_enter(group);dispatch_async(queue, ^{
dispatch_group_leave(group);
});
但这里并没有使用 dispatch_async,而是采用默认的同步方式。具体原因在于 loadSource 中有一部分属性是下一个队列需要使用到的,这部分属性的初始化需要在这个队列中进行阻塞的同步执行。LoadSource 方法中有一部分逻辑是异步的,这部分数据可以在 initModulesAndLoadSource 的 group 合并的时候处理。 片段 2 的处理比较简单,跳过直接看片段 3 的代码,如图 13 所示。
图 13 片段 3 代码详情
片段 3 中的任务是又一个复合任务,由一个新的
group(setupJSExecutorAndModuleConfig)
来管理依赖。有两个并发任务,初始化 JS Executor(119-124 行)和获取 module 配置(127-133 行)。这两个并发任务都放在并发队列 bridgeQueue 中执行,完成后进行合并处理(135-150 行)。需要注意的是片段 3 中采用
dispatch_group_async(group, queue, ^{ })
;来执行队列中的任务,其效果与前文叙述的
dispatch_group_enter/dispatch_group_leave
相同。
从上面的分析可以看出,GCD 利用
dispatch_group_t
可以很好地处理线程间的依赖关系。里面的线程操作虽不能像前文中组件的线程对开发有直接帮助,但是一个很好的利用 GCD 解决复杂任务的实例。
图片中的线程
看过 SDWebImage 的源码的同学知道,SDWebImage 采用的是 NSOperationQueue 来管理线程。但是 RN 在 image 组件中并没有采用 NSOperationQueue,还是一如继往地使用 GCD,有图 14 为证。眼尖的同学会发现图中明明有一个 NSOperationQueue 变量
_imageDecodeQueue
,这是干什么用的?有兴趣可以在工程中搜索一下这个变量,除了在这里定义了一下,没有在其他任何地方使用。
图 14 NSOperationQueue or GCD?
我猜当时作者是不是也在纠结要不要使用 NSOperationQueue,而决定用 GCD 之后忘了删掉这个变量。
既然决定了使用 GCD,就需要解决两个棘手的问题,控制线程的并发数以及取消线程的执行。这两个问题也是 GCD 与 NSOperationQueue 进行比较时谈论最多的问题,且普遍认为当有此类问题时,需要弃 GCD 而选 NSOperationQueue。下面就来叙述一下 RN 中是如何来解决这两个问题的。
最大并发数的控制
首先是控制线程的并发数。在 RCTImageLoader 中有一个属性
maxConcurrentLoadingTasks
,如图 15 所示。除此之外,还有一个控制图片解码任务的并发数
maxConcurrentDecodingTasks
。加载图片和解码图片是一项非常耗内存/CPU 的操作,所以需要根据业务需求的具体情况来灵活设定。