美文网首页FlutterFlutterFlutter
玩玩Flutter Web —— 实现高德地图插件

玩玩Flutter Web —— 实现高德地图插件

作者: 唯鹿_weilu | 来源:发表于2020-06-01 09:14 被阅读0次
    Red Deer

    1.啰嗦几句

    去年写了一个功能简单的高德地图插件给flutter_deer使用,当时支持了Android与iOS两端。前一阵子有一个issue问是否会支持Flutter Web,当时我有点懵,毕竟js我都不熟。。。不过先记下这个需求,等着有时间了去研究一下。

    过了一个月,突然想起了这件事。就先去搜索了一下相关资料,发现都是实现的谷歌地图。而这些都使用到了一个google_maps的开源库。这个库其实就是借助js_wrapping封装了谷歌地图的js库,达到使用Dart代码调用js代码的目的。

    没办法了,看来我也只能去封装高德地图的js了。本想着照葫芦画瓢使用js_wrapping去实现,后面发现Dart sdk有提供操作js api的dart:js,同时也提供了更易于使用的package:js

    2.Dart调用JS

    这部分我尽量说的细一点,毕竟目前相关资料不多(还不快收藏起来~~)。避免大家像我一开始一样一头雾水。下面就以高德地图的Api来举例说明如何实现Dart调用JS代码,

    首先在pubspec.yaml添加依赖:

    dependencies:
      #  https://pub.flutter-io.cn/packages/js#-readme-tab-
      js: ^0.6.1+1
    

    创建amapjs.dart文件,导入package:js,同时用@JS注解指定库名:

    @JS('AMap')
    library amap;
    
    import 'package:js/js.dart';
    

    这里的AMap实际就是高德js的库名。

    [图片上传失败...(image-10b02e-1590974034090)]
    如果我们要实现上图的调用,就需要接着定义Map对象:

    @JS('AMap')
    library amap;
    
    import 'package:js/js.dart';
    
    // 这里`new Map(id)` 调用js的`new AMap.Map(id)`
    @JS()
    class Map {
      external Map(String id);
    }
    

    这里如果直接调用Map 可能会和Map<K, V>产生歧义,所以我们可以给注解@JS指定name来化解问题:

    @JS('Map')
    class AMap {
      external AMap(String id);
    }
    

    而添加external关键字的意思是指“外在”,也就是说这个方法是js代码实现的。

    下面我们看一下Map的文档

    Map的文档
    Map的构造方法不止一个div id这么简单,也可能是HTMLDivElement,所以我们不能使用之前的String类型了。同时有MapOptions这个初始化的参数对象。
    @JS('Map')
    class AMap {
      external AMap(dynamic /*String|HTMLDivElement*/ div, MapOptions opts);
    }
    

    MapOptions实际是一个Map<K, V>结构,并不是一个类,所以我们需要添加@anonymous注解,否则创建MapOptions就成了new AMap.MapOptions,这个显然不在js库中。

    @JS()
    @anonymous
    class MapOptions {
      external factory MapOptions({
        /// 初始中心经纬度
        LngLat center,
        /// 地图显示的缩放级别
        num zoom,
        /// 地图视图模式, 默认为‘2D’
        String /*‘2D’|‘3D’*/ viewMode,
      });
    }
    
    

    如果你想获取或修改某些参数,可以添加对应的getset方法。

    @JS()
    @anonymous
    class MapOptions {
      external LngLat get center;
      external set center(LngLat v);
      external factory MapOptions({
        LngLat center,
        num zoom,
        String /*‘2D’|‘3D’*/ viewMode,
      });
    }
    

    MapOptions的代码中出现了LngLat对象,这个类的文档如下:

    LngLat文档
    所以对应的Dart封装如下:
    @JS()
    class LngLat {
      external num getLng();
      external num getLat();
      external LngLat(num lng, num lat);
    }
    

    这里我没有写完全,只提供了我用到的getLnggetLat方法。


    这里我们使用一下我们目前的成果:

    JS代码:


    创建地图

    Dart代码:

    MapOptions _mapOptions = MapOptions(
      zoom: 11,
      viewMode: '3D',
      center: LngLat(116.397428, 39.90923),
    );
    AMap aMap = AMap('container', _mapOptions);
    

    到这里我们也能发现,大多数的基础类型我们都是可以和js去一一对应上的,比如我用到的String、num、bool、List,对于Map类型需要我们自己封装。

    3.进阶

    1.List

    来自JavaScript的数组实例总是List<dynamic>
    JavaScript数组没有具体的元素类型,因此JavaScript函数返回的数组不能在不检查每个元素的情况下保证其元素类型。

    举个例子:假设js有个数组list = ['Android', 'iOS', 'Web'];,看似以为它是个List<String>,其实它是List<dynamic>

    // true
    print(list is List);
    // false
    print(list is List<String>);
    

    在高德里有个poi的搜索功能,最后会返回一个Array<Poi>,实现代码如下:

    @JS()
    @anonymous
    class PoiList {
      external List<dynamic> get pois;
    }
    
    @JS()
    @anonymous
    class Poi {
      external String get citycode;
      external String get cityname;
      external String get adname;
      external String get name;
      ...
    }
    
    // 使用时
    pois.forEach((poi) {
      if (poi is Poi) {
        poi.citycode;
        ...
      }
    });
    

    这里的List我尝试过使用List<Poi>,测试也没什么问题。但是pois确实返回的是List<dynamic>,所以稳妥的写法还是使用List<dynamic>>,使用时再转换或强转。

    2.回调

    也就是传递函数,这里以地图插件加载方法来举例。文档如下:


    plugin方法文档

    JS代码如下:

    mapObj.plugin(["AMap.ToolBar"], function() {
        //加载工具条
        var tool = new AMap.ToolBar();
        mapObj.addControl(tool);
    });
    

    其实这里的function就对应Dart的Function

    @JS('Map')
    class AMap {
      external AMap(dynamic /*String|HTMLDivElement*/ div, MapOptions opts);
      /// 加载插件
      external plugin(dynamic/*String|List*/ name, void Function() callback); 
    }
    

    如果function有参数也是一样的。唯一的区别在于使用时的不同:

    import 'package:js/js.dart';
    // 错误
    mapObj.plugin(['AMap.ToolBar'], () {
      mapObj.addControl(ToolBar());
    });
    // 正确
    mapObj.plugin(['AMap.ToolBar'], allowInterop(() {
      mapObj.addControl(ToolBar());
    }));
    

    如果将Dart函数作为参数传递给JS Api,则需要使用allowInteropallowInteropCaptureThis方法确保兼容性。

    3.异步

    举例:高德推荐使用JSAPI Loader来进行加载地图及插件。使用方法如下:

    JSAPI Loader
    这部分代码的封装很简单:
    @JS('AMapLoader')
    library loader;
    
    import 'package:js/js.dart';
    
    /// 高德地图 Loader js
    external load(LoaderOptions options);
    
    @JS()
    @anonymous
    class LoaderOptions {
    
      external factory LoaderOptions({
        ///您申请的key值
        String key,
        /// JSAPI 版本号
        String version,
        //同步加载的插件列表
        List<String> plugins,
      });
    }
    

    主要还是使用上,怎么将Js的Promise转换成Dart的Future。这里就用到了promiseToFuture方法,源码如下:

    Future<T> promiseToFuture<T>(jsPromise) {
      final completer = Completer<T>();
    
      final success = convertDartClosureToJS((r) => completer.complete(r), 1);
      final error = convertDartClosureToJS((e) => completer.completeError(e), 1);
    
      JS('', '#.then(#, #)', jsPromise, success, error);
      return completer.future;
    }
    

    使用代码示例:

    import 'dart:js_util';
    
    var promise = load(LoaderOptions(
      key: 'xxx',
      version: '2.0',
      plugins: ['AMap.Scale'],
    ));
    
    promiseToFuture(promise).then((value) {
      AMap aMap = AMap('container');
      ...      
    }, onError: (e) {
      print('初始化错误:$e');
    });
    

    4.显示地图

    使用上面的方法,我将我使用到的高德api进行了封装,完成了JS调用部分的工作。到这里就剩下了地图显示及相应的逻辑实现了。

    功能的逻辑实现这里就不多说了,主要说说如何显示地图。

    首先在web目录的index.html中添加js(在main.dart.js之前):

    <script src="https://webapi.amap.com/loader.js"></script>
    

    其实与Android的AndroidView和iOS的UiKitView相同,Web这边有个HtmlElementView。(Flutter sdk:Dev channel 1.19.0-1.0.pre)

    它需要一个由PlatformViewFactory注册的唯一标识符viewType

    /// 这里使用时间作为唯一标识
    _divId = DateTime.now().toIso8601String();
    // ignore: undefined_prefixed_name
    ui.platformViewRegistry.registerViewFactory(_divId, (int viewId) => HtmlElement());
    
    return HtmlElementView(
      viewType: _divId,
    );
    

    地图创建需要div的id或者HTMLDivElement,所以我们需要创建一个div。在Dart的dart:html中为我们提供了DOM element、CSS样式、本地存储、音视频、事件等(4万行代码不是盖的...)。其中就有这里需要的HTMLDivElement

    @Native("HTMLDivElement")
    class DivElement extends HtmlElement {
      // To suppress missing implicit constructor warnings.
      factory DivElement._() {
        throw new UnsupportedError("Not supported");
      }
    
      factory DivElement() => JS('returns:DivElement;creates:DivElement;new:true',
          '#.createElement(#)', document, "div");
      /**
       * Constructor instantiated by the DOM when a custom element has been created.
       *
       * This can only be called by subclasses from their created constructor.
       */
      DivElement.created() : super.created();
    }
    

    整理后,完整代码如下:

    import 'dart:html';
    import 'dart:ui' as ui;
    
    String _divId;
    DivElement _element;
    
    @override
    void initState() {
      super.initState();
      /// 这里使用时间作为唯一标识
      _divId = DateTime.now().toIso8601String();
      /// 先创建div并注册
      // ignore: undefined_prefixed_name
      ui.platformViewRegistry.registerViewFactory(_divId, (int viewId) {
        /// 地图需要的Div
        _element = DivElement()
          ..style.width = '100%'
          ..style.height = '100%'
          ..style.margin = '0';
    
        return _element;
      });
      SchedulerBinding.instance.addPostFrameCallback((_) {
        /// 创建地图
        var promise = load(LoaderOptions(
          key: 'xxx',
          version: '2.0',
          plugins: ['AMap.Scale'],
        ));
    
        promiseToFuture(promise).then((value) {
          AMap aMap = AMap(_element);
        }, onError: (e) {
          print('初始化错误:$e');
        });
      });
    }
    
    @override
    Widget build(BuildContext context) {
      return HtmlElementView(
        viewType: _divId,
      );
    }
    

    这里其实有点问题,HtmlElementView没有和AndroidViewUiKitView一样给予onCreatePlatformView创建回调,导致我直接创建的地图会显示不出来,所以我使用了addPostFrameCallback来处理。或者参考这个issue,自定义PlatformViewLink来实现。

    不过我遇见的问题不止这些,大都是地图的显示问题。比如:

    • 高德地图的logo、定位、比例尺这类不显示,部分在地图的左上角被地图层覆盖。

    • 地图上的覆盖物添加后无法修改。

    • 地图上的覆盖物在地图放大缩小后位置偏离。(和第二点类似)

    可以看出问题都是渲染上的,查询相关资料得知现在是基于HTML DOM的模型,该模型结合了HTMLCSSCanvas API来实现页面,官方将此实现称为DomCanvas渲染系统。而目前在尝试使用第二种方法CanvasKitCanvasKit 使用WebAssemblyWebGLSkia引入Web,利用硬件加速从而提高了渲染复杂和密集图形的能力。

    现阶段Flutter Web 默认使用DomCanvas,所以我尝试使用以下命令启用CanvasKit渲染引擎来看看效果:

    flutter run -d chrome --release --dart-define=FLUTTER_WEB_USE_SKIA=true
    

    运行后发现这些问题得到了解决,但是又产生了新的问题。比如地图不能点击、拖动,文字乱码。。。

    CanvasKit版效果
    当然官方的文章也指出,现阶段CanvasKit引擎还是比较粗糙的,而DomCanvas引擎相对更加稳定。

    2021-02-18补充:

    目前默认使用渲染引擎规则:当在移动浏览器中运行时,使用DomCanvas,在PC浏览器中运行时,使用CanvasKit

    如果指定,可以使用以下命令:

    flutter run -d chrome --release --web-renderer html
    // 或
    flutter run -d chrome --release --web-renderer canvaskit
    

    详情见文档:Web renderers

    我使用了dev 1.27.0-4.0.pre的CanvasKit渲染引擎再次运行了地图项目,地图可以点击、拖动了,不过空格乱码。已经相较之前好了很多。

    1.27CanvasKit版效果
    而空格乱码问题目前已修复,详见[web]Can not show "Space" between chinese

    最终我还是使用了稳定的方案。因为其他功能,比如poi的搜索、地图点击这类不涉及显示的功能,测试均是正常的,基本可以满足使用。放一下现阶段的效果展示:

    效果展示

    实现的功能:

    • 自动定位并根据当前经纬度进行POI搜索
    • 点击地图获取经纬度并进行POI搜索
    • 点击地址信息,移动地图至当前位置
    • POI搜索功能

    其实从Flutter Web去年的首个预览版、到去年底的Beta版、到现在。我都会将flutter_deer在web端运行一下,我能明显的感觉到它表现的越来越好了,比如有时文字不居中、动画表现不一致、Stack的层级显示不正确等许多小问题都得到了解决。说不定哪天我再次运行起来,上面说的问题都解决了,哈哈!!

    这次发现Flutter Web也支持了PWA,我在PC和手机体验下来发现是真的很不错~~,手机端看上去几乎可以以假乱真,其实上面的GIF就是PC端的效果。

    最后,我将这部分完整代码都已提交至Github,现在这个小插件已经支持Android、iOS和Web了,欢迎体验!!最最后,点赞支持一下~

    5.参考

    相关文章

      网友评论

        本文标题:玩玩Flutter Web —— 实现高德地图插件

        本文链接:https://www.haomeiwen.com/subject/sxhjzhtx.html