一種頭像緩存策略
在具體場景下設計緩存的邏輯。
作者:@nixzhu
許多 App 都有用戶系統,不論是自己實現還是使用第三方,大概都需要顯示用戶的頭像。比較常見的情景下,頭像會在某些列表里出現,例如聯系人列表、消息列表等。
雖然頭像也是圖像,但相比于普通圖片,我們對頭像有更高的要求。
頭像的原始圖片可能有各種尺寸,但在 App 里,我們很可能需要某種固定樣式的頭像,例如正方形或者圓形。如果我們使用通用的圖片緩存工具如 SDWebImage、Kingfisher 等,那么還需要自己做圖片的裁剪和加工。如果直接用 UIImageView 來縮小,圖片細節就會變得過于“銳利”,影響觀看。
進一步,一個 App 里可能不只有一種頭像樣式。比如某些場景里要有大頭像,某些要用小頭像,某些要用原始尺寸的頭像;或者某些場景里要用正方形頭像,某些場景里要用圓角矩形 頭像,不一而足。注意:單純用 layer.cornerRadius 或 CALayer 來 mask 都會導致列表的滑動性能問題,因此不考慮。
如果要做優化,我們當然希望這些不同樣式的小頭像能夠存儲在本地,不用再從網絡獲取再裁減或處理樣式。一來減少不需要的流量消耗,二來提高頭像載入速度,用戶體驗自然會更好。
基于上述分析,我們來設計一個頭像緩存系統。它的目標是快速地獲取并緩存有樣式的頭像圖片,并能比較容易地集成到已有項目中。
一些前提:
- 頭像的圖片 URL 唯一,即不同的頭像有不同的 URL。如果用戶換了頭像,那么新頭像 URL 和舊的不一樣;
- 頭像是公開資源,不需要做驗證即可下載。
好了,我們來做一做思維游戲。
首先,已有的用戶模型可能為:
struct User { let userID: String let username: String let avatarURLString: String //... }
其中avatarURLString表示遠端的頭像鏈接。如果我們要下載它,最簡單的話,直接用 NSData 的一個構造方法即可:
if let URL = NSURL(string: avatarURLString), data = NSData(contentsOfURL: URL), image = UIImage(data: data) { // TODO }
假如某個列表里有 5 個條目都是同一個用戶產生的,那這個用戶的頭像要同時顯示 5 次,我們難道要下載 5 次嗎?
為了解決這個問題,我們可以將獲取頭像這一行為當作一個“請求”:
typealias Completion = UIImage -> Void struct Request: Equatable { let avatarURLString: String let completion: Completion }
我們可將請求用一個數組(即請求池)紀錄下來:
var requests: [Request]
這樣,每次要下載某個頭像時,構造一個請求。先將其放入請求池,然后檢查請求池中包含此 avatarURLString 的請求的個數,如果數量大于1,那說明之前已經有過同樣的請求,我們就不執行下載的操作,靜靜等待第一個請求的幫助。
過一會兒,之前的請求下載結束,那這時只需要執行全部有同樣 avatarURLString 的請求的 completion,后一個(或幾個)就“免費”得到了服務。
以上是初次需要下載的情況。若我們已經下載了頭像圖片,我們依然可以構造請求,只不過之后就不執行下載操作,而是去文件系統里尋找而已。同理,我們 的請求里可以包含樣式,假如同一個用戶的五個頭像有不同的樣式,我們只需要在最后執行每個請求的 completion 時再分別處理樣式。
既然下載后要保存,那怎么保存比較好呢?
有人傾向于直接用文件系統的 API 將數據存在文件系統里;有人已經使用的 Core Data,那他會傾向于放在 Core Data 里,況且 Core Data 實體的某些屬性類型有 External 特性,勾選后,既不占用內存空間,也免去了直接操作文件系統的繁瑣;還有人使用 Realm 或直接用 SQLite 等,反正都有類似的存儲過程,只不過細節不同。
我的建議是將原始圖片保存在文件系統中(或 Core Data 勾選 External),小的有樣式頭像可以直接以屬性保存在數據庫中(通常例如 Core Data 或 Realm 都支持 NSData 的屬性,但不能過大,因為它們會待在內存里)。
可惜,如果現在我們要制作一個框架來緩存頭像,那我們就不可能非常具體地去幫用戶存儲文件。因為細節千差萬別,實在顧不過來。而且就算有了,用戶也 很難集成這一步到已有代碼。若不考慮用戶的喜好,那我們實際上做出的是一個“通用”的圖片緩存系統。這里的通用以“不夠優化”來理解。
好在,我們可以定義協議,在協議中聲明存儲 API,用戶自行實現存儲過程。我們的緩存系統只需要調用 API 即可,不用關心存儲過程的細節。
再把頭像樣式考慮進去,于是有:
protocol Avatar { var URL: NSURL? { get } var style: AvatarStyle { get } var placeholderImage: UIImage? { get } var localOriginalImage: UIImage? { get } var localStyledImage: UIImage? { get } func saveOriginalImage(originalImage: UIImage, styledImage: UIImage) }
稍微說明一下:URL自然表示頭像的原始鏈接(可選值表示其可能不存在);style表示本次要顯示的頭像的樣式,稍微會進一步討論;placeholderImage很好理解,在顯示真正的頭像之前,需要一個占位符,不同的 App 有不同的設計,所以也交給用戶;localOriginalImage表示本地存儲的原始圖片,既然存儲已由用戶控制,那讀取自然由用戶控制;localStyledImage表示本地有樣式的頭像,用戶可以根據樣式的不同來提供;最后是saveOriginalImage(originalImage:styledImage:),由用戶控制存儲過程,包括原始圖片以及本次的樣式圖片。
其中,頭像樣式大概可以做成下面這樣:
enum AvatarStyle: Equatable { case Original case Rectangle(size: CGSize) case RoundedRectangle(size: CGSize, cornerRadius: CGFloat, borderWidth: CGFloat) typealias Transform= UIImage -> UIImage? case Free(name: String, transform: Transform) }
有原始樣式(不處理)、固定尺寸的矩形(可生成正方形等)、可帶透明邊的圓角矩形(可生產圓形等),以及一種自由樣式,由用戶自行提供圖片變換函數。看起來比較齊備,也可再增加。
然后我們再給UIImageView擴展一個方法:
extension UIImageView { func navi_setAvatar(avatar: Avatar) { // TODO } }
以方便用戶使用。最后的圖解如下:
用戶有列表要顯示,列表條目包含頭像,于是構造一個 Avatar 的描述去 AvatarPod 中喚醒 Avatar。
AvatarPod 避免重復下載,并實現整個邏輯。比如先去內存緩存中查詢,沒有就看看是否用戶提供了本地圖片,再沒有就去下載。下載好了就處理樣式,并將原圖和樣式圖交給用戶存儲。
那么在具體的集成上,用戶只需要讓其 User 實現 Avatar 協議或者構造一個新的“中間對象”,其包含 User 和 AvatarStyle,并讓此對象實現 Avatar 協議。我推薦用中間對象的方式,這樣對已有代碼的修改會很少,而且更好支持多種樣式。
具體請看 Navi 的代碼以及 Demo。文件并不多,應該能比較容易地看明白。不過運行 Demo 需要 iOS 設備已在“設置”里登錄了 推ter 帳戶,沒有 推ter 帳號的同學可先去 推ter.com 注冊一個。
最后,Navi 的名字來源于電影《阿凡達》里的納美人(Na'vi),為潘多拉星球上的智慧類人生物。人類到達后潘多拉后,利用基因改造合成了一種可以由人控制的類似那美人的生物,也就是 Avatar(意為化身或替身),以便更好地和原住的納美人交流。
歡迎轉載,但請一定注明出處! https://github.com/nixzhu/dev-blog
歡迎轉發此條 Tweet https://推ter.com/nixzhu/status/651958867203526656 或微博 http://weibo.com/2076580237/CE7Msz61V 以分享此文!