从零开发一个Android高德地图插件
前言
看过很多博客文章,发现很少提到关于实战中如何使用Android与Dart的混编交互,Flutter在实际项目中仍然需要大量运用到原生的一些功能,比如相机,地图,访问设备本地相册等功能的需求,尽管有大量的第三方插件提供了这些功能,但是实际项目开发中,很可能是不太符合需求的,所以必须要掌握如何在Flutter中使用Android或者iOS的原生View,及Dart与原生的数据交互。本次笔者在项目中接入高德地图,从而向读者展示如何在Flutter中嵌入AndroidView,并且能让Dart与Java交互。
效果预览
进入高德地图显示个人的位置,并以个人的位置为居中点,点击按钮通过 Dart 层向 Native层发送参数,Native根据 Dart 层发送的参数,动态生成 marker显示,回调参数给Dart 层。
实现需求
实现需求 | 是否实现 |
---|---|
3D地图 | ✅ |
显示定位 | ✅ |
自定义marker显示 | ✅ |
Dart 向 Native发送数据 | ✅ |
Native 向Dart发送数据 | ✅ |
开发环境
Flutter | 1.22.6.stable |
---|---|
Android SDK | minSdkVersion:21 compileSdkVersion:29 |
Gradle | 6.5 |
测试机型 | 华为荣耀 Play4T 系统Android 10 |
IDE | Android Sutdio 4.2 |
实现过程
第一步:新建一个Flutter Plugin工程,然后配置一些项目信息,如图1所示。
值得注意的是插件的语言 Android使用 Java,iOS使用 Swift。本次主要以Android插件开发为主。至于iOS的插件开发,笔者会在后期更新
等待Android Studio生成项目成功后,出现项目的 结构如图 2所示
其实看上去跟普通的Flutter工程没什么区别,多了个example目录用来测试插件功能,那么接下来开始编写原生代码插件。
第二步:编写Android原生插件
接下来是重点: 如何进入Android原生代码的编辑呢? 很多博客文章并没有完整地展示如何进入Android原生代码的编辑,我刚开始以为是用 Android Studio直接open到项目根目录下的 android文件夹进行Android原生代码的编写,但是结果完全行不通,各种报错,比如找不到 xxx类。然后又搜索了找不到 Flutter 相关类的问题,又是各种导入 Flutter 依赖,又是增加什么环境变量。但是直到我看一个 Flutter 插件的教学视频,才恍然大悟,原来是我打开的方式有问题。话不多说,看 图 3
相信各位应该能看得很清楚了,鼠标右击项目根目录,然后点击 Flutter 来引出 Open Android Module in Android Studio,等待一会的gradle build之后 这样就进入到了Android原生插件的编辑。结果如图4所示
整个界面没有一点报红的地方。现在可以正式编写Android 原生代码,值得注意的一点是:还记得我们一开始提到的 用于测试 插件的 example目录吗,没错那个example目录对应的其实是现在的app目录,而我们要在 easy_flutter_amap这个目录编写原生代码
要接入高德地图需要到移步到官方的 高德地图开放平台 申请key,官网有详细的申请流程与文档参考。
本次使用时高德最新的 3D地图,,先在 如图 5 插件的build.gradle 位置添加高德地图的依赖
dependencies {
implementation('com.amap.api:3dmap:7.8.0')
}
如图5,注意下划红线的部分代码,设置SDK的最低兼容版本为19, 然后 点击 sync,开始下载高德地图的依赖。
在我这个项目目录中,默认是没有res 目录的,所以要建立一个 res资源目录,如图6 图7
这样就成功地建立了 res 资源目录,接下来再建立一个 drawable目录用来存放一些图标(其实在Flutter项目中,图片的管理是比较复杂的,因为图片实际上既可以放到flutter项目的assets目录,也可以放到原生的目录当中,如果管理不当,很容易出现同样的图片在多端存在的情况,增大了应用app的体积,在笔者面试过的一家公司当中,就出现了这样很严重的问题)
在res目录中的drawable文件夹中添加 mime.png 图片作为 显示自己位置的图标,效果如图 7-1所示
再添加 location_marker.png 图片作为 mark标记的 图片,如图 7-2 所示
新建 AmapView.java文件,通过 params从 AmapViewFactory传来的参数,来初始化高德地图的一些配置信息,并实现与 Dart 交互的接口,从而让达到跨平台的目的。这些代码的目的是:居中显示个人的位置,并且能动态添加 marker标记点,并且添加marker标记点的 参数(标题,经纬度)通过Dart端传递过来,使用原生Native渲染,而这个方法的管道名称为 easy_flutter_amap ,所以 Dart端的 MethodChannel也得连接名称为 easy_flutter_amap,添加成功之后 Native端会返回 一个字符串 suc 给 Dart层。
package cn.jjvu.xiao.easy_flutter_amap.view;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import com.amap.api.maps.AMap;
import com.amap.api.maps.CameraUpdateFactory;
import com.amap.api.maps.LocationSource;
import com.amap.api.maps.MapView;
import com.amap.api.maps.model.BitmapDescriptorFactory;
import com.amap.api.maps.model.LatLng;
import com.amap.api.maps.model.MarkerOptions;
import com.amap.api.maps.model.MyLocationStyle;
import java.util.Map;
import cn.jjvu.xiao.easy_flutter_amap.R;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.platform.PlatformView;
public class AmapView implements PlatformView, MethodChannel.MethodCallHandler {
MapView mapView;
AMap aMap;
LocationSource.OnLocationChangedListener mListener;
private MethodChannel methodChannel;
private Context context;
private static final String TAG = "AmapView";
private Map<String, Object> initParams;
public AmapView(Context context, BinaryMessenger messenger, int id, Map<String, Object> params) {
Log.d(TAG, params.toString());
methodChannel = new MethodChannel(messenger, "easy_flutter_amap");
methodChannel.setMethodCallHandler(this);
initParams = params;
createMap(context);
initMapOptions();
mapView.onResume();
this.context = context;
}
@Override
public View getView() {
return mapView;
}
@Override
public void dispose() {
mapView.onDestroy();
}
private void createMap(Context context) {
mapView = new MapView(context);
mapView.onCreate(new Bundle());
aMap = mapView.getMap();
}
private void initMapOptions() {
Log.d(TAG, initParams.toString());
aMap.moveCamera(CameraUpdateFactory.zoomTo(Float.parseFloat(initParams.get("zoomLevel").toString())));
aMap.getUiSettings().setMyLocationButtonEnabled(true);
MyLocationStyle myLocationStyle = new MyLocationStyle();
myLocationStyle.interval(Long.parseLong(initParams.get("interval").toString()));
myLocationStyle.strokeWidth(1f);
myLocationStyle.strokeColor(Color.parseColor("#8052A3FF"));
myLocationStyle.radiusFillColor(Color.parseColor("#3052A3FF"));
myLocationStyle.showMyLocation(true);
myLocationStyle.myLocationIcon(BitmapDescriptorFactory.fromResource(R.drawable.mime));
myLocationStyle.myLocationType(MyLocationStyle.LOCATION_TYPE_LOCATION_ROTATE);
aMap.setMyLocationStyle(myLocationStyle);
aMap.setMyLocationEnabled(true);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (call.method.equals("addMarkers")) {
if (call.arguments != null) {
Map<String, Object> datas = (Map<String, Object>) call.arguments;
showMarker(datas);
result.success("suc");
}
}
}
private void showMarker(Map<String, Object> data) {
MarkerOptions markerOption = new MarkerOptions();
markerOption.position(new LatLng((double) data.get("latitude"), (double) data.get("longitude")));
markerOption.title((String) data.get("title"));
markerOption.draggable(false);
markerOption.icon(BitmapDescriptorFactory.fromBitmap(BitmapFactory
.decodeResource(this.context.getResources(), R.drawable.location_marker)));
markerOption.setFlat(true);
aMap.addMarker(markerOption);
}
}
新建 AmapViewFactory.java 文件,代码如下。将会从这里中转Flutter端获取到的参数,在Native View实例化的时候,注入Flutter端传递来的参数。
package cn.jjvu.xiao.easy_flutter_amap.view;
import android.app.Activity;
import android.content.Context;
import java.util.Map;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.StandardMessageCodec;
import io.flutter.plugin.platform.PlatformView;
import io.flutter.plugin.platform.PlatformViewFactory;
public class AmapViewFactory extends PlatformViewFactory {
private BinaryMessenger messenger;
private Activity activity;
public AmapViewFactory(BinaryMessenger messenge, Activity activity) {
super(StandardMessageCodec.INSTANCE);
this.activity = activity;
this.messenger = messenge;
}
@Override
public PlatformView create(Context context, int id, Object args) {
Map<String, Object> params = (Map<String, Object>) args;
return new AmapView(context, messenger, id, params);
}
}
在 EasyFlutterAmapPlugin .java文件中注册 我们的地图插件。
package cn.jjvu.xiao.easy_flutter_amap;
import android.app.Activity;
import androidx.annotation.NonNull;
import cn.jjvu.xiao.easy_flutter_amap.view.AmapViewFactory;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.platform.PlatformViewRegistry;
public class EasyFlutterAmapPlugin implements FlutterPlugin, MethodCallHandler, ActivityAware {
private MethodChannel channel;
private BinaryMessenger messenger;
private PlatformViewRegistry platformViewRegistry;
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
messenger = flutterPluginBinding.getBinaryMessenger();;
platformViewRegistry = flutterPluginBinding.getPlatformViewRegistry();
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
if (call.method.equals("getPlatformVersion")) {
result.success("Android " + android.os.Build.VERSION.RELEASE);
} else {
result.notImplemented();
}
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
}
@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
Activity activity = binding.getActivity();
platformViewRegistry.registerViewFactory("cn.jjvu.xiao.easy_flutter_amap/mapview", new AmapViewFactory(messenger, activity));
}
@Override
public void onDetachedFromActivityForConfigChanges() {
}
@Override
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
}
@Override
public void onDetachedFromActivity() {
}
}
在插件的 AndroidManifest.xml目录中 增加一些权限 还有高德地图的必要配置,具体以高德地图的官网说明为准,如图8,9所示
有些权限问题,在本文不作详细描述,如果有需要请看 Flutter Android权限问题,在几个xml文件中配置好 高德的key,这样就完成了初步的 高德地图原生代码的编写,再开始编写 Dart跨平台代码
在 eas_flutter_amap.dart文件中编写 AndroidView类,用AmapConfig类来配置一些高德地图的参数,本次示例简单一点配置初始缩放参数跟,刷新位置时间间隔,让Flutter端在Android View实例化的时候,往 Native端发送数据
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AmapView extends StatelessWidget {
AmapConfig config;
MethodChannel _channel = MethodChannel('easy_flutter_amap');
AmapView({this.config}) {
if (null == this.config)
this.config = AmapConfig();
}
@override
Widget build(BuildContext context) {
return Container(
child: AndroidView(
viewType:"cn.jjvu.xiao.easy_flutter_amap/mapview",
creationParamsCodec: StandardMessageCodec(),
creationParams: config.toMap(),
),
);
}
Future<String> addMarker(MarkerOption options) async {
return _channel.invokeMethod("addMarkers", options.toMap());
}
}
class AmapConfig {
int interval;
double zoomLevel;
AmapConfig({this.interval: 1000, this.zoomLevel: 28.0});
Map toMap() {
Map map = Map();
map['interval'] = interval;
map['zoomLevel'] = zoomLevel;
return map;
}
}
class MarkerOption {
double latitude;
double longitude;
String title;
MarkerOption({this.latitude, this.longitude, this.title});
Map toMap() {
Map map = Map();
map['latitude'] = latitude;
map['longitude'] = longitude;
map['title'] = title;
return map;
}
}
最后我们测试一下插件
在 example/lib/main.dart文件中编辑以下代码,用来测试插件,并打印 回调的 参数
import 'package:flutter/material.dart';
import 'package:easy_flutter_amap/easy_flutter_amap.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
AmapView amapView;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
amapView = AmapView(
config: AmapConfig(zoomLevel: 3),
);
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Plugin example app'),
),
body: Center(
child: amapView
),
floatingActionButton: FloatingActionButton(
child: Text('添加marker'),
onPressed: () async {
String msg = await amapView.addMarker(MarkerOption(latitude: 34.341568, longitude: 108.940174, title: "标记"));
debugPrint(msg);
},
),
),
);
}
}
运行 main.dart文件,结果出现问题:没有显示我的定位,以及地图中心点在北京,如图10所示
感觉代码应该是没问题的,问题会出现在哪呢?对了,可能是权限问题,因为我们这个测试项目暂时还未集成 permission、permission_handler这样的flutter插件,所以还需要手动设置以下权限,如图 11所示,
每个机型的设置可能都不太一样,图11 为笔者机型的效果
授予相关的权限之后再试试效果;这样就成功显示效果,如图12所示
注意事项
有几点需要注意
- 修改了原生代码之后,建议重新编译运行看效果(shift + f9)
- 虽然在AndroidManifest.xml申请了权限,但是还是有可能会出现未授予相关权限导致的报错
- 混合编程最大的难点是定位错误的位置,不要一昧地觉得是某一端的报错,建议先排除是 Dart 端的错误再 来定位 Native端
- 注意几个 字符串的 对应,比如 Dart 与 Native的 MethodChannel的对应 ,注册view与 Dart View 的对应问题
相关代码在我的github上 easy_flutter_amap 我会根据star热度跟个人空余时间来持续地维护这个项目,后期会上 Swift iOS,如果有大佬愿意加入iOS插件的编写,那就更好了
总结
笔者在此之前,使用了 amap_map_fluttify 这个插件,截止本文发布之前,我感觉这是最好用的 flutter 高德地图插件(甚至优于官方的插件),可惜作者维护频率很低,有很多Bug都未修复。笔者如果可能的话,可能也会继续维持一个好的库维护开发。
最近笔者在公司使用Flutter技术独自开发一款企业级的物联网应用,如果对笔者感兴趣,欢迎关注笔者。读者有好的建议,也欢迎在下面留言。码字不易,请给👍