原子

SwiftUI 的原子方法状态管理和依赖注入

📔 API 参考

build release swift platform license



简介

响应式数据绑定 有效的缓存 编译安全
依赖注入
可以从任何地方访问的应用数据片段,能够响应式地传播更改。 在使用期间缓存数据,仅在真正需要时重新计算。 成功的编译保证依赖注入已准备就绪。

Atoms 提供了一种简单而实用的能力来应对现代应用的复杂性。它有效地集成了状态管理和依赖注入的解决方案,同时允许我们快速构建健壮且可测试的应用。
通过组合原子构建状态可以自动优化基于其依赖关系图的渲染。这解决了在您意识到之前由于额外的重新渲染而导致的性能下降问题。

快速概览

struct CounterAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        0
    }
}
struct CountStepper: View {
    @WatchState(CounterAtom())
    var count

    var body: some View {
        Stepper(value: $count) {}
    }
}
struct CounterView: View {
    @Watch(CounterAtom())
    var count

    var body: some View {
        VStack {
            Text("Count: \(count)")
            CountStepper()
        }
    }
}

示例

Counter Todo TMDB Map Voice Memo Time Travel

每个示例也都有一个测试目标,以演示如何使用依赖注入来测试您的原子。
打开 Examples/Project.xcodeproj 并试用一下!


开始上手

文档

要求

最低版本
Swift 5.10, 6.0
Xcode 15.4, 16.1
iOS 14.0
macOS 11.0
tvOS 14.0
watchOS 7.0

安装

包的模块名称是 Atoms。选择以下说明之一进行安装,并将以下导入语句添加到您的源代码中。

import Atoms

Xcode Package Dependency

从 Xcode 菜单:File > Swift Packages...

https://github.com/ra1028/swiftui-atom-properties

Swift Package Manager

在您的 Package.swift 文件中,首先将以下内容添加到包的 dependencies

.package(url: "https://github.com/ra1028/swiftui-atom-properties"),

然后,将 "Atoms" 作为您目标的依赖项包含进去

.target(name: "<target>", dependencies: [
    .product(name: "Atoms", package: "swiftui-atom-properties"),
]),

基础教程

在本教程中,我们将创建一个简单的 todo 应用作为示例。此应用将支持创建/编辑/过滤 todo 项目。

每个使用原子的视图都必须在其祖先的某个位置有一个 AtomRoot。在 SwiftUI 生命周期应用中,建议将其放在 WindowGroup 下面。

@main
struct TodoApp: App {
    var body: some Scene {
        WindowGroup {
            AtomRoot {
                TodoList()
            }
        }
    }
}

首先,定义一个 todo 实体和一个表示过滤方法的枚举,并使用 StateAtom 声明一个表示可变状态的原子。

struct Todo {
    var id: UUID
    var text: String
    var isCompleted: Bool
}

enum Filter: CaseIterable, Hashable {
    case all, completed, uncompleted
}

struct TodosAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> [Todo] {
        []
    }
}

struct FilterAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Filter {
        .all
    }
}

下面的 FilteredTodosAtom 表示派生数据,它组合了上述两个原子。您可以将派生数据视为将值传递给纯函数的输出,该函数从依赖值中派生新值。

当依赖数据更改时,派生数据会做出反应式更新,并且输出值会被缓存,直到真正需要更新为止,因此您无需担心每次视图重新计算时调用过滤器函数会导致性能下降。

struct FilteredTodosAtom: ValueAtom, Hashable {
    func value(context: Context) -> [Todo] {
        let filter = context.watch(FilterAtom())
        let todos = context.watch(TodosAtom())

        switch filter {
        case .all:         return todos
        case .completed:   return todos.filter(\.isCompleted)
        case .uncompleted: return todos.filter { !$0.isCompleted }
        }
    }
}

要创建一个新的 todo 项目,您需要访问一个可写值,该值会更新您先前定义的 TodosAtom 的值。

struct TodoCreator: View {
    @WatchState(TodosAtom())
    var todos

    @State
    var text = ""

    var body: some View {
        HStack {
            TextField("Enter your todo", text: $text)
            Button("Add") {
                todos.append(Todo(id: UUID(), text: text, isCompleted: false))
                text = ""
            }
        }
    }
}

类似地,构建一个视图来切换 FilterAtom 的值。使用 $ 前缀获取由 @WatchState 公开的值的 Binding

struct TodoFilters: View {
    @WatchState(FilterAtom())
    var current

    var body: some View {
        Picker("Filter", selection: $current) {
            ForEach(Filter.allCases, id: \.self) { filter in
                switch filter {
                case .all:         Text("All")
                case .completed:   Text("Completed")
                case .uncompleted: Text("Uncompleted")
                }
            }
        }
        .pickerStyle(.segmented)
    }
}

接下来,创建一个视图来显示一个 todo 项目。它还支持编辑项目。

struct TodoItem: View {
    @WatchState(TodosAtom())
    var allTodos

    @State
    var text: String

    @State
    var isCompleted: Bool

    let todo: Todo

