一种头像缓存策略

在具体场景下设计缓存的逻辑。

作者:@nixzhu


许多 App 都有用户系统,不论是自己实现还是使用第三方,大概都需要显示用户的头像。比较常见的情景下,头像会在某些列表里出现,例如联系人列表、消息列表等。

虽然头像也是图像,但相比于普通图片,我们对头像有更高的要求。

头像的原始图片可能有各种尺寸,但在 App 里,我们很可能需要某种固定样式的头像,例如正方形或者圆形。如果我们使用通用的图片缓存工具如 SDWebImageKingfisher 等,那么还需要自己做图片的裁剪和加工。如果直接用 UIImageView 来缩小,图片细节就会变得过于“锐利”,影响观看。

进一步,一个 App 里可能不只有一种头像样式。比如某些场景里要有大头像,某些要用小头像,某些要用原始尺寸的头像;或者某些场景里要用正方形头像,某些场景里要用圆角矩形头像,不一而足。注意:单纯用 layer.cornerRadius 或 CALayer 来 mask 都会导致列表的滑动性能问题,因此不考虑。

如果要做优化,我们当然希望这些不同样式的小头像能够存储在本地,不用再从网络获取再裁减或处理样式。一来减少不需要的流量消耗,二来提高头像载入速度,用户体验自然会更好。

基于上述分析,我们来设计一个头像缓存系统。它的目标是快速地获取并缓存有样式的头像图片,并能比较容易地集成到已有项目中。

一些前提:

  1. 头像的图片 URL 唯一,即不同的头像有不同的 URL。如果用户换了头像,那么新头像 URL 和旧的不一样;
  2. 头像是公开资源,不需要做验证即可下载。

好了,我们来做一做思维游戏。

首先,已有的用户模型可能为:

  1. struct User {
  2. let userID: String
  3. let username: String
  4. let avatarURLString: String
  5. //...
  6. }

其中 avatarURLString 表示远端的头像链接。如果我们要下载它,最简单的话,直接用 NSData 的一个构造方法即可:

  1. if let
  2. URL = NSURL(string: avatarURLString),
  3. data = NSData(contentsOfURL: URL),
  4. image = UIImage(data: data) {
  5. // TODO
  6. }

假如某个列表里有 5 个条目都是同一个用户产生的,那这个用户的头像要同时显示 5 次,我们难道要下载 5 次吗?

为了解决这个问题,我们可以将获取头像这一行为当作一个“请求”:

  1. typealias Completion = UIImage -> Void
  2. struct Request: Equatable {
  3. let avatarURLString: String
  4. let completion: Completion
  5. }

我们可将请求用一个数组(即请求池)纪录下来:

  1. var requests: [Request]

这样,每次要下载某个头像时,构造一个请求。先将其放入请求池,然后检查请求池中包含此 avatarURLString 的请求的个数,如果数量大于1,那说明之前已经有过同样的请求,我们就不执行下载的操作,静静等待第一个请求的帮助。

Navi AvatarPod

过一会儿,之前的请求下载结束,那这时只需要执行全部有同样 avatarURLString 的请求的 completion,后一个(或几个)就“免费”得到了服务。

Navi Completion

以上是初次需要下载的情况。若我们已经下载了头像图片,我们依然可以构造请求,只不过之后就不执行下载操作,而是去文件系统里寻找而已。同理,我们的请求里可以包含样式,假如同一个用户的五个头像有不同的样式,我们只需要在最后执行每个请求的 completion 时再分别处理样式。

既然下载后要保存,那怎么保存比较好呢?

有人倾向于直接用文件系统的 API 将数据存在文件系统里;有人已经使用的 Core Data,那他会倾向于放在 Core Data 里,况且 Core Data 实体的某些属性类型有 External 特性,勾选后,既不占用内存空间,也免去了直接操作文件系统的繁琐;还有人使用 Realm 或直接用 SQLite 等,反正都有类似的存储过程,只不过细节不同。

