前言
界面展示类型的轮子往往定制性需求比较多,常常让人抓耳挠腮。这种接近业务的轮子如何设计才能兼顾便捷性和拓展性?如何有效的优化性能?如何控制内存不至于 OOM ?本文以 YBImageBrowser 的重构为切入点,尽量抽象提炼,谈谈笔者对以上问题的思考。
YBImageBrowser 是笔者 2018 年 4 月发布的开源项目。时隔一年多,接近 1.3k stars,处理了 100+ issues,200+ commits,30+ releases,两次深度重构。重构的原因很简单,无法忍受自己写的拙劣代码 😂,另一方面这份代码也承载了某种情怀。
下面就从几个方面谈谈这次重构引出的值得分享的东西。
一、图片处理流程
一张图片展示到屏幕上的流程:
对高清图进行压缩和裁剪避免图片超过最大支持纹理时 CPU 在主线程对图片额外处理,同时也降低了图片解码后的内存占用。
在这个流程中,有网络下载、读磁盘等 IO 密集型任务,也有压缩、裁剪、解码等 CPU 密集型任务,可见图片的处理过程非常消耗硬件资源,当出现大量的图片同时处理时将面临一些挑战,如何减轻 CPU 的压力、如何减轻内存的负担,时间和空间总是需要权衡取舍,有时它们互补,有时它们互斥。
二、架构设计
界面展示类型组件需要有良好的深度定制性,这就对架构设计要求较高,难点在于区分变量与不变量,各模块职责划分,以及合理的抽象。总的来说思维方向是不变的,落地到代码需要做很多的变化和取舍。
1、IOP 思想
IOP 是一个大家都知道的理念,但是落地到一个 UI 类型的组件中该如何实施?
保证深度定制性
YBImageBrowser 中默认有图片展示、视频播放两个模块,当用户不满足于此,比如可能需要加入一个广告模块(类似于微博图片浏览器最后一页)。为了拓展起来无障碍,配置数据源时就不能用具体的类型,抽象出一个协议:
/// 数据源数组
@property (nonatomic, copy) NSArray<id<YBIBDataProtocol>> *dataSourceArray;
如此用户便能自由的拓展其它模块,只是协议方法必须要明确职责,不能包含具体模块的业务。比如默认实现的YBIBImageData<YBIBDataProtocol>
类,图片解码压缩等属于这个模块独有的功能,所以不能让<YBIBDataProtocol>
协议有所感知。
当然,使用继承方式利用多态特性也能具有拓展能力,这样的好处是能提供默认实现,缺点是不够灵活,侵入性过强。单从制定规则的角度说,使用协议远比基类来得好。
兼顾便捷性
图片浏览器上面通常有一些工具栏,比如页码指示器、长按弹出的表单等,这些东西首先肯定要保证定制性,所以抽象一个协议是理所当然的:
@property (nonatomic, copy) NSArray<id<YBIBToolViewHandler>> *toolViewHandlers;
使用数组是因为可能有多个协议实现者。既然这是约束工具视图的协议,为什么要用id
类型而不是UIView
?因为考虑到可能用户需要用一个中介者来实现这个协议,然后由这个中介者管理所有的UIView
。
部分用户是不希望由自己来实现这种功能的,所以有必要提供一个默认实现类,默认实现类中可能有与协议无关的属性配置,所以将其暴露出来让用户可以便捷的配置(这里是与协议同名的实现类):
// 可以修改其属性
@property (nonatomic, weak, readonly) YBIBToolViewHandler *defaultToolViewHandler;
协议如何设计
协议可以让组件主体向其它子模块发送消息,但是当子模块想通过协议获取组件主体的信息如何做?
直接通过协议方法:
@protocol
- (void)setName:(NSString *)name;
@end
这种方式需要子模块将信息持有起来,更简洁的方式是直接定义一个属性:
@protocol
@property NSString *name;
@end
上面这两种方式在name
这个属性是常量时比较简单,若name
会动态变化呢?那必然还需要写更新逻辑。获取动态信息更优雅的方式是使用闭包:
@protocol
@property NSString *(^name)(void);
@end
主体中实现anyObject.name = ^NSString*{ return 动态计算结果 }
,在子模块中,只需要调用self.name()
就能获取到实时的数据了。
有些方法可能会出现在多个协议中,那抽象一些基协议就很有必要了,这样的好处不止是少写几句方法,在调用协议方法时还能复用代码,比如:
- (void)implementProtocolA:(id<ProtocolA>)anyObject {
协议有关、具体类型无关的方法调用
}
2、解耦的意义
需要理解解耦的目的,并不是一说架构就是解耦。
解开耦合的两个模块互相了解很少,就像男女朋友,“分手”比较容易,不拖泥带水;而互相关联的两个模块就像夫妻,多数情况下他们一生不会分离,要分离就叫“离婚”,关乎两边家庭,还要走法律程序,非常复杂。
所以,模块与模块之间是否需要解耦,判断它们是要做夫妻还是做男女朋友。
比如 YBImageBrowser 中的旋转处理类、数据中介者,它们就不需要解耦,抽离出来的目的只是为了方便管理和瘦身。
3、离散配置与集约配置
对于不同的实例对象,它们的功能最好能离散配置,集约配置仅作为便捷管理,这样才能保证场景覆盖完全。
比如使用 YBImageBrowser 时,用户需要对特定某张图片进行单独的预处理,就需要能离散配置。
4、谈谈 SDWebImage 和 YYImage
SDWebImage 5.0 优化了很多东西,很重要的一点是将很多集约配置的功能改为了离散配置。以前只能在进入 YBImageBrowser 时缓存配置,然后更改为组件需要的配置,退出 YBImageBrowser 时将缓存的配置还原,非常蛋疼。
还更新了一个重要的类 SDAnimatedImage,大概看了一下源码,两个缺点:不能支持普通图片,意味着要明确某张图片是动图,才能用这个类(组件中为了让用户对图片类型无感知,笔者就需要拓展普通图片的处理,成本较高);解码之前也没有暴露一个根据图片大小决议是否解码的接口,所以在处理超清大图不够灵活。而对于 YYImage,只有一个问题,就是没有暴露是否解码的接口。
所以最终还是选择了 YYImage,更改了源码让其可以通过图片的大小来动态判断是否解码。直接使用 SDWebImage 的下载模块和缓存模块,避免其框架内部的额外图片处理浪费资源。
5、构建子依赖
为了让用户可选集成,特意做了子依赖,默认是没有视频播放模块的,方便了对代码量要求严格的团队。
三、性能优化
UI 类型组件的性能优化,涉及算法复杂度的一般较少,多数情况都是利用硬件的能力进行立竿见影的优化。
1、任务异步化
最容易想到的一步就是把处理图片的任务尽量放子线程,这会让主线程倍感轻松。而有时由于各种原因,外部需要先进行一些阻塞主线程的操作(比如访问磁盘 IO),然后才将结果写入。这时可以提供一个闭包,类似于:
data = ^UIImage *{
return [UIImage customGetImageMethode];
}
如此,闭包代码执行线程就由组件控制了,同时还避免了持有读取的这块内存,不过前提是这个闭包包含的是方法而不是结果。
任务放异步线程时,线程上下文切换等会消耗一些时间,所以一般会降低任务的执行速度,得益于多核设备,让我们可以在保证任务不阻塞主线程的前提下提升执行效率,不过这需要在多个任务或者任务支持拆分时才能变得更快。
YBImageBrowser 几乎将所有的耗时任务异步化了,对共有变量的修改都保证在同一线程所以避免了使用锁。
2、分步缓存
YBImageBrowser 使用类实例来配置数据,数据处理后交付给 UICollectionViewCell 显示。这也是很多 UI 类型组件的做法,将数据和界面分离,大概如下:
在数据的处理过程中,需要将一些状态交付给 Cell 做界面提示(比如 Loading、Toast),很多时候处理完成一个数据并非只有一个任务。
对于 YBImageBrowser 来说,每一张图片都有一个这样的数据模型,所以可能某些数据模型的任务会被中断(这是后面要讲的优化),被中断任务的数据模型有两个结果,一个是释放,一个是待命。
当这个数据模型是待命状态,未来某一时刻恢复使用时,如果它之前做的“努力”白费了,就需要返工做之前做过的任务。所以为了减少重复“劳动”,可以对任务处理流程中产生的中间数据进行缓存,恢复使用时直接从上次中断的节点开始任务,以此来优化性能,典型的空间换时间。
同时,需要定义一些变量或枚举,标识当前的状态,在进入不可重入异步任务之前做个判断,避免发起多个相同的异步任务。
3、数据预加载
预加载是一个常规的优化思路,UI 类型组件的数据预加载往往可以放在动画转场、数据内容将要显示时。
YBImageBrowser 会在开始转场动画时立即加载目标数据模型,一般零点几秒的动效就能让用户无感知预加载了;在加载某一个数据模型时,还会“均分”加载两边的数据模型,当用户滑动不是很快时,多数情况下一张图片已经加载好了。
预加载的逻辑需要根据具体的业务需求来处理,图片浏览器的滑动不会跳跃,所以预加载临近的数据模型是个不错的选择。
4、任务中断
前面也说了处理图片的任务对硬件的消耗很大,在使用图片浏览器时,用户快速的滑动图片,将会让大量的数据模型发起处理流程,这对 CPU 会造成巨大的压力,也会让内存峰值飙升。
所以我们需要及时的中断不重要的任务,腾出 CPU 资源和内存做优先级高的事,那么怎么判断优先级高低?
判断优先级高低需要根据具体的业务,在图片浏览器中,数据模型有一个代理delegate
用来接收处理结果,这个代理就是 Cell。笔者假定:当delegate
对象从有到无时,说明这个数据模型的任务可以中断了。
delegate
从有到无体现到界面上就是,用户滑到了某张图片,然后又划开了这张图片,那么就可以认为,用户短时间内再滑回这种图片的几率较小。当然这种假定是不严谨的,但也是权宜之计。
可重入方法的处理
对于同一个数据模型来说,它的异步任务可能是允许重入的,比如图片浏览器的裁剪功能,有可能上一个裁剪任务未完成,下一个任务就发起了,而上一个任务结果已经没有意义了,那及时的中断上一个任务就非常有必要了。
这种处理方案就比较传统了,使用一个计数器递增:
int32_t value = [_cuttingSentinel increase];
BOOL (^isCancelled)(void) = ^BOOL(void) {
return value != self->_cuttingSentinel.value;
};
然后在异步任务的过程中,添加足够多的if(isCancelled()) return
,尽量降低中断的粒度就行了。
四、内存优化
图片处理不光是一个 CPU 敏感的业务,还是一个内存敏感的业务,所以在上面做了提升性能的各种方案过后,还需要对内存进行控制,不然很容易就内存警告或 OOM 了。
1、数据模型无关的缓存设计
在需要让用户配置不定数量的数据模型的组件设计中,一般使用数组或代理方法的方式配置,数组的特点就是始终会持有所有数据模型,代理的特点就是用完数据模型即扔掉。
那么,若数据模型缓存的数据将会占有大量的内存怎么办?我们需要一套内存管理及淘汰策略,那么如何来设计呢?
最容易想到的方案
既然数据模型缓存有大内存,直接将数据模型释放不就行了,那么就必须要让用户使用代理的方式配置数据源,才能用完就释放。但是这样又带来一个问题,如果用一个释放一个,那么用户切换到上一个数据又得重新加载了。
所以,还需要做一个局部的缓存,将一定数量的数据模型缓存起来(比如缓存 9 个),最大限度保证用户体验。
但是,这种方案无法支持数组。
优化过后的方案
笔者建议的方式是,使用与数据模型无关的内存管理策略,具体做法如下:
1、定义一个内存管理中介者。
2、定义所有数据模型可访问的内存管理散列容器(key:数据模型地址, value:大内存数据)。
3、定义散列容器的数据淘汰策略(比如直接用 NSCache 控制数量)
4、将数据模型产生的大内存数据存入散列容器,而自身不去引用。
5、使用时从散列容器中拿数据。
6、数据模型释放时从散列容器中删除数据。
如此,便可以同时支持数组和代理配置方式,进行无障碍缓存淘汰了。
中介者是否使用单例
这个图片内存管理中介者第一反应可能是做一个单例,实际上不需要,如果是单例的话可能由于数据模型的内存泄漏而导致单例内存清除不彻底,做为一个常驻内存是非常可怕的。
所以中介者应该每一个图片浏览器单独一个,然后跟随图片浏览器的释放而释放,把主要责任给一个人而不是分摊到所有人也是一个理念吧,况且在这个场景下,跟随图片浏览器释放是一个保底方式(数据模型释放异常)。
正在使用数据的防护
这种方案带来的问题就是当前数据正在使用,然后中介者就将它释放了,这会带来异常,所以笔者额外使用一个散列容器来持有目前不能释放的数据,当数据模型的失去delegate
时,移除这个散列容器对应的数据。
2、图片裁剪的优化
当图片过大需要压缩显示,放大时图片的”可视区域”表明了在原始图片中的大小和位置,裁剪原图的这个区域以显示清晰图片。当原图很大时这个区域也会比较大,如果直接让原图绘制在这样一个上下文中会消耗很大的内存,我们需要的仅仅是屏幕所显示的大小就行了,所以要将这个“可视区域”的较大图绘制到更小的上下文中。
目前的解决方案是:先CGImageCreateWithImageInRect(...)
生成CGImageRef
(未解码时不会造成内存负担),然后将这个比较大的”可视区域”缩小到屏幕的大小范围,最后将CGImageRef
绘制上去。
然而当触发裁剪的比例比较小时(比如放大 0.1 倍就触发裁剪),仍然会消耗很大的 CPU 资源,为了减轻这种问题,组件内部让触发裁剪的缩放比例与原图的比例正相关动态变化。
3、低内存设备降低性能
这是最后的补救措施,低内存设备确实在加载数张比较清晰的图片时会非常吃力,CPU 也不给力,所以组件内部在物理内存较小的设备中直接降低性能,减少内存占用。
后语
每过段时间都会审视自己的代码,重构是个苦力活,特别是代码量比较大,逻辑较为复杂的项目,别看这篇文章三言两语,因为都是些结论。
做完过后确实是有所收益的,但是收益不大,耗费了大量的脑细胞,很多时间可能都是纠结于系统框架的各种坑。对于 YBImageBrowser 理论上我是不会再大规模重构了,花费了太多时间。
天天看到各位大佬们刷题,心里比较慌,所以还是要选择一个收益比较大的方式学习,接下来开始加入刷题大军?