Flutter PlatformView实战:嵌入原生 iOS 视图
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 中构建一个带有此 viewType
的 UiKitView
时,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 约束 ...
}
}
在这个类中:
-
init
方法接收我们从 Dart 作为creationParams
传递过来的arguments
。 - 我们解析这些参数以获取纬度和经度。
- 我们初始化我们的
MapContentView
(一个 SwiftUI 视图),并将其包装在一个UIHostingController
中,以便它可以作为标准的UIView
使用。 -
view()
方法返回这个UIView
,然后 Flutter 将其渲染出来。
4. 使用 MethodChannel 进行双向通信
显示视图很棒,但是交互呢?我们需要一种方法让原生视图能够回过头来与 Flutter 通信。为此,我们使用 MethodChannel
。
4.1. 设置通道
通道必须在 Flutter 和原生两端用相同的名称进行初始化。
-
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;
}
}
// ...
}
-
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 应用中利用完整的原生生态系统。