Flutter PlatformView实战:嵌入原生 iOS 视图

600 阅读6分钟

flutter.webp


Flutter 的跨平台能力非常出色,但有时你需要使用一个平台特有的、在 Flutter 中没有对应实现的 UI 组件。你可能需要集成一个复杂的、经过实战检验的原生 SDK,或者你只是想复用一个已有的原生视图。这时候,PlatformView 就派上用场了。

PlatformView 允许你将原生的 UIView (在 iOS 上) 和 View (在 Android 上) 直接嵌入到你的 Flutter widget 树中。它是一个强大的功能,充当了你的 Flutter UI 和原生平台之间的桥梁。

在本文我们将通过一个完整的示例,演示如何使用 SwiftUI 将一个原生的 iOS MapKit 地图视图嵌入到 Flutter 应用中。

1. 在 Flutter 中显示原生视图

在 Flutter 中使用一个特殊的 widget 来承载原生视图。对于 iOS,这个 widget 是 UiKitView

lib/map_view.dart 文件中,定义了一个 MapView widget。这个 widget 的核心就是 UiKitView

// ... existing code ...
class _MapViewState extends State<MapView> {
  // ... existing code ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: UiKitView(
        // 一个唯一的标识符,用于将此 widget 连接到原生工厂。
        viewType: "map_view",
        layoutDirection: TextDirection.ltr,
        // 在创建时传递给原生视图的数据。
        creationParams: <String, dynamic>{
          "latitude": 23.12911,
          "longitude": 113.264385,
        },
        // 用于对 creationParams 进行编解码的编解码器。
        creationParamsCodec: const StandardMessageCodec(),
        onPlatformViewCreated: (id) {
          print("MapView created with id: $id");
        },
      ),
    );
  }
}

让我们分解一下 UiKitView 的关键属性:

  • viewType: 这是一个至关重要的 String 标识符。Flutter 使用它来查找相应的原生“工厂”,这个工厂知道如何创建我们想要的原生视图。
  • creationParams: 一个 Map 类型的动态数据,你希望在初始化时从 Flutter 发送到原生端。在我们的例子中,我们传递了地图的初始坐标。
  • creationParamsCodec: 这指定了 creationParams 应该如何在 Dart 和原生平台之间进行编码和解码。StandardMessageCodec 是一个通用的选择,支持常见的数据类型。

2. 注册原生视图工厂 (iOS)

在原生 iOS 端,我们需要告诉我们的 Flutter 应用如何构建与 viewType ("map_view") 关联的视图。这是通过注册一个 FlutterPlatformViewFactory 来完成的。

注册过程发生在 ios/Runner/AppDelegate.swift 中。

// ... existing code ...
@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        // ... MethodChannel 注册 ...

        /// 注册平台视图
        guard let registrar = self.registrar(forPlugin: "map_view") else {
            fatalError("Failed to get registrar")
        }
        // 实例化我们的工厂。
        let factory = MapViewFactory(messenger: registrar.messenger())
        // 使用唯一ID "map_view" 注册工厂。
        // 这必须与 UiKitView 中的 `viewType` 匹配。
        registrar.register(factory, withId: "map_view", gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded)

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

在这里,我们创建了一个 MapViewFactory 的实例,并用 ID "map_view" 注册了它。现在,每当 Flutter 中构建一个带有此 viewTypeUiKitView 时,Flutter 就会请求我们的 MapViewFactory 来创建相应的原生视图。

3. 实现工厂和平台视图 (iOS)

现在我们来看看工厂本身以及它所创建的平台视图。

3.1. 工厂 (MapViewFactory.swift)

工厂的工作很简单:创建我们的平台视图的一个实例。

// ... existing code ...
@available(iOS 17.0, *)
class MapViewFactory: NSObject, FlutterPlatformViewFactory {
    private var messenger: FlutterBinaryMessenger?

    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        super.init()
    }

    // ...

    // Flutter 调用此方法来创建原生视图。
    func create(
        withFrame frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?
    ) -> any FlutterPlatformView {

        // 它返回我们的平台视图类的一个实例。
        return MapView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger
        )
    }
}

3.2. 平台视图 (MapView.swift)

这是主要的桥接类。它遵守 FlutterPlatformView 协议,并负责创建和管理实际 UIView 的生命周期。

// ... existing code ...
@available(iOS 17.0, *)
class MapView: NSObject, FlutterPlatformView {
    // 这持有将要被嵌入的实际 UIView。
    private var _mapView: UIView

    private var hostingController: UIHostingController<MapContentView>?

