原文:Caching in Swift | Swift by Sundell

让应用程序感觉呼应敏捷不仅仅是调整 UI 的呈现办法,或提高操作和算法的朴实履行速度 —— 它一般与有用办理数据和防止不必要的开销相同重要。

这种不必要的作业的一个十分常见的来历是当咱们终究屡次从头加载彻底相同的数据时。它或许是多个功能加载同一模型的重复副本,或许视图的数据每次从头呈现在屏幕上时都会从头加载。

本周 —— 让咱们来看看缓存如安在这种情况下成为一个十分强大的工具,如安在 Swift 中构建一个高效而高雅的缓存 API,以及战略性地缓存各种值和目标如何对全体发生重大影呼应用程序的功能。

体系的一部分

缓存是这些任务之一,起先看起来比实际要简略得多。咱们不仅要有用地存储和加载值,还需求决议何时删去缓存,以保持低内存占用、使陈腐数据无效等等。

值得庆幸的是,Apple 现现已过内置的 NSCache 类为咱们处理了其中的许多问题。但是,运用它的确有一些注意事项,因为它在 Apple 自己的平台上仍然是一个 Objective-C 类 —— 这意味着它只能存储类实例,而且它只与根据 NSObject 的键兼容:

// 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 实例:

final class Cache<Key: Hashable, Value> {
    private let wrapped = NSCache<WrappedKey, Entry>()
}

顾名思义,咱们的 WrappedKey 类型将包装咱们面向公众的 Key 值,以使它们与 NSCache 兼容。为了完成这一点,让咱们子类化 NSObject 并完成 hashisEqual 办法—— 因为这是 Objective-C 用来确认两个实例是否相等的东西:

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 实例:

private extension Cache {
    final class Entry {
        let value: Value
        init(value: Value) {
            self.value = value
        }
    }
}

有了上面的内容,咱们现在就可以为 Cache 提供一组初始的 API。让咱们从三种办法开端 —— 一种用于为给定键刺进值,一种用于检索值,一种用于删去现有值:

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 存储,它是下标的理想用例 —— 所以咱们也能够经过这种办法检索和刺进值:

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 来加载文章模型。经过运用咱们的新缓存来存储咱们加载的文章,以及在加载新文章之前检查任何曾经缓存的文章 —— 咱们能够确保咱们只会加载每篇文章一次,如下所示:

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 特点,以便能够盯梢每个条目的剩余生命周期:

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 小时:

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 考虑在内:

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,这样咱们就能够直接保存每个条目,并能够删去未运用的键:

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 的托付,以便在删去条目时得到通知:

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—— 咱们还将设置最大条目数,这将协助咱们防止将太多数据写入磁盘 —— 如下所示:

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 办法的一部分履行此操作:

func insert(_ value: Value, forKey key: Key) {
    ...
    keyTracker.keys.insert(key)
}

为了能够真实耐久化缓存的内容,咱们首要需求对其进行序列化。就像咱们如何运用 NSCache 在体系之上构建咱们自己的缓存 API 相同,让咱们运用 Codable 使咱们的缓存能够运用任何兼容格局(例如 JSON)进行编码和解码。

咱们将从让咱们的 Entry 类型契合 Codable 开端 —— 但咱们不想要求一切缓存条目都是可编码的 —— 所以让咱们运用条件一致性来仅对具有可编码键和值的条目采用 Codable,如下所示:

extension Cache.Entry: Codable where Key: Codable, Value: Codable {}

在编码和解码过程中,咱们将检索和刺进条目,因此为了防止重复咱们曾经的 insertvalue 办法的代码 —— 让咱们也将处理 Entry 实例的一切逻辑移动到两个新的私有实用程序办法中:

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 本身可编码 —— 经过运用上述两个实用办法,咱们现在能够十分轻松地对一切条目进行编码和解码:

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 键和值的缓存保存到磁盘 —— 只需将其编码为数据,然后将该数据写入咱们应用程序专用缓存目录中的文件,如下所示:

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 中运用缓存,您更喜爱 NSCacheURLCache 仍是彻底自定义的处理方案?经过 Twitter 或电子邮件让我知道,并附上您的问题、谈论和反应。

谢谢阅览!