UIKit 与 SwiftUI 混合开发

面向存量 UIKit 工程的渐进式迁移场景,讲清 UIHostingController 嵌入 SwiftUI、UIViewRepresentable 与 UIViewControllerRepresentable 封装 UIKit、Coordinator 桥接 delegate、两套导航与生命周期差异,以及尺寸布局键盘等常见坑,给出可落地的替换顺序与边界划分建议。

开篇

存量 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。

四、生命周期差异

两边生命周期模型的对照:

阶段SwiftUIUIKit
视图出现onAppearviewWillAppear / viewDidAppear
视图消失onDisappearviewWillDisappear / 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
    }
}

五、状态共享与内存

跨边界共享状态的三种模式:

  1. 闭包回传:UIKit 事件通过闭包传给 SwiftUI。最简单,适合单向、低频。
  2. 共享 ViewModel:两边持有同一个 @Observable 对象。适合双向、高频。UIKit 侧可以用 withObservationTracking 或 KVO(@Observable 类支持 KVO)观察变化。
  3. 通知/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。

六、渐进式迁移策略

不要"按功能模块整体重写",而要"按页面自底向上替换"。推荐顺序:

  1. 先建共享层:把数据模型、网络层、设计 token(颜色、字号、间距)抽成独立模块,两边共用。这一步不碰 UI,风险最低。
  2. 再换叶子页面:详情页、设置页、关于页这类没有复杂导航和自研控件的页面,优先用 SwiftUI 重写,通过 UIHostingController 接入现有导航。
  3. 然后换列表页:List 与 UICollectionView 的差距在复杂 cell 布局上最大,等设计 token 稳定后再换。
  4. 最后动导航容器:当 SwiftUI 页面占多数后,再把根 UINavigationController 换成 NavigationStack。
  5. 收尾:删除不再使用的 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"] 判断并跳过。

相关阅读

小结

UIKit 与 SwiftUI 混合开发没有"最优解",只有"边界清晰"。嵌入用 UIHostingController,封装用 UIViewRepresentable / UIViewControllerRepresentable,桥接用 Coordinator,状态共享要明确单一入口。导航不要双层嵌套,生命周期记住 onAppear 会重复触发而 viewDidLoad 不会。迁移顺序应当是"共享层 → 叶子页面 → 列表页 → 导航容器",每一步可独立发布可回滚。把跨边界的数据流收敛成"向下传值、向上回传事件",绝大多数混合开发的诡异 bug 都会消失。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全