iOS 开发走到今天SwiftUI 已经不是什么新词了但真正把它用在有历史包袱的项目里的人踩的坑往往比教程里写的多得多。我从几年前的第一个纯 SwiftUI 小工具开始到现在手上三个模块都完成了混编迁移中间经历过列表卡顿、状态错乱、导航莫名其妙的返回、预览一天崩十几次也算把这个框架的脾气摸清了一些。这篇就按我实际做过的路径把 SwiftUI 的选型理由、状态管理、列表实操、布局动画、混编适配和问题排查完整讲一遍。声明式 UI 到底能解决什么、会带来什么新麻烦我会尽量说透。适不适合你现在就上看完你自己判断如果你写过 UIKit、想评估迁移成本或者刚入门想少走弯路这篇应该能省你不少时间。1. 我为什么把三个模块迁到 SwiftUI决定迁移之前我在一个内部小工具上跑了差不多半年的纯 SwiftUI才敢动业务代码。那半年最大的收获不是学会了写VStack而是搞明白了一件事SwiftUI 不是 UIKit 的语法糖它换的是整套心智模型。你如果带着 UIKit 的思维去写代码能跑但一定会在某个地方以一种莫名其妙的方式崩掉或者卡住而且报错信息往往指向别处。1.1 声明式与命令式的真实分界线UIKit 里我们做的事是描述变化某个 label 的文字变了我就去改它的text某个 view 要隐藏我就设isHidden true。整套逻辑是围绕当前屏幕上有什么这个可变对象展开的开发者是变化的执行者。SwiftUI 反过来你做的是描述状态和状态的映射关系我声明当items为空时显示空态当isLoading为真时显示转圈。至于屏幕怎么从旧样子变到新样子框架自己算。这个差别看起来只是写法不同实际上带来了两个直接后果。第一你不能再手动改 UI 了任何界面变化都必须有一个状态源状态变了 UI 自己跟着变。第二任何没有被状态覆盖到的界面细节都会在状态切换时露出破绽。我见过最常见的翻车就是局部用了State存一个临时标记然后在某个异步回调里直接改改完发现视图没刷新——因为改的是另一个 struct 副本。所以我在迁移时定的第一条规矩是界面上的每一处变化都要能追溯到某个状态变量。做不到这一点的模块先不迁。这条规矩粗看起来有点教条但实测下来能挡掉八成的后续问题。比如一个带复杂输入校验的表单页如果校验逻辑深埋在若干 UIKit 控件的事件回调里直接搬过去会变成一堆散落在各处的State还不如留在原处。1.2 选型时我只看三条硬指标市面上讲 SwiftUI 优点的文章很多我就说说我实际用来判断这个页面该不该迁的三条指标都比较土但好用。判断维度适合迁移建议暂缓界面复杂度列表、表单、详情、设置页等结构化页面高度定制的绘图、复杂手势交互、视频剪辑类编辑界面状态来源状态集中、单向流动清晰状态分散在多个老组件、互相回调最低系统版本能定在较新的版本上必须兼容很老的系统第一张表里第三行是关键。SwiftUI 的 API 是跟着系统版本走的你在新系统上写的NavigationStack、ScrollViewReader、Layout协议老系统上一个都没有。如果产品要求覆盖很老的机型你会被迫写大量if #available分支代码会变丑收益会打折。我的做法是先把新功能的门槛提到较新版本老用户留在老版本继续用旧界面等覆盖率自然降下来再统一。第二条状态集中也很重要。UIKit 时代我们习惯让 ViewController 当万能管家什么都往里塞。迁到 SwiftUI 时如果照搬会得到一个几千行的body编译慢、预览崩、改一处牵连一片。正确的做法是先做一次状态梳理把数据和临时 UI 标记分开前者交给可观察对象后者才用State。1.3 迁移顺序先动什么后动什么我实际执行的顺序是新页面直接 SwiftUI → 独立的小页面迁移 → 列表类主页面 → 复杂交互页面。这个顺序的好处是风险递增每一步都能积累经验值。第一批迁的是一个关于我们和一个设置页几乎没状态纯粹是排版。这一步的目的不是省代码而是让团队熟悉布局系统和预览。第二批迁的是列表页这时候才开始碰网络请求、分页、下拉刷新、图片加载这些真东西。第三批才是带表单校验和复杂跳转的页面。这里有个我踩过的坑值得单独说别一上来就做混编。混编UIHostingController嵌 SwiftUI或者UIViewRepresentable嵌 UIKit本身不难难的是两边的生命周期和状态同步。如果团队还没熟悉纯 SwiftUI 的写法就上混编出了问题你根本分不清是框架的锅还是衔接的锅。我的建议是先在纯 SwiftUI 的小页面里把基本功练熟再考虑边界问题。还有一条经验迁移期间保留旧的 UI 测试用例。SwiftUI 的视图层级和 UIKit 完全不同很多基于 accessibility identifier 的自动化用例需要重写但功能断言本身是有价值的。先让旧用例在新页面上跑通比事后补测试效率高得多。2. 吃透状态管理先把五个属性包装器分清如果只能给 SwiftUI 初学者一句忠告我会说把属性包装器搞清楚你就学会一半了。我见过太多项目里State和StateObject混着用、ObservedObject到处传的情况代码能跑但对象反复重建、请求重复发起、界面偶发不刷新排查起来极其痛苦。2.1 View 是 struct值语义带来的一连串反应SwiftUI 的View是结构体不是类。这句话人人都知道但它带来的连锁反应很多人没想明白。结构体是值类型意味着每次父视图重新计算body子视图 struct 都会被重新创建一份。注意是创建不是销毁重建界面。框架内部会拿新旧两份 struct 做比对只把差异应用到真实渲染层。所以你在body里写print会看到它打印得比你预想的多得多——这不代表性能有问题是正常现象。由此推出两条实践结论。其一不要在 View 的初始化器里做重活。因为初始化器会被频繁调用你在里面发起网络请求、做大量计算就会重复执行。其二不要指望 struct 里的普通属性保持状态。你在 View 里写var count 0改了它视图不会更新因为每次body重算都是新的副本。要跨渲染保持的值必须放进属性包装器。我第一次踩这个坑是在一个计数器小页面上直接在按钮回调里self.count 1结果数字纹丝不动。当时还以为是自己哪里写错了后来才明白属性包装器的意义就是给值类型挂一个外部存储。2.2 五个属性包装器的适用边界这五个是日常最常用的我用一张表说清它们的归属和典型用途后面再展开。包装器存储位置典型用途常见误用State视图自身开关、输入框文本、局部动画标记存业务对象、存大数组Binding父视图子视图回写父视图的值在子视图里另外建一份副本StateObject视图自身只创建一次视图持有的 ViewModel和ObservedObject用反ObservedObject外部传入父视图传入的 ViewModel在视图里自己 new 一个EnvironmentObject环境全局登录态、主题、路由忘了在祖先注入导致崩溃State和Binding是一对本质是值的所有权在父使用权在子。子视图如果只需要读直接传值就行如果需要写就传Binding。这里有个细节Binding传进去的不是引用而是一个读写闭包所以子视图改了之后父视图的State会更新进而触发父视图重算再传新的值下来。StateObject和ObservedObject是最容易搞反的一对。记住一句话谁创建谁用StateObject谁接收谁用ObservedObject。StateObject保证对象在整个视图生命周期内只初始化一次而ObservedObject每次父视图重算都会重新执行初始化表达式——虽然它不会真的替换掉已有的对象实例但在初始化表达式里写ViewModel()这种写法会让你误以为对象被重建了实际上是表达式被求值后丢弃。这个区别在初始化器有副作用时比如发起请求会非常致命。我遇到过一个真实案例某个列表页在滚动时不停重复请求第一页数据。排查了半天发现 ViewModel 是用ObservedObject声明的并且初始化表达式里带了网络调用滚动导致父视图重算表达式被反复求值。改成StateObject后立刻正常。EnvironmentObject是把对象塞进环境子孙视图按类型取。它的优点是穿透层级方便缺点是类型必须唯一且未注入时会直接崩溃。我的用法是只放真正全局的东西登录态、当前主题、全局路由。不要什么对象都往里扔否则依赖关系会变得隐形接手的人很难看出某个视图依赖了什么。注意EnvironmentObject缺失时是运行时崩溃不是编译错误。涉及它的页面我都会在预览和单元测试里显式注入形成肌肉记忆。2.3 视图身份与 diffForEach 的 id 到底怎么给SwiftUI 的 diff 依赖身份这个概念。同一个位置如果身份没变框架认为还是那个视图只更新属性身份变了就当成新视图处理旧视图销毁、新视图创建附带的状态和动画也会重置。ForEach的 id 就是身份的直接来源。数据模型实现Identifiable是最省事的做法用稳定的唯一 ID。如果你传的是索引比如ForEach(0..items.count, id: \.self)那么删除第一项之后后面每一项的身份都会前移一位框架会认为内容变了但身份没变于是出现删了第一行界面显示的是第二行的内容但状态没跟上这类诡异现象。我吃过一次更隐蔽的亏列表项里有个展开/收起的状态我用State存在行视图内。结果每次数据刷新行视图的身份变了展开状态全部丢失。后来把展开状态提到 ViewModel 里用 ID 做 key 存一份集合问题解决。这件事给我的教训是凡是需要在数据刷新后保留的 UI 状态都不该放在行视图的State里。3. 从零做一个带缓存和分页的列表页讲理论不如直接上手。下面这个列表页是我实际项目中抽象出来的精简版包含请求、分页、三态展示、图片加载、详情跳转和暗黑模式适配。代码我尽量给全你可以直接对着改。3.1 数据模型与请求层先定模型。Identifiable用来给列表提供稳定身份Codable用来解析。struct Article: Identifiable, Codable, Hashable { let id: Int let title: String let summary: String let coverURL: URL? } struct FeedPage: Codable { let items: [Article] let hasMore: Bool }请求层我用了actor把缓存放在里面天然线程安全。这里要注意的是actor的方法默认是异步的调用处需要await。actor FeedService { private let session: URLSession private var cache: [Int: FeedPage] [:] init(session: URLSession .shared) { self.session session } func fetch(page: Int) async throws - FeedPage { if let cached cache[page] { return cached } var comps URLComponents(string: https://example.com/api/feed)! comps.queryItems [ URLQueryItem(name: page, value: \(page)), URLQueryItem(name: size, value: 20) ] let (data, response) try await session.data(from: comps.url!) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw URLError(.badServerResponse) } let pageData try JSONDecoder().decode(FeedPage.self, from: data) cache[page] pageData return pageData } }缓存这一层不是可有可无的。列表页用户上下滑动、反复进出详情不做缓存就会出现重复请求流量和等待时间都翻倍。用actor而不是普通 class 加锁是因为 Swift 的并发模型里 actor 的隔离更自然不需要手写DispatchQueue。3.2 加载态、错误态、空态的三态设计我一开始用了三个布尔值isLoading、hasError、isEmpty结果出现了同时 loading 又 error的荒谬状态。后来统一成一个枚举问题迎刃而解。enum Phase: Equatable { case idle case loading case loaded case empty case failed(String) } MainActor final class FeedViewModel: ObservableObject { Published private(set) var items: [Article] [] Published private(set) var phase: Phase .idle Published private(set) var isLoadingMore false private let service FeedService() private var page 1 private var hasMore true func loadFirst() async { guard phase .idle || isFailed else { return } phase .loading do { let result try await service.fetch(page: 1) page 1 hasMore result.hasMore items result.items phase items.isEmpty ? .empty : .loaded } catch { phase .failed(error.localizedDescription) } } func loadMoreIfNeeded(current: Article) async { guard hasMore, !isLoadingMore else { return } guard let idx items.firstIndex(of: current), idx items.count - 3 else { return } isLoadingMore true defer { isLoadingMore false } do { let next try await service.fetch(page: page 1) page 1 hasMore next.hasMore items.append(contentsOf: next.items) } catch { hasMore false } } private var isFailed: Bool { if case .failed phase { return true } return false } }几个细节值得说。MainActor标在类上保证Published的修改都在主线程省掉手动切换队列的麻烦。loadMoreIfNeeded用距离底部还剩 3 条作为触发条件比监听到达底部更早一点用户感知更顺滑。defer保证无论成功失败isLoadingMore都会复位避免卡在加载中。视图侧按phase分支渲染struct FeedView: View { StateObject private var vm FeedViewModel() var body: some View { Group { switch vm.phase { case .idle, .loading: ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) case .empty: ContentUnavailableView(暂无内容, systemImage: tray) case .failed(let msg): VStack(spacing: 12) { Text(msg).foregroundStyle(.secondary) Button(重试) { Task { await vm.loadFirst() } } } case .loaded: list } } .task { await vm.loadFirst() } } private var list: some View { List(vm.items) { item in NavigationLink(value: item) { RowView(item: item) } .task { await vm.loadMoreIfNeeded(current: item) } } .listStyle(.plain) .refreshable { await vm.reload() } } }.task修饰符是 iOS 15 引入的它在视图出现时启动异步任务视图消失时自动取消。这比在onAppear里手动Task { }要安全得多因为后者不会随视图销毁而取消容易造成内存泄漏或者对已销毁视图的状态写入。3.3 列表渲染与图片异步加载封面图我是自己写的加载器没用AsyncImage。原因是AsyncImage的缓存策略不够透明列表快速滚动时会重复解码很浪费。自己写一个带内存缓存的加载器几十行代码效果立竿见影。final class ImageCache { static let shared ImageCache() private let cache NSCacheNSURL, UIImage() private let lock NSLock() func image(for url: URL) - UIImage? { lock.lock(); defer { lock.unlock() } return cache.object(forKey: url as NSURL) } func store(_ image: UIImage, for url: URL) { lock.lock(); defer { lock.unlock() } cache.setObject(image, forKey: url as NSURL) } } struct RemoteImage: View { let url: URL? State private var image: UIImage? var body: some View { ZStack { Rectangle().fill(Color.secondary.opacity(0.15)) if let image { Image(uiImage: image).resizable().scaledToFill() } } .clipped() .task(id: url) { guard let url else { return } if let cached ImageCache.shared.image(for: url) { image cached; return } guard let (data, _) try? await URLSession.shared.data(from: url), let ui UIImage(data: data) else { return } ImageCache.shared.store(ui, for: url) image ui } } }.task(id:)这里很关键。列表行会被复用如果不带 id滑到另一个元素时task不会重新执行图片就会串。加上id: url后url 变了就取消旧任务、启动新任务这个问题就没了。注意行高不定时.clipped()必须加否则scaledToFill的图片会溢出到相邻行视觉上糟心还可能盖住点击区域。3.4 导航与详情页传值导航这块我经历了从NavigationView到NavigationStack的迁移。老的NavigationLink(destination:)写法在列表里会立刻创建目标视图数据量大时性能很差。新写法用值驱动NavigationStack { FeedView() .navigationDestination(for: Article.self) { article in DetailView(article: article) } }navigationDestination是在 iOS 16 引入的值驱动的好处是目标视图按需创建而且状态可以被还原。如果你要支持更老的系统就只能用NavigationLink(destination:)或者NavigationLink(tag:selection:)那就得接受性能差一点。跳转详情后返回列表的滚动位置会保留吗默认会。但有个前提列表数据没有被重建。如果你在每次onAppear时重新加载第一页items被整体替换滚动位置会跳回顶部。我的处理是加载逻辑放在.task里只在首次进入时执行返回时不重复触发。3.5 暗黑模式与动态字体适配这块经常被忽略但用户投诉最多的恰恰是这些细节。暗黑模式的关键是不要写死颜色用语义色。需求不推荐推荐背景Color.whiteColor(.systemBackground)次要文字Color.gray.secondary分割线Color(red: 0.9, ...)Color(.separator)动态字体则要避免固定font(.system(size: 14))改用文本样式加相对缩放Text(article.title) .font(.headline) .lineLimit(2) .minimumScaleFactor(0.85).font(.headline)会跟随系统的字号设置变化。如果你确实需要自定义字号可以用.font(.system(size: 17, weight: .semibold))之后再配.dynamicTypeSize(...DynamicTypeSize.accessibility3)限定上限防止超大字号把布局撑破。我用DynamicTypeSize(...DynamicTypeSize.xxxLarge)作为大多数页面的上限实测下来在超大字体下依然可用又不至于完全变形。4. 布局与动画几个能立刻上手的技巧布局和动画是 SwiftUI 最出彩的部分也是最容易写出看起来对但很别扭的地方。这部分我挑三个实际用得最多的点讲。4.1 布局系统的三把尺子SwiftUI 的布局逻辑是父视图给建议尺寸子视图返回实际尺寸。理解这个建议-返回的往复你就理解了大部分布局问题。HStack、VStack负责沿一个轴分配空间Spacer负责吸收剩余空间frame负责提出尺寸要求。这三个组合能解决九成布局问题。剩余的空间分配优先级由layoutPriority控制这个参数很多人不知道但在一个文本要被截断另一个要完整显示的场景里非常有用。HStack { Text(article.title).layoutPriority(1) Text(article.date).foregroundStyle(.secondary) }不加优先级的话两个文本会各压缩一半标题被截得很难看。加了之后优先满足标题日期适当压缩视觉效果明显更好。GeometryReader要慎用。它会把子视图的尺寸变成不确定导致内部的List、ScrollView行为异常。我现在的做法是能用frame(maxWidth: .infinity)就不用GeometryReader确实需要读尺寸时尽量把它放在叶子节点而不是包住整个页面。4.2 withAnimation 与 transition 的实际效果边界withAnimation改变状态时能扩散到同一次更新里的所有可动画属性。这个同一次更新的范围要理解清楚在闭包内改的状态会动画闭包外改的不会。withAnimation(.snappy(duration: 0.25)) { isExpanded.toggle() }.snappy是较新的系统预设曲线观感比传统的.easeInOut更利落。如果你的最低版本不支持就用.easeInOut(duration: 0.25)。transition控制的是视图插入和移除时的动画。常见误用是写了.transition(.slide)却没效果——因为插入/移除发生在if分支里而状态改变没有包裹在withAnimation或者没有挂在.animation修饰符上。正确写法if showBanner { BannerView() .transition(.move(edge: .top).combined(with: .opacity)) }然后在触发处用withAnimation。如果用.animation(_:value:)修饰符注意一定要带上value参数无参数的.animation()在新版本已经废弃因为它会让整个子树的所有变化都套上动画很容易出现莫名抖动的现象。4.3 matchedGeometryEffect 做转场这个修饰符能在两个不同的视图之间做几何动画最典型的场景是列表缩略图点击后放大成详情页大图。原理是给两个视图打上同一个 namespace 和 id框架自动计算位置尺寸差异并插值。Namespace private var hero // 列表里 RemoteImage(url: item.coverURL) .matchedGeometryEffect(id: item.id, in: hero) // 详情里 RemoteImage(url: item.coverURL) .matchedGeometryEffect(id: item.id, in: hero)使用时有两个坑。第一同一个id在同一时刻只能存在于一个视图上否则会出现莫名其妙的闪烁所以做转场时通常需要配合if显示隐藏或者借助ZStack控制层级。第二动画必须由withAnimation触发且两个视图要在同一次更新里完成切换。这个效果我一般只用在一个列表到详情的封面转场上用得多了反而显廉价。克制使用。5. 和 UIKit 混编、版本适配与上线前检查纯 SwiftUI 项目毕竟是少数大部分还是要和老代码共存。这块我踩的坑最多也最有实战价值。5.1 UIViewRepresentable 的正确写法把 UIKit 控件包进 SwiftUI需要实现UIViewRepresentable视图或UIViewControllerRepresentable控制器。核心是两个方法makeUIView负责创建updateUIView负责同步状态。struct MapWrapper: UIViewRepresentable { Binding var region: MKCoordinateRegion func makeUIView(context: Context) - MKMapView { let map MKMapView() map.delegate context.coordinator return map } func updateUIView(_ uiView: MKMapView, context: Context) { if uiView.region.center.latitude ! region.center.latitude { uiView.setRegion(region, animated: true) } } func makeCoordinator() - Coordinator { Coordinator(self) } final class Coordinator: NSObject, MKMapViewDelegate { var parent: MapWrapper init(_ parent: MapWrapper) { self.parent parent } } }这里最容易出问题的是updateUIView里的判断。如果无脑setRegion用户拖动地图时会和状态更新打架表现为地图抽搐。我的做法是比较中心点坐标只有外部状态真正变化时才同步。另一个注意点是 Coordinator 里持有 parent 是强引用而且要定期更新——除非你用的是Binding之类的包装器它在 Coordinator 里应该重新赋值否则会读到过期状态。内存方面makeUIView返回的 UIKit 对象由 SwiftUI 管理生命周期你不要自己持有强引用否则会出现视图已经移除但对象还活着的情况。我之前在一个播放器封装里就犯过这个错页面退出后音频还在放查了很久。5.2 available 与 API 降级版本适配的核心是available和#available。对于整个类型或者方法都依赖新 API 的情况用available标注声明对于页面内的局部差异用#available分支。var body: some View { if #available(iOS 17, *) { content.sensoryFeedback(.success, trigger: didSucceed) } else { content } }这里有个工程上的建议把版本分支集中管理。我建了一个Compat.swift把所有涉及新 API 的封装放里面比如新版的ContentUnavailableView、scrollTargetBehavior之类外面统一调用封装。这样将来最老版本提升只要删一个文件里的分支业务代码完全不用动。如果分散在各处清理时会非常痛苦而且很容易漏。另一个细节是预览和真机的差异。有些新 API 在预览里能跑在低版本真机上会崩溃所以每次引入新 API我都会在最低支持版本的真机上跑一遍关键流程而不是只看预览。5.3 上线前的自检清单这份清单是我每次发版前都会过一遍的帮我挡掉过好几次问题。所有StateObject的初始化是否轻量有没有在init里发起请求。所有ForEach的 id 是否稳定有没有用索引。所有.task是否有对应的取消逻辑.task会自动取消但Task { }不会。深色模式下所有自定义颜色是否可读尤其是.white和.black硬编码。动态字体调到最大时关键按钮是否还在可点区域内。EnvironmentObject是否在根节点注入预览是否也注入了。列表滚动到底部的分页是否在快速滑动时会重复触发。从详情返回后列表位置和展开状态是否保留。6. 问题排查实录与速查表下面这些是我实际遇到过、并且印象最深的问题按现象—原因—处理整理成表遇到类似情况可以直接对照。6.1 那些看着像 Bug 其实是用法问题现象真实原因处理方式界面偶发不刷新状态存在普通属性或错误的包装器里改用State或Published滚动时重复请求ViewModel 用ObservedObject且初始化带副作用改为StateObject初始化保持纯粹删除列表项后内容错位ForEach用了索引做 id改用模型自身的稳定 ID图片在快速滚动时串行异步任务没绑定 URL 作为 id使用.task(id: url)返回上一页后滚动位置丢失onAppear里重复加载第一页加载逻辑移到.task只执行一次打开页面直接崩溃EnvironmentObject未注入在祖先节点注入预览同步注入动画不生效状态改变没放在withAnimation里包裹状态修改或用.animation(_:value:)地图/视频控件抽搐updateUIView无条件同步状态加状态比较只在真正变化时写回这里面最值得展开的是界面偶发不刷新。这类问题最难查因为它不是必现。我的排查套路是先在body开头加一行打印看状态变化时body有没有重算。如果没重算说明状态源没被框架观察到大概率是包装器用错了或者改的是 struct 的副本。如果重算了但界面没变那就是视图身份的问题或者被上层某个固定尺寸给裁掉了。这个二分法能快速缩小范围比瞎猜高效得多。另一个高频问题是列表性能。List在 SwiftUI 里是懒加载的但前提是行视图本身足够轻。我见过在行视图里做日期格式化的写法每行一个DateFormatter滚动直接掉帧。正确做法是把DateFormatter做成静态的或者用Date.FormatStyle。这类问题在模拟器上往往看不出来必须在老设备真机上滑动才能发现。所以我现在的习惯是列表页写完第一件事就是在最低配的真机上滑三十下。6.2 几个容易忽略的隐性成本除了 Bug还有几类成本是迁到 SwiftUI 之后才会显现的提前知道能少走弯路。第一是编译时间。SwiftUI 的body如果有很深的嵌套和大量的类型推断编译会明显变慢。我的经验是单个body不要超过五十行超了就抽子视图。抽离之后不仅编译快预览也稳定得多。类型推断的另一个杀手是复杂的条件表达式三元运算符嵌套几层就会让编译器头疼能拆成if就拆。第二是预览稳定性。预览崩是家常便饭常见原因是预览里访问了真实网络或者依赖了未注入的环境对象。我的做法是给每个页面配一个 mock 数据入口预览里只喂 mock。虽然多写几行但换来的是每次改 UI 都能秒看效果长期看非常划算。第三是可测试性。SwiftUI 的视图不太好做单元测试所以逻辑一定要抽到 ViewModel 里。我的原则是视图里只允许有布局和绑定任何带分支的判断都放进 ViewModel用纯函数测。这个习惯建立起来之后测试覆盖率反而比 UIKit 时代更高因为 ViewModel 是纯 Swift 类型测起来很轻松。7. 几个我认为最值钱的经验写到这里技术点基本覆盖了。最后分享几条纯个人体会都是踩坑踩出来的不一定适用于所有人但至少能让你少走我走过的弯路。第一条先想清楚状态再动手写界面。我早期习惯先搭 UI 再补逻辑在 SwiftUI 里这个顺序会让你返工。因为界面完全由状态驱动状态没设计好界面写得再漂亮也得推倒重来。我现在的流程是先在纸上画状态机有哪些状态、状态之间怎么流转、每个状态对应什么界面。画完再写代码速度快很多。第二条属性包装器的选择要一次到位。这不是可以先这样后面再改的东西因为它影响的是对象生命周期改起来牵连很广。我在一个新项目里图省事先全用ObservedObject后来发现请求重复、状态丢失返工改了十几个文件。从那以后我在建文件的时候就把包装器定好不再事后补救。第三条别迷信新 API。SwiftUI 每年都有新东西但新 API 往往意味着更高的版本门槛。我现在的做法是业务确实需要、且能覆盖足够用户才引入纯粹为了用上新特性而提高门槛是给自己找麻烦。有次为了用一个新版的转场效果把最低版本提了一档结果收到一堆老设备用户的反馈最后又回退了白折腾。第四条混编边界要画清楚。哪些页面是 SwiftUI哪些还是 UIKit中间怎么通信这些要在迁移开始时就定好规则。我见过一个项目里同一个页面的一半用 SwiftUI 一半用 UIKit状态同步靠通知结果每次改动都要在两边各改一遍还经常不同步。边界清晰比技术先进重要得多。第五条把兼容分支集中管理。前面提过这里再强调一次。版本分支散落在各处是技术债里最难还的一种因为它不报错、不影响功能只是在每次升级时默默消耗你的时间。集中到一个文件将来清理就是删代码而不是满项目找。如果要说还有什么想补充的那就是多写。SwiftUI 的很多行为看文档是理解不了的必须自己动手改一改、跑一跑、看看预览怎么变。我最初学的时候就是把一个开源项目的界面用 SwiftUI 重写了一遍遇到不懂的就改一行看效果那个过程比看十篇教程都有用。