Combine #15:在 SwiftUI 中使用 Combine (1)

发布: (2026年1月10日 GMT+8 00:42)
11 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本内容,我将按照要求保留源链接、格式和代码块,仅翻译正文部分。

与 UIKit 的比较

在 UIKit 中,你可能会这样写:

// Modelo
var conditions: String

// Vista
let label = UILabel()
label.text = conditions   // copia de `conditions`
  • label.textconditions 的副本。
  • 必须在 conditions 变化时 手动更新 label.text

在 SwiftUI 中,视图可以 直接 使用数据模型的信息,无需复制或手动更新。

注意:我不确定直接耦合到另一层的信息到底有多好。不可变性、受保护的变体以及层之间的解耦概念对于构建灵活的系统非常有益。毫无疑问,我承认不复制信息非常重要,使用响应式编程也很方便,但我并不认为这一定是将系统层耦合在一起的动机。
另一方面,谁说在 UIKit 中必须复制数据?

ViewController 中的代码缩减

由于没有用于连接层的代码,ViewController 可以更小。

注意:这点有争议。我认为这并非 SwiftUI 独有的特性,而更像是所选架构的结果。

Source:

SwiftUI 基本示例

下面的视图在屏幕中心显示文本 “Hola”,并在右上角放置一个 “Settings” 按钮,用于呈现另一个覆盖在上面的屏幕。

struct PoCView: View {
    // var sheetVisible = false
    var body: some View {
        NavigationView {
            VStack {
                Text("Hola")
            }
            .navigationBarItems(trailing:
                Button("Settings") {
                    // sheetVisible = true
                }
            )
        }
    }
}

为什么会出现错误 “Cannot assign to property: ‘self’ is immutable”

  • body 是一个 计算属性,它不能更改结构体的存储状态,因为它本质上是不可变的。
  • 使用 mutating 只对 方法func)有效。
  • 从计算属性中修改结构体的存储状态在语言层面上是不可行的。

解决方案:在视图之外存储状态

创建一个 State 实例(它具有 wrappedValue 属性),并在 body 中修改该状态。

struct PoCView: View {
    var sheetVisible = State(initialValue: false)

    var body: some View {
        NavigationView {
            VStack {
                Text("Hola")
            }
            .navigationBarItems(trailing:
                Button("Settings") {
                    sheetVisible.wrappedValue = true
                }
            )
        }
    }
}

sheetVisibletrue 时显示一个 sheet(弹出视图)

要检测 wrappedValue 的变化,我们需要一个 PublisherState 提供了一个名为 projectedValue 的 Publisher。

struct PoCView: View {
    var sheetVisible = State(initialValue: false)

    var body: some View {
        NavigationView {
            VStack {
                Text("Hola")
            }
            .sheet(isPresented: self.sheetVisible.projectedValue) {
                Text("Chao")
            }
            .navigationBarItems(trailing:
                Button("Settings") {
                    sheetVisible.wrappedValue = true
                }
            )
        }
    }
}

属性包装器 @State

此实现如此常见,以至于 SwiftUI 引入了属性包装器 @State

  • 访问 wrappedValue → 通过属性名 (sheetVisible)。
  • 访问 projectedValue(Publisher) → 通过前缀 $ ($sheetVisible)。
  • 访问完整实例 → 通过前缀 _ (_sheetVisible)。
struct PoCView: View {
    @State var sheetVisible = false

    var body: some View {
        NavigationView {
            VStack {
                Text("Hola")
            }
            .sheet(isPresented: self.$sheetVisible) {
                Text("Chao")
            }
            .navigationBarItems(trailing:
                Button("Settings") {
                    sheetVisible = true
                }
            )
        }
    }
}

使用 Combine 下载内容

api.stories()
    .receive(on: DispatchQueue.main)
    .sink { completion in
        if case .failure(let error) = completion {
            self.error = error
        }
    } receiveValue: { stories in
        self.allStories = stories
        self.error = nil
    }
    .store(in: &subscriptions)
  • 使用 api.stories() 创建 Publisher
  • 指定应在 主线程 接收值。
  • 使用 sink 进行订阅,同时处理 完成 和接收到的
  • 将订阅保存在 subscriptions 中,以便以后取消。

