吐槽: 移动端缓存策略

3,523 阅读35分钟

从简书迁移到掘金

"时间?"

"去年夏天, 六月, 具体哪天记不得了. 我只记得那天非常的热, 喝了好多水还是很渴." "我没问你热不热渴不渴, 问什么答什么, 不要做多余的事情, 明白吗?"

"奥...明白了."

"嗯. 事情决定的时候你在哪? 在干嘛?"

"当时我正在我的工位上看小说. 啊...不是, 看博客! 啊...不是, 写代码! 嗯, 对的, 我当时正在专心写代码!"

"嗯? 算了. 事情是谁决定的? 具体细节是怎样的?"

"这个...我...我记不清楚了, 能不说吗?"

"记不清楚了? 你要明白现在你找我帮忙, 不是我找你帮忙! 你最好像小女生聊八卦一样把东西都仔仔细细说清楚喽, 不然, 谁都帮不了你!"

"这...哎...好吧, 我说就是了..."

"当时, 我正在看...写代码, A总突然让总监D哥去他办公室喝茶, 刚开始两个人确实开开心心地在喝茶, 但是, 过了一会儿, 里面就开始传出两个人谈话的声音. 我的位置离A总办公室很近, 那地方隔音又不好, 我隐约听见..."

A: "阿D, 你来. 你看罢, 这个页面我曾是见过的! 我就退出了一小会儿, 怎么再进来又要加载? 这些个页面又未曾变过, 每次进来却都要看这劳什子加载圈, 有时候还加载不出来给个错误页面, 你说气人不气人!"

D: "嗯...想来是数据获取慢了些, 加载圈转得自然就久了. 你知道的, 公司网不好, 之前申请升级一下公司网络, 你不是说不想花钱没给批嘛"

A: "哼, 又是网不好. 欺负我不懂技术不是? 你看罢, QQ/微信/微博都是正常的, 网不好它们怎么没问题? 你别说这是技术问题! 这技术上的问题, 怎么能算问题呢? 他们做得, 我们就做不得?"

D: "这..."

A: "这什么这! 嘿, 老伙计. 我敢打赌, 要是你嘴里再蹦出半个不字, 我就像中国足球队踢草坪那样, 踢爆你的屁股! 我向上帝保证, 我会这样做的!"

D: "那...行吧. 我这就下去办..."

"愉快的聊天后, D哥马上就召集我们紧急开会商量对策..."

D: "公司网络差, 客户端请求数据太慢, 老是显示加载中, A总对此很不满意! 我打算给客户端加上缓存, 每次数据加载前先拿缓存数据顶上, 获取到最新数据后再更新展示. 诸位, 意下如何啊?" 众人: ...

沉默 沉默是阻塞的主线程

D: "诶, 大家不要害羞嘛, 有什么想法都可以提出来, 集思广益嘛, 我又不是不讲道理." 同事X: "嗯...我觉得还是不要吧, 咱们现在工期紧, 已有任务都没完成, 搞个缓存不是更拖进度? 而且现在产品没推广, 用户比较少, 要加缓存的地方又多, 没必要搞这些吧." D: "你看, 你偏题了吧." 众人: ... 同事X: "拿人钱财, 与人消灾. 既然老板有需求, 做下属的自当赴汤蹈火死而后已, 只要老板开心就好. 我同意!" 众人: "同意" "同意" "我也同意" ... D: "很好, 难得大家如此支持, 一致同意. 那, 关于缓存策略, 诸位可有什么好的想法?" 众人: ...

沉默 沉默是异常的野指针

D: "诶, 大家不要害羞嘛, 有什么想法都可以提出来, 集思广益嘛, 我又不是不讲道理." 同事X: "额...要不, 您先说个想法让大家参考参考?" D: "也行, 那我就先说说我的想法, 不过毕竟是临时起意, 可能考虑不够周全, 有什么问题大家都可以提出来, 不要怕得罪人, 我又不是不讲道理. 嗯...大家觉得浏览器缓存的路子怎么样?" 众人: "同意" "同意" "我也同意" ...

"嗯, 这不是记得很清楚嘛! 就是这样, 好好配合, 不要搞事情. 对了, 上面说的那个浏览器缓存是什么意思?"

浏览器缓存策略

相信大家都有这样的体验, 浏览一次过的网页短时间再次加载速度会比第一次快很多, 点击浏览器的前进后退按钮也比重新输入网页地址浏览要快, 另外, 甚至在没网的情况下有时我们依然能浏览已经加载过的网页. 以上的一切其实都得益于我们的Web缓存机制, 而Web缓存机制又分为服务端缓存和客户端缓存, 篇幅有限, 这里我们仅简单介绍一下客户端缓存中的浏览器缓存.

  • Expires与Cache-Control

在HTTP1.0中, 客户端首次向服务器请求数据时, 服务器不仅会返回相应的响应数据还会在响应头中加上Expires描述. Expires描述了一个绝对时间, 它表示本次返回的数据在这个绝对时间之前都是不变的, 有效的, 所以在这个时间到达之前客户端都可以不用再次请求数据, 直接使用此次数据的缓存即可. 简单描述一下就是这样:

是否需要再次请求数据 = (客户端当前时间 > 缓存数据过期时间);

但是Expires存在一个问题: 它描述的是一个绝对时间(通常就是服务器时间), 如果客户端的时间与服务器的时间相差很大, 那么可能就会出现每次都重新请求或者永远都不再请求的情况. 显然, 这是不能接受的. 为此, HTTP1.1加入了Cache-Control改进过期时间描述. Cache-Control不再直接描述一个绝对时间, 而是通过max-age字段描述一个相对时间, max-age的值是一个具体的数字, 它表示从本次请求的客户端时间开始算起, 响应的数据在之后的max-age秒以内都是有效的. 假设某次max-age = 3600, 那么简单描述一下就是这样:

是否需要再次请求数据 = (客户端当前时间 - 客户端上次请求时间 > 3600);

需要注意的是, 当Expires和Cache-Control同时返回的情况下, 浏览器会优先考虑Cache-Control而忽略Expires.

