笔者花了挺多时间重做图片浏览器,【Android 开源系列】之缓存框架

图片 10

摘要LKImageKit
是一个来自腾讯的高性能iOS平台图片框架,包括了图片控件,图片下载、内存缓存、磁盘缓存、图片解码、图片处理等一系列能力。合理的架构和线程模型,并特别针对不同场景进行优化,能充分发挥硬件的性能。基本介绍LKImageKit
是一个高性能的图片框架,包括了图片控件,图片下载、内存缓存、磁盘缓存、图片解码、图片处理等一系列能力。合理的架构和线程模型,并特别针对不同场景进行优化,能充分发挥硬件的性能。该框架具有高度的扩展性。在此框架下,开发者可以自定义图片框架中的任何一个部分,比如:自定义图片显示逻辑、自定义缓存、自定义下载组件、自定义解码器、自定义图片处理算法等等。该组件旨在提供
iOS
平台上使用最简单,功能最强大的高性能图片解决方案。组件特性提供演示视频和
DEMODEMO中演示了如何在图片墙场景的数千张图片下,配合预加载、优先级控制、分级加载等技术,实现图片在快速滑动场景的高速下载和显示模块插件化可定制缓存、解码、加载、绘制等多个模块支持取消不再显示的图片迅速取消请求,节约内存占用支持优先级、优先级可动态调整通过对不同区域优先级的设置,使页面加载获得更好的体验支持预加载可以预先加载图片,预加载和图片正常显示会自动合并动图支持支持多图动态播放,包括正向播放、逆向播放、来回播放等雪碧图支持提供将雪碧图解码成序列帧的能力滤镜支持支持在图片显示前异步对图片进行滤镜处理渐进式加载支持图片边下载边显示多级加载支持多级请求,比如先加载小图再加载大图后台解码使用后台线程解码,提升页面流畅度请求合并相同类型的请求会被合并,不会导致重复的运算和下载并发数控制可以分别对加载、解码、处理等多个模块进行分别并发控制API调用顺序无关无需考虑
API 调用顺序,并不需要将 setURL
作为发送请求的接口加载有多快,有图有真相!开源地址详见:

尽可能多的配置选项(线程池,加载器,解析器,内存/磁盘缓存,显示参数等等)

简介

Picasso 、Glide、Freso 等是常用的图片加载库,在这三者中,Picasso
的优势是小,不到120K,以下是常用 api :

// 注:最新的代码中已经可以不用传递 context 参数
Picasso.with(context).load(url).placeholder(R.mipmap.ic_default).into(imageView);
//预加载
Picasso.with(context).load(url).fetch();
//同步加载
Picasso.with(context).load(url).get();

Picasso
源码地址为:https://github.com/square/picasso

-sd_setImageWithURL:(nullable NSURL *)url; //之传入图片url- sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder; //可设置占位图- sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder options:(SDWebImageOptions)options; //可设置占位图和加载方式- sd_setImageWithURL:(nullable NSURL *)url completed:(nullable SDExternalCompletionBlock)completedBlock; //- sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder completed:(nullable SDExternalCompletionBlock)completedBlock; // 在完成的闭包中可获取到 加载的image error 缓存方式 及图片url- sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder options:(SDWebImageOptions)options completed:(nullable SDExternalCompletionBlock)completedBlock; - sd_setImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder options:(SDWebImageOptions)options progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock completed:(nullable SDExternalCompletionBlock)completedBlock; //可获取到下载进度- sd_setImageWithPreviousCachedImageWithURL:(nullable NSURL *)url placeholderImage:(nullable UIImage *)placeholder options:(SDWebImageOptions)options progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock completed:(nullable SDExternalCompletionBlock)completedBlock;
2、当来回滑动 ScrollView,如何避免 Cell 反复发起异步请求?

这种情况经常出现,如果脱离业务来思考,对于同一个异步请求多次调用,应该使用一个数组来将所有发起请求的
Block
回调存储起来,并且若正在异步请求要及时返回,当异步请求完成,遍历数组中的回调
Block 分别调用。

实际上关于网络的框架都有类似的处理,比如 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 作为主体,通过重写如下方法自定义旋转方向:

