mcpField
A
axiom-uikit-bridging

axiom-uikit-bridging

github.com/charleswiltgen开发1.2k100 次安装今天更新
代码仓库

关于 axiom-uikit-bridging

面向 iOS 开发者的 UIKit 与 SwiftUI 桥接参考技能,说明如何将 UIKit 视图、控制器和手势识别器嵌入 SwiftUI,也介绍在 UIKit 中承载 SwiftUI。内容包含生命周期、状态同步、协调器和布局要点。

主要功能

  1. 01封装 UIView 与 UIViewController
  2. 02桥接 UIKit 手势识别器
  3. 03在 UIKit 中嵌入 SwiftUI
  4. 04同步 UIKit 与 SwiftUI 状态
  5. 05处理生命周期和布局

适用场景

  1. 01在 SwiftUI 中使用 MKMapView 或 WKWebView
  2. 02将照片选择器等控制器接入 SwiftUI
  3. 03把 SwiftUI 视图放入 UIKit 导航
  4. 04在 UIKit 单元格中呈现 SwiftUI 内容

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikit-bridging

安装后在 Claude Code 中输入/axiom-uikit-bridging确认已加载。

详细介绍

UIKit-SwiftUI 桥接

UIKit 与 SwiftUI 桥接的系统指南。大多数生产级 iOS 应用都需要同时使用两者——本技能讲解桥接模式本身,而非被桥接的特定领域视图。

决策框架

digraph bridge {
    start [label="What are you bridging?" shape=diamond];

    start -> "UIViewRepresentable" [label="UIView subclass → SwiftUI"];
    start -> "UIViewControllerRepresentable" [label="UIViewController → SwiftUI"];
    start -> "UIGestureRecognizerRepresentable" [label="UIGestureRecognizer → SwiftUI\n(iOS 18+)"];
    start -> "UIHostingController" [label="SwiftUI view → UIKit"];
    start -> "UIHostingConfiguration" [label="SwiftUI in UIKit cell\n(iOS 16+)"];

    "UIViewRepresentable" [shape=box];
    "UIViewControllerRepresentable" [shape=box];
    "UIGestureRecognizerRepresentable" [shape=box];
    "UIHostingController" [shape=box];
    "UIHostingConfiguration" [shape=box];
}

快速规则:

  • 封装 UIView → UIViewRepresentable(第 1 部分)
  • 封装 UIViewController → UIViewControllerRepresentable(第 2 部分)
  • 封装 UIGestureRecognizer 子类 → UIGestureRecognizerRepresentable(第 2b 部分,iOS 18+)
  • 在 UIKit 导航中嵌入 SwiftUI → UIHostingController(第 3 部分)
  • 在 UICollectionView/UITableView 单元格中使用 SwiftUI → UIHostingConfiguration(第 3 部分)
  • 在 UIKit 与 SwiftUI 之间共享状态 → @Observable 共享模型(第 4 部分)

第 1 部分:UIViewRepresentable — 封装 UIView

当你有一个 UIView 子类(MKMapView、WKWebView、自定义绘图视图)并需要在 SwiftUI 中使用它时,请使用此方式。

如需了解全面的 MapKit 模式以及 SwiftUI Map 与 MKMapView 的选择,请参阅 axiom-mapkit。

生命周期

makeUIView(context:)         → Called ONCE. Create and configure the view.
updateUIView(_:context:)     → Called on EVERY SwiftUI state change. Patch, don't recreate.
dismantleUIView(_:coordinator:) → Called when removed from hierarchy. Clean up observers/timers.

重要提示:updateUIView 会频繁调用。请避免不必要的操作:

struct MapView: UIViewRepresentable {
    let region: MKCoordinateRegion

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView()
        map.delegate = context.coordinator
        return map
    }

    func updateUIView(_ map: MKMapView, context: Context) {
        // ✅ Guard: only update if region actually changed
        if map.region.center.latitude != region.center.latitude
            || map.region.center.longitude != region.center.longitude {
            map.setRegion(region, animated: true)
        }
    }

    static func dismantleUIView(_ map: MKMapView, coordinator: Coordinator) {
        map.removeAnnotations(map.annotations)
    }
}

