YBImageBrowser 3.x 版本已经更新。
最新博客地址 : YBImageBrowser 重构心得:如何优化架构、性能、内存?
GitHub 地址 : iOS 图片浏览器 (支持视频)
注意:本文大部分方案新版本都已经废弃,请看最新博客。
写在前面
索引:
- 一、组件框架整体设计
- 二、组件中如何隐藏属性和方法
- 三、拖拽动效的算法优化
- 四、分页间距的算法优化(再次改造)
- 五、内存的优化
- 六、预下载和任务同步
- 七、屏幕旋转UI适配
一、组件框架整体设计
其实对于图片浏览器,开源项目也有不少,不管是代码上还是功能上没有一个能完整的满足笔者的需求。所以笔者索性做了一个,力图将粒度做小,功能做全,当然这需要一个漫长的过程,空闲时间笔者会持续迭代和优化。
目前采用的是 UIViewController 做为底,上层是一个横向滚动的 UICollectionView ,在 UICollectionViewCell 上面是 UIScrollView ,当然还包括主要显示图片、动画图片、裁剪显示前景图片等。
使用 UICollectionView 是为了利用苹果为我们做的复用机制,不需要专门去实现,不然逻辑代码太多,得不偿失;而缩放的效果依托于 UIScrollView ;采用 UIViewController 为底是为了更好的控制旋转屏幕时的UI适配,之前也是考虑更轻一点的 UIView,但是它会受父视图的旋转影响,可能适配难度会翻几倍,而且使用 UIViewController 能更方便和优雅的实现图片浏览器的入场和出场动画。
二、组件中如何隐藏属性和方法
在做一个组件的时候,我们往往思考着向用户隐藏某些细节实现,一方面是为了避免用户的无意更改,一方面是为了简化 API 使其看起来更清爽。
对于属性,若想让用户只读不可写,可以在.h
中对属性使用readonly
修饰符;若根本不想要用户看到,可以直接将该属性创建在需要使用的目标类的.m
文件内。
不过这样并不优雅,意味着我们很多代码和类必须搞到同一文件,才能达到外部无法直接访问,而内部可以访问的目的。若我们想分离多个文件好管理代码和实现更优秀的架构时,不得不将属性写到.h
里面让其他文件可以访问。
那么,何不换一种思路?尽管我们将属性写在.m
中隔离外部访问,实际上用户仍然可以用 KVC 的方式读写,那么我们框架组件内部为何不使用 KVC 进行读写?
于是,在组件的YBImageBrowserModel
的.h.m
文件中你可以看到这样的代码:
.h 中
FOUNDATION_EXTERN NSString * const YBImageBrowserModel_KVCKey_isLoading;
FOUNDATION_EXTERN NSString * const YBImageBrowserModel_KVCKey_isLoadFailed;
.m 中
NSString * const YBImageBrowserModel_KVCKey_isLoading = @"isLoading";
NSString * const YBImageBrowserModel_KVCKey_isLoadFailed = @"isLoadFailed";
这里使用字符串常量存放 KVC 的键,组件内部就使用valueForKey:
和setValue:forKey:
通过这些常量来优雅的读写实例变量了。
对于方法的隐藏,组件中不将方法暴露在.h
里面,只写在.m
里面,然后组件其他文件通过
下的objc_msgSend
方法处理,比如随便截取一段代码:
YBImageBrowserModelScaleImageSuccessBlock successBlock = ^(YBImageBrowserModel *backModel) {
...
};
((void(*)(id, SEL, CGRect, YBImageBrowserModelScaleImageSuccessBlock)) objc_msgSend)(model, sel_registerName(YBImageBrowserModel_SELName_scaleImage), imageFrame, successBlock);
或者使用NSInvocation
作为私有属性,外部也用 KVC 读写。
三、拖拽动效的算法优化
拖拽动效是目前很流行的图片浏览器出场效果,笔者看了好几个知名APP,“新浪微博”,“今日头条”,“QQ”,“QQ浏览器”,“微信”等都做了类似的动效,但是除了“微信”的效果人性化一点,其它的都有些不尽人意的地方。
这个效果咋一看比较简单,无非就是根据移动的距离,以某种数学关系移动图片并且缩小图片,实现可以直接计算frame
或者使用CATransform3D
等。
但是,有个容易忽略的问题,在拖动的时候我们希望看到的效果是图片跟随手指移动并且缩小,上图左右两种状态下的箭头指向的正是手指拖动触摸的点(理想状态),若写一个移动和缩放比例变化之间是线性的动画,手指触摸的点会是这种理想状态么?
答案是否定的,若移动的时候不缩放,是能达到理想状态,若缩放了状态二必然会是如下图所示:
处理方式:若是使用的动画相关的类库,可以考虑使用锚点来处理。本组件是使用frame
的方式处理,通过一张图解释如何处理这个逻辑:
实际上代码逻辑比看起来的复杂一些,有兴趣的可以看代码,这里只提出思路。
四、分页间距的算法优化
说起分页,几乎所有iOS工程师都会说.pagingEnabled
属性,又说分页间距,稍有经验的工程师都会说重写UICollectionView
的layout
,既创建一个UICollectionViewFlowLayout
类重写约束。现在这里不浪费篇幅讨论 API 的用法,你只需要知道在重写的layout
里面,几乎每一帧的界面都可以靠重写layoutAttributesForElementsInRect
等方法重新计算。
按照常规的逻辑思路,最好想到的方案是:若当前是 第n页 时,所有的 Cell 都向左移动 (n-1) * 间距。
确实,这种算法逻辑咋一看好像能解决问题,但当你滑到下图的情况下时,会发生奇怪的现象:
你会发现在滑动到 第n页 和 第n+1页 之间的临界点时,界面会突然向左或者向右跳动一段距离,因为这里就是上面所说方式判断移动的触发点,显然这不够平滑。
优化方案一
组件中笔者最初做法是,在每次重写布局时,都移动一个距离:当前偏移量 / 最大偏移量 * 总共页间距,这样就实现了“平滑”的移动。
隐晦问题:实际上这种算法的逻辑是完全正确的,但是在图片过多,需要移动的偏移量过大时,系统的UICollectionView
的复用机制发生了问题,应该是复用时判断可视区域出现了偏差,直接导致系统放弃绘制实际上可视的区域(这个BUG很奇怪,不明白苹果工程师是如何设计集合视图layout和复用机制的关系的)。
优化方案二
在该组件开源的过程中,终于有一位技术朋友发现了这一BUG,所以不得不考虑其他方案。
首先,结合于方案一的结论,我们要尽量避免重写布局移动大量的距离,以防止集合视图的复用机制失效。
第一种方式是使用UIScrollView
自己实现一套复用机制,但是考虑到时间成本,笔者果断放弃。
后来笔者灵光一现,思考出一种绝佳的方案:
找到离屏幕中心点最近的那个cell,然后将该cell的前一个cell向左移动一个间距,然后将该cell的后一个cell向右移动一个间距
五、内存的优化
由于如今的 APP 做的越来越复杂,作为一个合格的移动端程序员,我们需要时刻关注内存问题,虽然这并不是刚需。
本地图片的读取
在读取本地图片时,使用[UIImage imageNamed:]
方式时系统会缓存该图片,而释放缓存的时机很微妙。所以在使用比较大、调用频率低的图片时,尽量使用读取文件的方式做:
[UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:fileName ofType:fileType]]
超大图的处理
这样虽然能减少累加的内存,但若一张图片就非常大呢?系统将它解压过后将会占用比你想象中更大的内存,APP 可能变得非常卡顿甚至崩溃。
于是,组件中设置了一个 pt 的界限,当图片超过这个界限,组件会自动 异步压缩 到当前屏幕最大显示 pt 数量,当用户拖动或缩放放大图片时,组件会自动 异步裁剪 可视区域的图片,通过一张前景图片显示出来(当然裁剪也是有最大限度的)。
思路就两句话,实际逻辑结合其他功能会比较复杂,有兴趣可以看看代码,这里不过多阐述。
下载任务的释放
组件内部是利用SDWebImage
做的下载和缓存,在每一个model
释放的时候,都会将对应的下载任务取消已节约网络和内存开销。
六、预下载和任务同步
为了提高用户体验,在配置图片浏览器图片对应的model
的时候,可以通过 API 设置异步预下载,当网络状况不错的时候,可能用户打开浏览器图片就下载好了,毕竟图片浏览器是有很短的创建时间和较长的入场时间的。
其实这也是一种提升效率的思维,我们要习惯性的去思考利用程序的空闲预先做一些任务,才能编写出高效的代码。
这里有一个点需要注意,若我们执行了预下载,而在图片浏览器打开的时候,图片仍未预下载完成,而此刻又会执行正式的下载,它们之间如何信息同步?
哈哈,其实很简单,就是将同一类的任务放到同一个地方统一管理,比如本组件就是将 图片下载、图片缓存、图片压缩、图片裁剪 等都放到图片数据模型 YBImageBrowserModel
中处理,其它地方就用方法调度这些任务,虽然可能会造成看起来比较多的方法调用,但是对稳定性、容错率的提高不容小觑。
这种思维很重要,功能分类集中管理。
七、屏幕旋转UI适配
找到组件必然支持的方向
组件支持了旋转功能,由于采用的是 UIViewController 作为底类,理所当然的是让组件内部子控件跟随 UIViewController 的旋转而旋转,目前不支持强制旋转,因为可能会有些麻烦,后期迭代考虑增加。
UIViewController 的旋转会直接受到工程 general -> deployment info -> Device Orientation 处的影响,所以,在判断组件支持的旋转方向的时候,需要取一个交集:
- (void)configSupportAutorotateTypes {
UIApplication *application = [UIApplication sharedApplication];
UIInterfaceOrientationMask keyWindowSupport = [application supportedInterfaceOrientationsForWindow:window];
UIInterfaceOrientationMask selfSupport = ![self shouldAutorotate] ? UIInterfaceOrientationMaskPortrait : [self supportedInterfaceOrientations];
supportAutorotateTypes = keyWindowSupport & selfSupport;
}
然后这个交集就是 UIViewController 可能旋转的方向,也就是组件可能旋转的方向。
布局更新时机优化
大家很容易就想到,当设备旋转过后,若组件支持该方向,就通知所有子界面刷新布局(可能有人会说用autolayout,但是考虑到效率和可控性方面的问题,本组件都采用frame处理)。
其实若你是这样做,已经满足了需求,剩下了可能就是繁杂的布局执行流。
然而我会说还能优化。试想一下,手机的两种竖屏状态(home在上,home在下),两种横屏状态(home在左,home在右),它们的frame
是不是一样?
所以,这里需要加入一个标识,用来存储此时当前 UIView
显示的frame
类型是“竖屏”还是“横屏”,而不是每一种屏幕状态变化都去做所有的布局更新,理论上提高了一倍的布局开销。
引入代理规范布局流程
由于通知子视图更新布局、存储当前视图分别在“竖屏”和“横屏”下的frame
、存储当前适配的屏幕方向等信息是每一个视图几乎都会做的工作(虽然细节有些差异,但我们稍宏观的看这个问题)。
于是,组件做了一个代理:
@protocol YBImageBrowserScreenOrientationProtocol
@required
// 当前视图UI适配的屏幕方向
@property (nonatomic, assign) YBImageBrowserScreenOrientation so_screenOrientation;
// 当前视图在竖直屏幕的frame
@property (nonatomic, assign) CGRect so_frameOfVertical;
// 当前视图在横向屏幕的frame
@property (nonatomic, assign) CGRect so_frameOfHorizontal;
// 更新约束是否完成
@property (nonatomic, assign) BOOL so_isUpdateUICompletely;
- (void)so_setFrameInfoWithSuperViewScreenOrientation:(YBImageBrowserScreenOrientation)screenOrientation superViewSize:(CGSize)size;
- (void)so_updateFrameWithScreenOrientation:(YBImageBrowserScreenOrientation)screenOrientation;
@end
需要跟随屏幕旋转更新布局的UIView
都实现这个代理,达到标准控制的目的,值得注意的是代理里面的属性需要自己在实现文件关联一个实例变量,类似于
@synthesize so_frameOfVertical = _so_frameOfVertical;
@synthesize so_frameOfHorizontal = _so_frameOfHorizontal;
其实吧,这个地方笔者感觉设计得比较鸡肋,容笔者有更好的想法的时候更新组件。
写在后面
本文作为历史版本的实现文章,可以探寻 YBImageBrower 的优化经历。
编程是靠思维的东西。
希望大家共勉~