跳到主要内容

SwiftUI 常被介绍成「React,但 views 用 Swift 写」。这跳过了为什么 View struct 每次 update 都会被重建却不丢失 @State、为什么没有稳定 id 的 ForEach 会像缺了 key 一样打乱 rows、为什么 .onAppear 不是 useEffect([]),或为什么 Swift 里的 async/await 会发生 data-race,而 JavaScript 里同一套写法不会。

这个技术栈通过 Expo 交付 React Native。这篇文章是阅读 SwiftUI 时用的对照。它假设你已经把 React Native 理解成 React 的 host renderer:Fibers、commit、Fabric、Yoga,以及一条对 gestures 来说太晚的 JS thread。那个模型是 深入理解 React Native。你已经理解 native view tree。现在不靠 React 来写它。


text
RN:  setState → React render (Fibers) → commit → Fabric → UIView
SwiftUI: state mutation → invalidate body → value tree → diff by identity → UIView

示例针对 iOS 17+ 的 Observation(@Observable、@State、@Binding、@Environment)以及 Swift 6 的 isolation(MainActor、Task cancellation)。React Native 示例对齐姊妹篇里的 Expo SDK 57 line。Combine 的 ObservableObject / @ObservedObject 只出现一次,作为你在教程里仍会看到的旧路径。

四种陈述:

  • 一份 React Native 契约,例如 <View> 与 <Text> 是不同的 host components,或 Fiber 上的 key identity。
  • 一份 SwiftUI 契约,例如 View 是一份 value description,或 @State 活在 framework 的 identity slot 里而不是 struct 上。
  • 一条 Swift 语言规则,例如 structs 在赋值时 copy,或 async functions 跑在它们继承到的 executor 上,直到你 hop。
  • 一项 实现观察,例如 List 包着 UICollectionView。应用代码不得依赖它们。

贯穿全文的是一个小的 notes list → detail screen:fetch、下拉刷新、push 一条 detail route。


1. 这篇文章是什么

React Native 工程师已经有对的抽象:一棵 views 的 tree、会 invalidate 那棵 tree 的 state、layout、lazy lists、一条 native stack、不能等 JavaScript 的 gestures,以及带 cancellation 的 async 工作。SwiftUI 把每一项 remap 到不同的 runtime。

  • 先描述,再让 framework 更新 host。 JSX 返回 elements。body 返回 some View。两者都不是屏幕上的 UIView。
  • Identity 决定哪些 state 能活下来。 React 在 Fiber 上用 type + key。SwiftUI 用 structural position,再加上显式的 id: / ForEach ids。
  • Lists 是 lazy 的,navigation 是 native 的,layout 是单独的一趟。 FlashList、expo-router 的 native stack,以及 Yoga,都有 SwiftUI 对应物。它们不是同一套 algorithms。
  • 带不走的是 runtime。没有 Hermes,没有 Fabric shadow tree,没有 Yoga,也没有可以 OTA 替换的 Metro bundle。SwiftUI 被编译进 IPA。Update loop 是 framework 拥有的 value-tree diff,不是 React 的 render/commit。

这篇文章不会走 Xcode、signing 或 App Store。它不会教 UIKit wrapping、The Composable Architecture、SwiftData,或把 Combine 当默认 state 路径。


2. Runtime 不是 React

React Native 的 loop 是 React 的 render 与 commit 加上一个 host renderer。Setter 会 schedule 工作。React reconcile Fibers。Fabric mount 或 update UIView instances。不能掉帧的 gestures 离开那条 loop,通过 Reanimated 或 Gesture Handler 跑在 UI thread 上。

SwiftUI 的 loop 没有 Fiber,没有 JS thread,也没有 Yoga。View 是一个 value。当 view 读到的 state 改变时,SwiftUI 会 invalidate 那个 view,要一份新的 body,按 identity 把新的 value tree 与上一份 diff,然后更新底层的 UIKit attributes。重建 struct 是预期的,而且便宜。


text
React Native:
  useState setter → React render (Fibers) → Commit → Fabric mount → UIView

