上个月接了个智慧市政项目:一套城市井盖巡检系统,设备端指定跑在 OpenHarmony 上。业务方要求巡检员打开App就能看到当前辖区的井盖分布地图,点开井盖直接进巡检,按检查表逐项填报、拍照、签字,网络不好时也要能正常干活。团队里有Flutter背景的同事,评估一圈之后决定走 flutter_for_openharmony 这套路线。整个项目核心就两块:城市井盖地图App + 巡检检查表实现。两周时间把端上的主流程跑通,我把选型思路、工程初始化、地图点位渲染、动态表单引擎、离线同步这些关键环节完整整理出来,给后面要在OpenHarmony上用Flutter做行业应用的团队做个参考。
1. 项目背景与整体方案拆解:井盖巡检App到底要做什么
1.1 业务场景中的三个核心痛点
市政井盖的特点是数量大、分布广、状态变化快。某个城市一个中等辖区,井盖数量少说几千个,涉及雨水、污水、电力、通讯、燃气等不同权属单位。过去巡检主要靠纸质表格加人眼判断,问题井盖要手工记录位置、拍照、回单位再录入电脑。这套流程的实际问题,我在前期调研里总结成三个:
位置和编号对不上。井盖编号是唯一的,但巡检员在现场经常找不到"编号对应地图上哪个位置",尤其绿化带、人行道、机动车道这些场景,找到编号和找到井盖是两回事。
数据严重滞后。巡检结束才能集中录入,问题井盖从发现到上报进入维修流程,往往隔了一两天。涉及安全隐患的场景,这个滞后不可接受。
检查表质量不受控。纸面表单自由度太高,巡检员填多填少全凭个人标准,不同人交上来的数据维度不一致,后台没法横向统计。
App要解决的就是这三件事:用地图把井盖位置和状态可视化,用标准化的巡检检查表引导现场录入,再用一套可靠的同步机制把数据送回后台。这里"可靠"两个字很关键,因为井下作业、路边巡检经常遇到信号弱的情况,离线能力不是加分项,是硬性需求。
1.2 为什么选择Flutter for OpenHarmony而不是直接用ArkTS
这个问题在方案评审时被反复追问。OpenHarmony应用开发最正统的路线是ArkTS加ArkUI,但放到我们这个项目里有两个现实问题。
团队技术栈复用率太低。团队核心成员都是Flutter出身,Dart代码至少积累了三个项目的业务逻辑,换到ArkTS意味着所有代码推倒重来,排期直接翻倍。另外这股代码之后还计划覆盖其他移动端平台,到了那时候,Flutter一套代码能跑Android、iOS、Web的优势就会放大。
flutter_for_openharmony这套运行时已经能把Dart代码跑到OpenHarmony设备上,UI层由Flutter引擎自己渲染,不依赖ArkUI的组件体系。对业务开发来说,绝大多数页面代码和标准Flutter一致,只有涉及系统能力(定位、相机、存储、权限)时才需要用Platform Channel去对接OpenHarmony的API。
需要说清楚的是,它不是OpenHarmony官方默认的UI开发方式,更准确的定位是"把Flutter运行时搬到OHOS上,再通过桥接层调用系统服务"。所以很多Android/iOS上的Flutter插件在这里不能直接用,必须看插件是否提供ohos平台的实现。这个认知直接影响后面的技术选型和排期,建议团队在动手前先统一预期。
1.3 整体技术架构与关键数据量估算
整个应用的数据流是这样的:巡检员打开App,客户端请求定位权限并获取当前坐标,地图组件加载当前视野范围内的井盖点位,点击某个井盖弹出详情卡片,点"开始巡检"后进入检查表页面,表单由后台下发的JSON Schema动态渲染,巡检员逐项填写、拍照、签名,数据先落本地SQLite,网络可用时通过上传队列同步到服务端,服务端回写确认状态。
技术栈清单我直接列出来:
| 模块 | 选型 | 备注 |
|---|---|---|
| UI框架 | Flutter | 使用flutter_for_openharmony适配分支 |
| 地图能力 | 第三方地图SDK的OHOS适配版 | 正式版本用,备选WebView和自绘方案 |
| 本地存储 | Drift(SQLite封装) | 支持业务表和上传队列 |
| 网络请求 | dio | 配合自建上传队列 |
| 权限处理 | 系统权限桥接 | 自封装MethodChannel |
| 图片采集 | 相机插件适配版/自封装 | 涉及压缩与持久化 |
数据量估算在架构阶段就要做,否则后面存储和同步设计没有依据。井盖基础点位按5000个算,每条记录包含经纬度、编号、类型、状态等字段,单条约200字节,全量点位数据下载到本地也就1到2MB,完全可以全量落库。巡检记录按每天100个井盖、每个井盖3张照片估算,照片压缩后每张平均250KB,一天新增图片约75MB,一个工作周约375MB。这个体量决定了本地必须预留至少512MB的持久化存储空间,同时也说明图片不能直接存数据库,必须文件落盘加数据库记录路径。
2. 环境与工程初始化:三步跑通flutter_for_openharmony
2.1 环境清单和环境变量配置
OpenHarmony上的Flutter开发和标准Flutter环境最大的区别是SDK。除了常规的Flutter SDK和Dart SDK,还需要准备OpenHarmony SDK,并且Flutter SDK必须使用适配分支。我们团队在环境准备阶段踩了不少坑,核心是要把下面几项一次配齐:
OpenHarmony SDK,建议用API 10或更高版本,太老的API在Flutter适配层会有编译兼容问题。官方IDE工具链可以正常安装,但命令行编译时主要是依赖OpenHarmony SDK本身。Flutter for OpenHarmony的分支代码单独拉取,配到PATH里,注意和标准Flutter SDK区分开,避免混用。环境变量里需要显式配置OHOS SDK路径,很多初次上手的人漏掉这一步,导致flutter doctor检测不到设备。
配完之后用flutter doctor验证,能识别出OpenHarmony设备或模拟器就算通过。这里有个经验:不要一上来就接业务代码,先花半天时间把官方样例工程在真机上跑通,确认你手里这套工具链路能编译、能安装、能热重载,再往里填业务逻辑,不然后面排查环境问题会非常痛苦。
2.2 创建工程的关键命令与目录结构
工程创建分三步走。第一步拉取fluuter_for_openharmony的SDK,切到openharmony分支,配好环境变量。
git clone -b openharmony https://example.com/flutter_for_openharmony.git export PATH=$PATH:/your_path/flutter_for_openharmony/bin export OHOS_SDK_HOME=/your_path/ohos-sdk flutter doctor第二步用flutter create创建标准工程。
flutter create manhole_map_app cd manhole_map_app flutter build ohos --debug第三步是让工程生成ohos平台的壳工程,这一步和标准Flutter创建Android/iOS目录不一样,它是在构建过程中自动生成的。第一次执行flutter build ohos会比较慢,主要是在下载和编译OHOS平台的引擎产物,后面增量构建会快很多。
需要留意的是构建命令不是flutter build apk,而是flutter build ohos。团队里第一次接触的人十有八九会敲错命令,敲成apk之后报错又找不到原因。另外生成的工程目录里会多出一个ohos目录,这个目录的结构类似Android工程的壳工程概念,不要手工去改里面的源码,改完下次生成就被覆盖了。
2.3 插件兼容性验证清单:先跑通Demo再写业务
在写任何业务代码前,先做一轮插件兼容性验证。我们当时列了一个最小能力清单:定位、相机调用、网络请求、本地数据库。验证方式非常简单,每个能力写一个最小页面,跑一遍真机。
这轮验证的价值在于提前发现插件生态的缺口。比如定位插件,标准Flutter生态里的geolocator没有OHOS实现,网上搜到的适配版本可能存在API版本不一致的问题。相机插件也一样,image_picker的OHOS分支不一定稳定,有些场景需要自己包一层Platform Channel调用系统相机接口。
如果一个插件没有OHOS实现,通常有三条路:找社区适配的fork版本、自己写MethodChannel薄封装绕过去、换一种能力闭环的实现方式。我们在项目里就遇到地址选择器没有OHOS实现,最后直接用地图SDK里的POI检索替代了,反而比原来的地址选择器流程更短。这个验证清单建议做成项目的准入条件,每接一个新插件都先过一遍,再进入业务开发。
3. 城市井盖地图模块:点位加载、聚合渲染与交互实战
3.1 三种地图方案对比与选型
地图是这个App最核心的模块,也是技术上最不确定的一块。我们对比了三种实现路线:
第一种是第三方地图SDK的OHOS适配版。几家主流地图厂商陆续发布了面向OpenHarmony的SDK版本,支持底图渲染、marker、定位、POI检索这些核心能力。优势是原生渲染性能好,手势流畅,离线底图缓存成熟。劣势是版本不如Android/iOS成熟,文档也不全,遇到问题只能自己摸索。
第二种是WebView内嵌H5地图。开发最快,直接用现成的JS地图SDK,Flutter层只负责壳容器和JSBridge。劣势很明显:弱网离线时加载不稳定,和Flutter层交互链路长,marker点击回传延迟明显,这些对巡检现场是致命的。
第三种是自绘示意地图。用CustomPaint画简化路网和井盖点位,完全不依赖外部地图服务。优势是绝对可控、不依赖网络,适合数据量小、底图精度要求不高的场景。劣势是路网数据要自己维护,没有标准地图的POI和路名,巡检员很难凭示意图定位。
我们最终选了第一种做正式版本,第三种作为离线兜底方案。选型的底层逻辑是:巡检强依赖真实路网底图,自绘方案没法让人在路边找到几米外的井盖;WebView在弱网和手势交互上的短板和巡检场景严重冲突。第三方OHOS适配版即便有bug,但底图渲染和手势是原生的,体验下限有保障。
3.2 井盖点位数据模型设计
井盖点位的模型设计直接决定后续图层渲染和表单联动的复杂程度。我的建议是模型字段宁多勿少,因为井盖的状态、权属信息在巡检表单里都要用到,避免到时候再改模型引发连锁改动。
class ManholeCover { final String id; final String code; final double lat; final double lng; final String type; // 雨水井、污水井、电力井、通讯井、燃气井 final String ownerUnit; // 权属单位 final String status; // normal, damaged, missing, leaking final DateTime lastInspectionTime; final String address; // 逆地理编码地址,用于快速展示 }状态字段和颜色的映射关系要统一设计。正常是绿色、破损是黄色、缺失是红色、渗漏是蓝色。这里有个细节:颜色映射不要写死在各个页面里,统一放在一个Map里管理,后续后台要改状态语义时只动一个地方。
const statusColor = { 'normal': Color(0xFF2EB872), 'damaged': Color(0xFFF2B134), 'missing': Color(0xFFE3493C), 'leaking': Color(0xFF3F8EFC), };3.3 聚合渲染与大点位量性能优化
5000个井盖全部渲染成marker,任何地图SDK都会卡。我们做了两层的性能优化:视野加载和网格聚合。
视野加载的逻辑是监听地图移动事件,重新计算当前可视范围的经纬度边界,只加载这个范围内的点位数据。本地数据库按经纬度建立索引,查询返回速度很快。这保证无论总数据量多少,地图上同时存在的原始marker数量都能控制在几百个以内。
聚合是针对密集区域的进一步优化。思路是把当前视野分割成网格,每个网格内的点位聚合成一个聚合marker,点聚合marker后放大一级再重新展开。这里的关键是网格大小怎么取。Web Mercator投影下,zoom级别为z时,全球沿赤道方向被分成2^z个瓦片,某个纬度φ处的瓦片东西向长度约等于地球周长乘以cos(φ)再除以2^z。以项目所在地区北纬约36度、zoom级别14为例,单个瓦片长度大约等于40075公里乘以0.809再除以16384个瓦片,算下来约1.97公里。如果想聚类粒度控制在50米以内,需要在一个瓦片内继续细分大约40乘40的子网格,这个计算直接作为聚合的参数配置。
class GridCluster { final int gridX; final int gridY; final List<ManholeCover> items; double get centerLat => items.map((e) => e.lat).reduce((a, b) => a + b) / items.length; double get centerLng => items.map((e) => e.lng).reduce((a, b) => a + b) / items.length; }聚合后的marker用数字角标展示点位数量,点击聚合marker时地图zoom加1,让聚合点逐步裂开。这套方案在真机上实测,5000个点位的工程里地图滑动帧率能保持在30帧以上,稳定可用。
3.4 地图交互:详情弹窗、定位与附近搜索
地图交互有四个核心操作。点击井盖marker弹出底部详情卡片,展示编号、类型、权属单位、当前状态、最近巡检时间;卡片上放"开始巡检"按钮,直接进入检查表页面;底部加一个"定位到我的位置"按钮,请求定位权限后把地图中心移到当前坐标;同时支持按井盖编号模糊搜索,找到后把对应点位移到视野中心。
定位权限这块在OpenHarmony上要注意,权限模型和一些主流移动操作系统类似,也需要在配置文件里声明并在运行时动态申请。我们封装了一个MethodChannel来统一处理权限检查和申请,返回授权状态给Dart层,Dart层根据状态决定是否显示引导提示。
class OhosLocationBridge { static const _channel = MethodChannel('com.example.manhole_map/location'); static Future<Map<String, dynamic>> getLocation() async { final result = await _channel.invokeMethod('getLocation'); return Map<String, dynamic>.from(result); } }这里有个自己的实践体会:地图页面的生命周期管理一定要小心。Flutter的widget树在切换页面时会重建,如果每次重建都重新初始化地图控制器,来回几次内存就上去了。正确做法是地图控制器全局复用,只在页面真正销毁时释放,同时marker要复用而不是反复new。
4. 巡检检查表实现:动态表单引擎与照片签名采集
4.1 检查表动态化的必要性
巡检检查表如果写死在客户端,后续会非常被动。不同井盖类型的巡检项完全不同,雨水井主要查淤积和井盖破损,电力井要查电缆状态和警示标识,通讯井查设备箱和线路布放。后台业务人员今天加一个巡检项、明天调整一个必填项,如果客户端是硬编码,每次都要发版审核,这个节奏业务方完全接受不了。
所以检查表必须由后台下发模板,客户端动态渲染。后台配置一次JSON Schema,客户端就能生成对应的表单页面。这不仅解决了发版问题,还让不同片区的巡检标准可以差异化配置,同一个App在不同区域展示不同检查表。
动态表单的核心挑战在于组件映射、数据收集和校验。组件映射决定了Schema里的每个字段类型怎么对应到Flutter的Widget,数据收集决定了用户填完的内容怎么组织成提交数据,校验则直接关系到巡检数据质量。
4.2 表单Schema定义与校验规则
我们定义了一套最小可用的Schema规范。每个表单包含一个formId和一组items,每个item包含字段名、展示标签、类型、选项、是否必填等属性。类型目前支持单选、多选、文本、数值、拍照、签字六种。
{ "formId": "inspection_water_well", "items": [ { "field": "cover_status", "label": "井盖外观", "type": "single_choice", "options": ["完好", "破损", "缺失"], "required": true }, { "field": "sediment_level", "label": "淤积程度", "type": "single_choice", "options": ["无淤积", "轻度", "中度", "重度"], "required": true }, { "field": "photo", "label": "现场照片", "type": "photo", "maxCount": 3, "required": true }, { "field": "signature", "label": "巡检员签名", "type": "signature", "required": true }, { "field": "remark", "label": "备注", "type": "textarea", "required": false } ] }这套Schema的好处是把业务规则和代码逻辑分开了。后台加字段不需要动客户端,客户端只负责解释Schema。校验规则也直接写在Schema里,每个item的required属性就是提交时校验的依据。数值类字段我还扩展了一个数字范围和单位字段,比如井深可以限定0到10米。
4.3 表单动态渲染实现:类型到Widget的映射
动态渲染的核心是一个工厂函数,根据item的type返回对应的Widget。单选用RadioGroup,多选用CheckboxGroup,文本用TextField,数值用带了数字键盘的TextField,照片用自定义PhotoPicker,签字用自绘SignaturePad。
Widget buildFieldWidget(FormItem item, FormController controller) { switch (item.type) { case 'single_choice': return RadioGroup<String>( options: item.options, value: controller.valueFor(item.field), onChanged: (v) => controller.setValue(item.field, v), ); case 'multi_choice': return CheckboxGroup( options: item.options, selected: controller.valueFor(item.field), onChanged: (v) => controller.setValue(item.field, v), ); case 'textarea': return TextField( controller: controller.controllerFor(item.field), maxLines: 4, ); case 'numeric': return TextField( controller: controller.controllerFor(item.field), keyboardType: TextInputType.numberWithOptions(decimal: true), ); case 'photo': return PhotoPicker( maxCount: item.maxCount ?? 3, onPicked: (files) => controller.setValue(item.field, files), ); case 'signature': return SignaturePad( onSign: (bytes) => controller.setValue(item.field, bytes), ); } }FormController是这里的关键抽象。它维护一个Map保存每个字段的当前值,在提交时把所有item遍历一遍,根据required和类型规则做校验。校验失败时要把错误信息定位到具体字段上,并且在对应Widget下方显示红色提示。我建议把校验逻辑独立成一个方法,不要和UI混在一起,后面扩展自定义类型会轻松很多。
动态表单的另外一个坑是状态恢复。巡检员填到一半来电话,或者App被系统杀掉,表单数据不能丢。我们的做法是每当FormController里的值变化时,自动保存一份草稿到本地数据库,返回表单时先检查有没有草稿,有就自动回填。
4.4 拍照压缩、签名板与图片落盘
拍照是巡检表单里数据量最大的部分。项目要求每个井盖最多传3张照片,原图一张动辄3到5MB,如果不做压缩,一天的离线数据就能把存储空间吃满。我们的压缩策略是长边压到1280像素,JPEG质量85。实测下这个参数拍出来的照片在手机屏幕上看足够清晰,文件大小能控制在200到400KB,相比原图体积缩小了90%以上。
压缩逻辑放在拍摄完成之后,立刻把它放到后台Isolate里执行,不要在UI线程做,否则拍照后表单页面会卡住好几秒。压缩完成后把文件保存到App的持久目录,目录结构按巡检记录ID组织:
inspection_images/{recordId}/0.jpg inspection_images/{recordId}/1.jpg inspection_images/{recordId}/2.jpg这里有一个实操中很容易踩的坑:很多相机插件返回的图片路径是临时缓存目录,App进程被回收或者系统清理缓存时,临时目录里的文件会被删掉,数据库里保存的路径就成了死链。解决办法是拍照返回后立刻把文件复制到持久目录,确认复制成功后再把新路径写入表单数据。
签名板用CustomPaint实现。用户在画布上手指滑动,我们记录一系列坐标点,松手时把绘制内容生成Bitmap并编码成PNG。签名不需要像照片那样高清,200像素宽就够,否则一张签名图也有几百KB,浪费存储。
图片和表单主数据的关系要设计成引用关系。表单记录保存JSON文本,里面只存图片文件的相对路径和顺序,图片文件单独上传。原因是图片和JSON的传输特性不同,JSON小且必须保序,图片大且适合并发。合并传输会让上传队列逻辑复杂,还容易因为一个大图片失败导致整个表单重传。
5. 离线数据同步:本地库、上传队列与重试策略
5.1 本地库表结构与主键策略
巡检现场的网络情况不乐观,地下车库、桥洞、城乡结合部经常断网。所有巡检数据必须先存本地,网络恢复后再同步。本地库选用Drift,它是SQLite上面一个类型安全的封装,在Flutter生态里成熟度很高,OpenHarmony分支也能正常编译运行。
核心表有巡检记录表、巡检明细项表和图片文件表。巡检记录表把整个表单的JSON存成一个列,这样同步时可以把一条记录当成一个不可变对象整体上传,避免多个字段分散在不同表里需要逐字段合并的问题。
class InspectionRecords extends Table { TextColumn get id => text()(); TextColumn get coverId => text()(); DateTimeColumn get inspectTime => dateTime()(); IntColumn get syncStatus => integer()(); // 0本地草稿 1待同步 2已同步 TextColumn get formDataJson => text()(); TextColumn get localImages => text()(); // 相对路径列表 }主键策略直接用客户端生成UUID,不走服务端自增ID。原因是离线场景下客户端不可能先拿到服务端ID,用UUID可以保证幂等,服务端重复收到同一条记录时直接覆盖即可。不要用自增ID做主键,否则两条不同设备上的离线记录会撞在一起。
5.2 上传队列与指数退避重试
同步模块单独建了一张上传队列表,字段包括记录ID、优先级、重试次数、下次重试时间。每次生成一条巡检记录时,同时插入一条队列任务。网络恢复监听器收到信号后,就开始从队列里取任务执行。
上传顺序要按业务优先级排:问题井盖的记录优先于正常记录,缺失和损坏井盖的巡检数据要第一时间到达后台。每条记录上传时,先传JSON数据,服务端返回成功后,再并发上传该记录关联的图片文件。图片并发数控制在4到6个连接,不要一次性全开,否则弱网下反而会占用太多TCP连接导致整体变慢。
失败重试采用指数退避,间隔是1秒、5秒、30秒、5分钟、30分钟一共五档,超过五次后任务标记为失败,界面上提示巡检员手动重试。
Future<void> processQueue() async { while (true) { final task = await getNextUploadTask(); if (task == null) break; try { await uploadRecord(task.recordId); await uploadImages(task.recordId); markTaskDone(task.id); } catch (e) { final nextRetry = _backoff[task.retryCount]; if (nextRetry == null) { markTaskFailed(task.id); } else { scheduleRetry(task.id, nextRetry); } } } }批量上传也要有个上限。我们一次最多取50条记录打包上传,超过50条就等这批完成后再取。原因是每条记录都带JSON和图片,如果一次性取几百条,内存里会同时堆积大量图片字节,很容易OOM。
5.3 同步状态机与冲突处理
同步状态机设计得越简单越好。我们只用了五个状态:本地草稿、待同步、上传中、已同步、同步失败。每个状态的流转都是单向的,从草稿到待同步是用户点击提交触发的,从待同步到上传中是网络信号触发的,服务端确认后从上传中流转到已同步。
冲突处理上,巡检业务的并发写概率极低,几乎不可能出现两个人同时巡检同一个井盖并提交不同结果。所以服务端策略是简单处理:客户端记录带UUID,服务端按UUID判重,已存在则直接覆盖,同时记录一个服务端版本号。如果后续业务出现后台编辑和巡检端数据同时变化的情况,再升级成版本号比较加合并策略,当前阶段不做过度设计。
离线时间长了以后,要处理记录堆积。之前估算过,按照每天100个井盖、每个井盖3张照片、压缩后每张250KB,一天的新增图片约75MB,一个工作周约375MB。持久目录至少预留512MB空间,而且在服务端确认同步成功后,定期清理一个月前的旧照片,避免存储空间持续膨胀。
6. 常见问题与避坑实录:插件、地图性能和表单细节
6.1 插件不适配OHOS时的三条出路
实际开发中遇到最多的问题是某个Flutter插件在Android和iOS上跑得好好的,编译到OHOS平台直接报"没有对应实现"。排查方法很简单:看插件的platforms目录下有没有ohos文件夹,没有就是没适配。
处理方式按优先级排列。第一优先级是在插件作者或社区里找OHOS适配分支。第二优先级是自己用MethodChannel写一个薄封装,只调插件里最短路径的那几个能力。第三优先级是换一个功能相近、有OHOS实现的插件。
我们项目里需要地址选择器,翻了半天没有OHOS版,最后直接改用地图SDK自带的POI检索,输入关键词搜索周边地点名称来辅助定位井盖,反而少了一层跳转交互。这条路换来换去很正常,关键是提前验证,不要在排期中途才发现。
6.2 地图页面内存上涨问题
地图页是内存问题重灾区。一开始我们的页面在反复进出几次后,真机内存从200MB一路涨到500MB以上,最后被系统杀掉。排查下来有两个原因:每进一次页面都创建了新的地图实例,旧的没有释放;marker在图层刷新时不断new和add,没有复用。
解决办法是地图实例全局单例化,页面销毁时不销毁地图,只是移除图层;marker对象放进资源池,同一个语义的marker更新时先查资源池,有就复用它改坐标和图标,没有才新建。另外避免在Flutter的build方法里初始化地图或者刷新marker,这些逻辑应该放在地图状态变更的回调里。
6.3 照片临时路径失效问题
前面提过相机插件返回的临时路径会失效。这个问题的具体表现是:App热重启或者隔天再打开,之前填好的巡检记录里照片全部显示空白,点开提示文件不存在。
本质原因是临时目录不受应用持久存储保护,系统清理或进程回收后文件就没了。正确做法是在拿到图片字节的第一时间把它写入持久目录,并且写完后立刻做一次文件存在性检查,然后在表单里替换成持久路径。我们的草稿恢复机制也依赖这一点,如果草稿里存的还是临时路径,恢复时必然失败。
这个坑很隐蔽,因为当天调试不会暴露,往往在真机使用一两天后才出现,等到业务方反馈的时候已经丢了一大批巡检数据。建议从开发第一天就按持久化路径来设计图片存储,不要心存侥幸。
6.4 表单滚动与键盘遮挡问题
动态表单页面在窄屏设备上,键盘弹出来之后会遮挡下面的输入框。最直接的解决方法是整个表单包在SingleChildScrollView里,同时在文字输入类的item获得焦点时,用Scrollable.ensureVisible把当前字段滚动到可视区域。
还有一个细节是照片选择器在表单里占据的高度比较大,如果照片字段排在前面,后面的必填项会被挤到屏幕外。不知道这个字段存在的话,巡检员可能漏填。我们的做法是表单提交时做一次全量校验,未填写的必填项自动滚动到视野范围内,并在对应字段上显示醒目错误提示。
权限这块也提一下。OpenHarmony的权限机制和Android有差异,部分权限即使代码里申请了,系统弹窗被忽略后状态也不会自动恢复。所以每次进入地图或拍照之前要重新检查权限状态,如果被拒绝要跳转到系统设置页引导用户手动打开。
这次项目跑下来,我最大的体会是flutter_for_openharmony已经过了只能跑demo的阶段,但离生产环境还剩最后一公里,这一公里主要是插件生态补齐的活。如果你们也准备走这条路,建议先把地图、相机、定位、本地存储这几个硬骨头挨个验证完了再排期。另外别忽视检查表动态化设计和离线数据链路,这两块才是巡检类项目真正的核心复杂度所在。最后说一个从实战里熬出来的经验:不要在真机上反复热重载地图页面,你很快会被OpenHarmony的内存回收机制教做人。