iOS 任务调度器:为 CPU 和内存减负
GitHub 地址:YBTaskScheduler
支持 cocopods,使用简便,效率不错,一个性能优化的基础组件。
前言
前些时间有好几个技术朋友问过笔者类似的问题:主线程需要执行大量的任务导致卡顿如何处理?异步任务量级过大导致 CPU 和内存压力过高如何优化?
解决类似的问题可以用几个思路:降频、淘汰、优先级调度。
本来解决这些问题并不需要很复杂的代码,但是涉及到一些 C 代码并且要注意线程安全的问题,所以笔者就做了这样一个轮子,以解决任务调度引发的性能问题。
本文讲述 YBTaskScheduler 的原理,读者朋友需要有一定的 iOS 基础,了解一些性能优化的知识,基本用法可以先看看 GitHub README,DEMO 中也有一个相册列表的应用案例。
一、需求分析
就拿 DEMO 中的案例来说明,一个显示相册图片的列表:
实现图中业务,必然考虑到几个耗时操作:
- 从相册读取图片
- 解压图片
- 圆角处理
- 绘制图片
理所当然的想到处理方案(DEMO中有实现):
- 异步读取图片
- 异步裁剪图片为正方形(这个过程中就解压了)
- 异步裁剪圆角
- 回到主线程绘制图片
一整套流程下来,貌似需求很好的解决了,但是当快速滑动列表时,会发现 CPU 和内存的占用会比较高(这取决于从相册中读取并显示多大的图片)。当然 DEMO 中按照屏幕的物理像素处理,就算不使用任务调度器组件快速滑动列表也基本不会有掉帧的现象。考虑到老旧设备或者技术人员的水平,很多时候这种需求会导致严重的 CPU 和内存负担,甚至导致闪退。
以上处理方案可能存在的性能瓶颈:
- 从相册读取图片、裁剪图片,处理圆角、主线程绘制等操作会导致 CPU 计算压力过大。
- 同时解压的图片、同时绘制的图片过多导致内存峰值飙升(更不要说做了图片的缓存)。
任何一种情况都可能导致客户端卡死或者闪退,结合业务来分析问题,会发现优化的思路还是不难找到:
- 滑出屏幕的图片不会存在绘制压力,而当前屏幕中的图片会在一个 RunLoop 循环周期绘制,可能造成掉帧。所以可以减少一个 RunLoop 循环周期所绘制的图片数量。
- 快速滑动列表,大量的异步任务直接交由 CPU 执行,然而滑出屏幕的图片已经没有处理它的意义了。所以可以提前删除掉已经滑出屏幕的异步任务,以此来降低 CPU 和内存压力。
没错, YBTaskScheduler 组件就是替你做了这些事情 😁,而且还不止于此。
二、命令模式与 RunLoop
想要管理这些复杂的任务,并且在合适的时机调用它们,自然而然的就想到了命令模式。意味着任务不能直接执行,而是把任务作为一个命令装入容器。
在 Objective-C 中,显然 Block 代码块能解决延迟执行这个问题:
[_scheduler addTask:^{
/*
具体任务代码
解压图片、裁剪图片、访问磁盘等
*/
}];
然后组件将这些代码块“装起来”,组件由此“掌握”了所有的任务,可以自由的决定何时调用这些代码块,何时对某些代码块进行淘汰,还可以实现优先级调度。
既然是命令模式,还差一个 Invoker (调用程序),即何时去触发这些任务。结合 iOS 的技术特点,可以监听 RunLoop 循环周期来实现:
static void addRunLoopObserver() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
taskSchedulers = [NSHashTable weakObjectsHashTable];
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit, true, 0xFFFFFF, runLoopObserverCallBack, NULL);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
CFRelease(observer);
});
}
然后在回调函数中进行任务的调度。
三、策略模式
考虑到任务的淘汰策略和优先级调度,必然需要一些高效数据结构来支撑,为了提高处理效率,笔者直接使用了 C++ 的数据结构:deque
和priority_queue
。
因为要实现任务淘汰,所以使用deque
双端队列来模拟栈和队列,而不是直接使用stack
和queue
。使用priority_queue
优先队列来处理自定义的优先级调度,它的缺点是不能删除低优先级节点,为了节约时间成本姑且够用。
具体的策略:
- 栈:后加入的任务先执行(可以理解为后加入的任务优先级高),优先淘汰先加入的任务。
- 队列:先加入的任务先执行(可以理解为先加入的任务优先级高),优先淘汰后加入的任务。
- 优先队列:自定义任务优先级,不支持任务淘汰。
实际上组件是推荐使用栈和队列这两种策略,因为插入和取出的时间复杂度是常数级的,需要定制任务的优先级时才考虑使用优先队列,因为其插入复杂度是 O(nlogn) 的。
至此,整个组件的业务是比较清晰了,组件需要让这三种处理方式可以自由的变动,所以采用策略模式来处理,下面是 UML 类图:
UML类图嗯,这是个挺标准的策略模式😁。
四、线程安全
由于任务的调度可能在任意线程,所以必须要做好容器(栈、队列、优先队列)访问的线程安全问题,组件是使用pthread_mutex_t
和dispatch_once
来保证线程安全,同时笔者尽量减少临界区来提高性能。值得注意的是,如果不会存在线程安全的代码就不要去加锁了。
关于多线程及安全可以看笔者的另一篇文章:iOS 如何高效的使用多线程,这里就不赘述了。
后语
部分技术细节就不多说了,组件代码量比较少,如果感兴趣可以直接看源码。实际上这个组件的应用场景并不是很多,在项目稳定需要做深度的性能优化时可能会比较需要它,并且希望使用它的人也能了解一些原理,做到胸有成竹,才能灵活的运用。