高德地圖如何新增 Flutter Widget 作為覆蓋物

語言: CN / TW / HK

Flutter_Amap_Marker

使用 Flutter Widget 作為高德地圖覆蓋物

背景

早在 2021 年初,公司的一個 LBS 產品為了完成一些比較炫酷的互動動效和轉場效果,需要在高德地圖上方使用 Flutter 原生元件作為遮罩物。奈何當時在整個 Flutter 生態下,國內三大地圖廠商提供的,或官方或民間的 Flutter 地圖外掛,均不支援此特性。 無奈沒有現成的輪子,只能自己造了。

問題分析

首先,我們知道在 Flutter 中這類地圖元件大多是內嵌到 Flutter 檢視內的平臺原生元件,故我們無法直接在其上層直接繫結 Flutter Widget 作為覆蓋物。一個思路是將當前元件轉換成圖片,然後通過原生的 Marker 介面,將圖片渲染到地圖上。不過這種方法的弊端顯而易見,那就是圖片是靜態的,無法做一些動畫或複雜互動,因為此時地圖 marker 層與 Flutter 環境仍然是相互隔離的兩個容器,故此方案不通。

既然將元件轉成圖片這條路不通,那我們必須要找到一種方法將地圖檢視與 Flutter 層繫結才能達到相同的效果。那麼怎樣將地圖檢視與 Flutter 繫結呢?

方法很簡單,只要知道每個時刻地圖的經緯度邊界,我們就可以通過墨卡託投影拿到每個地理位置在螢幕中的投影座標。此時只要在地圖元件上方覆蓋一層 marker 層,通過調節每個 marker 的 position 即可完成繫結。此方案的優勢在於,marker 層完全由 Flutter 端控制渲染,靈活度極高,可滿足各種業務需求。

解決方案

通過查詢高德地圖官方SDK文件可知,Android端有 Projection.toScreenLocation 方法, iOS端有MAMapView.convertCoordinate:toPointToView 方法,可以將經緯度座標轉換為螢幕座標。

||| |:---:|:---:|

下面我們在高德地圖官方 amap_flutter_map 外掛 v3.0 版本的基礎上擴充套件一下,將這兩個方法暴露出來。

先來看下Android端:

```java // android/src/main/java/com/amap/flutter/map/core/MapController.java 148 行 case Const.METHOD_MAP_GET_SCREEN_LOCATION: if (null != amap) { LatLng location = new LatLng(call.argument("latitude"), call.argument("longitude")); Point position = amap.getProjection().toScreenLocation(location); result.success(ConvertUtil.pointToMap(position)); } break;

// android/src/main/java/com/amap/flutter/map/utils/ConvertUtil.java 182 行 public static Object pointToMap(Point point) { if (point == null) { return null; } final Map data = new HashMap<>(); data.put("x", point.x); data.put("y", point.y); return data; } ```

接著看下iOS端:

Objective-C // ios/Classes/AMapViewController.m 236 行 [self.channel addMethodName:@"map#screenLocation" withHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult _Nonnull result) { double latitude = [call.arguments[@"latitude"] doubleValue]; double longitude = [call.arguments[@"longitude"] doubleValue]; CGPoint position = [weakSelf.mapView convertCoordinate:CLLocationCoordinate2DMake(latitude, longitude) toPointToView:weakSelf.mapView]; result(@{ @"x":@(position.x), @"y":@(position.y), }); }];

最後 Dart 端對齊一下介面:

```dart // lib/src/core/method_channel_amap_flutter_map.dart 274 行 /// 獲取螢幕點座標 Future screenLocation(LatLng location, {required int mapId}) async { return channel(mapId) .invokeMethod('map#screenLocation', { 'latitude': location.latitude, 'longitude': location.longitude, }); }

// lib/src/amap_controller.dart 150 行 /// 獲取螢幕點座標 Future screenLocation(LatLng location) async { return _methodChannel.screenLocation(location, mapId: mapId); } ```