SwiftUI:
  State or Observable mutation → Invalidate views → Recompute body → Diff by identity → Update UIView

  • body 与 render() 都是描述。差别在 ownership。React 为每个 component instance 拥有一条 Fiber,并把 Hooks 存在上面。SwiftUI 拥有一张 identity map,并把 @State 存在那里。你的 struct 是那张 map 的 input,不是 instance。
  • SwiftUI 仍坐在 UIKit 上(UIView、UICollectionView、UINavigationController)。写 VStack 不会给你另一条 pixels pipeline。你得到的是不同的 description language 与不同的 identity/state model。

失败: 以为「没有 UIKit」能帮你读 stack trace。它帮不上。


3. React Native 工程师会踩的 Swift 陷阱

JavaScript values 除非是 primitives,否则是 references。突变 object 的一个 field,每个 alias 都看得见。Swift 对你放进 View 的数据默认相反:struct 是 value。赋值会 copy。对 copy 的 mutation 不会改到原本。


swift
struct Note {
  var id: String
  var title: String
}

var a = Note(id: "1", title: "Draft")
var b = a
b.title = "Published"
// a.title == "Draft"

  • View 是 structs 所 adopt 的 protocol。Framework 建立在便宜的 copies 上,而不是长寿的 component instances。
  • class 是 reference type。@Observable models 是 classes,因为 store 必须被共享并原地突变。读 store 的 view 仍然是 struct。
  • let vs var 不是 TypeScript 里的 const vs let。let 绑定一个不能被重新赋值的名字。let struct 不能突变它的 properties。let class reference 不能指向另一个 instance,但那个 instance 的 var properties 仍然可以改。
  • Optionals(String?、Note?)是一种 type,不是 runtime 的 undefined。你 unwrap 它们(if let、guard let、??),而不是检查 == null。SwiftUI 大量用 optionals 做 presentation:.sheet(item:) 接受 Binding<Item?>,在它非 nil 时 present。

失败: 把 @State 读成对 immutable struct 的魔法 mutation。Property wrapper 在跟 SwiftUI 的 identity slot 说话。你在 body 里看到的 struct 是一份新鲜的 value,它 project 那个 slot。


4. View 是描述,不是 Component Instance

Function component 是从 props(以及 Hook state)到 React element 的函数。SwiftUI view 是一个 conform 到 View 的 struct,并暴露 body。


swift
struct NoteRow: View {
  let title: String

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

  • 两者都是 pure descriptions。构造 NoteRow(title:) 不会 mount 一个 UIView。它产生一份 framework 将 reconcile 的 value。
  • body 是 computed property,不是你去调用的 function。SwiftUI 调用它。body 里的 side effects 与 React render 期间的 side effects 是同一类 bug。
  • some View 是 opaque type。 Compiler 知道具体的 nested type。Callers 不知道。它不是 ReactElement。它不是 any View。AnyView 存在,但会付出 identity 与 performance。Prefer some View。
  • ViewBuilder 是 result builder,这个 compiler feature 让你能写 VStack { Text("A"); Text("B") } 而不必返回 array。Builder 里的 if / switch 会改变 structural identity。
  • 每次 invalidation 都重建 struct,正是重点。没有「活过这次 setState 的 component instance」这种等价物。便宜的 value,持久的 identity slot。

Xcode Previews 不是 Fast Refresh。Fast Refresh 替换一个 JS function 并试图保住 Hook state。Preview 在 canvas 里重新实例化 view tree。


5. Identity 不是 Fiber Identity

React 的公开 identity 规则是 type + position,被 key 覆盖。State 活在 Fiber 上。SwiftUI 的公开 identity 规则是 structural identity(view 在 body tree 里坐在哪里),被显式 identity 覆盖:ForEach 的 id、.id(_:),以及 identifiable navigation values。

@State 不存在 struct 上。Struct 会被 copy。SwiftUI 把 state 存在一个由该 view 的 identity 作为 key 的 slot 里。若 identity 稳定,slot 能活过一份新的 body。若 identity 改变,slot 是新的,state 会 reset。若两行共享一个 identity,它们共享一个 slot——跟重复 key 是同一个 bug。


swift
ForEach(notes) { note in
  NoteRow(title: note.title)
}

// Note: Identifiable, or:
ForEach(notes, id: \.id) { note in
  NoteRow(title: note.title)
}

