SwiftUI 的原子方法状态管理和依赖注入
响应式数据绑定 | 有效的缓存 | 编译安全 依赖注入 |
---|---|---|
可以从任何地方访问的应用数据片段,能够响应式地传播更改。 | 在使用期间缓存数据,仅在真正需要时重新计算。 | 成功的编译保证依赖注入已准备就绪。 |
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()
}
}
}
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
---|
每个示例也都有一个测试目标,以演示如何使用依赖注入来测试您的原子。
打开 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 菜单:File
> Swift Packages...
https://github.com/ra1028/swiftui-atom-properties
在您的 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 参考。
此视图允许后代视图使用原子。它必须是整个应用程序中任何视图的根。
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
AtomRoot {
ExampleView()
}
}
}
}
原子代表一个状态片段,并且是您应用的真理来源。它也可以通过组合和转换一个或多个其他原子来表示派生数据。
每个原子实际上都没有内部全局数据,而是从 AtomRoot
提供的存储中检索值。这就是为什么它们可以从任何地方访问,但永远不会失去可测试性。
原子及其值使用唯一的 key
关联,如果原子符合 Hashable
,则会自动定义该 key
,但您也可以在不使用 Hashable 的情况下显式定义它。
struct UserNameAtom: StateAtom {
let userID: Int
var key: Int {
userID
}
func defaultValue(context: Context) -> String {
"Robert"
}
}
为了为结果值的类型提供最佳界面和有效的数据绑定,有以下几种原子变体。
描述 | |
---|---|
摘要 | 提供只读值。 |
输出 | 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)
}
}
描述 | |
---|---|
摘要 | 提供读写数据。 |
输出 | 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)
}
}
描述 | |
---|---|
摘要 | 从给定的 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")
}
}
}
描述 | |
---|---|
摘要 | 从给定的 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)
}
}
}
}
描述 | |
---|---|
摘要 | 提供一个 AsyncPhase 值,该值表示给定异步可抛出函数的结果。 |
输出 | AsyncPhase<T, E: Error> (Swift 5 中的 AsyncPhase<T, any Error> ) |
用例 | 抛出或非抛出的异步操作,例如,API 调用 |
注意
类型化 throws 功能在 Swift 6 中引入,允许将生成的 AsyncPhase
的 Failure
类型指定为任何类型,甚至是非抛出类型,但在没有它的 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)
}
}
}
}
描述 | |
---|---|
摘要 | 提供一个 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")
}
}
}
描述 | |
---|---|
摘要 | 提供一个 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))
}
}
}
描述 | |
---|---|
摘要 | 实例化一个可观察对象。 |
输出 | 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()
}
}
}
}
修饰符可以应用于原子以生成原始原子的不同版本,使其更易于编码或减少视图重新计算以优化性能。
描述 | |
---|---|
摘要 | 从原始原子派生具有指定键路径的部分属性,并在其新值等效于旧值时阻止其更新下游。 |
输出 | 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)
}
}
描述 | |
---|---|
摘要 | 当原子的新值与其旧值相同时,阻止原子更新其子视图或原子。 |
输出 | 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)
}
}
描述 | |
---|---|
摘要 | 当值更新时,为观看原子的视图添加动画效果。 |
输出 | T |
兼容 | 所有原子类型。 |
用例 | 将动画应用于视图 |
📖 示例
struct TextAtom: ValueAtom, Hashable {
func value(context: Context) -> String {
""
}
}
struct ExampleView: View {
@Watch(TextAtom().animation())
var text
var body: some View {
Text(text)
}
}
描述 | |
---|---|
摘要 | 将原始原子提供的 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.")
}
}
}
属性允许控制原子的基本工作方式,例如,状态的缓存控制。
Scoped
在最靠近其使用位置的祖先的范围内保留原子状态,并防止其在范围外共享。
📖 示例
在下面的示例案例中,每个 SearchPane
都使用为每个范围隔离的 SearchQueryAtom
状态。
struct SearchQueryAtom: StateAtom, Scoped, Hashable {
func defaultValue(context: Context) -> String {
""
}
}
VStack {
AtomScope {
SearchPane()
}
AtomScope {
SearchPane()
}
}
KeepAlive
允许原子即使在任何地方都不再被监视的情况下也保留其数据。
📖 示例
在下面的示例案例中,一旦从服务器获得主数据,就可以将其缓存在内存中,直到应用程序进程终止。
struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable {
func value(context: Context) async throws -> MasterData {
try await fetchMasterData()
}
}
Refreshable
允许您为原子实现自定义的可刷新行为。
📖 示例
它为 ValueAtom
添加了自定义刷新行为,而 ValueAtom
本质上无法刷新。
当需要具有任意刷新行为或在值依赖于私有原子时实现刷新时,它很有用。
在此示例中,FetchMoviesPhaseAtom
透明地将 FetchMoviesTaskAtom
的值公开为 AsyncPhase
,以便可以轻松地在原子内部处理错误,并且 Refreshable
为 FetchMoviesPhaseAtom
本身提供了刷新行为。
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
允许您为原子实现自定义的重置行为。
📖 示例
它为原子添加了自定义重置行为,该行为将在原子重置时执行。
当需要具有任意重置行为或在值依赖于私有原子时实现重置时,它很有用。
在以下示例中,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()
}
}
以下属性包装器用于将原子绑定到视图,并使用数据更改重新计算视图。
通过属性包装器检索原子,内部系统会将原子标记为正在使用中,并且这些值会被缓存,直到该视图被拆除。
描述 | |
---|---|
摘要 | 此属性包装器类似于 @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)")
}
}
描述 | |
---|---|
摘要 | 此属性包装器是读写的,接口与 @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 = ""
}
}
}
}
描述 | |
---|---|
摘要 | 此属性包装器具有与 @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)
}
}
}
}
与上述描述的属性包装器不同,此属性包装器并非旨在绑定单个原子。它为视图提供了一个 AtomViewContext
,从而可以对原子进行更功能性的控制。
例如,以下控制只能通过上下文完成。
refresh(_:)
运算符用于重置异步原子值并等待其完成。await context.refresh(FetchMoviesAtom())
reset(_:)
运算符用于清除当前原子值。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()
}
}
}
上下文是用于从视图或其他原子中使用原子值并与之交互的结构。
API | 用途 |
---|---|
watch(_:) | 获取原子值并开始监视其更新。 |
read(_:) | 获取原子值,但不监视其更新。 |
set(_:for:) | 为原子设置新值。 |
modify(_:body:) | 修改缓存的原子值。 |
subscript[] | 用于应用可变方法的读写访问。 |
refresh(_:) | 在等待异步操作完成后,生成原子的新值。 |
reset(_:) | 将原子重置为默认值或第一个输出。 |
上下文根据提供它们的环境,以以下类型提供。除了上面描述的通用 API 之外,每种上下文类型可能都有其独特的功能。
当从视图中使用原子时,通过 @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)
}
}
}
}
}
}
作为参数传递给每种原子类型的主要函数的上下文。
📖 示例
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
}
}
一个上下文,可以模拟从视图或其他原子中使用原子的任何场景,并提供全面的测试方法。
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)
}
}
AtomScope
允许您监视更改或覆盖后代视图中使用的原子。与 AtomRoot
不同,它们仅影响范围内的原子。
有关具体用途,请参阅 原子覆盖 和 调试 部分。
AtomScope {
CounterView()
}
.scopedObserve { snapshot in
if let count = snapshot.lookup(CounterAtom()) {
print(count)
}
}
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 原子在最靠近其使用位置的祖先的 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
属性才能将其也限定范围。
原子效果是一种 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
}
}
您可以在 AtomRoot 或 AtomScope 中覆盖原子,以覆盖特定视图中依赖注入或伪造状态的原子状态,这对于测试尤其有用。
无论覆盖的原子在其后代视图中的何处使用,在 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
,方法是使用 AtomRoot 的 observe(_:)
修饰符或 AtomScope 的 scopedObserve(_:)
修饰符。
请注意,在 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(一种图形可视化工具)转换为图像,以可视化方式分析有关应用程序状态的信息,如下所示。
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 在 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()
}
}
}
}
在 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
}
欢迎任何类型的贡献!例如