ObservableObject 用于数据模型

ObservableObject 会在其 @Published 属性发生变化时通过 objectWillChange 发出通知。这使得 SwiftUI 视图在模型改变时能够自动重新渲染。

class StoriesViewModel: ObservableObject {
    @Published var allStories: [Story] = []
    @Published var error: Error?

    private var subscriptions = Set()

    init() {
        loadStories()
    }

    func loadStories() {
        api.stories()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let err) = completion {
                    self?.error = err
                }
            } receiveValue: { [weak self] stories in
                self?.allStories = stories
                self?.error = nil
            }
            .store(in: &subscriptions)
    }
}

在视图中使用 ObservableObject

struct StoriesView: View {
    @ObservedObject var viewModel = StoriesViewModel()

    var body: some View {
        List(viewModel.allStories) { story in
            Text(story.title)
        }
        .alert(item: $viewModel.error) { error in
            Alert(title: Text("Error"), message: Text(error.localizedDescription))
        }
    }
}
  • @ObservedObject 移除视图内部的存储,并创建对模型的 binding
  • 此外,它会为属性添加一个 Publisher,以便在视图层级中进行订阅或绑定。

摘要

概念功能在 SwiftUI 中的使用方式
状态唯一的真相来源@State var isOn = false
@State提供 wrappedValueprojectedValue 的包装器$isOn 用于绑定,isOn 用于读写
ObservableObject在数据模型中通知更改@ObservedObject var vm = MyViewModel()
Combine对流的响应式处理api.stories().receive(on:...).sink{...}
sheet(isPresented:)基于 Bool 显示模态视图.sheet(isPresented: $showSheet) { … }

使用这些概念,你可以在 SwiftUI 中构建声明式、响应式且高度解耦的界面,充分利用 Combine 生态系统。

ReaderView – 错误处理、订阅和环境在 SwiftUI 中

模型和视图

class ReaderViewModel: ObservableObject {
    @Published var error: API.Error? = nil
    @Published private var allStories = [Story]()
    // …
}
struct ReaderView: View {
    @ObservedObject var model: ReaderViewModel
    // …
}

error 不为 nil 时显示警报

废弃的代码(但仍可工作):

struct ReaderView: View {
    var body: some View {
        // …
        .alert(item: self.$model.error) { error in
            Alert(
                title: Text("Network error"),
                message: Text(error.localizedDescription),
                dismissButton: .cancel()
            )
        }
    }
}

订阅任意 Publisher

并不一定要使用 ObservableObject / ObservedObject 这一对。
可以通过 onReceive(_:perform:) 将视图订阅到任意 Publisher

使用 Timer 的示例

private let timer = Timer.publish(every: 10, on: .main, in: .common)
    .autoconnect()
    .eraseToAnyPublisher()
@State var currentDate = Date()          // 视图的可变状态

// 在视图中:
.onReceive(timer) { self.currentDate = $0 }
struct ReaderView: View {
    var body: some View {
        // …
        PostedBy(time: story.time,
                 user: story.by,
                 currentDate: self.currentDate)
        // …
    }
}

注意: PostedBy 每当 Timer 发出事件时都会重新绘制。

使用 Settings 的响应式过滤

Settings 存储关键字(keywords),在修改时应发出事件。

final class Settings: ObservableObject {
    @Published var keywords = [FilterKeyword]()
}

SettingsReaderViewModel 的连接

  1. ReaderViewModel.filter 订阅到 Settings.keywords 的 Publisher。
  2. filter 暴露为 @Published,以便视图更新。
class ReaderViewModel: ObservableObject {
    @Published var filter = [String]()
    // …
}
struct HNReader: App {
    let viewModel = ReaderViewModel()
    let userSettings = Settings()
    private var subscriptions = Set()

    init() {
        // 创建 filter 对 keywords 的订阅
        userSettings.$keywords
            .map { $0.map { $0.value } }
            .assign(to: \.filter, on: viewModel)
            .store(in: &subscriptions)
    }
    // …
}

使用 SwiftUI 的 Environment

Environment 是一个 Publisher(例如 colorSchemelocale、时区等),会自动注入到视图层级中。

根据颜色方案更改链接颜色