  • 稳定的 ids 让 state 与 animations 留在对的 row 上。Index-as-id 跟 key={index} 是同一个陷阱。
  • ViewBuilder 里的 if / else 是 type-level branch。 SwiftUI 看到的是两个不同的 structural positions,不是一个 props 变了的 view。SwiftUI 里的条件 if 更接近互换 {condition ? <A /> : <B />} 且没有 keys,只是 framework 对此更严格。
  • .id(newValue) 强制一份新 identity。 在你确实意思是 remount 时再用。
  • 带 range 的 ForEach(ForEach(0..<count))是 index identity。Prefer 带 ids 的 data。

一行的 @State(swipe offset、expanded flag)必须跟着 note.id,而不是该行当前在 array 里的 index。

失败: 用 .id(newValue)「reset 一个 form」,结果每个 keystroke 都意外 remount。


6. State

React state 是 Fiber 上的 Hook slot。SwiftUI state 是 identity 上的 slot。

RoleReact NativeSwiftUI (iOS 17+)
Local UI stateuseState@State
Controlled childvalue + onChange@Binding ($state)
Shared screen modelmodule store, Context, Zustand@Observable class, often owned with @State, passed or put in Environment
Read-only dependencyContext@Environment / @Environment(\.dismiss)

swift
struct NoteEditor: View {
  @State private var title = ""

  var body: some View {
    TextField("Title", text: $title)
  }
}

struct TitleField: View {
  @Binding var title: String

  var body: some View {
    TextField("Title", text: $title)
  }
}

  • $title 是 Binding<String>——value 加上 setter。把 $title 传给 child 是反过来的 lifting:parent 拥有,child 透过它写入。
  • Screen 的 notes array 不属于每一行上的 @State。它属于一个标了 @Observable 的 class。View 用 @State 拥有 它(iOS 17 对 Observable instances 的规则)或接收它。
  • SwiftUI 跟踪 body 读了 哪些 properties。突变 isLoading 不会 invalidate 一个只读了 notes 的 view。这比典型的 React context value 更细。
  • @Environment(NotesStore.self) 是不靠 props 把 store 往下传的方式。它是 Context,不是 Redux。没有 reducer 要求。
  • Observation 之前的代码在 Combine 的 objectWillChange 上用 ObservableObject、@Published、@StateObject(owner)与 @ObservedObject(child)。@StateObject 不是 @State。@ObservedObject 不拥有 object;若 parent 重建它,你会 reset。iOS 17+ 的新代码不应从那里起步。

7. Layout 不是 Yoga

Yoga 是 flexbox:flexDirection、justifyContent、alignItems、flex: 1、margins。SwiftUI layout 是一套 proposal protocol。Parent 提出一个 size。Child 选择一个 size(最多是 proposal,除非它忽略)。Parent 再放置 child。HStack、VStack 与 ZStack 是三条 composition axes。ZStack 是 overlay,不是第三种 flex direction。


swift
struct NoteCard: View {
  let title: String
  let excerpt: String

  var body: some View {
    HStack(alignment: .top, spacing: 12) {
      VStack(alignment: .leading, spacing: 4) {
        Text(title).font(.headline)
        Text(excerpt).foregroundStyle(.secondary)
      }
      Spacer()
    }
    .padding(16)
  }
}