    init(todo: Todo) {
        self.todo = todo
        self._text = State(initialValue: todo.text)
        self._isCompleted = State(initialValue: todo.isCompleted)
    }

    var index: Int {
        allTodos.firstIndex { $0.id == todo.id }!
    }

    var body: some View {
        Toggle(isOn: $isCompleted) {
            TextField("Todo", text: $text) {
                allTodos[index].text = text
            }
        }
        .onChange(of: isCompleted) { isCompleted in
            allTodos[index].isCompleted = isCompleted
        }
    }
}

最后,组装您到目前为止创建的视图并完成。

struct TodoList: View {
    @Watch(FilteredTodosAtom())
    var filteredTodos

    var body: some View {
        List {
            TodoCreator()
            TodoFilters()

            ForEach(filteredTodos, id: \.id) { todo in
                TodoItem(todo: todo)
            }
        }
    }
}

这就是使用 Atoms 构建应用的基础知识,但即使是异步过程和更复杂的状态管理也可以按照相同的步骤解决。
有关更多详细信息,请参阅 指南 部分。此外,示例 目录中有几个项目可以探索具体用法。


指南

本节介绍可用的 API 及其用途。
要更详细地了解 API,请访问 API 参考


AtomRoot

此视图允许后代视图使用原子。它必须是整个应用程序中任何视图的根。

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            AtomRoot {
                ExampleView()
            }
        }
    }
}

Atom

原子代表一个状态片段,并且是您应用的真理来源。它也可以通过组合和转换一个或多个其他原子来表示派生数据。
每个原子实际上都没有内部全局数据,而是从 AtomRoot 提供的存储中检索值。这就是为什么它们可以从任何地方访问,但永远不会失去可测试性。

原子及其值使用唯一的 key 关联,如果原子符合 Hashable,则会自动定义该 key,但您也可以在不使用 Hashable 的情况下显式定义它。

struct UserNameAtom: StateAtom {
   let userID: Int

   var key: Int {
       userID
   }

   func defaultValue(context: Context) -> String {
       "Robert"
   }
}

为了为结果值的类型提供最佳界面和有效的数据绑定,有以下几种原子变体。

ValueAtom

描述
摘要 提供只读值。
输出 T
用例 计算属性、派生数据、依赖注入
📖 示例
struct LocaleAtom: ValueAtom, Hashable {
    func value(context: Context) -> Locale {
        .current
    }
}

struct LocaleView: View {
    @Watch(LocaleAtom())
    var locale

    var body: some View {
        Text(locale.identifier)
    }
}

StateAtom

描述
摘要 提供读写数据。
输出 T
用例 可变数据、派生数据
📖 示例
struct CounterAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        0
    }
}

struct CounterView: View {
    @WatchState(CounterAtom())
    var count

    var body: some View {
        Stepper("Count: \(count)", value: $count)
    }
}

TaskAtom

描述
摘要 从给定的 async 函数启动一个非抛出的 Task
输出 Task<T, Never>
用例 非抛出的异步操作,例如,昂贵的计算
📖 示例
struct FetchUserAtom: TaskAtom, Hashable {
    func value(context: Context) async -> User? {
        await fetchUser()
    }
}

struct UserView: View {
    @Watch(FetchUserAtom())
    var userTask

    var body: some View {
        Suspense(userTask) { user in
            Text(user?.name ?? "Unknown")
        }
    }
}

ThrowingTaskAtom

描述
摘要 从给定的 async throws 函数启动一个抛出的 Task
输出 Task<T, any Error>
用例 抛出的异步操作,例如,API 调用
📖 示例
struct FetchMoviesAtom: ThrowingTaskAtom, Hashable {
    func value(context: Context) async throws -> [Movie] {
        try await fetchMovies()
    }
}

struct MoviesView: View {
    @Watch(FetchMoviesAtom())
    var moviesTask

    var body: some View {
        List {
            Suspense(moviesTask) { movies in
                ForEach(movies, id: \.id) { movie in
                    Text(movie.title)
                }
            } catch: { error in
                Text(error.localizedDescription)
            }
        }
    }
}

AsyncPhaseAtom

描述
摘要 提供一个 AsyncPhase 值,该值表示给定异步可抛出函数的结果。
输出 AsyncPhase<T, E: Error> (Swift 5 中的 AsyncPhase<T, any Error>)
用例 抛出或非抛出的异步操作,例如,API 调用

注意
类型化 throws 功能在 Swift 6 中引入,允许将生成的 AsyncPhaseFailure 类型指定为任何类型,甚至是非抛出类型,但在没有它的 Swift 5 中,Failure 类型始终为 any Error
这是一个 typed throws 中的语法和结果 AsyncPhase 类型的图表。

语法 简写 生成
throws(E) throws(E) AsyncPhase<T, E>
throws(any Error) throws AsyncPhase<T, any Error>
throws(Never) AsyncPhase<T, Never>
📖 示例
struct FetchTrendingSongsAtom: AsyncPhaseAtom, Hashable {
    func value(context: Context) async throws(FetchSongsError) -> [Song] {
        try await fetchTrendingSongs()
    }
}

