swift-resource-provider

一个模块化的资源获取和管理系统。

这个小型库采用类似 Combine 的方法来获取具有标识符的事物。它为这种常见的抽象提供了一个易于理解的界面,该抽象基于一系列独特的特征重复获取某些东西,同时能够以可组合的步骤逐步增加实现的复杂性,包括但不限于缓存步骤。

与许多类似的框架和语言工具一样,这并不能简化这些复杂的问题,但它应该有助于以一种更加模块化和可测试的方式组织它们。

您可以在 Swift Package Index 文档归档中找到完整的模块 API 文档

示例

假设您正在开发您的 CRUD 应用程序,并且发现自己处于以下情景中,这是以前没有人遇到过的

我们还假设图像类型要么打包在整个应用程序中使用的 ID 类型中,要么它们都是相同的类型。 我们可以用以下声明来总结这两者

struct ImageID: Hashable {
    var id: <some type>
    
    var url: URL
    
    var type: UTType
}

extension CGImage {
    static func make(from data: Data, with id: ID) throws -> CGImage {  }
}

在您等待图像下载时,此库不会帮助您显示良好的 UI。 你最好说服你的后端同事发送一些媒体元数据,比如图像大小。 但是对于一个相当有效的图像获取和缓存系统,您可以编写类似这样的东西

import ResourceProvider

// Papering over the specifics of error reporting for this example.
struct ImageConversionError: Error {}

func makeImageProvider() -> some AsyncProvider<ImageID, CGImage, any Error> {
    Provider.networkDataSource()
        .mapID(\.url)
        .mapValue { data, id in
            let image = try CGImage.make(from: data, with: id)
            return (data, image)
        }
        .cache(LocalFileDataCache()
            .mapID { url in
                FilePath(url.lastPathComponent)
            }
            .mapValueToStorage { data, _ in
                data
            } fromStorage: { data, id in
                data.flatMap { data in try? CGImage.make(from: data, with: id).map { (data, $0) } }
            }
            .concurrent()
        )
        .mapValue { _, image in
            image
        }
        .cache(WeakObjectCache().makeAsync())
        .coordinated()
}

让我们逐步看一下这一切……

Provider.networkDataSource()

每个 provider 都需要一个源,该源有望始终返回一个事物或 throw(如果它不能)。 如果您幸运地控制了源的逻辑,以至于您有理由确信它永远不会失败,您可以传入一个不 throw 的源,并且您应用它的任何修饰符都不需要处理 trycatch

在本例中,我们使用简单的预构建 Provider.networkDataSource() 方法,该方法只是返回一个从给定 URL 下载数据的源,用作其 ID,如果下载操作因任何原因失败,则会失败(抛出异常)。

.mapID(\.url)

我们的 ID 不必是简单的字符串或 UUID,它们可以是任何我们想要的东西,只要它们是 Hashable。 因此,在许多情况下,我们将使用 struct,其中包括我们需要将资源编码和解码到不可知存储中的任何元数据。

在这个例子中,我们将 URL 和我们资源的 UTType 打包在一起,后者对于从 Data 中解码 CGImage 很有用。 然而,预构建的 networkDataSource 仅接受 URL,因此我们从我们的 id 中提取它。

.mapValue { data, id in
    let image = try CGImage.make(from: data, with: id)
    return (data, image)
}

如果从网络获取的 Data 对我们的显示需求没有好处,我们不想缓存它。 这也会锁定后续尝试的立即失败,这可能不是预期的(即,我们第一次获得的数据已损坏)。 因此,通常明智的做法是在开始缓存之前进行验证。

要重试,只需在项目失败后再次请求它。

我们同时传递数据和生成的图像,因此我们不必在返回调用者时重新处理它。

.cache(LocalFileDataCache()
    .mapID { url in
        FilePath(url.lastPathComponent)
    }
    .mapValueToStorage { data, _ in
        data
    } fromStorage: { data, id in
        data.flatMap { data in try? CGImage.make(from: data, with: id).map { (data, $0) } }
    }
)

