Flutter platform view 使用篇

Flutter作为备受关注的跨平台的开发框架,长远来看,前景肯定是比较好的。

在其基础组件还未完善与成熟之前,能够高效的复用现有的native组件,是比较合适的方案。官方提供了Plugin的方式,允许将一个成熟的native组件(比如mapview),封装成一个可用dart来操作的widget。本文以封装一个腾讯地图组件为例,介绍一下整个过程。具体也可以参照一下谷歌官方封装的地图组件google_maps_flutter

整体框架图

Dart侧

MapView StatefulWidget

首先,我们需要创建一个正常的MapView widget,该widget就是供外部展示native地图的widget。

typedef void MapViewCreatedCallback(MapViewController controller);

class MapView extends StatefulWidget {  

      final MapViewCreatedCallback onMapCreated;

    // 地图类型
        MapType mapType = MapType.standard;

    // 其他的地图初始化参数,需要传递给native侧,提供给native的地图对象初始用
        // ...

    @override
    State createState() {
        return _MapViewState();
    }
}

这里和普通的widget大体一致,需要注意的有2个点:

  1. 定义一些初始化参数,后面会在_MapViewState里面传递给native侧做初始化

  2. 定义了一个create回调,参数是一个controller,这个controller其实就是和native侧做交互的对象,后面详细介绍

_MapViewState State

下面看一下State的代码

class _MapViewState extends State {

  final Completer _controller = Completer();

  @override
  Widget build(BuildContext context) {

    final Map creationParams = {
      'mapType': widget.mapType.index,
            // 其他参数
    };

    if (defaultTargetPlatform == TargetPlatform.iOS) {
      return UiKitView(
        viewType: 'qq_maps',
        onPlatformViewCreated: onPlatformViewCreated,
        creationParams: creationParams,
        creationParamsCodec: const StandardMessageCodec(),
      );
    }

    return Text('$defaultTargetPlatform is not yet supported');
  }

  Future onPlatformViewCreated(int id) async {
    final MapViewController controller = await MapViewController.init(id, this);

    _controller.complete(controller);
    if (widget.onMapCreated != null) {
      widget.onMapCreated(controller);
    }
  }
}

核心就在 build 这里了,Flutter提供了一个UIKitView(iOS侧,安卓对应的是AndroidView)的组件,这个组件就是桥接native view的关键,我们看看其参数。

  • viewType 这个是传递给native侧,用作view factory的key,后面讲native代码时我们再看

  • creationParams 这里是允许传递给native侧的初始化参数

  • onPlatformViewCreated platformView创建成功回调,注意回调参数是viewId,通常会在这里初始化Controller,并将controller作为上面MapView onCreateCallback的参数。 这样子外部在使用MapView这个widget的时候,就能够拿到其对应的Controller

MapViewController

下面是dart侧最后一个类:controller。前面我们说过,组件使用者在创建MapView这个widget的时候,就能在onCreate回调拿到这个controller,然后后续就能够通过controller来与native做一些交互,比如说开启地图定位,搜索附近poi的列表等等。

typedef void MapViewRegionDidChange();

class MapViewController {

  final MethodChannel channel;
  final _MapViewState _googleMapState;

  MapViewRegionDidChange regionDidChange;

  MapViewController._(
    this.channel,
    this._googleMapState,
  ) {
    channel.setMethodCallHandler(_handleMethodCall);
  }

  static Future init(
    int id,
    _MapViewState mapViewState,
  ) async {
    assert(id != null);

    final MethodChannel channel = MethodChannel('qq_maps_$id');

    return MapViewController._(channel, mapViewState);
  }


  Future _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'map#regionDidChange':
        regionDidChange();
        break;
    }
  }

  Future backToCurLocation() async {
    await channel.invokeMethod(
      'map#backToCurLocation',
    );
  }

  Future getRecentPoiList({String keyword = "大厦"}) async {
    final Map data = await channel.invokeMethod(
      'map#getRecentPoiList',
      keyword,
    );

    int result = data["result"];
    if (result == 0) {  // 定位成功
      return data["poiList"];
    } else {
      return List();
    }
  }
}

  1. 首先我们观察到,controller维护了一个 channel 对象,这个对象就负责收发native端的消息。

    注意一个tips: 这个channel对象的name是 qq_maps_$id ,id是UIKitView的create回调带过来的,表示viewID。 这就是说如果有多个地图实例的话,每一个地图实例都对应一个自己的channel,保证消息收发不会串掉。

  2. 然后,controller提供了2个方法,这两个方法都是直接桥接native侧的:

  • backToCurLocation 这个是调用native的能力,将当前地图视图移动到当前定位点的位置

  • getRecentPoiList 这个是请求native去搜索附近的poi列表,注意这里会获取native的返回值

  1. 第三个注意点,就是接收native侧的事件回调,主要是通过channel的回调函数 _handleMethodCall 来统一处理的

  • regionDidChange native侧如果发现地图的视窗有变化(比如拖拽地图),flutter侧就能收到这个回调

Native侧

下面看下native侧对应的代码

MapviewPlugin

@interface MapviewPlugin : NSObject

@end

@implementation MapviewPlugin
+ (void)registerWithRegistrar:(NSObject*)registrar {
    QQMapViewFactory *factory = [[QQMapViewFactory alloc] initWithRegistrar:registrar];
    [registrar registerViewFactory:factory withId:@"qq_maps"];
}
@end

