资讯动态

SwiftUI实战:状态管理、布局与列表性能优化

发布时间:2026/10/26 15:43:11 来源:尧图企业网站定制
SwiftUI 我从它还在 beta 阶段就开始碰那会儿把它当玩具只敢在新项目里画两个静态页面试试水到今天我手头一个内容类客户端的主流程页面基本全跑在 SwiftUI 上列表、详情、搜索、设置一整套。中间踩的坑够写一本小册子状态刷新不生效、列表滚动卡成幻灯片、预览动不动崩、混编时导航栏打架。这篇就把 iOS 开发里 SwiftUI 这一套东西从我自己的实践角度拆开讲——它解决什么问题、状态该怎么管、布局为什么总是不听话、一个真实可用的列表页怎么搭、性能从哪儿抠、哪些坑我摔过。不管你是刚接触声明式 UI 的新手还是已经写了一堆State但说不清什么时候该换StateObject的老手下面这些内容应该都能直接抄走。1. 为什么我把主力开发方式换成了 SwiftUI1.1 声明式 UI 到底解决了什么实际问题说 SwiftUI 之前得先把 UIKit 时代的痛讲清楚不然很容易把它当成语法糖。UIKit 是命令式的你拿到一个视图对象手动改它的text、alpha、isHidden改完之后界面变成什么样靠你在脑子里推演。页面一复杂状态就有十几处分散在各个方法里任何一个改动都可能漏掉某个分支——比如详情页加载失败时忘了把 loading 转圈关掉或者数据从空变成有时忘了reloadData。SwiftUI 的声明式改的是这个心智模型你只描述给定这份数据界面长什么样至于什么时候重建、重建哪一部分交给框架。这个转变带来的最大收益不是代码短了而是状态的唯一真相源变得可落地。原来在 UIKit 里我要维护一个模型、一个视图、还有一堆中间变量在 SwiftUI 里模型变了视图自动跟着走中间那层手写的同步逻辑直接消失。我印象最深的一次改造是一个带筛选条件的列表页。UIKit 版本里筛选条件变化要走一条长长的链路更新模型 → 重新请求 → 更新 dataSource → 处理空态 → 处理错误态 → 复位分页。六个步骤每个步骤都是一个可能出 bug 的地方。改成 SwiftUI 之后我把这六个状态收敛成一个枚举Phase视图只做一件事根据Phase渲染不同的分支。代码从四百多行降到不足两百行bug 数量肉眼可见地减少。1.2 SwiftUI 和 UIKit 不是二选一是分层很多讨论喜欢把这两者对立起来其实完全没必要。我现在的做法是分层业务页面用 SwiftUI 写基础设施和性能敏感的组件用 UIKit 兜底。举几个我自己用过的组合富文本编辑、复杂图文混排UITextView的能力仍然比 SwiftUI 的TextEditor强得多用UIViewRepresentable包一层是常态相机预览、视频解码渲染这类直接操作图层的东西用 UIKit 或更低层的接口更踏实一些老项目里已经写好的复杂自定义控件短时间没必要重写包装进来继续用。反过来也成立 UIKit 项目里嵌一块 SwiftUI 页面用UIHostingController就行我做过的做法是把新建的独立页面先用 SwiftUI 写嵌进原有导航栈里一次只迁一个页面。这种逐个击破的方式风险最低出问题也好回滚。注意混编时最容易出事的是导航和生命周期。UIHostingController的viewDidLoad里不要做依赖 SwiftUI 状态的事情两个框架的生命周期节奏对不上很容易拿到还没初始化的数据。1.3 判断项目该不该上 SwiftUI 的三个维度不是所有项目都适合立刻全面转向。我自己总结的判断维度是三个最低支持版本、页面复杂度类型、团队熟悉度。最低支持版本是硬门槛。新 API 基本一年一迭代Observable宏、NavigationStack、新布局容器都有系统版本要求。如果产品必须兼容很老的系统那一堆新特性你只能看着写起来反而更别扭。页面复杂度类型很关键。表单、列表、设置页、详情页这类数据驱动的页面SwiftUI 的生产力优势巨大而绘图、复杂手势冲突、逐帧动画这类场景SwiftUI 反而会让你怀念 UIKit。团队熟悉度决定的是维护成本。声明式 UI 的调试思路和命令式完全不同一个习惯在layoutSubviews里断点的人刚开始用 SwiftUI 会觉得没地方下手。这个适应期是真实存在的别低估。2. 状态管理SwiftUI 最容易学错的一块2.1 五种属性包装器的选择逻辑新手最常问的就是这几个到底有什么区别。我一般不用背定义的方式讲而是问三个问题这份数据属不属于当前视图、需不需要被外部修改、生命周期归谁管。包装器数据归属典型场景常见误用State视图私有值类型开关、输入框文本、展开折叠标记拿来存需要跨页面共享的数据Binding引用外部数据子视图回写父视图的值为了省事把整个大模型当 Binding 传StateObject视图私有引用类型页面级 ViewModel视图重建时不能丢用在子视图里导致每次重建都新建ObservedObject外部注入引用类型从父视图传下来的 ViewModel在视图里State式地初始化它EnvironmentObject环境注入引用类型用户会话、主题、全局配置忘记注入导致运行时崩溃最容易踩的坑是StateObject和ObservedObject搞混。区别只有一句话谁创建谁用StateObject谁接收谁用ObservedObject。父视图创建 ViewModel 并用StateObject持有子视图拿到的是ObservedObject。如果子视图也用StateObject初始化视图一重建就会被重新创建之前加载的数据全没了表现就是页面闪一下又回到初始状态这个 bug 我调过整整一个下午。2.2 刷新时机与在视图更新中修改状态的循环SwiftUI 的刷新是跟着状态变化走的但有一个经典陷阱在body求值过程中修改状态。比如你在body里读到某个值顺手把它取反或者在.onAppear里同步改状态又触发再次渲染结果就是控制台刷一串 Modifying state during view update 警告严重时直接卡死。我的处理原则是把副作用和渲染彻底分开数据请求统一放在.task或.onAppear里绝不放在body里由状态 A 推导出的状态 B尽量做成计算属性不要真的存一份确实需要在渲染后做的调整用.onChange(of:)或.task(id:)让框架在正确的时机回调。.task(id:)这个 API 我很喜欢它等价于当 id 变化时取消旧任务、启动新任务做搜索联想输入特别顺手把搜索词当 id 传进去用户每改一个字上一个请求自动取消不用自己写防抖和取消逻辑虽然防抖还是得加但至少不会有竞态导致结果错乱。2.3 从 ObservableObject 迁到 Observable 宏的实际收益新版本系统带来的Observable宏我实测下来的收益主要有两个。第一个是观察粒度变细了ObservableObject时代Published任何一个属性变化所有观察它的视图都会重新求值Observable会记录视图实际读了哪些属性只在这些属性变化时刷新。对于一个大 ViewModel 挂多个子视图的页面这个差别很明显。第二个是样板代码变少不用再写Published不用再区分StateObject/ObservedObject统一用State和直接传值就行。迁移的时候有个细节要注意Observable的类如果要在body里被观察必须被State持有或者在视图初始化时读取属性如果只是普通属性持有不会触发刷新——这个坑我迁第一批页面时踩过页面白屏了半天最后发现是持有方式写错了。3. 布局系统三定律和常见翻车点3.1 布局三定律理解了就不迷路SwiftUI 的布局流程和 UIKit 的 Auto Layout 完全不同它是一轮自上而下、再自下而上的协商过程父视图给子视图提议一个尺寸子视图根据自己的内容决定实际尺寸可能不理会提议父视图根据子视图返回的尺寸决定它的位置。这三步就解释了绝大多数为什么它不按我想的来。比如一个Text放在HStack里你给它.frame(width: 100)它不一定真的占 100——frame只是加了一层外壳Text本身仍按内容决定大小多余的或被裁掉或留白取决于对齐方式。再比如两个Text并排左边那个默认会被压缩因为Text的布局优先级低右边被挤压时会截断。想控制谁优先用.layoutPriority()。这个我用了很久才形成肌肉记忆遇到文字被莫名截断先想 layoutPriority再想 fixedSize。3.2 容器怎么选从 HStack 到 Grid 再到自定义 Layout常见容器其实不多但选错了会带来不必要的嵌套HStack/VStack一维排列最常用。超过三层嵌套基本说明结构该拆了ZStack叠放做背景、遮罩、徽标LazyVStack/LazyHStack需要滚动且内容多的时候用注意它必须放在ScrollView里才生效LazyVGrid/LazyHGrid宫格布局配合GridItem用Grid二维表格布局行列需要对齐的场景比LazyVGrid更合适自定义Layout协议这是很多人不知道的能力实现sizeThatFits和placeSubviews两个方法就能写出类似流式标签云这种布局比用一堆HStack拼要干净得多。我做过一个标签云组件最初用嵌套HStack 手动计算宽度代码又长又容易错后来改成实现Layout协议三十多行搞定而且能自动适配动态字体。这个收益挺值得花半天学一下这个协议。3.3 GeometryReader 与尺寸优先级别乱用GeometryReader属于那种能不用就别用的组件。它有个非常反直觉的特性它自己会尽可能占满可用空间而且把子视图放在左上角。很多人第一次用它做居中布局结果内容全跑到左上角去了还以为是 bug。真正需要它的场景其实很少读取安全区尺寸、做按比例拉伸的图形、需要根据父容器宽度动态计算列数。这些场景之外能用.frame(maxWidth: .infinity)和.containerRelativeFrame解决的就别动GeometryReader。它嵌套多了会让布局计算量翻倍滚动列表里尤其明显。还有一个经常被忽略的点.frame的修饰顺序会影响结果。.frame(width: 100).padding()和.padding().frame(width: 100)出来的效果完全不同因为修饰符是从内向外包裹的。我的习惯是固定尺寸放最后内边距放前面这样语义上更符合内容 留白 实际占位的直觉。4. 实操搭一个能直接上线的分页列表页4.1 数据模型与网络层怎么设计先给数据和网络层这部分和 UI 框架无关但决定了后面写起来顺不顺。我习惯把模型写成Identifiable Hashable DecodableHashable是为了后面配合List的差异比较和动画。import Foundation struct Article: Identifiable, Decodable, Hashable { let id: Int let title: String let coverURL: String let summary: String } enum LoadPhase: Equatable { case idle case loading case loaded case empty case failed(String) }网络层用URLSession的 async/await 版本就够了没必要为了一个 GET 请求引入第三方库。我封装了一个极简的请求函数重点是把错误信息转成给用户看的话术而不是把原始错误码直接甩到界面上struct FeedAPI { static func fetchPage(_ page: Int, size: Int) async throws - [Article] { var comps URLComponents(string: https://example.invalid/api/feed)! comps.queryItems [ .init(name: page, value: \(page)), .init(name: size, value: \(size)) ] let (data, response) try await URLSession.shared.data(from: comps.url!) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw URLError(.badServerResponse) } return try JSONDecoder().decode([Article].self, from: data) } }提示上面的域名只是占位实际填写自己的接口地址时注意不要把测试地址带进正式包这个低级错误我见过不止一次。4.2 分页加载与状态收敛ViewModel 用Observable宏写把首次加载 / 加载更多 / 刷新三种入口区分开但共享同一份状态机import Observation Observable final class FeedModel { private(set) var items: [Article] [] private(set) var phase: LoadPhase .idle private(set) var isLoadingMore false private(set) var hasMore true private var page 1 private let size 20 private var requestTask: TaskVoid, Never? MainActor func loadFirstPage() async { requestTask?.cancel() phase .loading do { let result try await FeedAPI.fetchPage(1, size: size) items result page 1 hasMore result.count size phase result.isEmpty ? .empty : .loaded } catch { phase .failed(内容加载失败下拉试试) } } MainActor func loadMoreIfNeeded(current: Article) async { guard hasMore, !isLoadingMore, current.id items.last?.id else { return } isLoadingMore true defer { isLoadingMore false } do { let next page 1 let result try await FeedAPI.fetchPage(next, size: size) items.append(contentsOf: result) page next hasMore result.count size } catch { // 加载更多失败不做整页报错只提示保留已有内容 hasMore true } } }这里有两个刻意的设计。第一加载更多失败不走整页错误态只保留已有内容并允许重试因为用户已经有内容在看把页面清空是体验灾难。第二触底判断用current.id items.last?.id而不是判断索引这样在列表因为插入或删除发生变化时不会误触发。4.3 列表渲染与图片加载视图这边用List还是ScrollView LazyVStack取决于要不要List自带的样式分隔线、滑动操作、分组。纯瀑布流或自定义间距的我一般选后者struct FeedView: View { State private var model FeedModel() var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 12) { ForEach(model.items) { item in ArticleRow(article: item) .onAppear { Task { await model.loadMoreIfNeeded(current: item) } } } if model.isLoadingMore { ProgressView().padding() } } .padding(.horizontal, 16) } .refreshable { await model.loadFirstPage() } .task { if case .idle model.phase { await model.loadFirstPage() } } .overlay { stateOverlay } .navigationTitle(内容精选) } } ViewBuilder private var stateOverlay: some View { switch model.phase { case .loading: ProgressView() case .empty: ContentUnavailableView(暂时没有内容, systemImage: tray) case .failed(let msg): VStack(spacing: 12) { Text(msg).foregroundStyle(.secondary) Button(重新加载) { Task { await model.loadFirstPage() } } .buttonStyle(.borderedProminent) } default: EmptyView() } } }ArticleRow里图片加载我单独抽了一个组件做三级缓存内存用NSCache磁盘用文件系统最后才是网络。解说一下为什么值得自己写列表里同一张图反复出现、快速上下滑动导致请求重复发出是滚动卡顿的高发原因。加一层内存缓存重复请求基本就消失了。final class ImageCache { static let shared ImageCache() private let cache NSCacheNSString, UIImage() private init() { cache.countLimit 200 } func image(for key: String) - UIImage? { cache.object(forKey: key as NSString) } func store(_ image: UIImage, for key: String) { cache.setObject(image, forKey: key as NSString) } }4.4 空态、错误态和一些容易被忽略的细节三种非正常状态里最容易被敷衍的是错误态。我见过太多把error.localizedDescription直接糊在屏幕上的实现用户看到一串英文加错误码除了卸载没别的选择。正确的做法是把错误翻译成用户能采取行动的话术并且给出明确的下一步按钮。网络问题就说检查一下网络服务问题就说稍后重试并且始终提供一个重试入口。另一个细节是.task和.onAppear的取舍。.task会在视图消失时自动取消任务.onAppear不会。列表页这种可能在需要时才出现的视图用.task能省掉一堆手动取消的代码。但要注意.task的重入如果视图被反复插入移除任务会被反复启动配合if case .idle这种守卫就很有必要。5. 动画与交互别让动效变成卡顿源5.1 隐式动画与 withAnimation 的边界SwiftUI 的动画分两类隐式.animation(_:value:)修饰符和显式withAnimation闭包。隐式动画的好处是声明式状态一变就自动过渡坏处是容易和别的动画叠加出现两段动画抢同一个属性的诡异效果。我的经验是结构性变化用显式属性微调用隐式。比如列表插入删除、页面切换用withAnimation包住状态修改意图最清晰而某个控件的高亮、缩放用.animation(.easeOut(duration: 0.2), value: isPressed)就够了。注意老写法.animation(_:)不绑定 value 的那个在新版本里已经废弃原因是它会把动画作用到整个子树的所有变化上出问题极难排查。看到旧代码里有它优先改掉。5.2 转场与共享元素动画页面间的共享元素过渡比如列表缩略图放大成详情页大图是提升质感最有效的手段之一。传统做法是matchedGeometryEffect需要两个视图在同一个命名空间下配合if分支切换。它有几个约束两个视图必须在同一次渲染中出现或消失导航栈跳转时不好用。新版本系统提供了更省事的方式在NavigationLink上挂转场标识系统会自己处理跨页面的过渡。但这类 API 有明显版本门槛我的做法是降级策略新系统走新 API老系统退回到普通 push 过渡用条件编译或版本判断包起来。为了一个动效把最低版本抬上去通常不划算。还有一点值得提醒动画不要滥用。我给自己的规矩是一个页面最多两处主动动效多了反而显得廉价而且用户快速操作时容易看花眼。列表滚动过程中的动画更是要慎之又慎滚动时每一帧都在重建视图再叠动画就是双倍开销。5.3 手势与滚动联动手势这块最容易踩的是优先级和冲突。比如一个卡片上既有onTapGesture又有DragGesture点按经常会失效因为拖拽手势抢走了触摸。解决方式是给DragGesture设一个minimumDistance让它只在大幅拖动时才响应点按就正常了。滚动联动的经典需求是往上滑隐藏导航栏、往下滑显示。用.onScrollGeometryChange或者老的.coordinateSpaceGeometryReader都可以关键是别在滚动回调里同步修改视图状态——一帧内修改状态会触发重新布局滚动立刻掉帧。我一般用一个阈值判断加上状态防抖只有真的跨越阈值才更新中间过程一律忽略。6. 性能与调试视图重建的代价6.1 视图重建到底意味着什么很多人听到SwiftUI 每一帧都重建视图就慌了其实重建的是值类型的视图结构体很轻真正贵的是视图树差异计算和渲染提交。所以性能优化的方向不是减少重建次数这是框架的事而是减少每次重建要做的工作body里不要做耗时计算比如日期格式化、字符串拼接、大量数组过滤把复杂的子视图拆成独立的View结构体让差异比较能跳过没变的部分传给子视图的数据尽量Equatable配合EquatableView或直接依赖自动比较。我在一个详情页里做过对比把body里内联循环改成独立的行视图滚动帧率从 45 左右提到接近满帧。原因不复杂内联的循环每次父视图变化都要重新求值拆出去之后每行只在自身数据变化时更新。6.2 列表与图片的具体优化手段列表性能问题基本集中在三处图片尺寸、单元格复杂度、以及意外的全量刷新。图片这块最要命的是加载原图直接塞进小尺寸视图。一张 3000 像素宽的图解码到内存里是几十兆列表里有十几张就是灾难。正确的做法是在后台线程先做等比缩放到目标尺寸再缓存或者让服务端提供多档尺寸。这个改动我做下来列表内存峰值下降非常明显。意外全量刷新通常来自ForEach的 id 不稳定。如果 id 用的是UUID()每次重新生成或者用数组下标数据一变整个列表就会认为是全新内容全部重建。id 必须绑定真正稳定的业务主键。6.3 我常用的几个调试手段第一个是Self._printChanges()在body里调一下控制台会打出这次求值是因为哪个属性变化触发的。找出无谓刷新的源头特别有效比盲猜快多了。第二个是预览里的性能检查。新版 Xcode 的预览能直接看视图层级和重建次数调布局时比跑模拟器快得多。我现在写界面基本都在预览里完成只在涉及真机特性时才跑设备。第三个是 Instruments 的视图相关模板。滚动卡不卡用真机跑一遍录屏式分析最直接火焰图能一眼看出是布局计算吃满还是渲染提交超时。我遇到过一例是阴影和圆角同时作用在LazyVStack的每一行上导致离屏渲染去掉其中一个组合后帧率立刻正常。7. 踩坑速查表下面这些是我和周围人真实遇到过、并且反复出现的问题整理成表方便对照现象可能原因处理方式子页面返回后数据被重置子视图用了StateObject自行创建 ViewModel改为父视图持有子视图用ObservedObject控制台刷 Modifying state during view update在body求值中同步修改了状态移到.task/.onChange/DispatchQueue.main.asyncGeometryReader里内容跑到左上角它默认把子视图放在原点且自身占满显式加.frame(maxWidth: .infinity, maxHeight: .infinity)和居中文字莫名截断布局优先级默认相同被压缩加.layoutPriority(1)或.fixedSize()列表滚动掉帧单元格里图片未缩放、阴影圆角过度叠加后台缩放图片、简化视觉效果、拆分行视图列表整体重建ForEach的 id 不稳定用了下标或随机值绑定稳定的业务主键预览频繁崩溃预览里跑了网络请求或依赖了运行环境预览只喂静态 mock 数据环境对象取出时崩溃父链上漏了.environmentObject注入检查注入层级或改为显式传参手势互抢导致点击无效拖拽手势优先响应了触摸设minimumDistance或用simultaneously组合状态更新后界面不动引用类型没被观察未用State或未触发属性读取检查持有方式确认视图真的读了该属性再补几条不在表里但同样高频的不要在body里创建引用类型。每次重建都 new 一个对象虽然不报错但行为和你的预期完全不同数据看着偶尔会丢就是这个原因。不要在ForEach里用索引做删除。删一个元素后面的索引全错位出现删了 A 结果 B 消失了。用 id 定位或者在删除前先算出目标元素。List的行高不要算命。.frame(height:)写死之后动态字体一调大内容就被裁。用.fixedSize(horizontal: false, vertical: true)让高度自适应更稳。8. 工程化和渐进迁移的实操路线8.1 预览、模块拆分与依赖注入预览能不能救取决于你的视图有没有和外部世界强耦合。我踩过一个项目预览必崩原因是视图初始化时就去读了全局单例并触发网络请求。后来做了一次改造视图的依赖全部通过构造参数传入预览里塞 mock 数据崩溃问题直接消失顺带让测试也好写了。模块拆分上我的粒度是页面一个模块通用组件一个模块。好处是编译增量小改一个页面不用重编整个工程坏处是初始配置麻烦一些需要处理好访问级别。小项目其实没必要做那么细但一旦页面超过二三十个拆模块的收益会很快体现出来。8.2 多端适配的实际做法同一套代码要跑手机和平板最容易出问题的是宽度约束。手机上占满宽度的卡片到平板上横过来会变成一条极宽的横幅非常难看。我的处理是给内容加一个最大宽度再配合尺寸类做列数切换手机上单列平板横屏两列或三列。动态字体是另一个必须考虑的。我见过太多用固定行高的列表用户把字号调大后文字直接溢出。稳妥的做法是所有高度都让它自适应实在需要固定高度的比如图标按钮用.dynamicTypeSize限制上限至少保证不会排版崩坏。8.3 从 UIKit 项目渐进迁移的路径如果你手上是个还在维护的 UIKit 老项目我的建议是从新页面开始不要从首页开始。首页通常是全项目最复杂、改动风险最高的地方拿它试水等于给自己找麻烦。挑一个独立的设置页、关于页、消息列表用 SwiftUI 写用UIHostingController嵌进原有导航栈。第二步是把可复用的部分沉淀出来网络层、数据模型、图片缓存这些本来就不该和 UI 框架绑定的东西抽出来两边共用。等新页面数量攒到一定规模再考虑要不要把导航层也切过去。整个过程里每一次迁移都应该是可独立回滚的这是我在老项目上做重构唯一定下的死规矩——一次改一大片出问题根本不知道是哪一处引入的。最后说个我自己的体会。SwiftUI 上手第一周会很爽觉得什么都能几行搞定第二周到第一个月会开始骂觉得处处受限、报错莫名其妙过了这个阶段你会发现它的限制其实在逼你把状态想清楚。我迁过来的这些页面回头去看代码量少了一半但更重要的是每次改需求时我能明确知道改动会影响哪些状态这个确定性比省下的那点代码值钱得多。如果说有什么建议那就是别急着全面替换先用它写三个真实页面把状态、布局、性能这三关各摔一次摔完了再决定后面怎么走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