状态同步

状态会通过桥接双向流动:

SwiftUI → UIKit:通过 updateUIView。SwiftUI 状态发生变化时会触发此方法。

UIKit → SwiftUI:通过 Coordinator,在父结构体上使用 @Binding。

struct SearchField: UIViewRepresentable {
    @Binding var text: String
    @Binding var isEditing: Bool

    func makeUIView(context: Context) -> UISearchBar {
        let bar = UISearchBar()
        bar.delegate = context.coordinator
        return bar
    }

    func updateUIView(_ bar: UISearchBar, context: Context) {
        bar.text = text  // SwiftUI → UIKit
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, UISearchBarDelegate {
        var parent: SearchField

        init(_ parent: SearchField) { self.parent = parent }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            parent.text = searchText  // UIKit → SwiftUI
        }

        func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
            parent.isEditing = true  // UIKit → SwiftUI
        }

        func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
            parent.isEditing = false
        }
    }
}

布局属性警告

SwiftUI 负责可表示视图的布局。切勿修改被封装 UIView 的 center、bounds、frame 或 transform——根据 Apple 文档,这属于未定义行为。SwiftUI 会在布局过程中设置这些属性。如果需要自定义尺寸,请在 UIView 上重写 intrinsicContentSize,或使用 sizeThatFits(_:)。

Coordinator 模式

Coordinator 是一种引用类型(class),它:

  1. 充当 UIKit 视图的代理/数据源
  2. 持有对父 UIViewRepresentable 结构体的引用
  3. 将 UIKit 回调桥接回 SwiftUI 的 @Binding 属性

makeCoordinator() 是可选的——如果 UIKit 视图不需要代理回调或 UIKit→SwiftUI 通信(例如,仅用于静态显示的视图),可以省略。

为什么不用闭包? 闭包会捕获 self 并造成循环引用。Coordinator 模式提供了一个稳定的引用类型,由 SwiftUI 管理。

// ❌ Closure-based: retain cycle risk, no delegate protocol support
func makeUIView(context: Context) -> UITextField {
    let field = UITextField()
    field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // Won't compile — self is a struct
    return field
}

// ✅ Coordinator: clean lifecycle, delegate support
func makeCoordinator() -> Coordinator { Coordinator(self) }

class Coordinator: NSObject, UITextFieldDelegate {
    var parent: SearchField
    init(_ parent: SearchField) { self.parent = parent }

    func textFieldDidChangeSelection(_ textField: UITextField) {
        parent.text = textField.text ?? ""
    }
}

尺寸设置

UIViewRepresentable 视图会参与 SwiftUI 布局。可通过以下方式控制尺寸:

// If the UIView has intrinsicContentSize, SwiftUI respects it
// For views without intrinsic size (MKMapView, WKWebView), set a frame:
MapView(region: region)
    .frame(height: 300)

// For views that should size to fit their content:
WrappedLabel(text: "Hello")
    .fixedSize()  // Uses intrinsicContentSize

重写 sizeThatFits(_:),以自定义尺寸提议:

struct WrappedLabel: UIViewRepresentable {
    let text: String

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.numberOfLines = 0
        return label
    }

    func updateUIView(_ label: UILabel, context: Context) {
        label.text = text
    }

    // Custom size proposal — SwiftUI calls this during layout
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
        let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
        return uiView.systemLayoutSizeFitting(
            CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        )
    }
}

跟踪滚动的导航栏(iOS 15+)

封装 UIScrollView 子类时,告知导航栏要跟踪哪个滚动视图,以便折叠大标题:

func makeUIView(context: Context) -> UITableView {
    let table = UITableView()
    return table
}

func updateUIView(_ table: UITableView, context: Context) {
    // Tell the nearest navigation controller to track this scroll view
    // for inline/large title transitions
    if let navController = sequence(first: table as UIResponder, next: \.next)
        .compactMap({ $0 as? UINavigationController }).first {
        navController.navigationBar.setContentScrollView(table, forEdge: .top)
    }
}

如果不这样做,滚动封装的 UIScrollView 时,导航栏的大标题不会折叠。