  • Spacer() 一般不是 sibling 上的 flex: 1。 它沿 stack 的 axis 扩张,吃掉剩下的 proposed space。Text column 上剩下的 width,往往是在 VStack 上用 .frame(maxWidth: .infinity, alignment: .leading),而不是在它后面放 spacer。
  • .frame(width:height:) 是一份 proposal,然后是一个 size。 maxWidth: .infinity 的意思是「拿 parent 提出的无论多少」。
  • Safe area 默认开着。 SwiftUI 在它里面 layout。.ignoresSafeArea() 选择退出。RN 默认的 View 不会 inset;你加上 SafeAreaView 或 useSafeAreaInsets()。Defaults 是反的。
  • 没有 StyleSheet。 Modifiers wrap 这份 value。Order 重要:.padding().background() 不是 .background().padding()。那更接近 nested Views,而不是一份扁平的 style object。

两套 stack 都不要每帧 animate height。Prefer transforms 与 opacity。


8. Lists

FlatList / FlashList recycle cells。ScrollView + map 不会。SwiftUI 的 List 是 lazy 的。List 里的 ForEach 是 lazy 的。普通 ScrollView 里的 ForEach 不是,除非你改成 LazyVStack / LazyHStack。


swift
List(notes) { note in
  NoteRow(title: note.title)
}

ScrollView {
  LazyVStack {
    ForEach(notes) { note in
      NoteRow(title: note.title)
    }
  }
}

  • List 带上 platform styling(inset grouped、separators、swipe actions)。FlashList 是一块空白的 recycling surface。若你想要自定义 feed,ScrollView + LazyVStack 比 List 更接近 FlashList。
  • 一行上的 onAppear 在该行被 realized 时触发,不是 screen mount 时。那是 viewability,不是 list screen 上的 useEffect。
  • 下拉刷新是 list 上的 .refreshable,相对 FlatList 上的 RefreshControl。两者都是 UI-thread 上的 platform controls。Main actor 上一次 synchronous load 仍会挡住 UI。

失败: Identity bugs 表现为错的 row 在 animate。修 id,不是修 animation。


9. Navigation

expo-router 把 app/ 下的 files 对应到一条 native stack。URLs、deep links 与 layouts 是 Expo 契约。Transition 仍跑在 UINavigationController 上。SwiftUI 的 NavigationStack 也是一条 native stack。除非你自己建,它不是一棵 URL tree。你 push 的 value 就是 route。navigationDestination(for:) 是 registrar。


swift
NavigationStack {
  List(notes) { note in
    NavigationLink(value: note) {
      Text(note.title)
    }
  }
  .navigationDestination(for: Note.self) { note in
    NoteDetailView(note: note)
  }
}

Note 必须是 Hashable(通常也是 Identifiable)才能活在 path 上。

Jobexpo-routerSwiftUI
Stackapp/**/_layout.tsx + <Stack />NavigationStack
Pushrouter.push('/notes/1')NavigationLink(value:) or path.append
Typed screenapp/notes/[id].tsx.navigationDestination(for: Note.self)
Modalpresentation: 'modal'.sheet(item:) / .fullScreenCover
Dismissrouter.back()@Environment(\.dismiss)

  • 两套 stacks 用对了,都会把 transition 留在你的 update loop 之外。RN 里的 JS stack navigator 每一帧都 re-enter React。自定义 SwiftUI transition 若每帧 tick @State,每一帧都会 re-enter body。Prefer platform stack。
  • 没有 file-system router。 你不会从 folder 得到 typed routes。NavigationPath 是你可以 encode 的 untyped stack。Deep linking 是 onOpenURL 加上你自己的 parsing,或一个 library。

10. Gestures 与 Animation

React Native 的 JS thread 对 60fps 的 pan 来说太晚。不能等 JS 的工作属于 Reanimated worklets 或 Gesture Handler。那是 深入理解 React Native。

SwiftUI 没有 JS thread。DragGesture、MagnifyGesture 与 withAnimation 已经跑在 UI process 里。你不需要 worklet compiler 才能用手指移动一个 view。那并不让 SwiftUI animation 等价于 Reanimated worklet。


swift
struct SwipeRow: View {
  @State private var x: CGFloat = 0

  var body: some View {
    Text("Note")
      .offset(x: x)
      .gesture(
        DragGesture()
          .onChanged { value in
            x = value.translation.width
          }
          .onEnded { _ in
            withAnimation(.spring) { x = 0 }
          }
      )
  }
}