Expires与Cache-Control以不同的形式描述了本地缓存的过期时间, 那么, 当这个过期时间到达后服务端就一定需要再次返回响应数据吗? 答案是否定的. 因为实际情况中, 有些资源文件(如静态页面或者图片资源)可能几天甚至几月都不会改变, 这些情况下, 即使缓存的过期时间到了, 客户端的缓存其实依然是有效的, 不必再次返回响应数据. 即服务端只在资源有更新的情况下才再次返回数据.

  • Last-Modified/If-Modified-Since

Last-Modified便是资源文件更新状态的描述, 它的值是一个服务器的绝对时间, 表示某个资源文件最近一次更新的时间, 它会在客户端首次请求数据时返回. 当客户端再次向服务器请求数据时, 应该将本次请求头中的If-Modified-Since设置为上次服务器返回的Last-Modified中的值. 服务器通过比对资源文件更新时间和If-Modified-Since中的上次更新时间判断资源文件是否有更新, 如果资源没有更新, 仅仅返回一个304状态码通知客户端继续使用本地缓存. 反之, 返回一个200和更新后的资源通知客户端使用最新数据. 简单描述一下就是:

首次请求客户端获取: 
{  
  Request request = [Request New];
  ...
  [SendRequest: request];
}
首次请求服务器返回: 
{
  Response response = [Response New];
  response.Expires = ...
  response.Cache-Control.max-age = ...
  response.body = File.data;
  response.Last-Modified = File.Last-Modified;
  ...
  return response;
}

再次请求客户端获取: 
{  
  Request request = [Request New];
  ...
  request.If-Modified-Since = 上次请求返回的Last-Modified
  [SendRequest: request];
}

再次请求服务器返回: 
{
  Response response = [Response New];
  if (request.If-Modified-Since == File.Last-Modified) {
    response.statusCode = 304
  } else {
  
    response.statusCode = 200;
    response.body = File.data;
    response.Last-Modified = File.Last-Modified;
  }
  ...
  return response;
}
  • Etag/If-None-Match

事实上, Last-Modified也存在一些不足:

  1. Last-Modified标注的最后修改只能精确到秒级, 如果某些文件在1秒钟以内被修改多次的话, 它将不能准确标注文件的修改时间(无法及时更新文件).
  2. 如果某些文件会被定期生成, 而内容其实并没有发生任何变化, 但Last-Modified却改变了, 这种情况其实应该返回304而不是200加上资源文件.

ETag便是为解决以上问题而生的. ETag描述了一个资源文件内容的唯一标识符, 如果两个文件具有相同的ETag, 那么表示这两个文件的内容完全一样, 即使它们各自的更新/创建时间不同. 同样的, ETag也会在首次请求数据时返回. 当客户端再次向服务器请求数据时, 应该将本次请求头中的If-None-Match设置为上次服务器返回的ETag中的值. 服务器通过比对资源文件的ETag和If-None-Match中值判断返回304还是200加上资源文件.

当Last-Modified和ETag共用时, 服务器通常会优先判断If-None-Match(ETag), 如果并没有If-None-Match(ETag)字段再判断If-Modified-Since(Last-Modified). 但ETag目前并没有一个规定的统一生成方式, 有的用hash, 有的用md5, 有的甚至直接用Last-Modified时间. 所以有时ETag的生成策略比较繁琐时, 后台程序员可能会先判断If-Modified-Since, 如果If-Modified-Since不同再去生成ETag做比对. 这并不是强制的, 主要看开发人员的心情.

移动端缓存策略

上面简单介绍了一下浏览器缓存策略, 容易知道, 当浏览器加载网页时, 会存在以下四种情况:

  1. 本地缓存为空, 发起网络请求获取后台数据进行展示并缓存, 同时记录数据有效期(Expires/Cache-Control + 本次请求时间), 数据校验值(Last-Modified/ETag).

  2. 本地缓存不为空且处于有效期内, 直接加载缓存数据进行展示.

  3. 本地缓存不为空但已过期, 发起网络请求(请求头中带有数据校验值), 服务器通过校验值核对后表示缓存依然有效(仅仅返回304), 浏览器后续处理流程同2.

  4. 本地缓存不为空但已过期, 发起网络请求(请求头中带有数据校验值), 服务器通过校验值核对后表示缓存需要更新(返回200 + 数据), 浏览器后续处理流程同1.

这里我们姑且将第1步称作"缓存初始化", 2~4称作"缓存更新"(2和3更新量为零), 接下来要做的就是照猫画虎, 把这套缓存策略在移动端实现一遍.

缓存初始化

缓存初始化作为整个缓存策略的第一步, 其重要性不言而喻, 我们需要尽量保证初始化过程能够拿到正确完整的数据, 否则之后的"缓存更新"也就没有任何意义了. 万事开头难, 在第一步我们就会遇到一个大问题: 初始化数据量大, 如何分页?

  • 通过页码分页初始化

这个问题很容易出现, 比如一个用户有400+好友, 一个网络请求把400+都拉下来肯定不现实, 客户端势必是要做个分页拉取的. 直觉上, 我们可以像普通的分页请求一样, APP直接传页码让后台分页返回数据似乎就能搞定这个问题. 然而实际情况是: 最好不要这样做.

考虑以下情况, 总共200+左右的好友数据, 每次分页拉取50个.

第一次拉取时本地页码为1, 拉取0~49个好友成功后, 本地页码更新为2. 第二次拉取50~99个好友时失败了, 本地页码不更新依然为2.

如果此时用户刚好在网页端/Android端又添加了50个新好友, 于是后台页码后移, 本来处在第一页的0~49现在变成了50~99, 而第二页的50~99现在变成了100~149. 所以, 当我们通过本地页码2去拉取数据时拉取到的数据其实是早就获取过的数据, 本次拉取只是在浪费时间, 浪费流量而已, 而新增的那些好友显然这次是拉取不到了. 上面只是小问题, 反过来, 如果用户当时不是在添加好友而是在删除好友(假设删除的就是0~49), 那么后台页码前移, 第二页的50~99现在变成了第一页, 而我们的本地页码还是2, 那么原来的第二页数据肯定就拿不到了, 同时第一页本来该删除的数据却被缓存下来了, 这便是数据错乱, 大问题!

事实上, 整个过程并不需要有什么请求失败之类的特殊条件, 只要在初始化过程中后台数据发生了变化, 页码方式获取到的数据或多或少都有问题, 理论上, 初始化的时间拉的越长, 那么问题出现的概率和严重性就越大(比如请求失败或者初始化了一半就退出APP了).

  • 通过URL数组分页初始化