动画桥接

使用 context.transaction.animation 将 SwiftUI 动画桥接到 UIKit:

func updateUIView(_ uiView: UIView, context: Context) {
    if context.transaction.animation != nil {
        UIView.animate(withDuration: 0.3) {
            uiView.alpha = isVisible ? 1 : 0
        }
    } else {
        uiView.alpha = isVisible ? 1 : 0
    }
}

iOS 18+ 动画统一:可以通过 UIView.animate(_:) 将 SwiftUI 动画直接应用于 UIKit 视图。不过,请注意以下不兼容情况:

  • SwiftUI 动画不基于 CAAnimation——它们使用不同的渲染路径
  • 与 UIViewPropertyAnimator 和 UIView 关键帧动画不兼容
  • 速度重定向:重定向后的 SwiftUI 动画会延续被中断动画的速度,从而实现流畅过渡

如需了解全面的动画桥接模式,请参阅 /skill axiom-swiftui-animation-ref 第 10 部分。


第 2 部分:UIViewControllerRepresentable — 封装 UIViewController

封装完整的 UIViewController 时,请使用此方式——例如选择器、邮件撰写、Safari、相机,或任何管理自身视图层级的控制器。

生命周期

makeUIViewController(context:)         → Called ONCE. Create and configure.
updateUIViewController(_:context:)     → Called on SwiftUI state changes.
dismantleUIViewController(_:coordinator:) → Cleanup.

标准示例:PHPickerViewController

struct PhotoPicker: UIViewControllerRepresentable {
    @Binding var selectedImages: [UIImage]
    @Environment(\.dismiss) private var dismiss

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var config = PHPickerConfiguration()
        config.selectionLimit = 5
        config.filter = .images
        let picker = PHPickerViewController(configuration: config)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ picker: PHPickerViewController, context: Context) {
        // PHPicker doesn't support updates after creation
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        var parent: PhotoPicker

        init(_ parent: PhotoPicker) { self.parent = parent }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            parent.selectedImages = []
            for result in results {
                result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
                    if let image = image as? UIImage {
                        DispatchQueue.main.async {
                            self.parent.selectedImages.append(image)
                        }
                    }
                }
            }
            parent.dismiss()
        }
    }
}

当控制器显示自己的界面时

某些控制器(UIImagePickerController、MFMailComposeViewController、SFSafariViewController)会显示自己的全屏界面。请通过协调器处理关闭:

class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
    var parent: MailComposer

    func mailComposeController(_ controller: MFMailComposeViewController,
                                didFinishWith result: MFMailComposeResult, error: Error?) {
        parent.dismiss()  // Let SwiftUI handle the dismissal
    }
}

不要在协调器中直接调用 controller.dismiss(animated:)——应由 SwiftUI 的 @Environment(\.dismiss) 或控制显示状态的绑定来处理。

显示上下文

封装的控制器不会自动继承 SwiftUI 的导航上下文。如果需要让控制器推送到导航堆栈,需要在 NavigationStack 中使用 UIViewControllerRepresentable,并让控制器访问导航控制器:

// ❌ This won't push — the controller has no navigationController
struct WrappedVC: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> MyViewController {
        let vc = MyViewController()
        vc.navigationController?.pushViewController(otherVC, animated: true) // nil
        return vc
    }
}

// ✅ Present modally instead, or use UIHostingController in a UIKit navigation flow
.sheet(isPresented: $showPicker) {
    PhotoPicker(selectedImages: $images)
}

第 2b 部分:UIGestureRecognizerRepresentable(iOS 18+)

当需要在 SwiftUI 中使用 UIKit 手势识别器时使用——适用于 SwiftUI 原生手势 API 不支持的手势(自定义子类、精确的 UIKit 手势状态机、命中测试控制)。

iOS 18 之前的回退方案:将手势识别器附加到一个透明的 UIView,并用 UIViewRepresentable 封装,再使用协调器作为 target/action 接收方(参见第 1 部分的协调器模式)。这样会失去 CoordinateSpaceConverter,但可以直接使用识别器的 location(in:)。

生命周期

