一个简单但出人意料的强大数据存储,而且远不止于此
"我抛弃了Core Data,这才是它应该工作的方式"
"Boutique 实现起来非常简单,让持久化变得轻而易举。它已成为我每个新项目的首选。"
"Boutique 已经变得不可或缺,我现在的每个个人项目都在使用它。不用关心持久化是很棒的,而且上手的成本几乎为零。"
如果你觉得 Boutique 有价值,我将非常感谢你考虑赞助我的开源工作,这样我就可以继续开发像 Boutique 这样的项目来帮助像你这样的开发者。
Boutique 是一个简单但功能强大的持久化库,它是一组小型的属性包装器和类型,可以为 SwiftUI、UIKit 和 AppKit 构建incredibly简单的状态驱动应用。通过其双层内存+磁盘缓存架构,Boutique 提供了一种方法,只需几行代码就可以使用incredibly简单的 API 构建实时更新并具有完整离线存储功能的应用。Boutique 构建在 Bodega 之上,你可以在这个仓库中找到基于 Model View Controller Store 架构构建的演示应用,该应用展示了如何仅用几行代码就构建出一个支持离线的 SwiftUI 应用。你可以在这篇探讨 MVCS 架构的博文中了解更多关于这种架构背后的思考。
入门
Boutique 只有一个你需要理解的概念。当你将数据保存到 Store
时,你的数据会自动为你持久化,并以常规 Swift 数组的形式暴露出来。@StoredValue
和 @AsyncStoredValue
属性包装器的工作方式相同,但它们不是处理数组,而是处理单个 Swift 值。你永远不需要考虑数据库,你的应用中的所有内容都是使用应用模型的常规 Swift 数组或值,代码简单直观,看起来就像任何其他应用一样。
你可能熟悉 Redux 或 The Composable Architecture 中的 Store
,但与这些框架不同,你不需要担心添加 Actions 或 Reducers。使用这个 Store
实现,所有数据都会自动为你持久化,无需额外代码。这允许你以一种incredibly简单直接的方式构建具有完整离线支持的实时更新应用。
你可以在下面阅读 Boutique 的高级概述,但 Boutique 也有完整的文档。
Store
我们将在下面对 Store
进行高级概述,但 Store
在这里有完整的文档,包括上下文、用例和示例。
实现完整离线支持和整个应用的实时模型更新的 API 表面积仅包含三个方法:.insert()
、.remove()
和 .removeAll()
。
// 创建一个 Store ¹
let store = Store<Animal>(
storage: SQLiteStorageEngine.default(appendingPath: "Animals"),
cacheIdentifier: \.id
)
// 将一个项目插入 Store ²
let redPanda = Animal(id: "red_panda")
try await store.insert(redPanda)
// 从 Store 中移除一个动物
try await store.remove(redPanda)
// 向 Store 插入另外两个动物
let dog = Animal(id: "dog")
let cat = Animal(id: "cat")
try await store.insert([dog, cat])
// 你可以直接读取项目
print(store.items) // 打印 [dog, cat]
// 你也不需要担心维护唯一性,Store 会为你处理唯一性
let secondDog = Animal(id: "dog")
try await store.insert(secondDog)
print(store.items) // 打印 [dog, cat]
// 通过一次性移除所有项目来清空你的 store
store.removeAll()
print(store.items) // 打印 []
// 你甚至可以将命令链接在一起
try await store
.insert(dog)
.insert(cat)
.run()
print(store.items) // 打印 [dog, cat]
// 这是清除陈旧缓存数据的好方法
try await store
.removeAll()
.insert(redPanda)
.run()
print(store.items) // 打印 [redPanda]
如果你正在构建一个 SwiftUI 应用,你不需要改变任何东西,Boutique 是为 SwiftUI 设计的,并与之兼容。(当然,它在 UIKit 和 AppKit 中也能很好地工作。😉)
// 由于 items 是一个 @Published 属性
// 你可以实时订阅任何更改。
store.$items.sink({ items in
print("Items 已更新", items)
})
// 对于更复杂的管道,可以很好地与 SwiftUI 配合使用。
.onReceive(store.$items, perform: {
self.allItems = $0.filter({ $0.id > 100 })
})
¹ 你可以根据需要拥有任意多或任意少的 Store。对于应用中下载的所有图片使用一个 Store 可能是一个好策略,但你可能也想为每种需要缓存的模型类型创建一个 Store。你甚至可以为测试创建单独的 store,Boutique 不做规定,你可以自由选择如何建模你的数据。你还会注意到,这是来自 Bodega 的概念,你可以在 Bodega 的 StorageEngine 文档中了解更多信息。
² 在底层,当你添加或删除项目时,Store 会完成将所有更改保存到磁盘的工作。
³ 在 SwiftUI 中,你甚至可以使用 $items
为你的 View
提供动力,并使用 .onReceive()
来更新和操作 Store 的 $items
发布的数据。
警告 在 Boutique 中存储图像或其他二进制数据技术上是支持的,但不推荐。原因是在 Boutique 中存储图像可能会使内存中的 store 膨胀,从而导致应用的内存也随之增加。出于类似的原因,不建议在数据库中存储图像或二进制 blob,同样也不建议在 Boutique 中存储图像或二进制 blob。
@Stored 的魔力
我们将在下面对 @Stored
属性包装器进行高级概述,但 @Stored
在这里有完整的文档,包括上下文、用例和示例。
这很简单,但我想向你展示一些让 Boutique 感觉非常神奇的东西。Store
是一种简单的方式来获得离线存储和实时更新的好处,但通过使用 @Stored
属性包装器,我们可以仅用一行代码就在内存和磁盘中缓存任何属性。
extension Store where Item == RemoteImage {
// 初始化一个 Store 来保存我们的图像
static let imagesStore = Store<RemoteImage>(
storage: SQLiteStorageEngine.default(appendingPath: "Images")
)
}
final class ImagesController: ObservableObject {
/// 创建一个 @Stored 属性来处理图像的内存和磁盘缓存。⁴
@Stored(in: .imagesStore) var images
/// 从 API 获取 `RemoteImage`,如果请求成功则为用户提供一个红熊猫。
func fetchImage() async throws -> RemoteImage {
// 调用提供随机图像元数据的 API
let imageURL = URL(string: "https://image.redpanda.club/random/json")!
let randomImageRequest = URLRequest(url: imageURL)
let (imageResponse, _) = try await URLSession.shared.data(for: randomImageRequest)
return RemoteImage(createdAt: .now, url: imageResponse.url, width: imageResponse.width, height: imageResponse.height, imageData: imageResponse.imageData)
}
/// 将图像保存到内存和磁盘的`Store`中。
func saveImage(image: RemoteImage) async throws {
try await self.$images.insert(image)
}
/// 从内存和磁盘的`Store`中移除一张图像。
func removeImage(image: RemoteImage) async throws {
try await self.$images.remove(image)
}
/// 从内存和磁盘的`Store`中移除所有图像。
func clearAllImages() async throws {
try await self.$images.removeAll()
}
}
就是这样,真的就这么简单。这种技术可以很好地扩展,在多个视图之间共享这些数据正是Boutique从简单到复杂应用程序的扩展方式,而无需增加API的复杂性。很难相信现在你的应用程序只需要一行代码就可以实现实时状态更新和完全离线存储。@Stored(in: .imagesStore) var images
⁴ (如果你更喜欢将存储与视图模型、控制器或管理器对象解耦,你可以像这样将存储注入到对象中。)
final class ImagesController: ObservableObject {
@Stored var images: [RemoteImage]
init(store: Store<RemoteImage>) {
self._images = Stored(in: store)
}
}
StoredValue, SecurelyStoredValue, 和 AsyncStoredValue
我们将对@StoredValue
、@SecurelyStoredValue
和@AsyncStoredValue
属性包装器进行概述,但它们在这里有详细的文档,包含上下文、用例和示例。
Store
和@Stored
最初是为了存储数据数组而创建的,因为大多数应用程序渲染的数据都是以数组形式出现的。但有时我们需要存储单个值,这就是@StoredValue
、@SecurelyStoredValue
和@AsyncStoredValue
派上用场的地方。
无论你是需要为下次应用程序启动保存重要信息,在钥匙串中存储认证令牌,还是想根据用户设置改变应用程序的外观,这些应用程序配置都是你想要持久化的单个值。
通常人们会选择在UserDefaults
中存储这样的单个项目。如果你使用过@AppStorage
,那么@StoredValue
会让你感到很熟悉,它有非常相似的API,还有一些额外的功能。@StoredValue
最终会存储在UserDefaults
中,但它还暴露了一个publisher
,方便你订阅变化。
// 设置一个`@StoredValue`,@AsyncStoredValue有相同的API。
@StoredValue(key: "hasHapticsEnabled")
var hasHapticsEnabled = false
// 你也可以存储nil值
@StoredValue(key: "lastOpenedDate")
var lastOpenedDate: Date? = nil
// 枚举也可以,只要它符合`Codable`和`Equatable`协议。
@StoredValue(key: "currentTheme")
var currentlySelectedTheme = .light
// 复杂对象也可以
struct UserPreferences: Codable, Equatable {
var hasHapticsEnabled: Bool
var prefersDarkMode: Bool
var prefersWideScreen: Bool
var spatialAudioEnabled: Bool
}
@StoredValue(key: "userPreferences")
var preferences = UserPreferences()
// 将lastOpenedDate设置为现在
$lastOpenedDate.set(.now)
// currentlySelected现在是.dark
$currentlySelectedTheme.set(.dark)
// 由布尔值支持的StoredValues也有一个toggle()函数
$hasHapticsEnabled.toggle()
@SecurelyStoredValue
属性包装器可以做@StoredValue
能做的所有事情,但不是将值存储在UserDefaults
中,@SecurelyStoredValue
会将项目持久化到系统的钥匙串中。这非常适合存储敏感值,如密码或认证令牌,你不会想将这些存储在UserDefaults
中。
你可能不想使用UserDefaults
或系统钥匙串来存储值,在这种情况下,你可以使用自己的StorageEngine
。为此,你应该使用@AsyncStoredValue
属性包装器,它允许你在你提供的StorageEngine
中存储单个值。这并不常用,但它提供了额外的灵活性,同时保持了Boutique的@StoredValue
API的一致性。
文档
如果你有任何问题,我建议你首先查看文档,Boutique和Bodega都有非常详细的文档。此外,Boutique还附带了两个演示应用,每个应用都有不同的用途,但都展示了如何构建一个基于Boutique的应用。
在构建v1时,我注意到那些理解Boutique的人都很喜欢它,而那些认为它可能不错但有疑问的人一旦理解了如何使用它,也会爱上它。因此,我着手编写了大量文档,解释在构建iOS或macOS应用时会遇到的概念和常见用例。如果你仍有问题或建议,我非常欢迎反馈,如何贡献在本readme的反馈部分有讨论。
进一步探索
Boutique本身就非常有用,只需几行代码就可以构建实时离线就绪的应用程序,但当你使用我开发的Model View Controller Store架构时,它更加强大,如上面的ImagesController
所示。MVCS将你熟悉和喜爱的MVC架构的简单性与Store
的强大功能结合起来,为你的应用程序提供简单但定义明确的状态管理和数据架构。
如果你想了解更多关于它如何工作的信息,你可以阅读这篇博客文章,其中我探讨了SwiftUI的MVCS,你可以在这个仓库中找到由Boutique支持的离线就绪实时MVCS应用程序的参考实现。
我们在这里只是触及了Boutique能做什么的表面。利用Bodega的StorageEngine
,你可以构建复杂的数据管道,从缓存数据到与API服务器接口。Boutique和Bodega不仅仅是库,它们是任何数据驱动应用程序的一组原语,所以我建议你尝试一下,玩玩演示应用,甚至构建你自己的应用程序!
反馈
本项目提供多种方式向维护者提供反馈。
-
如果你对Boutique有疑问,我们建议你首先查阅文档,看看你的问题是否已经在那里得到解答。
-
本项目有大量文档,但也包括多个示例项目。
-
如果你仍有问题、改进建议或改进Boutique的方法,本项目利用GitHub的讨论功能。
-
如果你发现了bug并希望报告,我们会感谢你提交issue。
要求
- iOS 13.0+
- macOS 11.0+
- Xcode 13.2+
安装
Swift Package Manager
Swift Package Manager是一个用于自动化分发Swift代码的工具,它集成在Swift构建系统中。
一旦你设置好了Swift包,将Boutique作为依赖项添加到你的Package.swift
的dependencies值中就很容易了。
dependencies: [
.package(url: "https://github.com/mergesort/Boutique.git", .upToNextMajor(from: "1.0.0"))
]
手动集成
如果你不想使用SPM,你可以通过复制文件来手动将Boutique集成到你的项目中。
关于我
我是Joe,你可以在网上各处找到我,尤其是在Mastodon上。
许可证
查看许可证以了解更多关于如何使用Boutique的信息。
赞助
Boutique是一个充满爱的项目,旨在帮助开发者构建更好的应用,让你更容易释放创造力,为自己和用户打造出色的作品。如果你觉得Boutique有价值,我将非常感谢你考虑赞助我的开源工作,这样我就可以继续开发像Boutique这样的项目来帮助像你这样的开发者。
现在你已经知道了有什么"好货"在等着你,是时候开始了 🏪