- shouldAutorotate { return YES;}- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return self.supportedOrientations;}

但其实当前控制器实际允许旋转的方向受很多因素控制。一是
general -> deployment info -> Device Orientation
中勾选的设备支持的旋转方向,它的优先级是最高的;二是在 AppDelegate
中实现的 <UIApplicationDelegate> 代理方法
-application:supportedInterfaceOrientationsForWindow:,它的优先级次之;三是若当前控制器是栈内的,它的旋转方向由
UINavagationController 重载的 -shouldAutorotate
-supportedInterfaceOrientations 方法控制,若存在
UITabBarController,它将控制它管理的那些控制器的旋转方向。

所以,实际上组件内部可以说无法准确的获取到 YBImageBrowser
这个控制器实际支持的方向,这些逻辑需要开发者自行去解决。

可以加载本地资源

  • 拆 Jake Wharton 系列之
    ButterKnife
  • 拆 Jake Wharton 系列之
    RxAndroid
  • 拆 Jake Wharton 系列之
    Picasso

1、当 Cell 进入复用池的时候,是否需要放弃它发起的未完成的异步操作?

当然,并不是所有异步任务都是可以中断的,发起的异步操作消耗了一定资源,笔者认为不应该放弃掉,而是将结果存储在异步回调
Block 持有的 data 中,至于 UI 刷新与否按照之前说的方法判断。

那么就带来了另外一个问题:

【Android 开源系列】之网络请求框架

参考文章

http://blog.csdn.net/chdjj/article/details/49964901
https://github.com/android-cn/android-open-project-analysis/blob/master/tool-lib/image-cache/picasso/README.md

  1. 如果该 url 是第一次加载的话,那么就会执行 createCallback
    这个回调block ,然后在 createCallback
    里面开始构建网络请求,在下载过程中执行各类进度 block 回调.
TODO

关于自定义转场,需要设置如下代码:

self.transitioningDelegate = ...;self.modalPresentationStyle = UIModalPresentationCustom;

UIModalPresentationCustom
模式下,才能做到完美的出场和入场动效,但是有个非常蛋疼的地方,若在该模式下,图片浏览器旋转的时候,它的
presentingViewController 会跟着旋转,不管 presentingViewController
是否支持这个方向。然后在图片浏览器 dismiss
的时候,presentingViewController 方向并不会恢复。

这个问题笔者未找到完美的解决方案,看了一下“微博”的图片浏览器貌似也是类似的实现方式,在横屏的时候出场是立即触发的,猜测可能是此刻将屏幕旋转回来。

所以,尝试了一下,若当前图片浏览器的方向和 presentingViewController
起始的方向不同,将取消手势交互动效,直接 dimiss
转场,并且在转场的同时强制旋转屏幕。

然而预期的效果和“微博”并不一样,强制转场有一定的延时。若读者朋友有解决方案还望指点一下,目前就采用这个处理方案,作为一个待完成的优化吧。

上一个版本是使用 SDWebImage + FLAnimatedImage
来处理的,但是感觉使用体验不太好,在创建本地图片的时候需要用户判断当前图片是不是
gif,所以后来笔者选择了功能更强、代码质量很高的 YYImage 做为 GIF
的处理框架,它还支持 APNG、WebP 等格式,使用也很简单,完全兼容
UIImage。YYImage 原理可看笔者的一篇博客:YYImage
源码剖析:图片处理技巧。

它的内存缓存就是一个 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 添加缓存的源码中又看到了这样一个出其不意的判断:

- storeImage:(nullable UIImage *)image imageData:(nullable NSData *)imageData forKey:(nullable NSString *)key toDisk: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
格式的图片仍然会消耗比较长的时间来解码(比如相册图片通常都比较大)。

所以在新版的组件中,笔者默认开启了允许异步解码的选项。同时,为了减少用户查看图片的等待时间,组件做了预加载功能。

就比如 UIViewController,它并不是每次释放都会走 -viewWillAppear:
方法,可能内存强制清理或者闪退等导致意外释放。只要是释放,理论上就会走
-dealloc 方法,所以在这个方法中需要做一些危机处理。

在组件的设计中,应该尽量避免对外部业务的直接操作,但是有的时候又不可避免,比如图片浏览器要做这个效果:

图片 1

图片浏览器当前展示哪张图片就将业务外的哪张图片隐藏,为了方便用户使用,组件不得不操作外部视图变量使其隐藏或者显示。那么,考虑到意外释放等问题,对外部操作的复位应该写在
-dealloc 中:

- 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
实例,而实例变量针对某一个,这是组件内部常用的伎俩。

值得注意的是,全局区变量生命周期会延长到程序结束,所以对于内存占用比较高的变量需要慎重考虑是否放入全局区,或者手动管理它的内存释放。

图片浏览器的手势交互并非看起来的那么简单,图片的放大状态、UIScrollView
的回弹和减速机制、嵌套 UIScrollView
的手势冲突,这些都可能会导致一些难以控制的情况出现。

“微博”的图片浏览器在手势交互的时候应该是借助了其它的视图,因为每次对 GIF
的拖动都会回到第一帧,这样体验并不是非常好;而“今日头条”的图片浏览器在手势交互的时候
GIF 会暂停,一开始笔者还以为在 runloopModeUITrackingRunLoopMode
的时候停止了 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<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect: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,只需要时刻记住一个准则就不会出现问题:布局的更新一定是线性的,而不能跳跃。

本地图片需要给组件一个 UIImage 或者其子类
,这会带来性能瓶颈的地方有两点:一是对慢速 IO
设备磁盘的访问;二是当图片是 GIF
时,图片处理框架需要将每一帧图片读取出来,非常耗时。

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

// Local imageYBImageBrowseCellData *data = [YBImageBrowseCellData new];data.imageBlock = ^__kindof UIImage *{ 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:@ { return [self->_dataCache objectForKey:@]; } else { id<YBImageBrowserCellDataProtocol> data = [self.yb_dataSource yb_imageBrowserView:self dataForCellAtIndex:index]; [self->_dataCache setObject:data forKey:@]; return data; }}

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

新版的组件默认使用了图片的异步解码,这样虽然能让交互效果更加流畅,但是会带来更多的等待时间。为了减少用户查看图片的等待时间,不得不设计一个预加载的功能。

预加载的理论是比较简单的,就是滑动到第 n 张图片的时候,预先处理前面 m
张和后面 m 张图片,该组件的处理方式就是如此。

值得注意的是,预加载一定要控制范围,过多的预加载任务会导致 CPU
压力过高,大量图片的异步解码会占用过多的内存,这些都会导致严重的问题。

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

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

Facebook 开源的一个强大的图片加载组件。用于管理图像和他们使用的内存的库

写这篇文章时候,Jake Wharton 已经从 Square
离职半个月,令人唏嘘不已,追求更广阔的诗和远方想必都是大神们的宿命。当然,开源的价值不会随着你的地位、职业、企业的改变而消失或是贬值,这正是开源的魅力所在。

  1. 可使用 闭包 获取图片下载进度及成功失败状态:

GitHub地址 : iOS图片浏览器

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

概览

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

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

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

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

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

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

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

