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 来写它。
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 上的keyidentity。 - 一份 SwiftUI 契约,例如
View是一份 value description,或@State活在 framework 的 identity slot 里而不是 struct 上。 - 一条 Swift 语言规则,例如 structs 在赋值时 copy,或
asyncfunctions 跑在它们继承到的 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:/ForEachids。 - 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 是预期的,而且便宜。
React Native:
useState setter → React render (Fibers) → Commit → Fabric mount → UIView
SwiftUI:
State or Observable mutation → Invalidate views → Recompute body → Diff by identity → Update UIViewbody与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 不会改到原本。
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。@Observablemodels 是 classes,因为 store 必须被共享并原地突变。读 store 的 view 仍然是 struct。letvsvar不是 TypeScript 里的constvslet。let绑定一个不能被重新赋值的名字。letstruct 不能突变它的 properties。letclass reference 不能指向另一个 instance,但那个 instance 的varproperties 仍然可以改。- 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。
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。Prefersome 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。
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。
| Role | React Native | SwiftUI (iOS 17+) |
|---|---|---|
| Local UI state | useState | @State |
| Controlled child | value + onChange | @Binding ($state) |
| Shared screen model | module store, Context, Zustand | @Observable class, often owned with @State, passed or put in Environment |
| Read-only dependency | Context | @Environment / @Environment(\.dismiss) |
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。
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。
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。
NavigationStack {
List(notes) { note in
NavigationLink(value: note) {
Text(note.title)
}
}
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note)
}
}Note 必须是 Hashable(通常也是 Identifiable)才能活在 path 上。
| Job | expo-router | SwiftUI |
|---|---|---|
| Stack | app/**/_layout.tsx + <Stack /> | NavigationStack |
| Push | router.push('/notes/1') | NavigationLink(value:) or path.append |
| Typed screen | app/notes/[id].tsx | .navigationDestination(for: Note.self) |
| Modal | presentation: 'modal' | .sheet(item:) / .fullScreenCover |
| Dismiss | router.back() | @Environment(\.dismiss) |
- 两套 stacks 用对了,都会把 transition 留在你的 update loop 之外。RN 里的 JS stack navigator 每一帧都 re-enter React。自定义 SwiftUI transition 若每帧 tick
@State,每一帧都会 re-enterbody。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。
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。 withAnimationinterpolate 的是 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。
@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)
}.taskcancellation 是 cooperative 的,像AbortController。URLSession会尊重它。紧密的forloop 不会,除非你检查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 上用
.taskload,在 detail identity 上用.task(id: note.id)load detail。 - 若你的意思是「Fiber 没了」,
onDisappear不是可靠的「save draft」hook。从显式 action save,或从.taskcancellation(defer/try awaitteardown)save,前提是你已经决定把它绑在哪个 identity 上。
13. Notes List,两套 Stack
同一块产品表面。Fetch 一份 list、下拉刷新、push 一条 detail、load body。RN 侧是 Expo(FlatList + useEffect + AbortController + router.push)。SwiftUI 侧是 Observation + NavigationStack。
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.idvs path 上的Identifiable/Hashable。 - Ownership: screen 上的
useStatevs@State拥有一个@Observablestore。 - Load + cancel:
useEffect+AbortControllervs.task/.task(id:)。 - Refresh:
RefreshControlvs.refreshable。 - Push:
router.push("/notes/" + id)vsNavigationLink(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不是迂腐。.taskcancellation 是 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」。