我的建议是将原始图片保存在文件系统中(或 Core Data 勾选 External),小的有样式头像可以直接以属性保存在数据库中(通常例如 Core Data 或 Realm 都支持 NSData 的属性,但不能过大,因为它们会待在内存里)。

可惜,如果现在我们要制作一个框架来缓存头像,那我们就不可能非常具体地去帮用户存储文件。因为细节千差万别,实在顾不过来。而且就算有了,用户也很难集成这一步到已有代码。若不考虑用户的喜好,那我们实际上做出的是一个“通用”的图片缓存系统。这里的通用以“不够优化”来理解。

好在,我们可以定义协议,在协议中声明存储 API,用户自行实现存储过程。我们的缓存系统只需要调用 API 即可,不用关心存储过程的细节。

再把头像样式考虑进去,于是有:

  1. protocol Avatar {
  2. var URL: NSURL? { get }
  3. var style: AvatarStyle { get }
  4. var placeholderImage: UIImage? { get }
  5. var localOriginalImage: UIImage? { get }
  6. var localStyledImage: UIImage? { get }
  7. func saveOriginalImage(originalImage: UIImage, styledImage: UIImage)
  8. }

稍微说明一下:URL 自然表示头像的原始链接(可选值表示其可能不存在);style 表示本次要显示的头像的样式,稍微会进一步讨论;placeholderImage 很好理解,在显示真正的头像之前,需要一个占位符,不同的 App 有不同的设计,所以也交给用户;localOriginalImage 表示本地存储的原始图片,既然存储已由用户控制,那读取自然由用户控制;localStyledImage 表示本地有样式的头像,用户可以根据样式的不同来提供;最后是saveOriginalImage(originalImage:styledImage:),由用户控制存储过程,包括原始图片以及本次的样式图片。

其中,头像样式大概可以做成下面这样:

  1. enum AvatarStyle: Equatable {
  2. case Original
  3. case Rectangle(size: CGSize)
  4. case RoundedRectangle(size: CGSize, cornerRadius: CGFloat, borderWidth: CGFloat)
  5. typealias Transform= UIImage -> UIImage?
  6. case Free(name: String, transform: Transform)
  7. }

有原始样式(不处理)、固定尺寸的矩形(可生成正方形等)、可带透明边的圆角矩形(可生产圆形等),以及一种自由样式,由用户自行提供图片变换函数。看起来比较齐备,也可再增加。

然后我们再给 UIImageView 扩展一个方法:

  1. extension UIImageView {
  2. func navi_setAvatar(avatar: Avatar) {
  3. // TODO
  4. }
  5. }

以方便用户使用。最后的图解如下:

Navi 的缓存架构

用户有列表要显示,列表条目包含头像,于是构造一个 Avatar 的描述去 AvatarPod 中唤醒 Avatar。

AvatarPod 避免重复下载,并实现整个逻辑。比如先去内存缓存中查询,没有就看看是否用户提供了本地图片,再没有就去下载。下载好了就处理样式,并将原图和样式图交给用户存储。

那么在具体的集成上,用户只需要让其 User 实现 Avatar 协议或者构造一个新的“中间对象”,其包含 User 和 AvatarStyle,并让此对象实现 Avatar 协议。我推荐用中间对象的方式,这样对已有代码的修改会很少,而且更好支持多种样式。

具体请看 Navi 的代码以及 Demo。文件并不多,应该能比较容易地看明白。不过运行 Demo 需要 iOS 设备已在“设置”里登录了 Twitter 帐户,没有 Twitter 帐号的同学可先去 twitter.com 注册一个。

最后,Navi 的名字来源于电影《阿凡达》里的纳美人(Na’vi),为潘多拉星球上的智慧类人生物。人类到达后潘多拉后,利用基因改造合成了一种可以由人控制的类似那美人的生物,也就是 Avatar(意为化身或替身),以便更好地和原住的纳美人交流。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog