Boutique Logo

一个简单但出奇精致的数据存储,远不止于此

“我抛弃了 Core Data,这才是它应该有的样子”

Josh Holtz

“Boutique 非常容易实现,让持久化变得轻而易举。它已成为我每个项目开始时的首选添加项。”

Tyler Hillsman

“Boutique 变得非常宝贵,我现在在每个副项目中使用它。不必关心持久化非常棒,而且入门成本几乎为零。”

Romain Pouclet

如果您觉得 Boutique 有价值,我将非常感谢您考虑赞助我的开源工作,这样我才能继续从事像 Boutique 这样的项目,以帮助像您这样的开发者。


Boutique 是一个简单而强大的持久化库,一小组属性包装器和类型,可以为 SwiftUI、UIKit 和 AppKit 构建极其简单的状态驱动应用程序。 凭借其双层内存+磁盘缓存架构,Boutique 提供了一种构建应用程序的方法,该应用程序可以实时更新,并使用极其简单的 API 在几行代码中实现完整的离线存储。 Boutique 构建于 Bodega 之上,您可以在此 repo 中找到一个构建在 Model View Controller Store 架构之上的演示应用程序,该应用程序向您展示了如何在几行代码中创建一个可离线使用的 SwiftUI 应用程序。您可以在这篇探讨 MVCS 架构 的博客文章中阅读更多关于该架构背后的想法。



入门

Boutique 只有一个您需要理解的概念。当您将数据保存到 Store 时,您的数据将自动持久化,并作为常规 Swift 数组公开。@StoredValue@AsyncStoredValue 属性包装器的工作方式相同,但它们处理的是单个 Swift 值,而不是数组。您永远不必考虑数据库,您应用程序中的所有内容都是使用应用程序模型的常规 Swift 数组或值,其代码简单明了,看起来与任何其他应用程序相同。

您可能熟悉 ReduxThe Composable Architecture 中的 Store,但与这些框架不同的是,您无需担心添加 Actions 或 Reducers。 使用此 Store 实现,您的所有数据都会自动为您持久化,无需任何额外代码。 这使您可以以一种极其简单明了的方式构建具有完整离线支持的实时更新应用程序。

您可以在下面阅读 Boutique 的高级概述,但 Boutique 也在此处提供了完整文档 here


Store

我们将在下面介绍 Store 的高级概述,但 Store 包含完整的上下文、用例和示例文档 here

在您的整个应用程序中实现完全离线支持和实时模型更新的 API 的整个表面积只有三种方法:.insert().remove().removeAll()

// Create a Store ¹
let store = Store<Animal>(
    storage: SQLiteStorageEngine.default(appendingPath: "Animals"),
    cacheIdentifier: \.id
)

// Insert an item into the Store ²
let redPanda = Animal(id: "red_panda")
try await store.insert(redPanda)

// Remove an animal from the Store
try await store.remove(redPanda)

// Insert two more animals to the Store
let dog = Animal(id: "dog")
let cat = Animal(id: "cat")
try await store.insert([dog, cat])

// You can read items directly
print(store.items) // Prints [dog, cat]

// You also don't have to worry about maintaining uniqueness, the Store handles uniqueness for you
let secondDog = Animal(id: "dog")
try await store.insert(secondDog)
print(store.items) // Prints [dog, cat]

// Clear your store by removing all the items at once.
store.removeAll()

print(store.items) // Prints []

// You can even chain commands together
try await store
    .insert(dog)
    .insert(cat)
    .run()
    
print(store.items) // Prints [dog, cat]

// This is a good way to clear stale cached data
try await store
    .removeAll()
    .insert(redPanda)
    .run()

print(store.items) // Prints [redPanda]

如果您正在构建 SwiftUI 应用程序,您无需进行任何更改,Boutique 的设计就考虑到了 SwiftUI。(当然,在 UIKit 和 AppKit 中也运行良好。😉)

