前阵子加班到凌晨,出了地铁口想买瓶水,绕了一圈愣是没找到还亮着灯的自动售货机。那时候我就冒出个念头:能不能自己动手做一个“附近自助售货机查询”的小应用,打开就能看到周边哪里有售货机、还剩下什么商品。正好那段时间在折腾 Flutter 跨平台开发,又赶上鸿蒙生态逐渐成熟,干脆拿这个需求当练手项目,把 Flutter 适配鸿蒙系统这条路也顺便趟一遍。
这篇文章就是把整个项目从零到一的完整过程记录下来。内容会涵盖:为什么选 Flutter 做鸿蒙跨平台、项目功能怎么拆解、定位与地图怎么接、售货机数据怎么展示、鸿蒙平台怎么适配和打包,以及我在实际调试中踩过的坑。适配对象是 Flutter 开发者,尤其是对鸿蒙适配还不太熟、又想做一个实打实的 LBS(基于位置服务)应用的朋友。文章里的代码都是可以照着抄的,步骤也会写得很细,希望能帮你省掉不少试错的时间。
1. 项目思路与整体架构设计
1.1 为什么选 Flutter 来跨鸿蒙而不是原生开发
先说说选型问题。一个“附近售货机查询”应用,最自然的做法是 Android、iOS、鸿蒙各写一套原生界面,但这样成本实在太高。你不仅要维护三套代码,还得分别处理定位权限、地图 SDK、生命周期管理这些平台差异,光想想就头大。
Flutter 的优势在于它自己绘制 UI,不依赖平台的原生控件。不管底层是 Android 的 View、iOS 的 UIKit,还是鸿蒙的 ArkUI,Flutter 最终都是拿 Skia 引擎把每一帧画出来的。这意味着你写一套 Dart 代码,UI 的表现能高度一致,不用为了不同系统的控件差异反复调样式。再加上 Flutter 的状态管理、网络请求、本地存储这些基础设施都非常成熟,生态上可以少踩很多坑。
鸿蒙这边其实也不用太担心。OpenHarmony 生态里已经有 Flutter 的适配分支,社区维护的版本能支持大部分核心能力,包括相机、定位、网络、文件读写这些。相比从零学 ArkTS 原生开发,对于已经有 Flutter 基础的人,把现有代码迁移到鸿蒙要平滑得多。当然,鸿蒙适配链路里也有一些小坑,比如权限声明方式、SDK 路径配置等,后面专门写一节。
1.2 功能模块拆解与整体数据流设计
一个“附近自助售货机查询应用”,核心功能可以拆成这么几块:
- 地图浏览:打开应用就展示当前所在区域的地图,能够拖动、缩放。
- 附近搜索:基于当前定位坐标,向服务端拉取周边一定范围内的售货机点位。
- 列表展示:以列表形式展示售货机信息,包括距离、地址、当前营业状态。
- 详情查看:点击某个售货机,进入详情页,展示库存商品、补货时间等。
- 导航跳转:用户在详情页可以直接发起导航。
整个数据流我用一句话概括:定位拿坐标,坐标换点位,点位画地图,点击看详情。前端启动时先触发定位,拿到经纬度后调用后端接口,后端根据经纬度做范围检索,返回一个含售货机位置、状态、商品列表的 JSON,前端拿到数据后同时驱动地图标注和列表渲染。用户点击列表项,页面聚焦到地图对应点位并打开详情;点击地图标注,也能反向弹出售货机卡片。这样的双向联动,是 LBS 类应用最常见的交互模式。
这个设计的核心考虑是让“地图”和“列表”共用同一份数据源。如果两个模块各自拉接口,就会出现不同步的问题:地图上看到一台售货机,列表里却没有,用户会很困惑。所以我认为,数据层必须统一,UI 层只是同一份数据的两种呈现方式。
2. 环境准备与工程初始化
2.1 Flutter 环境与鸿蒙平台工具链配置
动手之前先把环境准备好。Flutter 这边,我用的版本是 3.x 以上的稳定版,Dart 版本跟着 Flutter 一起走就行。鸿蒙侧的适配工作,我基于社区维护的 Flutter SDK 分支来操作,它能对接到鸿蒙的工具链。
几个关键的环境变量要配好:
FLUTTER_ROOT:指向鸿蒙适配版 Flutter SDK 的根目录。DEVECO_SDK_HOME:指向 DevEco Studio 自带的鸿蒙 SDK 路径。PATH:把 Flutter 的 bin 目录和 DevEco Studio 的工具链目录都加进去。
为什么要单独提 SDK 路径?因为 Flutter 工程在构建鸿蒙目标时,需要通过工具链去编译原生层代码,如果工具链找不到鸿蒙 SDK,构建会直接报错。我第一次配置时就是漏了DEVECO_SDK_HOME,编译后提示找不到模块,排查了半天。
鸿蒙侧还需要准备一个签名配置。真机调试必须有签名,否则应用装不上去。调试阶段可以先使用自动签名,发布时再配置正式的发布证书。
2.2 创建 Flutter 项目与核心依赖选型
环境配好之后,用 Flutter 命令行创建项目:
flutter create vending_machine_finder项目创建好后,默认只有 Android、iOS 这些平台目录。要支持鸿蒙,需要手动添加 ohos 平台的工程配置文件。这里我用的适配分支会在构建时自动生成鸿蒙平台目录,你不需要手工复制大量文件,但如果需要自定义鸿蒙侧的原生行为,就要在生成的hms或ohos目录里改。
接下来是依赖选型。在这个项目里,我用到了这么几个关键包:
| 功能 | 包名 | 用途说明 |
|---|---|---|
| 网络请求 | dio | 封装 HTTP API 调用,支持拦截器、超时控制 |
| 状态管理 | provider | 跨页面共享定位状态、售货机列表状态 |
| 定位 | geolocator | 获取当前经纬度,处理位置权限 |
| 地图 | 地图 SDK | 地图显示、标注、点击交互 |
| 本地存储 | shared_preferences | 缓存用户上次定位位置、搜索历史 |
选依赖不是越新越好,我一般会看这几个维度:是否还在活跃维护、有没有鸿蒙适配的兼容方案、会不会引入 AAR/SO 这类原生依赖导致鸿蒙构建困难。比如定位和地图,这类涉及原生能力的三方库,一定要确认它在鸿蒙侧有对应适配,否则编译期就会卡住。
3. 核心功能实现与关键代码解析
3.1 定位模块:拿不到经纬度,后面全白搭
定位是整个应用的地基,拿不到经纬度,售货机位置根本没法算。我用 geolocator 来获取定位,但在不同平台上需要提前配置权限。
Android 侧,需要在AndroidManifest.xml里申请位置权限:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />鸿蒙侧则是在module.json5里声明权限:
{ module: { requestPermissions: [ { name: "ohos.permission.LOCATION" } ] } }iOS 侧需要在Info.plist里加NSLocationWhenInUseUsageDescription。不同平台的权限配置方式差别很大,这部分尤其容易在鸿蒙侧遗漏。
定位的核心操作很简单,先发起权限请求,再获取当前坐标:
Future<Position> getCurrentPosition() async { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { throw Exception('位置服务未开启'); } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever) { throw Exception('位置权限被永久拒绝'); } return await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); }注意,permission == LocationPermission.denied和deniedForever的区分很重要。如果是denied,可以再弹一次申请;如果是deniedForever,只能引导用户去系统设置里手动打开。如果应用没有提示用户去设置,用户就会一直卡在权限页,以为是应用 Bug。我在实际测试中就遇到过这种情况,后来加了openAppSettings()的引导入口才解决。
定位成功后,我会把经纬度存到 Provider 里,供地图和列表同时读取。如果定位失败或超时,我采用了降级策略:读取 shared_preferences 里缓存的最后定位结果,或者默认使用所在城市的中心坐标作为兜底。这个兜底在演示阶段挺好用,真要上线的话,最好提示用户手动选择定位。
3.2 地图展示与售货机点位标注
地图模块是用户感知最强的部分。初始化地图时,需要在initState里完成 SDK 的初始化,再设置地图的中心点和缩放级别。
地图 SDK 一般都用 key 来鉴权,Android、iOS、鸿蒙各有各的配置入口。鸿蒙侧的配置方式和 Android 不太一样,它是通过应用级的配置文件来声明的。我第一次做鸿蒙地图适配时,在地图初始化上花了很长时间,排查到最后才发现是 key 没有配置到正确的位置。
地图加载好后,把从接口返回的售货机列表映射成标注点,用 marker 加到地图上:
void addVendingPoint(VendingMachineNode node) { Marker marker = Marker( position: LatLng(node.latitude, node.longitude), infoWindow: InfoWindow( title: node.name, snippet: '还剩 ${node.stockCount} 件商品', ), onTap: () { _selectNode(node.id); }, ); _mapController.addMarker(marker); }这里有个细节:onTap触发的回调里,不能直接用node对象的局部副本去做页面跳转,因为地图 SDK 在内部可能对 marker 做了序列化或拷贝。我在做标注点击事件时,一开始直接传了node对象,点击后发现跳转的目标 ID 对不上,排查半天才意识到是局部变量捕获的问题。后来改成了只传node.id,再通过 ID 从列表数据源里查完整对象。
地图和列表的双向联动,我是通过 Provider 里的selectedId状态实现的。点击列表项时更新selectedId,地图侧监听这个值的变化,自动把相机移动到对应标注点,同时让 marker 的样式切换成高亮态。
3.3 售货机列表与详情页:信息展示的核心逻辑
列表页其实是最体现产品逻辑的地方。一个售货机点位,用户最关心的是:离我多远、有没有货、现在能不能买。所以在列表项上,我会优先展示距离和营业状态,而不是详细地址这种次要信息。
列表的数据结构我定义成这样的模型类:
class VendingMachineNode { final String id; final String name; final String address; final double latitude; final double longitude; final int distance; final int stockCount; final bool isOnline; final List<String> topProducts; }列表排序默认按距离从近到远。每次定位结果更新后,都要重新计算每个售货机与当前位置的距离,然后刷新排序。计算距离可以用 geolocator 提供的distanceBetween方法,它会基于地球表面的球面距离公式来计算,结果比简单的勾股定理准确得多。
详情页还要注意一个场景:售货机可能处于离线状态。离线状态时要避免调用后端接口查库存,否则只会白等几秒然后报个超时错误。我加了一个兜底逻辑:如果isOnline为 false,直接展示一张离线占位卡片,提示用户“该设备当前离线,无法查看商品库存”,同时给出最近的一个在线售货机作为替代推荐。这个体验细节很加分,用户不会因为一台离线设备就放弃整个应用。
3.4 状态管理与网络层封装
状态管理我用了 provider,因为它的上手成本低,而且对项目规模不敏感。这个应用的状态实际上不算特别复杂,核心就是三块:定位状态、售货机列表状态、选中点位状态。
我用一个VendingMachineStore来管理这些状态:
class VendingMachineStore extends ChangeNotifier { Position? _currentPosition; List<VendingMachineNode> _nodes = []; String? _selectedId; bool _isLoading = false; Future<void> loadNearbyNodes() async { if (_currentPosition == null) return; _isLoading = true; notifyListeners(); try { final nodes = await _api.fetchNearbyNodes( lat: _currentPosition!.latitude, lng: _currentPosition!.longitude, radius: 5000, ); _nodes = nodes; } catch (e) { _errorMessage = '附近网点加载失败,请检查网络'; } finally { _isLoading = false; notifyListeners(); } } }网络层用 dio 做二次封装,核心是统一处理超时、错误码解析和报文日志。API 接口设计上,我向服务端请求的数据结构是:
GET /api/vending/nearby?lat=31.2304&lng=121.4737&radius=5000返回的 JSON 结构大概是:
{ "code": 0, "data": { "list": [ { "id": "VM001", "name": "地铁站东口售货机", "latitude": 31.2311, "longitude": 121.4742, "distance": 320, "stock_count": 42, "is_online": true, "top_products": ["矿泉水", "可乐", "薯片"] } ] } }封装 dio 时的几个关键点:连接超时建议 5 秒左右,响应超时 10 秒左右,太短容易在弱网时误报,太长会让用户等得失去耐心。另外,一定要做重试机制,定位场景下用户常在移动网络切换的边缘,一次请求失败不代表服务端不可用,我一般对 POST 之外的非幂等请求做一次快速重试。
4. 鸿蒙平台适配与打包发布全流程
4.1 鸿蒙适配里的权限、签名与构建配置
鸿蒙适配是整个项目里最需要耐心的一环。先说权限,鸿蒙的权限配置在entry/src/main/module.json5里,和 Android 的 Manifest 是两套体系。除了定位权限,还必须在设备上开启“位置服务”,否则定位接口会直接返回错误。
我整理了一个鸿蒙适配的关键配置清单:
| 配置项 | 位置 | 说明 |
|---|---|---|
| 权限声明 | module.json5的requestPermissions | 声明定位、网络等权限 |
| 应用签名 | 工程中build-profile.json5配置签名文件 | 真机安装必须有签名 |
| 网络权限 | 使用 HTTP 接口时需要配置明文传输许可 | 否则接口请求会被拦截 |
| SDK 版本 | build-profile.json5中配置 compatibleSdkVersion | 版本太低会导致构建失败 |
这里特别提醒一点:如果后端接口用的是 HTTP 明文协议,鸿蒙默认会拦截。必须在配置里允许明文流量,或者在开发阶段直接改用 HTTPS。我一开始没注意这个问题,后端接口在 Android 上能通,换到鸿蒙上就一直是网络异常,后来在日志里发现是明文请求被系统拦掉的。
4.2 构建 HAP 包与真机调试
当代码在模拟器上和 Android 真机上都能跑通之后,就可以开始构建鸿蒙安装包了。
鸿蒙应用最终打包成 HAP 格式。我在构建流程上走了这么几步:
- 先用命令行清理缓存,避免旧的原生构建产物影响新平台编译。
- 执行 Flutter 鸿蒙适配分支的构建命令,生成 HarmonyOS 平台的原生工程目录。
- 用绾DevEco Studio 打开生成的原生工程,配置签名。
- 构建 HAP 包。
- 通过数据线连接鸿蒙真机,用 hdc 工具安装 HAP 包。
真机调试时,如果遇到安装失败,先看看签名配置有没有问题。鸿蒙对签名校验比较严格,调试证书和发布证书不能混用。另外,真机的开发者模式要在设置里手动打开,不然 hdc 设备列表里根本看不到设备。
我在第一次 HAP 安装时就踩了一个坑:开发机上的签名证书过期了,导致安装时提示“签名校验失败”。这个问题在 Android 上通常不会有那么严格的校验,但在鸿蒙上必须重新生成证书才能装上。
4.3 性能优化与包体积控制
Flutter 应用打包到鸿蒙后,还要注意体积和性能问题。一个新初始化的 Flutter 工程,HAP 包体积通常在 50MB 以上,因为要带上 Flutter 引擎和 Skia 渲染库。如果应用里有地图 SDK,体积还会涨。控制包体积的一个有效手段是启用原生库压缩,并在打包时排除不用的 ABI 架构。
性能方面,地图和列表同时渲染时,最容易出现帧率下降。我的优化做法是给列表加懒加载,只在滚动到接近底部时才加载下一页数据;地图上如果点位超过 50 个,就用聚类算法把相邻的点合成一个聚合标记,等用户放大到足够级别后再拆分显示。这两个优化做完之后,滚动流畅度明显提升。
启动速度上,我做了两个改进:一个是把地图 SDK 的初始化放到首帧渲染之后异步执行,不阻塞主界面;另一个是缓存在线点位的列表快照,用户二次打开应用时可以先渲染缓存数据,等新鲜数据返回后再刷新。
5. 常见问题与排查技巧实录
5.1 高频报错对照表与排查思路
下面这份问题对照表,是我在开发过程中真实遇到的,按出现频率从高到低排列:
| 现象 | 根因 | 排查/解决方案 |
|---|---|---|
| 鸿蒙构建报找不到 SDK | DEVECO_SDK_HOME环境变量未设置 | 检查环境变量,重启终端 |
| 地图白屏不显示 | 地图 key 未配置或配置位置错误 | 检查平台配置文件里的 key 是否匹配 |
| 定位一直转圈 | 权限未申请或用户拒绝 | 检查权限声明和运行时权限弹窗 |
| HTTP 接口在鸿蒙上不通 | 明文流量未开启 | 在鸿蒙配置里允许明文流量 |
| 列表滚动卡顿 | 地图标注点过多 | 开启聚类,列表分页懒加载 |
| HAP 安装失败 | 签名校验不通过 | 检查签名证书有效期和签名配置 |
最值得说的是“地图白屏”这个问题。它在鸿蒙上的原因和 Android 不太一样,有时候不是 key 配错,而是地图初始化时 UI 线程被阻塞,导致地图渲染没有及时启动。我遇到过一次,地图页面一直白屏,日志里什么都查不到,后来在初始化前后分别打印时间戳,才发现是地图初始化被一个同步磁盘读取操作阻塞了。改成异步调取缓存数据之后,问题立刻消失。
5.2 定位偏差与“伪定位”问题
LBS 应用最容易引起用户吐槽的就是定位不准。我自己用一个国产安卓手机测试时,室内定位偏差有时候能到 200 米以上,这在找售货机的场景下基本是灾难,明明旁边的售货机可能被排在列表很后面的位置。
我的处理策略是:对定位结果做一次“可信度判断”,结合 GPS 信号强度和最近一次定位的偏移量,如果偏差过大,就使用缓存的有效位置。同时在 UI 上显示一个“定位精度”图标,让用户知道当前的定位可靠程度。这个细节虽然不起眼,但在用户投诉“附近查询不准”时,能极大减少信任流失。
另外,售货机分布在城市里往往很密集,用户在一个地铁站周边 500 米内可能能看到十几台设备。列表页不能只看绝对距离,要把“营业状态”当作第一优先级,在线且有库存的设备永远排在离线设备前面。这其实是在产品逻辑层面解决定位误差带来的体验问题。
5.3 弱网环境的降级处理与数据缓存
售货机查询的核心场景是在户外,户外网络状况往往不稳定。我做了三层降级。
第一层是先渲染缓存点位。哪怕缓存是半小时前的,也比一打开就白屏强。用户看到地图上有数据,自然会产生“在加载新数据”的感知,而不是认为应用坏了。
第二层是接口请求的快速失败与重试。请求发出后 4 秒没有响应,我会直接取消这次请求,过 1 秒再发起新请求。如果两次都失败,就展示一个轻量提示条“网络开小差了,已为您展示缓存数据”,而不是弹一个不可关闭的错误对话框。
第三层是离线地图缓存。地图 SDK 通常支持预加载局部区域的瓦片数据,我会在用户连接到 Wi-Fi 时,自动下载用户常驻地点周边 3 公里的地图瓦片。这个功能极大提升了户外场景下的地图加载速度,也是一个小而实用的差异化卖点。
6. 写在最后的个人体会
这个项目从构思到跑通,我前后花了一周多的业余时间。最大的收获不是学会了 Flutter 怎么画地图,而是把“定位、地图、列表联动、跨端适配”这一整条链路完整走了一遍。很多知识看文档是一回事,真正把一个功能从 Android 迁到鸿蒙,再从模拟器搬到真机,心态是完全不一样的。
如果你也想拿这个方向练手,我的建议是分三步走:先用最简单的 UI 把定位和地图跑通,别管样式漂不漂亮;然后接一个假数据列表,把地图标注和列表的交互理顺;最后才是接后端接口、做缓存和性能优化。一上来就想着做到完美,很容易卡在地图初始化或者鸿蒙适配的环境问题上,项目热情就被磨没了。
最后分享一个小技巧:开发这种跨端 LBS 应用时,日志系统一定要做得早、做得细。我吃过没日志的亏,地图白屏、定位失效这种问题,没有日志根本无从下手。给 dio 加一个打印请求体和响应体的拦截器,给定位模块打上精度和时间戳日志,这些看起来不起眼的功夫,会在你排查问题时省下十倍的时间。