iOS图片浏览器 (支持视频)
本文主要讲述 YBImageBrowser 的一些功能技术细节,代码架构思路,设计模式选择等,希望对组件原理感兴趣的朋友有所帮助,也可以作为如何高效构建图片浏览器的参考资料。
概览
一、组件的视图层次
二、面向协议的设计模式
三、迪米特设计原则
四、当多线程遇上复用机制
五、异步任务的重复请求
六、巧用观察者设计模式
七、屏幕旋转的处理
八、三方图片处理框架的选择
九、异步解压的思考
十、意外释放的危机处理
十一、何时将变量放入全局区
十二、巧用局部 Block
十三、手势交互动效的技术细节
十四:分页间距的优化
闲谈
图片浏览器在移动端信息流业务中有着重要的地位,它的功能设计和交互体验都在不断演化。知名 APP 里的图片浏览器往往能引领潮流,比如“微信”、“微博”、“今日头条”、“知乎”、“QQ”等,它们的实现有很多相似之处,也有些设计上的瑕疵,其中交互和功能做得比较好的是“微信”和“微博”。
这里不得不吐槽“掘金” iOS APP 蹩脚的图片浏览器了,稀土掘金作为一个新兴的技术分享平台在这一点上确实让人失望,挺久之前笔者还提过建议,但迭代了 n 次版本都未进行优化,交互体验极差,BUG 满天飞,让笔者有多次想要卸载的冲动。
话说回来,开源社区有不少的图片浏览器,不过不管是从功能上,还是代码质量上都不能让笔者满意,所以几个月前笔者自己做了一个,开源社区的反馈还行,收获了不少 star,不过也发现了一些问题,比如臃肿的代码设计难以维护,严重的耦合难以自定义和拓展。
所以,笔者花了挺多时间重做图片浏览器,从功能、技术细节、代码架构都做了大量改进和优化,尽可能保证代码质量、提高可维护性和拓展性。
YBImageBrowser 2.x 版本已更新,如果项目中的图片浏览器过于蹩脚,替换掉它吧。笔者会抽时间维护和升级,打造开源第一是追求也是激励。
一、组件的视图层次
考虑到屏幕旋转的适配,笔者使用 UIViewController 作为图片浏览器的主体类,同时也方便做自定义的转场效果。内容的载体是 UICollectionView ,可以避免手动实现复用机制,并且可以优雅的管理布局。UICollectionViewCell 作为主要显示内容的载体,组件实现了两个,一个支持图像,一个支持视频。
除此之外,组件有两个概念,一个是工具栏 (ToolBar) ,一个是弹出视图 (SheetView)。”TooBar” 视图层级是在内容载体 UICollectionView 之上的,组件中默认实现了一个显示页码的 “TooBar”;”SheetView” 是需要的时候添加到 UIViewController 上,它的层级可以理解为组件内部最高。至于它们如何架构和自定义后文会阐述。
二、面向协议的设计模式
显示内容的载体目前有图像和视频,笔者先是考虑过写一个 UICollectionViewCell 的基类,利用多态来做子类的自定义,然而这样会带来问题:一是若组件使用者想要拓展内容载体但却不便于继承这个基类;二是继承本身带来的问题,虽然子类之间不直接接触,但是它们有同一个父类,若想组件和这些子类之间不直接耦合,必然要频繁的对这个基类做更改,牵一发而动全身,并且对于方法重载来说,不好准确的限定是否必须重载,是否需要调用父类方法。
继承往往是灾难的开端,所以,多态的解决方案被淘汰。
换个思路来思考,组件主体对内容载体也就是 UICollectionViewCell 的关系应该是无耦合的,就像上面多态的思路,组件只关心这个基类,而不直接和子类交互。我们无非是想遵守依赖倒置原则,既然想到这个设计原则,很容易想到面向协议的设计模式。
所以,笔者在组件中创建了数个协议:
YBImageBrowserCellDataProtocol.hYBImageBrowserCellProtocol.hYBImageBrowserToolBarProtocol.hYBImageBrowserSheetViewProtocol.h
正如你所见,对于 “ToolBar” 和 “SheetView” 都有独自的协议。组件主体和这些视图都与协议耦合而不依赖对方,笔者可以优雅的移除或者添加视图元素,使用者也可以轻松的实现这些协议来自定义界面。
“我不关心你是不是鸭子,只要你会‘嘎嘎’叫并且有两只脚我把你当做鸭子”。
三、迪米特设计原则
在组件设计中,应该尽量遵循迪米特原则,在 OC 编程中会存在一个问题,属性和方法没有 protect,写在 .h 中的是公开的,写在 .m 中是私有的,所以对于某个对象来说,其子类和其它类的访问权限可以说是一样的。
解决这个问题的方案有几种,最简单的是将两个类的实现写在同一个文件,但是很多时候不希望这么做;笔者之前的版本中使用过objc_msgSend直接发送消息,也使用过 KVC 直接访问实例变量,虽然从效率的角度来看无伤大雅,使用 Runtime 甚至更快,但是代码却有些晦涩。
最终笔者选择了一种比较优雅的方式,使用独立文件的延展 (Extension) 来做“知识”隔离控制:
文件:YBImageBrowser+Internal.h@interface YBImageBrowser ()@property (nonatomic, strong) YBImageBrowserView *browserView;@end
YBImageBrowser+Internal.h 延展虽然是一个独立的文件,但是仍然是 YBImageBrowser 类的一部分,里面的方法和属性都是在编译期决议的,所以延展里面的属性是会自动生成实例变量的。这不同于分类 (Category) ,分类是运行期动态注入类中,所以只能添加方法而不能添加实例变量。
那么,在需要调用这些方法的类中导入 YBImageBrowser+Internal.h 就能访问了。
四、当多线程遇上复用机制
多线程和复用机制看似互不相干,却会碰撞出意外的 BUG。
举个例子,一个 Cell 中的 UIImageView 在异步线程发起一个下载图片的网络请求,UITableView 在这期间滑动,触发了复用机制,该 Cell 的数据源更换,它的 UIImageView 又发起了另外的一个下载图片请求,当第一次网络请求成功返回图片的时候,已经不是这个 Cell 的 UIImageView 期望的图片了。
因为复用机制的问题,视图不能作为可信的异步回调接收者,但是数据却可以:
id tmpData = self.datanetworkAsyc^{ if (tmpData == self.data) { update UI. }}
在 UITableView 滑动的时候,会不断的为 Cell 更新数据源 data,所以 cell.data 表示的就是 Cell 当前的数据状态,创建一个临时变量让 Block 持有它,这个临时变量就是异步网络请求所对应的数据。
这应该是最简单的处理方案。SDWebImage 是为 UIImageView 动态关联一个请求标识来判定最新的网络请求 URL,YYWebImage 是为 UIImageView 计数,通过异步回调回来的计数和局部计数变量比较来判定。
但是组件中并没有使用这种方法,而是使用了观察者设计模式来巧妙解决,后文会讲解。
五、异步任务的重复请求
对于图片浏览器每一个图像,都有一个数据模型 data,当异步操作回调过后,虽然可以通过对比 cell.data 和 block 持有的 data 来判断是否需要进行 UI 刷新,但是却不能解决另外一些问题:
1、当 Cell 进入复用池的时候,是否需要放弃它发起的未完成的异步操作?
当然,并不是所有异步任务都是可以中断的,发起的异步操作消耗了一定资源,笔者认为不应该放弃掉,而是将结果存储在异步回调 Block 持有的 data 中,至于 UI 刷新与否按照之前说的方法判断。
那么就带来了另外一个问题:
2、当来回滑动 ScrollView,如何避免 Cell 反复发起异步请求?
这种情况经常出现,如果脱离业务来思考,对于一个同一个异步请求多次调用,应该使用一个数组来将所有发起请求的 Block 回调存储起来,并且若正在异步请求要及时返回,当异步请求完成,遍历数组中的回调 Block 分别调用。
实际上关于网络的框架都有类似的处理,比如 AFNetworking、SDWebImage 之类,它们可以通过 URL 来判断是否是重复的请求。
落地到图片浏览器中,若想判断某个异步请求是否是同一个,通过请求参数来判断有些复杂,最直接的方法就是把异步请求都写在 data 中,比如图片压缩异步请求,对于同一个 data 就很好判断是否正在压缩,只需要一个 BOOL 值。
在图片浏览器的功能设计中,笔者加入了预加载的功能,也就是说,data 中的这些异步操作并不都是在显示界面的时候由 cell 来调用,而是在创建 data 的时候就会调用。
比如在创建网络图片 data 的时候,就要发起异步请求下载图片,而当图片浏览器展示当前 data 对应的 cell 的时候,异步请求还未完成,cell 又调用 data 发起了相同的异步请求。这时候在异步请求中就要用一个指针存储这个 cell 发起异步请求的回调 Block,在异步请求成功的时候调用这个 Block,这带来了潜在的循环引用问题,并且代码观感非常差。
并且实际情况比这个更为复杂,在笔者的图片浏览器中,一个 data 需要进行的异步请求可能有好几个,比如异步查询缓存、异步解压、异步下载、异步压缩、异步裁剪,若统统使用这种方式处理,将会是代码维护的灾难。
六、巧用观察者设计模式
问题的本质就是,data 中的异步任务结果要在 cell 需要的时候通知它,而在 cell 不需要的时候默默执行。
笔者最终决定采用观察者模式,考虑到业务的特殊性,对于同一个 data,基本上异步操作是串联的,也就是说,不会在下载的同时异步压缩,不会在异步查询缓存的时候下载。所以,基本上同一时刻,data 的状态是唯一的,如此,对于组件中的 YBImageBrowseCellData,定制了一系列的状态:
typedef NS_ENUM(NSInteger, YBImageBrowseCellDataState) { YBImageBrowseCellDataStateInvalid, YBImageBrowseCellDataStateImageReady, ... YBImageBrowseCellDataStateIsDownloading, YBImageBrowseCellDataStateDownloadProcess, YBImageBrowseCellDataStateDownloadSuccess, YBImageBrowseCellDataStateDownloadFailed,};
在异步请求的过程中,更新这些状态。
而对于 cell,只需要在赋值 data 的时候观察这个 state,在进入复用池等情况移除就行了。state 改变的时候,就做一些 UI 操作,比如 YBImageBrowseCellDataStateDownloadProcess 更新下载进度条,在YBImageBrowseCellDataStateDownloadFailed 显示下载失败文案。
这是观察者模式比较好的实践,但有一点需要注意,若有某些异步任务不是串联的,需要设置另外一个 state 枚举。
七、屏幕旋转的处理
有两个概念,一个是设备的方向通过 UIDeviceOrientationDidChangeNotification 添加通知,一个是状态栏的方向通过 UIApplicationDidChangeStatusBarOrientationNotification 添加通知。
通常情况下,状态栏的方向可以确定当前控制器的布局方向,所以通过监听状态栏的方向更新子视图的布局。
组件采用 UIViewController 作为主体,通过重写如下方法自定义旋转方向:
- (BOOL)shouldAutorotate { return YES;}- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return self.supportedOrientations;}
但其实当前控制器实际允许旋转的方向受很多因素控制。一是 general -> deployment info -> Device Orientation 中勾选的设备支持的旋转方向,它的优先级是最高的;二是在 AppDelegate 中实现的 代理方法 -application:supportedInterfaceOrientationsForWindow:,它的优先级次之;三是若当前控制器是栈内的,它的旋转方向由 UINavagationController 重载的 -shouldAutorotate 和 -supportedInterfaceOrientations 方法控制,若存在 UITabBarController,它将控制它管理的那些控制器的旋转方向。
所以,实际上组件内部可以说无法准确的获取到 YBImageBrowser 这个控制器实际支持的方向,这些逻辑需要开发者自行去解决。
TODO
关于自定义转场,需要设置如下代码:
self.transitioningDelegate = ...;self.modalPresentationStyle = UIModalPresentationCustom;
UIModalPresentationCustom 模式下,才能做到完美的出场和入场动效,但是有个非常蛋疼的地方,若在该模式下,图片浏览器旋转的时候,它的 presentingViewController 会跟着旋转,不管 presentingViewController 是否支持这个方向。然后在图片浏览器 dismiss 的时候,presentingViewController 方向并不会恢复。
这个问题笔者未找到完美的解决方案,看了一下“微博”的图片浏览器貌似也是类似的实现方式,在横屏的时候出场是立即触发的,猜测可能是此刻将屏幕旋转回来。
所以,尝试了一下,若当前图片浏览器的方向和 presentingViewController 起始的方向不同,将取消手势交互动效,直接 dimiss 转场,并且在转场的同时强制旋转屏幕。
然而预期的效果和“微博”并不一样,强制转场有一定的延时。若读者朋友有解决方案还望指点一下,目前就采用这个处理方案,作为一个待完成的优化吧。
八、三方图片处理框架的选择
上一个版本是使用 SDWebImage + FLAnimatedImage 来处理的,但是感觉使用体验不太好,在创建本地图片的时候需要用户判断当前图片是不是 gif,所以后来笔者选择了功能更强、代码质量很高的 YYImage 做为 GIF 的处理框架,它还支持 APNG、WebP 等格式,使用也很简单,完全兼容 UIImage。YYImage 原理可看笔者的一篇博客:YYImage 源码剖析:图片处理技巧。
吐槽一下 SDWebImage 蹩脚的缓存设计
它的内存缓存就是一个 hash 容器,没有缓存策略,不及基于 LRU 淘汰算法的 YYMemeryCache。
SDWebImage 缓存策略中有一个逻辑,在磁盘缓存中查找到了缓存,会解压过后放入内存缓存,若这个图片是 GIF 的,它就会解压为第一帧图片,不能满足我们的需求。
从解压过后是否放入缓存说起:它是由 [SDImageCache sharedImageCache].config.shouldCacheImagesInMemory 决定的,所以一开始我想要在框架生命周期内禁止它。然而 shouldCacheImagesInMemory 同时决定了调用 -stroreImage:imageData:forKey:toDisk 的时候是否缓存到内存,所以这个属性是不能设置为 NO 的,否则内存缓存永远存不进去。
发现了么,死循环,要想 -stroreImage:imageData:forKey:toDisk 支持内存缓存,就要 shouldCacheImagesInMemory 为 YES,而它为 YES 就会错误的同步 GIF 的第一帧到内存缓存。
以 SD 的思路,最好的解决方案就是使用 SDWebImage 的 GIF 分类 + FLAnimatedImage 显示了,SD 解压的 GIF 图片类型可以由 FLAnimatedImageView 解析。这个设计让我有些无语,有种捆绑销售的感觉,在这个需求下,SD 的拓展性做得不太友好。
之所以选择 SDWebImage 是因为它的人气最高,并且长期有人维护,然而我又舍不得放弃强大的 YYImage,所以目前的处理方式就是放弃内存缓存,每次从磁盘查找。
然而笔者在 SD 添加缓存的源码中又看到了这样一个出其不意的判断:
- (void)storeImage:(nullable UIImage *)image imageData:(nullable NSData *)imageData forKey:(nullable NSString *)key toDisk:(BOOL)toDisk completion:(nullable SDWebImageNoParamsBlock)completionBlock { if (!image || !key) { if (completionBlock) { completionBlock(); } return; }...
在 image 不存在的时候,居然直接返回,这不得不让组件在下载完成的时候同时传入 NSData 和 UIImage 对象,然后 SD 就会做磁盘和内存缓存。然而,组件内部暂时又不需要内存缓存。
SDWebImage 缓存方面的拓展性确实不能让人满意,也坚定了笔者替换掉它的想法,在后面的版本中,考虑的是用 YYWebImage 替换它,虽然 YYWebImage 很久不维护了,使用的时候可能需要做一些源代码调整,但是能逃脱 SDWebImage 的魔爪这个成本还是可以接受的。
缓存共享问题
组件用到了缓存,而开发者自己的业务中同样用到了缓存,它们之间如何共享是一个问题,若是用的同一个缓存框架还好说,若不是就比较麻烦了。因为不同的图片处理框架对缓存的处理或多或少有些差别,很多时候通过上层的 API 做不到联合查找缓存,所以关于这个待优化的功能,笔者还需要考虑一些时间。
下载框架的替换问题
用 SDWebImage 或 YYWebImage 的开发者总是看不上另一个框架,这也是个恼人的问题,若笔者自己实现却又感觉成本太高,这个问题同样需要考量一下。
九、异步解压的思考
另外值得一提的是,图片浏览器做了高清图的压缩和裁剪,所以只要框架使用者不去改变这个临界值,图片绘制基本上不会消耗 CPU 和 GPU 格外的资源去裁剪高清图,而且图片浏览器同时最多有两张图片在界面上,解压的压力不大(并且高清图组件已经解压了图片),所以对图片的解压和绘制并不会成为性能瓶颈。
那么,对于业界提高图片绘制性能的常用做法:异步解压,图片浏览器就不再需要,当数据模型都被图片浏览器持有,且图片都比较大时,异步解压缓存的内存无法及时释放,甚至还会造成内存的过多负担。由此,笔者取消了 SDWebImage 所有异步解压操作;将 YYImage 复制到 YBImage,把异步解压逻辑取消掉,并且便于以后的自定义。
十、意外释放的危机处理
就比如 UIViewController,它并不是每次释放都会走 -viewWillAppear: 方法,可能内存强制清理或者闪退等导致意外释放。只要是释放,理论上就会走 -dealloc 方法,所以在这个方法中需要做一些危机处理。
在组件的设计中,应该尽量避免对外部业务的直接操作,但是有的时候又不可避免,比如图片浏览器要做这个效果:
图片浏览器当前展示哪张图片就将业务外的哪张图片隐藏,为了方便用户使用,组件不得不操作外部视图变量使其隐藏或者显示。那么,考虑到意外释放等问题,对外部操作的复位应该写在 -dealloc 中:
- (void)dealloc { // If the current instance is released (possibly uncontrollable release), we need to restore the changes to external business. [YBIBWebImageManager restoreOutsideConfiguration]; self.hiddenSourceObject = nil;}
-restoreOutsideConfiguration 方法是恢复对三方组件的修改,-setHiddenSourceObject 方法就是对外部隐藏的图片的复位。
十一、何时将变量放入全局区
YBImageBrowseCellData 是组件处理图片的数据源,它不应该和 YBImageBrowser 耦合,甚至 YBImageBrowser 都不应该知道它的存在,那么,对于 YBImageBrowseCellData 的全局配置如何做?答案就是使用全局变量:
@property (nonatomic, class) YBImageBrowseFillType globalVerticalfillType;@property (nonatomic, assign) YBImageBrowseFillType verticalfillType;
对于纵向的填充类型,同时包含实例变量和全局变量,全局变量针对所有的 YBImageBrowseCellData 实例,而实例变量针对某一个,这是组件内部常用的伎俩。
值得注意的是,全局区变量生命周期会延长到程序结束,所以对于内存占用比较高的变量需要慎重考虑是否放入全局区,或者手动管理它的内存释放。
十二、巧用局部 Block
经常会有一些需求,比如某段动画可以选择是否执行,可以如下处理:
void (^animationsBlock)(void) = ^{ ... }; void (^completionBlock)(BOOL) = ^(BOOL x){ ... }; if (duration <= 0) { animationsBlock(); completionBlock(YES); } else { [UIView animateWithDuration:duration animations:animationsBlock completion:completionBlock]; }
创建两个栈区的 Block,若需要动画就传入 -animateWithDuration: 系列方法,若不需要动画 Block 就不用被拷贝到堆区,而是直接调用。这样处理还有一个好处就是不用重复写两个 Block 中的业务逻辑了,避免格外的方法封装。
十三、手势交互动效的技术细节
图片浏览器的手势交互并非看起来的那么简单,图片的放大状态、UIScrollView 的回弹和减速机制、嵌套 UIScrollView 的手势冲突,这些都可能会导致一些难以控制的情况出现。
手势交互效果的实现载体
“微博”的图片浏览器在手势交互的时候应该是借助了其它的视图,因为每次对 GIF 的拖动都会回到第一帧,这样体验并不是非常好;而“今日头条”的图片浏览器在手势交互的时候 GIF 会暂停,一开始笔者还以为在 runloopMode 为 UITrackingRunLoopMode 的时候停止了 GIF 动图播放,然而当手势交互结束时,GIF 的播放位置发生了变化,可以确定播放 GIF 的 runloopMode 仍然是 NSRunLoopCommonModes,只是借助了其他视图做动效。
综上,“微博”和“今日头条”的交互设计都不太完美。
一个好的动效应该尽量减少不必要的额外视图和逻辑,所以笔者通过对 cell.contentView 的操作来实现拖动动效,并且 GIF 的播放 runloopMode 为 NSRunLoopCommonModes ,所以在拖动的时候 GIF 仍然会播放,这样保证最佳的用户体验。对视频的交互的处理方式基本是一样的,在拖动的时候视频仍然能播放。
手势交互移动缩放的算法实现
实际上在上个版本的代码中,YBImageBrowser 使用了一个稍显复杂的算法来实现图片移动的同时缩放,后来笔者实践了一种更为简洁的方法,优雅了许多:
CGRect startFrame = ...;CGFloat anchorX = point.x / startFrame.size.width,anchorY = point.y / startFrame.size.height;self.mainContentView.layer.anchorPoint = CGPointMake(anchorX, anchorY);
实际上就是将触发交互的那个 point 作为动画视图的锚点,然后更新动画只需要通过触摸点更新 center、借助 CGAffineTransform 实现缩放就行了,交互移动缩放的效果算是比较完美了。
手势交互触发点的优化
手势交互动效一旦触发,就要让两个 UIScrollView 禁止滑动,所以这个触发点不能过于灵敏,不然用户切换图片的时候会一不小心触发。
大致的处理如下(伪代码):
BOOL can = ABS(currentPoint.x - startPoint.x) > triggerDistance && ABS(currentPoint.y - startPoint.y) < triggerDistance;
可以理解为:当用户拖动离垂直方向最小角度的绝对值小于 45° 的时候就会允许触发。这样也同时解决了超清大图展示的时候,在边缘拖动频繁触发手势交互动效的问题。
如此处理过后,当用户快速滑动切换图片的时候,还是经常会触发手势交互动效,测试发现当拖动速度过快,panGesture 响应的 point 并非绝对的准确,所以笔者索性加入了一个速度判断(伪代码):
CGPoint velocityPoint = [panGesture velocityInView:...];BOOL can = ABS(velocityPoint.x) < 500;
至此,触发点的问题基本解决。
十四:分页间距的优化
分页间距,作者做过好几次方案,都或多或少有些问题,后来思考了一下,做了一个比较完美的效果:
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { NSArray *layoutAttsArray = [[NSArray alloc] initWithArray:[super layoutAttributesForElementsInRect:rect] copyItems:YES]; CGFloat halfWidth = self.collectionView.bounds.size.width / 2.0; CGFloat centerX = self.collectionView.contentOffset.x + halfWidth; [layoutAttsArray enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { obj.center = CGPointMake(obj.center.x + (obj.center.x - centerX) / halfWidth * self.distanceBetweenPages / 2, obj.center.y); }]; return layoutAttsArray;}
一句话概括:离屏幕中心越远,Item 的中心点偏移越多。
实际上对于 UICollectionView 的自定义 layout,只需要时刻记住一个准则就不会出现问题:
布局的更新一定是线性的,而不能跳跃。
后语
一个看起来简单的效果并非真的简单,当你觉得它简单的时候,思考一下是不是自己太菜,每一个问题深入过后都有很多衍生的东西,周全考虑性能、内存、可维护性、可拓展性是对代码架构能力的考量。
希望本文能给读者朋友带来帮助。
作者:indulge_in