1. 方案落点:为什么偏偏是“鱼缸水质记录器”撞上鸿蒙 + Flutter
不少初学者一看到“跨平台鸿蒙开发”就习惯性地去搜环境变量、去配置 build 工具链,结果配了一整天,连页面上放什么按钮都没想清楚。我建议反过来:先找一个足够真实、但复杂度刚好能被 Flutter 吃下的场景。鱼缸水质记录器就是很典型的切入点。
养鱼这件事看似休闲,实际是个持续采集数据的活。pH、氨氮、亚硝酸盐、硝酸盐、水温这些参数,不同鱼的耐受范围完全不同,草缸和裸缸的管理逻辑也不一样。大多数鱼友还在用纸质表格或者微信群接龙记录,偶尔有人用 Excel,但手机端查询历史曲线非常不方便。也就是说,这是一个有明确用户痛点、有清晰数据模型、需要可视化表达的应用场景,而且它不需要复杂的后端服务,非常适合拿来验证一套跨平台方案到底顺不顺手。
选择鸿蒙作为目标平台也很有代表性。过去做跨平台,惯例是先出 Android 版,再考虑 iOS 和桌面端。但现在身边不少人主力机已经变成了鸿蒙设备,鱼缸应用这类轻度工具型 App 如果只停留在 Android,等于把一批真实用户挡在门外。而基于 Flutter 做鸿蒙适配的好处在于:Dart 层和 UI 层逻辑可以完全复用,真正需要额外处理的只有平台相关的能力,比如蓝牙连接水质传感器、通知提醒这类。通过这个项目,你可以把“Flutter 应用如何跑上鸿蒙”这件事完整走一遍,比单纯看文档印象深得多。
适合阅读这篇教程的人有两类:一是已经写过 Flutter 应用、想试试鸿蒙出包流程和常见差异的开发者;二是想找个具体项目练手但又不愿意只写 to-do list 的初学者。前者可以直接跳到第 3 章之后的工程适配和构建部分,后者建议从现有的数据层和页面结构一路跟下来。
鱼缸水质记录器的核心功能范围其实很小:记录哪一天测了什么水质参数、顺手记一笔换水或者喂食、能按时间维度回看曲线。但在 Flutter 的组件体系里,它恰好能覆盖表单、列表、状态管理、本地数据库、图表绘制这几大块。你把这个小项目啃下来之后,再去做购物车、记账本、打卡类应用,基本上就是换一套业务字段而已。
2. 鸿蒙侧的 Flutter 环境:版本选型与工程创建阶段的三个坑
在开始写业务代码之前,先把环境说清楚。这里所谓的“鸿蒙侧的 Flutter”,指的是 OpenHarmony 社区维护的 Flutter 分支,它基于官方主干做了一批针对鸿蒙能力适配的改动,包括编译目标、引擎层接入以及插件兼容层。官方社区托管在 gitee 上,仓库名是 flutter_flutter,常用的分支形如 ohos-x.x.x。如果你直接把 Google 官方 Flutter SDK 拿来指望它生成 hap 包,那是行不通的,因为官方 Flutter 目前对鸿蒙没有一等公民支持。
2.1 SDK 下载、分支选择与 PATH 配置
下载方式很简单,国内网络环境下直接走 gitee 镜像,避免访问远端仓库时反复失败:
git clone -b ohos-3.22 https://gitee.com/openharmony-sig/flutter_flutter.git这里的分支号需要结合你手头的 DevEco Studio 和 HarmonyOS SDK 版本去匹配。如果用的是 DevEco Studio 5.0、SDK API 12 及以上的环境,建议选择较新的 ohos 分支;如果 API 版本较老,分支太高反而可能在编译阶段报 SDK 版本不匹配。一个比较稳妥的做法是:先安装最新版 DevEco Studio,再查它对应支持哪个 Flutter ohos 分支,别急着 clone 错分支。
下载完成之后,把 bin 目录加入 PATH。这里有个典型坑:如果你之前装过官方版 Flutter,两个 flutter 命令会在 shell 里冲突。我一般会把官方 SDK 和 ohos 分支的文件目录命名区分开,比如flutter_official和flutter_harmony,然后在.bashrc或环境变量里按项目切换。
PATH 配置好之后,先不要急着建工程,先确认两个跟鸿蒙有关的命令:
flutter doctor flutter config --list如果能正常输出版本信息,说明 Flutter 本体没问题。接下来需要确保 DevEco Studio 自带的hdc命令在 PATH 中,hdc 是鸿蒙生态对标 Android ADB 的设备调试工具,后面真机安装依赖它。DevEco Studio 安装完通常设备调试终端里可以直接用,但如果想在 Flutter 的命令行进程里直接调,需要把 SDK 目录下toolchains加进 PATH。
2.2 创建工程并确认 ohos 目录生成
环境就绪后,进入项目目录执行:
flutter create --org com.example --project-name fish_logger .这里的project-name建议显式指定,因为 Flutter 会用它作为诸多编译配置的根命名,一旦你之后想改包名,牵扯面会比较广。创建完成后,目录下应该出现一个ohos/文件夹,这是 ohos 分支的特有产物,内部结构和安卓工程很像,主要入口在ohos/entry/src/main/ets/下。如果你创建完找不到这个目录,大概率是当前 flutter 命令仍旧指向了官方分支,检查一下flutter --version输出的来源路径。
2.3 用 DevEco Studio 打开 ohos 工程时的注意点
你不要直接在 DevEco 里打开整个 Flutter 项目,那不是它认识的工程格式。正确做法是打开ohos/这一层目录,DevEco Studio 会把它当成标准 HAP 工程识别。同时,Flutter 调试器一般还是用 IDE 里的 Dart 插件跑,两边工作是分离的:Dart 插件负责热重载和 Dart 侧调试,DevEco 负责鸿蒙侧的编译资源、权限描述和签名配置。
实际项目里你可能会遇到一个比较困惑的场景:在 Android 工程里,我们通常把minSdk和targetSdk写在build.gradle;而在鸿蒙工程里,对应的关键文件是ohos/entry/src/main/module.json5,它负责声明应用权限以及入口页。如果你的应用需要访问网络、蓝牙,或者要读取传感器设备信息,注意事项要在这里写清楚,具体内容我在第 6 章展开。
3. 数据模型设计:水质参数该怎么建模才不会越写越乱
鱼缸水质记录器的核心价值在于数据能回溯,所以数据模型是整棵代码树的根。我先按照最常见的需求把实体拆出来:
- 鱼缸信息:缸的名称、尺寸、水体容量、养的是哪些鱼。
- 水质记录:测量时间、pH、氨氮、亚硝酸盐、硝酸盐、水温、换水量、备注。
- 事件记录:换水、喂食、下药、清过滤器。
大部分教程喜欢做一个“大而全”的记录表,一个 model 把所有字段塞进去。短时间是省事,但一旦你想按鱼缸维度显示“平均水温”或者在图表上标注换水时刻,字段混在一起会变得很别扭。我当时的做法是拆成两张表:一张tanks,一张records,事件用record_type字段表达,同时用tank_id关联。
表结构参考如下:
CREATE TABLE tanks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, capacity REAL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE records ( id INTEGER PRIMARY KEY AUTOINCREMENT, tank_id INTEGER NOT NULL, recorded_at INTEGER NOT NULL, ph REAL, ammonia REAL, nitrite REAL, nitrate REAL, temperature REAL, event_type TEXT NOT NULL DEFAULT 'measure', water_change REAL DEFAULT 0, note TEXT, FOREIGN KEY (tank_id) REFERENCES tanks(id) ); CREATE INDEX idx_records_tank_time ON records(tank_id, recorded_at DESC);recorded_at我统一存毫秒时间戳,而不是存字符串。原因很简单:图表的 X 轴、列表里的日期分段、以及“过去 7 天平均 pH”这类统计,最终都要基于时间戳计算,字符串日期无论格式怎么统一,做区间查询和排序都会多绕一层转换。UI 层需要显示成“2025-03-02 14:30”的时候再格式化不迟。
在 Dart 侧对应的模型类:
class WaterRecord { final int? id; final int tankId; final DateTime recordedAt; final double? ph; final double? ammonia; final double? nitrite; final double? nitrate; final double? temperature; final String eventType; final double waterChange; final String note; WaterRecord({ this.id, required this.tankId, required this.recordedAt, this.ph, this.ammonia, this.nitrite, this.nitrate, this.temperature, this.eventType = 'measure', this.waterChange = 0, this.note = '', }); Map<String, Object?> toMap() => { 'id': id, 'tank_id': tankId, 'recorded_at': recordedAt.millisecondsSinceEpoch, 'ph': ph, 'ammonia': ammonia, 'nitrite': nitrite, 'nitrate': nitrate, 'temperature': temperature, 'event_type': eventType, 'water_change': waterChange, 'note': note, }; }3.1 为什么不加一个“传感器设备表”
你可能注意到我上面没有设计传感器绑定表。这背后有个取舍:真实场景里水质记录器的数据来源有两条路,手工输入和自动采集。自动采集需要连蓝牙传感器、解析协议、做采样校准,这会导致项目规模膨胀。对一篇面向 Flutter 跨平台迁移的教程来说,我更愿意把自动采集单独拆成一个“可替换的采集源”,数据层只关心“上游给我一组 WaterRecord 字段”。这样不管后面接入的是蓝牙模块、串口模块还是纯模拟数据,上层逻辑都不用大改。
3.2 关于“氨氮”等参数允许为空的设计
水质参数的检测不是每次都能全测。有的人只测温度和 pH,氨氮试纸用完了就没测,如果数据库表里把 ammonia 定义成NOT NULL,那么表单上没填这个值直接提交就会被打回。我见过不少新手栽在这个问题上,他们认为“所有字段必须填完才能提交”,但对真实用户来说,有空缺本来就是正常状态。设计上要允许 nullable,同时表单校验可以给“本次未测”提供一个明确的占位,别让空值和零值混在一起。零是测量结果,null 是未测量,这两个在统计上差别很大。
4. 存储层选型的坑:sqflite 在鸿蒙上不灵了,怎么办
数据库连接这块是 Flutter 跨平台项目里最容易“假适配”的部分。sqflite 是 Flutter 生态最常用的 SQLite 插件,但它的实现依赖 Android/iOS 原生的 SQLite 封装,鸿蒙上并没有直接对应的原生实现。如果你直接使用sqflite包里的openDatabase,在鸿蒙真机上往往会得到一个MissingPluginException。
解决思路有两种。第一种是寻找鸿蒙专属的 sqflite 实现包,比如社区里有大佬把 sqlite 能力用鸿蒙的 Native API 重新封装了一遍,功能上可以做到与 sqflite 对齐,但这类包通常更新节奏不稳定,插件生态里的其他包如果也依赖 sqflite 的 channel 名,就可能出现通道互斥。第二种是我更推荐的方案:统一改用sqflite_common_ffi。它通过 Dart FFI 直接加载 SQLite 的动态库,不依赖 Flutter 原生 channel。这在 Linux、Windows、macOS 桌面端也适用,鸿蒙侧目前有很多团队实测是能跑的。
具体改法很简单。原来这样写:
final db = await openDatabase('fish.db', version: 1);改成:
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void main() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; runApp(const FishApp()); }注意sqfliteFfiInit()必须在runApp之前执行。初始化之后所有对openDatabase的调用都会走 FFI 分支。为了兼容 Android/iOS 上效率优先的场景,可以做一个简单的数据库工厂适配:
Future<Database> openAppDatabase() async { if (Platform.isAndroid || Platform.isIOS) { return openDatabase('fish.db', version: 1, onCreate: _onCreate); } sqfliteFfiInit(); return databaseFactoryFfi.openDatabase('fish.db', options: OpenDatabaseOptions( version: 1, onCreate: _onCreate, )); }这里我不去强调“哪个更好”,而是建议你从工程维护角度选最不容易被平台绑架的方案。鱼缸记录器这种轻量应用,数据量撑死几千条,FFI 和原生通道的读写性能差异完全感知不到,稳定性反而更重要。但凡遇到“插件不支持鸿蒙”,第一反应不应该是硬找替代插件,而是看这个插件的能力能不能下沉到纯 Dart 层。
5. 用 Provider 做状态管理:从列表页到表单页的组件通信链路
Flutter 官方推荐的入门状态管理其实是setState,但鱼缸水质记录器这种多页面应用里,如果所有状态都依赖setState一层层回调往上传,代码很快会变成一团乱麻。比如用户在表单页新增了一条水质记录,列表页的刷新动作就需要通知;首页的总览卡片要显示“最近一次 pH”,仪表盘页要拉取历史数据。这些场景跨页面、跨组件,适合引入应用级状态管理框架。
Provider 是当前生态里比较中庸稳妥的选择。它不像 Bloc 那样要求你先理解 Event、State、Stream 一整套概念,也不像 Riverpod 有较强的函数式写法要求;Provider 的核心对象就是ChangeNotifier和InheritedWidget,理解成本低,尤其适合中小型应用。
5.1 定义全局数据模型
class FishLogState extends ChangeNotifier { final DatabaseService _db; List<WaterRecord> _records = []; List<Tank> _tanks = []; bool _loading = false; FishLogState(this._db); List<WaterRecord> get records => List.unmodifiable(_records); bool get loading => _loading; Future<void> loadAll() async { _loading = true; notifyListeners(); _tanks = await _db.getTanks(); _records = await _db.getRecords(); _loading = false; notifyListeners(); } Future<void> addRecord(WaterRecord record) async { await _db.insertRecord(record); await loadAll(); } }在入口处用MultiProvider包起来:
void main() { WidgetsFlutterBinding.ensureInitialized(); final db = DatabaseService.instance; runApp( ChangeNotifierProvider( create: (_) => FishLogState(db)..loadAll(), child: const FishApp(), ), ); }5.2 表单页如何把新数据推回列表页
这里最容易写歪的地方是:新手会习惯把列表数据直接通过构造函数传给表单页,然后等表单页返回时再手动调用一次刷新。这样不是不能用,但页面一多,会出现“某个页面忘了刷新”的隐性 bug。正确姿势是让表单页只干一件事:把构建好的WaterRecord交给全局状态,由全局状态负责持久化和通知。
Future<void> _submitRecord(WaterRecord draft) async { final state = context.read<FishLogState>(); await state.addRecord(draft); if (context.mounted) { Navigator.of(context).pop(); } }列表页不需要接收任何“刷新指令”,它只需要在 build 方法里通过context.watch<FishLogState>()订阅状态:
final records = context.watch<FishLogState>().records;watch和read的区别是这里最值得强调的组件通信细节:watch会建立依赖关系,状态一变,当前 widget 就会重建;read只负责拿对象,不建立依赖。在按钮回调、事件处理这类不关心重建的场景里用read,在 build 中需要动态刷新数据的场景里用watch。如果反着来,要么页面不刷新,要么一个无关状态变化导致整页抖动。
5.3 组件内部通信的另一个高频场景
除了全局状态,同一页面内的父子组件通信也值得一提。比如仪表盘页面里,主卡片显示最近一条记录,下面列表显示近十条记录,这两者其实都来自同一个状态对象,不需要额外传参。真正需要手动处理的只有一种:某个卡片组件内部状态变化,并且要影响同层级的兄弟组件。这时可以提升状态,让父组件持有这个ChangeNotifier,用ListenableBuilder局部订阅,避免整页重建。Provider 的粒度控制在“这一个值有多少页面关心”上,不是所有状态都塞进全局对象。水质记录器里的“当前选中的鱼缸”就是个典型全局状态,“表单里 pH 输入框的焦点状态”就不是。
6. 图表可视化:把一周的水质变化画成能看懂的曲线
数据录进去了,如果只展示列表,用户看历史的意愿会大打折扣。这一章讲图表,我选了 fl_chart,它的 LineChart 组件足够支撑水质曲线,而且它基于 Canvas 绘制,不依赖原生能力,天然适配鸿蒙。
6.1 图表数据如何从 WaterRecord 映射
fl_chart 的输入是横纵坐标点列表。我们的横轴是时间戳,纵轴是具体水质参数。一个常见问题是:用户可能在某一天测量了 pH 和温度,但没测氨氮。如果直接把缺测的数据滤掉,曲线会断成一节一节;如果填零,曲线又会出现一个误导性的骤降。我对这类缺测点的处理是:跳过 null 值,但在图表底部显示“测点数量”,同时在列表里标注“测量不完整”,而不是强行让曲线连续。
List<FlSpot> _buildSpots(List<WaterRecord> records, double Function(WaterRecord) valueOf) { final spots = <FlSpot>[]; for (var record in records) { final v = valueOf(record); if (v == null) continue; spots.add(FlSpot(record.recordedAt.millisecondsSinceEpoch.toDouble(), v)); } return spots; }6.2 LineChart 的骨架配置
LineChart( LineChartData( minX: startTime.millisecondsSinceEpoch.toDouble(), maxX: endTime.millisecondsSinceEpoch.toDouble(), minY: 0, maxY: 9, lineBarsData: [ LineChartBarData( spots: phSpots, isCurved: true, color: const Color(0xFF56A0FF), barWidth: 2, dotData: const FlDotData(show: true), ), ], titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { final time = DateTime.fromMillisecondsSinceEpoch(value.toInt()); return Text('${time.month}-${time.day}'); }, ), ), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 30), ), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: const FlGridData(show: true), ), )pH 的正常范围是 0 到 14,但观赏鱼水质里常用的是 6.5 到 8.5 之间,所以我不会每次都把 Y 轴从 0 开始,更合理的做法是取当前数据的最小值和最大值,各留 10% 余量,这样曲线放大之后细节更明显。但你也要注意:如果不同鱼缸的数据混在一起展示,Y 轴动态缩放会导致用户产生数据剧变的错觉。我给每个鱼缸单独设置了一个“显示范围偏好”,存到本地设置里。
6.3 换水事件的叠加展示
水质记录器比较有意思的图表需求是“换水事件”和“水质参数”之间的关联。比如你某天换了一大缸水,pH 值短期内会有变化,用户会想看“是不是换水导致的”。fl_chart 虽然没有内置事件标注组件,但可以额外绘制一个背景区间。比如在LineChartData里定义extraLinesData,在换水的那个时间点画一条竖向虚线,再配上文字“换水”。这样比单独开一个时序表格直观得多。
这段代码的核心是维护好事件坐标,注意时区问题。记录时间和图表显示时间建议统一用本地时区,不要混用 UTC,否则凌晨记录的数据点可能会跑到“明天”的位置去。真要引入多时区支持,那是另一个数据同步项目的维度,这个 App 撑不住也没必要。
7. 鸿蒙真机构建与调试:从 HAP 出包到插件兼容性排查
写完业务层,最后落到工程构建。在 ohos 分支下,Flutter 项目打包的产物不是 APK,而是 HAP。命令大概是:
flutter build hap --release第一次跑这条命令之前,需要检查几项配置:签名、bundle 名称、权限声明。鸿蒙应用安装到真机必须签名,跟 Android 的签名机制类似,但具体流程在 DevEco Studio 里操作。如果你在命令行直接 build,默认用的可能是 debug 签名,只能装到允许调试的设备上;要上架或者给朋友装,需要配置正式的签名文件。
7.1 真机调试的链路
连接鸿蒙真机之后,先用hdc list targets确认设备被识别。如果这个命令找不到设备,检查两个地方:是否开启了开发者模式,以及 USB 调试授权弹窗是否点了允许。很多新手折腾半天,最后发现是手机上的“仅充电”模式没切到“传输文件”。
确认设备连接后,可以直接通过 DevEco Studio 的 Run 按钮跑起 HAP。启动过程比 Android 要多一步:鸿蒙设备上的Module概念等同于 Android 的Application模块。debug 模式下,热重载能力依然可用,但偶尔会遇到“热重载后页面状态丢失”的情况,这是因为鸿蒙侧的引擎生命周期和 Flutter 引擎重启策略不完全一致。遇到这个现象,习惯就好,直接 R 重启应用比纠结热重载失败更省时间。
7.2 插件兼容性排查清单
对于从 Android 迁移过来的 Flutter 项目,依赖库是否能在鸿蒙上跑,是最耗时间的一环。我这边的实务经验是:凡是纯 Dart 实现的包,基本不需要担心;凡是依赖原生 channel 的包,就得逐个过一遍鸿蒙的插件封装情况。
| 依赖包 | 用途 | 鸿蒙适配情况 |
|---|---|---|
| provider | 状态管理 | 纯 Dart,直接可用 |
| fl_chart | 图表 | 纯 Dart Canvas 绘制,直接可用 |
| sqflite | 本地数据库 | 原生 channel 不可用,改用 sqflite_common_ffi |
| path_provider | 获取路径 | 需要用支持鸿蒙的版本或改由 dart:io 兜底 |
| shared_preferences | 轻量存储 | 鸿蒙社区有适配版,建议确认来源 |
| image_picker | 拍照/选图 | 取决于鸿蒙插件实现,暂无则先用 file_picker 平替 |
这里格外提一下 shared_preferences。表面看它只是读写个偏好设置,但底层实现同样依赖原生接口。Continuous 的适配版本存在,但要注意不同作者维护的包在初始化方式上可能有差异。最稳妥的办法是:如果业务里只是存少量键值,比如鱼缸名称、图表范围偏好,干脆自己用dart:io写一个 JSON 文件存到应用目录里,能少一个依赖就少一个风险。
7.3 网络权限和蓝牙权限的声明位置
鸿蒙的权限声明不在 Flutter 工程的AndroidManifest.xml里,而在ohos/entry/src/main/module.json5中。以下是常见权限的写法:
{ module: { requestPermissions: [ { name: "ohos.permission.INTERNET" }, { name: "ohos.permission.BLUETOOTH" }, { name: "ohos.permission.ACCESS_BLUETOOTH" } ] } }注意,如果你只是把水质数据本地记录,不需要联网权限,那这一项就不要加。权限这种东西,不加是最省事的,一旦加了,审核和应用自查的时候就得说明为什么需要。很多 flutter 新手有个毛病,就是项目跑通了,但权限声明得比功能还要多,真到上架阶段再一个个删,成本反而更高。
关于构建的另一个容易漏的细节是 resize 资源。Flutter 默认有若干启动图资源,鸿蒙工程的启动图标路径在ohos/entry/src/main/resources/base/media/下,如果你发现安装到真机上的 App 图标是默认 Flutter 图标,就去这个目录检查是否替换成功。相比之下,Android 和 iOS 的图标尺寸多且杂,鸿蒙在这边的目录结构要简单得多。
8. 我实测中遇到的编译报错与解决方案(可直接抄)
构建过程中总会撞上一些报错,信息量少,搜索引擎也未必能查得到。我把这次项目中遇到的三个高频问题贴出来,希望你能少走弯路。
8.1 “You are applying Flutter's main Gradle plugin imperatively”
这个报错在官方 Flutter 新版本里也出现过,本质是 Gradle 插件应用方式变更。落到鸿蒙场景,通常是 3.7 以上的 Flutter 版本与旧工程里残留的android/settings.gradle写法不兼容。解决办法很简单:找到工程根目录下的android/settings.gradle,检查里面是否还有旧的插件配置,把它改成新版推荐的plugins { id "com.android.application" version ... }这种声明式写法。如果报错发生在 ohos 目录的 gradle 文件里,也一样,先检查是否使用了老式的apply plugin:。
8.2 找不到 ohos SDK 的路径
Flutter 构建 HAP 时,需要读取 HarmonyOS SDK 路径。报错信息一般是“SDK location not found”。这个问题的排查顺序是:先确认 DevEco Studio 有没有下载 HarmonyOS SDK 组件,再确认环境变量里有没有配置DEVECO_SDK_HOME,或者在local.properties文件里写清楚sdk.dir。命令行构建时最容易缺这一步,因为 IDE 自己知道 SDK 在哪,但命令行进程没有上下文。建议在项目里创建一个local.properties,内容类似:
sdk.dir=D:\\Huawei\\DevEcoStudio\\sdk注意路径里的反斜杠转义。这个文件不要提交到版本库,因为每台电脑的 SDK 路径都不一样。我一开始吃了这个亏,换电脑之后构建直接翻车,后来养成了第一时间补local.properties的习惯。
8.3 热重载后白色页面,日志里出现 Engine 崩溃
这个在鸿蒙 debug 模式偶尔出现。原因通常是 JIT 模式下的引擎初始化问题,尤其是页面里有复杂图片资源时。我的临时解决办法是:先用flutter run跑起来第一轮,确认页面能正常渲染,再在 IDE 里做增量热重载。如果热重载多次后画面异常,直接按 R 重启。发布 release 版本不受此问题影响,所以不影响上线。
9. 把这个项目收尾前,我还想说两句
鱼缸水质记录器这个项目本身不大,但它串起了一条很完整的 Flutter 跨平台链路:从模型设计、状态管理、本地存储到图表可视化,最后落到鸿蒙真机出包。做完它之后,你再去接其他跨平台项目,心里会有一条更清晰的判断标准:哪些功能可以放心交给纯 Dart 生态,哪些需要提前查平台的插件适配情况。
我在实际开发中比较深的体会是,跨平台适配的“坑”七成不在 UI 层,而在插件层。UI 组件有 Flutter 引擎兜底,Canvas 绘制一遍,哪里都一致;存储、相机、定位这些原生能力,才是真正考验团队排查能力的地方。所以如果你现在正要开始自己的鸿蒙 + Flutter 项目,我建议先把数据层和存储层跑通,再去做那些花里胡哨的动画和交互。
最后分享一个测试技巧:给鱼缸水质记录器加入不同鱼缸的并行测试数据,比如一口草缸和一口裸缸,每天用不同的 pH 和温度范围记录。多做几组数据之后,你会发现列表排序、图表区间、统计平均值这些逻辑都会暴露出不少边界问题。趁项目小的时候把这些边界修干净,比以后数据量大了再迁移要舒服得多。