struct TrendingSongsView: View {
    @Watch(FetchTrendingSongsAtom())
    var phase

    var body: some View {
        List {
            switch phase {
            case .success(let songs):
                ForEach(songs, id: \.id) { song in
                    Text(song.title)
                }

            case .failure(.noData):
                Text("There are no currently trending songs.")

            case .failure(let error):
                Text(error.localizedDescription)
            }
        }
    }
}

AsyncSequenceAtom

描述
摘要 提供一个 AsyncPhase 值,该值表示给定 AsyncSequence 的异步顺序元素。
输出 AsyncPhase<T, any Error>
用例 处理多个异步值,例如,web-sockets
📖 示例
struct NotificationAtom: AsyncSequenceAtom, Hashable {
    let name: Notification.Name

    func sequence(context: Context) -> NotificationCenter.Notifications {
        NotificationCenter.default.notifications(named: name)
    }
}

struct NotificationView: View {
    @Watch(NotificationAtom(name: UIApplication.didBecomeActiveNotification))
    var notificationPhase

    var body: some View {
        switch notificationPhase {
        case .suspending, .failure:
            Text("Unknown")

        case .success:
            Text("Active")
        }
    }
}

PublisherAtom

描述
摘要 提供一个 AsyncPhase 值,该值表示给定 Publisher 的值序列。
输出 AsyncPhase<T, E: Error>
用例 处理单个或多个异步值,例如,API 调用
📖 示例
struct TimerAtom: PublisherAtom, Hashable {
    func publisher(context: Context) -> AnyPublisher<Date, Never> {
        Timer.publish(every: 1, on: .main, in: .default)
            .autoconnect()
            .eraseToAnyPublisher()
    }
}

struct TimerView: View {
    @Watch(TimerAtom())
    var timerPhase

    var body: some View {
        if let date = timerPhase.value {
            Text(date.formatted(date: .numeric, time: .shortened))
        }
    }
}

ObservableObjectAtom

描述
摘要 实例化一个可观察对象。
输出 T: ObservableObject
用例 可变复杂状态对象
📖 示例
class Contact: ObservableObject {
    @Published var name = ""
    @Published var age = 20

    func haveBirthday() {
        age += 1
    }
}

struct ContactAtom: ObservableObjectAtom, Hashable {
    func object(context: Context) -> Contact {
        Contact()
    }
}

struct ContactView: View {
    @WatchStateObject(ContactAtom())
    var contact

    var body: some View {
        VStack {
            TextField("Enter your name", text: $contact.name)
            Text("Age: \(contact.age)")
            Button("Celebrate your birthday!") {
                contact.haveBirthday()
            }
        }
    }
}

Modifier

修饰符可以应用于原子以生成原始原子的不同版本,使其更易于编码或减少视图重新计算以优化性能。

changes(of:)

描述
摘要 从原始原子派生具有指定键路径的部分属性,并在其新值等效于旧值时阻止其更新下游。
输出 T: Equatable
兼容 所有原子类型。派生属性必须符合 Equatable
用例 性能优化、属性范围限制
📖 示例
struct CountAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        12345
    }
}

struct CountDisplayView: View {
    @Watch(CountAtom().changes(of: \.description))
    var description  // : String

    var body: some View {
        Text(description)
    }
}

changes

描述
摘要 当原子的新值与其旧值相同时,阻止原子更新其子视图或原子。
输出 T: Equatable
兼容 所有生成符合 Equatable 的值的原子类型。
用例 性能优化
📖 示例
struct CountAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        12345
    }
}

struct CountDisplayView: View {
    @Watch(CountAtom().changes)
    var count  // : Int

    var body: some View {
        Text(count.description)
    }
}

animation(_:)

描述
摘要 当值更新时,为观看原子的视图添加动画效果。
输出 T
兼容 所有原子类型。
用例 将动画应用于视图
📖 示例
struct TextAtom: ValueAtom, Hashable {
    func value(context: Context) -> String {
        ""
    }
}

struct ExampleView: View {
    @Watch(TextAtom().animation())
    var text

    var body: some View {
        Text(text)
    }
}

TaskAtom/phase | ThrowingTaskAtom/phase

描述
摘要 将原始原子提供的 Task 转换为 AsyncPhase
输出 AsyncPhase<T, E: Error>
兼容 TaskAtom, ThrowingTaskAtom
用例 将异步结果作为 AsyncPhase 消费
📖 示例
struct FetchWeatherAtom: ThrowingTaskAtom, Hashable {
    func value(context: Context) async throws -> Weather {
        try await fetchWeather()
    }
}

struct WeatherReportView: View {
    @Watch(FetchWeatherAtom().phase)
    var weatherPhase  // : AsyncPhase<Weather, any Error>

    var body: some View {
        switch weatherPhase {
        case .suspending:
            Text("Loading.")

        case .success(let weather):
            Text("It's \(weather.description) now!")

        case .failure:
            Text("Failed to get weather data.")
        }
    }
}

Attribute

属性允许控制原子的基本工作方式,例如,状态的缓存控制。

Scoped

