使应用程序感觉快速和响应不仅仅是调整其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
属性,该属性将存储Entry
由WrappedKey
类型键入的实例:
final class Cache<Key: Hashable, Value> {
private let wrapped = NSCache<WrappedKey, Entry>()
}
我们的WrappedKey
类型将像它的名字所暗示的那样,包含我们面向公众的Key
价值观,以使它们NSCache
兼容。为了实现这一点,让我们继承NSObject
和实施hash
以及isEqual
-因为这就是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))
}
}
由于缓存本质上只是一个专门的键值存储,它是下标的理想用例- 所以我们也可以通过这种方式检索和插入值:
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)
}
}
}
随着最初的功能集实现 - 让我们的新东西Cache
旋转!假设我们正在开发一个用于阅读文章的应用程序,并且我们正在使用它ArticleLoader
来加载Article
模型。通过使用我们的新缓存来存储我们加载的文章,以及在加载新文章之前检查任何以前缓存的文章 - 我们可以确保我们只加载每篇文章一次,如下所示:
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中的竞争条件”。
以上看起来似乎不会对我们的应用程序的性能产生很大的影响,但它确实可以使我们的应用程序看起来更快,因为当用户将导航回已经加载的文章时 - 它现在会立即出现在那里。如果我们还将上述内容与用户可能打开的预取文章(例如用户最喜欢的类别中的最新文章)结合起来,那么我们真的可以让我们的应用程序使用起来更加愉快。
避免过时的数据
是什么让NSCache
一个更适合相比,雨燕标准库(如找到的集合缓存值Dictionary
)是当系统内存不足,它会自动驱逐对象-这反过来又使我们的应用程序本身留在记忆更长时间。
但是,我们可能希望添加一些我们自己的缓存失效条件,否则我们可能最终会保留过时的数据。虽然能够重用我们已经加载的数据当然是一件好事,但向用户显示过时的数据绝对不是。
缓解该问题的一种方法是通过在一定时间间隔之后删除它们来限制我们的缓存条目的生存期。为此,我们首先expirationDate
在我们的Entry
类中添加一个属性,以便能够跟踪每个条目的剩余生命周期:
final class Entry {
let value: Value
let expirationDate: Date
init(value: Value, expirationDate: Date) {
self.value = value
self.expirationDate = expirationDate
}
}
接下来,我们需要一种方法Cache
来获取当前日期,以确定给定条目是否仍然有效。虽然我们可以Date()
在需要时调用内联,但这会使单元测试变得非常困难 - 所以让我们将生成Date
函数作为初始化程序的一部分。我们还将添加一个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
}
}
接下来,我们需要一种方法来跟踪缓存包含条目的键,因为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
初始化时设置Cache
- 我们还将设置最大条目数,这将有助于我们避免将太多数据写入磁盘 - 如下所示:
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 {}
在编码和解码过程中,我们将检索和插入条目,因此为了避免重复我们之前insert
和value
方法中的代码- 让我们将处理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
自己Codable
处于我们之前使用的相同条件下 - 通过使用上述两种实用方法,我们现在可以非常轻松地对所有条目进行编码和解码:
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))
}
}
有了上述内容,我们现在可以将Cache
包含Codable
键和值的任何内容保存到磁盘 - 只需将其编码到Data
,然后将该数据写入应用程序专用临时目录中的文件,如下所示:
extension Cache where Key: Codable, Value: Codable {
func saveToDisk(
as name: String,
at folderURL: URL = FileManager.default.temporaryDirectory
) throws {
let fileURL = folderURL.appendingPathComponent(name + ".cache")
let data = try JSONEncoder().encode(self)
try data.write(to: fileURL)
}
}
就这样,我们已经建立了一个高度动态高速缓存这是完全斯威夫特兼容-与支持基于时间的无效,在磁盘上的持久性,并在其上所包含的条目数量的限制-所有通过利用系统API,如NSCache
与Codable
以避免重新发明轮子。
结论
策略性地部署缓存以避免不得不多次重新加载相同的数据会对应用程序的性能产生巨大的积极影响。毕竟,即使我们可以优化我们在应用程序中加载数据的方式,但根本不需要加载数据总是会更快 - 而缓存可能是实现这一目标的好方法。
但是,在向数据加载管道添加缓存时需要记住多项事项 - 例如,不要将陈旧数据保留太长时间,在应用程序环境发生变化时(例如当用户更改其首选语言环境时)使缓存条目无效,并确保已正确清除已删除的项目。
部署缓存时要考虑的另一件事是要缓存的数据以及在何处执行此操作。虽然我们在本文中已经看过NSCache基于基于方法的方法,但是还有其他多种路径可以被探索,例如使用另一个系统API URLCache- 来在网络层中执行我们的缓存。在接下来的文章中,我们将仔细研究它以及其他类型的缓存。
你怎么看?你通常如何使用斯威夫特缓存,和你喜欢NSCache,URLCache或完全定制的解决方案?请通过加我们的交流群 点击此处进交流群 ,来一起交流或者发布您的问题,意见或反馈。
网友评论