問題
一般一個 iOS APP 做的事就是:請求數據->保存數據->展示數據,一般用 Sqlite 作為持久存儲層,保存從網絡拉取的數據,下次讀取可以直接從 Sqlite DB 讀取。我們先忽略從網絡請求數據這一環節,假設數據已經保存在 DB 裡,那我們要做的事就是,ViewController 從 DB 取數據,再傳給 view 渲染:
這是最簡單的情況,隨著程序變復雜,多個 ViewController 都要向 DB 取數據,ViewController本身也會因為數據變化重新去 DB 取數據,會有兩個問題:
數據每次有變動,ViewController 都要重新去DB讀取,做 IO 操作。
多個 ViewController 之間可能會共用數據,例如同一份數據,本來在 Controller1 已經從 DB 取出來了,在 Controller2 要使用得重新去 DB 讀取,浪費 IO。
對這裡做優化,自然會想到在 DB 和 VC 層之間再加一層 cache,把從 DB 讀取出來的數據 cache 在內存裡,下次來取同樣的數據就不需要再去磁盤讀取 DB 了。
幾乎所有的數據庫框架都做了這個事情,包括微信讀書開源的 GYDataCenter,CoreData,Realm 等。但這樣做會導致一個問題,就是數據的線程安全問題。
按上面的設計,Cache層會有一個集合,持有從DB讀取的數據。
除了 VC 層,其他層也會從cache取數據,例如網絡層。上層拿到的數據都是對 cache 層這裡數據的引用:
可能還會在網絡層子線程,或其他一些用於預加載的子線程使用到,如果某個時候一條子線程對這個 Book1 對象的屬性進行修改,同時主線程在讀這個對象的屬性,就會 crash,因為一般我們為了性能會把對象屬性設為nonatomic,是非線程安全的,多線程讀寫時會有問題:
//Network WRBook *book = [WRCache bookWithId:@“10000”]; book.fav = YES; //子線程在寫 [book save]; //VC1 WRBook *book = [WRCache bookWithId:@“10000”]; self.view.title = book.title; //主線程在讀
可以通過這個測試看到 crash 場景:
@interface TestMultiThread : NSObject @property (nonatomic) NSArray *arr; @end @implementation TestMultiThread @end TestMultiThread *obj = [[TestMultiThread alloc] init]; for (int i = 0; i < 100000; i ++) { dispatch_async(dispatch_get_global_queue(0, 0), ^{ id a = obj.arr; }); dispatch_async(dispatch_get_global_queue(0, 0), ^{ obj.arr = [NSArray arrayWithObject:@"b"]; }); }
解決方案
對這種情況,一般有三種解決方案:
1. 加鎖
既然這個對象的屬性是非線程安全的,那加鎖讓它變成線程安全就行了。可以給每個對象自定義一個鎖,也可以直接用 OC 裡支持的屬性指示符 atomic:
@property (atomic) NSArray *arr;
這樣就不用擔心多線程同時讀寫的問題了。但在APP裡大規模使用鎖很可能會導致出現各種不可預測的問題,鎖競爭,優先級反轉,死鎖等,會讓整個APP復雜性增大,問題難以排查,並不是一個好的解決方案。
2. 分線程cache
另一種方案是一條線程創建一個 cache,每條線程只對這條線程對應的 cache 進行讀寫,這樣就沒有線程安全問題了。CoreData 和 Realm 都是這種做法,但這個方案有兩個缺點:
a.使用者需要知道當前代碼在哪條線程執行。
b.多條線程裡的 cache 數據需要同步。
CoreData 在不同線程要創建自己的 NSManagedObjectContext,這個 context 裡維護了自己的 cache,如果某條子線程沒有創建 NSManagedObjectContext,要讀取數據就需要通過 performBlockAndWait: 等接口跑到其他線程去讀取。如果多個 context 需要同步 cache 數據,就要調用它的 merge 方法,或者通過 parent-children context 層級結構去做。這導致它多線程使用起來很麻煩,API 友好度極低。
Realm 做得好一點,會在線程 runloop 開始執行時自動去同步數據,但如果線程沒有 runloop 就需要手動去調Realm.refresh() 同步。使用者還是需要明確知道代碼在哪條線程執行,避免在多線程之間傳遞對象。
3.數據不可變
我們的問題是多線程同時讀寫導致,那如果只讀不寫,是不是就沒有問題了?數據不可變指的就是一個數據對象生成後,對象裡的屬性值不會再發生改變,不允許像上述例子那樣 book.fav = YES 直接設置,若一個對象屬性值變了,那就新建一個對象,直接整個替換掉這個舊的對象:
//WRCache @implementation WRCache +(void) updateBookWithId:(NSString *)bookId params:(NSDictionary *)params { [WRDBCenter updateBookWithId:@“10000” params:{@“fav”: @(YES)}]; //更新DB數據 WRBook *book = [WRDBCenter readBookWithId:bookId]; //重新從DB讀取,新對象 [self.cache setObject:book forKey:bookId]; //整個替換cache裡的對象 } @end
self.book = [WRCache bookWithId:@“10000”]; // book.fav = YES; //不這樣寫 [WRCache updateBookWithId:@“10000” params:{@“fav”: @(YES)}]; //在cache裡整個更新 self.book = [WRCache bookWithId:@“10000”]; //重新讀取對象
這樣就不會再有線程安全問題,一旦屬性有修改,就整個數據重新從DB讀取,這些對象的屬性都不會再有寫操作,而多線程同時讀是沒問題的。
但這種方案有個缺陷,就是數據修改後,會在 cache 層整個替換掉這個對象,但這時上層扔持有著舊的對象,並不會自動把對象更新過來:
所以怎樣讓上層更新數據呢?有兩種方式,push 和 pull。
a. push
push 的方式就是 cache 層把更新 push 給上層,cache對整個對象更新替換掉時,發送廣播通知上層,這裡發通知的粒度可以按需求斟酌,上層監聽自己關心的通知,如果發現自己持有的對象更新了,就要更新自己的數據,但這裡的更新數據也是件挺麻煩的事。
舉個例子,讀書有一個想法列表WRReviewController,存著一個數組 reviews,保存著想法 review 數據對象,數組裡的每一個 review 會持有這個這個想法對應的一本書,也就是 review.book 持有一個 WRBook 數據對象。然後這時 cache 層通知這個 WRReviewController,某個 book 對象有屬性變了,這時這個 WRReviewController 要怎樣處理呢?有兩個選擇:
遍歷 reviews 數組,再遍歷每一個 review 裡的 book 對象,如果更新的是這個 book 對象,就把這個 book 對象替換更新。
什麼都不管,只要有數據更新的通知過來,所有數據都重新往 cache 層讀一遍,重新組裝數據,界面全部刷新。
第一種是精細化的做法,優點是不影響性能,缺點是蛋疼,工作量增多,還容易漏更新,需要清楚知道當前模塊持有了哪些數據,有哪些需要更新。第二種是粗犷的做法,優點是省事省心,全部大刷一遍就行了,缺點是在一些復雜頁面需要組裝數據,會對性能造成較大影響。
b. pull
另一種 pull 的方式是指上層在特定時機自己去判斷數據有沒有更新。
首先所有數據對象都會有一個屬性,暫時命名為 dirty,在 cache 層更新替換數據對象前,先把舊對象的 dirty 屬性設為YES,表示這個舊對象已經從 cache 裡被拋棄了,屬於髒數據,需要更新。然後上層在合適的時候自行去判斷自己持有的對象的 dirty 屬性是否為 YES,若是則重新在 cache 裡取最新數據。
實際上這樣做發生了多線程讀寫 dirty 屬性,是有線程安全問題的,但因為 dirty 屬性讀取不頻繁,可以直接給這個屬性的讀寫加鎖,不會像對所有屬性加鎖那樣引發各種問題,解決對這個 dirty 屬性讀寫的線程安全問題。
這裡主要的問題是上層應該在什麼時機去 pull 數據更新。可以在每次界面顯示 -viewWillAppear 或用戶操作後去檢查,例如用戶點個贊,就可以觸發一次檢查,去更新贊的數據,在這兩個地方做檢查已經可以解決90%的問題,剩下的就是同個界面聯動的問題,例如 iPad 郵件左右兩欄兩個 controller,右邊詳情點個收藏,左邊列表收藏圖標也要高亮,這種情況可以做特殊處理,也可以結合上面 push 的方式去做通知。
push 和 pull 兩種是可以結合在一起用的,pull 的方式彌補了 push 後數據全部重新讀取大刷導致的性能低下問題,push 彌補了 pull 更新時機的問題,實際使用中配合一些事先制定的規則或框架一起使用效果更佳。
總結
對於 APP 緩存數據線程安全問題,分線程 cache 和數據不可變是比較常見的解決方案,都有著不同的實現代價,分線程 cache 接口不友好,數據不可變需要配合單向數據流之類的規則或框架才會變得好用,可以按需選擇合適的方案。