我们希望将这些图像存储在本地文件中,存储在系统可以删除的缓存文件夹中,如果它需要更多空间。 幸运的是,LocalFileDataCache 正是这样做的。

但是,LocalFileDataCacheFilePathData 上运行,因为它需要可以轻松写入和读取文件系统的内容。 mapID 会将我们的 URL 转换为文件系统喜欢的东西 —— 示例代码假设最后一个路径组件将足够唯一 —— 并且 mapValueToStorage(_:fromStorage) 会在进入缓存存储时剥离 UIImage 并在需要时在返回时重新创建它。

请注意,创建 UIImage 的失败不会是硬性失败,因为它仍然可以再次检查网络数据。 我们可以只返回 nil,并且在实际逻辑中,我们还会记录错误和/或进行断言,以便我们在发生这种情况时注意到。

请注意,这两种映射方法都接受请求的 id,在这种情况下我们不需要它进行存储,但在从存储返回时它会派上用场,因为我们的 provider id 具有嵌入在其中的类型信息。

最后,由于 LocalFileDataCacheSendable 并且对并发使用很友好,只要不同时修改同一个文件(我们稍后会处理这个问题)。 我们将 concurrent() 应用于它,以便它可以被 provider 的其余部分并发使用……。

.mapValue { _, image in
    image
}

我们现在已经完成了处理原始 Data 的工作,因此我们只需过滤掉它并传递 UIImage

.cache(WeakObjectCache().makeAsync())

弱对象缓存意味着我们可以立即访问其他人之前获取并已在使用的任何对象,因此它基本上是“免费的”。 可以使用任何最有效的缓存失效方法构建其他内存替代方案。 NSCache 听起来不错,但很少是你真正想要的。

因为它使用旧的、对并发不友好的 Foundation 类型构建,所以 WeakObjectCache 不是 Sendable,尝试并发使用它会导致数据竞争。 但是因为它只是执行字典查找,我们可以将它包装在一个 actor 中,这保证了它的串行使用,而不会引入实际的性能问题。 为了更简单地正确处理所有这些,声明了一个 makeAsync() 方法,它可以完成所有这些。

.coordinated()

您始终希望使用这个来完成任何 async provider。 它保证了无论在代码更深层、更靠上的位置必须发生什么其他工作,如果您的应用程序的任何其他部分在处理相同项目时请求该项目,则不会重复。

一旦你得到这个东西,你就会想要一个离散的类型来存储结果。 为此使用 AnyAsyncProvider,这也使得用模拟来替换整个东西以进行测试变得更加容易。

等等,再来一个例子

好的,现在你正在加载这些图像,但是将它们全尺寸地放在你的 UI 上会让你的应用程序性能下降。 所以你去找你友好的邻居后端工程师

“我们是否可以将缩略图 URL 添加到 API”

“不”

你的后端朋友太忙于处理 CEO 最新的奇思妙想:Uber,但用于玩 D&D。 你必须自己做一些事情。 好吧,我们已经有一个图像 provider 了。 哟,不如我们从图像 provider 创建一个缩略图 provider 怎么样?

像这样

struct ThumbnailID: Hashable {
    var image: ImageID
    
    var size: CGSize
}

func makeThumbnailProvider() -> some AsyncProvider<ThumbnailID, CGImage, any Error> {
    makeImageProvider()
        .mapID(\.image)
        .mapValue { image, id in
            if image.isLargerThanSize(id.size) {
                image.downscaled(size: id.size)
            } else {
                return image
            }
        }
        .cache(WeakObjectCache().makeAsync())
        .coordinated()
}

我们将把这个的分步分解留给读者。

这应该有所帮助。 如果它没有足够的帮助,你可以使用此软件包提供的一些工具和一些独创性来构建更复杂的东西。

你也没有义务直接从 provider 返回数据类型。 你可以返回 Task,或 publishers(小心,因为到目前为止,Combine 的那些不是 Sendable),或你的应用程序渴望的其他具有所需行为的东西。

提示和技巧

通用

处理并发