3、UIViewRepresentable协议:将UIKit视图包装为SwiftUI组件

说实话,SwiftUI虽然好用,但总有些场景它搞不定。比如地图、视频播放、或者一些复杂的自定义控件。这时候怎么办?

苹果早就想到了——UIViewRepresentable协议就是专门干这个的。它就像一座桥,把UIKit的世界和SwiftUI的世界连起来。

3.1 理解UIViewRepresentable的本质

这个协议说白了就两个核心方法:

  • makeUIView(context:) — 创建你的UIKit视图
  • updateUIView(_:context:) — 当SwiftUI状态变化时更新它

我刚开始接触时觉得挺绕的。后来想明白了:makeUIView就像装修时打地基,updateUIView就像住进去后调整家具位置。一个管创建,一个管更新。

核心要点:UIViewRepresentable不是视图本身,它是一个包装器。SwiftUI通过它来托管UIKit视图的生命周期。

3.2 实现一个最简单的包装

咱们直接上手。假设我想在SwiftUI里用UILabel——虽然SwiftUI有Text,但UILabel有些特性是Text没有的。

struct UIKitLabel: UIViewRepresentable {
    var text: String
    var textColor: UIColor = .black
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.textAlignment = .center
        label.font = UIFont.systemFont(ofSize: 16)
        // 注意:这里只做初始配置
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        // 每次SwiftUI状态变化,这里都会被调用
        uiView.text = text
        uiView.textColor = textColor
    }
}

嗯,这里要注意:makeUIView只调用一次,而updateUIView会调用多次。我在项目中遇到过有人把全部配置都塞进makeUIView里,结果状态变了视图不更新——这就是没理解两者的分工。

3.3 使用Coordinator处理事件

SwiftUI是数据驱动的,但UIKit很多控件靠代理和target-action。怎么把UIKit的事件传回SwiftUI?

答案是用Coordinator。它是UIViewRepresentable内部的一个协调器,专门处理UIKit的回调。

struct UIKitTextField: UIViewRepresentable {
    @Binding var text: String
    var placeholder: String
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.placeholder = placeholder
        textField.borderStyle = .roundedRect
        // 设置代理
        textField.delegate = context.coordinator
        // 添加事件监听
        textField.addTarget(
            context.coordinator,
            action: #selector(Coordinator.textChanged(_:)),
            for: .editingChanged
        )
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        // 只在值不同时更新,避免死循环
        if uiView.text != text {
            uiView.text = text
        }
    }
    
    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: UIKitTextField
        
        init(_ parent: UIKitTextField) {
            self.parent = parent
        }
        
        @objc func textChanged(_ sender: UITextField) {
            parent.text = sender.text ?? ""
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            textField.resignFirstResponder()
            return true
        }
    }
}

我曾经踩过一个坑:在updateUIView里直接设置uiView.text = text,结果用户输入时触发了editingChanged事件,事件又更新了$text,然后SwiftUI重新渲染又调updateUIView…死循环了。所以加了个判断:if uiView.text != text

小技巧:Coordinator里持有parent引用时,注意用weak避免循环引用。不过UIViewRepresentable的Coordinator生命周期和视图绑定,一般不会出问题。

3.4 性能优化:减少不必要的更新

SwiftUI的更新机制很频繁。一个父视图状态变了,所有子视图都可能重新走updateUIView。如果你的UIKit视图很重(比如地图、视频播放器),这会造成卡顿。

我的做法是:

  • 实现equatable:让SwiftUI知道什么时候才需要更新
  • 在updateUIView里做diff:只更新真正变化的部分
  • 使用sizeThatFits:告诉SwiftUI你的视图需要多大空间
struct HeavyUIKitView: UIViewRepresentable {
    var configuration: ViewConfiguration
    
    // 告诉SwiftUI:只有configuration变了才需要更新
    static func dismantleUIView(_ uiView: SomeHeavyView, coordinator: ()) {
        // 清理资源,比如停止动画、移除通知
        uiView.cleanup()
    }
    
    func updateUIView(_ uiView: SomeHeavyView, context: Context) {
        // 只更新变化的部分
        if uiView.currentConfig != configuration {
            uiView.apply(configuration)
        }
    }
}

注意:别忘了实现dismantleUIView方法。当SwiftUI视图被移除时,这个方法会被调用。我见过有人没在这里释放资源,导致内存泄漏——尤其是那些注册了NotificationCenter观察者的视图。

3.5 实战:包装一个UIScrollView

SwiftUI的ScrollView在某些场景下不够灵活。比如我想实现一个水平滚动的日历控件,用UIScrollView更顺手。

struct HorizontalScrollView<Content: View>: UIViewRepresentable {
    let content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.isPagingEnabled = true
        return scrollView
    }
    
    func updateUIView(_ scrollView: UIScrollView, context: Context) {
        // 使用UIHostingController把SwiftUI视图嵌入UIKit
        let hostingController = UIHostingController(rootView: content)
        hostingController.view.frame = scrollView.bounds
        scrollView.addSubview(hostingController.view)
    }
}

你想想看,这样我们就能在UIKit里用SwiftUI的声明式语法,同时享受UIScrollView的精细控制。说白了就是两头的好处都占了。

3.6 避坑指南

我总结了几条血泪教训:

问题 原因 解决方案
视图不更新 updateUIView没被调用 检查属性是否被SwiftUI观察(用@State/@Binding)
死循环 updateUIView里修改了触发更新的属性 加条件判断,只在值不同时更新
内存泄漏 Coordinator持有parent强引用 用weak引用,或在dismantleUIView里清理
布局异常 UIKit视图的intrinsicContentSize不对 实现intrinsicContentSize或使用frame

我曾经有一个项目,因为没处理好updateUIView里的条件判断,导致用户每次输入都触发整个视图重建——卡得不行。后来加了个简单的diff逻辑,性能直接翻倍。

总结一下:UIViewRepresentable的核心就是三个方法——makeUIView创建、updateUIView更新、Coordinator桥接事件。记住它们的职责分工,你就能在SwiftUI里随心所欲地使用任何UIKit控件。

下一章我们会聊UIViewControllerRepresentable,它和UIViewRepresentable类似,但用来包装整个视图控制器。到时候你会看到更多有意思的实战案例。