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<K, V>
    
    产生歧义,所以我们可以给注解
    
     @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<K, V>
    
    结构,并不是一个类,所以我们需要添加
    
     @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<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.回调
   
    也就是传递函数,这里以地图插件加载方法来举例。文档如下:
    
    
    
    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<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
    
    没有和
    
     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端的效果。
   
 