首先,MapviewPlugin继承自FlutterPlugin对象,该对象主要是用来向flutter注册我们的plugin,具体代码在 GeneratedPluginRegistrant 类里面,这里未列出。

这里主要关注我们创建了一个QQMapViewFactory, FlutterPluginRegistrar 注册了这个mapview工厂,其对应的id实际上就是Dart侧里面UIKitView的 viewType ,表明我这个工厂管理的就是mapview这个类型的view对象。

QQMapViewFactory

@interface QQMapViewFactory : NSObject

@end

@implementation QQMapViewFactory

// 其他代码略

- (NSObject*)createWithFrame:(CGRect)frame
                                   viewIdentifier:(int64_t)viewId
                                        arguments:(id _Nullable)args {

  return [[QQMapViewController alloc] initWithFrame:frame
                                        viewIdentifier:viewId
                                             arguments:args
                                             registrar:_registrar];
}

工厂的核心就是这个createWithFrame方法了,这个方法是由dart侧来驱动的。dart侧使用MapView的widget,配合flutter的布局widget,来计算出何处需要一个mapview的native view,其frame和viewid,都是dart侧传递过来。我们看下这个函数的参数:

  • frame dart侧通过其布局widget来计算得来

  • viewId 由于可能有多个地图组件同时展示,每个地图实例都有各自的viewId来区分

  • args 对应dart侧UIKitView的creationParams参数

  • 返回值 FlutterPlatformView协议,这个协议实际上就一个接口,返回一个UIView对象

QQMapViewController

最后就是controller对象了,controller继承自FlutterPlatformView协议,工厂调用Controller对象来创建真正的view实例。

代码部分稍多一点,我们分两部分来说,下面是第一部分

@interface QQMapViewController : NSObject
// 略
@end

@implementation QQMapViewController

- (instancetype)initWithFrame:(CGRect)frame
               viewIdentifier:(int64_t)viewId
                    arguments:(id _Nullable)args
                    registrar:(NSObject *)registrar
{
    if (self = [super init]) {

        _mapView = [[QMapView alloc] initWithFrame:frame];
        _mapView.delegate = self;
        [self mapArgs:args toView:_mapView];

        NSString *channelName = [NSString stringWithFormat:@"qq_maps_%lld", viewId];
        _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:registrar.messenger];

        __weak __typeof__(self) weakSelf = self;
        [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
          if (weakSelf) {
            [weakSelf onMethodCall:call result:result];
          }
        }];
    }
    return self;
}

- (void)mapArgs:(id _Nullable)args toView:(QMapView *)view
{
    if ([args isKindOfClass:[NSDictionary class]] && view != nil) {
        view.mapType = [args[@"mapType"] intValue];
                // 其他参数,略
    }
}

- (nonnull UIView *)view {
    return _mapView;
}

第一部分很简单,我们主要关注如下点:

  1. 真正的QMapView对象初始化,然后在 FlutterPlatformView 协议的view方法里面返回。这个view对象就是真正的和dart层MapView widget对应的view了

  2. 与dart侧的controller相对应,native侧的controller也管理了channel对象,channel的name与dart侧一致。native侧与dart侧的消息收发同样通过这个channel

  3. dart侧传递过来的初始化参数, mapArgs:toView 方法里面我们传递给了mapview对象。

- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    if ([call.method isEqualToString:@"map#backToCurLocation"]) {
        [_mapView setCenterCoordinate:_mapView.userLocation.location.coordinate animated:YES];
        result(nil);
    } else if ([call.method isEqualToString:@"map#getRecentPoiList"]) {

        QMSPoiSearchOption *option = [[QMSPoiSearchOption alloc] init];
        option.keyword = call.arguments;
        [option setBoundaryByNearbyWithCenterCoordinate:_mapView.centerCoordinate radius:1000 autoExtend:1];

        [_searcher searchWithPoiSearchOption:option];

        _searchResult = result;
    }
}

#pragma mark - mapview delegate
- (void)mapView:(QMapView *)mapView regionDidChangeAnimated:(BOOL)animated gesture:(BOOL)bGesture
{
    [_channel invokeMethod:@"map#regionDidChange" arguments:nil];
}

#pragma mark - QMSSearchDelegate
- (void)searchWithPoiSearchOption:(QMSPoiSearchOption *)poiSearchOption didReceiveResult:(QMSPoiSearchResult *)poiSearchResult
{
    NSMutableArray *list = [NSMutableArray new];
    for (QMSPoiData *poi in poiSearchResult.dataArray) {
        NSDictionary *poiDic = @{
            @"id": poi.id_,
            @"title": poi.title,
            @"distance": @(QMetersBetweenCoordinates(poi.location, _mapView.centerCoordinate)),
            @"address": poi.address
        };
        [list addObject:poiDic];
    }

    _searchResult(@{
        @"result": @(0),
        @"poiList": list
    });
}

第二部分就是和dart侧相关交互的代码了,基本和dart的controller代码相对应:

  1. onMethodCall dart侧发起的函数调用,首先会到这里,然后再分发给具体的实现函数。 我们可以看到刚刚dart侧的2个接口( map#backToCurLocation map#getRecentPoiList ),在native侧具体是怎么实现的。

  2. mapView:regionDidChangeAnimated 这个是地图sdk给的回调,这里面我们可以看到是直接将该回调通过channel的 invokeMethod 方法传递到dart端。

本篇主要是使用介绍,具体platform view的原理,可以看看下一篇原理篇。