你的位置:首页 > 操作系统

[操作系统]一种头像缓存策略


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

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

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

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

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

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

一些前提:

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

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

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

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 -> Voidstruct Request: Equatable {  let avatarURLString: String  let completion: Completion}

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

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 即可,不用关心存储过程的细节。

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

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  }}

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

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

欢迎转发此条 Tweet https://twitter.com/nixzhu/status/651958867203526656 或微博http://weibo.com/2076580237/CE7Msz61V 以分享此文!

全能程序员交流QQ群290551701,群内程序员都是来自,百度、阿里、京东、小米、去哪儿、饿了吗、蓝港等高级程序员 ,拥有丰富的经验。加入我们,直线沟通技术大牛,最佳的学习环境,了解业内的一手的资讯。如果你想结实大牛,那 就加入进来,让大牛带你超神!