除此之外,组件有两个概念,一个是工具栏 ,一个是弹出视图
(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
类的一部分,里面的方法和属性都是在编译期决议的,所以延展里面的属性是会自动生成实例变量的。这不同于分类
,分类是运行期动态注入类中,所以只能添加方法而不能添加实例变量。

那么,在需要调用这些方法的类中导入 YBImageBrowser+Internal.h
就能访问了。

多线程和复用机制看似互不相干,却会碰撞出意外的 BUG。

举个例子,一个 Cell 中的 UIImageView
在异步线程发起一个下载图片的网络请求,UITableView
在这期间滑动,触发了复用机制,该 Cell 的数据源更换,它的 UIImageView
又发起了另外的一个下载图片请求,当第一次网络请求成功返回图片的时候,已经不是这个
Cell 的 UIImageView 期望的图片了。

因为复用机制的问题,视图不能作为可信的异步回调接收者,但是数据却可以:

id tmpData = self.datanetworkAsync^{ 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 刷新,但是却不能解决另外一些问题:

内存管理,两个内存缓存加上磁盘缓存构成了三级缓存

核心类

图片 2

图片来自参考文章

  1. Picasso:门面类,提供 Picaaso
    单例的创建,预置了默认的现线程池、内存缓存和磁盘缓存策略。
  2. Request:封装了图片加载请求的信息,如图片的Uri、Resource
    ID、宽高、scaleType 等。
  3. RequestCreator:用于创建 Request 对象。
  4. RequestHandler
    • 图片加载请求的处理器,定义了不同类型来源的文件请求如何处理,最终将返回
      Source 类型,可以理解为文件字节流。
    • 图片来源类型包括:Assets 资源、SD
      卡图片、网络图片、联系人照片、其他内容服务提供者、多媒体资源等。因此该抽象类有多个具体的子类。
    • 这些子类将以集合的形式,存在于 Picasso 单例中,当 Request 符合
      RequestHandler 的处理规则时,便以该 Hander 进行处理。
    • 返回的字节流将经过一系列的解码、变换后,变成最终的 Bitmap 对象。
  5. Dispatcher:分发器,负责分发和处理图片加载的不同阶段,如提交(入队)、取消、暂停、继续、完成、重试、网络状态变化等,并内置了
    HandlerThread
    来处理大部分无需主线程处理的任务,有了分发器的存在,代码结构更清晰。
  6. BitmapHunter:图片处理的工作线程,图片的下载、解码、变换等耗时任务均在该线程中执行。
  7. Action:如果 RequestHandler 是图片加载的开始阶段,Action
    则是结束阶段,Action
    是抽象类,他决定了图片的最后一个环节:如何将图片渲染在目标容器中(如
    ImageView 和 RemoteViews
    等),由于目标容器有多种情况,因此也有多个子类。
  8. Download:图片下载器,内置了实现类 OkHttp3Downloader
    和磁盘缓存策略,可自定义实现类进行扩展。
  9. PicassoExecutorService:内置的线程池,容量定义策略见下文分析。
  10. Cache:内存缓存接口,内置了缓存策略实现类
    LruCache,可自定义实现类进行扩展。
  11. Transformation:图像的变换接口,如果需要对图片进行范围裁切或几何变换均可实现该接口进行自定义,也可参考
    picasso-transformations
  12. Stats:统计图片加载过程中的数据,如缓存命中数、命中率、图片下载大小,经过变换的图片大小等信息。
  1. 类功能列表SDWebImage 类区分的很清晰, 各个类功能也很明确, 各司其职,
    封装的特别好.

图片 3

应该是使用最多的图片缓存,支持主流图片缓存的绝大多数特性

Picasso 延续了 Jake Wharton 和 Square
开源库的风格,即小而美,且命名诗情画意。从三把刀(ButterKnife、Dagger
1、Scalpel)、Java
诗人(JavaPoet)到西班牙画家毕加索(Picasso),这些库的命名处处体现着技术和人文的结合。这些命名之下,理性的代码们显得感性且有温度。

//传入图片的URL,图片下载过程的Block回调,图片完成的Block回调- (nullable SDWebImageDownloadToken *)downloadImageWithURL:(nullable NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock { __weak SDWebImageDownloader *wself = self; // 传入对应的参数,addProgressCallback: completedBlock: forURL: createCallback: 方法 return [self addProgressCallback:progressBlock completedBlock:completedBlock forURL:url createCallback:^SDWebImageDownloaderOperation *{ __strong __typeof  sself = wself; //超时时间 默认15秒 NSTimeInterval timeoutInterval = sself.downloadTimeout; if (timeoutInterval == 0.0) { timeoutInterval = 15.0; } // In order to prevent from potential duplicate caching (NSURLCache + SDImageCache) we disable the cache for image requests if told otherwise // Header的设置 NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:(options & SDWebImageDownloaderUseNSURLCache ? NSURLRequestUseProtocolCachePolicy : NSURLRequestReloadIgnoringLocalCacheData) timeoutInterval:timeoutInterval]; request.HTTPShouldHandleCookies = (options & SDWebImageDownloaderHandleCookies); request.HTTPShouldUsePipelining = YES; if (sself.headersFilter) { request.allHTTPHeaderFields = sself.headersFilter(url, [sself.HTTPHeaders copy]); } else { request.allHTTPHeaderFields = sself.HTTPHeaders; } //创建 SDWebImageDownloaderOperation,这个是下载任务的执行者。并设置对应的参数 SDWebImageDownloaderOperation *operation = [[sself.operationClass alloc] initWithRequest:request inSession:sself.session options:options]; operation.shouldDecompressImages = sself.shouldDecompressImages; //用于请求认证 if (sself.urlCredential) { operation.credential = sself.urlCredential; } else if (sself.username && sself.password) { operation.credential = [NSURLCredential credentialWithUser:sself.username password:sself.password persistence:NSURLCredentialPersistenceForSession]; } //下载任务优先级 if (options & SDWebImageDownloaderHighPriority) { operation.queuePriority = NSOperationQueuePriorityHigh; } else if (options & SDWebImageDownloaderLowPriority) { operation.queuePriority = NSOperationQueuePriorityLow; } //加入任务的执行队列 [sself.downloadQueue addOperation:operation]; if (sself.executionOrder == SDWebImageDownloaderLIFOExecutionOrder) { // Emulate LIFO execution order by systematically adding new operations as last operation's dependency [sself.lastAddedOperation addDependency:operation]; sself.lastAddedOperation = operation; } return operation; }];}

博客更新日志:2018.11.30 ——
修改图片异步解码的分析,增加滑动预加载的说明。2018.11.05 ——
修改图片异步解码的分析。2018.10.13 ——
修改部分内容,增加本地图片处理和局部缓存的应用说明。

图片可以缓存在内存中,或者设备文件目录下,或者SD卡中

内存缓存

LruCache 为 Picasso 中的缓存实现,该类的主要实现与 Android
默认提供的基本一致,区别有两点:

  1. 前者重载了构造器,定制了缓存大小的计算,其计算逻辑为:应用所分配内存的
    15% ,源码在 Utils.calculateMemoryCacheSize(context)
    中,缓存大小的申请比例也可以作为有类似应用场景时的参考。
  2. 抽象出接口
    Cache,面向接口编程,如此一来,只要开发者提供实现类,便可扩展缓存策略。
  1. SDWebImageManager下载图片

多线程图片加载

磁盘缓存

当加载网络图片时,我们往往会将图片下载下来,缓存在磁盘中,因此会涉及到磁盘缓存。Picasso
内置了图片下载器 OkHttp3Downloader,本质上是使用自家的 OkHttp
进行图片下载,并内置了缓存策略
DiskLruCache,默认可缓存的文件大小总数为 50M
。值得一提的是,DiskLruCache
也是由 JakeWharton 提供的。

如果需要更换图片下载器和磁盘缓存策略,则可以自定义 Downloader
的实现类进行扩展。

以上所述的线程池、缓存策略等均是面向接口编程,因此都可以扩展,扩展的套路便是在
Picasso.Builder
中设置属性,这种建造者模式的写法我们见惯不怪,源码中的方法声明如下:

public Builder executor(@NonNull ExecutorService executorService){}
public Builder memoryCache(@NonNull Cache memoryCache){}
public Builder downloader(@NonNull Downloader downloader){}

本地视频剧照的解码

如何阅读 Picasso 源码

Picasso 对图片开始请求加载到显示的每个阶段均做了完整的日志记录,以官方
Demo 为例,启动日志开关后,打开图片详情页:

图片 4

由于此时为该图片的第一次加载,因此涉及到图片的下载、缓存和显示等不同阶段,日志如下:

Picasso: Main        created      [R333] Request{http://i.imgur.com/zkaAooq.jpg resize(984,984)}
Picasso: Dispatcher  enqueued     [R333]+20ms 
Picasso: Hunter      executing    [R333]+20ms 
Picasso: Hunter      decoded      [R333]+28ms 
Picasso: Hunter      transformed  [R333]+36ms 
Picasso: Dispatcher  batched      [R333]+43ms for completion
Picasso: Dispatcher  delivered    [R333]+260ms 
Picasso: Main        completed    [R333]+260ms from DISK

日志中反应了三个不同的角色,及他们所负责的任务:

  • Main:主线程,负责发起图片加载的请求,最终完成加载。
  • Dispatcher:分发器,负责将图片加载的请求入队、打包、分发。
  • Hunter:工作线程,负责图片的下载、解码、转换。

第一次加载时,没有任何内存和磁盘缓存,第二次加载时,主线程直接从缓存中读取图片即可,日志如下:

Picasso: Main        created      [R341] Request{http://i.imgur.com/zkaAooq.jpg resize(984,984)}
Picasso: Main        completed    [R341] from MEMORY

跟随日志阅读 Picaasso 源码,方可事半功倍。

// 取消图片加载操作- sd_cancelImageLoadOperationWithKey:(nullable NSString *)key { // Cancel in progress downloader from queue SDOperationsDictionary *operationDictionary = [self operationDictionary]; //获取UIView上动态添加的属性 id operations = operationDictionary[key]; if (operations) { //如果有对应的加载操作 if ([operations isKindOfClass:[NSArray class]]) { // SDWebImageOperation数组, 将数组中的每个加载操作都取消 for (id <SDWebImageOperation> operation in operations) { if (operation) { [operation cancel]; } } } else if ([operations conformsToProtocol:@protocol(SDWebImageOperation)]){ //实现 SDWebImageOperation 协议 [(id<SDWebImageOperation>) operations cancel]; } [operationDictionary removeObjectForKey:key]; //取消后 移除这个属性 }}

更多样的显示,如圆角、进度条、点击重试、自定义对焦点

Picasso 总览

  • 可通过key读取缓存,key默认是图片的url

可以监听加载进度

缓存

内存和磁盘缓存策略及实现是图片框架必不可少的部分。Picasso
中的两级缓存都采用了 LRU 的缓存策略。

在iOS项目常用的框架中,SDWebImage 是不可少的, 相信大部分的iOS
开发攻城狮都和SDWebImage打过交道,
使用是使用了,但是你了解阅读过它的源码,了解它的实现原理吗?

这两天闲下来, 我有时间阅读了SDWebImage的源码,
收获很大,为了整理下我理解的思路,
把它变成自己的知识,就写了这篇文章,向大家分享下我的阅读心得。github:

图片变换

毕加索作品

解决方案:在调用 sd_setImageWithURL: placeholderImage: options:
方法时设置 options 参数为
SDWebImageRefreshCached,这样虽然会降低性能,但是下载图片时会照顾到服务器返回的
caching control

支持Gif图和WebP格式

图片框架的用例

图片 5

用例图

一个图片框架,一般都会包含缓存、图片下载、图片处理(压缩、解码、变换、加载、显示)、统计等四大模块,Picasso
也不例外。

  1. 在加载图片时,如何添加默认的 progress indicator

Android开发者e周报 第2期

线程和线程池

Picasso 中的主要线程有四类,分别是:

  1. 负责下载、解码、转换图片的工作线程——BitmapHunter,这类线程由线程池
    PicassoExecutorService 进行统一调度。
  2. 负责分发图片在加载过程中的不同阶段的行为指令(如
    submit、cancel、pause、resume、retry
    等)——Dispatcher.dispatcherThread,其类型为 HandlerThread
  3. 负责统计(如缓存命中数、命中率、缓存大小等)的线程——Stats.statsThread,其类型为
    HandlerThread
  4. 负责加载图片的线程——主线程。

这是一个 HandlerThread 的典型应用场景,主线程仅负责跟 UI
相关的工作,其他无关的工作均在工作线程或 HandlerThread
中进行处理,如线程之间需要通讯,则通过相应的 Handler
进行通讯,大大减轻了主线程的负担。

图片 6

Picasso 中的线程池大小会根据网络状态而改变,其规则是 Wifi
状态下,线程池个数为4,4G/3G/2G
状态下分别为3/2/1,这种定义线程池大小的策略可以作为我们有类似应用场景的参考。

支持debug模式

值得注意的细节

  1. Picasso 的所有代码均在一个 package
    中,其好处是可以将大部分类和方法的访问权限均设置 default
    的,对外隐藏,对内暴露,缺点则是代码分类略显杂乱,但相比优点和其代码量小的特点来说,缺点不值一提。

  2. 负责图片加载的 Action 持有 Target(一般是ImageView)的
    WeakReference,当图片加载的生命周期更长时,确保 Target
    能被回收而不会造成内存泄露。

  3. 简洁而风格统一的日志设计。Picasso.setLoggingEnabled(true)
    的方法可以启动日志打印,上文提到的日志反映出的图片加载的不同阶段均是在工具类
    Utils中定义的,如下图:

    图片 7

  4. 图片来源指示器。在开发阶段,我们可以通过Picasso.setIndicatorsEnabled(true)启动图片指示器,标识图片的来源,这是对开发者非常友好的设计:

    图片 8

  5. 使用 ContentProvider 提供 Context对象供 Picasso
    单例使用,此版本尚未发布,从 master 中可以看到此代码:

    图片 9

相应的,`Picasso.with()` 不需要再传入 Context 对象:  

![](https://upload-images.jianshu.io/upload_images/638283-d3b62fbcf80cf3fe.png)


这小技巧可以扩展我们提供 api 的思路,如果不需要特定的
Context,则可以通过 ContentProvider 来提供,方便使用。
  1. 该库的单元测试行覆盖率也高达 72% 。
功能
SDWebImageManager 将图片下载SDWebImageDownloader 和图片缓存SDImageCache 两个独立的功能组合起来
SDWebImageDownloader 专门用来下载图片和优化图片加载的,跟缓存没有关系
SDWebImageDownloaderOperation 继承于 NSOperation,用来处理下载任务
SDImageCache 异步处理内存缓存和磁盘缓存,不阻塞主线程
SDWebImageDecoder 图片解码器,用于图片下载完成后进行解码
SDWebImagePrefetcher 预下载图片,方便后续使用,图片下载的优先级低,其内部由 SDWebImageManager 来处理图片下载和缓存
UIView+WebCacheOperation 图片加载operation,可取消和移除,可显示加载进度view
UIImageView+WebCache 集成 SDWebImageManager 的图片下载和缓存功能到 UIImageView 的方法中,方便通过 UIImageView 直接调用
UIImageView+HighlightedWebCache UIImageView+WebCache 功能相似,用于加载 highlighted 状态的图片
UIButton+WebCache 集成 SDWebImageManager 的图片下载和缓存功能到 UIImageView 的方法中,方便通过 UIButton 直接调用
MKAnnotationView+WebCache 集成 SDWebImageManager 的图片下载和缓存功能到 MKAnnotationView 的方法中,方便通过 MKAnnotationView 直接调用
NSData+ImageContentType 用于获取图片数据的格式(如:JPEG、PNG、GIF等)
UIImage+GIF 用于加载 GIF 动图,还可判断图片是否是GIF格式
UIImage+WebP 用于加载 WebP 图片
UIImage+MultiFormat 根据不同格式的二进制数据转成 UIImage 对象

转码的支持

图片 10

let manager = SDWebImageManager.shared() manager?.downloadImage(with: URL.init(string: ""), options: .avoidAutoSetImage, progress: { (receivedSize, expectedSize) in //receivedSize:接受大小,expectedSize:总大小 }, completed: { (image, error, cacheType, isFinished, url) in //image:加载的图片,error:错误,cacheType:缓存类型,isFinished是否加载完成,url: 图片url })

特性:

总结

目前而言,虽然 Picasso
并非最主流的图片加载框架,但由于其体型娇小能量巨大,更容易入手阅读,通过它,我们可以了解图片框架的用例、实现套路、缓存策略的思路、复杂线程的处理等,也是非常值得一读的开源库。

 btn.sd_setImage(with: URL.init(string: ""), for: .normal, placeholderImage: UIImage.init(named: "placeHolderImage")) btn.sd_setImage(with: URL.init(string: ""), for: .highlighted, placeholderImage: UIImage.init(named: "placeHolderImage"))

相关文章

Leave a Comment.