×
广告

打造开源第一 iOS 图片浏览器 (支持视频)

96
indulge_in
2018.09.19 11:59* 字数 7703

博客更新日志:
2018.11.05 —— 修改异步绘制的分析。
2018.10.13 —— 修改部分内容,增加 GIF 的处理方案和局部缓存的应用说明。

Github地址 : iOS图片浏览器 (支持视频)

本文主要讲述 YBImageBrowser 的一些功能技术细节,代码架构思路,设计模式选择等,希望对组件原理感兴趣的朋友有所帮助,也可以作为如何高效构建图片浏览器的参考资料。

概览

  • 一、组件的视图层次
  • 二、面向协议的设计模式
  • 三、迪米特设计原则
  • 四、当多线程遇上复用机制
  • 五、异步任务的重复请求
  • 六、巧用观察者设计模式
  • 七、屏幕旋转的处理
  • 八、三方图片处理框架的选择
  • 九、异步解压的思考
  • 十、意外释放的危机处理
  • 十一、何时将变量放入全局区
  • 十二、巧用局部 Block
  • 十三、手势交互动效的技术细节
  • 十四、分页间距的优化
  • 十五、GIF 处理的思考
  • 十六、巧用局部缓存:实现代理数据源复用

闲谈

图片浏览器在移动端信息流业务中有着重要的地位,它的功能设计和交互体验都在不断演化。知名 APP 里的图片浏览器往往能引领潮流,比如“微信”、“微博”、“今日头条”、“知乎”、“QQ”等,它们的实现有很多相似之处,也有些设计上的瑕疵,其中交互和功能做得比较好的是“微信”和“微博”。

这里不得不吐槽“掘金” iOS APP 蹩脚的图片浏览器了,稀土掘金作为一个新兴的技术分享平台在这一点上确实让人失望,挺久之前笔者还提过建议,但迭代了 n 次版本都未进行优化,交互体验极差,BUG 满天飞,让笔者有多次想要卸载的冲动😂。

话说回来,开源社区有不少的图片浏览器,不过不管是从功能上,还是代码质量上都不能让笔者满意,所以几个月前笔者自己做了一个,开源社区的反馈还行,收获了不少 star,不过也发现了一些问题,比如臃肿的代码设计难以维护,严重的耦合难以自定义和拓展。

所以,笔者花了挺多时间重做图片浏览器,从功能、技术细节、代码架构都做了大量改进和优化,尽可能保证代码质量、提高可维护性和拓展性。

YBImageBrowser 2.x 版本已更新,如果项目中的图片浏览器过于蹩脚,替换掉它吧。笔者会抽时间维护和升级,打造开源第一是追求也是激励。

一、组件的视图层次

考虑到屏幕旋转的适配,笔者使用 UIViewController 作为图片浏览器的主体类,同时也方便做自定义的转场效果。内容的载体是 UICollectionView ,可以避免手动实现复用机制,并且可以优雅的管理布局。UICollectionViewCell 作为主要显示内容的载体,组件实现了两个,一个支持图像,一个支持视频。

除此之外,组件有两个概念,一个是工具栏 (ToolBar) ,一个是弹出视图 (SheetView)。"TooBar" 视图层级是在内容载体 UICollectionView 之上的,组件中默认实现了一个显示页码的 "TooBar";"SheetView" 是需要的时候添加到 UIViewController 上,它的层级可以理解为组件内部最高。至于它们如何架构和自定义后文会阐述。

二、面向协议的设计模式

显示内容的载体目前有图像和视频,笔者先是考虑过写一个 UICollectionViewCell 的基类,利用多态来做子类的自定义,然而这样会带来问题:一是若组件使用者想要拓展内容载体但却不便于继承这个基类;二是继承本身带来的问题,虽然子类之间不直接接触,但是它们有同一个父类,若想组件和这些子类之间不直接耦合,必然要频繁的对这个基类做更改,牵一发而动全身,并且对于方法重载来说,不好准确的限定是否必须重载,是否需要调用父类方法。