Scoped 在最靠近其使用位置的祖先的范围内保留原子状态,并防止其在范围外共享。

📖 示例

在下面的示例案例中,每个 SearchPane 都使用为每个范围隔离的 SearchQueryAtom 状态。

struct SearchQueryAtom: StateAtom, Scoped, Hashable {
    func defaultValue(context: Context) -> String {
         ""
    }
}

VStack {
    AtomScope {
        SearchPane()
    }

    AtomScope {
        SearchPane()
    }
}

KeepAlive

KeepAlive 允许原子即使在任何地方都不再被监视的情况下也保留其数据。

📖 示例

在下面的示例案例中,一旦从服务器获得主数据,就可以将其缓存在内存中,直到应用程序进程终止。

struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable {
    func value(context: Context) async throws -> MasterData {
        try await fetchMasterData()
    }
}

Refreshable

Refreshable 允许您为原子实现自定义的可刷新行为。

📖 示例

它为 ValueAtom 添加了自定义刷新行为,而 ValueAtom 本质上无法刷新。
当需要具有任意刷新行为或在值依赖于私有原子时实现刷新时,它很有用。
在此示例中,FetchMoviesPhaseAtom 透明地将 FetchMoviesTaskAtom 的值公开为 AsyncPhase,以便可以轻松地在原子内部处理错误,并且 RefreshableFetchMoviesPhaseAtom 本身提供了刷新行为。

private struct FetchMoviesTaskAtom: ThrowingTaskAtom, Hashable {
    func value(context: Context) async throws -> [Movies] {
        try await fetchMovies()
    }
}

struct FetchMoviesPhaseAtom: ValueAtom, Refreshable, Hashable {
    func value(context: Context) -> AsyncPhase<[Movies], any Error> {
        context.watch(FetchMoviesTaskAtom().phase)
    }

    func refresh(context: CurrentContext) async -> AsyncPhase<[Movies], any Error> {
        await context.refresh(FetchMoviesTaskAtom().phase)
    }

    func effect(context: CurrentContext) -> some AtomEffect {
        UpdateEffect {
            if case .failure = context.read(self) {
                print("Failed to fetch movies.")
            }
        }
    }
}

Resettable

Resettable 允许您为原子实现自定义的重置行为。

📖 示例

它为原子添加了自定义重置行为,该行为将在原子重置时执行。
当需要具有任意重置行为或在值依赖于私有原子时实现重置时,它很有用。
在以下示例中,RandomIntAtom 使用从私有 RandomNumberGeneratorAtom 生成的随机值生成随机值,并且 Resettable 提供了用 RandomNumberGeneratorAtom 重置替换公开重置的能力。

struct RandomIntAtom: ValueAtom, Resettable, Hashable {
    func value(context: Context) -> Int {
        var generator = context.watch(RandomNumberGeneratorAtom())
        return .random(in: 0..<100, using: &generator)
    }

    func reset(context: CurrentContext) {
        context.reset(RandomNumberGeneratorAtom())
    }
}

private struct RandomNumberGeneratorAtom: ValueAtom, Hashable {
    func value(context: Context) -> CustomRandomNumberGenerator {
        CustomRandomNumberGenerator()
    }
}

Property Wrapper

以下属性包装器用于将原子绑定到视图,并使用数据更改重新计算视图。
通过属性包装器检索原子,内部系统会将原子标记为正在使用中,并且这些值会被缓存,直到该视图被拆除。

@Watch

描述
摘要 此属性包装器类似于 @State@Environment,但始终是只读的。它使用值更改重新计算视图。
兼容 所有原子类型
📖 示例
struct UserNameAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> String {
        "John"
    }
}

struct UserNameDisplayView: View {
    @Watch(UserNameAtom())
    var name

    var body: some View {
        Text("User name: \(name)")
    }
}

@WatchState

描述
摘要 此属性包装器是读写的,接口与 @State 相同。它使用数据更改重新计算视图。您可以使用 $ 前缀获取值的 Binding
兼容 StateAtom
📖 示例
struct UserNameAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> String {
        "Jim"
    }
}

struct UserNameInputView: View {
    @WatchState(UserNameAtom())
    var name

    var body: some View {
        VStack {
            TextField("User name", text: $name)
            Button("Clear") {
                name = ""
            }
        }
    }
}

@WatchStateObject

描述
摘要 此属性包装器具有与 @StateObject@ObservedObject 相同的接口。当可观察对象更新时,它会重新计算视图。您可以使用 $ 前缀获取可观察对象的属性之一的 Binding
兼容 ObservableObjectAtom
📖 示例
class Counter: ObservableObject {
    @Published var count = 0

    func plus(_ value: Int) {
        count += value
    }
}

struct CounterAtom: ObservableObjectAtom, Hashable {
    func object(context: Context) -> Counter {
        Counter()
    }
}

struct CounterView: View {
    @WatchStateObject(CounterObjectAtom())
    var counter

    var body: some View {
        VStack {
            Text("Count: \(counter.count)")
            Stepper(value: $counter.count) {}
            Button("+100") {
                counter.plus(100)
            }
        }
    }
}

