
说实话第一次听说要把 Flutter 项目同时跑在鸿蒙系统上时我心里是有点打鼓的。跨平台方案本身不稀奇但“附近自助售货机查询”这个场景要做的事情很杂既要定位、又要地图展示、还要处理实时库存和扫码购买任何一个环节在鸿蒙上水土不服整个项目都会卡住。这篇文章就把我实际做这个应用的全过程记录下来从环境搭建、核心功能实现到鸿蒙适配的踩坑全部摊开讲适合已经会 Flutter 基础、想跨到鸿蒙生态试试水的开发者也适合那些准备做 LBS 类工具应用但还在犹豫技术栈的朋友。整个项目的核心其实就一句话用一套 Flutter 代码让用户在手机上看到附近的自动售货机、查看货道状态、扫机器码在线购买。听起来简单真正落地的时候定位权限、地图渲染、列表状态同步、支付回调这一串链条任何一个环节出 bug体验都会崩。下面我按实际开发顺序逐步复盘。1. 项目缘起与整体设计思路1.1 为什么选 Flutter 而不是原生双写先聊选题。自助售货机这几年在办公园区、地铁站、社区门口铺得很密用户痛点很明确想买瓶水不知道最近的机器在哪走过去才发现缺货或者只能现金支付。做一个“地图找机器 在线购买”的查询应用需求是真实存在的。技术选型上我一开始就锁定了 Flutter。原因很简单团队只有我一个人原生 Android 和 iOS 各写一套不现实更不要说再单独适配鸿蒙。Flutter 的渲染引擎自绘 UI在跨平台场景下能保持高度一致的交互体验列表滚动、地图覆盖层、自定义弹窗这些高频组件都有成熟方案。鸿蒙生态已经逐渐进入主流视野用 Flutter 做一套代码覆盖安卓、iOS、Web 和鸿蒙是性价比最高的路径。实际开发中也验证了这个判断。项目里 UI 部分几乎没写平台分支只有涉及系统能力定位、扫码、支付时才需要走插件通道。如果你预计将来还要出小程序或者桌面端Flutter 这套代码的复用空间会更大。1.2 需求拆解与应用架构把“附近自助售货机查询”拆开核心需求其实是四个模块地图定位获取用户当前位置以经纬度为中心展示周边售货机 POI。售货机列表支持按距离排序、筛选状态正常/离线/缺货、分页加载。商品浏览进入售货机详情后展示货道和商品库存实时反映售卖状态。扫码购买扫描机身二维码拉起在线支付支付成功后下发出货指令。架构上我没有搞得很重用的还是 MVVM 加 Repository 仓储层。状态管理选了 Provider原因就两条一是官方推荐、资料多二是学习成本低团队接手快。整个lib目录结构如下lib/ ├── main.dart ├── app.dart ├── core/ │ ├── api/ # 网络请求封装 │ ├── config/ # 环境配置接口地址、地图 key │ └── utils/ # 定位、权限、格式化工具 ├── features/ │ ├── map/ # 地图页地图组件、地图 marker │ ├── store_list/ # 列表页售货机列表、筛选、排序 │ ├── product/ # 商品详情货道展示、库存管理 │ └── order/ # 订单下单、支付回调、出货状态 └── shared/ ├── models/ # 数据模型售货机、商品、订单 └── widgets/ # 通用组件卡片、空态、加载中这个结构的好处是每个功能模块互不依赖地图页和列表页可以独立调试。我建议你不管项目大小都保持这种“功能竖切”的目录习惯后面加功能不会把代码搅成一团。2. 环境配置与项目脚手架搭建2.1 Flutter 与鸿蒙开发环境准备先说环境。这个项目我用的 Flutter 3.x 稳定版搭配 Dart 3.x鸿蒙侧需要一套完整的原生开发工具链。我当时的做法是安装 Flutter SDK并把flutter/bin加进 PATH。安装鸿蒙开发工具和对应的 SDK 包用于编译鸿蒙工程。在 Flutter 工程里手动添加鸿蒙平台目录官方脚手架默认没有直接生成鸿蒙工程需要根据当前 Flutter 版本对鸿蒙的支持情况用命令行或脚本初始化一次鸿蒙壳工程。配置环境变量让 Flutter 能识别到鸿蒙 SDK 路径。这一步是最容易劝退新手的。很多人卡在“flutter create 之后发现没有鸿蒙目录”这个很正常因为默认模板只生成 android、ios、web 等平台。鸿蒙目录需要单独生成。不同 Flutter 版本的初始化命令可能略有差异以你本地的 SDK 版本说明为准。跑通一个 hello world 再继续别一上来就堆业务代码。配置完成后我习惯先跑一遍flutter doctor确认基础环境正常然后再用鸿蒙 IDE 单独打开生成的鸿蒙壳工程做一次编译验证。这一步能提前暴露引擎版本、SDK 版本不匹配的问题。2.2 依赖选型与版本锁定依赖选型直接决定后面要踩多少坑。我整理了一张表格列出这个项目实际用到的关键依赖和我选择它们的原因能力依赖方案选择理由定位geolocator 插件接口统一支持安卓、iOS、鸿蒙权限处理封装得比较友好地图某地图 SDK 的 Flutter 插件地图 POI 是国内场景的刚需该插件对 Flutter 支持较完整网络请求dio拦截器方便上传下载、取消请求都有现成方案状态管理provider官方推荐、写法直观适合中小型项目本地存储shared_preferences轻量缓存用户偏好和最近定位记录扫码系统相机能力加自定义扫码组件避免引入过重的扫码库鸿蒙端更好适配特别提醒一点地图插件是跨平台适配里最容易出问题的环节。我在项目里把地图相关代码单独封装了一层后续如果某个平台的地图渲染不行只需要替换内部实现对外暴露的接口不变。项目管理上我用pubspec.lock锁定了所有依赖版本。跨平台项目最怕“某个平台某天突然升级依赖导致编译挂了”锁定版本能极大降低这种风险。建议你在pubspec.yaml里给核心依赖写上比较精确的版本号不要用^通配到最新。3. 核心功能实现定位、地图与售货机列表3.1 定位权限处理与位置服务接入定位是所有 LBS 功能的地基。这个项目里我先处理权限再做定位最后把经纬度传给地图和列表接口。安卓端在AndroidManifest.xml里声明定位权限同时要区分前台和后台定位iOS 端需要在Info.plist里写清楚用途描述否则系统直接拒绝鸿蒙端则是在module.json5里声明需要的权限项。权限声明是硬性的漏掉一个真机上运行时就直接黑屏或者弹不了授权框。定位代码用 geolocator 写起来很简洁import package:geolocator/geolocator.dart; FuturePosition? determinePosition() async { // 1. 检查定位服务是否开启 bool serviceEnabled await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { return null; } // 2. 检查权限状态 LocationPermission permission await Geolocator.checkPermission(); if (permission LocationPermission.denied) { permission await Geolocator.requestPermission(); } if (permission LocationPermission.deniedForever) { return null; } // 3. 获取当前位置 return await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); }这里有个经验不要一进入应用就强拉授权弹窗而是先给用户一个解释页说明“需要定位权限来查找附近的售货机”用户点了按钮再触发系统授权。这样做的目的是在系统弹窗之前先让用户理解用途授权通过率会明显高很多。定位结果拿到后我会把它存进全局状态里地图页和列表页都会用到。另外还有一个细节城市级别的定位精度对列表接口已经够用但地图上显示附近 POI 时建议用更高的精度否则容易出现“地图上的售货机和你在同一条街但显示隔了两个路口”这种体验问题。3.2 地图组件集成与周边展示地图页是这个应用的视觉核心。用户打开 App 第一眼看到的就是地图和分布在地图上的售货机标记点。我在接入地图组件的时候重点处理了三件事。第一件是地图初始化。地图组件加载需要传入中心点经纬度和缩放级别。我的做法是先读取本地缓存的上次定位位置如果有就直接用没有就显示默认城市中心等定位结果返回后再做一次地图移动动画把中心点平滑拉到用户位置。第二件是添加售货机 marker。接口返回附近的售货机列表后每一台机器在地图上对应一个标记点点击标记点弹出底部卡片展示机器状态、距离、营业时间等信息。标记点的自定义图标需要区分三种状态正常、缺货、离线。这个状态切换的视觉反馈很关键用户扫一眼地图就知道哪台机器能去。第三件是地图和列表的联动。我在地图和列表之间加了一条联动逻辑地图移动结束后会触发“重新获取当前可视范围内的售货机”列表页则根据距离排序展示同样的数据源。数据源统一存放在 Provider 里地图和列表都是它的观察者这样就不会出现两边数据不一致的诡异 bug。地图组件这块要特别留意平台差异。安卓和 iOS 上的渲染能力基本一致鸿蒙端如果地图插件不直接支持我的备用方案是用平台视图承载鸿蒙原生地图组件再通过通信层把 marker 数据和点击事件传给 Flutter 层。这个方案会增加不少工作量所以我建议在项目启动前先做一次最小化的地图渲染验证而不是等到功能做到一半才发现地图打不开。3.3 列表页数据加载与状态管理列表页是用户获取信息密度最高的页面。我实现了三个要素分页加载、状态筛选、下拉刷新。分页加载的常规做法是记录当前页码和是否有更多数据每次到底部自动请求下一页。这里要注意一个坑筛选条件变化之后必须重置页码否则会出现“筛选后第一页加载出来却是上一批条件的数据”这种错乱。状态管理用 Provider 写起来很顺手。我定义了一个StoreListProvider负责持有列表数据、加载状态、错误信息和分页参数。页面层只用context.watchStoreListProvider()来读取状态触发加载则调用 provider 里的方法。这样 UI 层保持很薄调试起来逻辑也清晰。核心 Provider 的核心逻辑大概是这样class StoreListProvider extends ChangeNotifier { final StoreRepository _repository StoreRepository(); ListVendingMachine _stores []; bool _isLoading false; bool _hasMore true; int _page 1; ListVendingMachine get stores List.unmodifiable(_stores); bool get isLoading _isLoading; bool get hasMore _hasMore; Futurevoid refreshNearby(double lat, double lng, {String? status}) async { _page 1; _hasMore true; await _loadPage(lat, lng, statusFilter: status); } Futurevoid loadMore(double lat, double lng, {String? status}) async { if (_isLoading || !_hasMore) return; _page; await _loadPage(lat, lng, statusFilter: status); } Futurevoid _loadPage( double lat, double lng, { String? statusFilter, }) async { _isLoading true; notifyListeners(); try { final result await _repository.fetchNearbyStores( lat: lat, lng: lng, page: _page, pageSize: 20, status: statusFilter, ); if (_page 1) { _stores result.items; } else { _stores.addAll(result.items); } _hasMore result.hasMore; } catch (e) { // 捕获异常由 UI 层根据错误类型展示重试或提示 errorMessage e.toString(); } finally { _isLoading false; notifyListeners(); } } }这个写法的好处是刷新和加载更多共用同一个分页数据源不会因为两条代码路径各自维护状态而出现分页混乱。UI 层只需要关心isLoading、hasMore、stores三个状态就能正确渲染加载中、空列表、正常列表和底部加载更多的不同视图。列表项 UI 我用的卡片式布局左侧机器照片占位图中间显示售货机名称、距离、地址摘要右侧用状态标签展示“营业中”“暂时缺货”“离线”。距离的计算我会放在后端接口完成前端只负责展示这样列表排序就已经按距离排好了。如果你的接口不返回距离前端也可以自己用 Haversine 公式算但大规模列表分页时还是建议后端算。4. 商品浏览与在线购买流程实现4.1 售货机详情与货道库存展示用户从地图或列表点进一台售货机后看到的是这台机器的详情页机器基础信息、货道列表、每个货道里的商品和库存。自助售货机内部通常有多个货道每个货道放一种或多种商品售卖时会根据货道编号出货。详情页的关键是真实反映“可购买状态”。一个货道如果已经售空就必须在 UI 上置灰并且不可点击否则用户下单后才发现没货支付又已经完成了体验会非常糟糕。我在项目的详情接口里设计了三个字段货道编号、商品信息、剩余库存。前端做实时判断库存为 0 时直接禁用按钮。商品列表我用了网格布局来展示。每件商品展示缩略图、名称、价格和购买按钮。这里有一个细节货柜内的商品可能会因为温度分区不同而产生差异比如冷藏柜和热柜的商品不能互相出货。我的模型里给每个货道加了一个zone字段前端不直接使用但后台下单校验时会根据购买商品和用户选择的出货方式做判断。购买按钮的点击流程是这样的用户点击商品弹出确认弹窗展示商品信息和价格用户确认后前端向服务端创建订单订单创建成功后引导用户完成在线支付支付结果通过回调通知服务端服务端再向售货机下发解锁出货指令。4.2 下单支付与出货状态轮询支付流程是整个应用里最需要谨慎处理的部分。我把它设计成四个阶段创建订单、支付收款、支付回调、出货确认。创建订单这一步前端只需要把售货机 ID、货道编号、商品 SKU、数量传给服务端服务端生成订单号并返回支付参数。前端不要自己计算金额然后传给服务端金额一律以服务端计算为准防止客户端被篡改。支付收款阶段我调用第三方支付 SDK 拉起收银台。这里有一个非常容易踩的坑支付 SDK 的回调地址和返回页面的跳转逻辑在不同平台上有不同的处理方式。安卓上需要在 Activity 的onActivityResult里透传结果iOS 需要在 AppDelegate 里处理 URL Scheme。鸿蒙端的支付 SDK 支持情况和回调路径又不一样所以我把支付环节单独封装成一个服务类每个平台按需实现对应方法核心下单逻辑保持统一。支付结果不能完全相信前端回调。我的做法是支付 SDK 回调返回“支付成功”后前端不立即显示“购买成功”而是进入一个“出货确认”的轮询页面每 2 秒请求一次订单状态接口直到服务端确认“已出货”才显示成功页。轮询状态机的写法大致是这样enum OrderStatus { pending, paying, paid, dispensing, completed, failed } class OrderService { FutureOrderStatus queryOrderStatus(String orderId) async { final resp await _api.get(/order/$orderId/status); // 根据服务端返回映射 OrderStatus return mapStatus(resp.data[status]); } }页面根据状态机跳转paid状态显示“正在出货”dispensing状态显示“取货中”completed状态显示“购买成功”。如果连续轮询 30 秒仍然没有进入completed就提示用户联系客服或者去机器旁查看。这个轮询机制虽然简单但很可靠。我理解很多人会嫌轮询“太笨”想用 WebSocket 或长连接替代但自助售货机的网络环境往往不太稳定过度的实时连接反而不如短轮询来得扎实。轮询对服务端的压力也完全可以接受毕竟这个场景下用户的购买频率并不高。5. 鸿蒙适配踩坑记录与性能调优5.1 鸿蒙适配的核心差异点把 Flutter 应用跑到鸿蒙上最大的工作量不在 UI而是在平台能力的适配。我把项目里遇到的几个关键差异点整理成了一张速查表差异点安卓/iOS 的习惯做法鸿蒙上的差异与对策权限声明Manifest / Info.plist需要在鸿蒙工程的module.json5中声明requestPermissions定位服务geolocator 直接调用系统定位鸿蒙上需要确认系统定位服务已开启部分设备需要引导用户打开定位开关文件路径安卓分区存储、iOS 沙箱鸿蒙的文件访问策略独立路径不能直接写死要使用系统提供的目录接口返回手势安卓返回键、iOS 手势鸿蒙的返回导航逻辑独立页面需要监听系统返回事件并正确关闭 Flutter 页面相机扫码直接调起相机权限鸿蒙相机能力走独立的权限声明且不同系统版本 UI 差异明显地图渲染原生地图叠加 Flutter 层地图组件可能只能用鸿蒙原生地图实现通过平台视图嵌入需要额外桥接这块我认为是全文最值得留意的内容。很多人误以为 Flutter 跨平台就是“写一次哪都能跑”实际跑鸿蒙的时候才发现凡是触碰系统能力的地方都要单独处理。而且鸿蒙的版本迭代节奏很快不同系统版本的权限模型和接口行为差异较大适配工作不是一次性的需要预留持续跟进的精力。针对地图这块我的实际策略是先用 Flutter 插件方案在地图上完成功能开发和联调再在鸿蒙真机上做验证。一旦发现插件方案不可用立即切换到平台视图方案。平台视图方案的通信需要自己定义一套 message 协议把地图的中心点、标记列表、点击事件在原生和 Flutter 之间同步。使用消息通道传输复杂对象时要控制数据量marker 数量超过 200 个时建议做聚合或抽稀。5.2 性能优化与内存治理自助售货机查询应用虽然不算重度应用但地图加列表的组合在低端设备上还是容易卡顿。我做了三个层面的优化。第一是列表图片加载优化。售货机的商品图片和门头照片比较占内存我用了带内存缓存和磁盘缓存的图片加载方案同时把图片裁剪成列表页需要的尺寸避免加载原图。缩略图和详情大图分开 URL服务端返回的图片地址就区分好规格前端不做二次裁剪。第二是地图 marker 的聚合。当用户缩放到城市级别时附近几百台机器全部以单点 marker 绘制会让地图非常卡而且视觉上也看不清。我的做法是根据缩放级别动态聚合地图级别低时把附近机器聚合成一个计数点点击后再展开。这样地图渲染压力大幅下降。第三是避免不必要的重建。Flutter 里最容易犯的性能错误是在build方法里创建新对象导致子组件频繁重建。我的列表项组件都用了const构造函数把不变的部分提前声明好状态变化只集中在 Provider 层widget 使用selector精确监听自己关心的字段而不是整个状态对象一变就全页面刷新。鸿蒙端的性能调优思路和安卓类似但要多关注系统的内存回收策略。我在鸿蒙真机调试时遇见过长时间停留在列表页后内存缓慢上涨的问题最后发现是地图页销毁时没有完全释放原生地图实例解决办法是在页面dispose时显式调用地图组件的清理方法并且把地图相关的全局引用置空。6. 常见问题速查与调试技巧实录6.1 高频问题与排查方案开发过程中我遇到了一批很有代表性的问题整理出来给你做避坑参考。这张表里的每一条都是我或者同组开发者真实碰到过、并且花了不少时间才定位到原因的问题现象可能原因解决办法鸿蒙编译报错提示找不到某 SDK 组件Flutter 版本和鸿蒙 SDK 版本不匹配查阅当前 Flutter 版本对鸿蒙的支持矩阵切换对应 SDK 版本定位按钮点了没反应权限声明缺失或定位服务未开启检查module.json5权限声明并确认系统定位开关已打开地图白屏或只有网格地图 SDK 在鸿蒙端不被支持切换为鸿蒙原生地图平台视图或改用静态地图截图方案列表滚动明显掉帧图片缓存未生效或列表项构建过重启用图片缓存拆分组件减少不必要的setState支付成功后一直停在“正在出货”前端没有正确响应支付回调或服务端回调地址配置错误检查支付回调链路日志确认订单状态机是否流转到paid下拉刷新之后重复数据分页参数没有随刷新重置刷新时强制把页码重置为 1并清空旧列表再加载小程序退到后台再进入时地图黑屏地图实例在后台被系统回收监听应用生命周期回到前台后重新初始化地图组件鸿蒙真机调试无法安装调试包签名证书未配置或开发模式未开启检查鸿蒙开发调试证书确认打开设备调试模式排查这类问题我自己有一个固定套路先看日志再看链路最后看数据。比如“支付成功后一直停在出货中”不要一上来就怀疑支付 SDK先把服务端订单状态接口手动 curl 一遍确认服务端到底有没有收到支付回调。很多时候问题不是支付没成功而是回调没到达服务端或者服务端已经更新了状态但前端轮询请求的订单号不对。按链路一层层排查比瞎猜高效得多。6.2 真机联调与日志分析跨平台项目最忌讳“只在模拟器上看着没问题”。地图和定位这类能力在模拟器里的表现和真机差距巨大尤其是鸿蒙设备不同型号的定位精度、屏幕比例、系统权限弹窗样式都不一样。我从项目第二天开始就坚持真机调试每次提交代码前至少跑一遍定位、地图、扫码、支付四个主链路。日志分析方面Flutter 侧用debugPrint打印关键流程日志调试时用 DevTools 看性能时间线重点关注地图页的帧渲染时间和列表页的图片加载时长。原生侧我分别在安卓和鸿蒙的日志工具里过滤 Flutter 引擎和平台通道相关日志。平台通道出问题时日志里通常会出现卡住的调用记录这是定位 bug 的第一线索。另外强烈建议在生产代码里加一层可配置的日志开关。我把定位、地图、支付三个模块的日志都通过自定义工具类输出线上只开错误级别调试时才开全量日志。这样既能保证排查问题时有据可查又不会刷屏影响性能。还有一个调试技巧把关键状态变化用日志打点的方式记录下来比如“订单创建成功”“支付回调收到”“出货指令下发”然后对照时间戳检查各步骤时延。这个项目里我最满意的一个优化就是通过日志时间线发现支付回调平均耗时比预期多了 800 毫秒定位到是回调处理里做了一次多余的同步网络请求改异步之后整个购买流程明显顺畅了。写在最后的一点体会如果现在让我重新做一遍这个项目我会先把鸿蒙平台的地图和支付两个高风险能力用最小 demo 验证一遍再开始写业务页面。地图和支付是整个项目里最容易出现“平台不支持”的环节提前花了半天验证就能避免后面返工一周。另外一点我想说的是跨平台开发一定要把“能力封装层”做好。这个项目里地图、定位、支付、扫码都有独立的服务接口底层切什么实现都不影响上层业务代码。做 Flutter 鸿蒙适配最大的收获不是学会了一个框架而是理解了一个道理跨平台不只是代码层面的跨更是能力边界上的梳理和取舍。这套思路带去任何项目都受用。