普通的页码拉取的方式行不通, 那么分页拉取应该如何搞? 回答这个问题, 我们可以看看浏览器是如何初始化一个网页的, 模仿到底嘛.

当浏览器首次向服务器请求网页数据时, 服务器的首次返回数据其实是一个HTML文件, 这个HTML文件只包含一些基本的页面展示, 而页面内嵌的Image/JS/CSS等等都是作为一个个HTML标签而不是直接一次性返回的. 浏览器在拿到这个HTML后一边渲染一边解析, 一旦解析到一个Image/JS/CSS它就会通过标签引用的URL向服务器获取相应的Image/JS/CSS, 获取到相应资源以后填充到合适的位置以提供展示/操作.

如果我们把一个TableView当成一个HTML页面看的话, 那么列表内部展示的一个个Cell其实就相当于HTML中的一个个Image标签, Cell展示的数据源其实就是这些标签引用的URL对应的图片. 不过和HTML请求标签元素的情况不同, Cell的数据源不像图片那样动辄上百KB甚至几MB, 所以我们没必要针对每个标签都分别发起一次请求, 一次性拉取几十上百个数据源完全没有问题.

那么按照这个思路, 针对初始化的处理会分成两步:

  1. 拉取待初始化列表元素的的URL数组(也就是各个Model的主键)
  2. 根据上面的URL数组分页拉取列表元素

仍然以上面的情况举例, 我们看看这种思路能不能解决上面的问题:

初始化一个200人的好友列表, 首先我们会拉取这200个好友的用户Id, 假设是[0...199]. 拉取第一页时我们传入[0...49]个Id从服务器拉取50个好友, 拉取成功后从初始化Id列表删除这50个Id, 初始化Id列表变成[50...199], 此时有50个新好友被添加到服务器, 服务器数据变动, 但是本地的初始化列表没变, 所以我们可以继续拉取到[50...99]部分的数据, 以此类推. 显然, 我们不会有任何冗余的数据请求.

反过来, 如果[0...49]部分的好友被删除, 服务器数据变动, 但是本地列表因为没有变动, 后续的[50...199]自然也是能准确拉取到的, 不会发生数据丢失.

但是这样的做法依然存在弊端, 因为本地的初始化列表不做变更, 那么服务器在初始化过程中新增的数据我们是不知道的, 自然也就不会去拉取, 初始化的数据就少了. 反过来, 初始化过程已拉取的数据如果被删除了, 客户端依然不知情, 缓存中就会有无效数据. 那么, 如何解决这两个问题呢?

一个简单的解决方法是: 在某次分页拉取的返回数据中, 服务器不仅返回对应的数据, 同时也返回一下此时最新的Id数组. 本地根据这个最新的Id数组进行比对, 多出来的部分显然就是新增的, 我们将这部分更新到初始化列表继续拉取. 而少掉的部分显然就是被删除的, 我们从数据库中删除这部分无效数据. 这样会多一部分Id数组的开销, 但是相比它解决的问题而言, 这点开销微不足道.

上面的论述通过一个简单的例子解释了为什么应该选择了URL数组分页而不是页码分页的方式来进行缓存初始化. 这里需要说明的是, URL数组分页的方式本身还有非常多可以优化的点, 不过于我而言, 完全不想搞得那么复杂(预优化什么的, 能不做就不做). 实际的代码中, 实现其实也比较简单, 不会过多的考虑优化点和特殊情况.

该说的都说的差不多了, 接下来就看看具体的实现代码吧(目前我司走的是TCP+Protobuf做网络层, CoreData做缓存持久化, 这些工具的相应细节在之前的博客中都有介绍, 这里我假设各位已经看过这些旧博客了, 因为下面的代码都会以此为前提) :

  • 获取待初始化Id数组
//获取当前登录用户的待初始化Id数组
- (void)fetchInitialIdsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    /** 构建Protobuf请求body */
    IdArrayReqBuilder *builder = [IdArrayReq builder];
    builder.userId = [LoginUserId integerValue];
//  builder.xxx = ...
    IdArrayReq *requestBody = [builder build];
    
    HHDataAPIConfiguration *config = [HHDataAPIConfiguration new];
    config.message = requestBody;
    config.messageType = Init_IdArray;/** 请求序列号(URL) */
    [self dispatchDataTaskWithConfiguration:config completionHandler:^(NSError *error, id result) {
        
        if (!error) {
            
            IdArrayResp *response = [IdArrayResp parseFromData:result];
            if (response.state != 200) {
                
                error = [NSError errorWithDomain:response.msg code:response.state userInfo:nil];
            } else {
                
                /** 一.存储最新的服务器Id数组 */
                HHUser *loginUser = [HHUser new];
                loginUser.userId = [LoginUserId integerValue];
                loginUser.groupIdArray = response.result.groupIdArray;/** 群组Id数组 */
                loginUser.friendIdArray = response.result.friendUserIdArray;/** 好友Id数组 */
                loginUser.favoriteIdArray = response.result.favoritesIdArray;/** 收藏夹Id数组 */
//                ...各种Id数组
                [loginUser save];

                /** 二.存储所有待初始化的缓存Id数组 */
                [self saveInitialIdsWithOwner:loginUser];
                
                /** 三.删除本地多余缓存数据 */
                [self syncCache];
            }
        }
        completionHandler ? completionHandler(error, result) : nil;
    }];
}

