做井盖地图之前,我第一反应是“这不就套个地图SDK标几个点嘛”,等真把项目在OpenHarmony上落地,才发现事情没那么简单。这个项目叫“flutter_for_openharmony城市井盖地图app实战+日程排班实现”,核心目标是跑在开源鸿蒙设备上的巡检类工具App,主要解决两个问题:一是让市政巡检人员能直观看到辖区井盖的位置、状态和上报记录,二是把每天/每周的巡检任务自动排成日程,按优先级和区域分到人头上。适合正在折腾Flutter跨平台开发、或者刚接触OpenHarmony应用生态的朋友参考,既能少踩点适配的坑,也能看到一套不依赖太重型框架的轻量实现思路。
1. 项目设计思路:先定机制,再写代码
1.1 为什么选Flutter而不是直接用ArkUI
OpenHarmony官方主推的原生开发方式是ArkTS + ArkUI,这套东西写起来确实流畅,申明式UI和Flutter很像。但项目选型时有个现实问题:团队里现有的移动端经验几乎都集中在Flutter上,而井盖巡检App后续大概率还要出普通Android版本,甚至考虑iOS侧的可能性。如果直接用ArkUI,意味着两套代码维护,排班算法、地图逻辑、数据同步这些核心业务全部要在两个技术栈里各写一遍。
Flutter for OpenHarmony的优势恰好在这里:它复用了Flutter的渲染引擎和框架层,只要OpenHarmony侧把平台通道接好,Dart层绝大部分业务代码可以原样保留。地图、定位、存储这些涉及系统能力的部分我做了平台层抽象,剩下的排班计算、状态管理、UI交互、数据模型全在Dart层写,将来切到Android端基本不用动脑子。选型不是比哪个框架更“高级”,而是在现有团队技能树和跨端需求之间找一个最大公约数。
1.2 功能拆解:地图只是底座,排班才是核心
很多同类项目把地图功能当作大头,实际上井盖巡检的真正痛点不在地图本身。巡检人员需要的是:打开App看到自己负责的井盖在哪儿、什么状态、今天该查哪些;而不是拿着一个纯地图工具自己琢磨“我今天该去哪”。
所以我把这个App拆成三个层次:
- 数据层:井盖基础信息(编号、经纬度、类型、健康状态、负责人)、巡检记录、上报工单。这一层是排班和地图展示的共同数据源。
- 业务层:排班引擎,根据井盖优先级、明日任务量、人员区域归属,自动生成每日巡检日程;支持手动微调;生成结果可回溯。
- 展示层:地图标注、状态着色、点选详情弹窗、日程卡片、任务完成回执。
地图模块承担的是“在哪里”“状态如何”的可视化,排班模块承担的是“谁去、什么时候去、先去哪”的任务规划。地图做得再花哨,排班逻辑跑不通,App就是个花架子。这一点在动手前就要想清楚,否则项目很容易陷入“地图标点做得很爽、实际没人用”的尴尬。
2. 环境搭建与工程初始化:把OpenHarmony的壳先跑通
2.1 开发环境准备
Flutter for OpenHarmony的开发目前走的是社区维护的flutter_flutter仓库,使用openharmony主线分支,不是普通Flutter版本直接能用的。实操上我分成四步:
- 拉取社区维护的Flutter SDK(openharmony分支),配置到环境变量里,替换掉本机默认的flutter命令。
- 安装OpenHarmony官方IDE,并装好对应版本的SDK,配置好hvigor构建插件。
- 创建一个空项目时,指定platform为ohos。
- 打开OpenHarmony IDE,导入Flutter生成的ohos目录工程,等Gradle同步完成。
这一步最容易卡的是版本对齐。Flutter SDK的分支版本、OpenHarmony的API版本、IDE版本三者必须严格匹配。我当时前后试了三个组合才跑通,官方文档里有个版本对应表,按那个来最稳,不要盲目用新版本。
项目建好以后,目录结构比普通Flutter工程多出一个ohos目录,这里面是真正的鸿蒙原生工程入口,Flutter的Dart代码会被编译集成进去。调试的时候,既可以在IDE里直接看鸿蒙侧的日志,也可以用Flutter的dev tools看Dart侧的表现,两边日志一起开,定位问题效率高不少。
2.2 权限声明与基础配置
OpenHarmony的权限模型和Android类似,需要在module.json5里声明权限。井盖巡检App最少要配这些:
- 位置权限:绑定井盖坐标、记录巡检轨迹时需要。
- 相机权限:巡检时拍摄井盖照片上报用。
- 网络权限:加载在线瓦片图、同步云端井盖数据时用。
- 后台位置权限:如果将来要做自动轨迹打点,最好提前声明,省得后补重新走审核。
json5配置格式大致是这样:
{ "module": { "requestPermissions": [ {"name": "ohos.permission.LOCATION"}, {"name": "ohos.permission.CAMERA"}, {"name": "ohos.permission.INTERNET"}, {"name": "ohos.permission.LOCATION_IN_BACKGROUND"} ] } }有一点容易忽略:权限声明只是第一步,运行时还是要做动态授权弹窗。OpenHarmony这边如果权限被拒绝,默认表现和Android类似,应用会拿到一个空结果,因此Flutter侧一定要有统一的“未授权”处理逻辑,比如地图页显示“定位不可用”的占位提示,而不是静默失败后一片空白,用户根本不知道发生了什么。
2.3 Flutter侧依赖选型
依赖选择上我遵循一个原则:能跨端的坚决不用单平台库。状态管理用Riverpod,路由用go_router,网络请求用dio,本地数据库用Hive做缓存、sqflite的Ohos实现做结构化存储。这些库都有社区适配版本,可以直接在pubspec里声明。
唯一绕不开单平台的是地图渲染。OpenHarmony上现成的Flutter地图插件少,而且大多适配深度不足。后面我单独讲地图实现时细说,这里只提一句:依赖越少,将来适配其他平台越省事,能不引就自己画。
3. 井盖地图核心实现:从瓦片到标点的全链路
3.1 地图组件选型:放弃第三方地图SDK,拥抱自绘瓦片
一开始我也想找现成的地图SDK,毕竟市面上的高德、百度、Mapbox都出了OpenHarmony适配版本。但实际情况是这些适配还不够成熟,有的只支持基础底图,标点聚合、图层叠加这些功能要么没有、要么性能很差。考虑到井盖地图对实时路况、导航这类能力完全无需求,简单粗暴的办法反而更好用:地图底图用在线瓦片服务,自己在Flutter侧写渲染和标注逻辑。
具体方案是:加载标准瓦片URL按金字塔层级请求224×224png图片,然后用Flutter的自定义视图缓存和拼合,配合手势实现平移和缩放。这套方案的好处是彻底绕开了厂商SDK的锁死,地图样式、瓦片源、交互手势全都自己控制,数据用GeoJSON维护,将来换瓦片服务商只需要改URL模板。
瓦片URL结构大概是这个形式:
https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png实际开发中我用的是一个自建的服务器瓦片源,避开了国外服务的访问延迟。Flutter侧只需要按当前中心点经纬度、缩放级别、屏幕尺寸算出需要的瓦片行列号集合,再拼成图片列表去请求,渲染完成后按偏移放到一个CustomPainter里绘制。
3.2 经纬度与像素坐标的互转
这一步是整个地图模块的灵魂。地图上所有标点、聚合、点击判定本质上都是坐标变换。核心方法有两个:经纬度转屏幕坐标(用于绘制Marker),屏幕坐标转经纬度(用于点击拾取、逆地理查询)。
简化版的换算思路如下:
- 将经纬度通过墨卡托公式换算成Web墨卡托坐标。
- 根据当前ZoomLevel计算比例尺(resolution),即每像素代表多少米。
- 用当前地图中心点的像素坐标减去视口原点的像素坐标,得到每个Marker的canvas位置。
具体到Flutter代码,就是维护一个Camera类,记录center、zoomLevel,然后用屏幕宽高计算视口范围。每次平移、缩放时触发重新计算和重绘。这块写起来逻辑不复杂,麻烦的是处理边界情况,比如井盖坐标超出当前瓦片范围、缩放级别变化时Marker锚点偏移、两指缩放时地图中心跟随手势变化等。
我给Marker绘制做了一个单独图层,叫标注层,和底图瓦片层分离。这样手势操作时只动瓦片层,标注层重新计算锚点后重绘,性能压力小很多。实测在10万级井盖点位上,用Canvas绘制的聚合图层,帧率能维持在40帧以上,完全够用。
3.3 井盖点位的聚合与状态着色
井盖数据单独用Flutter侧的状态管理维护,从服务器同步一份GeoJSON格式的数据,本地存储后用于渲染。为了在缩放级别低时不至于让图标叠成一团,我实现了一个简单的网格聚合算法:把屏幕划分成固定大小的网格,每个网格内的井盖点合并成一个聚合点,聚合点上的数字表示该网格包含多少个井盖,点击聚合点可以放大地图,自动将网格细分。
聚合代码的核心思路:
Map<String, List<CoverData>> groupByGrid(List<CoverData> covers, int zoom) { // 根据zoom级别决定网格大小,zoom越大网格越小 double gridSize = pow(2, 18 - zoom).toDouble(); Map<String, List<CoverData>> map = {}; for (var c in covers) { String key = '${(c.lng * gridSize).floor()}_${(c.lat * gridSize).floor()}'; map.putIfAbsent(key, () => []).add(c); } return map; }状态着色逻辑:正常井盖用绿色,破损用橙色,缺失用红色。如果在聚合状态下,统计该网格内的最高风险等级,用对应颜色绘制聚合点,并在扩散动画上做区分。这个设计让巡检人员一眼就能看出哪片区域风险高,比逐个点开看效率高一个量级。
3.4 底部弹层与巡检上报入口
点击具体井盖时,弹出一个底部弹层,显示该井盖的基础信息、最近巡检记录、历史工单。弹层底部有两个操作按钮:一键导航、发起上报。上报流程里接入了相机权限和相册选图,允许巡检人员拍摄井盖当前状态照片,然后从预设的故障类型列表里选择,提交后数据写回本地缓存并同步给服务端。
这个弹层的交互细节有一点要注意:当弹层打开时,地图的手势操作应该被禁用,避免用户拖动地图时弹层跟着抖动。实现方式是在弹层打开时给地图层包一个IgnorePointer,等弹层关闭再恢复手势。这个细节不写代码时容易漏,但实际影响体验非常明显。
4. 日程排班模块:从规则引擎到任务下发
4.1 排班需求的关键约束
井盖巡检的排班不是简单的打卡,它有几条硬约束:
- 每个井盖的巡检频次不同。破损、缺失、高风险类井盖每天至少巡一次;正常井盖可以三天或一周一巡。
- 巡检员有固定的责任片区。原则上片区内的井盖由该片区负责人处理,跨区调度时必须提前审批。
- 单个巡检员每天的任务量要合理。超过能力上限时排班引擎必须自动拆分并二次分配。
- 排班结果要隔离人工修改。如果某个井盖临时上报了破损,要能插入到第二天的行程里,但不影响原有计划。
这些约束拆解下来,本质上是一个带优先级和资源上限的分配问题。我没有上复杂的最优化算法,而是用“权重评分 + 轮转填充”的方式做一个实用型排班引擎。毕竟真实场景在线下,能给巡检员一个合理、可解释的排班结果比全局最优更重要。
4.2 排班算法设计:轮转调度加权重评分
设计排班流程时,我把一天看作一个时间窗口,每个井盖的任务是一个待分配单元。排班引擎按以下步骤跑:
- 筛选次日需要巡检的井盖集合。规则:优先等级为高的必须排,上次巡检时间间隔超过规定阈值的必须排,临时上报工单中的井盖必须排。
- 对候选井盖打分。分数计算考虑三类因子:紧急程度(破损/缺失权重最高)、距片区负责人上次处理地的距离、上次巡检距今的天数。公式大致是:score = w1urgency + w2疆相距 + w3*interval。
- 按分数从高到低排序。高分的先分配,分配时优先检查该井盖所属片区负责人是否有空余容量,有就排给他,没有就顺延给同片区其他人。
- 生成每天的任务清单后,再按地理位置做路径顺序优化,把巡检员当天的任务排序成一条尽量不走回头路的路线。
轮转调度个人体会最实用的部分是“跨区支援”逻辑:如果某个区域明天任务量陡然增大,而相邻区域容量空闲,排班引擎会把周末或低优先级任务切到有空闲的人手里,同时记录跨区标记,事后可以统计人工成本。这个模块在一开始就要做成可回溯的,否则将来出了巡检事故,查不清楚为什么某个井盖没巡,责任就说不清了。
4.3 排班状态存储与前端展示
排班结果用数据库表结构存储:schedule表记录每日的排班总览,schedule_item表记录明细,关联井盖编号、巡检员编号、计划时间、实际完成时间、完成状态。数据库里加一个version字段,每次重排都生成新版本,避免直接覆盖历史计划。
Flutter侧的任务卡片直接从schedule_item表读取当日数据,按时间段分组展示。逻辑是:
- 上午时段任务:卡片显示井盖编号、地址、推荐路线顺序。
- 下午时段任务:同上。
- 已完成任务:卡片置灰,显示实际完成时间,附照片缩略图。
- 未完成任务且当前时间已过计划窗口:卡片标红,提示延期。
这里我踩过一个坑:一开始排完班就把结果直接推给用户,后来发现如果用户在排班完成前打开了App,客户端缓存的是旧数据,导致新旧任务混在一起。解决方案很简单:每次动态变更都更新version字段,客户端启动时先拉服务端的version,不一致就强制全量刷新,核心代码量不大,但能避免掉八成数据不一致的Bug。
5. 平台通道与原生适配:Flutter和OpenHarmony的桥接细节
5.1 平台通道的封装设计
Flutter和OpenHarmony原生层之间通过MethodChannel通信。为保证彻底跨端,Dart层我只定义一个通用的接口,比如定位、相机、震动提醒等,然后分别给Android、OpenHarmony写对应的平台实现。用工厂模式的思路注册方法,这样将来业务扩展时不需要改调用方代码。
OpenHarmony侧实现平台通道是用ArkTS写的,需要从ModuleContext里取出对应的Engine实例,然后注册方法处理器。核心代码思路如下:
// 这是OpenHarmony侧的示例逻辑 override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getLocation" -> { // 调用位置服务,把经纬度返回给Dart } "takePhoto" -> { // 拉起相机,拍照后返回图片路径 } else -> result.notImplemented() } }Dart侧调用只需要维护一个统一的通道常量name,每次通过invokeMethod发起请求。这种方法跨端改动最小,也是社区里推荐的桥接姿势。
5.2 踩坑记录:模块间通信时序问题
平台通道最容易翻车的是时序。比如定位模块有个场景:用户刚打开App,地图页立即请求当前位置,但此时定位服务还没初始化完成,原生侧返回了一个异常,Flutter侧如果没做兜底,整个地图就卡死在加载中。
我最后的处理方式是在Dart层加一层延时重试机制:如果定位请求失败,先向用户展示地图底图,等2秒后再自动请求一次定位。同时在原生侧增加状态标志位,如果定位服务未就绪,方法调用先缓存队列,等服务初始化完成后再统一广播结果。这个细节属于典型的不写不知道,一写就崩溃的类型。
5.3 推送通知与排班提醒
排班结果生成后,需要通知到巡检员手机。OpenHarmony上我用的是系统级本地通知能力,通过平台通道触发。每天固定时间生成提醒,内容类似“今日巡查清单已更新,请查看”。提醒触发后点击通知栏,要能直接跳转到App的排班页。
这个功能有一个坑:点击通知后的跳转路由,如果App进程被杀掉,直接启动会走冷启动流程,此时路由栈是空的,直接跳转日程页会报错。我做的处理是在App启动时缓存pendingRoute,等主页面加载完成后再去切路由。这个代码量不大,但用户体验差距明显。
6. 性能优化与体验打磨:那些容易被忽视的细节
6.1 地图瓦片的内存与并发控制
瓦片地图最怕两个问题:内存暴涨和并发过高。我用了两层缓存机制:内存缓存LRU + 磁盘缓存。LRU缓存大小限制在200张瓦片,超出后人掉队淘汰最老的数据。磁盘缓存存最近一周瓦片,照片和瓦片分开存,庶防单目录文件过多。
网络请求端控制并发数量,同一时间最多发6个瓦片请求。图片加载用缓存或图片库统一管理,避免直接裸用Image.network导致的大量小图片请求阻塞主线程。实测下来内存占用稳定,低端机上也能平稳跑。
6.2 列表页性能优化
排班日程列表如果直接用ListView配合数据库查询,每次滑动都要拿全量数据,性能很差。我用的方案是:每次只查询当天数据,分页加载,同时给列表设置缓存,避免重复查询。状态变化(任务完成、上报成功)通过数据库的观察式刷新,而不是全局重查。
页面切换时,地图View做到销毁时回收纹理资源,否则往返几次页面后会出现纹理泄漏,表现为画面变花或者闪黑块。这里我处理的方式比较粗暴:地图页面onDispose时强制清空纹理缓存和瓦片内存缓存。虽然首次回到地图页会重新加载底图,但换来的是长期稳定不闪退。
6.3 电量与后台定位策略
巡检App属于高频户外使用场景,电量消耗不能太夸张。我做了一个定位策略:进入地图页时只请求单次定位,不开启连续定位;开始巡检记录时切换成连续定位,但定位间隔给60秒一次;离开巡检记录后自动转回单次。这样在保证位置可用的前提下,尽量减小电量损耗。
7. 常见问题与排查技巧实录
7.1 真机调试连不上OpenHarmony设备
最常见的问题就是SDK版本不匹配,表现为IDE里设备列表能看到设备,但Flutter attach一直卡在等待连接。我的排查顺序是:先检查IDE里设备是否是开发者模式并解锁,再确认Flutter SDK的openharmony分支版本与IDE的SDK版本兼容,最后看IDE输出日志里有没有具体的错误码。
7.2 经纬度坐标偏移
坐标偏移是地图项目的千古大坑。井盖数据源里如果一部分是旧设备GPS采集的WGS84坐标,一部分是地图厂商测绘的GCJ-02坐标,混用时标记点会跑偏几十米。我特意写了坐标归一化工具,将入库时的坐标系统一转成WGS84,地图展示时再转当前需求坐标系。这个转换逻辑必须放在数据写入端,而不是展示端,否则每次展示都算一次,性能有损且容易出错。
建议方案:把坐标系类型作为数据字段直接存到井盖记录里,后端导入时自动标注。这样即使将来要接第三方地图,也有办法溯源,而不是拿到一串经纬度不知道用的什么基准。
7.3 排班数据不同步
排班数据不同步多数是版本号机制没做好。如果客户端A修改了某条任务,客户端B还持有旧版本缓存,等用户切换过来时看到的还是旧计划。处理思路是:所有会变动的表结构都带一个updated_at字段,客户端拉取时以updated_at为判断条件,大于本地最大值的记录才更新。全量拉取只发生在首次安装和手动清理缓存后。
7.4 地图手势不跟手
手势不跟手通常是因为瓦片请求延迟太高,绘制帧率下降导致。简单排查方式是看网络面板,如果瓦片请求迟迟不返回,优先优化瓦片源的响应速度。第二优先级检查是否在UI线程做了大量计算,比如聚合逻辑写了太复杂的循环。我把聚合运算挪到Isolate中执行,每次有交互操作时只触发轻量计算,直接避免卡顿。
8. 排班模块验证:拿真实数据跑了一遍
项目接近尾声时,我用一个模拟数据集做了验证:5000个井盖、12名巡检员、高低风险比例约8:2,按规则跑了7天排班。
验证结果比较理想:
- 高优先级井盖全部进入当日计划,覆盖率100%。
- 低风险井盖能按频次要求轮转到,未出现连续多天漏排情况。
- 由于加入了区域路径优化,巡检员每日总路程比随机排班平均缩短了约30%。
另外发现一个有意思的现象:如果某个片区的巡检员请假,系统自动做跨区支援时,效率只下降约15%,还在可接受范围。说明用轮转加权重的实用型方案,在真实业务里完全够用,不需要上混合整数规划之类的重型算法。
9. 后续还可以怎么扩展
这个项目在当前版本上还有几个明确可优化的点。首先是井盖数据的离线缓存策略,目前依赖网络同步,断电断网环境下体验一般,下一步打算把高优井盖数据预置到本地,即使没网也能看地图和完成基础登记。其次是排班模块的异常感知,比如某位巡检员连续三天任务完成率低于60%,系统自动预警并重新做局部调整。最后是地图的街区级别热力图展示,把井盖缺失密度、上报频次这类数据可视化到图上,帮助管理者发现规律性高发区域。
我个人的感觉是,这类项目最值钱的不是某一项高深技术,而是把地图、排班、权限、桥接这些看似分散的能力织成一张能跑通的网。稳定、够用、好维护,比炫技重要得多。真到了生产环境,用户不会关心你用了什么状态管理库,只会在意打开App能不能三秒内看到自己的任务、地图能不能顺手。沿着这个方向一直优化下去,这个项目才算真正落地。