资讯动态

UIKit迁移SwiftUI:状态、布局、导航与性能避坑

发布时间:2026/10/26 21:58:02 来源:尧图企业网站定制
1. 从 UIKit 迁到 SwiftUI第一周最容易写崩的三件事接手一个半成品项目是我这几年最常遇到的场景。上一次是在一个已经写了两个月的小项目里代码里同时存在三种状态管理写法有人用State管列表数据有人用ObservableObject管一个按钮的开关还有人把整个页面的数据塞进了EnvironmentObject。我改了一个按钮的圆角结果三个不相关的页面同时崩了。这种经历大概每个从 UIKit 转过来的开发者都遇到过问题不在于 SwiftUI 难而在于它和 UIKit 的思维模型几乎是反着来的。SwiftUI 的核心是声明式 UI你描述界面在某一个状态下应该长什么样框架负责在状态变化时把差异算出来并刷新。这套机制一旦理解对了写起来极快理解错了就会出现我明明没改这里它为什么刷新了或者我明明改了数据界面为什么不动这类玄学。下面这三件事是我在带人、看代码、救火的过程中反复见到的崩点也是我认为刚上手时最该先建立的认知。1.1 视图是值不是对象结构体每帧重建的连锁反应在 UIKit 里UIViewController是一个长期存在的对象你创建它、持有它、它的属性会一直保留直到被释放。SwiftUI 里完全不是这样View是一个遵循View协议的结构体每次父视图body求值子视图结构体就会被重新构造一遍。很多人第一次看到这个机制会觉得那我的数据不就没了吗其实数据没丢因为真正被框架长期持有的是状态而不是视图本身。这个区别带来第一个实际影响不要指望在视图结构体里存可变数据。我见过有人在结构体里写var timer: Timer?然后在onAppear里赋值结果发现下一次刷新时 timer 变成 nil 了——因为那是另一个新建的结构体实例前面那个已经被丢掉。正确的做法是把这类需要跨刷新存活的东西放进State、放进观察对象或者干脆交给专门的服务层。第二个影响是初始化开销被放大了。body里每多一次重计算都会随着刷新频率成倍放大。比如有人在body里写let formatter DateFormatter()这在低频刷新时看不出问题一旦放进滚动列表每一行都重新构造一次格式化器卡顿就来了。我自己踩过一次一个日期显示的列表滚动时明显掉帧最后定位到就是这么一行。解决办法很简单把DateFormatter提成静态常量或者放进模型层。// 错误示范每一行都新建一次 var body: some View { let formatter DateFormatter() formatter.dateFormat MM-dd return Text(formatter.string(from: item.date)) } // 正确做法提成静态常量只构造一次 private static let dateFormatter: DateFormatter { let f DateFormatter() f.dateFormat MM-dd return f }() var body: some View { Text(Self.dateFormatter.string(from: item.date)) }这类问题的隐蔽之处在于它不会报错、不会崩溃只是慢慢变卡等你发现的时候已经积了一堆。1.2 状态归属错位State 放进子视图的那次事故第二个高频崩点是状态放错了层。State是给视图自身私有的、短生命周期的状态用的它跟着视图的身份走。一旦你把本该由父层持有的数据放进了子视图的State就会出现父层拿不到、子层改了自己又不同步的情况。我印象最深的一次是一个可以编辑的输入行。开发同学在行视图里写了State private var text: String用onAppear从模型里把初始值赋进去然后onChange再往外回调。单行测试一切正常但列表滚动复用之后来回滑动输入内容会串行——第 3 行显示的是第 5 行输入的内容。原因就是视图身份复用了State的值跟着身份走而他的赋值逻辑只在onAppear里跑了一次。判断一个状态该放哪里我一般用一条简单规则这个状态需不需要被外部读写。只在当前视图内部用、外部完全不关心放State需要传给子视图读写用Binding但值的所有权仍在父层需要跨多个页面共享、且有业务含义才升级成观察对象。按这个规则走90% 的错位问题会自然消失。还有一个小细节容易忽略State的初始值表达式只在视图第一次创建时求值一次之后父视图再怎么用新参数初始化它都不会覆盖已有的状态。很多人误以为它每次都会重置于是在里面写了依赖外部参数的计算逻辑结果参数变了界面不变。要跟随外部参数变化得用Binding或者用.id()强制重建——后者成本高慎用。1.3 隐式刷新的代价body 里藏着的重复计算第三个崩点是刷新副作用。SwiftUI 不会告诉你这次刷新是因为哪个状态变了它只会重新求值body。所以任何写在body里的、带有副作用的代码都会被反复执行。常见的错误写法包括在body里做网络请求、在body里写文件、在body里调用print打日志这个最容易被忽略日志刷屏本身就是性能损耗。我见过一个项目body里调了一个数据聚合方法内部是两层循环几百条数据。第一次卡了 40 毫秒没人管后来数据涨到几千条滑动就变成了幻灯片。处理这类问题的思路是把计算前移。能放进模型层初始化的就放进去能缓存成计算属性且依赖明确的就用缓存实在不行的用task或onChange触发。判断标准很简单如果一段代码执行两次会得到不同结果那它一定不该出现在body里。2. 数据流怎么摆局部状态、共享模型与全局环境的边界状态管理和数据流是 SwiftUI 里最需要拿捏的部分因为可选项太多而且新版本还在不断加新工具。工具多不代表可以随手挑每种方案背后对应的是不同的生命周期和所有权模型选错了后期改起来比重新写还麻烦。我的经验是先把数据按作用范围和生命周期两个维度分类再对着选工具比背 API 有效得多。2.1 State 加 Binding 撑起的局部闭环对于表单、开关、输入框、折叠展开这类完全局部的交互状态State配Binding是最干净的组合。父视图持有真值子视图通过Binding拿到一个可读可写的引用改动会直接写回父层。这套组合的关键优势是依赖关系清晰谁持有、谁读、谁写从声明上就能看出来。缺点是跨层级传递会很难看三层以上就要一层层往下传 Binding代码里全是穿透参数。遇到这种情况我会先停下来问一句这个状态真的需要穿这么深吗很多情况下答案是其实可以在中间层合并成一个模型把多个分散的 Binding 收拢成一个结构体传递成本立刻降下来。struct FilterState { var keyword: String var onlyStarred: Bool false var sortIndex: Int 0 } struct FilterBar: View { Binding var state: FilterState var body: some View { // 内部随意拆分子控件各取所需 VStack { TextField(搜索, text: $state.keyword) Toggle(只看收藏, isOn: $state.onlyStarred) } } }用结构体收拢还有个额外好处状态变化时可以用Equatable判断是否需要刷新减少无谓的视图更新。2.2 从 ObservableObject 到 Observable迁移时的三个坑新的观察宏出现之后我陆续把几个项目里的ObservableObject换掉了整体收益明显代码少了不需要再写Published而且框架能精确追踪属性级别变化不会一个属性变就全视图重刷。但迁移过程中确实踩了几个坑值得先说一下。第一个坑是初始化方式的差异。老的写法用StateObject保证对象在视图重建时不被重新创建新宏对应的写法是State同样能保证只初始化一次。如果混用比如对Observable类型用StateObject会直接编译不过反过来把ObservableObject放进State编译能过但行为不对对象会在每次重建时被重新构造之前的数据全丢。第二个坑是嵌套对象的观察深度。老的ObservableObject只观察被Published标记的属性嵌套结构里的变化不会自动传导新宏对嵌套的Observable类型同样能追踪但对普通结构体字段的变化还是需要整体替换才能触发。迁移时如果没注意到这层差异会出现改了内层、界面不动的诡异现象。第三个坑是与旧代码的混用。一个项目里如果同时存在两种观察机制一定要在命名和目录上做好区分我一般会在类型名前加前缀或者放在不同的文件夹里。混用最麻烦的地方是排错你看到一个视图不刷新第一反应是状态没变其实可能是观察机制不匹配白白浪费时间。2.3 Environment 用得好是高速公路用不好是黑箱环境注入是跨层级共享状态的利器主题、语言、登录态、全局配置都适合放进去。它的好处是子视图不需要层层传参在任意深度都能取到。坏处也来自这一点依赖关系被藏起来了。我接手过一个项目一个列表视图莫名其妙在主题切换时崩溃。翻代码列表本身跟主题毫无关系最后发现是深层的某个自定义组件从环境里取了一个配置对象取的时候做了强制解包而那个层级当时并没有注入。这种问题在纯参数传递的结构里根本不会出现因为编译器会拦住你。我的使用原则是环境只放真正全局、且读多写少的东西比如窗口尺寸类别、外观模式、用户偏好。业务数据尽量不用环境传哪怕多写几层参数。另外如果一个环境值在多个层级被读取但很少写入注意别把它做成高频变化的对象——环境值变化会导致所有读取它的视图重算范围难以预估。用EnvironmentObject或新宏的环境写法时还要特别注意崩溃兜底。没注入就取运行时会直接崩而且崩溃栈指向的地方往往离真正的问题很远。我的习惯是在开发阶段给关键的环境值加一个默认值把忘记注入从崩溃降级成显示异常更好排查。3. 布局的谈判机制frame、GeometryReader 与滚动列表布局是 SwiftUI 里最容易被误判的部分因为它看起来太像 CSS 了很多人凭直觉写结果和预期差很远。实际上它更像一场三方谈判父视图提出一个尺寸建议子视图根据自己的内容决定实际尺寸父视图再决定把它放在哪。理解了这个流程大部分为什么这个视图没撑满为什么这个文本被截断了的问题都能自己推出来。3.1 父提方案、子报尺寸、父定位置的三角关系SwiftUI 的布局是自上而下提议、自下而上回报的过程。父视图给子视图一个建议尺寸可能是具体数值也可能是nil表示你随意子视图返回自己实际需要的尺寸父视图再据此确定最终位置。这个机制解释了很多现象。比如给Text加.frame(width: 100)文字放不下就会被截断因为父视图明确只给 100子视图没法突破。反过来给一个Spacer加.frame(maxWidth: .infinity)就能撑满因为它在可分配的范围内尽量扩张。frame的三个参数经常被混用我整理了一张对照表供参考参数作用常见误用width/height强制指定尺寸子视图无法超出用来做最小宽度结果内容被压扁minWidth/maxWidth给一个取值范围由内容决定落在哪只写 max 不写 min小内容时表现异常idealWidth理想尺寸父视图空间充足时优先采用误以为它是默认宽度实际优先级最低我个人的习惯是能用padding解决的绝不写frame能用maxWidth: .infinity表达撑满的绝不写死数值。硬编码尺寸在字体放大、语言切换、横屏时几乎必然出问题这是我在适配无障碍字号时吃过亏换来的教训。3.2 GeometryReader 撑满与偏移的副作用GeometryReader是用来读取父视图可用空间的很多教程会推荐用它来做自适应布局。但它的一个特性经常被忽略它会尽可能占满所有可用空间而且它的子视图默认从左上角开始排布不是居中。这导致两个典型问题。第一把GeometryReader塞进VStack里它会把剩下的空间全吃掉导致其他元素被挤走。第二在GeometryReader里放一个居中的内容需要额外加.frame(maxWidth: .infinity, maxHeight: .infinity)才有效果很多人写完发现内容靠在左上角以为是 bug。我的建议是尽量不用它。需要按比例布局时用.containerRelativeFrame或者GeometryReader的替代方案需要读取尺寸做计算的场景优先考虑用布局协议自定义容器只有在确实需要根据容器尺寸动态调整时才用它并且把它的作用范围限制在最小。3.3 List 与 LazyVStack 的选择依据滚动列表有两个主要选择List和LazyVStack配合ScrollView。很多人因为List的默认样式不好改而一律用LazyVStack也有人因为List自带能力而一律用它两种极端都会带来问题。List的优势在于系统级的行复用、滑动删除、编辑模式、分隔线、选中态、无障碍支持这些都是现成的自己实现成本很高。缺点是样式定制受限尤其是分隔线和内边距历史版本里需要各种 hack 绕。LazyVStack的优势是布局完全自由缺点是没有行复用——它只懒加载、不复用行数上千时会持续占用内存。我在一个消息列表里用过三千多条消息时内存明显上涨后来改回List才压下来。选择依据可以归纳为需要系统交互能力、行数可能很大用 List需要复杂自定义布局、行数可控用 LazyVStack。如果既要自定义又要复用可能需要考虑底层的滚动容器方案成本会高不少。4. 导航与路由值驱动跳转怎么落地导航这块在新版本里改过一次大方向从视图驱动变成了值驱动。这个转变的意义不只是 API 换了而是整个页面组织方式可以重构跳转目标可以用枚举表达、可以用数据描述路由表和页面实现可以彻底解耦。对于需要做深度跳转、页面恢复、状态回退的项目这个能力很关键。4.1 NavigationStack 的 path 设计值驱动导航的核心是一个绑定到栈上的路径数组。每次往数组里 push 一个值栈就多一层pop 就是移除。这个数组可以是简单的类型数组也可以是自定义的枚举。用枚举的优势是路径可以被序列化、可以被统一分发配合集中式的路由表跳转逻辑能收敛到一处。我的做法一般是这样组织的enum Route: Hashable { case detail(id: String) case profile(userID: String) case settings } struct RootView: View { State private var path: [Route] [] var body: some View { NavigationStack(path: $path) { HomeView() .navigationDestination(for: Route.self) { route in switch route { case .detail(let id): DetailView(id: id) case .profile(let uid): ProfileView(userID: uid) case .settings: SettingsView() } } } } }这样做的直接好处是任何地方只要拿到path的绑定就能从任意层级跳转到任意页面不需要一层层往下传导航链接。深度跳转的实现也简化成构造一个路径数组然后赋值比如从通知打开某条详情路径可能是[.profile(uid), .detail(id)]一次赋值就完成了多层栈的构建。要注意的是navigationDestination的注册位置。它必须挂在栈内部、且在路径覆盖的范围内挂错位置会导致跳转无效或者出现运行时警告。我踩过的坑是把它挂在NavigationStack外层编译能过点击毫无反应找了好一会儿。4.2 深层跳转与返回时的状态残留路径数组解决了一层层的跳转但也带来了新的问题返回时的状态残留。因为你把整个路径保存下来了用户从深层页返回后那部分数据还在路径里如果你没做清理下次再进可能看到旧数据。还有一种情况是路径里的 id 对应的数据已经被删除了。用户从列表进入详情在别的地方删掉了这条数据然后点返回再前进页面就崩了。处理方式是在navigationDestination的构建闭包里对数据做存在性校验拿不到就展示一个空状态或者直接触发一次 pop别让视图里出现强制解包。我一般会在路由表里加一层转换路径里存的是轻量的标识页面构建时再去数据源查询。这样即使数据变化也只是降级展示不会崩。标识尽量用不可变的 id别直接存整个模型对象——模型对象放进路径数组里会导致路径的比较和序列化都变复杂。5. 和 UIKit 混编桥接层的生命周期对齐现实项目里完全用 SwiftUI 从零开始的机会不多大多数是混编。混编本身不复杂麻烦的是两套生命周期模型的差异UIKit 的视图控制器有明确的出现、消失、布局回调SwiftUI 的视图是值没有生命周期这个概念。桥接层的职责本质上就是把这两套模型对齐。5.1 UIViewRepresentable 的协调器职责把 UIKit 视图包进 SwiftUI需要实现三个方法创建视图、更新视图、销毁视图。其中最容易被写错的是更新。updateUIView会在 SwiftUI 认为需要同步时被调用频率不固定可能很频繁。很多人在这里做全量重建比如每次都重新设置一遍数据源、重新注册一遍回调结果性能很差还会出现状态被重置的问题。正确的做法是只更新变化的部分并且用协调器Coordinator持有回调和代理。协调器的作用是承接 UIKit 那边的代理方法把事件转发回 SwiftUI 层。这里有个细节协调器里持有的闭包会捕获外部变量如果捕获的是视图结构体里的值可能拿到的是旧值。我一般通过parent属性或者绑定的方式传递保证拿到的是最新的。struct MapWrapper: UIViewRepresentable { Binding var region: MKCoordinateRegion func makeUIView(context: Context) - MKMapView { let view MKMapView() view.delegate context.coordinator return view } func updateUIView(_ uiView: MKMapView, context: Context) { // 只在区域差异明显时才更新避免无谓刷新 if !uiView.region.isApproximatelyEqual(to: region) { uiView.setRegion(region, animated: false) } } func makeCoordinator() - Coordinator { Coordinator(self) } final class Coordinator: NSObject, MKMapViewDelegate { private let parent: MapWrapper init(_ parent: MapWrapper) { self.parent parent } // 代理回调里把变化写回 parent.region } }上面这段只是示意结构真正的关键是那个只在差异明显时更新的判断。缺了它地图会陷入更新触发代理回调、回调触发状态变化、状态变化触发更新的循环表现为地图一直在抖。5.2 混编页面里的线程与内存注意点线程问题在混编里格外突出。SwiftUI 的视图更新默认在主线程UIKit 的大部分操作也必须在主线程但业务层的异步回调可能不在主线程。如果从异步回调里直接改绑定值会出现运行时警告严重时界面错乱。我的处理习惯是在桥接层的入口统一做一次主线程派发别指望每个回调点都记得加。另外UIKit 视图的强引用关系要特别注意协调器持有父视图、父视图的绑定又被协调器回调修改这种循环在桥接结构里很常见。用weak或者明确的解绑时机来打断别等到内存曲线一路向上才回头查。还有一个容易忽略的点是键盘和手势。SwiftUI 的键盘避让是自动的嵌进了 UIKit 视图之后自动避让可能失效需要手动监听键盘通知做偏移。手势冲突也是父层的滚动手势和 UIKit 内部的滑动手势会打架得靠gesture的优先级或者让 UIKit 那边主动让出。6. 真机才暴露的问题性能与调试预览里跑得飞起的界面上了真机可能完全不是一回事。这不是玄学而是预览环境的设备性能和真机差异、以及一些只在真实数据量下才暴露的问题。我在真机上遇到的性能问题绝大部分可以归到两类视图体太胖和资源处理时机不对。6.1 视图体膨胀的拆分标准一个body写到两三百行是很多项目的常态。编译器对复杂表达式有类型推断的时间上限超过就报表达式过于复杂的编译错误——这是显性症状好处理。更麻烦的是隐性症状视图体太大任意一个状态变化都会导致整块重算哪怕只有一个小角落依赖那个状态。拆分标准我一般按两条走。第一条是依赖边界如果两块 UI 依赖的状态完全不相交就拆成两个子视图让刷新范围收窄。第二条是复用边界如果一段 UI 在多个地方出现或者可能复用就抽成独立组件但不要在还没复用时过度抽象。拆分时有个容易犯的错把状态也一起拆下去了。前面说过状态放错层会引发一堆问题。拆视图的时候状态的所有权应该留在原来的位置通过参数或绑定往下传而不是跟着视图一起搬走。6.2 动画掉帧与图片解码位置图片相关的性能问题特别典型。列表里展示网络图片如果不做处理每次滚动到新行都会触发一次解码解码在主线程就会掉帧。表现是滑动时一顿一顿的但用静态图片测试完全正常。解决办法是把解码提前。加载完数据后在后台队列做一次解码把解码后的图缓存起来滚动时直接取用。另外图片尺寸要按显示尺寸处理别拿原图直接塞进小控件——一张 4000 像素宽的图缩到 80 点显示内存和渲染成本都是浪费。动画掉帧还有一类原因是不必要的隐式动画。给一个容器加了动画修饰符容器内所有属性变化都会走动画包括那些你根本不想动画的布局变化。我遇到过一次列表刷新时每一行都在做淡入淡出看起来像是加载动画实际上是外层动画串到了内层。定位方式是把动画修饰符的范围收窄到具体属性用animation(_:value:)绑定到明确的触发值上问题立刻消失。调试这类问题我的习惯是先怀疑刷新频率再怀疑单次开销。加个计数器在body里看刷新次数往往能立刻发现异常——正常页面滚动时刷新次数应该是可控的如果每一帧都在刷那说明有状态在滚动过程中被反复修改。7. 几个我反复用到的实操习惯写了这么久有些习惯已经变成肌肉记忆这里挑几个最实用的说说。第一个是先跑通数据流再写 UI。很多人习惯先把界面画出来再往里接数据结果接的时候发现状态放错层要重构一大片。我的顺序反过来先把状态的所有权和流向理清楚写成最小的模型再往上搭界面。这样界面写起来几乎不需要返工。第二个是给每个自定义组件写一个带边界数据的预览。空数据、超长文本、极端字号、深色模式这四个场景能提前暴露大部分布局问题。预览里加一组就能覆盖成本极低收益很高。第三个是遇到界面不刷新先查三层状态是不是真的变了、观察机制是不是匹配、读取的地方是不是在正确的层级。按这个顺序查基本不用盲猜。第四个是别急着追新 API。新版本的能力确实好用但如果项目还要支持较旧的系统版本条件编译的复杂度会上来。我一般等主线功能稳定之后再挑收益最大的部分迁移一次只动一块出问题好回退。最后分享一个我自己的体会SwiftUI 的学习曲线不是陡是拐弯。UIKit 的经验在前两周几乎是负资产你会不停地用旧模型去套新框架然后被反复打脸。真正的转折点出现在你不再问这个视图什么时候被创建而是开始问这个状态变化会影响到哪些视图的时候。从那一刻起写 SwiftUI 才算是入门了。

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

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

免费获取报价 →
↑