- (void)saveInitialIdsWithOwner:(HHUser *)user {
    
    void (^saveInitialIds)(NSString *, NSString *, NSArray *) = ^(NSString *saveKey, NSString *saveTableName, NSArray *saveIds) {

        NSString *kAlreadySetInitIds = [NSString stringWithFormat:@"AlreadySet_%@", saveKey];
        if (saveIds.count > 0 && ![UserDefaults boolForKey:kAlreadySetInitIds]) {
            
            [UserDefaults setBool:YES forKey:kAlreadySetInitIds];

            HHCacheInfo *cacheInfo = [HHCacheInfo cacheInfoWithTableName:saveTableName];
            cacheInfo.ownerId = user.userId;
            cacheInfo.cacheInterval = 60;
            cacheInfo.loadedPrimaryKeys = saveIds;
            [cacheInfo save];
            
            [UserDefaults setObject:saveIds forKey:saveKey];
        }
    };
    
    NSNumber *currentUserId = @(user.userId);
    saveInitialIds(kInitialGroupIds(currentUserId), @"CoreGroup", user.groupIdArray);
    saveInitialIds(kInitialFriendIds(currentUserId), @"CoreFriend", user.friendIdArray);
    saveInitialIds(kInitialFavoriteIds(currentUserId), @"CoreFavorite", user.favoriteIdArray);
//    ...各种Id数组
}
#define kInitialGroupIds(userId) [NSString stringWithFormat:@"%@_InitialGroupIds", userId]
#define kInitialFriendIds(userId) [NSString stringWithFormat:@"%@_InitialFriendIds", userId]
...
@interface HHCacheInfo : NSObject

@property (copy, nonatomic) NSString *tableName;/**< 缓存表名 */

@property (assign, nonatomic) NSInteger cacheInterval;/**< 有效缓存的时间间隔 */
@property (assign, nonatomic) NSInteger lastRequestDate;/**< 最后一次请求时间 */
@property (assign, nonatomic) NSInteger lastModifiedDate;/**< 最后一次更新时间 */

@property (strong, nonatomic) NSArray *loadedPrimaryKeys;/**< 缓存表的所有id数组 */

@property (assign, nonatomic) NSInteger ownerId;/**< 缓存数据所属的用户id */
@property (assign, nonatomic) NSInteger groupId;/**< 三级缓存所属模块id */

@end

首先, 我们需要一个接口返回需要初始化的Id数组, 代码中这个接口会一次性返回所有需要初始化数据的Id数组(实际上每个缓存表都有各自的Id数组接口, 这个统一接口只是为了方便). 这个接口的调用时机比较早, 目前是在用户手动登录或者APP启动自动登录后我们就会马上去获取这些Id数组.

获取当前登录用户的待初始化Id数组(fetchInitialIdsWithCompletionHandler:)中的一和三以及HHCacheInfo .loadedPrimaryKeys属于缓存更新的内容, 我们暂且不谈.

这里先介绍和初始化相关的部分:

  1. HHCacheInfo的大部分属性定义主要参照浏览器缓存, 而特有的ownerId用于区分单个手机多个用户的情况, 也就是二级缓存标识, groupId则是某个用户群组/收藏夹之类三级缓存标识(用户属于一级缓存, 某个用户的好友/关注/群组属于二级缓存, 某个用户的群组下的群成员/群聊属于三级缓存).

  2. saveInitialIdsWithOwner:方法会设置每个缓存表的过期时间间隔(简单起见, 这个时间直接在本地设置, 当然, 也可以由服务器返回后设置), 同时将获取到Id数组按照各自对应的缓存表名存储到UserDefaults, 需要说明的是, 虽然获取服务器最新数据Id数组(即初始化Id数组)的接口会调用多次, 但存储初始化Id数组的过程只会执行一次.

  • 初始化某个具体的缓存表

获取到这些初始化Id数组后, 当用户点击进入某个具体页面时, 这个页面的相关数据的初始化流程就会启动. 这里我们以好友列表页面举例:

//TODO: 加载第一页好友列表
- (void)refreshFriendsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    self.friendAPIRecorder.currentPage = 0;
    [self fetchFriendsWithPage:self.friendAPIRecorder.currentPage pageSize:self.friendAPIRecorder.pageSize completionHandler:completionHandler];
}

//TODO: 加载下一页好友列表
- (void)loadMoreFriendsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    self.friendAPIRecorder.currentPage += 1;
    [self fetchFriendsWithPage:self.friendAPIRecorder.currentPage pageSize:self.friendAPIRecorder.pageSize completionHandler:completionHandler];
}

- (void)fetchFriendsWithPage:(NSInteger)page pageSize:(NSInteger)pageSize completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    HHCacheInfo *cacheInfo = [HHCacheInfo findFirstWithPredicate:[NSPredicate predicateWithFormat:@"ownerId = %@ && tableName = CoreFriend", LoginUserId]];
    
    //1.每次进入好友列表都会进入初始化流程 但只有拉取第一页数据完成后才需要执行回调方法
    BOOL isFirstTimeInit = (cacheInfo.lastRequestDate == 0);
    [self initializeFriendsWithCompletionHandler:isFirstTimeInit ? completionHandler : nil];
    if (!isFirstTimeInit) {
        
        //2.先将缓存数据返回进行页面展示
        [self findFriendsWithPage:page pageSize:pageSize completionHandler:completionHandler];//获取缓存数据
        
        //3.判断缓存是否过期 过期的话进入缓存更新流程
        //...缓存更新先不看 略
        }
    }
}
//TODO: 初始化我的好友列表1.1
static NSMutableDictionary *isInitializingFriends;
- (void)initializeFriendsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    isInitializingFriends = isInitializingFriends ?: [NSMutableDictionary dictionary];

    NSNumber *currentUserId = LoginUserId;
    
    1.没有需要初始化的数据或者初始化正在执行中 直接返回
    NSArray *allInitialIds = [UserDefaults objectForKey:kInitialFriendIds(currentUserId)];
    if (allInitialIds.count == 0 || [isInitializingFriends[currentUserId] boolValue]) {
        !completionHandler ?: completionHandler(HHError(@"暂无数据", HHSocketTaskErrorNoData), nil);
    } else {
        
        2.否则进入初始化流程 同时正在初始化的标志位给1
        [self fetchAllFriendsWithCompletionHandler:completionHandler];
    }
}

//TODO: 初始化我的好友用户列表1.2
- (void)fetchAllFriendsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    //预防初始化过程中用户切换或者退出登录的情况
    NSNumber *currentUserId = LoginUserId;
    isInitializingFriends[currentUserId] = @YES;

    1.根据Id数组从新向旧拉取数据
    NSMutableArray *allInitialIds = [[UserDefaults objectForKey:kInitialFriendIds(currentUserId)] mutableCopy];
    NSArray *currentPageInitialIds = [allInitialIds subarrayWithRange:NSMakeRange(MAX(0, allInitialIds.count - 123), MIN(123, allInitialIds.count))];
    
    /** 构建Protobuf请求body */
    UserListFriendInitReqBuilder *builder = [UserListFriendInitReq builder];
    [builder setUserIdArrayArray:currentPageInitialIds];
