從簡書遷移到掘金程序員
"時間?"數據庫
"去年夏天, 六月, 具體哪天記不得了. 我只記得那天很是的熱, 喝了好多水仍是很渴." "我沒問你熱不熱渴不渴, 問什麼答什麼, 不要作多餘的事情, 明白嗎?"數組
"奧...明白了."瀏覽器
"嗯. 事情決定的時候你在哪? 在幹嗎?"緩存
"當時我正在個人工位上看小說. 啊...不是, 看博客! 啊...不是, 寫代碼! 嗯, 對的, 我當時正在專心寫代碼!"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!
你看, 這還有一下子纔到吃藥時間. 我們再玩一次, 這回換我當程序員, 你演那個穿白大褂的, 來!來!來! 嘿嘿嘿嘿..."