  • Reanimated 的 x.value 可以更新而 不必 React render。Shared values 是 native-thread state。SwiftUI 的 x 是 @State。每次 onChanged 都会 invalidate view 并重新计算 body。对一行 offset 这没问题。对一棵在 body 里做真正工作的 views graph,这并不免费。
  • Worklet 的类比不是 withAnimation。它仍是「把这个留在 description pass 之外」——UIKit,或一个 Canvas,或一个写入 SwiftUI 能在不重跑整棵 tree 的情况下 interpolate 的 transaction 的 gesture。
  • withAnimation interpolate 的是 SwiftUI 已经知道如何 animatable-diff 的 values(frame、opacity、offset、部分 layout)。body 里的任意工作不会因为你 wrap 了 setter 就变成 worklet。

在 SwiftUI 里,你一开始就站在 RN 离开 React 才进入的那个世界。你仍可能掉帧。你掉帧是因为在 body 里做太多,不是因为在等 Hermes。


11. Swift Concurrency 对比 JavaScript Event Loop

JavaScript 是 single-threaded。Promises 与 async/await 是在 event loop 上 scheduling。你不能从两条 threads data-race 同一个 JS object。你仍可能有 stale closures、错过的 cleanups,以及一条在 UI 等待时很忙的 JS thread。

Swift 是 concurrent 的。async/await 不是 event loop。一个 async function 会 suspend。当它 resume 时,它可能在 不同的 executor 上。UIKit 与 SwiftUI state 属于 main actor。Hop 出去 fetch,然后在没有回到 MainActor 的情况下突变 store,就是 data race。Swift 6 的 compiler 常常会拒绝这件事。把拒绝当成 feature。


swift
@Observable
@MainActor
final class NotesStore {
  var notes: [Note] = []

  func load() async {
    let next = try? await fetchNotes()
    notes = next ?? []
  }
}

.task {
  await store.load()
}

.task(id: selectedId) {
  await store.loadDetail(id: selectedId)
}

  • .task cancellation 是 cooperative 的,像 AbortController。URLSession 会尊重它。紧密的 for loop 不会,除非你检查 Task.isCancelled。
  • .task(id:) 是 dependency array。 没有 id 的 .task { } 更接近 useEffect(() => { ... }, []) 加上 cancel-on-unmount,但「unmount」指的是 SwiftUI identity 消失,不是 Fiber unmounting。若 identity 闪烁,你会 refetch。
  • actor 把 mutable state isolate 到一个 serial mailbox。若 background task 突变一个 body 会读的 class,你需要 isolation(store 上的 @MainActor 是常见的 UI 选择),否则你有一场 JS 心智模型看不见的 race。
  • setTimeout(0) 不是 Task { }。Task { } schedule 工作。它不会等一个不存在的 microtask checkpoint。MainActor.run { } 是「hop 到 UI actor」,不是 queueMicrotask。

12. Lifecycle 不是 Mount

useEffect 在 commit 之后跑。useEffect(() => { ... }, []) 在 mount 之后跑。SwiftUI 的 .onAppear / .onDisappear 跟随 rendered tree 里的 identity,不是 Fiber mount。List 一行的 onAppear 在 cell 被 realized 时触发——滚出屏幕,onDisappear;滚回来,再一次 onAppear。那更接近 onViewableItemsChanged,而不是 screen-level 的 useEffect([])。

.task 是更好的 loading primitive:view 出现时开始,view 的 identity 消失时 cancel,并且可以用 .task(id:) restart。Prefer 它,而不是 onAppear { Task { ... } },后者很容易 leak。