makeUIGestureRecognizer(context:)              → Called ONCE. Create the recognizer.
handleUIGestureRecognizerAction(_:context:)    → Called when the gesture is recognized.
updateUIGestureRecognizer(_:context:)          → Called on SwiftUI state changes.
makeCoordinator(converter:)                    → Optional. Create coordinator for state.

无需手动设置 target/action——系统会管理 action target 的安装。改为实现 handleUIGestureRecognizerAction。

标准示例:带位置的长按

struct LongPressGesture: UIGestureRecognizerRepresentable {
    @Binding var pressLocation: CGPoint?

    func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
        let recognizer = UILongPressGestureRecognizer()
        recognizer.minimumPressDuration = 0.5
        return recognizer
    }

    func handleUIGestureRecognizerAction(
        _ recognizer: UILongPressGestureRecognizer, context: Context
    ) {
        switch recognizer.state {
        case .began:
            // localLocation converts UIKit coordinates to SwiftUI coordinate space
            pressLocation = context.converter.localLocation
        case .ended, .cancelled:
            pressLocation = nil
        default:
            break
        }
    }
}

// Usage
struct ContentView: View {
    @State private var pressLocation: CGPoint?

    var body: some View {
        Rectangle()
            .gesture(LongPressGesture(pressLocation: $pressLocation))
    }
}

CoordinateSpaceConverter

context.converter 可将 UIKit 手势坐标转换到 SwiftUI 坐标空间:

属性/方法说明
localLocation手势在所附加的 SwiftUI 视图空间中的位置
localTranslation手势在局部空间中的移动
localVelocity手势在局部空间中的速度
location(in:)将位置转换到某个祖先坐标空间
translation(in:)将移动转换到某个祖先坐标空间
velocity(in:)将速度转换到某个祖先坐标空间

何时使用它,而非 SwiftUI 手势

需求使用
标准点按、拖动、长按、旋转、缩放SwiftUI 原生手势
自定义 UIGestureRecognizer 子类UIGestureRecognizerRepresentable
精确控制手势状态机(.possible、.began、.changed 等)UIGestureRecognizerRepresentable
需要通过 delegate 方法处理失败条件或同时识别的手势配合协调器使用 UIGestureRecognizerRepresentable
UIKit 与 SwiftUI 之间的坐标空间转换UIGestureRecognizerRepresentable(内置转换器)

第 3 部分:UIHostingController——UIKit 中的 SwiftUI

在现有 UIKit 导航层级中嵌入 SwiftUI 视图时使用。

基本嵌入

// Push onto UIKit navigation stack
let profileView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: profileView)
navigationController?.pushViewController(hostingController, animated: true)

// Present modally
let settingsView = SettingsView()
let hostingController = UIHostingController(rootView: settingsView)
hostingController.modalPresentationStyle = .pageSheet
present(hostingController, animated: true)

子视图控制器嵌入

作为子 VC 嵌入时(例如,在 UIKit 布局中嵌入 SwiftUI 卡片):

let swiftUIView = StatusCard(status: currentStatus)
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.sizingOptions = .intrinsicContentSize  // iOS 16+

addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    hostingController.view.topAnchor.constraint(equalTo: headerView.bottomAnchor)
])
hostingController.didMove(toParent: self)

sizingOptions: .intrinsicContentSize(iOS 16+)会使宿主控制器向 Auto Layout 报告其 SwiftUI 内容尺寸。若不设置,宿主控制器的视图没有固有尺寸,完全依赖约束。

sizingOptions 的取值(iOS 16+,OptionSet):

  • .intrinsicContentSize — SwiftUI 内容变化时自动使固有内容尺寸失效
  • .preferredContentSize — 在控制器的 preferredContentSize 中跟踪内容的理想尺寸

显式尺寸查询

使用 sizeThatFits(in:) 计算 SwiftUI 内容在 Auto Layout 集成时的首选尺寸:

let hostingController = UIHostingController(rootView: CompactCard(item: item))

// Query preferred size for a given width constraint
let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
// Returns the optimal CGSize for the SwiftUI content

