iOS 當中的 Cache 設計

MrPeak發表於2017-01-05

Cache的設計是個基礎計算機理論,也是程式設計師的重要基本功之一。Cache幾乎無處不在,CPU的L1 L2 Cache,iOS系統的clean page和dirty page機制,HTTP的tag機制等,這些背後都是Cache設計思想的應用。

為什麼需要Cache

Cache的目的是為了追求更高的速度體驗,Cache的源頭是兩種資料讀取方式在成本和效能上的差異。

在開始著手設計Cache之前,需要先理清資料儲存的媒介。作為客戶端開發人員來說,我們所關注的資料儲存方式也有不少種:

  • 資料最開始是儲存在Server上,這些資料需要通過網路請求獲取。
  • 從Server獲取資料時,會經過各種中間網路節點(比如代理),這些節點有時會快取我們的資料。
  • 把資料下載到本地之後,我們會在本地disk快取一份,這樣或許不用每次都重新去伺服器請求。
  • 存到disk之後,資料的儲存方式會影響到讀取的速度,以B+ Tree儲存的sqlite就比直接序列化NSArray到檔案之中要快不少。
  • App啟動時,系統會將從Server下載到的資料,從disk載入到memory,memory的讀寫效能比disk要快很多。
  • 到了Memory中,不同的資料結構儲存方式也會存在速度上的差異。用NSDictionary(hash表)形式儲存讀資料,寫效能都比Array好,但space開銷更大。雖說memory的讀寫效能比disk都高了很多,但在大集合類資料操作的時候有時也會遇到瓶頸。
  • 比Memory更快的還有Register,L1,L2,只不過對於iOS App開發來說,很少深入到這一層面的優化。

上面所說的每一個環節,都存在效能和成本上的差別,Server的資料自然是最及時最準確的,但一個App要以NSArray的形式獲取到Server的資料,中間要經過「漫長」的過程,可以說每一步中都存在cache的設計思想。

對於Cache的理解和實踐,前提是我們對於儲存媒介,和不同資料結構差異,有比較深入的掌握。

我們大部分App的效能優化,如果涉及到Cache,一般都是在Memory這一媒介上做處理。將需要從Disk中,或者通過CPU複雜計算才能獲取的資料,通過合理的資料結構儲存在Memory中,就能解決我們App開發裡,絕大部分的Cache需求了。這一層面的Cache設計也有著不同的姿勢,先來看看簡單可用型。

簡單可用型Cache

得益於Foundation中NSDictionary的封裝,我們可以用hash表這種資料結構來實現一個簡單可用的cache機制,先來看一個例項:

這是個簡單的格式化手機號碼的函式,其中formatPhoneNumber函式是個CPU Intensive的呼叫,而且在業務場景中針對同一個手機號碼,需要經常性的獲取格式化之後的NSString,如果每次都重複計算顯然是對CPU資源的浪費,而且效能也不好。我們可以加個簡單的Cache來優化:

通過引入NSMutableDictionary,就避免了每次都需要重複呼叫formatPhoneNumber的問題,so easy就完成了一個快速的cache設計,馬上就可以提交給測試,把優化成果甩產品經理臉上,這歸功於hash表O(1)的時間複雜度。記憶體空間會多消耗一些,不過對於小量的資料影響比較小,現代的hash表不會一開始就分配大量的空間,而是隨著資料的增加而逐漸擴容。

這種簡單可用型的Cache設計,最大的問題在於,程式碼過於零散且不可控。小量且分散的cache設計幾乎等同於挖坑,在你設計cache的時候可能資料量還小,但後面維護的時候,業務改變的時候,誰也不能保證這塊記憶體的開銷依然可以忽略不計。而且這種記憶體方面的損耗很難察覺,巧妙的隱蔽在某個.m檔案中,到後期想控制整個App的記憶體開銷時,會感覺到處都有坑,無從下手。你可能也發現了,上面這段Cache程式碼沒有釋放Cache的地方。

所有對我們整個App有副作用的程式碼都需要被集中管理,要能從架構的層面去理解和定位。怎麼去定義副作用呢?可以抽象成一種「寫操作」,往Cache中新增新的記錄就是寫操作,這種寫操作的副作用是額外的記憶體開銷,Cache的本質是以空間換時間,這空間損耗就是我們的副作用,一個副作用會引發其他更多的副作用,理清這些副作用往往需要反覆查閱大量的程式碼。更好的辦法是,一開始就把有副作用的程式碼集中管理。

優雅可控型Cache