@ViewContext

与上述描述的属性包装器不同,此属性包装器并非旨在绑定单个原子。它为视图提供了一个 AtomViewContext,从而可以对原子进行更功能性的控制。
例如,以下控制只能通过上下文完成。

await context.refresh(FetchMoviesAtom())
context.reset(CounterAtom())

上下文还为将动态参数传递给原子的初始化程序提供了灵活的解决方案。有关更多详细信息,请参阅 Context 部分。

📖 示例
struct FetchBookAtom: ThrowingTaskAtom, Hashable {
    let id: Int

    func value(context: Context) async throws -> Book {
        try await fetchBook(id: id)
    }
}

struct BookView: View {
    @ViewContext
    var context

    let id: Int

    var body: some View {
        let task = context.watch(FetchBookAtom(id: id))

        Suspense(task) { book in
            Text(book.content)
        } suspending: {
            ProgressView()
        }
    }
}

Context

上下文是用于从视图或其他原子中使用原子值并与之交互的结构。

API 用途
watch(_:) 获取原子值并开始监视其更新。
read(_:) 获取原子值,但不监视其更新。
set(_:for:) 为原子设置新值。
modify(_:body:) 修改缓存的原子值。
subscript[] 用于应用可变方法的读写访问。
refresh(_:) 在等待异步操作完成后,生成原子的新值。
reset(_:) 将原子重置为默认值或第一个输出。

上下文根据提供它们的环境,以以下类型提供。除了上面描述的通用 API 之外,每种上下文类型可能都有其独特的功能。

AtomViewContext

当从视图中使用原子时,通过 @ViewContext 属性包装器提供的上下文。

API 用途
binding(_:) 获取原子状态的绑定。
snapshot() 对于调试,拍摄快照,捕获特定原子值集。
📖 示例
struct SearchQueryAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> String {
        ""
    }
}

struct FetchBooksAtom: ThrowingTaskAtom, Hashable {
    func value(context: Context) async throws -> [Book] {
        let query = context.watch(SearchQueryAtom())
        return try await fetchBooks(query: query)
    }
}

struct BooksView: View {
    @ViewContext
    var context: AtomViewContext

    var body: some View {
        // watch
        let booksTask = context.watch(FetchBooksAtom())     // Task<[Book], any Error>
        // binding
        let searchQuery = context.binding(SearchQueryAtom())  // Binding<String>

        List {
            Suspense(booksTask) { books in
                ForEach(books, id: \.isbn) { book in
                    Text("\(book.title): \(book.isbn)")
                }
            }
        }
        .searchable(text: searchQuery)
        .refreshable {
            // refresh
            await context.refresh(FetchBooksAtom())
        }
        .toolbar {
            ToolbarItem(placement: .bottomBar) {
                HStack {
                    Button("Reset") {
                        // reset
                        context.reset(SearchQueryAtom())
                    }
                    Button("All") {
                        // set
                        context.set("All", for: SearchQueryAtom())
                    }
                    Button("Space") {
                        // subscript
                        context[SearchQueryAtom()].append(" ")
                    }
                    Button("Print") {
                        // read
                        let query = context.read(SearchQueryAtom())
                        print(query)
                    }
                    Button("Snapshot") {
                        // snapshot
                        let snapshot = context.snapshot()
                        print(snapshot)
                    }
                }
            }
        }
    }
}

AtomTransactionContext

作为参数传递给每种原子类型的主要函数的上下文。

📖 示例
final class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { ... }

struct LocationManagerDelegateAtom: ValueAtom, Hashable {
    func value(context: Context) -> LocationManagerDelegate {
        LocationManagerDelegate()
    }
}

struct LocationManagerAtom: ValueAtom, Hashable {
    func value(context: Context) -> any LocationManagerProtocol {
        let delegate = context.watch(LocationManagerDelegateAtom())
        let manager = CLLocationManager()
        manager.delegate = delegate
        return manager
    }
}

AtomTestContext

一个上下文,可以模拟从视图或其他原子中使用原子的任何场景,并提供全面的测试方法。

API 用途
lookup(_:) 获取原子值而不创建缓存。
unwatch(_:) 模拟原子不再被监视的场景。
override(_:with:) 使用固定值覆盖特定原子或给定类型的所有原子的输出。
waitForUpdate(timeout:) 等待直到通过此上下文监视的任何原子都已更新。
wait(for:timeout:until:) 等待给定的原子,直到它达到某种状态。
onUpdate 设置一个闭包,用于通知原子之一已更新。
📖 示例
protocol APIClientProtocol {
    func fetchMusics() async throws -> [Music]
}

struct APIClient: APIClientProtocol { ... }
struct MockAPIClient: APIClientProtocol { ... }

struct APIClientAtom: ValueAtom, Hashable {
    func value(context: Context) -> any APIClientProtocol {
        APIClient()
    }
}

struct FetchMusicsAtom: ThrowingTaskAtom, Hashable {
    func value(context: Context) async throws -> [Music] {
        let api = context.watch(APIClientAtom())
        return try await api.fetchMusics()
    }
}