当需要在将宿主控制器添加到视图层级之前获取其尺寸,或在仅靠 sizingOptions 不足以满足需求的上下文中嵌入时(例如,手动设置弹出框内容尺寸),这会很有用。

环境桥接

标准系统环境值(colorScheme、sizeCategory、locale)会通过 UIKit 特征系统自动桥接。父级 SwiftUI 视图中的自定义 @Environment 键不会自动桥接,除非使用 UITraitBridgedEnvironmentKey。

选项 1:显式注入(最简单,适用于所有版本):

let view = DetailView(store: appStore, theme: currentTheme)
let hostingController = UIHostingController(rootView: view)

选项 2:UITraitBridgedEnvironmentKey(iOS 17+,双向桥接):

在 UIKit 特征和 SwiftUI 环境之间桥接自定义环境值:

// 1. Define a UIKit trait
struct FeatureOneTrait: UITraitDefinition {
    static let defaultValue = false
}

extension UIMutableTraits {
    var featureOne: Bool {
        get { self[FeatureOneTrait.self] }
        set { self[FeatureOneTrait.self] = newValue }
    }
}

// 2. Define a SwiftUI EnvironmentKey
struct FeatureOneKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    var featureOne: Bool {
        get { self[FeatureOneKey.self] }
        set { self[FeatureOneKey.self] = newValue }
    }
}

// 3. Bridge them
extension FeatureOneKey: UITraitBridgedEnvironmentKey {
    static func read(from traitCollection: UITraitCollection) -> Bool {
        traitCollection[FeatureOneTrait.self]
    }
    static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
        mutableTraits.featureOne = value
    }
}

现在,@Environment(\.featureOne) 会自动双向同步——UIKit traitOverrides 的更新会传递到 SwiftUI 视图,而 SwiftUI .environment(\.featureOne, true) 的更新会传递到 UIKit 视图。

要将值从 UIKit 传递到托管的 SwiftUI 内容:

// In any UIKit view controller — flows down to UIHostingController children
viewController.traitOverrides.featureOne = true

UIHostingConfiguration(iOS 16+)

使用 SwiftUI 视图作为 UICollectionView 或 UITableView 单元格:

cell.contentConfiguration = UIHostingConfiguration {
    HStack {
        Image(systemName: item.icon)
            .foregroundStyle(.tint)
        VStack(alignment: .leading) {
            Text(item.title)
                .font(.headline)
            Text(item.subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
    }
}
.margins(.all, EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.minSize(width: nil, height: 44)  // Minimum tap target height
.background(.quaternarySystemFill)  // ShapeStyle background

单元格被裁剪? UIHostingConfiguration 单元格会自动调整尺寸。如果单元格被裁剪,集合视图布局可能使用了固定的 itemSize——请在组合布局中改用 estimated 尺寸,让单元格能够扩展以适配 SwiftUI 内容。

相较于完整 UIHostingController 的优势
  • 无需管理子视图控制器
  • 自动调整单元格尺寸
  • 状态变化时自动使尺寸失效并重新计算
  • 与可差异化数据源兼容
何时使用 UIHostingConfiguration,何时使用 UIHostingController
场景使用
UICollectionView/UITableView 中的单元格内容UIHostingConfiguration
全屏页面或导航目标UIHostingController
布局中的子 VCUIHostingController
覆盖层或装饰视图补充视图中的 UIHostingConfiguration

导航栏的滚动跟踪

当 UIHostingController 包含滚动视图并被推入 UINavigationController 时,大标题可能无法折叠。请使用 setContentScrollView:

let hostingController = UIHostingController(rootView: ScrollableListView())

// After pushing, tell the nav bar to track the scroll view
if let scrollView = hostingController.view.subviews.compactMap({ $0 as? UIScrollView }).first {
    navigationController?.navigationBar.setContentScrollView(scrollView, forEdge: .top)
}

在 UIKit 导航中嵌入 SwiftUI List 或 ScrollView 时,这是一个常见问题。

混合布局中的键盘处理

混用 UIKit 和 SwiftUI 时,键盘避让可能不会自动生效。对于包含 SwiftUI 内容的 UIKit 布局,请使用 UIKeyboardLayoutGuide(iOS 15+)基于约束跟踪键盘:

// Constrain the hosting controller's view above the keyboard
hostingController.view.bottomAnchor.constraint(
    equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true

第 4 部分:使用 @Observable 共享状态

当 UIKit 和 SwiftUI 共存于同一应用中时,需要一个共享的模型层。@Observable(iOS 17+)无需 Combine,即可在两个框架中自然使用。

使用 @Observable 作为共享模型层

@Observable
class AppState {
    var userName: String = ""
    var isLoggedIn: Bool = false
    var itemCount: Int = 0
}

SwiftUI 侧——标准属性包装器:

struct ProfileView: View {
    @State var appState: AppState  // or @Environment, @Bindable

    var body: some View {
        Text("Welcome, \(appState.userName)")
        Text("\(appState.itemCount) items")
    }
}

UIKit 为什么需要显式观察:SwiftUI 的渲染引擎会自动参与 Observation 框架——当视图的 body 访问 @Observable 属性时,SwiftUI 会注册该访问,并在属性变化时重新渲染。UIKit 是命令式的,没有等效的重新求值机制,因此必须显式启用观察。

UIKit 侧(iOS 26 之前)——使用 withObservationTracking() 手动观察:

class DashboardViewController: UIViewController {
    let appState: AppState

    override func viewDidLoad() {
        super.viewDidLoad()
        observeState()
    }

    private func observeState() {
        withObservationTracking {
            // Properties accessed here are tracked
            titleLabel.text = appState.userName
            countLabel.text = "\(appState.itemCount) items"
        } onChange: {
            // Fires ONCE on the thread that mutated the property — must re-register
            // Always dispatch to main: onChange can fire on ANY thread
            DispatchQueue.main.async { [weak self] in
                self?.observeState()
            }
        }
    }
}

UIKit 侧(iOS 26+)——自动观察跟踪:

UIKit 会自动跟踪指定生命周期方法中对 @Observable 属性的访问。在这些方法中读取的属性发生变化时,会触发自动 UI 更新:

方法类更新内容
updateProperties()UIView、UIViewController内容和样式
layoutSubviews()UIView几何布局和位置
viewWillLayoutSubviews()UIViewController布局前处理
draw(_:)UIView自定义绘制
class DashboardViewController: UIViewController {
    let appState: AppState

    // iOS 26+: Properties accessed here are auto-tracked
    override func updateProperties() {
        super.updateProperties()
        titleLabel.text = appState.userName
        countLabel.text = "\(appState.itemCount) items"
    }
}

Info.plist 要求:在 iOS 18 中,将 UIObservationTrackingEnabled = true 添加到 Info.plist,以启用自动观察跟踪。iOS 26+ 默认启用。

iOS 16 回退方案:ObservableObject + Combine

如果目标版本是 iOS 16(早于 @Observable),请使用带有 @Published 的 ObservableObject,并在 UIKit 侧通过 Combine 进行观察:

class AppState: ObservableObject {
    @Published var userName: String = ""
    @Published var itemCount: Int = 0
}

// UIKit side — observe with Combine sink
class DashboardViewController: UIViewController {
    let appState: AppState
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        appState.$userName
            .receive(on: DispatchQueue.main)
            .sink { [weak self] name in
                self?.titleLabel.text = name
            }
            .store(in: &cancellables)
    }
}

迁移说明

@Observable 可替代 ObservableObject + @Published,且无需使用 Combine。对于混合应用:

  • 将 ObservableObject 类替换为 @Observable
  • 移除 @Published 属性包装器(观察会自动进行)
  • SwiftUI 视图仍可正常工作——@State 和 @Environment 直接支持 @Observable
  • UIKit 视图可通过 withObservationTracking()(iOS 17+)或自动跟踪(iOS 26+)实现观察

第 5 部分:常见问题

常见问题症状修复方法
Coordinator 持有父对象内存泄漏,视图永不释放Coordinator 使用 var parent: X(而非 let)。SwiftUI 会在每次调用 updateUIView 时更新父对象引用。不要添加额外的强引用。
过度调用 updateUIViewUIKit 视图闪烁、滚动位置重置、用户输入丢失使用相等性检查进行保护。应用更改前先比较新旧值。
Environment 未跨桥接传递自定义环境值为 nil/默认值使用 UITraitBridgedEnvironmentKey(iOS 17+)实现双向桥接,或通过初始化器注入依赖项。系统特征(颜色方案、字号类别)会自动桥接。
大标题无法折叠滚动包装的 UIScrollView 时导航栏仍保持展开在导航栏上调用 setContentScrollView(_:forEdge:)。
UIHostingController 尺寸错误视图尺寸为零,或布局后位置跳变使用 sizingOptions: .intrinsicContentSize(iOS 16+)。对于更早版本,在根视图更改后调用 hostingController.view.invalidateIntrinsicContentSize()。
导航栈混用返回按钮行为不可预测,状态丢失不要在同一流程中混用 UINavigationController 和 NavigationStack。迁移整个导航子树。
多次调用 makeUIView视图意外重建确保 UIViewRepresentable 结构体的身份稳定。避免将它放在身份会变化的条件语句中。
Coordinator 未收到回调委托方法从未触发在 makeUIView 中设置 delegate = context.coordinator,不要在 updateUIView 中设置。确认协议一致性。
在 representable 视图上修改布局属性视图跳变、消失或布局不一致切勿修改包装的 UIView 上的 center、bounds、frame 或 transform——这些由 SwiftUI 管理。
混合布局中的键盘遮挡内容文本字段或内容被键盘遮挡在 UIKit 中使用 UIKeyboardLayoutGuide(iOS 15+)约束,或确保未禁用 SwiftUI 的键盘避让。
@Observable 未更新 UIKit 视图模型更改后 UIKit 视图显示过时数据使用 withObservationTracking()(iOS 17+),或在 Info.plist 中启用 UIObservationTrackingEnabled(iOS 18)。iOS 26+ 会在 updateProperties() 中自动跟踪。

第 6 部分:反模式

模式问题修复方法
“我会用 UIViewRepresentable 来实现整个屏幕”UIViewControllerRepresentable 专为管理自身视图层级、处理旋转并参与响应者链的控制器而设计UIViewController 使用 UIViewControllerRepresentable。UIViewRepresentable 用于普通 UIView。
“我不需要协调器,用闭包就行”闭包捕获的是结构体值(而非引用),更新后会变得过时,也无法遵循代理协议使用 Coordinator。它是稳定的引用类型,SwiftUI 会让它保持存活并进行更新。
“每次更新时我都会重新创建 UIKit 视图”makeUIView 只会运行一次。在 updateUIView 中重新创建视图会导致闪烁、状态丢失和性能问题。在 makeUIView 中创建视图。在 updateUIView 中更新属性。
“SwiftUI 环境会自动跨越桥接层”自定义 @Environment 值无法跨越 UIKit 边界使用 UITraitBridgedEnvironmentKey(iOS 17+)进行桥接,或通过初始化器显式注入。基于系统特征的值会自动桥接。
“我会直接关闭 UIKit 控制器”在协调器中调用 dismiss(animated:) 会绕过 SwiftUI 的呈现状态,导致绑定状态不同步使用 @Environment(\.dismiss) 或 @Binding var isPresented,让 SwiftUI 处理关闭操作。
“我会跳过 dismantleUIView,它会自动清理”UIView 上的计时器、观察者和 KVO 注册会泄漏对于仅靠 deinit 无法处理的清理工作,实现 dismantleUIView(静态方法)。

资源

WWDC:2019-231, 2022-10072, 2023-10149, 2024-10118, 2024-10145, 2025-243, 2025-256

文档:/swiftui/uiviewrepresentable, /swiftui/uiviewcontrollerrepresentable, /swiftui/uigesturerecognizerrepresentable, /uikit/uihostingcontroller, /uikit/uihostingconfiguration, /swiftui/uitraitbridgedenvironmentkey, /observation, /uikit/updating-views-automatically-with-observation-tracking

技能:app-composition, swiftui-animation-ref, camera-capture, transferable-ref, swift-concurrency

评论

支持 Markdown