  • onAppear 不是 componentDidMount。 一张盖住 view 的 sheet,历史上会产生与「底下仍 mounted」不匹配的 appear/disappear 序列。不要把「整个 app session 只跑一次」的逻辑放进 onAppear。
  • 对 notes list,在 list identity 上用 .task load,在 detail identity 上用 .task(id: note.id) load detail。
  • 若你的意思是「Fiber 没了」,onDisappear 不是可靠的「save draft」hook。从显式 action save,或从 .task cancellation(defer / try await teardown)save,前提是你已经决定把它绑在哪个 identity 上。

13. Notes List,两套 Stack

同一块产品表面。Fetch 一份 list、下拉刷新、push 一条 detail、load body。RN 侧是 Expo(FlatList + useEffect + AbortController + router.push)。SwiftUI 侧是 Observation + NavigationStack。


swift
import Observation
import SwiftUI

struct Note: Identifiable, Hashable {
  let id: String
  var title: String
  var body: String
}

@Observable
@MainActor
final class NotesStore {
  var notes: [Note] = []
  var isLoading = false

  func load() async {
    isLoading = true
    defer { isLoading = false }
    notes = (try? await fetchNotes()) ?? []
  }

  func loadDetail(id: String) async -> Note? {
    try? await fetchNote(id: id)
  }
}

struct NotesListView: View {
  @State private var store = NotesStore()

  var body: some View {
    NavigationStack {
      Group {
        if store.isLoading && store.notes.isEmpty {
          ProgressView()
        } else {
          List(store.notes) { note in
            NavigationLink(value: note) {
              Text(note.title)
            }
          }
          .refreshable { await store.load() }
        }
      }
      .navigationDestination(for: Note.self) { note in
        NoteDetailView(note: note, store: store)
      }
      .task { await store.load() }
    }
  }
}

struct NoteDetailView: View {
  let note: Note
  var store: NotesStore
  @State private var bodyText: String?

  var body: some View {
    Group {
      if let bodyText {
        Text(bodyText)
      } else {
        ProgressView()
      }
    }
    .navigationTitle(note.title)
    .task(id: note.id) {
      bodyText = await store.loadDetail(id: note.id)?.body
    }
  }
}

把这当成一份对照来读,不是第二份教程:

  • Identity: keyExtractor / note.id vs path 上的 Identifiable / Hashable。
  • Ownership: screen 上的 useState vs @State 拥有一个 @Observable store。
  • Load + cancel: useEffect + AbortController vs .task / .task(id:)。
  • Refresh: RefreshControl vs .refreshable。
  • Push: router.push("/notes/" + id) vs NavigationLink(value:) + navigationDestination。

SwiftUI detail 仍然会 fetch,即使 list 已经有一个 Note。List row 不是一份完整 document。把整个 Note 当 navigation value 传过去,是为了 title 的方便,不是 cache policy。


14. Analogy 在哪里破裂

上面的对照有用,直到它们被当成等式。

  • 一个 View 是一个 value。一个 function component 是 Fiber 上的 function。 重建 struct 不是 remounting。@State 能存活是因为 identity,不是因为 struct 长命。
  • 没有 Yoga,也没有 StyleSheet。 Proposed sizes 与 modifier order 取代 flexbox 与 style objects。Spacer 不是 flex: 1。Safe area defaults 是反的。
  • 没有 JS thread,也没有 OTA JavaScript。 Reanimated 存在是因为 Hermes 太晚。SwiftUI gestures 从 UI process 开始;你仍可能 stall body。一个新的 SwiftUI screen 是一份新 binary。EAS Update 不能替换 NotesListView。Store build 就是 tree。
  • Navigation 不是一棵 URL tree。 expo-router 让 file routes 成为产品功能。NavigationStack 让 value path 成为产品功能。你可以在上面建 URLs。Framework 不是从那里开始。
  • async/await 不是 event loop。 Single-threaded JS 不能 race 同一个 object 的两次突变。Swift 可以。Store 上的 @MainActor 不是迂腐。.task cancellation 是 identity-shaped,不是 Fiber-shaped。
  • .onAppear 不是 mount。 Lazy lists、sheets,以及 identity 改变,会比 screen component 上的 useEffect([]) 更常调用它。

第一次读 sample 时留着这套对照。当一个 view reset state、refetch 两次,或 animate 错的 row 时丢掉它。那时的问题是 SwiftUI 的问题:identity 是什么,body 读了什么,哪个 actor 拥有这次 mutation? 不是「哪条 Fiber committed」。


Recap Q&A