class ReaderViewModel: ObservableObject {
    @Environment(\.colorScheme) var colorScheme: ColorScheme
    // …
        .foregroundStyle(colorScheme == .light ? .blue : .orange)
}

提示: 若要实时查看更改,请启用
Debug ▶ View Debugging ▶ Configure Environment Overrides

@EnvironmentObject – 全局对象注入

@EnvironmentObject 允许在 整个 视图层级中注入 任意 对象。

WindowGroup {
    ContentView()
        .environmentObject(Settings(theme: "Light"))   // 第一个对象
        .environmentObject(Settings(theme: "Dark"))   // 替换前一个
}
  • 不需要指定 key‑path;SwiftUI 会按 类型 匹配。
  • 同一类型的 EnvironmentObject 不能出现两个

解决方案

1️⃣ 使用不同类型

final class UserSettings: ObservableObject {  }
final class AdminSettings: ObservableObject {  }

.environmentObject(UserSettings())
.environmentObject(AdminSettings())

// 在视图中:
@EnvironmentObject var userSettings: UserSettings
@EnvironmentObject var adminSettings: AdminSettings

2️⃣ 定义自定义 EnvironmentKeys

(此处未展示,但也是一种替代方案)。

列表重新排序

SwiftUI 提供 onMove(perform:) 用于在数组中移动元素。

.onMove { (indices: IndexSet, newOffset: Int) in
    fruits.move(fromOffsets: indices, toOffset: newOffset)
}
  • 如果将元素拖到末尾,newOffset 将大于数组的大小,表示“在最后一个元素之后”。
  • move(fromOffsets:toOffset:) 保持其余元素的顺序。

示例代码

// move(fromOffsets:toOffset:) 使用 swapAt(_:_:)
// 当目标位置大于数组中元素的数量时(在已移除该元素之后),需要将元素取出并插入到末尾
// (在已移除该元素之后)。

let source = 0
let destination = 3   // 来自 SwiftUI
let element = fruits.remove(at: source)

if destination > fruits.count {
    fruits.append(element)
} else {
    fruits.insert(element, at: destination)
}

未回答的问题

  • 视图层次结构的根视图。

  • 系统状态唯一存储和更新的点。

  • 第一个连接到视图的 Publisher。

  • 应用程序的主 ViewModel。
    body 是什么?

  • 因为 body 是类方法,无法访问实例变量。

  • 因为 body 是计算属性,而结构体是不可变的。

  • 因为 body 属于主线程。

  • 因为编译器不允许在 body 中使用闭包。

SwiftUI 中的 @State

  • 将属性转换为全局 Publisher。
  • 在子视图之间创建共享引用。
  • 允许修改视图外部存储的值,并对其变化作出响应。
  • 将变量转换为不可变常量。

ObservableObject

  • 在后台渲染视图。
  • 当标记为 @Published 的属性更改时发出事件。
  • 封装 SwiftUI 环境的依赖。
  • 在多个 @State 之间同步更新。

SwiftUI 中的 @ObservedObject@State

  • @ObservedObject 在视图重新创建时保持其值,而 @State 不会。
  • @State 观察外部模型,而 @ObservedObject 保持自己的状态。
  • @ObservedObject 观察外部模型(如 ViewModel),而 @State 处理视图的内部状态。
  • 它们等价;两者都会自动生成 Publisher。

视图层次结构中相同类型的 @EnvironmentObject

  • SwiftUI 为每个子视图创建一个副本。
  • SwiftUI 使用在树中找到的最后一个。
  • SwiftUI 会抛出…(原文不完整)
Back to Blog

相关文章

阅读更多 »

Combine #13:资源管理

share 和 multicast_: share 大多数 Combine 的 Publisher 是 struct,只是描述一个 pipeline,而不保存共享状态。不会创建一个 i...

Combine #6:时间操控操作员

时间延迟 delayfor:tolerance:scheduler:options: https://developer.apple.com/documentation/combine/publisher/delayfor:tolerance:scheduler:optio...

Swift Combine 中的热与冷发布者

什么是 Hot 和 Cold Publisher? Cold Publisher Cold Publisher 为每个订阅者创建一个新的执行。当你订阅时,工作会重新开始。swift...