4、UIViewControllerRepresentable协议:将UIKit控制器包装为SwiftUI组件、协调器模式详解

4.1 为什么需要UIViewControllerRepresentable?

说实话,SwiftUI虽然好用,但生态还不够完善。很多成熟的UIKit组件,比如地图、相机、WebView,SwiftUI还没提供原生替代品。

我在项目中遇到过好几次这种情况:产品经理说「这个页面用地图展示」,我一看SwiftUI的MapKit支持,嗯…功能太基础了。最后还是得把MKMapView搬过来。

这时候就需要UIViewControllerRepresentable协议了。它就像一座桥,把UIKit的控制器无缝接入SwiftUI的世界。

核心思想:SwiftUI管理生命周期,UIKit负责具体渲染。两者各司其职,互不干扰。

4.2 协议的基本结构

先看一个最简单的例子。把UIActivityViewController包装成SwiftUI组件:

struct ActivityViewController: UIViewControllerRepresentable {
    let activityItems: [Any]
    let applicationActivities: [UIActivity]?
    
    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: applicationActivities
        )
    }
    
    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
        // 不需要更新逻辑
    }
}

这里有两个必须实现的方法:

  • makeUIViewController:创建并返回UIKit控制器。只调用一次。
  • updateUIViewController:当SwiftUI状态变化时,更新控制器。每次刷新都会调用。

你想想看,这跟UIViewRepresentable是不是很像?对,设计思路完全一致。

4.3 协调器模式详解

光能展示还不够。UIKit控制器有各种代理方法、事件回调,怎么把这些事件传回SwiftUI?

答案就是协调器(Coordinator)

我个人习惯把协调器理解为「翻译官」。它懂UIKit的语言,也懂SwiftUI的语言,负责在两者之间传话。

4.3.1 创建协调器

struct ImagePicker: UIViewControllerRepresentable {
    @Binding var selectedImage: UIImage?
    @Environment(\.dismiss) private var dismiss
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator
        return picker
    }
    
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
    
    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
        let parent: ImagePicker
        
        init(_ parent: ImagePicker) {
            self.parent = parent
        }
        
        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
            if let image = info[.originalImage] as? UIImage {
                parent.selectedImage = image
            }
            parent.dismiss()
        }
    }
}

注意几个关键点:

  • 协调器必须继承NSObject,因为UIKit代理都是OC时代的产物
  • 通过context.coordinator访问协调器实例
  • 协调器持有parent引用,用来更新SwiftUI状态

小技巧:协调器里不要直接持有parent的强引用,否则会造成循环引用。但这里parent是struct,所以没问题。如果协调器里持有的是class,记得用weak。

4.3.2 协调器的生命周期

我记得刚学的时候有个疑问:协调器什么时候创建?什么时候销毁?

其实很简单:

  • 创建时机:在makeUIViewController之前,SwiftUI会先调用makeCoordinator()
  • 销毁时机:当SwiftUI视图被移除时,协调器也会被释放
  • 更新时机:每次updateUIViewController调用时,context.coordinator都是同一个实例

4.4 实战:包装一个完整的WebView

光说不练假把式。咱们来包装一个WKWebView,支持加载URL、进度回调、JavaScript交互。

struct WebView: UIViewControllerRepresentable {
    let url: URL
    @Binding var isLoading: Bool
    @Binding var progress: Double
    var onPageTitleChanged: ((String) -> Void)?
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> UIViewController {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        webView.addObserver(
            context.coordinator,
            forKeyPath: #keyPath(WKWebView.estimatedProgress),
            options: .new,
            context: nil
        )
        
        let controller = UIViewController()
        controller.view = webView
        webView.load(URLRequest(url: url))
        
        return controller
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        guard let webView = uiViewController.view as? WKWebView else { return }
        if webView.url != url {
            webView.load(URLRequest(url: url))
        }
    }
    
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebView
        
        init(_ parent: WebView) {
            self.parent = parent
        }
        
        override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            if keyPath == "estimatedProgress" {
                if let webView = object as? WKWebView {
                    parent.progress = webView.estimatedProgress
                    parent.isLoading = webView.estimatedProgress < 1.0
                }
            }
        }
        
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            webView.evaluateJavaScript("document.title") { result, error in
                if let title = result as? String {
                    self.parent.onPageTitleChanged?(title)
                }
            }
        }
        
        deinit {
            print("协调器被释放了")
        }
    }
}

注意:KVO观察者一定要在协调器deinit时移除,否则会崩溃。但这里我们没写移除代码,因为协调器销毁时WKWebView也会被销毁,所以没问题。如果你把webView作为属性持有,记得在deinit里调用removeObserver。

4.5 常见坑点与避坑指南

做混合开发,坑是少不了的。我踩过的坑,你就不用再踩了。

4.5.1 状态不同步问题

我曾经遇到一个bug:SwiftUI里改了@State,但UIKit控制器没反应。查了半天,发现是updateUIViewController没写对。

记住:updateUIViewController只在SwiftUI状态变化时调用。如果你在UIKit内部修改了状态,需要手动触发刷新。

4.5.2 内存泄漏

协调器里如果持有parent的闭包,很容易造成循环引用。我建议:

  • 能用值类型就用值类型
  • 闭包中使用[weak self]
  • 代理方法里不要强引用parent

4.5.3 视图层级问题

有时候UIKit控制器需要嵌入到SwiftUI的NavigationView里。这时候要注意:

  • UIKit控制器自己不要管理导航栏
  • 把导航逻辑交给SwiftUI处理
  • 可以用@Environment(\.dismiss)来控制返回

4.6 性能优化建议

场景 建议 原因
频繁更新UI 使用协调器批量处理 减少SwiftUI刷新次数
大型列表 用UICollectionView代替List UIKit的复用机制更成熟
动画 在UIKit侧处理 SwiftUI动画与UIKit动画可能冲突
内存敏感 及时释放协调器 避免UIKit对象常驻内存

4.7 总结

UIViewControllerRepresentable说白了就是SwiftUI给UIKit开的一扇后门。用好它,你就能在SwiftUI项目里随意调用任何UIKit组件。

核心要点就三个:

  1. makeUIViewController负责创建,updateUIViewController负责更新
  2. 协调器是UIKit和SwiftUI之间的桥梁,处理所有事件回调
  3. 注意生命周期管理,避免内存泄漏和状态不同步

嗯,掌握了这些,你就能在SwiftUI和UIKit之间自由穿梭了。下一章咱们聊聊UIViewRepresentable,原理类似但细节不同,到时候再细说。