//  builder.xxx = ...
//  ...
    UserListFriendInitReq *requestBody = [builder build];
    
    HHDataAPIConfiguration *config = [HHDataAPIConfiguration new];
    config.message = requestBody;
    config.messageType = USER_LIST_FRIEND_INIT;/** 请求序列号(URL) */
//  config.messageHeader = ...
    [self dispatchDataTaskWithConfiguration:config completionHandler:^(NSError *error, id result) {
        
        if (!error) {
            
            UserListFriendResp *response = [UserListFriendResp parseFromData:result];
            //2.获取数据出错 解析错误信息
            if (response.state != 200 || response.result.objFriend.count == 0) {
                
                error = [NSError errorWithDomain:response.msg code:response.state userInfo:nil];
            } else {
                
                BOOL isFirstTimeInit = (completionHandler != nil);
                
                //3. 获取完一页数据 更新待初始化的数据Id数组
                [allInitialIds removeObjectsInArray:currentPageInitialIds];
                [UserDefaults setObject:allInitialIds forKey:kInitialFriendIds(currentUserId)];
                
                if (isFirstTimeInit) {
                    
                    4. 只有第一页数据初始化需要更新缓存信息 
                    HHCacheInfo *cacheInfo = [HHCacheInfo cacheInfoWithTableName:@"CoreFriend"];
                    cacheInfo.ownerId = [currentUserId integerValue];
                    cacheInfo.lastRequestDate = [[NSDate date] timeIntervalSince1970];//更新本地请求时间
                    cacheInfo.lastModifiedDate = response.result.lastModifiedDate;//更新最近一次数据更新时间
                    [cacheInfo save];
                }
                
                NSMutableArray *currentPageFriends = [NSMutableArray array];
                for (UserListFriendRespObjFriend *object in response.result.objFriend) {
                    
                    HHFriend *friend = [HHFriend instanceWithProtoObject:object];
                    friend.ownerId = [currentUserId integerValue];
                    [currentPageFriends addObject:friend];
                }
                
                5.获取到的数据存入数据库
                HHPredicate *predicate = [HHPredicate predicateWithEqualProperties:@[@"ownerId"] containProperties:@[@"userId"]];
                [HHFriend saveObjects:currentPageFriends checkByPredicate:predicate completionHandler:^{
                    
                    //6.第一页数据初始化完成 通知页面刷新展示
                    if (isFirstTimeInit) {
                        [self findFriendsWithPage:0 pageSize:self.friendAPIRecorder.pageSize completionHandler:completionHandler];
                    }
                }];
            }
        }
        
        //7.只有拉取第一页数据失败的情况本地没有数据 所以需要展示错误信息
        if (error != nil && isFirstTimeInit) {
            completionHandler(error, nil);
        }
        
        //8. 根据情况判断是否继续拉取下一页初始化数据
        if (allInitialIds.count == 0 || error != nil) {
            /** 初始化数据拉取完成 或者 拉取出错 退出此次初始化 等待下次进入页面重启初始化流程 */
            isInitializingFriends[currentUserId] = @NO;//正在初始化的标志位给0
        } else {/** 没出错且还有初始化数据 继续拉取 */
            [self fetchAllFriendsWithCompletionHandler:nil];
        }
    }];
}
//TODO: 获取缓存中我的好友
- (void)findFriendsWithPage:(NSInteger)page pageSize:(NSInteger)pageSize completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"ownerId = %@ && friendState = 2",LoginUserId];
    [HHFriend findAllSortedBy:@"contactTime" ascending:NO withPredicate:predicate page:page row:pageSize completionHandler:^(NSArray *objects) {

        NSError *error;
        if (objects.count == 0) {
            
            NSInteger errorCode = page == 0 ? HHNetworkTaskErrorNoData : HHNetworkTaskErrorNoMoreData;
            NSString *errorNotice = page == 0 ? @"空空如也~" : @"没有更多了~";
            error = HHError(errorNotice, errorCode);
        }
        completionHandler ? completionHandler(error, objects) : nil;
    }];
}

东西有点多, 我们一个方法一个方法来看:

  • fetchFriendsWithPage:pageSize:completionHandler:

这个方法是VC获取好友列表数据的接口, 做的事情很简单, 判断一下本地是否有缓存数据, 有就展示, 没有就进入缓存初始化流程或者缓存更新流程. 需要注意的是, 因为我们不能保证所有的初始化数据都已经拉取完成了(比如请求失败, 只拉取了一部分数据APP就被用户杀死了等等), 所以初始化流程每次都会进行. 另外, 只有拉取第一页初始化数据的情况下本地是没有任何数据的, 所以第一页初始化数据拉取完成后需要执行页面刷新回调, 而其他情况中本地缓存都至少有一页数据, 所以就直接读取缓存进行展示而不需要等到网络请求执行完成后才展示.

  • initializeFriendsWithCompletionHandler:

这个方法只是一些简单的逻辑判断, 防止已初始化/正在初始化的数据多次拉取等等(即处理反复多次进出页面, 反复刷新之类的情况), 看注释即可.

  • fetchAllFriendsWithCompletionHandler:

这个方法是最终执行网络请求的地方, 做的事情最多, 不过流程我都写了注释, 阅读起来应该没什么问题, 这里我只列举几个需要注意的细节:

1.把之前获取的Id数组进行分页, 留待下方使用. 这里细节在于:分页的顺序是从后向前截取而不是直接顺序截取的. 这是因为服务器返回的Id数组默认是升序排列的, 最新的数据对应的Id其实处在最后, 本着最新的数据最先展示的逻辑, 所以我们需要倒着拉取.

3.获取完本页数据后,将获取过的Id数组移除. 这个很基础, 但是很重要, 专门提一下.

4.更新缓存信息. 在浏览器缓存策略部分提过: Last-Modified指示的是缓存最近一次的更新时间. 在我们的初始化数据中, 最近一次的更新时间显然就是第一页数据中最后的那一条的更新时间了. 只有在这个时间之后的数据才会比当前初始化数据还要新, 需要进入缓存更新流程. 而在这个时间之前的数据, 显然都已经在我们的初始化Id数组中了, 直接拉取即可. 所以, 只有在第一页数据拉取完成后我们才需要保存CacheInfo.lastModifiedDate.