继承往往是灾难的开端,所以,多态的解决方案被淘汰。

换个思路来思考,组件主体对内容载体也就是 UICollectionViewCell 的关系应该是无耦合的,就像上面多态的思路,组件只关心这个基类,而不直接和子类交互。我们无非是想遵守依赖倒置原则,既然想到这个设计原则,很容易想到面向协议的设计模式

所以,笔者在组件中创建了数个协议:

YBImageBrowserCellDataProtocol.h
YBImageBrowserCellProtocol.h
YBImageBrowserToolBarProtocol.h
YBImageBrowserSheetViewProtocol.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.data
networkAsync^{
    if (tmpData == self.data) {
        update UI.
    }
}

在 UITableView 滑动的时候,会不断的为 Cell 更新数据源 data,所以 cell.data 表示的就是 Cell 当前的数据状态,创建一个临时变量让 Block 持有它,这个临时变量就是异步网络请求所对应的数据。

这应该是最简单的处理方案。SDWebImage 是为 UIImageView 动态关联一个请求标识来判定最新的网络请求 URL,YYWebImage 是为 UIImageView 计数,异步回调回来,通过闭包持有的计数变量和 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 中实现的 <UIApplicationDelegate> 代理方法 -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 不存在的时候,居然直接返回,这不得不让组件在下载完成的时候同时传入 NSDataUIImage 对象,然后 SD 就会做磁盘和内存缓存。然而,组件内部暂时又不需要内存缓存。

SDWebImage 缓存方面的拓展性确实不能让人满意,也坚定了笔者替换掉它的想法,在后面的版本中,考虑的是用 YYWebImage 替换它,虽然 YYWebImage 很久不维护了,使用的时候需要做一些源代码调整,并且拓展下载之类的接口,但是能逃脱 SDWebImage 的魔爪这个成本还是可以接受的。

缓存共享问题

组件用到了缓存,而开发者自己的业务中同样用到了缓存,它们之间如何共享是一个问题,若是用的同一个缓存框架还好说,若不是就比较麻烦了。因为不同的图片处理框架对缓存的处理或多或少有些差别,很多时候通过上层的 API 做不到联合查找缓存,所以关于这个待优化的功能,笔者还需要考虑一些时间。

下载框架的替换问题

用 SDWebImage 或 YYWebImage 的开发者总是看不上另一个框架,这也是个恼人的问题,若笔者自己实现却又感觉成本太高,这个问题同样需要考量一下。

九、异步解压的思考

图片浏览器做了高清图的压缩和裁剪,所以只要组件使用者不去改变这个临界值,图片绘制不会消耗过多的 CPU 和 GPU 格外资源去处理高清图。

那么,绘制在屏幕上的图片不会很大且最多有两张,笔者早些时候也认为图片在主线程的解码压力不大,然而一条 issues 让我看到了图片浏览器主线程解码的性能瓶颈。

由于 iOS 设备的 CPU 是支持 JPEG 图片格式的硬解码的,所以当图片浏览器展示多张 JPEG 图片时,主线程看起来是可以轻松解决的。然而对于颜色和细节丰富的 PNG 图片来说,主线程解码的压力倍增,导致会产生交互卡顿的现象。

所以,笔者为该组件加入了一个是否允许异步解码的配置属性,为了避免某些图片格式解码压力过大造成性能瓶颈。

当然,笔者也提倡使用 JPEG (或者 WebP) 等解码较快的图片作为该组件的数据源。对于色彩丰富的图片,PNG 不管是从图片体积还是解码速度来说都不太令人满意。

并且,避免异步解码能减轻 CPU 的压力(虽然不会阻塞主线程)、减少时间消耗,让用户更快的看到绘制过后的图像。

十、意外释放的危机处理

