玩玩Flutter Web —— 实现高德地图插件
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的库名。
如果我们要实现上图的调用,就需要接着定义 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
产生歧义,所以我们可以给注解 @JS
指定name来化解问题:
@JS('Map') class AMap { external AMap(String id); }
而添加 external
关键字的意思是指“外在”,也就是说这个方法是js代码实现的。
下面我们看一下 Map的文档 :
Map
的构造方法不止一个div id这么简单,也可能是 HTMLDivElement
,所以我们不能使用之前的String类型了。同时有 MapOptions
这个初始化的参数对象。
@JS('Map') class AMap { external AMap(dynamic /*String|HTMLDivElement*/ div, MapOptions opts); }
而 MapOptions
实际是一个 Map
结构,并不是一个类,所以我们需要添加 @anonymous
注解,否则创建 MapOptions
就成了 new AMap.MapOptions
,这个显然不在js库中。
@JS() @anonymous class MapOptions { external factory MapOptions({ /// 初始中心经纬度 LngLat center, /// 地图显示的缩放级别 num zoom, /// 地图视图模式, 默认为‘2D’ String /*‘2D’|‘3D’*/ viewMode, }); }
如果你想获取或修改某些参数,可以添加对应的 get
、 set
方法。
@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
对象,这个类的文档如下:
所以对应的Dart封装如下:
@JS() class LngLat { external num getLng(); external num getLat(); external LngLat(num lng, num lat); }
这里我没有写完全,只提供了我用到的 getLng
、 getLat
方法。
这里我们使用一下我们目前的成果:
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
JavaScript数组没有具体的元素类型,因此JavaScript函数返回的数组不能在不检查每个元素的情况下保证其元素类型。
举个例子:假设js有个数组 list = ['Android', 'iOS', 'Web'];
,看似以为它是个 List
,其实它是 List
。
// true print(list is List); // false print(list is List);
在高德里有个poi的搜索功能,最后会返回一个 Array
,实现代码如下:
@JS() @anonymous class PoiList { external List 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
,测试也没什么问题。但是pois确实返回的是 List
,所以稳妥的写法还是使用 List
>,使用时再转换或强转。
2.回调
也就是传递函数,这里以地图插件加载方法来举例。文档如下:
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,则需要使用 allowInterop
或 allowInteropCaptureThis
方法确保兼容性。
3.异步
举例:高德推荐使用 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 plugins, }); }
主要还是使用上,怎么将Js的 Promise
转换成Dart的 Future
。这里就用到了 promiseToFuture
方法,源码如下:
Future promiseToFuture(jsPromise) { final completer = Completer(); 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
之前):
其实与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
没有和 AndroidView
、 UiKitView
一样给予 onCreatePlatformView
创建回调,导致我直接创建的地图会显示不出来,所以我使用了 addPostFrameCallback
来处理。或者参考这个 issue ,自定义 PlatformViewLink
来实现。
不过我遇见的问题不止这些,大都是地图的显示问题。比如:
- 高德地图的logo、定位、比例尺这类不显示,部分在地图的左上角被地图层覆盖。
- 地图上的覆盖物添加后无法修改。
- 地图上的覆盖物在地图放大缩小后位置偏离。(和第二点类似)
可以看出问题都是渲染上的,查询相关资料得知现在是基于 HTML DOM
的模型,该模型结合了 HTML
, CSS
和 Canvas API
来实现页面,官方将此实现称为 DomCanvas
渲染系统。而目前在尝试使用第二种方法 CanvasKit
, CanvasKit
使用 WebAssembly
和 WebGL
将 Skia
引入Web,利用硬件加速从而提高了渲染复杂和密集图形的能力。
现阶段Flutter Web 默认使用 DomCanvas
,所以我尝试使用以下命令启用 CanvasKit
渲染引擎来看看效果:
flutter run -d chrome --release --dart-define=FLUTTER_WEB_USE_SKIA=true
运行后发现这些问题得到了解决,但是又产生了新的问题。比如地图不能点击、拖动,文字乱码。。。
当然官方的文章也指出,现阶段 CanvasKit
引擎还是比较粗糙的,而 DomCanvas
引擎相对更加稳定。
最终我还是使用了稳定的方案。因为其他功能,比如poi的搜索、地图点击这类不涉及显示的功能,测试均是正常的,基本可以满足使用。放一下现阶段的效果展示:
实现的功能:
- 自动定位并根据当前经纬度进行POI搜索
- 点击地图获取经纬度并进行POI搜索
- 点击地址信息,移动地图至当前位置
- POI搜索功能
其实从Flutter Web去年的首个预览版、到去年底的Beta版、到现在。我都会将 flutter_deer 在web端运行一下,我能明显的感觉到它表现的越来越好了,比如有时文字不居中、动画表现不一致、 Stack
的层级显示不正确等许多小问题都得到了解决。说不定哪天我再次运行起来,上面说的问题都解决了,哈哈!!
这次发现Flutter Web也支持了 PWA
,我在PC和手机体验下来发现是真的很不错~~,手机端看上去几乎可以以假乱真,其实上面的GIF就是PC端的效果。
最后,我将这部分 完整代码 都已提交至Github,现在这个小插件已经支持Android、iOS和Web了,欢迎体验!!最最后,点赞支持一下~