8.拉取完成后的标识位设置(正在初始化和所有初始化数据都拉取完成的标识), 很基础, 但是很重要.

缓存更新

初始化成功后, 在缓存过期之前都可以直接读取本地缓存进行展示, 这能显著提升页面加载速度, 同时一定程度上减轻服务器的压力. 然而, 缓存总会过期, 这时候就需要进入缓存更新的流程了. 这里我们将缓存更新拆成两部分: 添加更新缓存和删除无用缓存.

  • 添加更新缓存
- (void)fetchFriendsWithPage:(NSInteger)page pageSize:(NSInteger)pageSize completionHandler:(HHNetworkTaskCompletionHander)completionHandler {

    HHCacheInfo *cacheInfo = [HHCacheInfo findFirstWithPredicate:[NSPredicate predicateWithFormat:@"ownerId = %@ && tableName = CoreFriend", LoginUserId]];

    //1.每次进入好友列表都会进入初始化流程 但只有拉取第一页数据完成后才需要执行回调方法
    BOOL isFirstTimeInit = (cacheInfo.lastRequestDate == 0);
    [self initializeFriendsWithCompletionHandler:isFirstTimeInit ? completionHandler : nil];
    if (!isFirstTimeInit) {

        //2.先将缓存数据返回进行页面展示
        [self findFriendsWithPage:page pageSize:pageSize completionHandler:completionHandler];//获取缓存数据

        //3.判断缓存是否过期 过期的话进入缓存更新流程
        [self checkIncreasedFriendWithCacheInfo:cacheInfo completionHandler:completionHandler];
        }
    }
}

//TODO: 缓存更新1: 检查本地和服务器是否有需要拉取的更新数据
static NSMutableDictionary *isFetchingFriendsIncrement;
- (void)checkIncreasedFriendWithCacheInfo:(HHCacheInfo *)cacheInfo completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    isFetchingFriendsIncrement = isFetchingFriendsIncrement ?: [NSMutableDictionary dictionary];
    
    //1.正在拉取更新数据 直接返回
    NSNumber *currentUserId = LoginUserId;
    if ([isFetchingFriendsIncrement[currentUserId] boolValue]) { return; }
    
    NSInteger currentDate = [[NSDate date] timeIntervalSince1970];
    if (currentDate - cacheInfo.lastRequestDate <= cacheInfo.cacheInterval) {
        
        2.缓存未过期 但是本地还有未拉取的更新数据Id数组(可能上次拉取第二页更新数据出错了) 继续拉取
        NSArray *allIncreaseIds = [UserDefaults objectForKey:kIncreasedFriendIds(currentUserId)];
        if (allIncreaseIds.count > 0) {
            [self fetchAllIncreasedFriendsWithCompletionHandler:completionHandler];
        }
    } else {
        3.缓存过期了 通过lastModifiedDate询问服务器是否有更新的数据
        [self fetchIncreasedFriendIdsWithLastModifiedDate:cacheInfo.lastModifiedDate completionHandler:completionHandler];
    }
}

//TODO: 缓存更新2 获取服务器更新数据的Id数组 有更新的话从通过Id数组从服务器拉取数据
- (void)fetchIncreasedFriendIdsWithLastModifiedDate:(NSInteger)lastModifiedDate completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    1.正在拉取更新数据标志位给1
    NSNumber *currentUserId = LoginUserId;
    isFetchingFriendsIncrement[currentUserId] = @YES;
    
    /** 构建Protobuf请求body */
    UserListFriendReqBuilder *builder = [UserListFriendReq builder];
    builder.lastModifiedDate = lastModifiedDate;/** 提供数据上次更新时间给服务器校验 */
    //      builder.xxx = ...
    //      ...
    UserListFriendReq *request = [builder build];
    
    HHDataAPIConfiguration *config = [HHDataAPIConfiguration new];
    config.message = request;
    config.messageType = USER_LIST_FRIEND_INC;/** 请求序列号(URL) */
    //      config.messageHeader = ...
    [self dispatchDataTaskWithConfiguration:config completionHandler:^(NSError *error, id result) {
        
        NSMutableArray *allIncreaseIds = [NSMutableArray arrayWithArray:[UserDefaults objectForKey:kIncreasedFriendIds(currentUserId)]];
        if (!error) {
            
            UserListFriendIncResp *response = [UserListFriendIncResp parseFromData:result];
            if (response.state == 200) {
                
                2.将本地Id数组和服务器返回的更新Id数组简单合并一下
                NSMutableSet *resultIncreseIdSet = [NSMutableSet setWithArray:response.result.userIdArray];//服务器返回的更新数据Id数组
                NSMutableSet *currentIncreseIdSet = [NSMutableSet setWithArray:allIncreaseIds];//本地尚未获取的更新数据Id数组
                [resultIncreseIdSet minusSet:currentIncreseIdSet];//剔掉重复部分
                
                if (resultIncreseIdSet.count > 0) {

                      /** 服务器返回的更新Id数组排在最后面(即最先获取) */
                    [allIncreaseIds addObjectsFromArray:resultIncreseIdSet.allObjects];
                    [UserDefaults setObject:allIncreaseIds forKey:kIncreasedFriendIds(currentUserId)];
                }
            }
        }
        
        3.判断是否有未拉取的更新数据并进行拉取
        if (allIncreaseIds.count == 0) {
            
            //本地没有需要更新的Id数组 服务器也没有返回更新Id数组 直接返回
            isFetchingFriendsIncrement[currentUserId] = @NO;//重置标志位
        } else {
            
            //否则进入更新流程
            [self fetchAllIncreasedFriendsWithCompletionHandler:completionHandler];
        }
    }];
}