就比如 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 会暂停,一开始笔者还以为在 runloopModeUITrackingRunLoopMode 的时候停止了 GIF 动图播放,然而当手势交互结束时,GIF 的播放位置发生了变化,可以确定播放 GIF 的 runloopMode 仍然是 NSRunLoopCommonModes,只是借助了其他视图做动效。

综上,“微博”和“今日头条”的交互设计都不太完美。

一个好的动效应该尽量减少不必要的额外视图和逻辑,所以笔者通过对 cell.contentView 的操作来实现拖动动效,并且 GIF 的播放 runloopModeNSRunLoopCommonModes ,所以在拖动的时候 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<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSArray<UICollectionViewLayoutAttributes *> *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,只需要时刻记住一个准则就不会出现问题:
布局的更新一定是线性的,而不能跳跃。

十五、GIF 处理的思考

还有一个问题需要特别关注:GIF 处理。因为图片处理框架需要将每一帧图片读取出来,非常耗时,所以这个过程需要异步处理。

如果用户配置本地图片之前需要开辟子线程处理图片再传给图片浏览器,这将会严重影响使用者的体验。考虑到最少知识原则,2.0.6 版本过后笔者使用 YBImageBrowseCellData 的一个属性 imageBlock 来配置本地图片:

// Local image
YBImageBrowseCellData *data = [YBImageBrowseCellData new];
data.imageBlock = ^YBImage *{ return [YBImage imageNamed:...]; };

可以很容易想到,使用闭包配置,组件就能控制该代码的执行线程,并且一并解决了图片读取时可能带来的过多时间损耗。

至于网络 GIF,笔者在下载完成的时候也做了异步处理,保证性能。

十六、巧用局部缓存:解决代理数据源复用问题

大致了解了本组件过后,应该知道笔者提供代理来配置数据源的方式,是为了降低内存峰值(因为数组会持有数据源,而代理配置组件不会持有数据源)。当然,若数据源不多的情况下,使用数组配置能拥有更好的用户体验(比如朋友圈九宫格)。

使用代理配置时,由于组件不会持有数据源,所以会带来一个问题:数据源无法复用。

我们要知道组件使用数据源并非只是在显示的时候(比如转场动效),而使用代理获取数据的特点就是“用完即扔”,组件无法保证只调用一次代理方法,就算组件是想获取同一个下标的数据源,每次调用代理都会创建新的实例,无法复用。除了开辟内存带来的性能损耗外,当代理方法出栈,当前数据源释放,组件处理数据源所做的缓存将会随之而去,从而带来各种各样奇怪的问题。

笔者思考良久,最终决定采用局部缓存。原理很简单,就是用组件实例持有最近使用的若干个数据源。

为了降低复杂程度,使用系统的 NSCache 来做内存缓存,它的淘汰算法是直接删除最早添加的,虽然不及 LRU 时髦,但也够用。至于缓存的数量,笔者暂时设定为 6,核心代码如下:

- (id<YBImageBrowserCellDataProtocol>)dataAtIndex:(NSUInteger)index {
    if (!self->_dataCache) {
        self->_dataCache = [NSCache new];
        self->_dataCache.countLimit = 6;
    }
    if (self->_dataCache && [self->_dataCache objectForKey:@(index)]) {
        return [self->_dataCache objectForKey:@(index)];
    } else {
        id<YBImageBrowserCellDataProtocol> data = [self.yb_dataSource yb_imageBrowserView:self dataForCellAtIndex:index];
        [self->_dataCache setObject:data forKey:@(index)];
        return data;
    }
}

只需要保证组件获取数据源的时候都调用这个方法,就能轻松复用数据源且不用担心性能和内存问题。

后语

一个看起来简单的效果并非真的简单,当你觉得它简单的时候,思考一下是不是自己太菜,每一个问题深入过后都有很多衍生的东西,周全考虑性能、内存、可维护性、可拓展性是对代码架构能力的考量。

希望本文能给读者朋友带来帮助。

iOS
Web note ad 1