一种头像缓存策略
在具体场景下设计缓存的逻辑。
作者:@nixzhu
许多 App 都有用户系统,不论是自己实现还是使用第三方,大概都需要显示用户的头像。比较常见的情景下,头像会在某些列表里出现,例如联系人列表、消息列表等。
虽然头像也是图像,但相比于普通图片,我们对头像有更高的要求。
头像的原始图片可能有各种尺寸,但在 App 里,我们很可能需要某种固定样式的头像,例如正方形或者圆形。如果我们使用通用的图片缓存工具如 SDWebImage、Kingfisher 等,那么还需要自己做图片的裁剪和加工。如果直接用 UIImageView 来缩小,图片细节就会变得过于“锐利”,影响观看。
进一步,一个 App 里可能不只有一种头像样式。比如某些场景里要有大头像,某些要用小头像,某些要用原始尺寸的头像;或者某些场景里要用正方形头像,某些场景里要用圆角矩形 头像,不一而足。注意:单纯用 layer.cornerRadius 或 CALayer 来 mask 都会导致列表的滑动性能问题,因此不考虑。
如果要做优化,我们当然希望这些不同样式的小头像能够存储在本地,不用再从网络获取再裁减或处理样式。一来减少不需要的流量消耗,二来提高头像载入速度,用户体验自然会更好。
基于上述分析,我们来设计一个头像缓存系统。它的目标是快速地获取并缓存有样式的头像图片,并能比较容易地集成到已有项目中。
一些前提:
- 头像的图片 URL 唯一,即不同的头像有不同的 URL。如果用户换了头像,那么新头像 URL 和旧的不一样;
- 头像是公开资源,不需要做验证即可下载。
好了,我们来做一做思维游戏。
首先,已有的用户模型可能为:
struct User { let userID: String let username: String let avatarURLString: String //... }
其中avatarURLString表示远端的头像链接。如果我们要下载它,最简单的话,直接用 NSData 的一个构造方法即可:
if let URL = NSURL(string: avatarURLString), data = NSData(contentsOfURL: URL), image = UIImage(data: data) { // TODO }
假如某个列表里有 5 个条目都是同一个用户产生的,那这个用户的头像要同时显示 5 次,我们难道要下载 5 次吗?
为了解决这个问题,我们可以将获取头像这一行为当作一个“请求”:
typealias Completion = UIImage -> Void struct Request: Equatable { let avatarURLString: String let completion: Completion }
我们可将请求用一个数组(即请求池)纪录下来:
var requests: [Request]
这样,每次要下载某个头像时,构造一个请求。先将其放入请求池,然后检查请求池中包含此 avatarURLString 的请求的个数,如果数量大于1,那说明之前已经有过同样的请求,我们就不执行下载的操作,静静等待第一个请求的帮助。
过一会儿,之前的请求下载结束,那这时只需要执行全部有同样 avatarURLString 的请求的 completion,后一个(或几个)就“免费”得到了服务。
以上是初次需要下载的情况。若我们已经下载了头像图片,我们依然可以构造请求,只不过之后就不执行下载操作,而是去文件系统里寻找而已。同理,我们 的请求里可以包含样式,假如同一个用户的五个头像有不同的样式,我们只需要在最后执行每个请求的 completion 时再分别处理样式。
既然下载后要保存,那怎么保存比较好呢?
有人倾向于直接用文件系统的 API 将数据存在文件系统里;有人已经使用的 Core Data,那他会倾向于放在 Core Data 里,况且 Core Data 实体的某些属性类型有 External 特性,勾选后,既不占用内存空间,也免去了直接操作文件系统的繁琐;还有人使用 Realm 或直接用 SQLite 等,反正都有类似的存储过程,只不过细节不同。
我的建议是将原始图片保存在文件系统中(或 Core Data 勾选 External),小的有样式头像可以直接以属性保存在数据库中(通常例如 Core Data 或 Realm 都支持 NSData 的属性,但不能过大,因为它们会待在内存里)。
可惜,如果现在我们要制作一个框架来缓存头像,那我们就不可能非常具体地去帮用户存储文件。因为细节千差万别,实在顾不过来。而且就算有了,用户也 很难集成这一步到已有代码。若不考虑用户的喜好,那我们实际上做出的是一个“通用”的图片缓存系统。这里的通用以“不够优化”来理解。
好在,我们可以定义协议,在协议中声明存储 API,用户自行实现存储过程。我们的缓存系统只需要调用 API 即可,不用关心存储过程的细节。
再把头像样式考虑进去,于是有:
protocol Avatar { var URL: NSURL? { get } var style: AvatarStyle { get } var placeholderImage: UIImage? { get } var localOriginalImage: UIImage? { get } var localStyledImage: UIImage? { get } func saveOriginalImage(originalImage: UIImage, styledImage: UIImage) }
稍微说明一下:URL自然表示头像的原始链接(可选值表示其可能不存在);style表示本次要显示的头像的样式,稍微会进一步讨论;placeholderImage很好理解,在显示真正的头像之前,需要一个占位符,不同的 App 有不同的设计,所以也交给用户;localOriginalImage表示本地存储的原始图片,既然存储已由用户控制,那读取自然由用户控制;localStyledImage表示本地有样式的头像,用户可以根据样式的不同来提供;最后是saveOriginalImage(originalImage:styledImage:),由用户控制存储过程,包括原始图片以及本次的样式图片。
其中,头像样式大概可以做成下面这样:
enum AvatarStyle: Equatable { case Original case Rectangle(size: CGSize) case RoundedRectangle(size: CGSize, cornerRadius: CGFloat, borderWidth: CGFloat) typealias Transform= UIImage -> UIImage? case Free(name: String, transform: Transform) }
有原始样式(不处理)、固定尺寸的矩形(可生成正方形等)、可带透明边的圆角矩形(可生产圆形等),以及一种自由样式,由用户自行提供图片变换函数。看起来比较齐备,也可再增加。
然后我们再给UIImageView扩展一个方法:
extension UIImageView { func navi_setAvatar(avatar: Avatar) { // TODO } }
以方便用户使用。最后的图解如下:
用户有列表要显示,列表条目包含头像,于是构造一个 Avatar 的描述去 AvatarPod 中唤醒 Avatar。
AvatarPod 避免重复下载,并实现整个逻辑。比如先去内存缓存中查询,没有就看看是否用户提供了本地图片,再没有就去下载。下载好了就处理样式,并将原图和样式图交给用户存储。
那么在具体的集成上,用户只需要让其 User 实现 Avatar 协议或者构造一个新的“中间对象”,其包含 User 和 AvatarStyle,并让此对象实现 Avatar 协议。我推荐用中间对象的方式,这样对已有代码的修改会很少,而且更好支持多种样式。
具体请看 Navi 的代码以及 Demo。文件并不多,应该能比较容易地看明白。不过运行 Demo 需要 iOS 设备已在“设置”里登录了 推ter 帐户,没有 推ter 帐号的同学可先去 推ter.com 注册一个。
最后,Navi 的名字来源于电影《阿凡达》里的纳美人(Na'vi),为潘多拉星球上的智慧类人生物。人类到达后潘多拉后,利用基因改造合成了一种可以由人控制的类似那美人的生物,也就是 Avatar(意为化身或替身),以便更好地和原住的纳美人交流。
欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog
欢迎转发此条 Tweet https://推ter.com/nixzhu/status/651958867203526656 或微博 http://weibo.com/2076580237/CE7Msz61V 以分享此文!