//TODO: 缓存更新3 根据Id数组拉取服务器更新数据
- (void)fetchAllIncreasedFriendsWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    //预防缓存更新过程中用户切换或者退出登录的情况
    NSNumber *currentUserId = LoginUserId;
    isFetchingFriendsIncrement[currentUserId] = @YES;
    
    //1.根据Id数组从新向旧拉取数据
    NSMutableArray *allIncreaseIds = [[UserDefaults objectForKey:kIncreasedFriendIds(currentUserId)] mutableCopy];
    NSArray *currentPageIncreaseIds = [allIncreaseIds subarrayWithRange:NSMakeRange(MAX(0, allIncreaseIds.count - 123), MIN(123, allIncreaseIds.count))];
    
    /** 构建Protobuf请求body */
    UserListFriendInitReqBuilder *builder = [UserListFriendInitReq builder];
    [builder setUserIdArrayArray:currentPageIncreaseIds];
    //  builder.xxx = ...
    //  ...
    UserListFriendInitReq *requestBody = [builder build];
    
    HHDataAPIConfiguration *config = [HHDataAPIConfiguration new];
    config.message = requestBody;
    config.messageType = USER_LIST_FRIEND_INIT;/** 请求序列号(URL) */
    //  config.messageHeader = ...
    [self dispatchDataTaskWithConfiguration:config completionHandler:^(NSError *error, id result) {
        
        if (!error) {
            
            UserListFriendResp *response = [UserListFriendResp parseFromData:result];
            //2.获取数据出错 解析错误信息
            if (response.state != 200 || response.result.objFriend.count == 0) {
                
                error = [NSError errorWithDomain:response.msg code:response.state userInfo:nil];
            } else {
                
                BOOL isFirstPageIncrement = (completionHandler != nil);
                
                //3. 获取完一页数据 更新未拉取更新数据的数据Id数组
                [allIncreaseIds removeObjectsInArray:currentPageIncreaseIds];
                [UserDefaults setObject:allIncreaseIds forKey:kIncreasedFriendIds(currentUserId)];
                
                if (isFirstPageIncrement) {
                    
                    //4. 只有第一页更新数据需要更新缓存信息
                    HHCacheInfo *cacheInfo = [HHCacheInfo cacheInfoWithTableName:@"CoreFriend"];
                    cacheInfo.ownerId = [currentUserId integerValue];
                    cacheInfo.lastRequestDate = [[NSDate date] timeIntervalSince1970];//更新本地请求时间
                    cacheInfo.lastModifiedDate = response.result.lastModifiedDate;//更新最近一次数据更新时间
                    [cacheInfo save];
                }
                
                NSMutableArray *currentPageFriends = [NSMutableArray array];
                for (UserListFriendRespObjFriend *object in response.result.objFriend) {
                    
                    HHFriend *friend = [HHFriend instanceWithProtoObject:object];
                    friend.ownerId = [currentUserId integerValue];
                    [currentPageFriends addObject:friend];
                }
                
                //5.获取到的数据存入数据库
                HHPredicate *predicate = [HHPredicate predicateWithEqualProperties:@[@"ownerId"] containProperties:@[@"userId"]];
                [HHFriend saveObjects:currentPageFriends checkByPredicate:predicate completionHandler:^{
                    
                    //6.第一页更新数据拉取完成 通知页面刷新展示
                    if (isFirstPageIncrement) {
                        [self findFriendsWithPage:0 pageSize:self.friendAPIRecorder.pageSize completionHandler:completionHandler];
                    }
                }];
            }
        }

        //7. 根据情况判断是否继续拉取下一页更新数据
        if (allIncreaseIds.count == 0 || error != nil) {
            /** 更新数据拉取完成 或者 拉取出错 退出此次缓存更新 等待下次进入页面重启缓存更新流程 */
            isFetchingFriendsIncrement[currentUserId] = @NO;//正在拉取更新数据的标志位给0
        } else {/** 没出错且还有初始化数据 继续拉取 */
            [self fetchAllIncreasedFriendsWithCompletionHandler:nil];
        }
    }];
}

添加更新缓存的逻辑跟浏览器缓存更新的策略差不多: 在缓存过期以后, 将上次请求返回的lastModifiedDate回传给服务器, 服务器查询这个时间之后的更新数据并以Id数组的形式返回给客户端, 客户端拿到更新数据的Id数组后将Id数组进行分页后拉取即可. 当然, 如果服务器返回的更新数据Id数组为空(相当于304), 那就表示我们的数据就是最新的, 也就不用做什么分页拉取了. 代码比较简单, 提两个细节即可:

1.因为我们的数据拉取逻辑比较简单, 出现错误并不会进行重试操作而是直接返回, 有可能更新的数据只拉取了一部分或者一点都没拉取到, 所以和初始化流程一样, 每次进入相应页面我们都会检查一下是否有更新数据还没拉取到, 如果有就继续拉取.

2.在1的基础上, 我们细分出两种情况: 更新数据一点都没拉取到和拉取了一部分更新数据.

第一种情况很简单, 因为一点数据拉取都没有拉取, 所以Cache.lastRequestDate是没有更新的, 下次进入页面依然是处于缓存过期的状态, 我们重新获取一下更新数据的Id数组, 覆盖本地的更新Id数组后重新拉取即可.

第二种情况麻烦一点, 因为拉取了第一页更新数据后肯定就更新过Cache.lastRequestDate了(更新lastRequestDate的逻辑和初始化是一样的), 所以下次进入页面可能是处在缓存有效期内, 也可能再次过期了. 前者很好处理, 根据本地未拉取的Id数组接着进行拉取即可. 后者的话需要先拉取本次服务器更新数据的Id数组, 然后和本地未拉取的Id数组进行去重后合并. 又因为此次服务器更新的数据肯定比我们本地未获取的数据要新, 按照倒序拉取的逻辑, 所以合并的顺序是服务器的Id数组在后, 本地的Id数组在前.

当然, 这些都是理论分析. 实际的情况是, 除了群聊/群成员少数接口外, 大部分接口的数据即使十天半个月不用APP, 再次使用时的更新量也很难超出一页(毕竟一页少说也能拉个七八十个数据呢, 半个月加七八十个好友/关注/群组之类的还是蛮难的), 所以缓存更新不像初始化那样可能存在部分拉取成功部分拉取失败的情况, 通常缓存更新只有一次拉取操作, 要么成功要么失败, 比较简单.

  • 删除无用缓存

