开篇
存量 App 里"全量重写 SwiftUI"几乎不可能发生。真实情况是:一个跑了七八年的 UIKit 工程,新页面用 SwiftUI 写,老页面继续维护,两者共存至少两三年。于是混合开发的每一个接口点——嵌入、封装、导航、生命周期、状态、内存——都会变成实际问题。
最常见的三类需求:把一个新的 SwiftUI 页面塞进现有的 UINavigationController 流程;在 SwiftUI 页面里复用老的 MKMapView、WKWebView 或自研的复杂 UIKit 控件;以及让两边的状态能互相读到。本文按"嵌入 → 封装 → 导航 → 生命周期 → 状态 → 迁移策略 → 坑"的顺序讲,全部基于 iOS 17/18、Xcode 15/16。
先给结论:混合开发的核心是边界要清晰。凡是跨边界的数据流,都要有明确的单向入口和出口,否则状态会同时在两边存在,bug 会变得不可复现。
一、UIHostingController 把 SwiftUI 嵌入 UIKit
1.1 基本用法
UIHostingController 是一个 UIViewController 子类,把任意 View 包成控制器,因此可以直接参与 UIKit 的 push、present、addChild:
import SwiftUI
import UIKit
struct SettingsView: View {
@State private var notificationsEnabled = true
var body: some View {
Form {
Toggle("推送通知", isOn: $notificationsEnabled)
}
}
}
final class SettingsHostingController: UIHostingController<SettingsView> {
init() {
super.init(rootView: SettingsView())
title = "设置"
}
@available(*, unavailable)
required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 从 UIKit 侧 push
let vc = SettingsHostingController()
navigationController?.pushViewController(vc, animated: true)
注意 required init?(coder:) 必须处理。SwiftUI 场景下它永远不会被调用,但编译器强制要求,用 @available(*, unavailable) 标掉最干净。
1.2 尺寸与布局
UIHostingController 的 view 有 intrinsic content size,但只在理想尺寸下有效。如果嵌入到 UIStackView 或 Auto Layout 里,需要显式约束:
let host = UIHostingController(rootView: SettingsView())
addChild(host)
view.addSubview(host.view)
host.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
host.didMove(toParent: self)
addChild / didMove 这套调用不能省,否则子控制器的生命周期回调(viewWillAppear 等)不会正确转发。还有一个细节:如果 SwiftUI 内容高度是动态的(比如一个可展开列表),需要监听 host.view.systemLayoutSizeFitting 或让 SwiftUI 侧用 .fixedSize(horizontal: false, vertical: true)。
1.3 模态呈现与转场
从 UIKit 侧 present 一个 SwiftUI 页面,最常见的是半屏 sheet:
let host = UIHostingController(rootView: SettingsView())
host.modalPresentationStyle = .pageSheet
if let sheet = host.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
present(host, animated: true)
sheetPresentationController 是 iOS 15+ 的 API,detents 控制可停靠高度,配合 SwiftUI 内容就能得到原生的半屏体验。这里有个视觉坑:UIHostingController 的 view 背景取决于 SwiftUI 根视图,如果根视图没有背景,半屏 sheet 会透出下层内容。给根视图加 .background(Color(uiColor: .systemBackground)) 可以兜底。
反过来,从 SwiftUI 侧 present UIKit 页面,用 .sheet 或 .fullScreenCover 包一层 UIViewControllerRepresentable 即可。注意 UIViewControllerRepresentable 作为 sheet 内容时,它会自己带一个导航栏,若不想要需要显式隐藏。
1.4 从 UIKit 读取 SwiftUI 的更新
UIHostingController 本身不暴露内部状态。UIKit 侧要感知 SwiftUI 页面的变化,有两条路:一是让 SwiftUI 通过闭包/回调把事件送出来;二是两边共享同一个 @Observable 对象,UIKit 侧用 withObservationTracking 观察:
func observe(_ cart: SharedCart) {
withObservationTracking {
_ = cart.itemCount // 声明关心的属性
} onChange: {
DispatchQueue.main.async { // onChange 只触发一次,需要重新注册
self.badge.setTitle("\(cart.itemCount)", for: .normal)
self.observe(cart)
}
}
}
withObservationTracking 的 onChange 是一次性的,触发后必须重新注册,这是它和 Combine 订阅最大的区别,也是最容易漏的地方。
二、Representable 把 UIKit 包进 SwiftUI
反向封装有两个协议,选择标准很简单:封装的是 UIView 就用 UIViewRepresentable,是 UIViewController 就用 UIViewControllerRepresentable。
2.1 UIViewRepresentable
struct MapView: UIViewRepresentable {
var region: MKCoordinateRegion
var onRegionChange: (MKCoordinateRegion) -> Void
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
map.setRegion(region, animated: false)
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(onRegionChange: onRegionChange)
}
}
两个方法的分工必须记牢:makeUIView 只调用一次,负责创建;updateUIView 每次 SwiftUI 状态变化都会调用,负责把新状态同步给 UIKit 视图。updateUIView 里一定要判断是否需要更新,否则会形成"状态变 → 更新视图 → 视图回调 → 状态变"的死循环。
2.2 UIViewControllerRepresentable
封装系统控制器(如 UIImagePickerController、PHPickerViewController)用这个协议,结构和上面几乎一致,只是返回类型是控制器:
struct PhotoPicker: UIViewControllerRepresentable {
var onPicked: (UIImage) -> Void
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
}
2.3 Coordinator 与 delegate 桥接
UIKit 的回调是 delegate/target-action,SwiftUI 的输入是闭包。Coordinator 就是两者之间的适配器:它作为 delegate 接收 UIKit 事件,再调用 SwiftUI 传进来的闭包。
extension MapView {
final class Coordinator: NSObject, MKMapViewDelegate {
private let onRegionChange: (MKCoordinateRegion) -> Void
init(onRegionChange: @escaping (MKCoordinateRegion) -> Void) {
self.onRegionChange = onRegionChange
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
onRegionChange(mapView.region)
}
}
}
三个注意点:Coordinator 是 class,必须继承 NSObject(delegate 协议要求);闭包用 @escaping 存起来;如果闭包捕获了 self(视图),要注意循环引用——视图是值类型,通常不会,但闭包捕获的 ViewModel 可能是引用类型。
2.4 拆解、尺寸协商与清理
Representable 还有几个常被忽略的成员。第一个是拆解钩子:
static func dismantleUIView(_ uiView: MKMapView, coordinator: Coordinator) {
uiView.delegate = nil
coordinator.cancelPendingWork()
}
dismantleUIView 在视图从层级中移除时调用,是断开 delegate、取消任务、释放资源的正确位置,对应控制器版本是 dismantleUIViewController。不要指望 deinit——Coordinator 可能被别的对象持有,deinit 时机不可靠。
第二个是 iOS 16 引入的尺寸协商方法 sizeThatFits(_:uiView:context:),它让 SwiftUI 在布局阶段询问 UIKit 视图的理想尺寸,是解决"嵌入后高度塌陷"的正规做法:
func sizeThatFits(_ proposal: ProposedViewSize,
uiView: MKMapView,
context: Context) -> CGSize? {
CGSize(width: proposal.width ?? 0, height: 220)
}
第三个细节是 context.environment。在 makeUIView 里可以读到 SwiftUI 注入的环境值(colorScheme、dynamicTypeSize、locale),据此初始化 UIKit 视图的样式,这样明暗模式和动态字体会自动跟随系统。
三、导航混用
两套导航系统混用是最容易出问题的地方。规则如下:
- 不要把
NavigationView/NavigationStack嵌进UINavigationController再嵌NavigationStack。双层导航栏会叠在一起,返回手势失效。 - 正确做法:UIKit 主导航(
UINavigationController),SwiftUI 页面作为叶子节点被 push,页面内部不再使用NavigationStack,需要标题就用.navigationTitle+.toolbar,这些 modifier 会被UIHostingController自动桥接到父UINavigationItem。 - 反过来,SwiftUI 主导航时(
NavigationStack),用navigationDestination推 UIKit 页面,此时该页面是独立的UIViewController,它自带的导航栏会被外层接管,需要手动隐藏。
// SwiftUI 主导航时推 UIKit 页面
.navigationDestination(for: Route.self) { route in
switch route {
case .legacyDetail(let id):
LegacyDetailView(id: id) // 内部是 UIViewControllerRepresentable
.navigationBarBackButtonHidden(false)
}
}
toolbar 与 navigationItem 的桥接是单向的:SwiftUI 设置 .toolbar 会写进 UIHostingController 的 navigationItem,但 UIKit 侧后改的 navigationItem 不会反向同步回 SwiftUI。
四、生命周期差异
两边生命周期模型的对照:
| 阶段 | SwiftUI | UIKit |
|---|---|---|
| 视图出现 | onAppear | viewWillAppear / viewDidAppear |
| 视图消失 | onDisappear | viewWillDisappear / viewDidDisappear |
| 首次初始化 | init(可能多次) | viewDidLoad(一次) |
| 状态变化 | body 重跑 | 手动更新 |
关键差异有三条:
第一,SwiftUI 的 onAppear 不保证只调用一次。视图被移除再插入(比如切 Tab、if 分支切换)都会重新触发。UIKit 的 viewDidLoad 才是真正的"只一次"。所以"只执行一次的初始化"在 SwiftUI 里应该放在 @State 的初始化表达式或 ViewModel 的 init 里,而不是 onAppear。
第二,SwiftUI 的 init 会被频繁调用(每次父视图重建都会 init),所以 init 里不能做副作用。
第三,UIHostingController 的 viewWillAppear 会转发给内部 SwiftUI 视图的 onAppear,但时序上略有延迟。如果依赖"进入页面就发请求",推荐在 ViewModel 里用 .task { } 而不是 onAppear,因为 .task 会随视图消失自动取消。
struct FeedView: View {
@State private var model = FeedViewModel()
var body: some View {
List(model.posts) { post in
Text(post.title)
}
.task { await model.load() } // 视图消失自动取消,优于 onAppear
}
}
五、状态共享与内存
跨边界共享状态的三种模式:
- 闭包回传:UIKit 事件通过闭包传给 SwiftUI。最简单,适合单向、低频。
- 共享 ViewModel:两边持有同一个
@Observable对象。适合双向、高频。UIKit 侧可以用withObservationTracking或 KVO(@Observable类支持 KVO)观察变化。 - 通知/Combine:
NotificationCenter或PassthroughSubject作为总线。适合跨模块解耦,但要注意取消订阅。
@Observable
final class SharedCart {
var itemCount = 0
}
// UIKit 侧持有同一实例
final class CartBadgeButton: UIButton {
private let cart: SharedCart
init(cart: SharedCart) {
self.cart = cart
super.init(frame: .zero)
update()
}
func update() { setTitle("\(cart.itemCount)", for: .normal) }
}
内存风险集中在三处:Coordinator 持有闭包、闭包捕获了控制器;UIHostingController 被强引用循环持有(常见于把它存进自己的 rootView 里);Combine 的订阅没有取消。用 Instruments 的 Leaks 模板配合 deinit 打印是排查这类问题的标准手段。
5.1 用通知做跨边界解耦
当 UIKit 与 SwiftUI 分属不同模块、不想互相 import 时,用 NotificationCenter 或 Combine 的 PassthroughSubject 做总线是最轻的方案:
extension Notification.Name {
static let cartDidChange = Notification.Name("cart.didChange")
}
// SwiftUI 侧发布
.onChange(of: model.itemCount) { _, newValue in
NotificationCenter.default.post(name: .cartDidChange,
object: nil,
userInfo: ["count": newValue])
}
// UIKit 侧订阅(用 Combine 自动管理取消)
NotificationCenter.default.publisher(for: .cartDidChange)
.compactMap { $0.userInfo?["count"] as? Int }
.sink { [weak self] count in self?.badge.setTitle("\(count)", for: .normal) }
.store(in: &cancellables)
这套方案的代价是"类型安全靠约定"——userInfo 是 [AnyHashable: Any],取错类型只有运行时才知道。所以它适合"事件本身很简单、跨模块边界"的场景,模块内部的传递还是优先闭包或共享 ViewModel。
六、渐进式迁移策略
不要"按功能模块整体重写",而要"按页面自底向上替换"。推荐顺序:
- 先建共享层:把数据模型、网络层、设计 token(颜色、字号、间距)抽成独立模块,两边共用。这一步不碰 UI,风险最低。
- 再换叶子页面:详情页、设置页、关于页这类没有复杂导航和自研控件的页面,优先用 SwiftUI 重写,通过
UIHostingController接入现有导航。 - 然后换列表页:
List与UICollectionView的差距在复杂 cell 布局上最大,等设计 token 稳定后再换。 - 最后动导航容器:当 SwiftUI 页面占多数后,再把根
UINavigationController换成NavigationStack。 - 收尾:删除不再使用的 UIKit 桥接代码,把
Representable收敛到必要的最小集。
每一步都应该是可独立发布、可回滚的。判断"该换"的信号是:这个页面的 UIKit 代码半年内几乎没改过、且没有自定义手势和复杂动画。
6.1 用路由协议隔离 UI 层
迁移期最容易出现的反模式是"UIKit 页面直接 import SwiftUI 页面、SwiftUI 页面又 import UIKit 页面",形成双向依赖。用一层路由协议可以打断它:
protocol RouteBuilding {
func makeSettings() -> UIViewController
func makeLegacyDetail(id: Int) -> UIViewController
}
// SwiftUI 侧只依赖协议,不依赖具体 UIKit 类
final class LegacyFlow: RouteBuilding {
func makeSettings() -> UIViewController { SettingsHostingController() }
func makeLegacyDetail(id: Int) -> UIViewController { LegacyDetailVC(id: id) }
}
页面之间只通过 RouteBuilding 拿控制器,谁实现、用什么框架实现都藏起来。等到 UIKit 页面全部下线,删掉对应实现即可,调用方一行不用改。这是把"混合"限制在一个模块内的关键手段。
七、常见坑清单
- 尺寸塌陷:
UIHostingController.view放进UIStackView后高度变 0,通常是没有设translatesAutoresizingMaskIntoConstraints = false或 SwiftUI 内容没有确定高度。 - 布局冲突:SwiftUI 的
.frame(maxWidth: .infinity)与 UIKit 约束打架,表现为日志里大量 Auto Layout 警告。统一由一侧决定尺寸。 - 键盘遮挡:SwiftUI 的
.ignoresSafeArea(.keyboard)只对 SwiftUI 布局生效,嵌入 UIKit 容器后键盘避让要由容器负责,或让 SwiftUI 页面自己处理。 - SafeArea 双重内边距:UIKit 容器已经避让了安全区,SwiftUI 内部又避让一次,导致上下多出空白。用
.ignoresSafeArea()或.safeAreaInset精确控制。 - delegate 丢失:
makeUIView里设的 delegate 在视图被复用后失效,忘记在updateUIView里重新赋值。 updateUIView死循环:没有做值比较就无条件回写,导致状态抖动。加if判断。- 导航栏叠加:双层
NavigationStack/UINavigationController,表现为两条导航栏或返回按钮失灵。 onAppear重复发请求:把"只一次"的初始化逻辑写进onAppear,切 Tab 就重复请求。
FAQ
Q:一个页面里 SwiftUI 和 UIKit 各占一半,值不值?
如果这个页面已经存在且稳定,值;如果是新页面,不建议——两套布局系统共存会持续产生"谁负责尺寸"的争议。
Q:UIHostingController 能拿到 SwiftUI 的 @State 吗?
不能直接拿到。@State 是视图私有的。需要外部读写就用 Binding 从 rootView 传入,或在初始化时注入 ViewModel。
Q:Representable 里能用 async 吗?makeUIView 是同步的。异步初始化应该在 Coordinator 里起 Task,注意任务要在 dismantleUIView 或 deinit 里取消。
Q:迁移到 SwiftUI 后启动变慢了吗?UIHostingController 首次渲染有一次性开销(约几十毫秒),之后正常。如果启动路径上有 SwiftUI 页面,考虑延迟到首帧之后创建。
Q:SwiftUI 页面里的返回按钮怎么改成 UIKit 风格?.toolbar 里放 ToolbarItem(placement: .navigationBarLeading) 自定义按钮,或直接在 UIKit 容器里替换 navigationItem.leftBarButtonItem。后者需要 UIKit 侧持有 UIHostingController 的引用。
Q:UIViewRepresentable 支持 SwiftUI 的预览吗?
支持,PreviewProvider 里正常写即可。但 UIView 的初始化会在预览进程里执行,如果 makeUIView 里有网络或定位调用,预览会卡住,建议用 ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] 判断并跳过。
相关阅读
- SwiftUI 声明式界面与状态管理
— 理解
@State/@Observable才能设计好跨边界的共享状态 - Swift 并发 Combine 与 async/await — 混合开发中异步加载与取消的两种方案
- iOS 性能与 Instruments — 用 Leaks 与 Time Profiler 定位混合栈里的内存与卡顿
小结
UIKit 与 SwiftUI 混合开发没有"最优解",只有"边界清晰"。嵌入用 UIHostingController,封装用 UIViewRepresentable / UIViewControllerRepresentable,桥接用 Coordinator,状态共享要明确单一入口。导航不要双层嵌套,生命周期记住 onAppear 会重复触发而 viewDidLoad 不会。迁移顺序应当是"共享层 → 叶子页面 → 列表页 → 导航容器",每一步可独立发布可回滚。把跨边界的数据流收敛成"向下传值、向上回传事件",绝大多数混合开发的诡异 bug 都会消失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。