吐槽: 移动端缓存策略
從簡書遷移到掘金
"時間?"
"去年夏天, 六月, 具體哪天記不得了. 我只記得那天非常的熱, 喝了好多水還是很渴." "我沒問你熱不熱渴不渴, 問什么答什么, 不要做多余的事情, 明白嗎?"
"奧...明白了."
"嗯. 事情決定的時候你在哪? 在干嘛?"
"當時我正在我的工位上看小說. 啊...不是, 看博客! 啊...不是, 寫代碼! 嗯, 對的, 我當時正在專心寫代碼!"
"嗯? 算了. 事情是誰決定的? 具體細節是怎樣的?"
"這個...我...我記不清楚了, 能不說嗎?"
"記不清楚了? 你要明白現在你找我幫忙, 不是我找你幫忙! 你最好像小女生聊八卦一樣把東西都仔仔細細說清楚嘍, 不然, 誰都幫不了你!"
"這...哎...好吧, 我說就是了..."
"當時, 我正在看...寫代碼, 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也存在一些不足:
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了).
- 通過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, 所以我們沒必要針對每個標簽都分別發起一次請求, 一次性拉取幾十上百個數據源完全沒有問題.
那么按照這個思路, 針對初始化的處理會分成兩步:
仍然以上面的情況舉例, 我們看看這種思路能不能解決上面的問題:
初始化一個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數組, 代碼中這個接口會一次性返回所有需要初始化數據的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;}]; } 復制代碼東西有點多, 我們一個方法一個方法來看:
- fetchFriendsWithPage:pageSize:completionHandler:
這個方法是VC獲取好友列表數據的接口, 做的事情很簡單, 判斷一下本地是否有緩存數據, 有就展示, 沒有就進入緩存初始化流程或者緩存更新流程. 需要注意的是, 因為我們不能保證所有的初始化數據都已經拉取完成了(比如請求失敗, 只拉取了一部分數據APP就被用戶殺死了等等), 所以初始化流程每次都會進行. 另外, 只有拉取第一頁初始化數據的情況下本地是沒有任何數據的, 所以第一頁初始化數據拉取完成后需要執行頁面刷新回調, 而其他情況中本地緩存都至少有一頁數據, 所以就直接讀取緩存進行展示而不需要等到網絡請求執行完成后才展示.
- initializeFriendsWithCompletionHandler:
這個方法只是一些簡單的邏輯判斷, 防止已初始化/正在初始化的數據多次拉取等等(即處理反復多次進出頁面, 反復刷新之類的情況), 看注釋即可.
- fetchAllFriendsWithCompletionHandler:
這個方法是最終執行網絡請求的地方, 做的事情最多, 不過流程我都寫了注釋, 閱讀起來應該沒什么問題, 這里我只列舉幾個需要注意的細節:
1.把之前獲取的Id數組進行分頁, 留待下方使用. 這里細節在于:分頁的順序是從后向前截取而不是直接順序截取的. 這是因為服務器返回的Id數組默認是升序排列的, 最新的數據對應的Id其實處在最后, 本著最新的數據最先展示的邏輯, 所以我們需要倒著拉取.
3.獲取完本頁數據后,將獲取過的Id數組移除. 這個很基礎, 但是很重要, 專門提一下.
4.更新緩存信息. 在瀏覽器緩存策略部分提過: Last-Modified指示的是緩存最近一次的更新時間. 在我們的初始化數據中, 最近一次的更新時間顯然就是第一頁數據中最后的那一條的更新時間了. 只有在這個時間之后的數據才會比當前初始化數據還要新, 需要進入緩存更新流程. 而在這個時間之前的數據, 顯然都已經在我們的初始化Id數組中了, 直接拉取即可. 所以, 只有在第一頁數據拉取完成后我們才需要保存CacheInfo.lastModifiedDate.
8.拉取完成后的標識位設置(正在初始化和所有初始化數據都拉取完成的標識), 很基礎, 但是很重要.
緩存更新
初始化成功后, 在緩存過期之前都可以直接讀取本地緩存進行展示, 這能顯著提升頁面加載速度, 同時一定程度上減輕服務器的壓力. 然而, 緩存總會過期, 這時候就需要進入緩存更新的流程了. 這里我們將緩存更新拆成兩部分: 添加更新緩存和刪除無用緩存.
- 添加更新緩存
添加更新緩存的邏輯跟瀏覽器緩存更新的策略差不多: 在緩存過期以后, 將上次請求返回的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!
你看, 這還有一會兒才到吃藥時間. 咱們再玩一次, 這回換我當程序員, 你演那個穿白大褂的, 來!來!來! 嘿嘿嘿嘿..."
總結
以上是生活随笔為你收集整理的吐槽: 移动端缓存策略的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于rman 全备+归档在线搭建DG
- 下一篇: 4.3. postForObject