OK,準備工作完成,最後改造一下 AmapWidget

```dart // lib/src/amap_marker_controller.dart part of amap_flutter_map;

class FlutterMarker { final LatLng latlng; final Widget child; Point? position; FlutterMarker({ required this.latlng, required this.child, }); }

class MarkersStack extends StatefulWidget { final AmapMarkerController controller; const MarkersStack({required this.controller, Key? key}) : super(key: key);

@override State createState() => _MarkersStackState(); }

class _MarkersStackState extends State { void rebuild() { setState(() {}); }

@override Widget build(BuildContext context) { widget.controller.rebuildMarkersCallback = rebuild; return Stack( children: widget.controller.markers .map( (e) => Positioned( left: (e.position?.x ?? 0.0) as double, top: (e.position?.y ?? 0.0) as double, child: Offstage( offstage: e.position == null, child: e.child, ), ), ) .toList(), ); } }

class AmapMarkerController {

///地圖控制器 AMapController? controller;

///地圖覆蓋物 List _markers = [];

///marker重新整理回撥 Function? rebuildMarkersCallback;

List get markers => _markers;

void setMarkers(List markers) { _markers = markers; updateMarkers(); }

///更新覆蓋物 Future updateMarkers() async { if (_markers.isEmpty) return; for (var marker in _markers) { marker.position = await map2screen(marker.latlng); } rebuildMarkersCallback?.call(); }

///經緯度轉螢幕座標 /// ///返回Point(x,y)(不在螢幕中為null) Future?> map2screen(LatLng? location) async { if (_markers.isEmpty || location == null || controller == null) { return Future.value(null); } final result = await controller!.screenLocation(location); final p = Platform.isAndroid ? window.devicePixelRatio : 1; return result == null ? null : Point(result["x"] / p, result["y"] / p); } } ```

優化擴充套件

通過 screenLocation 方法拿到地圖座標對應螢幕座標的方式雖然簡單可靠,不過當遮罩物數量過多時,Flutter 與原生平臺間的非同步通訊就會成為效能瓶頸,造成卡頓甚至服務不可用。上面我們提到,只要知道每個時刻地圖的經緯度邊界,我們就可以通過墨卡託投影拿到每個地理位置在螢幕中的投影座標。此種方式只需要在地圖移動時更新一次地圖經緯度邊界,即與原生平臺端通訊一次即可,後續位置對映相關的計算可在 Dart 層實時處理,極大節省了通訊成本。不過此方式的缺點是不支援地圖旋轉和俯仰角變化,讀者可以綜合性能與實際需求,酌情取捨兩種方案。

使用示例

``` Dart import 'package:flutter/material.dart'; import 'package:amap_flutter_map/amap_flutter_map.dart'; import 'package:amap_flutter_base/amap_flutter_base.dart';

void main() { runApp(MaterialApp(home: MapPage())); }

class MapPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: AMapWidget( flutterMarkers: [ FlutterMarker( latlng: LatLng(39.909187, 116.397451), child: FlutterLogo(size: 64), ) ], ), ); } }

```

專案地址

https://github.com/idootop/Flutter_Amap_Marker

其他說明

本專案修改自高德地圖官方 amap_flutter_map 外掛 v3.0 版本,作為「Flutter元件做地圖覆蓋物」相關思路的演示。

程式碼僅供參考,請勿直接用於生產環境!

相關連結

  1. 高德地圖官方 amap_flutter_map 外掛文件:https://pub.flutter-io.cn/packages/amap_flutter_map
  2. 高德地圖 Android 原生 SDK 介面文件:https://a.amap.com/lbs/static/unzip/AMap_HarmonyOS_API_3DMap_Doc/overview-summary.html
  3. 高德地圖 iOS 原生 SDK 介面文件:https://a.amap.com/lbs/static/unzip/iOS_Map_Doc/AMap_iOS_API_Doc_2D/interface_m_a_map_view.html