避免Cache程式碼散亂放置的做法是,設計一個優雅可控的Cache模組。一個App中,可能會有各種各樣的資料需要Cache,phoneNumberCache,avatarCache,spaceshipCache等等,我們需要有個源頭來追蹤這些cache,直觀的做法是通過工廠類來生成和持有這些各式各樣的cache:

這樣當我們需要評估各種Cache對整個App記憶體開銷的影響之時,只需要從CacheFactory程式碼著手即可,除錯起來也有跡可循,其他工程師接手你的程式碼也會感激涕零的。

通過protocol的方式,將cache的宣告和實現想分離,這也是個好習慣。cache的另一個重要知識點是cache的淘汰策略,不同的策略表現也不一樣,FIFO,LRU,2Queues等等,現在有不少成熟的第三方cache框架可以使用,系統也提供了淘汰策略不明確的NSCache,如果沒有動手寫過任何cache淘汰策略,我還是建議大家自己動手試著做一個,至少要讀一下相關的實現原始碼,瞭解這些淘汰策略很有必要,在做一些深度優化的時候需要因地制宜來做決定。

cache的使用要有收有放,不能只建立不釋放,事實上,所有涉及到data的操作都要考慮data的生命週期。我們做業務的時候,多是以Controller為基礎單位,有些場景下,一個Controller在退出之後被再次進入的可能性就非常之低了,適時的清理cache會讓我們App的整體表現更好。

Immutable Cache

Cache中存放的是啥?是Data。說到Data,就不得不提peak君最愛囉嗦的”Immutability(不可變性)”了,Immutability和我們程式碼的穩定性有著極大的關係,大到就像「房間裡的大象」,很重要也容易被忽視。

在實踐Immutability的時候,需要先將Data做分類,再去區分每一種型別Data如何去實施不可變性。做Data分類最重要的是分清楚值型別和引用型別的差別。傳值的時候傳遞的是新的記憶體拷貝,所以值型別大多是安全的,傳指標的時候傳遞的是同一塊共享記憶體空間,這也是指標之所以危險的一大原因。bool,Int,long等等這些primitive type都是值型別,可以放心的傳遞,而物件型別往往是以指標的形式在傳遞,需要特別的注意,我們一般通過copy的方式(生成新的記憶體拷貝)來傳遞。這也是為什麼Swift中將很多原先在Objective C中基礎類變為值型別的原因,強化Immutability,讓我們的程式碼更加安全。

我們看下不同型別的資料在Cache中的讀寫操作。

值型別-讀

值型別可以安心返回:

值型別-寫

值型別也可以安全的寫:

物件型別-讀

指標型別需要生成新拷貝:

物件類的copy方法需要我們手動實現NSCopying protocol,開發的初期雖然顯得繁瑣了些,但後期的回報很大。而且這裡的copy必須是deep copy,User中的每一個被持有的property都需要遞迴copy。

物件型別-寫

物件型別寫操作的危險之處在於函式的入參,入參也是物件型別的話,傳入的是一個共享的引用:

集合型別-讀

集合類也需要copy,是bug和crash的重災區:

集合型別-寫

看到這裡,大家可能也發現了,其實原則也比較簡單,只要保證業務模組從Cache中獲取的資料都是獨立的copy,就能避免資料共享帶來的各種隱患。Cache模組有點類似函數語言程式設計中的純函式,既不依賴於外部的狀態,也不會修改外部的狀態,重點處理每一個函式呼叫的input(入參)和output(返回值)即可。

多執行緒安全

只要談到資料的處理,就避免不了多執行緒安全的話題,可以看下我之前寫的幾篇關於多執行緒安全的文章:

iOS多執行緒到底不安全在哪裡?

正確使用多執行緒同步鎖@synchronized()

如何用Xcode8解決多執行緒問題

Cache多執行緒安全的重點在於對集合類的處理,Cache本身多數時候都是在管理資料的集合。需要特別注意的是NSString其實也應該歸到集合類,從資料讀寫和多執行緒安全方面看,NSString和NSArray在很多方面表現都是一致的。一些成熟的第三方Cache庫已經替我們處理好了多執行緒安全的問題,如果是自己造的輪子,尤其要注意保證讀寫都是原子操作,至於如何使用鎖,相關的文章分享已經很多了,此處不做贅述了。

總結

瞭解Cache關鍵在於明白其背後的設計思想,進而能對我們App的行為有更全面的掌握,能明白每一個業務流程背後對資料處理的瓶頸在哪。隨著程式碼越寫越多,業務越來越複雜,今天或明天,我們總要遇到需要應用Cache設計的時候。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

iOS 當中的 Cache 設計

相關文章