接手鸿蒙(HarmonyOS NEXT)适配那会,我第一反应是先扒一遍 Flutter 项目里的依赖树,看有没有那种卡在手里根本绕不开的原生模块。结果翻到flutter_google_maps_webservices这个库的时候,松了口气:它是个纯 Dart 包,走的 HTTP + JSON 路线,封装的是 Google Maps Web Services(地理编码、方向、距离矩阵、地点、时区这些核心能力),并不依赖 Android/iOS 的原生 Google Maps SDK。这意味着鸿蒙化时不需要重写原生代码,适配重点直接转移到权限声明、密钥管理、网络错误处理和调用治理上。如果你也在把出海 Flutter 应用迁到鸿蒙,恰好在用或者准备用这套地图 Web 能力,这篇文章应该能帮你少走几周的弯路。
我用一句话概括这个库的定位:它把后端地图计算能力搬到了 Flutter 客户端,让移动应用可以直接调 Google 的地图 Web API,拿到地址坐标、路线几何和点位信息。适合的场景包括外卖配送的距离计算、门店选址分析、路线规划、地址自动补全,这些功能在鸿蒙 App 里同样需要,因此适配这件事本身就有实打实的业务价值。
1. 项目背景与适配思路:为什么这个库的鸿蒙化没那么吓人
1.1 出海应用迁移鸿蒙时,地图 Web 服务为什么不能砍
鸿蒙 NEXT 不再兼容 Android 安装包,这就意味着原来跑在 Android 上的 Flutter 应用要真正"重新落地"一遍。对出海产品来说,地图能力里最容易出问题的不是地图 UI 渲染,而是背后已经跑了很多年的业务逻辑:地址解析、门店排序、运费计算、ETA 预估。
举个例子,外卖 App 里"用户地址和商家距离小于 3 公里才配送"这个规则,如果在旧 Android 版里用的是 Google Distance Matrix API,那这套逻辑会散落在好几个地方:客户端发请求、后端缓存结果、数据库里存距离数据。若因为地图 SDK 不好迁就把整个 Web 服务层换掉,意味着之前所有基于 Google 坐标体系的缓存、历史订单数据、计价策略全部失效,这是极其昂贵的工程事故。
所以在鸿蒙化初期,我定了条原则:先保 Web 服务,再谈地图渲染。只要 Google Maps Web Services 能在鸿蒙设备上发出 HTTPS 请求并拿到 JSON 响应,业务核心就不会塌。地图 UI 部分可以后续通过其他渲染引擎接上,但 Web 服务是数据层,迁移动不得。
1.2 拆解 flutter_google_maps_webservices 的模块边界
这个库的爹是 fluttercommunity,它把 Google Maps Web Services 里最常用的几个 API 全部封装成了 Dart 类:
- GoogleGeocoding:正向地理编码(地址转坐标)和反向地理编码(坐标转地址)。
- GoogleDirections:路线规划,返回折线点列表、距离、时长。
- GoogleDistanceMatrix:批量计算多个起点到多个终点的距离和时长,很适合配送计价。
- GooglePlaces:地点搜索、自动补全、地点详情。
- GoogleTimeZone:根据经纬度和时间戳返回时区 ID。
核心请求链路是:Dart 层构造 URL(比如https://maps.googleapis.com/maps/api/geocode/json) -> http 包发送 GET -> 解析 JSON -> 返回强类型模型。
因为所有能力都是通过 Web API 实现的,而不是通过 Google 官方地图 SDK 的原生方法(安卓的 GoogleMap 对象、iOS 的 MKMapView),所以鸿蒙的 Flutter SDK 只要保证dart:io的 socket 能力映射到鸿蒙协议栈,这个库就能跑。
1.3 鸿蒙化的本质:不是重写,是打通三层
我在项目里反复强调一个观点:第三方库鸿蒙化,先判断它是"纯 Dart 库"还是"平台插件"。纯 Dart 库的鸿蒙化重点是三层:
第一层是编译链路。确认 pub 依赖在鸿蒙 Flutter SDK 下能正常解析、编译产物能打进鸿蒙应用。第二层是网络路径。鸿蒙没有默认给非系统应用放行网络权限,需要在 module.json5 里声明ohos.permission.INTERNET,还要处理 DNS、TLS、超时等实际工程问题。第三层是服务端鉴权。Google API 的 Key 不能硬编码在客户端,密钥管理策略在鸿蒙上尤其重要,因为鸿蒙生态的应用审核和加固体系跟 Android 并不完全一致。
把这三点处理完,剩下的就是业务代码层面的包装了。这远比写一个原生鸿蒙 Module 要省事得多。
2. 适配前的静态检查:把依赖、权限和调试环境一次备齐
2.1 先看依赖树,再做最小编译验证
正式开始动代码之前,我建议先跑一遍依赖检查。我自己习惯用这条命令:
flutter pub deps --style=compact重点看两个东西:第一,flutter_google_maps_webservices依赖了哪些包。它的生态里最核心的依赖是http、json_annotation、json_serializable,这些都是纯 Dart 包,不涉及原生平台通道;第二,这些包的版本在鸿蒙 Flutter SDK 上是否彼此兼容。有时候单独某个包没问题,但锁定的版本跟鸿蒙 SDK 的 Dart 版本冲突,就会在编译期直接崩掉。
我在验证的时候是这么操作的:先用鸿蒙分支的 Flutter SDK 创建一个空工程,直接在这个工程里添加依赖,不写任何业务代码,只写一个初始化GoogleGeocoding的 Dart 文件,编译到鸿蒙真机上。这一步过了,再往里面叠加业务层。千万别上来就搬整个项目,否则出错时根本分不清是鸿蒙适配问题还是业务代码问题。
2.2 module.json5 权限清单:INTERNET 是最低要求
在 Android 项目里,网络权限是在 AndroidManifest.xml 里写<uses-permission android:name="android.permission.INTERNET"/>。鸿蒙不一样,HarmonyOS NEXT 应用模块的权限配置在module.json5,里面有一个requestPermissions数组。最小配置长这样:
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }这里有个很容易忽略的点:如果后续你会用到"当前位置附近的门店"这类功能,除了 INTERNET 权限,还需要加定位权限。鸿蒙的定位权限分两个级别:
{ "name": "ohos.permission.APPROXIMATELY_LOCATION" }以及精确位置权限ohos.permission.LOCATION。申请定位权限时,系统会走动态授权弹窗,这一点跟 Android 的运行时权限逻辑类似。但定位权限不是随便申请的,审核时如果发现权限和功能不匹配,会被打回来。我只申请和应用场景强相关的权限,避免为了省事一把梭。
2.3 调试链路:模拟器、真机和抓包工具
开发期我用鸿蒙模拟器做第一轮验证,跑通后再挪到真机上。这里要单独说下抓包:调试 Google Maps Web Services 时,抓包几乎是必做的,因为你得确认 HTTPS 请求是否真的发出去、响应体里到底返回了什么错误。
鸿蒙真机抓包有一个坑,我记得在 4.0 系统上特别明显:本地抓包工具打开后,App 里所有 HTTPS 请求都会证书校验失败。原因是抓包工具用的证书没有安装到鸿蒙系统信任区。解决办法是手动安装并信任抓包工具的调试证书,然后重启 App。如果遇到CERTIFICATE_VERIFY_FAILED,先别急着怀疑代码,大概率就是证书没装好。
我自己调试时会在 Dart 侧加一个环境开关,只有 debug 模式才设置自定义的 HttpClient,release 模式坚决不碰证书逻辑,避免把调试通道带进生产包。
3. 实操记录:把 flutter_google_maps_webservices 跑通鸿蒙设备
3.1 环境准备:鸿蒙 Flutter SDK 与工程落地
鸿蒙应用开发目前要用 DevEco Studio + HarmonyOS SDK,而 Flutter 侧的鸿蒙适配不是谷歌官方 Flutter 仓库直接支持的,它基于 OpenHarmony 的分支。我用的方案是:
- 安装 DevEco Studio,装好 HarmonyOS SDK。
- 配置 Flutter 鸿蒙分支 SDK,并把
flutter命令路径切到该分支。 - 用
flutter doctor -v检查 SDK 是否被正确识别。
工程落地有两种方式。第一种是直接用 Flutter 鸿蒙分支提供的模板工程,它自带ohos目录,可以在 DevEco Studio 里打开。第二种是把现有 Flutter 项目的ohos目录补齐。对已经存在的项目,我更喜欢第二种,因为业务代码不用动,只要把鸿蒙壳工程加上去。
创建完工程后,第一件事是跑flutter build harmonyos,确认基础构建链路没问题。此刻先不管 Google 地图,先把"空 Flutter App 能跑在鸿蒙 Simulator 上"这个里程碑落地。
3.2 最小验证:用 Geocoding 打通 API 通路
在工程里添加依赖:
dependencies: flutter_google_maps_webservices: ^0.1.1然后写一个最小页面,把 Geocoding 请求跑起来。我选 Geocoding 作为第一个验证对象,因为它的入参最简单:一个地址字符串,返回坐标,链路最短,最容易定位问题。
import 'package:flutter/material.dart'; import 'package:flutter_google_maps_webservices/geocoding.dart'; class GeocodingDemo extends StatefulWidget { const GeocodingDemo({super.key}); @override State<GeocodingDemo> createState() => _GeocodingDemoState(); } class _GeocodingDemoState extends State<GeocodingDemo> { String _result = '等待请求'; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Geocoding 验证')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: _callGeocoding, child: const Text('发起地址解析'), ), const SizedBox(height: 16), Padding( padding: const EdgeInsets.all(16), child: Text(_result), ), ], ), ), ); } Future<void> _callGeocoding() async { final geocoding = GoogleGeocoding(_readApiKey()); final response = await geocoding.geocode('1600 Amphitheatre Parkway, Mountain View, CA'); if (response.status == GeocodingStatus.ok) { final first = response.results.first; setState(() { _result = first.formattedAddress ?? ''; }); } else { setState(() { _result = '请求失败: ${response.errorMessage}'; }); } } }这里有个重要的实践经验:GoogleGeocoding构造时接受apiKey,但我强烈不建议把 Key 以字符串常量写在上面的代码里。后面我会专门讲密钥管理,但现在先拿一个临时 Key 验证通路。
当你在鸿蒙真机上看到请求成功返回地址时,说明三件事已经成立:鸿蒙 Flutter 网络的 socket 能力正常、Google API 域名可达、库的 JSON 解析逻辑没在鸿蒙运行时上出问题。
3.3 API Key 注入:dotenv、打包与后端转发
API Key 就像银行卡,客户端直连 Google 相当于把银行卡密码写在手机壳背面。鸿蒙应用相比 Android 有一个更麻烦的地方:鸿蒙生态的加固、混淆、包提取防护方案跟 Android 不完全一样,密钥藏在客户端里的安全性更难保证。所以我给团队的方案分两级:
第一级是,至少要做到编译期注入而不是硬编码。用 flutter_dotenv:
import 'package:flutter_dotenv/flutter_dotenv.dart'; final apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';.env文件加进.gitignore,不出现在版本库里。
第二级是,业务量大的项目直接走"后端转发"。Flutter 端只请求自己后端:
GET /api/maps/geocode?address=...后端拿到请求后再拼上 Google API Key 去访问真实服务。这样客户端永远不接触 Key,配额、计费、缓存、降级全部收敛在后端。代价是多一跳延迟,但配合后端缓存,整体速度未必比客户端直连差。这个方案尤其适合鸿蒙上架后的监管合规要求,因为很多企业有密钥审计需求,后端转发是最好的兜底实践。
3.4 更多 API 的调用示例:Directionss、DistanceMatrix 和 Places
Geocoding 通了之后,其余 API 基本是复制粘贴级别。我直接贴几个我实测过的调用片段:
Directions 路线规划:
import 'package:flutter_google_maps_webservices/directions.dart'; final directions = GoogleDirections(_readApiKey()); final response = await directions.directions( origin: Location(lat: 37.4224764, lng: -122.0842499), destination: Location(lat: 37.7749295, lng: -122.4194155), travelMode: TravelMode.driving, );Distance Matrix 距离矩阵:
import 'package:flutter_google_maps_webservices/distance_matrix.dart'; final matrix = GoogleDistanceMatrix(_readApiKey()); final response = await matrix.distance( origins: [Location(lat: 37.4224764, lng: -122.0842499)], destinations: [Location(lat: 37.7749295, lng: -122.4194155)], travelMode: TravelMode.driving, );注意Location类来自这个库的geocoding.dart或独立的location.dart,在鸿蒙上没有任何原生依赖,放心用。
Places 自动补全:
import 'package:flutter_google_maps_webservices/places.dart'; final places = GooglePlaces( _readApiKey(), language: 'en', region: 'us', ); final result = await places.autocomplete('Mountain View');这两个参数值得解释一下:language控制返回结果的语言,region控制地点排序的偏向。比如同样是输入 "New York",region=us会优先纽约州的结果,而region=gb可能优先英国同名地点。这个参数对海外多区域运营很重要。
4. 排坑实录:鸿蒙化路上我踩过的 7 个问题
4.1 请求超时:默认 http.Client 没有超时
第一个坑最隐蔽。代码写好后,在地铁里随手点了几次接口,发现请求经常挂起两分钟才报错。查了下源码,发现flutter_google_maps_webservices内部使用的http.Client()没有默认超时时间。这在网络环境良好的办公室没问题,但鸿蒙设备在弱网环境(比如双卡切换、公用 Wi-Fi)中非常容易卡死。
我的解决办法是给每个请求统一设置超时。但库的构造器不一定暴露了所有请求的超时入口,所以更稳妥的做法是在工程层包装一层http.Client:
import 'package:http/http.dart' as http; class TimeoutClient extends http.BaseClient { TimeoutClient(this._inner, {this.timeout = const Duration(seconds: 15)}); final http.Client _inner; final Duration timeout; @override Future<http.StreamedResponse> send(http.BaseRequest request) { return _inner.send(request).timeout(timeout); } }然后尽量通过库的httpClient参数传入。如果某些 API 类不支持注入,就用Completer包裹请求做超时控制。核心思想是:绝对允许超时,终端用户能接受失败,但不能接受无响应。
4.2 模拟器上 DNS 解析异常,真机却正常
这大概是鸿蒙模拟器的老毛病。调试时发现模拟器上所有 Google API 请求都失败,错误是SocketException: Failed host lookup。但同样的代码放到真机上立刻就好。
排查思路是先确认是不是 DNS 的锅。我写了段临时代码,在 Dart 里手动解析域名:
import 'dart:io'; final addresses = await InternetAddress.lookup('maps.googleapis.com'); print(addresses);在模拟器上看到返回的全部是 IPv6 地址,而模拟器网络环境里 IPv6 路由根本不通,所以请求全部超时。解决方式很直接:在模拟器设置里把网络切到桥接模式,或者改用真机调试。做网络类库适配时,真机优先级应该高于模拟器。
4.3 REQUEST_DENIED:一半以上是配置问题
鸿蒙上跑起来以后,最常见的业务错误是REQUEST_DENIED。出现这个错误,我建议按这个顺序排查:
先在 Google Cloud Console 确认你要用的 API(比如 Geocoding API、Places API)确实已经开启;再检查 API Key 的类型和限制,如果 Key 设置了 HTTP 引用来源限制,但客户端请求的 User-Agent 不符合预期,就会被拒绝;最后检查是否绑定到了正确的项目,别把 Key 从 A 项目抄到 B 项目,然后对着 B 项目排查半天。
鸿蒙环境下尤其要注意,因为鸿蒙浏览器的容器和常见 Android WebView 在标识上不同,如果你限制了 referer 白名单,可能误伤正常的 Flutter 请求。这种问题在日志里看不到明显特征,最好的方式是先用一个无限制的临时 Key 验证通路,确认能通后再逐步收紧。
4.4 OVER_QUERY_LIMIT:配额不是无限刷的
业务上线后,第一个星期就开始收到OVER_QUERY_LIMIT的错误上报。这个错误分成两种情况:超过了每日配额,或者超过了每秒请求数限制。
Google Maps Web Services 对每个 API 都有每日免费额度,比如 Geocoding API 大约是每天 10000 次请求,Distance Matrix API 按单次请求里起止点对数量计费。如果在鸿蒙端同时有多个页面并发调用,每秒 QPS 很容易打爆。
解决办法是后端缓存 + 队列。我后面第 6 章会细讲,这里先说原则:所有高频、数据相对静态的请求,全部用 Redis 或本地数据库缓存,不要让同一地址的编码请求反复打到 Google。
4.5 抓包工具介入后,所有请求突然证书失败
调试 Google API 的响应内容离不开抓包。但鸿蒙真机开抓包工具后,我遇到一个非常迷惑的现象:只有用了抓包工具,证书校验就失败;关掉抓包工具立刻恢复。
原因不复杂:抓包工具在中间做 TLS 解密,需要 iOS/Android/HarmonyOS 信任它的根证书,否则客户端校验服务器证书时发现链路不完整,直接拒绝。鸿蒙的证书信任机制比 Android 更严格,普通方式安装的调试证书只能影响部分应用。
我的实践方式是:只在专用的测试机上操作,安装抓包工具证书到系统信任区,并且每一次装完证书后强制杀掉 App 进程再重新打开。不要试图在生产机上搞这个操作,得不偿失。
4.6 定位数据接不进 Flutter:EventChannel 的正确姿势
很多地图 Web 服务没有定位能力本身,但业务入口往往需要"当前经纬度"。鸿蒙原生的定位结果要送给 Flutter 层,绕不开平台通道通信。
鸿蒙侧通过geoLocationManager注册位置监听,把经纬度打包后通过eventChannel发送给 Dart:
// ets 端示例 import { geoLocationManager } from '@kit.LocationKit'; eventChannel.send({ latitude: currentLocation.latitude, longitude: currentLocation.longitude, });Dart 侧用 EventChannel 接收:
import 'package:flutter/services.dart'; const channel = EventChannel('location/updates'); channel.receiveBroadcastStream().listen((event) { final map = Map<String, dynamic>.from(event as Map); // 拿到经纬度后传给 GoogleGeocoding 做反向地址解析 });这里有个易错点:鸿蒙的定位权限请求是异步的,必须在 Dart 侧先调用 MethodChannel 触发权限弹窗,等用户授权后再注册 EventChannel 监听,否则数据收不到。
4.7 混淆与序列化:release 包偶发解析全空
开发环境跑起来一切正常,打 release 包之后,Geocoding 的返回对象.results经常为空或者字段全空。这个问题一度让我怀疑是鸿蒙的系统字体或者编码问题,查到最后发现是打包时的混淆策略在作祟。
flutter_google_maps_webservices的模型大量使用json_serializable自动生成的 fromJson/toJson。如果打包时开启过激进的混淆、字段名被重写,或者生成代码里通过 getter 取字段的方式被破坏,都会导致运行时拿到的是空值。
我的建议是:如果业务对包体大小不是极度敏感,先不要对依赖库做混淆;如果一定要混淆,release 包出来后完整过一遍所有 API 的回归用例,不要只看编译产物能否生成。
5. 常用错误码速查表
排查 Google Maps Web Services 时,把错误码背下来是没用的,但做成速查表贴在文档里非常有效。这是我在鸿蒙项目里沉淀下来的表格:
| 错误码 | 含义 | 鸿蒙场景下的排查建议 |
|---|---|---|
| INVALID_REQUEST | 请求参数缺失或不合法 | 检查经纬度是否传反、格式是否错误;检查路线规划的起终点是否有 null |
| ZERO_RESULTS | 查无结果 | 地址拼写不对;region 区域偏向导致;尝试缩短地址文本 |
| NOT_FOUND | 路径规划中无法定位起终点 | 确认坐标在 Google 支持的范围内 |
| REQUEST_DENIED | 请求被拒绝 | 检查 API 是否开启、Key 是否有效、是否受限;用临时无限制 Key 排除问题 |
| OVER_QUERY_LIMIT | 超出配额或 QPS 限制 | 看 Cloud Console 用量曲线;后端加缓存和队列 |
| UNKNOWN_ERROR | 服务器内部错误 | 通常等几秒重试可恢复,需要在客户端做重试 |
| 网络异常 | SocketException、TimeoutException、DNS 解析失败 | 鸿蒙模拟器优先切换真机;确认 INTERNET 权限;检查企业路由器是否拦截 HTTPS |
这类表格最好放进团队内部的 Wiki 或者代码仓库的 README 里,遇到问题先查表,比临时翻官方文档快得多。
6. 进阶治理:限速、缓存与错误提示的统一收口
6.1 并发控制:把自由请求改成受限队列
Google Maps Web Services 不是无限并发服务。距离矩阵这类 API 是按元素数计费的,如果业务上每分钟发起上千个点对计算,后端要能压得住。
我在鸿蒙适配里采用了一种很朴素的限速方案:Dart 侧维护一个全局请求队列,控制每 200ms 最多发一个请求。简单实现如下:
class RateLimiter { RateLimiter({this.minInterval = const Duration(milliseconds: 200)}); final Duration minInterval; DateTime _lastRequestAt = DateTime.fromMillisecondsSinceEpoch(0); Future<T> run<T>(Future<T> Function() action) async { final now = DateTime.now(); final waitTime = minInterval - (now.difference(_lastRequestAt)); if (waitTime > Duration.zero) { await Future.delayed(waitTime); } _lastRequestAt = DateTime.now(); return action(); } }不要小看这个 200ms,它能帮你把瞬时 QPS 从几十压到个位数,极大降低OVER_QUERY_LIMIT的概率。当然,更精细的做法是使用rate_limiter包或者后端令牌桶,但绝大多数客户端场景下这个简单实现已经够用。
6.2 缓存策略:让重复请求不再打爆 Google 配额
地址解析的结果在短期内是稳定的,"北京市朝阳区某大厦"今天解析出来的坐标和明天不会有区别。这意味着 Geocoding 是缓存收益最高的接口。
我建议按"请求参数 + region + language"拼缓存 key,把响应 JSON 存到本地数据库或者 Hive 里,缓存有效期可以放到 30 天。Places 自动补全的缓存时间可以短一些,因为商家数据可能变化。Distance Matrix 的缓存更特殊,它跟路况、交通模式相关,建议默认缓存 1 小时,高峰期的路线结果只缓存 5 分钟。
我在鸿蒙项目里用hive做本地缓存,Dart 侧封装一层MapsCache,每次请求前先查缓存,命中则直接返回,未命中再走网络。一套下来,Google 的配额消耗能降低六成以上。
6.3 用户侧的错误提示:别把内部错误码裸露给用户
鸿蒙应用上架审核对崩溃率、无响应率抓得很严。如果用户在弱网下看到OVER_QUERY_LIMIT或者REQUEST_DENIED这种错误码,会觉得很不专业,而且这种页面很容易被用户投诉。
我把业务侧的错误提示全部收口成一个统一的ApiException:
class ApiException implements Exception { ApiException(this.message, {this.rawCode}); final String message; final String? rawCode; }UI 层只根据ApiException.message展示,比如"网络暂时繁忙,请稍后再试"。原始错误码只在日志和监控平台里透出,方便自己排查,不让用户看到。
这样一个看似简单的收口,实际上对上线后的用户评价影响非常大。鸿蒙应用商店的内测用户对崩溃和异常的容忍度比老安卓用户低得多。错误提示打磨得干净一点,审核通过率和用户留存都会受益。
7. 写在最后的个人经验
真正把flutter_google_maps_webservices迁到鸿蒙之后,我最大的体悟是:鸿蒙化不等于重写,关键在于分类判断。纯 Dart 库就是适配成本最低的那一类,它不碰原生 SDK,只是需要你把网络权限、密钥管理、错误处理和缓存治理这层"外围工事"做好。
最后再分享一个小技巧:如果后续你的鸿蒙应用想彻底摆脱地图渲染层面的历史包袱,可以只保留这个库的 Web 服务能力用于路线计算和地址解析,地图 UI 部分改用鸿蒙原生地图 SDK 的 TileOverlay 渲染。这样从数据到渲染全部原生化,海外版和国内版可以在同一套 Flutter 业务代码下分别对接不同的地图服务商,运维起来会舒服很多。