本文是来自@indulge_in的投稿
支持 cocopods,功能完善,性能不错,代码质量尚可,喜欢的朋友可以给个小星星。
为了适应组件的自定义需求,代码和逻辑有点多,所以尽量不要修改源码,有什么问题或者建议可以在本文或者 github issues 留言。
本文讲解 YBImageBrowser 的组件设计思路和部分技术实现原理,对本框架有兴趣的朋友可以看看,若只是想使用该框架,请直接移步 github 。行文的重点是笔者的框架设计理念、代码及体验优化的思考、关键技术点的实现,希望不管是老鸟还是新手看完之后都能有所收获和感悟。
欢迎大家交流探讨,当然,笔者水平有限,若有大佬指教不胜感激。
一、组件框架整体设计
二、组件中如何隐藏属性和方法
三、拖拽动效的算法优化
四、分页间距的算法优化
五、内存的优化
六、预下载和任务同步
七、屏幕旋转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里面,然后组件其他文件通过
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页 之间的临界点时,界面会突然向左或者向右跳动一段距离,因为这里就是上面所说方式判断移动的触发点,显然这不够平滑。
于是组件中笔者的做法是,在每次重写布局时,都移动一个距离:当前偏移量 / 最大偏移量 * 总共页间距
其实做法很简单,但这种思维方式却非常实用,在我们做很多需要平滑过渡的逻辑时(不局限于界面),都可以以这种思维做出“平滑”的效果。
由于如今的 APP 做的越来越复杂,作为一个合格的移动端程序员,我们需要时刻关注内存问题,虽然这并不是刚需。
在读取本地图片时,使用[UIImage imageNamed:]方式时系统会缓存该图片,而释放缓存的时机很微妙。所以在使用比较大、调用频率低的图片时,尽量使用读取文件的方式做:
[UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:fileName ofType:fileType]]
这样虽然能减少累加的内存,但若一张图片就非常大呢?系统将它解压过后将会占用比你想象中更大的内存,APP 可能变得非常卡顿甚至崩溃。
于是,组件中设置了一个 pt 的界限,当图片超过这个界限,组件会自动 异步压缩 到当前屏幕最大显示 pt 数量,当用户拖动或缩放放大图片时,组件会自动 异步裁剪 可视区域的图片,通过一张前景图片显示出来(当然裁剪也是有最大限度的)。
思路就两句话,实际逻辑结合其他功能会比较复杂,有兴趣可以看看代码,这里不过多阐述。
组件内部是利用SDWebImage做的下载和缓存,在每一个model释放的时候,都会将对应的下载任务取消已节约网络和内存开销。
为了提高用户体验,在配置图片浏览器图片对应的model的时候,可以通过 API 设置异步预下载,当网络状况不错的时候,可能用户打开浏览器图片就下载好了,毕竟图片浏览器是有很短的创建时间和较长的入场时间的。
其实这也是一种提升效率的思维,我们要习惯性的去思考利用程序的空闲预先做一些任务,才能编写出高效的代码。
这里有一个点需要注意,若我们执行了预下载,而在图片浏览器打开的时候,图片仍未预下载完成,而此刻又会执行正式的下载,它们之间如何信息同步?
哈哈,其实很简单,就是将同一类的任务放到同一个地方统一管理,比如本组件就是将 图片下载、图片缓存、图片压缩、图片裁剪 等都放到图片数据模型YBImageBrowserModel中处理,其它地方就用方法调度这些任务,虽然可能会造成看起来比较多的方法调用,但是对稳定性、容错率的提高不容小觑。
这种思维很重要,可以不严密的理解为 AOP,功能分类集中管理。
组件支持了旋转功能,由于采用的是 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;
其实吧,这个地方笔者感觉设计得比较鸡肋,容笔者有更好的想法的时候更新组件。
看到这里可能有的朋友有些蒙,这通篇都说些什么,没一句完整的代码。哈哈,实际上这就是组件的核心,是我花了许多时间做的一些思考和总结,科普基础知识挺费劲的,百度就是一大篇一大篇的,我相信本文的价值还是有的。
越来越觉得有位朋友的话很有道理:编程是靠思维的东西。
希望大家共勉~
作者:indulge_in
链接:https://www.jianshu.com/p/bff0c6d89814