@MainActor
class FetchMusicsTests: XCTestCase {
    func testFetchMusicsAtom() async throws {
        let context = AtomTestContext()

        context.override(APIClientAtom()) { _ in
            MockAPIClient()
        }

        let musics = try await context.watch(FetchMusicsAtom()).value

        XCTAssertTrue(musics.isEmpty)
    }
}

View

AtomScope

AtomScope 允许您监视更改或覆盖后代视图中使用的原子。与 AtomRoot 不同,它们仅影响范围内的原子。
有关具体用途,请参阅 原子覆盖调试 部分。

AtomScope {
    CounterView()
}
.scopedObserve { snapshot in
    if let count = snapshot.lookup(CounterAtom()) {
        print(count)
    }
}

Suspense

Suspense 等待给定 Task 的结果值,并根据其阶段显示内容。
可选地,您可以传递 suspending 内容,以在任务完成之前显示,并传递 catch 内容,以在任务失败时显示。

struct NewsView: View {
    @Watch(LatestNewsAtom())
    var newsTask: Task<News, any Error>

    var body: some View {
        Suspense(newsTask) { news in
            Text(news.content)
        } suspending: {
            ProgressView()
        } catch: { error in
            Text(error.localizedDescription)
        }
    }
}

技术

Scoped Atom

此库的设计原则是共享状态作为单一真理来源,但状态也可以根据预期用途进行限定范围。
Scoped 原子在最靠近其使用位置的祖先的 AtomScope 中保留原子状态,并防止其在范围外共享。Scoped 是此功能的属性。

struct TextInputAtom: StateAtom, Scoped, Hashable {
    func defaultValue(context: Context) -> String {
        ""
    }
}

struct TextInputView: View {
    @Watch(TextInputAtom())
    ...
}

VStack {
    // The following two TextInputView don't share TextInputAtom state.

    AtomScope {
        TextInputView()
    }

    AtomScope {
        TextInputView()
    }
}

当嵌套多个 AtomScope 并且您想要在特定范围内存储和共享原子状态时,可以定义一个范围 ID,用于查找匹配的范围。

struct TextScopeID: Hashable {}

struct TextInputAtom: StateAtom, Scoped, Hashable {
    var scopeID: TextScopeID {
        TextScopeID()
    }

    func defaultValue(context: Context) -> String {
        ""
    }
}

AtomScope(id: TextScopeID()) {
    TextInputView()

    AtomScope {
        // Shares TextInputAtom state with the TextInputView placed in the parent scope.
        TextInputView()
    }
}

当堆叠多个相同的屏幕并且每个屏幕都需要隔离状态(例如用户输入)时,这也很有用。
请注意,依赖于 scoped 原子的其他原子将处于共享状态,并且也必须赋予 Scoped 属性才能将其也限定范围。

Atom Effect

原子效果是一种 API,用于管理与原子生命周期同步的副作用。通过观察和响应状态更改,它们广泛适用于各种用途,例如状态同步、状态持久性、日志记录等。

您可以创建符合 AtomEffect 协议的自定义效果,但也有几个预定义的效果。

API 用途
InitializeEffect 在原子初始化时执行任意操作。
UpdateEffect 在原子更新时执行任意操作。
ReleaseEffect 在原子释放时执行任意操作。
MergedEffect 将多个原子效果合并为一个。

原子效果通过 Atom.effect(context:) 函数附加到原子。

struct CounterAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        UserDefaults.standard.integer(forKey: "persistence_key")
    }

    func effect(context: CurrentContext) -> some AtomEffect {
        UpdateEffect {
            UserDefaults.standard.set(context.read(self), forKey: "persistence_key")
        }
    }
}

每个原子在原子初始化时初始化其效果,并且该效果会一直保留,直到原子不再从任何地方使用并被释放,因此它允许声明有状态的副作用。

struct CounterAtom: StateAtom, Hashable {
    func defaultValue(context: Context) -> Int {
        0
    }

    func effect(context: CurrentContext) -> some AtomEffect {
        CountTimerEffect()
    }
}


final class CountTimerEffect: AtomEffect {
    private var timer: Timer?

    func initialized(context: Context) {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            context[CounterAtom()] += 1
        }
    }

    func updated(context: Context) {
        print("Count: \(context.read(CounterAtom()))")
    }

    func released(context: Context) {
        timer?.invalidate()
        timer = nil
    }
}

Atom Override

您可以在 AtomRootAtomScope 中覆盖原子,以覆盖特定视图中依赖注入或伪造状态的原子状态,这对于测试尤其有用。

无论覆盖的原子在其后代视图中的何处使用,在 AtomRoot 中覆盖都会返回给定值,而不是实际的原子值。

// Overrides the CounterAtom value to be `456` in anywhere in the ancestor.
AtomRoot {
    RootView()
}
.override(CounterAtom()) { _ in
    456
}

另一方面,使用 AtomScope 进行覆盖的行为类似于在 AtomRoot 中覆盖,但后代中嵌套的其他范围中使用的原子不会被覆盖。