// Since items is a @Published property 
// you can subscribe to any changes in realtime.
store.$items.sink({ items in
    print("Items was updated", items)
})

// Works great with SwiftUI out the box for more complex pipelines.
.onReceive(store.$items, perform: {
    self.allItems = $0.filter({ $0.id > 100 })
})

¹ 您可以拥有任意数量的 Store。 为应用程序中下载的所有图像设置一个 Store 可能是一个好策略,但您也可能希望为要缓存的每种模型类型设置一个 Store。 您甚至可以为测试创建单独的存储,Boutique 不是强制性的,如何建模您的数据由您选择。 您还会注意到,这是 Bodega 中的一个概念,您可以在 Bodega 的 StorageEngine 文档中阅读相关内容。

² 在底层,当您添加或删除项目时,Store 正在完成将所有更改保存到磁盘的工作。

³ 在 SwiftUI 中,您甚至可以使用 $items 为您的 View 提供支持,并使用 .onReceive() 来更新和操作 Store 的 $items 发布的数据。

警告 从技术上讲,支持在 Boutique 中存储图像或其他二进制数据,但不建议这样做。 原因是在 Boutique 中存储图像会使内存中的存储空间膨胀,从而导致应用程序的内存膨胀。 与不建议在数据库中存储图像或二进制 blob 的原因类似,不建议在 Boutique 中存储图像或二进制 blob。


@Stored 的魔力

我们将在下面介绍 @Stored 属性包装器的高级概述,但 @Stored 包含完整的上下文、用例和示例文档 here

这很容易,但我想向您展示一些让 Boutique 感觉非常神奇的东西。 Store 是一种简单的方式来获得离线存储和实时更新的好处,但是通过使用 @Stored 属性包装器,我们只需一行代码就可以在内存和磁盘上缓存任何属性。

extension Store where Item == RemoteImage {
    // Initialize a Store to save our images into
    static let imagesStore = Store<RemoteImage>(
        storage: SQLiteStorageEngine.default(appendingPath: "Images")
    )

}

final class ImagesController: ObservableObject {
    /// Creates a @Stored property to handle an in-memory and on-disk cache of images. ⁴
    @Stored(in: .imagesStore) var images

    /// Fetches `RemoteImage` from the API, providing the user with a red panda if the request succeeds.
    func fetchImage() async throws -> RemoteImage {
        // Hit the API that provides you a random image's metadata
        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)
    }
  
    /// Saves an image to the `Store` in memory and on disk.
    func saveImage(image: RemoteImage) async throws {
        try await self.$images.insert(image)
    }
  
    /// Removes one image from the `Store` in memory and on disk.
    func removeImage(image: RemoteImage) async throws {
        try await self.$images.remove(image)
    }
  
    /// Removes all of the images from the `Store` in memory and on disk.
    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 属性包装器的高级概述,但它们包含完整的上下文、用例和示例文档 here

创建 Store@Stored 是为了存储数据数组,因为大多数数据应用程序呈现的数据都采用数组的形式。 但是,有时我们需要存储单个值,这就是 @StoredValue @SecurelyStoredValue@AsyncStoredValue 派上用场的地方。

无论您是需要保存重要信息以供下次启动应用程序时使用、在密钥链中存储身份验证令牌,还是想根据用户的设置更改应用程序的外观,这些应用程序配置都是您想要保留的单个值。

通常,人们会选择将这样的单个项目存储在 UserDefaults 中。 如果您使用过 @AppStorage,那么 @StoredValue 会让您感到宾至如归,它具有非常相似的 API,并提供了一些额外的功能。 @StoredValue 最终将存储在 UserDefaults 中,但它也会公开一个 publisher,以便您可以轻松订阅更改。

// Setup a `@StoredValue, @AsyncStoredValue has the same API.
@StoredValue(key: "hasHapticsEnabled")
var hasHapticsEnabled = false

// You can also store nil values
@StoredValue(key: "lastOpenedDate")
var lastOpenedDate: Date? = nil

