1. 项目概述与整体思路
接到这个项目的时候,我第一反应是:城市井盖地图App,这不就是那种“看起来简单、做起来全是坑”的典型项目吗?实际动手之后发现,远比预想的复杂。表面上是把井盖点位画到地图上,实际上要把“地图展示、空间查询、工单流转、图片取证、离线缓存”全部捏合到一个运行在OpenHarmony设备上的Flutter应用里。这个标题里的“实战”两个字,我认为重点就在工单详情那部分,因为地图点位做出来只是第一步,工单详情才是真正串联业务的地方。
项目背景是很典型的市政管养场景。某城市治理平台需要对辖区内的井盖设施做全生命周期管理,外勤巡查人员拿着终端设备,在App上查看当前区域的井盖分布,点开某个井盖就能看到它的基本信息、权属单位、历史维修记录。更重要的是,平台可以把维修任务直接派发给外勤人员,外勤人员查看工单详情,按照流程到现场处理,然后拍照、填写处理说明、上报结果。整套流程里,地图是定位入口,工单是本。App需要同时满足两个角色的需求:一是巡查人员快速发现问题和上报问题,二是维修人员接收工单并完成闭环处理。
为什么选择Flutter for OpenHarmony?因为项目要覆盖的设备形态比较杂。OpenHarmony生态里有传统手机形态的终端,也有带三防外壳的工业手持设备,甚至有一些嵌入式的巡检终端,设备厂家不同、屏幕分辨率不同,如果每个终端都用ArkTS原生开发一套业务,开发和维护成本都扛不住。Flutter的跨端UI方案可以把页面层、状态层、业务逻辑层全部复用到各形态设备上,只需要在平台层针对不同设备差异做适配。这个方案还有个额外好处:Dart侧代码未来如果哪天要迁移到Android、iOS或桌面端,也能比较平滑地复用大部分代码,不至于被某个系统綁死。
技术栈确定之后,我按“平台适配层、业务服务层、UI展示层”三层来划分工程结构。平台适配层负责OpenHarmony和Flutter的桥接,包括定位权限、相册权限、拍照、文件存储、原生地图能力封装;业务服务层负责井盖数据模型、工单状态机、网络请求、本地数据库缓存、图片压缩上传;UI展示层承载地图页、列表页、工单详情页、登录页等页面。这个分层思路在后面的开发和排错中帮了大忙,至少我们排查问题的时候能快速定位到底层是平台侧问题,还是业务逻辑问题,还是UI渲染问题。
2. 开发环境与工程搭建
2.1 OpenHarmony侧的SDK与Flutter版本对齐
OpenHarmony上的Flutter开发和Android开发有个非常大的不同:Flutter官方对OpenHarmony的支持走的是社区适配路线,而不是官方主线。所以第一步不是急着写代码,而是把SDK和Flutter版本对齐。
我这里用的版本组合是:OpenHarmony SDK API 9以上的某稳定版本,配合社区维护的Flutter for OpenHarmony分支。为什么强调版本对齐?因为OpenHarmony的API演进比较快,而Flutter的鸿蒙适配分支往往只支持某个特定的SDK API等级,如果OpenHarmony SDK版本太新,或者Flutter分支太旧,编译的时候会出现各种让人抓狂的符号找不到、接口签名不匹配的问题。
建议的做法是搭建工程时先跑通一个最小Demo,再开始写业务代码。工程目录结构类似:
manhole_project/ ├── ohos/ # OpenHarmony 原生工程目录 ├── lib/ # Flutter Dart 代码目录 ├── pubspec.yaml └── build script这里有个经验:不要直接用命令行的默认模板生成工程,最好从某个已经验证过的开源样例工程开始改。因为OpenHarmony工程配置项比Android工程多一些,比如module.json5里的abilities配置、权限声明方式、aar/har依赖的引用路径,如果从零手写容易漏配置。从样例工程改造,至少保证基础编译链路是通的。
2.2 Dart侧依赖与鸿蒙原生包的关系
Flutter工程需要用到三方插件时,在Android生态里是Gradle自动拉取aar,但在OpenHarmony上逻辑不太一样。鸿蒙侧是hvigor构建,依赖的native能力通常是har包或直接源码方式引入的,Dart侧的插件必须要有对应的鸿蒙实现,否则编译能过,运行时报“MissingPluginException”。
我踩过的一个很典型的坑是:Dart侧用了某个通用的路径处理插件,Android上是正常工作的,但是在OpenHarmony设备上运行到某个功能时报MissingPluginException。排查后发现,这个插件的鸿蒙适配实现只实现了部分接口,路径处理相关的method没有在鸿蒙侧注册。解决办法有两个:要么换一个已经完整适配鸿蒙的插件,要么自己在ohos目录下写一个MethodChannel实现。
所以项目的依赖管理要特别注意:每个依赖需要确认是否在OpenHarmony上有完整实现,不能只看pub.dev上写了支持Flutter就觉得可以。像这个项目里用到的图片选择、定位、权限请求这几个关键插件,我最后都选择了自研或深度定制,而不是直接引第三方,原因就是避免插件适配不全导致上线后出问题。
2.3 平台通道封装:定位、权限与相册
平台通道是OpenHarmony和Flutter通信的桥梁。Android是用MethodChannel,鸿蒙侧实现方式类似,只是原生代码语言是ArkTS。我们封装的几个核心通道需要重点说明一下。
定位通道:Flutter侧调用Dart接口获取当前坐标,鸿蒙侧通过系统定位服务实现。这个通道必须设计成异步回调形式,因为定位本身是耗时操作,而且首次定位可能需要几秒甚至十几秒。我在Dart侧定义了如下接口:
class LocationService { static const _channel = MethodChannel('manhole/location'); static Future<LocationResult> getCurrentLocation() async { final result = await _channel.invokeMethod('getCurrentLocation'); // 解析result,返回经纬度、精度、时间戳 } }鸿蒙侧对应的实现,需要先在module.json5里声明ohos.permission.LOCATION权限,然后在定位服务代码里注册位置回调,拿到坐标后通过result.success返回给Dart侧。这里提醒大家,OpenHarmony的权限模型是运行时动态授权的,不是安装时就授予的,所以Dart侧调用前要好权限状态检测,鸿蒙侧做一个权限请求的承载。
相册和相机通道的逻辑类似,但要注意的是OpenHarmony的图片选择器和Android不同,它返回的媒体URI不是本地文件路径,直接拿这个URI去存储或上传是会出问题的。需要在鸿蒙侧先把URI转换成可读的文件路径或者直接返回字节流,再从Dart侧处理。我们这边的方案是鸿蒙侧用一个FileAsset实体把path、name、size、mimeType都传到Dart,这样Flutter侧做压缩和上传就省事多了。
3. 井盖地图模块实现
3.1 地图选型:自绘地图图层
地图是整个系统的视觉核心。本来想接入某个商用地图SDK的鸿蒙适配版本,但调研后发现我们的需求其实并不需要完整的地图引擎,而是要展示井盖点、支持点击交互、支持缩放平移。商用SDK在这个项目里有点“杀鸡用牛刀”的感觉,而且还可能面临SDK授权和鸿蒙适配风险。最终我选择了自绘图层方案:使用一个可以拖动缩放的画布,自行绘制底图网格和井盖点位,在此基础上做交互。
自绘地图的好处非常多。第一,不依赖第三方地图服务的SDK适配,不会因为某个地图SDK不支持OpenHarmony而受阻;第二,数据完全在我们手里,可以做到离线可用——把一定范围内的井盖数据缓存到本地,即使网络不好也能看到点位。第三,性能好控制,我们只需要绘制点、线、简单图形,帧率比完整地图引擎更高。
缺点也很明显:没有道路底图、没有实时路况、没有POI检索。但对这个项目来说,这些都不是刚需。巡查人员关心的是“井盖在哪个位置、怎么走、谁负责”,这类信息通过坐标加上简单的行政区划底图就能支撑起来。
自绘地图实现的核心是坐标变换:把经纬度坐标映射到画布像素坐标。我用了一个简单的墨卡托投影近似。
Offset worldToOffset(LatLng latlng) { double x = (latlng.lng + 180) / 360.0 * _worldSize; double y = (1 - _log(tan(pi / 4 + _radians(latlng.lat) / 2)) / pi) / 2 * _worldSize; return Offset(x - _viewCenter.dx + _canvasSize.width / 2, y - _viewCenter.dy + _canvasSize.height / 2); }LatLng offsetToLatLng(Offset offset) { double x = offset.dx - _canvasSize.width / 2 + _viewCenter.dx; double y = offset.dy - _canvasSize.height / 2 + _viewCenter.dy; double lng = x / _worldSize * 360.0 - 180.0; double lat = _degrees(atan(sinh((1 - y / _worldSize * 2) * pi))); return LatLng(lat, lng); }缩放级别通过_worldSize来控制,_worldSize越大,显示的坐标范围越小,地图越“放大”。这个方案的实际渲染效果是够的,至少在我们测试的几台OpenHarmony设备上,交互流畅度完全可以接受。
3.2 井盖数据模型与点聚合
地图上要展示的井盖点位数据,来自后端接口,包含经纬度、类型、状态、权属单位等信息。单条数据不复杂,复杂的是数量。市域范围内井盖可能有几万甚至十几万个,如果全部一次性拉到本地且直接绘制,不管是网络、内存还是渲染层都会吃不消。所以要做两个处理:数据预加载和点聚合。
数据预加载策略是:按地图可视区域动态请求。当地图中心点变化或者缩放级别变化时,根据当前可视范围的经纬度边界,向后端请求这个范围内的井盖数据。为了防止频繁请求,我加了一个去抖机制:只有地图停止移动超过300毫秒才发起新的请求。数据返回后缓存在内存Map里,如果再次滑动到相同区域,直接从内存读取,不再重复请求。
点聚合的逻辑要处理的是“同一像素范围内多个点重叠”的情况。当缩放级别小、可视范围大时,附近的几个井盖在屏幕上可能挤到同一个像素附近,如果不做聚合并渲染,用户根本点不准。聚合算法不算复杂,我实现了一个基于网格的聚合:把屏幕划分成64x64像素的网格,每个网格内的井盖聚合成一个聚合点,显示数量,点击聚合点则以该点为中心放大一级地图。
Map<String, List<ManholeCover>> aggregatePoints(List<ManholeCover> points) { final grid = <String, List<ManholeCover>>{}; for (final point in points) { final offset = worldToOffset(point.latLng); final key = '${(offset.dx ~/ 64)}_${(offset.dy ~/ 64)}'; grid.putIfAbsent(key, () => []).add(point); } return grid; }聚合点绘制成带有数字的圆形标记,普通点绘制成井盖类型对应的小图标。这样分层计算结果也简化了点击命中检测:先判断是否命中聚合点,再判断是否命中单个井盖点。
3.3 图层交互与点击命中检测
地图页面的交互主要有三种:拖动平移、双指缩放、点击点位。自绘方案里,拖动和缩放用GestureDetector处理,点击集用命中测试来处理。
有个很关键的性能细节:不要在build方法里做大量的hit test遍历。井盖数据可能有上千个点,如果每次点击都循环遍历所有点判断是否命中,UI线程会明显卡顿。我的做法是先把点的像素坐标提前算好并缓存,存储成一个空间索引结构。点击时先根据点击坐标做网格索引查找,只检查命中网格内的点,这样每次点击最多比较十来个点,速度很快。
final spatialIndex = <String, List<ManholeCover>>{}; void rebuildSpatialIndex(List<ManholeCover> points) { spatialIndex.clear(); for (final point in points) { final offset = worldToOffset(point.latLng); final key = '${(offset.dx / 64).floor()}_${(offset.dy / 64).floor()}'; spatialIndex.putIfAbsent(key, () => []).add(point); } } ManholeCover? hitTestPoint(Offset position) { final key = '${(position.dx / 64).floor()}_${(position.dy / 64).floor()}'; final candidates = spatialIndex[key]; if (candidates == null) return null; for (final point in candidates) { final offset = worldToOffset(point.latLng); if ((offset - position).distance < 24) return point; } return null; }还有一个坐标系的细节大家要留意:地图数据源提供的经纬度可能是WGS-84坐标系,但国内地图服务通常使用的是GCJ-02或更高精度的坐标系。如果不做转换,点位会偏几百米甚至几公里。这个项目里井盖坐标是从政务平台下发的基础台账里来的,源头往往是测绘部门测的WGS-84坐标,展示在我们自绘的地图上不做偏转问题不大,但如果后续要对接第三方地图做路径规划,就必须先做WGS-84转GCJ-02的纠偏。项目里我预留了纠偏函数接口,方便后续扩展。
3.4 定位与当前点位展示
打开地图后的默认行为是定位到用户当前所在位置,并显示周围的井盖。这里涉及两个问题:定位权限申请流程和定位精度处理。
权限申请流程在OpenHarmony上有自己的AB模型,和Android的权限弹窗机制略有不同。鸿蒙侧需要在你请求定位服务前先检查权限状态,如果没有授权,要调用requestPermissionsFromUser来拉起授权弹窗。由于权限弹窗必须在用户事件回调里拉起,不能异步随机弹,所以Dart侧的定位按钮点击后,要先通过MethodChannel通知鸿蒙侧去申请权限,等授权完成再发起定位。
定位精度处理方面,OpenHarmony系统定位返回的坐标可能有几十米到几百米的误差,尤其是室内定位,误差更大。我在地图上做了一个精度圈指示:从定位服务拿到accuracy字段后,在地图上画一个以定位点为中心、半径为accuracy的圆,让用户直观看到定位误差范围。同时结合混凝土井盖通常分布在路侧的特征,在地图中用候选点吸附算法,把定位点吸附到最近的可达道路上的井盖旁边,提升体验。
LatLng snapToNearbyManhole(LatLng current, List<ManholeCover> points) { ManholeCover nearest; double minDistance = double.infinity; for (final point in points) { final d = distanceInMeters(current, point.latLng); if (d < minDistance) { minDistance = d; nearest = point; } } if (minDistance < 200) return nearest.latLng; return current; }这里的“吸附”不是把定位点硬拽到井盖上,而是如果附近200米内确实有井盖,把中心点引导到井盖旁,方便用户优先看到待巡检目标。如果周围没有井盖,还是显示原始定位结果。
4. 工单详情功能实现
4.1 工单信息模型与状态流转
工单详情是整个App里业务含量最重的模块,也是最需要用心设计的部分。我先从数据模型说起。
工单的字段很多,核心几类:基本信息(工单编号、标题、紧急程度、下发时间)、位置信息(井盖ID、坐标点、地址描述)、处理信息(当前状态、当前处理人、处理时限、上报节点、到达时间、完成时间)、附件信息(现场照片、语音备注)、流程记录(每一步状态变更的操作人、时间、意见)。这些字段不仅详情页要用,列表页过滤、统计报表也要用,所以数据模型要尽量接近后端结构。
工单状态我是用枚举来定义的,状态流转严格按照业务规则来:
enum WorkOrderStatus { created, // 创建 dispatched, // 已派发 accepted, // 已接单 processing, // 处理中 rejected, // 已驳回 completed, // 已完成 }状态之间的转移规则:created只能到dispatched,dispatched只能到accepted或rejected,accepted只能到processing,processing只能到completed。这个规则我在前端和服务端都做了一遍校验,前端能提前拦截掉不合法的操作,后端能保证最终一致性。详情页底部的操作按钮,就是根据当前状态动态渲染的:已派发状态显示“接单”和“退回”按钮,处理中状态显示“提交完成”按钮,已完成状态不显示任何操作按钮。
4.2 详情页布局拆解
工单详情页的UI我分成了五个区域,从上到下依次是:工单状态区、基础信息区、井盖定位区、附件区、流程记录区。
工单状态区是整个页面的视觉锚点,用一张横跨页面宽度的状态卡展示当前工单的状态名称、状态颜色、紧急程度标识,顶部还有一个处理倒计时。倒计时的计算要特别注意,不能用服务器返回的deadline减去当前时间的简单逻辑,因为可能存在时区差异。我的做法是后端同时下发deadlineTimestamp和serverTime,前端计算差值,而不是依赖本地时钟的绝对值。
基础信息区展示工单编号、派发单位、当前处理人、联系电话、地址描述等,用表格形式排列。这里有个交互细节:联系电话字段不应只是一段文本,而是要支持点击直接拨号,因为外勤人员在现场经常需要跟调度中心确认情况。电话拨号在鸿蒙侧要单独封装一个能力,通过MethodChannel调用。
井盖定位区在详情页里嵌入一个地图预览图,不是完整的地图操作视图,就是一张包含目标井盖点位的静态快照。点这个预览图可以跳到地图页做完整展示,并自动发起导航路径规划。这块的实现是把井盖经纬度传给地图页的参数对象,地图页收到参数后自动定位到该井盖、弹出详情气泡。我一开始图省事想把地图预览直接用完整地图组件做缩小版,结果发现每次进详情页都要重建地图组件,开销太大、卡顿明显,后来改成静态快照方案,流畅度立竿见影。
附件区展示现场照片的缩略图网格,点击可以查看大图,长按可以删除。附件区还支持拍照新增和从相册选择新增。每个图片都需要显示上传状态:待上传、上传中、已上传、失败。上传状态的实时反馈很关键,因为外勤现场网络经常不稳定,用户拍完照片后如果不能立刻看到上传状态,会非常焦虑,也容易重复提交。
流程记录区是一个纵向时间线,展示工单从创建到当前的所有操作节点。每个节点显示操作人、操作时间、操作动作和备注。这个区域的数据来自后端的flowHistory接口,前端只做展示。有一个值得分享的细节:时间线末端的圆点要根据状态动态变化颜色,已完成节点用蓝色填充,当前节点用圆环样式,这样用户的视觉重心能快速落脚在“进度到哪里了”。
4.3 图片拍摄、压缩与上传策略
现场拍照取证是井盖维修工单里最重要的环节,图片质量和上传效率之间必须找到平衡。原始的拍照图片,在OpenHarmony手持设备上通常每张在3MB到8MB之间,如果工单要传6张图,就是几十MB的数据,在弱网环境下根本传不动。必须做压缩。
压缩策略我踩了几次坑后才稳定下来。第一,不能只看设置quality,因为不同图片的原始尺寸不一样,单纯压缩质量可能压不掉大图。我的方案是:先限制最长边不超过1600像素,再设置质量系数0.7,同时设定单张图片体积上限300KB,如果压缩后还是超过300KB,进一步降分辨率到1280并降质量到0.5。
Future<File> compressAndLimit({required String filePath, required int maxBytes}) async { var imageFile = File(filePath); var imageSize = imageFile.lengthSync(); var decodeWidth = 1600; var qualityRate = 70; while (imageSize > maxBytes && (decodeWidth > 600 || qualityRate > 30)) { final tempDir = await getApplicationDocumentsDirectory(); final outputPath = '${tempDir.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg'; final compressed = await FlutterImageCompress.compressWithImageFile( imageFile, minWidth: decodeWidth, minHeight: decodeWidth, quality: qualityRate, format: CompressFormat.jpeg, outputPath: outputPath, ); imageFile = File(outputPath); imageSize = imageFile.lengthSync(); if (decodeWidth > 600) decodeWidth -= 200; qualityRate -= 10; } return imageFile; }上传策略采用串行队列加自动重试。为什么串行,不是并行?因为井盖维修现场往往网络不好,多个图片并发上传会抢带宽,导致每张都传不上去,串行上传至少能保证前面的图片尽快传输完成。每次上传前生成一个taskId,上传成功后标记图片状态为已完成;上传失败则进入重试队列,用户回到页面时可以一键重试。为了防止用户在某张图片上传失败后无感知,我在图片缩略图的右上角加了一个红色感叹号标识,点击后弹出“重新上传”操作。
4.4 流转记录时间线实现
时间线组件项目里很多页面都要用,我单独抽成了TimeLineView组件。实现方式不复杂,核心是竖线和圆点的绘制:用ListView承载列表项,每个列表项的左侧通过Container加圆点、右侧放置内容、下方用一条竖线连接到下一个节点。最后一个节点不显示竖线。
class TimeLineView extends StatelessWidget { final List<FlowRecord> records; Widget buildItem(BuildContext context, int index) { final record = records[index]; final isLast = index == records.length - 1; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 左侧时间轴列:圆点 + 竖线 SizedBox( width: 48, child: Column( children: [ Container(width: 12, height: 12, decoration: BoxDecoration( shape: BoxShape.circle, color: index == records.length - 1 ? Colors.blue : Colors.grey, )), if (!isLast) Expanded(child: Container(width: 2, color: Colors.grey[300])), ], ), ), // 右侧内容区 Expanded(child: Padding( padding: const EdgeInsets.only(bottom: 16, right: 12, top: 4), child: Column(...), )), ], ), ); } }不过这里有个坑:Row的crossAxisAlignment如果用默认值,竖线的高度会比较尴尬,需要用IntrinsicHeight来让Row撑满内容高度。或者更省事的做法:不用弹性布局,直接给每条记录设置一个基于内容预估的固定高度,但那样如果文本换行长度不同,看起来会不整齐。我最终用了IntrinsicHeight方案,实测在几十条记录的时间线下性能没有问题。
时间线数据里如果操作人离职了或者账号注销了怎么办?这个问题我在项目里遇到过。后端返回的opName字段可能存在null,前端要处理这个情况,显示“未知操作人”,不能直接渲染null导致崩溃。
4.5 离线暂存与补偿提交
工单处理有一个真实场景:外勤人员到了井盖位置,发现现场信号质量特别差,可能连得上看地图但传不了图片。这种情况下,如果严格在线提交,工单根本完不成。我做了离线暂存功能,方案是维护一个本地待提交队列。
用户在详情页填写完处理说明、拍完照片后,点击“提交完成”按钮,如果检测到网络不可用,先不提示错误,而是把工单ID、处理说明、图片文件本地路径、提交时间存储到本地数据库的pending_submit表里,同时把图片文件保留在应用沙箱目录,UI上显示“已保存,待网络恢复后自动提交”,然后通过一个轮询机制每两分钟检测一次网络状态,网络恢复后自动把暂存的数据按顺序提交上去并同步状态。
class OfflineSubmitManager { Future<void> submitPending() async { final pendingList = await LocalDb.queryPendingSubmits(); for (final item in pendingList) { final connected = await NetworkProbe.isConnected(); if (!connected) break; final success = await SubmitApi.submitWorkOrder(item); if (success) { await LocalDb.markPendingSubmitted(item.id); } } } }这里要注意图片文件的处理。图片压缩后我存储的是一个绝对路径,但这个路径在应用重启后不一定仍然有效,尤其是如果之前用的是临时目录。我的建议是:压缩后就把图片复制到应用文档目录下一个专门的管理文件夹,路径存数据库,提交成功后根据配置决定是否删除。不能提交成功就立刻删,因为还要考虑服务端可能异步处理失败回滚,稳妥的办法是保留一天后再自动清理。
5. 状态管理、路由与数据持久化
5.1 状态管理选型:Riverpod还是Provider
项目进入中期后,页面间的共享状态越来越多,比如地图页选中的井盖,工单列表页的筛选条件,全局的用户登录态和当前定位。最开始我直接用Provider包了几个全局Provider,后来发现地图页的井盖数据每次数据更新都会触发地图重绘,重绘还要等点位计算完成,导致卡顿,最后决定迁移到Riverpod。
迁移的原因是Riverpod可以在不重建整个Widget树的情况下,精细控制监听范围。比如地图数据Provider更新时,我只让绑定了点位集合的Widget重新渲染,而不是整个地图页。Riverpod的Selector语法可以精确选取需要监听的字段,这个在复杂页面里的价值非常大。
final manholeDataProvider = FutureProvider.autoDispose<List<ManholeCover>>((ref) async { final visibleBounds = ref.watch(mapBoundsProvider); return await ManholeApi.fetchManholes(visibleBounds); }); // 在Widget里只监听需要的字段 final manholes = ref.watch(manholeDataProvider.select( (data) => data.asData?.value ?? const [], ));实际使用中,我发现Riverpod的autoDispose在有地图这种场景要特别小心。当地图短暂滑出可视区域再回到原区域时,autoDispose可能已经把Provider销毁了,重新创建后会再次请求接口,造成重复加载。所以地图数据相关的Provider我改成了普通Provider,不自动销毁,而是用缓存时间控制刷新频率。
5.2 网络请求层与错误处理
网络层其实是最能体现一个App“耐不耐用”的地方。整体架构是Dio封装统一的ApiClient,所有请求增加超时控制、统一的返回码处理、Token刷新机制和错误码映射。
请求设计的几个细节值得分享。第一,时间超时不能一刀切。普通查询接口超时设置10秒,图片上传接口的超时设置60秒,因为图片在弱网环境下确实要更久的等待时间。第二,返回码要统一封装,后端接口的返回报文可能是{code:0, data:{}, message:""}这种格式,但不同接口可能code表示含义不同,我在ApiClient层统一处理,业务层拿到的已经是解析好的对象,不用每个页面都判断一遍。
关于Token失效,常见做法是全局拦截401响应后自动刷新Token,刷新成功则重放原请求。这个机制在OpenHarmony的Flutter应用里同样适用。有一点要注意:如果多个请求同时返回401,会触发多个刷新Token并发请求,需要加锁,避免重复刷新导致死锁。我的方案是维护一个Future来完成刷新操作,所有401请求都等待同一个Future。
弱网环境下,请求失败会走重试逻辑。我针对不同接口设置了不同的重试次数:查询类接口重试2次,提交类接口使用了前面提到的离线队列,不直接重试而是先落库,避免反复请求把用户流量耗光。
5.3 本地数据库与缓存同步
这类应用对数据离线能力的要求比较高,涉及缓存的数据有三大类:基础字典数据(井盖类型、单位列表)、地图点位数据、工单数据。基础字典数据更新频率极低,适合全量缓存;地图点位数据体量大、更新频率中等,适合按区域增量缓存;工单数据变化频繁,本地只保留最近三十天的已完成的工单记录,避免本地数据库无限膨胀。
本地存储我选择了Sqflite的鸿蒙适配版本。为什么不用Hive?因为工单数据有比较多的按字段筛选和组合条件查询的需求,Hive这种KV数据库做起来不太顺手。Sqflite在鸿蒙上的适配已经比较成熟,SQLite的数据库文件也能在系统之间自由迁移。
地图点位数据的缓存策略稍微特殊一点。为了减少本地数据库体积,我不把整条井盖记录都存成JSON,而是单独建了一张bin表,把经纬度、类型、地图上需要展示的字段存为结构化字段,把权属单位、地址、历史维修记录等字段存成JSON字符串,查询时再按需解析。这样地图渲染时不需要解析大JSON,性能更好;进入详情页时才去解析JSON获取完整信息。
6. 常见问题排查与性能优化实录
6.1 常见问题速查表
我把这个项目过程中遇到的一线问题整理成了一个速查表,各位在OpenHarmony上跑Flutter应用时很可能也会碰到。
| 问题现象 | 排查思路 | 解决方案 |
|---|---|---|
| Flutter页面初始化加载慢 | 检查引擎启动是否在主线程,是否有大量同步IO | 使用FlutterEngine预热,延迟创建非首屏页面 |
| 地图拖动卡顿 | 检查每帧是否做命中测试、是否同步IO | 点位索引缓存、命中测试分帧延迟处理 |
| 图片上传失败且无回调 | 检查网络状态判断逻辑,确认是否走了离线队列 | 网络探测使用真实请求,不依赖系统广播 |
| MethodChannel调用无响应 | 检查鸿蒙侧是否注册了对应方法,方法名大小写 | 统一方法名命名规范,两侧代码同步核对 |
| 字体异常或中文显示为方块 | 检查OpenHarmony系统字体加载路径 | 工程内嵌入所需字体文件,FontLoader加载 |
| 状态更新后UI不刷新 | 检查Provider使用的是不是同一个容器 | 使用UncontrolledProviderScope并统一入口 |
表格里有几个问题值得展开。字体问题是最容易忽略的,OpenHarmony设备的中文字体文件路径与Android不同,如果三方字体库加载失败,整个页面会显示成方块乱码,非常影响演示效果。我的做法是将常用字体文件打包进应用资源,通过Dart侧FontLoader主动注册,不依赖系统字体。
还有一个高频问题:OpenHarmony应用退到后台再回来,Flutter页面动画卡顿或黑屏。这类问题多半是系统窗口SYSTEM_UI的焦点没有及时交给Flutter视图导致。处理方式是在页面onShow事件中调用FlutterView重新获取焦点。这个修复有点偏原生,但影响很大,建议大家把它作为通用适配步骤写进项目模板。
6.2 渲染卡顿与降频优化
地图页的渲染优化值得单独写一节,因为井盖点位多的时候,如果优化不得当,帧率会跌到可以感知的程度。优化工作分两部分:绘制阶段和构建阶段。
绘制阶段,点位的数量直接影响Canvas的drawPath调用次数。我们点位数在1000以下时,普通绘制没有压力;但到了3000以上,就要做分帧和范围过滤。范围过滤的思路很直接:只绘制在当前屏幕可视区域内的点。点位坐标部分虽然可能在屏幕外,但是聚合点、标注文字、气泡控件仍然会被构建。所以地图页我把标注文本做成了延迟加载:缩放到一定级别以上才显示文字标签,低级别只显示点。这个策略能让绘制指令减少一半以上。
构建阶段的优化更重要。地图页的主体是CustomPaint,如果井盖点位的Paint对象每次重绘都重建,开销很大。我把所有静态的Paint对象提取为final字段,只在地图缩放级别变化时更新。同时把每次重绘的索引结构缓存起来,不是每次刷帧都重建索引。这几个改动加起来,在测试机上地图滑动帧率从30帧以下提升到接近60帧。
还有一个很隐蔽的问题要提醒:当井盖数量多的时候,地图页面的widget树层级不要设计得过深。一个常见错误是,地图页外层套了多个Padding和Container,每次手势事件都要穿透多层手势识别器,事件响应会明显变慢。地图页我优化到从根节点到CustomPaint只有三层Widget,手势识别直接放在最顶层。
6.3 OpenHarmony包签名与发布注意事项
OpenHarmony应用上线和Android发布流程差异较大,这里简单说一下关键步骤。首先是签名配置,需要在工程配置中设置签名证书,证书的生成和配置通过OpenHarmony的签名工具完成。不同设备如果调试运行,还需要配置设备的调试证书。其次,OpenHarmony应用在真机调试时需要通过工具连接设备,并开启设备的开发者模式,这和Android的adb逻辑类似,但命令行工具和配置路径不一样。
打包产物是HAP包。如果项目需要上架到某个应用市场,还需要关注目标设备的API等级兼容性。OpenHarmony版本迭代快,不同设备出厂系统版本可能差异很大,兼容性测试要覆盖API 9和API 10两个等级。我就在这个上面吃过亏:开发时用的是API 10的特性,结果有一批部署设备是API 9,编译能过,运行直接报NoSuchMethodError。所以大家如果在项目里用了某个API的特殊能力,一定要到目标设备上回归验证,不能只看模拟器。
发布前还有一项必做工作:混淆配置。OpenHarmony应用默认不做代码混淆,如果对安全性有要求,需要在构建配置中开启混淆并配置keep规则,把工单相关model类保留,否则会影响反射和序列化。这个点文档里写得很浅,实践起来细节不少,建议时间充裕的话,就先用默认配置,确保功能稳定后再做混淆优化。
写在最后的一点实操体会
这个项目做下来,我最深的体会是:Flutter for OpenHarmony并不是一套“装好就能跑”的开发体验,它更像是在一个还在快速演进的平台上做定制开发,每个环节都要更谨慎一点。如果非要说哪些经验最值钱,我总结三条。
第一,先把最小闭环跑通再铺开功能。不要在OpenHarmony工程配置还一头雾水的时候就开始写业务页面,先跑通一个带着定位权限、地图自绘、工单提交的空壳应用,等于把整个链路里最不确定的地雷排掉,后面的开发都会变得很顺畅。
第二,一定要把平台通道的接口定义当成API来对待。Dart侧和ArkTS侧的方法名、参数类型、回调时序必须文档化,谁改谁维护。项目后期我们踩的MissingPluginException类问题,有一半是因为两侧代码不同步导致的。
第三,工单详情这类业务页面,一定要先梳理状态机,再写界面。我一开始先画UI,结果写到“驳回重做”和“撤回待审”这几个状态时,才发现界面上的操作逻辑根本对不上,返工了一次。先把状态流转和操作规则敲定,页面代码其实只是规则的翻译器。
项目后续如果要扩展,我建议可以加一个“常用位置收藏”功能,让外勤人员可以收藏自己负责的井盖集合,每次打开地图直接聚焦到自己的管养区域,这个需求在真实业务里呼声很高。数据维度上,可以考虑对接井盖的物联传感器数据,让地图上的井盖不仅能看静态台账,还能实时展示水位、倾斜角度等动态信息。不过这些都是后话了,先把地图和工单闭环做扎实,这个App在真实场景里的价值就已经不小了。