关于 axiom-uikit-bridging
面向 iOS 开发者的 UIKit 与 SwiftUI 桥接参考技能,说明如何将 UIKit 视图、控制器和手势识别器嵌入 SwiftUI,也介绍在 UIKit 中承载 SwiftUI。内容包含生命周期、状态同步、协调器和布局要点。
主要功能
- 01封装 UIView 与 UIViewController
- 02桥接 UIKit 手势识别器
- 03在 UIKit 中嵌入 SwiftUI
- 04同步 UIKit 与 SwiftUI 状态
- 05处理生命周期和布局
适用场景
- 01在 SwiftUI 中使用 MKMapView 或 WKWebView
- 02将照片选择器等控制器接入 SwiftUI
- 03把 SwiftUI 视图放入 UIKit 导航
- 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),它:
- 充当 UIKit 视图的代理/数据源
- 持有对父
UIViewRepresentable结构体的引用 - 将 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 |
| 布局中的子 VC | UIHostingController |
| 覆盖层或装饰视图 | 补充视图中的 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 时更新父对象引用。不要添加额外的强引用。 |
| 过度调用 updateUIView | UIKit 视图闪烁、滚动位置重置、用户输入丢失 | 使用相等性检查进行保护。应用更改前先比较新旧值。 |
| 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