相比初始化和添加更新缓存, 删除无用缓存就简单多了, 我们只需要在拉取到服务器最新的Id数组后, 和本地缓存Id数组一作比较, 删除本地缓存中多余的部分即可. 拉取服务器Id数组的接口在上面已经介绍过了, 现在我们需要的只是查询本地缓存中的Id数组就行了. 在CoreData中, 只获取某个表的某一列/几列属性大概这样写:

NSFetchRequest *request = [CoreFriend MR_requestAllWithPredicate:[NSPredicate predicateWithFormat:@"ownerId = %@", LoginUserId]];
request.resultType = NSDictionaryResultType;//设置返回类型为字典
request.propertiesToFetch = @[@"userId"];//设置只查询userId(只有返回类型为NSDictionaryResultType才有用)
NSArray<NSDictionary *> *result = [CoreFriend MR_executeFetchRequest:request];

NSMutableArray *friendIds = [NSMutableArray array];
[result enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    [friendIds addObject:obj[@"userId"]];
}];

注意查询结果是一个字典数组, 所以本地还要再遍历一次, 略有些麻烦. 不过, 我们可以换一种思路, 因为本地缓存所有的数据其实都是通过初始化/更新获取到的, 在这两项操作进行时, 我是完完全全知道数据的Id数组是什么的, 我需要做的就是将这些Id数组存到CacheInfo.loadedPrimaryKeys中, 当我要用的时候, 直接查询CacheInfo就好了, 没必要查询整个缓存表后再做一次遍历. 两种思路各有利弊, 按需选择即可. 这里我以第二种思路举例:

/**
 根据服务器最新的Id数组删除本地多余缓存

 @param freshFriendIds 服务器最新的Id数组
 */
- (void)syncCacheWithFreshFriendIds:(NSArray *)freshFriendIds {
    
    HHCacheInfo *cacheInfo = [HHCacheInfo findFirstWithPredicate:[NSPredicate predicateWithFormat:@"tableName = CoreFriend && ownerId = %@", LoginUserId]];
    if (cacheInfo.loadedPrimaryKeys.count > 0) {
    
      NSMutableSet *freshFriendIdSet = [NSMutableSet setWithArray:freshFriendIds];//服务器最新Id数组
      NSMutableSet *cachedFriendIdSet = [NSMutableSet setWithArray:cacheInfo.loadedPrimaryKeys];//本地缓存的Id数组
      [cachedFriendIdSet minusSet:freshFriendIdSet];
      [cachedFriendIdSet removeObject:@""];
    
      //将本地缓存多余的部分从数据库中删除
      NSArray *deleteFriendIds = cachedFriendIdSet.allObjects;
      if (deleteFriendIds.count > 0) {

          NSPredicate *predicate = [NSPredicate predicateWithFormat:@"ownerId = %@ && userId in %@",LoginUserId, deleteFriendIds];
          [HHFriend deleteAllMatchingPredicate:predicate completionHandler:^{
            
              cacheInfo.loadedPrimaryKeys = freshFriendIds;
              [cacheInfo save];
        }];
    }
}

好友模块的缓存逻辑大概就是这样了, 其他的二级缓存如关注/群组/作品等等的缓存逻辑也差不多, 一通百通. 三级缓存的逻辑会多一些, 不过套路是类似的, 就不写了. 不得不说的是, 即使只是一个普通的二级缓存且不考虑优化的情况下, 整个缓存逻辑的代码也有大概350+, 代码量堪比一个普通的ViewController. 想象一下项目中大概有接近20个接口都要做这样的缓存处理, 心里便如阵阵暖流拂过般温暖.

最后需要说明的是, 这套缓存策略并不是万能的, 有两种情况并不适用:

  1. 数据更新太频繁的情况不适用. 如首页动态这样一秒七十二变的接口, 有网情况的缓存基本没有任何意义, 无网缓存到是可以做一做, 博老板一笑.
  2. 数据量太大的情况不适用. 如粉丝这样动辄上万的接口, 数据量太大, 拉取耗时耗力, 而且效果不明显, 肯定是不做的. 一般这个数据量最好不要过千, 比如QQ的好友/群组数量根据等级不同依次为500~900个. 想要超出这个限制也行, 这可是程序员的心血苦汗, 得加钱! 然而, 加钱也最多到2000个.

然后啊...

"你的意思是, 即使当时工期很紧, APP用户也不多的情况下, 你们依然不得不做个缓存逗老板开心?" "嗯呐!" "奥. 那东西做出来了, 然后呢?" "然后啊..."

D: "A总, APP优化完成了, 您过目一下."

A: "嗯, 不错. 现在进过一次的页面都是秒开, 没网的情况也能有东西展示了, 挺好!"

D: "您开心就好...有什么要求您尽管..."

A: "等等! 为什么这个页面第一次进的时候还是一直在转加载圈? 还有这个, 这个, 这个也是..."

D: "额...你知道的, 公司网不好..."

A: "哼, 又是网不好! 你看看人家QQ/微信/微博..."

"呵呵, 倒是两个妙人. 行了, 该问的也问得差不多了, 最后问个问题就结束吧. 已知你的月薪为X元, 深圳个税起征点是Y元, 个税税率为%Z, 公司每月只给你交最低档的社保和公积金. 问: 在做缓存策略这个月你每天朝九晚九并且周末无双休, 那么, 你本月的加班费应当为多少?"

"很简单, 0! 因为我们没有加班费..."

"嗯, 很好. 在之前的谈话中, 你的记忆力, 逻辑思维能力和反应力都表现为正常人的水准, 只是可能加班过度, 有点儿焦虑情绪, 别的没什么大问题. 行了, 也别住院了, 我给开点儿药, 回去呢你按时吃, 平时多注意休息, 没事儿多看看<小时代>或者<白衣校花与大长腿>之类的片子, 有助于睡眠..."

...

...

...

"我可以出院了? 我可以出院了! 我可以出...院...了!!!"

"诶, 你...你别喊啊! 别...别喊了! 我...般若掌! 你说你喊什么喊, 要是让那帮家伙听见了, 又得给你来一针! 咱可说好了, 你不喊了, 我就撒手, 听懂了就眨眨眼!

诶...这就对了, Easy, Easy!

你看, 这还有一会儿才到吃药时间. 咱们再玩一次, 这回换我当程序员, 你演那个穿白大褂的, 来!来!来! 嘿嘿嘿嘿..."