// Overrides the CounterAtom value to be `456` only for this scope.
AtomScope {
    CountDisplay()

    // CounterAtom is not overridden in this scope.
    AtomScope {
        CountDisplay()
    }
}
.scopedOverride(CounterAtom()) { _ in
    456
}

如果您想从父范围继承覆盖的原子,您可以显式传递在父范围中获得的 @ViewContext 上下文。然后,新范围完全继承父范围的上下文。

@ViewContext
var context

var body: some {
    // Inherites the parent scope's overrides.
    AtomScope(inheriting: context) {
        CountDisplay()
    }
}

请注意,AtomScope 中覆盖的原子会自动限定范围,但依赖于它们的其他原子将处于共享状态,并且必须赋予 Scoped 属性(另请参阅:Scoped Atom)以避免在范围外共享。

有关单元测试中依赖注入的详细信息,请参阅 测试 部分。

测试

此库自然地集成了依赖注入和数据绑定,以提供全面的测试方法。它允许您按小型原子进行测试,这样您就可以保持编写每个最小状态单元的简单测试用例,而无需将所有状态组合成一个庞大的对象并假设复杂的集成测试场景。
为了充分测试您的应用,此库保证以下原则

在测试用例中,您首先创建一个 AtomTestContext 实例,该实例的行为与其他上下文类型类似。上下文允许使用 Context 部分中描述的控制功能灵活地重现预期的测试场景。
此外,它还能够使用 override 函数将原子值替换为测试友好的依赖项。它可以帮助您编写可重现且稳定的测试。
由于原子需要从主线程使用以保证线程安全,因此测试原子的函数应具有 @MainActor 属性。

点击展开要测试的类
struct Book: Equatable {
    var title: String
    var isbn: String
}

protocol APIClientProtocol {
    func fetchBook(isbn: String) async throws -> Book
}

struct APIClient: APIClientProtocol {
    func fetchBook(isbn: String) async throws -> Book {
        ... // Networking logic.
    }
}

class MockAPIClient: APIClientProtocol {
    var response: Book?

    func fetchBook(isbn: String) async throws -> Book {
        guard let response else {
            throw URLError(.unknown)
        }
        return response
    }
}

struct APIClientAtom: ValueAtom, Hashable {
    func value(context: Context) -> any APIClientProtocol {
        APIClient()
    }
}

struct FetchBookAtom: ThrowingTaskAtom, Hashable {
    let isbn: String

    func value(context: Context) async throws -> Book {
        let api = context.watch(APIClientAtom())
        return try await api.fetchBook(isbn: isbn)
    }
}
class FetchBookTests: XCTestCase {
    @MainActor
    func testFetch() async throws {
        let context = AtomTestContext()
        let api = MockAPIClient()

        // Override the atom value with the mock instance.
        context.override(APIClientAtom()) { _ in
            api
        }

        let expected = Book(title: "A book", isbn: "ISBN000–0–0000–0000–0")

        // Inject the expected response to the mock.
        api.response = expected

        let book = try await context.watch(FetchBookAtom(isbn: "ISBN000–0–0000–0000–0")).value

        XCTAssertEqual(book, expected)
    }
}

调试

此库在内部定义了一个有向无环图 (DAG) 来集中管理原子状态,从而可以轻松分析其依赖关系以及它们在何处(或未在何处)被使用。
以下两种方法可以在给定时间点获取依赖关系图的 Snapshot

第一种是通过 @ViewContext 获取 Snapshot。此 API 适用于按需获取和分析调试信息。

@ViewContext
var context

var debugButton: some View {
    Button("Dump dependency graph") {
        let snapshot = context.snapshot()
        print(snapshot.graphDescription())
    }
}

或者,您可以观察所有状态更改,并始终继续接收该时间点的 Snapshots,方法是使用 AtomRootobserve(_:) 修饰符或 AtomScopescopedObserve(_:) 修饰符。
请注意,在 AtomRoot 中观察将接收整个应用程序中发生的每个状态更改,但在 AtomScope 中观察将观察范围内使用的原子的更改。

AtomRoot {
    HomeScreen()
}
.observe { snapshot in
    print(snapshot.graphDescription())
}

@ViewContext 还支持还原在检索到的快照及其依赖关系图中某个时间点捕获的原子值和依赖关系图,以便您可以调查发生了什么。
调试技术称为 时间旅行调试,示例应用程序 here 演示了它的工作原理。

@ViewContext
var context

@State
var snapshot: Snapshot?

var body: some View {
    VStack {
        Button("Capture") {
            snapshot = context.snapshot()
        }
        Button("Restore") {
            if let snapshot {
                context.restore(snapshot)
            }
        }
    }
}

此外,graphDescription() 方法返回一个字符串,该字符串以 graph description language DOT 的形式表示依赖关系图以及它们的使用位置。
这可以使用 Graphviz(一种图形可视化工具)转换为图像,以可视化方式分析有关应用程序状态的信息,如下所示。

Dependency Graph

