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端的效果。
最後,我將這部分完整代碼都已提交至Github,現在這個小插件已經支持Android、iOS和Web了,歡迎體驗!!最最後,點贊支持一下~