原文:Caching in Swift | Swift by Sundell
让应用程序感觉响应迅速不仅仅是调整 UI 的呈现方式,或提高操作和算法的纯粹执行速度 ------ 它通常与有效管理数据和避免不必要的开销同样重要。
这种不必要的工作的一个非常常见的来源是当我们最终多次重新加载完全相同的数据时。它可能是多个功能加载同一模型的重复副本,或者视图的数据每次重新出现在屏幕上时都会重新加载。
本周 ------ 让我们来看看缓存如何在这种情况下成为一个非常强大的工具,如何在 Swift 中构建一个高效而优雅的缓存 API,以及战略性地缓存各种值和对象如何对整体产生重大影响应用程序的性能。
系统的一部分
缓存是这些任务之一,起初看起来比实际要简单得多。我们不仅要有效地存储和加载值,还需要决定何时删除缓存,以保持低内存占用、使陈旧数据无效等等。
值得庆幸的是,Apple 已经通过内置的 NSCache 类为我们解决了其中的许多问题。然而,使用它确实有一些注意事项,因为它在 Apple 自己的平台上仍然是一个 Objective-C 类 ------ 这意味着它只能存储类实例,并且它只与基于 NSObject
的键兼容:
swift
// To be able to use strings as caching keys, we have to use
// NSString here, since NSCache is only compatible with keys
// that are subclasses of NSObject:
let cache = NSCache<NSString, MyClass>()
然而,通过围绕 NSCache
编写一个薄包装器,我们可以创建一个更加灵活的 Swift 缓存 API------ 它使我们能够存储结构和其他值类型,并允许我们使用任何 Hashable
键类型 ------ 而不需要我们重写所有底层为 NSCache
提供动力的逻辑。所以,让我们这样做吧。
一切始于声明
我们要做的第一件事是声明我们的新缓存类型。我们称它为 Cache
,并使它成为任何 Hashable
键类型和任何值类型的泛型。然后我们会给它一个 NSCache
属性,它将存储由 WrappedKey
类型键控的 Entry
实例:
swift
final class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
}
顾名思义,我们的 WrappedKey
类型将包装我们面向公众的 Key
值,以使它们与 NSCache
兼容。为了实现这一点,让我们子类化 NSObject
并实现 hash
和 isEqual
方法------ 因为这是 Objective-C 用来确定两个实例是否相等的东西:
swift
private extension Cache {
final class WrappedKey: NSObject {
let key: Key
init(_ key: Key) { self.key = key }
override var hash: Int { return key.hashValue }
override func isEqual(_ object: Any?) -> Bool {
guard let value = object as? WrappedKey else {
return false
}
return value.key == key
}
}
}
对于我们的 Entry
类型,唯一的要求是它必须是一个类(它不需要子类化 NSObject
),这意味着我们可以简单地让它存储一个 Value
实例:
swift
private extension Cache {
final class Entry {
let value: Value
init(value: Value) {
self.value = value
}
}
}
有了上面的内容,我们现在就可以为 Cache
提供一组初始的 API。让我们从三种方法开始 ------ 一种用于为给定键插入值,一种用于检索值,一种用于删除现有值:
swift
final class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
func insert(_ value: Value, forKey key: Key) {
let entry = Entry(value: value)
wrapped.setObject(entry, forKey: WrappedKey(key))
}
func value(forKey key: Key) -> Value? {
let entry = wrapped.object(forKey: WrappedKey(key))
return entry?.value
}
func removeValue(forKey key: Key) {
wrapped.removeObject(forKey: WrappedKey(key))
}
}
由于缓存本质上只是一个专门的 key-value 存储,它是下标的理想用例 ------ 所以我们也可以通过这种方式检索和插入值:
swift
extension Cache {
subscript(key: Key) -> Value? {
get { return value(forKey: key) }
set {
guard let value = newValue else {
// If nil was assigned using our subscript,
// then we remove any value for that key:
removeValue(forKey: key)
return
}
insert(value, forKey: key)
}
}
}
实现了最初的一组功能后 ------ 让我们试一试我们的新缓存吧!假设我们正在开发一个用于阅读文章的应用程序,并且我们正在使用 ArticleLoader
来加载文章模型。通过使用我们的新缓存来存储我们加载的文章,以及在加载新文章之前检查任何以前缓存的文章 ------ 我们可以确保我们只会加载每篇文章一次,如下所示:
swift
class ArticleLoader {
typealias Handler = (Result<Article, Error>) -> Void
private let cache = Cache<Article.ID, Article>()
func loadArticle(withID id: Article.ID,
then handler: @escaping Handler) {
if let cached = cache[id] {
return handler(.success(cached))
}
performLoading { [weak self] result in
let article = try? result.get()
article.map { self?.cache[id] = $0 }
handler(result)
}
}
}
💡 优化上述加载代码的另一种方法是,如果我们要加载的文章已经加载,则避免重复请求。要了解有关执行此操作的技术的更多信息,请查看 "在 Swift 中避免竞态条件"。
上面的内容似乎不会对我们的应用程序的性能产生很大影响,但它确实可以使我们的应用程序看起来更快,因为当用户导航回已加载的文章时 ------ 它现在会立即出现在那里。如果我们还将上述内容与用户可能打开的预取文章(例如用户最喜欢的类别中的最新文章)结合起来,那么我们真的可以让我们的应用使用起来更加愉快。
避免过期数据
与 Swift 标准库(例如 Dictionary
)中的集合相比,NSCache
更适合缓存值的原因在于它会在系统内存不足时自动驱逐对象 ------ 这反过来又使我们的应用程序本身能够保留在记忆中更久。
然而,我们可能想要添加一些我们自己的缓存失效条件,否则我们最终可能会保留过时的数据。虽然能够重用我们已经加载的数据当然是一件好事,但向我们的用户显示过时的数据绝对不是。
缓解该问题的一种方法是通过在特定时间间隔后删除它们来限制缓存条目的生命周期。为此,我们首先向 Entry
类添加一个 expirationDate
属性,以便能够跟踪每个条目的剩余生命周期:
swift
final class Entry {
let value: Value
let expirationDate: Date
init(value: Value, expirationDate: Date) {
self.value = value
self.expirationDate = expirationDate
}
}
接下来,我们需要一种方法让 Cache
获取当前日期,以确定给定条目是否仍然有效。虽然我们可以在需要时直接调用 Date()
内联,但这会使单元测试变得非常困难 ------ 所以让我们注入一个 Date-producing 函数作为我们初始化程序的一部分。我们还将添加一个 entryLifetime
属性,默认值为 12 小时:
swift
final class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
private let dateProvider: () -> Date
private let entryLifetime: TimeInterval
init(dateProvider: @escaping () -> Date = Date.init,
entryLifetime: TimeInterval = 12 * 60 * 60) {
self.dateProvider = dateProvider
self.entryLifetime = entryLifetime
}
...
}
💡 要了解有关上述依赖注入的更多信息,请查看 "使用函数的简单 Swift 依赖注入"。
有了上面的内容,现在让我们更新插入和检索值的方法,以将当前日期和指定的 entryLifetime
考虑在内:
swift
func insert(_ value: Value, forKey key: Key) {
let date = dateProvider().addingTimeInterval(entryLifetime)
let entry = Entry(value: value, expirationDate: date)
wrapped.setObject(entry, forKey: WrappedKey(key))
}
func value(forKey key: Key) -> Value? {
guard let entry = wrapped.object(forKey: WrappedKey(key)) else {
return nil
}
guard dateProvider() < entry.expirationDate else {
// Discard values that have expired
removeValue(forKey: key)
return nil
}
return entry.value
}
虽然准确地使过时条目失效可以说是实现任何类型缓存中最困难的部分 ------ 通过将上述类型的过期日期与基于特定事件(例如,如果用户删除文章)删除值的模型特定逻辑相结合,我们通常可以避免重复工作和无效数据。
持久缓存
到目前为止,我们只是在内存中缓存值,这意味着一旦我们的应用程序终止,数据就会消失。虽然这可能是我们真正想要的,但有时也使缓存值能够持久保存在磁盘上可能非常有价值,并且还可能解锁使用我们应用程序的新方式 ------ 例如在启动应用程序时仍然可以访问通过网络下载的数据离线时。
由于我们可能只想有选择地在磁盘上保留特定的缓存 ------ 让我们让它成为一个完全可选的功能。首先,我们将更新 Entry
以存储与其关联的 Key
,这样我们就可以直接保留每个条目,并能够删除未使用的键:
swift
final class Entry {
let key: Key
let value: Value
let expirationDate: Date
init(key: Key, value: Value, expirationDate: Date) {
self.key = key
self.value = value
self.expirationDate = expirationDate
}
}
接下来,我们需要一种方法来跟踪我们的缓存包含哪些 key 的条目,因为 NSCache
不公开该信息。为此,我们将添加一个专用的 KeyTracker
类型,它将成为我们底层 NSCache
的委托,以便在删除条目时得到通知:
swift
private extension Cache {
final class KeyTracker: NSObject, NSCacheDelegate {
var keys = Set<Key>()
func cache(_ cache: NSCache<AnyObject, AnyObject>,
willEvictObject object: Any) {
guard let entry = object as? Entry else {
return
}
keys.remove(entry.key)
}
}
}
我们将在初始化缓存时设置我们的 KeyTracker------ 我们还将设置最大条目数,这将帮助我们避免将太多数据写入磁盘 ------ 如下所示:
swift
final class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
private let dateProvider: () -> Date
private let entryLifetime: TimeInterval
private let keyTracker = KeyTracker()
init(dateProvider: @escaping () -> Date = Date.init,
entryLifetime: TimeInterval = 12 * 60 * 60,
maximumEntryCount: Int = 50) {
self.dateProvider = dateProvider
self.entryLifetime = entryLifetime
wrapped.countLimit = maximumEntryCount
wrapped.delegate = keyTracker
}
...
}
由于我们的 KeyTracker
已经在从缓存中删除条目时收到通知,因此我们完成其集成所需要做的就是在添加密钥时通知它,我们将作为 insert
方法的一部分执行此操作:
swift
func insert(_ value: Value, forKey key: Key) {
...
keyTracker.keys.insert(key)
}
为了能够真正持久化缓存的内容,我们首先需要对其进行序列化。就像我们如何利用 NSCache
在系统之上构建我们自己的缓存 API 一样,让我们使用 Codable
使我们的缓存能够使用任何兼容格式(例如 JSON)进行编码和解码。
我们将从让我们的 Entry
类型符合 Codable
开始 ------ 但我们不想要求所有缓存条目都是可编码的 ------ 所以让我们使用条件一致性来仅对具有可编码键和值的条目采用 Codable
,如下所示:
swift
extension Cache.Entry: Codable where Key: Codable, Value: Codable {}
在编码和解码过程中,我们将检索和插入条目,因此为了避免重复我们以前的 insert
和 value
方法的代码 ------ 让我们也将处理 Entry
实例的所有逻辑移动到两个新的私有实用程序方法中:
swift
private extension Cache {
func entry(forKey key: Key) -> Entry? {
guard let entry = wrapped.object(forKey: WrappedKey(key)) else {
return nil
}
guard dateProvider() < entry.expirationDate else {
removeValue(forKey: key)
return nil
}
return entry
}
func insert(_ entry: Entry) {
wrapped.setObject(entry, forKey: WrappedKey(entry.key))
keyTracker.keys.insert(entry.key)
}
}
最后一个难题是在我们之前使用的相同条件下使 Cache
本身可编码 ------ 通过使用上述两个实用方法,我们现在可以非常轻松地对所有条目进行编码和解码:
swift
extension Cache: Codable where Key: Codable, Value: Codable {
convenience init(from decoder: Decoder) throws {
self.init()
let container = try decoder.singleValueContainer()
let entries = try container.decode([Entry].self)
entries.forEach(insert)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(keyTracker.keys.compactMap(entry))
}
}
有了上面的内容,我们现在可以将任何包含 Codable
键和值的缓存保存到磁盘 ------ 只需将其编码为数据,然后将该数据写入我们应用程序专用缓存目录中的文件,如下所示:
swift
extension Cache where Key: Codable, Value: Codable {
func saveToDisk(
withName name: String,
using fileManager: FileManager = .default
) throws {
let folderURLs = fileManager.urls(
for: .cachesDirectory,
in: .userDomainMask
)
let fileURL = folderURLs[0].appendingPathComponent(name + ".cache")
let data = try JSONEncoder().encode(self)
try data.write(to: fileURL)
}
}
就像那样,我们已经构建了一个完全与 Swift 兼容的高度动态的缓存 ------ 支持基于时间的失效、磁盘持久化,以及它包含的条目数量的限制 ------ 所有这些都是通过利用像 NSCache
和可编码以避免必须重新发明轮子。
总结
战略性地部署缓存以避免必须多次重新加载相同的数据会对应用程序的性能产生很大的积极影响。毕竟,即使我们可能会优化我们在应用程序中加载数据的方式,但完全不必加载该数据总是会更快 ------ 而缓存可能是实现这一目标的好方法。
但是,在将缓存添加到数据加载管道时需要牢记多件事 ------ 例如不要将陈旧数据保留太久,当应用程序的环境发生变化时(例如当用户更改其首选语言环境时)使缓存条目失效,并确保已删除的项目被正确清除。
部署缓存时要考虑的另一件事是要缓存哪些数据,以及在何处进行缓存。虽然我们在本文中了解了基于 NSCache
的方法,但也可以探索其他多种途径,例如使用另一个系统 API --- URLCache
--- 在网络层中执行我们的缓存。我们将在接下来的文章中仔细研究它和其他类型的缓存。
你怎么认为?您通常如何在 Swift 中使用缓存,您更喜欢 NSCache
、URLCache
还是完全自定义的解决方案?通过 Twitter 或电子邮件让我知道,并附上您的问题、评论和反馈。
谢谢阅读! 🚀