digraph {
  node [shape=box]
  "FilterAtom"
  "FilterAtom" -> "TodoApp/FilterPicker.swift" [label="line:3"]
  "FilterAtom" -> "FilteredTodosAtom"
  "TodosAtom"
  "TodosAtom" -> "FilteredTodosAtom"
  "FilteredTodosAtom"
  "FilteredTodosAtom" -> "TodoApp/TodoList.swift" [label="line:5"]
  "TodoApp/TodoList.swift" [style=filled]
  "TodoApp/FilterPicker.swift" [style=filled]
}

预览

即使在 SwiftUI 预览中,视图也必须在其祖先的某个位置具有 AtomRoot
要注入依赖项以便显示静态预览,请将依赖项定义为原子并覆盖它们。

struct NewsList_Preview: PreviewProvider {
    static var previews: some View {
        AtomRoot {
            NewsList()
        }
        .override(APIClientAtom()) { _ in
            StubAPIClient()
        }
    }
}

有关依赖注入的更多详细信息,请参阅 Override Atoms 部分。


高级用法

在不监视的情况下使用原子

read(_:) 函数是一种获取原子数据的方式,无需监视它并接收其未来的更新。它通常在由调用操作触发的函数内部使用。

📖 示例
struct TextAtom: StateAtom, Hashable {
    func value(context: Context) -> String {
        ""
    }
}

struct TextCopyView: View {
    @ViewContext
    var context

    var body: some View {
        Button("Copy") {
            UIPasteboard.general.string = context.read(TextAtom())
        }
    }
}

动态启动原子族

每个原子都必须具有唯一的 key 才能与其值唯一关联。如 Atom 部分所述,它通过符合 Hashable 自动合成,但通过显式指定 key,您可以将任意外部参数传递给原子。它通常用于例如从服务器检索与动态指定的 ID 关联的用户信息。

📖 示例
struct FetchUserAtom: ThrowingTaskAtom {
    let id: Int

    // This atom can also conforms to `Hashable` in this case,
    // but this example specifies the key explicitly.
    var key: Int {
        id
    }

    func value(context: Context) async throws -> Value {
        try await fetchUser(id: id)
    }
}

struct UserView: View {
    let id: Int

    @ViewContext
    var context

    var body: some View {
        let task = context.watch(FetchUserAtom(id: id))

        Suspense(task) { user in
            VStack {
                Text("Name: \(user.name)")
                Text("Age: \(user.age)")
            }
        }
    }
}

在对象内部使用原子

您可以将上下文传递给您的对象,并在任何异步时序与其他原子交互。但是,在这种情况下,当调用 watch 时,最终结果是对象实例本身将被使用新数据重新创建。因此,您可以通过将其作为 AtomContext 类型传递来显式阻止使用 watch

📖 示例
struct MessageLoaderAtom: ObservableObjectAtom, Hashable {
    func object(context: Context) -> MessageLoader {
        MessageLoader(context: context)
    }
}

@MainActor
class MessageLoader: ObservableObject {
    let context: AtomContext

    @Published
    var phase = AsyncPhase<[Message], any Error>.suspending

    init(context: AtomContext) {
        self.context = context
    }

    func load() async {
        let api = context.read(APIClientAtom())
        phase = await AsyncPhase {
            try await api.fetchMessages(offset: 0)
        }
    }

    func loadNext() async {
        guard let messages = phase.value else {
            return
        }

        let api = context.read(APIClientAtom())
        let nextPhase = await AsyncPhase {
            try await api.fetchMessages(offset: messages.count)
        }
        phase = nextPhase.map { messages + $0 }
    }
}

处理已知的 SwiftUI 错误

模态演示在关闭时导致 assertionFailure(在 iOS15 中已修复)

不幸的是,SwiftUI 在 iOS14 或更低版本中存在一个错误,其中 EnvironmentValue 在使用 .sheet 呈现的屏幕即将关闭之前被移除。由于此库是基于 EnvironmentValue 设计的,因此此错误最终会触发友好的 assertionFailure,添加该错误是为了让开发人员可以轻松意识到忘记实现的 AtomRoot
作为一种解决方法,AtomScope 能够通过父视图中的 AtomViewContext 显式继承存储。

💡 点击展开解决方法
struct RootView: View {
    @State
    var isPresented = false

    @ViewContext
    var context

    var body: some View {
        VStack {
            Text("Example View")
        }
        .sheet(isPresented: $isPresented) {
            AtomScope(inheriting: context) {
                MailView()
            }
        }
    }
}

一些 SwiftUI 修饰符导致内存泄漏(在 iOS16 中已修复)

在 iOS 15 或更低版本中,SwiftUI 中的某些修饰符如果隐式或显式捕获 self,似乎会导致内部内存泄漏。为避免该错误,请确保在使用这些修饰符时未捕获 self
以下是我发现会导致内存泄漏的修饰符列表

💡 点击展开解决方法
@ViewContext
var context

...

.refreshable { [context] in
    await context.refresh(FetchDataAtom())
}
@State
var isShowingSearchScreen = false

...

.onSubmit { [$isShowingSearchScreen] in
    $isShowingSearchScreen.wrappedValue = true
}

贡献

欢迎任何类型的贡献!例如


致谢


许可证

MIT © Ryo Aoyama