Chào mọi người, mình là Lâm.
Trước đây mình có đọc 1 bài viết hay về việc tự custom 1 class để phục vụ cho việc cache dữ liệu (nguồn bài viết: https://www.swiftbysundell.com/articles/caching-in-swift/ )
Mình đã áp dụng nó vào 1 số project mini của mình, thấy hữu ích nên ở bài viết này mình xin được trình bày lại, cũng như có 1 số phân tích của bản thân để đâu đấy có thể giúp clear hơn, giúp mn tiết kiệm thời gian đọc hơn.
Note: source code mình có để ở link git bên dưới bài viết.
Tóm tắt:
- Cache là 1 chủ đề quen thuộc của mobile app, cache dữ liệu để ko phải load lại nhiều lần, tối ưu cho app, or khi không có mạng thì mình vẫn xem được những nội dung đã cache.
- Ở bài viết này mình sẽ trình bày cách viết 1 class Cache based trên 1 class build-in của iOS là NSCache (https://developer.apple.com/documentation/foundation/nscache).
NSCache nó có 1 cái rất hay là nó sẽ tự động remove record khỏi cache khi bộ nhớ của app bị warning, hay nói đơn giản app đang ăn quá nhiều RAM, vì nó là build-in class nên việc này chắc chắn là đã được optimize rồi, nó còn 1 số optimize khác nữa mn đọc trong document refer ở bên trên. - Câu hỏi là sao ko dùng luôn NSCache mà phải viết 1 class trên nó làm gì? Lý do là bởi vì NSCache nó là class thuộc về Objective-C, khi cache nó yêu cầu key phải là 1 NSObject, và value phải là 1 class instance (ko cần phải là NSObject nhưng phải là instance của 1 class).
- ( NSString là 1 NSObject, và MyClass là 1 custom class)
- Swift thì lại hay dùng struct, bây giờ muốn cache được cả class, struct… thì phải đi đường vòng 1 chút. Ý tưởng là mình sẽ wrap key/value thực sự của mình vào trong NSObject/Class để có thể adapt được với NSCache.
1. Tạo Custom class:
Tạo 1 class tên Cache, với 2 generics là Key và Value. Key phải là Hashable thì nó với làm key được, vì nó phải là unique.
Tiếp theo tạo 2 inner class để wrap key/value:
Class WrappedKey là 1 subclass của NSObject để nó có thể trở thành key của NSCache, có property key bên trong là key thực sự mà mình sẽ dùng, type của key là Key được resolve từ outer class là Cache.
Đến đây mn cũng có thể hình dung vì sao WrappedKey phải là inner class của Cache, vì nó phải resolved cái generic Key của Cache, nếu nó là 1 class nằm bên ngoài Cache thì nó sẽ ko nhìn thấy generic của Cache. Tiếp theo, mình sẽ override lại property hash:Int và func isEqual(_ object: Any) của NSObject và implement lại dưới cái key của mình (vì đây với là cái key thực sự).
Tương tự với class Entry (hay có thể gọi là WrappedValue, gọi Entry nghe ngắn hơn). Entry thì ko cần phải unique nên ko cần override lại hash, và cũng ko cần là subclass của NSObject, cứ wrap value của nó vào là được.
Note:
- nếu dài quá có thể tách class WrappedKey/Entry ra thành 2 file riêng dưới dạng extension của Cache.
- nếu mình ko subclass lại 1 class thì nên mark nó là final, như vậy sẽ tối ưu hơn, vì sao lại tối ưu thì có thể tìm hiểu về dynamic/static dispatch ở đây: https://medium.com/flawless-app-stories/static-vs-dynamic-dispatch-in-swift-a-decisive-choice-cece1e872d
2. Cache’s methods:
OK, với 3 class được dựng ở trên thì mình có thể viết 1 số API được rồi:
nên viết 1 subscript để lúc sử dụng code nó ngắn gọn, đẹp hơn.
3. Thêm constraint cho Cache
Bây giờ mình muốn có thêm nhiều control hơn nữa với cache của mình:
- Cache 1 số lượng record nhất định
- Cache trong 1 khoảng thời gian nhất định
NSCache có 1 property gọi là countLimit: Int, muốn cache bao nhiêu record thì mình gán giá trị cho nó, khi cache vượt quá số record này thì nó sẽ tự động remove record cũ đi. NSCache còn có 1 property gọi là totalCostLimit để đặt ra threshold về số byte sẽ được cache, tuy nhiên vì nó đã auto handle memory rồi nên cái này chắc cũng hiếm dùng.
Về thời gian cache, mình sẽ tạo biến entryLifeTime để giữ lượng thời gian, tính bằng giây (TimeInterval).
cần phải update lại Entry, lúc này Entry của ngoài giữ value còn giữ cả expirationDate nữa, mỗi entry sẽ có expirationDate của nó:
Sau đấy update lại method của Cache:
- Với method insert, trước khi insert mình sẽ tính thêm expirationDate cho entry
- Với method value(forKey: ), mình sẽ check xem entry đấy đã bị expired hay chưa, nếu rồi thì remove nó.
4. Persistent Cache
OK, khá ngon rồi, nhưng mà cache hiện vẫn đang chỉ là in-memory, tức là trên RAM thôi. Bây giờ mong muốn là phải save được nó xuống disk, để sau này lấy ra.
Vấn đề của NSCache là nó ko public ra cho mình thông tin keys –> dẫn đến việc giả sử mình có save được cache rồi, lúc lấy lại ra cũng ko thể lấy được value, vì mình ko có keys –> mình phải tự control list key của mình bằng cách tạo thêm 1 inner class KeyTracker.
class này sẽ giữ 1 set keys, và nó sẽ là delegate cho NSCache. Sau này khi NSCache tự động remove record, nó sẽ bắn về delegate method là cache(_ cache: , willEvictObject: Any), response của mình với method này là sẽ remove key tương ứng với record đấy ra khỏi set keys.
Tuy nhiên delegate này trả về cho mình object sẽ bị remove (ở đây là Entry), làm sao mình biết được là key nào đang associated với object đấy? Mình đang bị lack data, ở Entry mình cần phải giữ thêm key associated với nó.
và update method khi insert data (add thêm key vào khi init entry):
OK, vậy là đã xử lý xong vấn đề về keys, bước cuối để có thể save được cache là cache của mình phải serialize được thành data để lưu, và lúc mình lấy cục data đấy ra thì mình phải deserialize ngược lại được thành object, hay nói đơn giản là encode/decode. Vì mình làm việc với json từ API, nên dùng Codable ở đây là hợp lý nhất.
Đầu tiên phải serialize được Entry, vì vậy nên sẽ Entry sẽ phải là Codable. Tuy nhiên mình nếu fix như vậy thì generic của mình sẽ bị cứng, khi tạo cache sẽ require Entry bắt buộc phải là Codable, ko hay. Thế nên ở đây sẽ dùng kỹ thuật gọi là protocol conditional conform, hay nói đơn giản là 1 type sẽ chỉ conform vào protocol dưới 1 số điều kiện nhất định.
Ở đây mình chỉ muốn Entry conform vào Codable dưới điều kiện là Key và Value của nó Codable:
Trong quá trình encode/decode, sẽ cần cả insert và retrieve những entries hiện tại, nên sẽ tạo 2 methods để handle việc này, nên đặt tên khác 1 chút để phân biệt tránh bị nhầm lẫn với API của Cache, và nên mark nó là private luôn, vì nó ko phải API của cache.
Với 2 methods này bây giờ mình có thể encode cục Cache rồi:
- Khi encode thì sẽ sử dụng list keys ở keyTracker + map với method entry(forKey key: Key) để ra 1 list Entry, và encode list này bằng singleValueContainer
- Khi decode thì lấy ngược lại list Entry đã encode và add lại vào cache bằng method insert(_ entry: Entry).
Có thể đọc thêm về encode/decode bằng singleValueContainer ở đây: https://medium.com/swiftly-swift/swift-4-decodable-beyond-the-basics-990cc48b7375
Công việc còn lại chỉ là write cái cục vừa encode đấy xuống file, để ý là write vào folder cache (.cachesDirectory) luôn cho nó chuẩn:
5. Demo with App
Để demo, hãy viết 1 app đơn giản lấy dữ liệu từ 1 open API: https://developers.themoviedb.org/3
đây là API về phim, mình mê phim cho nên là quyết định dùng API này:D
mỗi phim thì sẽ chưa thông tin gồm: Id/Name/Overview/posterImage
Như vậy mình sẽ cần 2 cache: 1 cho thông tin về phim, 2 cho posterImage load được
- movieCache sẽ có key là Int(id của phim) và value là Movie (là 1 struct đối ứng với json từ API)
- imageCache sẽ có key là String(imageUrl) và value là 1 UIImage.
- tạo thêm 1 queue cho việc ghi cache và 1 queue cho việc save cache xuống disk, queue saveCache mình để QoS = .userInitiated để nó có độ ưu tiên cao khi được dùng.
Có thể đọc thêm về qos ở đây: https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/PrioritizeWorkWithQoS.html
Khi lấy được dữ liệu từ API thì mình sẽ ghi nó vào cache:
Và khi app xuống background thì mình sẽ save nó xuống disk, tuỳ vào bài toán, mình có thể đặt 1 cái timer cho nó 5 phút save 1 lần cũng được, ở đây mình làm thế này cho dễ test.
M.n có để ý là với movieCache mình có thể gọi method saveToDisk được, nhưng với imageCache thì lại ko có method đấy? Lý do là vì protocol conditional conformance, nếu mn nhìn lại sẽ thấy method saveToDisk() chỉ được conform với điều kiện Key và Value đều Codable. imageCache value của nó là UIImage, UIImage ko Codable –> imageCache sẽ ko conform vào method này, và sẽ ko visible luôn, có thể đổi UIImage thành Data thì sẽ lại conform.
OK, sau khi mà save cache xuống disk rồi thì nó sẽ nằm ở folder cache:
bây giờ sẽ lấy ngược lại cache ra khi mở app, ở đây mình lấy ở application didFinishLaunching và ở main thread luôn:
và mình viết thêm 1 computed property nữa để lấy ra list Movie đã cache, 1 cái conditional conformances nữa, property listCacheMovie này sẽ chỉ visible với movieCache, hợp lý đúng ko?
bây giờ giả sử trong case API fail thì mình sẽ dùng cache, ví dụ vậy đi:
Lúc đầu mình có nói là nếu mình đặt countLimit cho cache thì vượt quá số đấy nó sẽ tự delete record đi, để test mn có thể pull source về rồi đặt print ra console để xem new recrod được insert vào và old record bị remove đi liên tục nhé.
Link source: https://github.com/nguyenlam96/CacheIt
Bài viết cũng đã khá dài rồi, nhưng mà mình tin nó cũng gói gọn được 1 vấn đề, hy vọng mn cảm thấy hữu ích.
Thanks for reading.