从简书迁移到掘金程序员
"时间?"数据库
"去年夏天, 六月, 具体哪天记不得了. 我只记得那天很是的热, 喝了好多水仍是很渴." "我没问你热不热渴不渴, 问什么答什么, 不要作多余的事情, 明白吗?"数组
"奥...明白了."浏览器
"嗯. 事情决定的时候你在哪? 在干吗?"缓存
"当时我正在个人工位上看小说. 啊...不是, 看博客! 啊...不是, 写代码! 嗯, 对的, 我当时正在专心写代码!"bash
"嗯? 算了. 事情是谁决定的? 具体细节是怎样的?"服务器
"这个...我...我记不清楚了, 能不说吗?"微信
"记不清楚了? 你要明白如今你找我帮忙, 不是我找你帮忙! 你最好像小女生聊八卦同样把东西都仔仔细细说清楚喽, 否则, 谁都帮不了你!"网络
"这...哎...好吧, 我说就是了..."工具
"当时, 我正在看...写代码, A总忽然让总监D哥去他办公室喝茶, 刚开始两我的确实开开心心地在喝茶, 可是, 过了一下子, 里面就开始传出两我的谈话的声音. 个人位置离A总办公室很近, 那地方隔音又很差, 我隐约听见..."
A: "阿D, 你来. 你看罢, 这个页面我曾是见过的! 我就退出了一小会儿, 怎么再进来又要加载? 这些个页面又不曾变过, 每次进来却都要看这劳什子加载圈, 有时候还加载不出来给个错误页面, 你说气人不气人!"
D: "嗯...想来是数据获取慢了些, 加载圈转得天然就久了. 你知道的, 公司网很差, 以前申请升级一下公司网络, 你不是说不想花钱没给批嘛"
A: "哼, 又是网很差. 欺负我不懂技术不是? 你看罢, QQ/微信/微博都是正常的, 网很差它们怎么没问题? 你别说这是技术问题! 这技术上的问题, 怎么能算问题呢? 他们作得, 咱们就作不得?"
D: "这..."
A: "这什么这! 嘿, 老伙计. 我敢打赌, 要是你嘴里再蹦出半个不字, 我就像中国足球队踢草坪那样, 踢爆你的屁股! 我向上帝保证, 我会这样作的!"
D: "那...行吧. 我这就下去办..."
"愉快的聊天后, D哥立刻就召集咱们紧急开会商量对策..."
D: "公司网络差, 客户端请求数据太慢, 总是显示加载中, A总对此很不满意! 我打算给客户端加上缓存, 每次数据加载前先拿缓存数据顶上, 获取到最新数据后再更新展现. 诸位, 意下如何啊?" 众人: ...
沉默 沉默是阻塞的主线程
D: "诶, 你们不要害羞嘛, 有什么想法均可以提出来, 集思广益嘛, 我又不是不讲道理." 同事X: "嗯...我以为仍是不要吧, 我们如今工期紧, 已有任务都没完成, 搞个缓存不是更拖进度? 并且如今产品没推广, 用户比较少, 要加缓存的地方又多, 不必搞这些吧." D: "你看, 你偏题了吧." 众人: ... 同事X: "拿人钱财, 与人消灾. 既然老板有需求, 作下属的自当赴汤蹈火死然后已, 只要老板开心就好. 我赞成!" 众人: "赞成" "赞成" "我也赞成" ... D: "很好, 可贵你们如此支持, 一致赞成. 那, 关于缓存策略, 诸位可有什么好的想法?" 众人: ...
沉默 沉默是异常的野指针
D: "诶, 你们不要害羞嘛, 有什么想法均可以提出来, 集思广益嘛, 我又不是不讲道理." 同事X: "额...要不, 您先说个想法让你们参考参考?" D: "也行, 那我就先说说个人想法, 不过毕竟是临时起意, 可能考虑不够周全, 有什么问题你们均可以提出来, 不要怕得罪人, 我又不是不讲道理. 嗯...你们以为浏览器缓存的路子怎么样?" 众人: "赞成" "赞成" "我也赞成" ...
"嗯, 这不是记得很清楚嘛! 就是这样, 好好配合, 不要搞事情. 对了, 上面说的那个浏览器缓存是什么意思?"
相信你们都有这样的体验, 浏览一次过的网页短期再次加载速度会比第一次快不少, 点击浏览器的前进后退按钮也比从新输入网页地址浏览要快, 另外, 甚至在没网的状况下有时咱们依然能浏览已经加载过的网页. 以上的一切其实都得益于咱们的Web缓存机制, 而Web缓存机制又分为服务端缓存和客户端缓存, 篇幅有限, 这里咱们仅简单介绍一下客户端缓存中的浏览器缓存.
在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中的上次更新时间判断资源文件是否有更新, 若是资源没有更新, 仅仅返回一个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;
}
复制代码
事实上, Last-Modified也存在一些不足:
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作比对. 这并非强制的, 主要看开发人员的心情.
上面简单介绍了一下浏览器缓存策略, 容易知道, 当浏览器加载网页时, 会存在如下四种状况:
本地缓存为空, 发起网络请求获取后台数据进行展现并缓存, 同时记录数据有效期(Expires/Cache-Control + 本次请求时间), 数据校验值(Last-Modified/ETag).
本地缓存不为空且处于有效期内, 直接加载缓存数据进行展现.
本地缓存不为空但已过时, 发起网络请求(请求头中带有数据校验值), 服务器经过校验值核对后表示缓存依然有效(仅仅返回304), 浏览器后续处理流程同2.
本地缓存不为空但已过时, 发起网络请求(请求头中带有数据校验值), 服务器经过校验值核对后表示缓存须要更新(返回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了).
普通的页码拉取的方式行不通, 那么分页拉取应该如何搞? 回答这个问题, 咱们能够看看浏览器是如何初始化一个网页的, 模仿到底嘛.
当浏览器首次向服务器请求网页数据时, 服务器的首次返回数据实际上是一个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, 因此咱们不必针对每一个标签都分别发起一次请求, 一次性拉取几十上百个数据源彻底没有问题.
那么按照这个思路, 针对初始化的处理会分红两步:
仍然以上面的状况举例, 咱们看看这种思路能不能解决上面的问题:
初始化一个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数组
- (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属于缓存更新的内容, 咱们暂且不谈.
这里先介绍和初始化相关的部分:
HHCacheInfo的大部分属性定义主要参照浏览器缓存, 而特有的ownerId用于区分单个手机多个用户的状况, 也就是二级缓存标识, groupId则是某个用户群组/收藏夹之类三级缓存标识(用户属于一级缓存, 某个用户的好友/关注/群组属于二级缓存, 某个用户的群组下的群成员/群聊属于三级缓存).
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;
}];
}
复制代码
东西有点多, 咱们一个方法一个方法来看:
这个方法是VC获取好友列表数据的接口, 作的事情很简单, 判断一下本地是否有缓存数据, 有就展现, 没有就进入缓存初始化流程或者缓存更新流程. 须要注意的是, 由于咱们不能保证全部的初始化数据都已经拉取完成了(好比请求失败, 只拉取了一部分数据APP就被用户杀死了等等), 因此初始化流程每次都会进行. 另外, 只有拉取第一页初始化数据的状况下本地是没有任何数据的, 因此第一页初始化数据拉取完成后须要执行页面刷新回调, 而其余状况中本地缓存都至少有一页数据, 因此就直接读取缓存进行展现而不须要等到网络请求执行完成后才展现.
这个方法只是一些简单的逻辑判断, 防止已初始化/正在初始化的数据屡次拉取等等(即处理反复屡次进出页面, 反复刷新之类的状况), 看注释便可.
这个方法是最终执行网络请求的地方, 作的事情最多, 不过流程我都写了注释, 阅读起来应该没什么问题, 这里我只列举几个须要注意的细节:
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个接口都要作这样的缓存处理, 内心便如阵阵暖流拂过般温暖.
最后须要说明的是, 这套缓存策略并非万能的, 有两种状况并不适用:
"你的意思是, 即便当时工期很紧, APP用户也很少的状况下, 大家依然不得不作个缓存逗老板开心?" "嗯呐!" "奥. 那东西作出来了, 而后呢?" "而后啊..."
D: "A总, APP优化完成了, 您过目一下."
A: "嗯, 不错. 如今进过一次的页面都是秒开, 没网的状况也能有东西展现了, 挺好!"
D: "您开心就好...有什么要求您尽管..."
A: "等等! 为何这个页面第一次进的时候仍是一直在转加载圈? 还有这个, 这个, 这个也是..."
D: "额...你知道的, 公司网很差..."
A: "哼, 又是网很差! 你看看人家QQ/微信/微博..."
"呵呵, 却是两个妙人. 行了, 该问的也问得差很少了, 最后问个问题就结束吧. 已知你的月薪为X元, 深圳个税起征点是Y元, 个税税率为%Z, 公司每个月只给你交最低档的社保和公积金. 问: 在作缓存策略这个月你天天朝九晚九而且周末无双休, 那么, 你本月的加班费应当为多少?"
"很简单, 0! 由于咱们没有加班费..."
"嗯, 很好. 在以前的谈话中, 你的记忆力, 逻辑思惟能力和反应力都表现为正常人的水准, 只是可能加班过分, 有点儿焦虑情绪, 别的没什么大问题. 行了, 也别住院了, 我给开点儿药, 回去呢你按时吃, 平时多注意休息, 没事儿多看看<小时代>或者<白衣校花与大长腿>之类的片子, 有助于睡眠..."
...
...
...
"我能够出院了? 我能够出院了! 我能够出...院...了!!!"
"诶, 你...你别喊啊! 别...别喊了! 我...般若掌! 你说你喊什么喊, 要是让那帮家伙听见了, 又得给你来一针! 咱可说好了, 你不喊了, 我就撒手, 听懂了就眨眨眼!
诶...这就对了, Easy, Easy!
你看, 这还有一下子才到吃药时间. 我们再玩一次, 这回换我当程序员, 你演那个穿白大褂的, 来!来!来! 嘿嘿嘿嘿..."