    init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?, // 这些是来自 Flutter 的 creationParams。
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        self._mapView = UIView()
        super.init()
        // 我们调用一个辅助方法来设置 SwiftUI 视图。
        createMapView(view: _mapView, args: args)
    }

    // 这个方法必须返回 Flutter 将要显示的 UIView。
    func view() -> UIView {
        return _mapView
    }

    func createMapView(view: UIView, args: Any?) {
        // 1. 解码来自 Flutter 的参数。
        guard let args = args as? [String: Any] else { return }
        guard let latitude = args["latitude"] as? Double,
        let longitude = args["longitude"] as? Double else { return }

        // 2. 用数据创建我们的 SwiftUI 视图。
        let mapContentView = MapContentView(latitude: latitude, longitude: longitude)

        // 3. 将 SwiftUI 视图托管在 UIHostingController 中。
        hostingController = UIHostingController(rootView: mapContentView)
        guard let hostingController = hostingController else { return }

        // 4. 将托管控制器的视图添加为子视图并设置约束。
        _mapView.addSubview(hostingController.view)
        // ... AutoLayout 约束 ...
    }
}

在这个类中:

  1. init 方法接收我们从 Dart 作为 creationParams 传递过来的 arguments
  2. 我们解析这些参数以获取纬度和经度。
  3. 我们初始化我们的 MapContentView (一个 SwiftUI 视图),并将其包装在一个 UIHostingController 中,以便它可以作为标准的 UIView 使用。
  4. view() 方法返回这个 UIView,然后 Flutter 将其渲染出来。

4. 使用 MethodChannel 进行双向通信

显示视图很棒,但是交互呢?我们需要一种方法让原生视图能够回过头来与 Flutter 通信。为此,我们使用 MethodChannel

4.1. 设置通道

通道必须在 Flutter 和原生两端用相同的名称进行初始化。

  1. Flutter ( lib/map_view.dart ):
    我们创建通道并设置一个处理器来监听来自原生的方法调用。
class _MapViewState extends State<MapView> {
  // 1. 使用与原生代码中相同的名称创建通道。
  final MethodChannel _channel = MethodChannel("map_view");

  @override
  void initState() {
    super.initState();
    // 2. 设置一个处理器来处理来自原生端的消息。
    _channel.setMethodCallHandler(_handle);
  }

  // 3. 处理器函数。
  Future<void> _handle(MethodCall call) async {
    switch (call.method) {
        // 如果原生端调用 "backFlutterView"...
      case "backFlutterView":
        // ...则弹出当前路由以返回。
        Navigator.pop(context);
        break;
    }
  }
  // ...
}
  1. iOS ( ios/Runner/AppDelegate.swift ):
    我们注册相同的通道,并且为了方便起见,将它存储在一个全局单例中,以便我们原生代码的其他部分可以轻松访问它。
/// 一个简单的单例来持有对通道的引用。
public class ChannelManager {
    static let shared = ChannelManager()
    var methodChannel: FlutterMethodChannel?
    private init() {}
}

@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application( /* ... */ ) -> Bool {
        // ...
        /// 注册通道
        guard let flutterViewController = window?.rootViewController as? FlutterViewController else {
            fatalError("RootViewController is not FlutterViewController")
        }
        let channel = FlutterMethodChannel(name: "map_view", binaryMessenger: flutterViewController.binaryMessenger)
        // 将通道存储在我们的单例中。
        ChannelManager.shared.methodChannel = channel
        // ...
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

4.2. 从原生调用 Flutter

现在,我们的原生视图可以使用这个通道来发送消息。在我们的 MapContentView 中,我们有一个原生的返回按钮。当点击它时,它会在通道上调用 "backFlutterView" 方法。

// ...
struct MapContentView: View {
    // ...
    var body: some View {
        ZStack {
            // ... 地图和其他 UI ...
            VStack {
                HStack {
                    Image(systemName: "chevron.left")
                    // ... 样式 ...
                    .onTapGesture(perform: onBackTap) // 点击时调用 onBackTap。
                    Spacer()
                    // ... 菜单 ...
                }
                // ...
            }
        }
        .ignoresSafeArea()
    }

    // 这个函数向 Flutter 发送消息。
    private func onBackTap() {
        DispatchQueue.main.async {
            // 使用共享的通道来调用一个方法。
            ChannelManager.shared.methodChannel?.invokeMethod(
                "backFlutterView", // 要调用的方法名。
                arguments: nil
            )
        }
    }
}

当这段代码运行时,它会通过 "map_view" 通道发送一条消息。我们的 Flutter _handle 函数接收到此消息,看到方法名是 "backFlutterView",然后执行 Navigator.pop(context),从而关闭地图屏幕并返回到主 Flutter 页面。

5. 总结

PlatformView 是与原生平台进行深度集成不可或缺的工具。虽然它可能会有性能方面的影响(尤其是在旧版 Android 上),但它提供了一种强大的方式来在你的 Flutter 应用中利用完整的原生生态系统。

6. 演示

录屏 2025年7月19日.gif