// Enums work as well, as long as it conforms to `Codable` and `Equatable`.
@StoredValue(key: "currentTheme")
var currentlySelectedTheme = .light

// Complex objects work as well
struct UserPreferences: Codable, Equatable {
    var hasHapticsEnabled: Bool
    var prefersDarkMode: Bool
    var prefersWideScreen: Bool
    var spatialAudioEnabled: Bool
}

@StoredValue(key: "userPreferences")
var preferences = UserPreferences()

// Set the lastOpenedDate to now
$lastOpenedDate.set(.now)

// currentlySelected is now .dark
$currentlySelectedTheme.set(.dark)

// StoredValues that are backed by a boolean also have a toggle() function
$hasHapticsEnabled.toggle()

@SecurelyStoredValue 属性包装器可以执行 @StoredValue 可以执行的所有操作,但 @SecurelyStoredValue 不会将值存储在 UserDefaults 中,而是会将项目持久化到系统的 Keychain 中。 这非常适合存储敏感值,例如密码或身份验证令牌,您不希望将其存储在 UserDefaults 中。

您可能不想使用 UserDefaults 或系统 Keychain 来存储值,在这种情况下,您可以使用自己的 StorageEngine。 为此,您应该使用 @AsyncStoredValue 属性包装器,它允许您将单个值存储在您提供的 StorageEngine 中。 这通常不是必需的,但在保持 Boutique 的 @StoredValue API 的同时提供了额外的灵活性。

文档

如果您有任何疑问,我希望您首先查看文档,Boutique 和 Bodega 都记录得很详细。 最重要的是,Boutique 附带了不止一个而是两个演示应用程序,每个应用程序都服务于不同的目的,但演示了如何构建由 Boutique 支持的应用程序。

在构建 v1 时,我注意到喜欢 Boutique 的人真的很喜欢它,而那些认为它可能很好但有问题的人一旦了解如何使用它,就会爱上它。 因此,我试图编写大量文档来解释您在构建 iOS 或 macOS 应用程序时会遇到的概念和常见用例。 如果您仍有疑问或建议,我很乐意接受反馈,如何在本文档的 反馈 部分中讨论了如何做出贡献。


进一步探索

Boutique 本身对于构建具有几行代码的实时离线就绪应用程序非常有用,但当您使用我开发的 Model View Controller Store 架构时,它会更加强大,如上面的 ImagesController 中所示。 MVCS 将您所熟知和喜爱的 MVC 架构的熟悉性和简单性与 Store 的强大功能结合在一起,为您的应用程序提供一个简单但定义明确的状态管理和数据架构。

如果您想了解更多关于它的工作原理的信息,您可以在 博客文章 中阅读关于该哲学的文章,我在其中探讨了 SwiftUI 的 MVCS,并且您可以在此 repo 中找到由 Boutique 支持的可离线使用的实时 MVCS 应用程序的参考实现。

我们只触及了 Boutique 可以做的事情的表面。 利用 Bodega 的 StorageEngine,您可以构建复杂的数据管道,这些管道可以完成从缓存数据到与您的 API 服务器交互的所有事情。 Boutique 和 Bodega 不仅仅是库,它们是一组用于任何数据驱动应用程序的原语,因此我建议您尝试一下,玩玩 演示应用程序,甚至构建您自己的应用程序!


反馈

本项目提供多种向维护者提供反馈的形式。


要求

安装

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 上。

许可

请参阅 license 以获取有关如何使用 Boutique 的更多信息。

赞助

Boutique 是一个充满爱的项目,旨在帮助开发者构建更好的应用程序,让您更容易释放您的创造力,为您自己和您的用户创造一些了不起的东西。如果您认为 Boutique 很有价值,如果您考虑 赞助我的开源工作,我将非常感激,这样我就可以继续从事像 Boutique 这样的项目来帮助像您这样的开发者。


既然您已经知道了商店里有什么,现在是开始行动的时候了 🏪