從簡書遷移到掘金
"時間?"
"去年夏天, 六月, 具體哪天記不得了. 我只記得那天非常的熱, 喝了好多水還是很渴." "我沒問你熱不熱渴不渴, 問什麼答什麼, 不要做多餘的事情, 明白嗎?"
"奧...明白了."
"嗯. 事情決定的時候你在哪? 在幹嘛?"
"當時我正在我的工位上看小說. 啊...不是, 看部落格! 啊...不是, 寫程式碼! 嗯, 對的, 我當時正在專心寫程式碼!"
"嗯? 算了. 事情是誰決定的? 具體細節是怎樣的?"
"這個...我...我記不清楚了, 能不說嗎?"
"記不清楚了? 你要明白現在你找我幫忙, 不是我找你幫忙! 你最好像小女生聊八卦一樣把東西都仔仔細細說清楚嘍, 不然, 誰都幫不了你!"
"這...哎...好吧, 我說就是了..."
"當時, 我正在看...寫程式碼, 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也存在一些不足:
- Last-Modified標註的最後修改只能精確到秒級, 如果某些檔案在1秒鐘以內被修改多次的話, 它將不能準確標註檔案的修改時間(無法及時更新檔案).
- 如果某些檔案會被定期生成, 而內容其實並沒有發生任何變化, 但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做比對. 這並不是強制的, 主要看開發人員的心情.
移動端快取策略
上面簡單介紹了一下瀏覽器快取策略, 容易知道, 當瀏覽器載入網頁時, 會存在以下四種情況:
-
本地快取為空, 發起網路請求獲取後臺資料進行展示並快取, 同時記錄資料有效期(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, 所以我們沒必要針對每個標籤都分別發起一次請求, 一次性拉取幾十上百個資料來源完全沒有問題.
那麼按照這個思路, 針對初始化的處理會分成兩步:
- 拉取待初始化列表元素的的URL陣列(也就是各個Model的主鍵)
- 根據上面的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屬於快取更新的內容, 我們暫且不談.
這裡先介紹和初始化相關的部分:
-
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.拉取完成後的標識位設定(正在初始化和所有初始化資料都拉取完成的標識), 很基礎, 但是很重要.
快取更新
初始化成功後, 在快取過期之前都可以直接讀取本地快取進行展示, 這能顯著提升頁面載入速度, 同時一定程度上減輕伺服器的壓力. 然而, 快取總會過期, 這時候就需要進入快取更新的流程了. 這裡我們將快取更新拆成兩部分: 新增更新快取和刪除無用快取.
- 新增更新快取
- (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個介面都要做這樣的快取處理, 心裡便如陣陣暖流拂過般溫暖.
最後需要說明的是, 這套快取策略並不是萬能的, 有兩種情況並不適用:
- 資料更新太頻繁的情況不適用. 如首頁動態這樣一秒七十二變的介面, 有網情況的快取基本沒有任何意義, 無網快取到是可以做一做, 博老闆一笑.
- 資料量太大的情況不適用. 如粉絲這樣動輒上萬的介面, 資料量太大, 拉取耗時耗力, 而且效果不明顯, 肯定是不做的. 一般這個資料量最好不要過千, 比如QQ的好友/群組數量根據等級不同依次為500~900個. 想要超出這個限制也行, 這可是程式設計師的心血苦汗, 得加錢! 然而, 加錢也最多到2000個.
然後啊...
"你的意思是, 即使當時工期很緊, APP使用者也不多的情況下, 你們依然不得不做個快取逗老闆開心?" "嗯吶!" "奧. 那東西做出來了, 然後呢?" "然後啊..."
D: "A總, APP優化完成了, 您過目一下."
A: "嗯, 不錯. 現在進過一次的頁面都是秒開, 沒網的情況也能有東西展示了, 挺好!"
D: "您開心就好...有什麼要求您儘管..."
A: "等等! 為什麼這個頁面第一次進的時候還是一直在轉載入圈? 還有這個, 這個, 這個也是..."
D: "額...你知道的, 公司網不好..."
A: "哼, 又是網不好! 你看看人家QQ/微信/微博..."
"呵呵, 倒是兩個妙人. 行了, 該問的也問得差不多了, 最後問個問題就結束吧. 已知你的月薪為X元, 深圳個稅起徵點是Y元, 個稅稅率為%Z, 公司每月只給你交最低檔的社保和公積金. 問: 在做快取策略這個月你每天朝九晚九並且週末無雙休, 那麼, 你本月的加班費應當為多少?"
"很簡單, 0! 因為我們沒有加班費..."
"嗯, 很好. 在之前的談話中, 你的記憶力, 邏輯思維能力和反應力都表現為正常人的水準, 只是可能加班過度, 有點兒焦慮情緒, 別的沒什麼大問題. 行了, 也別住院了, 我給開點兒藥, 回去呢你按時吃, 平時多注意休息, 沒事兒多看看<小時代>或者<白衣校花與大長腿>之類的片子, 有助於睡眠..."
...
...
...
"我可以出院了? 我可以出院了! 我可以出...院...了!!!"
"誒, 你...你別喊啊! 別...別喊了! 我...般若掌! 你說你喊什麼喊, 要是讓那幫傢伙聽見了, 又得給你來一針! 我們可說好了, 你不喊了, 我就撒手, 聽懂了就眨眨眼!
誒...這就對了, Easy, Easy!
你看, 這還有一會兒才到吃藥時間. 我們們再玩一次, 這回換我當程式設計師, 你演那個穿白大褂的, 來!來!來! 嘿嘿嘿嘿..."