最近我在折腾一个小项目:用 Flutter 写一个篆刻石料记录应用,目标是一套代码同时跑在 Android、iOS 和鸿蒙设备上。项目本身不算大,但价值在于这是我第一次把 Flutter 的跨平台能力延伸到鸿蒙系统上走完整条链路。如果你也在关注 Flutter 跨平台开发、鸿蒙应用开发,或者只是想找一个“不算复杂但足够真实”的练手项目,这篇教程应该对你有用。
先说说这个应用解决什么问题。篆刻圈子里有一个特别实际的痛点:石料一多就乱。什么品种、什么尺寸、从哪里买的、刻到哪一步了、有没有边款稿,全靠脑子记真的不靠谱。我见过有朋友用 Excel 记账本式的表格管理石料,也有直接在手机备忘录里贴照片的,但都不够顺手。这个应用就是把每一块石料变成一条结构化记录,带照片、带标签、带搜索,打开手机就能查“我那块 2.5 厘米的巴林冻石到底收在哪了”。
选 Flutter 来做这件事,理由很直接:我不需要为 Android 和鸿蒙各写一套 UI,Dart 写一遍,两边都能编译运行。如果你正好在纠结怎么入门鸿蒙开发,又放不下已有 Flutter 技术栈,这篇文章应该能给你一个比较完整的参考答案。下面我从需求拆解讲到数据建模,再到鸿蒙适配和代码实操,最后把踩过的坑整理成清单,你不妨照着走一遍。
1. 先聊清楚:这个应用到底要做什么
1.1 篆刻石料记录的真实使用场景
篆刻用的石料,常见的有青田、寿山、巴林、昌化这几大类,每一个大类下面还能细分出很多小品种。价格差异极大,从几十块钱的练习石到几千上万的冻石都有。尺寸从最小的 1.5 厘米印章料,到五六厘米的闲章料,甚至还有不规则的自然形。这些信息对一个玩篆刻的人来说,每一笔都是值得记的。
但“记录”这件事,难点从来不是记下来,而是以后怎么找出来。比如你某天在店里看到一块很喜欢的老挝石,想对比一下家里有没有同尺寸同品类的,如果全靠翻相册、翻聊天记录,那就太痛苦了。所以这个应用的核心逻辑有三条:录入要快、信息要全、检索要准。我甚至把常用的尺寸和品种做成了下拉选项,就是为了减少打字。
这个场景其实和“收藏品管理”或者“仓库台账”非常像,数据模型的设计思路是可以直接复用的。你做别的工具类应用,比如胶片相机镜头管理、酒柜存酒记录,这套结构基本上换皮就能用。
1.2 技术选型:为什么是 Flutter,以及鸿蒙的适配现状
先说 Flutter 本身。Flutter 和 React Native、Weex 这类框架最大的区别在于 UI 不是调用系统控件,而是自己用 Skia 或者 Impeller 引擎直接绘制。这意味着同一套代码在不同系统上渲染出来的效果几乎一模一样,跨平台表现非常稳定。对工具类应用来说,这种稳定比“像素级还原设计稿”更重要。
再说鸿蒙。OpenHarmony 和华为 HarmonyOS NEXT 的生态还在高速迭代期,原生开发推荐的是 ArkTS 语言和 ArkUI 框架,开发工具是 DevEco Studio。如果你是从零开始学鸿蒙原生,也不是不行,但如果你已经积累了不少 Flutter 代码,直接用 Flutter 适配层往鸿蒙上搬,投入产出比明显更高。目前社区已经有比较成熟的 Flutter 鸿蒙适配仓库,原理上就是把 Flutter 引擎编译产物打包进鸿蒙的 HAR 包里,再在 Ability 的界面容器里挂载 Flutter 视图。网上搜“开源鸿蒙 flutter”能看到不少相关讨论。
我还注意到最近 Electron 移植鸿蒙、Tauri 2 跑鸿蒙这类话题也很热。Electron 和 Tauri 本质是 WebView 方案,把网页应用套个壳跑在鸿蒙上,开发成本低,但性能和系统能力调用的深度都有限。Flutter 是编译成原生代码运行,引擎自绘 UI,在交互流畅度和原生能力对接上都要强一截。我做这个石料记录应用选 Flutter,就是看中它能平衡开发效率和最终体验。
1.3 功能清单:第一版只做四件事
任何一个项目上来就想做成“完美术管理软件”,基本都会烂尾。我给自己划的第一版功能非常克制,只有四块:
- 石料列表:按最近更新时间倒序,卡片式展示缩略图、品种、尺寸和状态。
- 石料详情与编辑:查看完整信息,支持修改、补图、删记录。
- 图片采集:调用系统相机拍照,或者从相册选择,支持压缩处理。
- 搜索与筛选:按关键词模糊搜,按状态和品种过滤。
第二版我预留了两个方向:导出 JSON/CSV 备份,以及边款图、印面布局图的单独管理。第三版想加一个简单的刻制定期提醒,比如“这块石头放太久了,拿出来练练手”。版本规划的意义在于,你永远可以用最小的成本验证核心流程有没有问题,后面再慢慢加东西。
2. 数据模型与本地存储设计
2.1 石料信息表应该怎么建
这个应用的数据模型是整个项目的基石。我建了一张stones表,字段设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键 |
| name | TEXT | 自定义名称,比如“巴林冻石-朱文印” |
| variety | TEXT | 品种,青田、寿山、老挝等 |
| origin | TEXT | 产地或来源店铺 |
| length / width / height | REAL | 尺寸,单位 cm |
| weight | REAL | 重量,单位 g,允许为空 |
| status | INTEGER | 状态,0未刻 1设计稿 2刻制中 3完成 |
| purchase_date | TEXT | 购入日期,ISO 格式 |
| purchase_price | REAL | 购入价格,可为空 |
| tags | TEXT | 逗号分隔的标签,比如“练手石,送礼” |
| notes | TEXT | 备注,可写边款内容、灵感记录 |
| image_path | TEXT | 封面图绝对路径 |
| created_at / updated_at | TEXT | 时间戳 |
有几个字段值得特别注意。status我用整数而不是字符串,是因为状态是要做筛选和统计的,整数映射的枚举在代码里更好维护。tags字段虽然违反了“一张表一个字段存一个值”的规范,但对移动端轻量场景来说,逗号分隔存储的查询压力可以接受,而且实现最简单。如果你以后要做标签云或者多标签组合筛选,再拆一张关联表也不迟。purchase_date我建议不存时间戳格式,直接存 ISO 字符串更直观,SQLite 的字符串排序对 ISO 日期天然友好。
2.2 为什么用 SQLite 而不是直接写 JSON 文件
有的朋友可能会问,就记录石料这么点数据,直接写 JSON 文件不就好了吗?一开始我也这么想,但实际做下去发现查询场景太复杂了。你要按品种过滤,又要按尺寸区间过滤,还要按状态统计,纯手动操作 JSON 数组写这套过滤逻辑,代码又臭又长。SQLite 一条SELECT * FROM stones WHERE status = 0 AND width >= 2.0 AND width <= 3.0就搞定了。
移动端用 SQLite 是再常规不过的选择。Flutter 生态里最常用的就是sqflite插件,API 封装得比较简单,支持事务,支持原生 SQL。我的做法是在应用启动时初始化数据库,建表,然后封装一个StoneRepository类,所有对stones表增删改查的操作都走这个类。这样页面层不直接碰 SQL,以后换数据库或者加字段,页面代码基本不用改。
调试的时候我强烈推荐一个桌面工具:DB Browser for SQLite,也就是网上常说的 db4s。它是个开源跨平台的 SQLite 数据库管理工具,直接打开模拟器导出的.db文件,就能看表结构、跑 SQL、改数据。我排查过好几次“为什么列表页少显示一条记录”的问题,都是在 db4s 里跑了一遍 SQL 才发现是筛选条件写错了,而不是代码 bug。
2.3 图片存储策略:存路径,别存 BLOB
图片存储是我特别想强调的一点。很多人第一次做带图应用,顺手就把图片二进制塞进数据库 BLOB 字段。这在小数据量时没问题,但图片一多,数据库体积会迅速膨胀,备份、同步、迁移全都变慢。正确做法是图片文件存磁盘,数据库里只存一个绝对路径。
我的目录结构是这样规划的:/storage/emulated/0/Android/data/包名/files/seal_stones/下面按石料 ID 建子目录,比如seal_stones/12/cover.jpg、seal_stones/12/extra_1.jpg。这样备份的时候只需要拷整个seal_stones目录,非常干净。
还有一个容易被新手忽略的问题:图片压缩。手机拍一张照片原图动辄 3 到 5 兆,直接存进去,以后导出备份就是一场灾难。我录入图片的时候会用flutter_image_compress压缩一遍,长边控制在 1920 像素,质量参数选 85,肉眼几乎看不出差别,体积能压到原图的五分之一甚至更小。
3. 鸿蒙平台适配:从工程搭建到渲染引擎
3.1 开发环境准备与工程目录结构
如果你想自己动手跑一遍,环境准备是第一步。Flutter 这边就按常规来,用 Android Studio 的 New Project 向导创建 Flutter 项目,这一步网上搜“如何 as 创建 flutter 项目”有大量教程,不再赘述。关键点是项目创建之后,你会在根目录看到android/、ios/、lib/这几个标准目录。鸿蒙适配要做的事情,是在这个工程里再增加一个鸿蒙原生工程目录。
鸿蒙侧需要准备的开发环境是 DevEco Studio 和 OpenHarmony SDK。Flutter 跑鸿蒙的基本思路是:Flutter 引擎作为依赖库打进鸿蒙工程,鸿蒙的 Ability 作为宿主容器,在页面上挂一个 Flutter 渲染视图。整个鸿蒙工程可以理解成一个壳工程,它负责把 Flutter 引擎拉起来,UI 全部由 Flutter 侧渲染。
我踩过的第一个坑就是版本对应关系。Flutter SDK 版本和鸿蒙适配仓库的版本必须严格对应,不能想当然拿最新的 Flutter 稳定版去配一个几个月前的适配层,编译报错会让你怀疑人生。建议直接看适配仓库的 README,它通常会明确写“本适配版本对应 Flutter x.y.z”。这个环节没有任何捷径,老老实实按文档锁版本就对了。
3.2 理解混合工程集成:从“flutter aar”说起
网上关于“flutter aar”的讨论很多,这个问题的本质是把 Flutter 模块打包成 Android 的 AAR 库,再混入原生工程。鸿蒙侧做的其实是同一件事,只是产物从 AAR 变成了鸿蒙的 HAR 或者动态库。
我第一次理解这个概念的时候,把 Flutter 想象成一台游戏机,鸿蒙的 Ability 是电源插座,Flutter 引擎是游戏机本身,Dart 代码是插进去的游戏卡带。主机(鸿蒙应用)还是你自己的,但插上卡带之后,画面和玩法都是 Flutter 说了算。
混合工程的集成顺序我强烈建议这样来:先跑通一个最简单的 hello world,Ability 创建时就拉 FlutterView,Flutter 侧显示一屏文字,确认双向链路正常。然后第二步再测试 MethodChannel,原生和 Flutter 互相调一个方法。最后第三步才往里面加真正的业务页面。一上来就接 PlatformView、接复杂插件,出了问题你根本不知道是引擎问题还是代码问题。
3.3 PlatformView 与原生能力桥接:鸿蒙适配的重头戏
石料记录应用要用到相机和相册,这是系统级能力。问题在于,Flutter 社区里成熟的插件比如image_picker,可能没有现成的鸿蒙平台实现。这时候你有两条路可走:要么用 MethodChannel 自己写桥接,调到鸿蒙原生 API;要么参考插件的 platform interface 机制,为鸿蒙平台补一个实现类。
我实际做的时候两条路都走了。image_picker这种成熟插件先查它有没有鸿蒙实现,没有就用自己的 MethodChannel。这里有一个非常关键的坑点:PlatformView 在鸿蒙适配层的创建时机。Flutter 侧创建原生视图的请求发过去之后,鸿蒙侧如果没在正确的生命周期节点上插入原生视图,页面切换的时候就会出现黑块或者闪烁。我最后用“延迟加载 + 视图容器复用”解决了这个问题,页面切回来的时候不会重新创建整个原生视图,只更新内容数据。
“flutter platformview”这个关键词搜索热度一直很高,说明大家确实都在跨端开发里被原生视图折磨过。我的体会是,不要试图在 PlatformView 里塞太多交互控件,能用 Flutter 自己绘制的部分就自己绘制,双方交互越少越好,这是一个工程折衷。
3.4 Impeller 渲染引擎:鸿蒙上的潜在性能红利
Flutter 3.10 之后,iOS 平台默认启用 Impeller 渲染引擎,Android 也在逐步切换。它解决的是 Skia 在部分设备上着色器编译导致的首帧掉帧、卡顿问题。对 Flutter 鸿蒙适配来说,Impeller 的意义在于,如果适配版本支持 Impeller,那么鸿蒙设备上的滚动流畅度、动画顺滑度都会有明显提升;如果不支持,运行期碰到复杂动画时可能触发一次性着色器编译,画面会卡一下。
石料列表页有大量图片和卡片,滚动非常频繁。我在鸿蒙适配版本上专门验证过 Impeller 的开关情况。没有 Impeller 的时候,快速滚动列表偶尔会有几帧掉帧,开启之后明显感觉丝滑了。如果你在做别的动画场景更复杂的 Flutter 鸿蒙应用,一定要去确认适配层是否支持 Impeller,并在初始化时显式配置,不要靠默认值。
4. 核心功能实现与组件通信
4.1 页面结构与底部导航设计
这个应用的主界面用三个 Tab 页组成:石料列表、筛选搜索、个人设置。放在 Flutter 里,最直接的做法就是用BottomNavigationBar加索引切换页面,一套代码在 Android 和鸿蒙上效果一样好。热搜词里“鸿蒙应用开发底部导航栏”的搜索量很高,但如果你用的是 Flutter,这个需求反而是最省心的,因为 Flutter 已经把底部导航做成了最基础的组件,你不需要关心鸿蒙的 ArkUI 怎么实现。
三个页面的职责划分要清晰。列表页负责展示和跳转,筛选搜索页负责构建查询条件和展示结果,设置页负责备份、恢复、版本信息等低频操作。我见过不少新手把筛选功能塞进列表页的顶部,结果一个页面越写越长,最后自己也维护不动了。独立页面还有一个好处:搜索页的搜索框可以获得独立的焦点管理,不会跟列表页的滚动手势抢事件。
4.2 组件通信:从“人传人”到统一状态管理
“flutter组件通信”是高频搜索词,说明这是很多初学者跨不过去的坎。组件通信本质上就三件事:父传子、子传父、跨层传。石料记录应用里这三个场景全都涉及。
父传子最简单,构造函数直接传参。子传父稍微绕一点,需要把回调函数作为参数传下去,比如列表项组件点击时回调onTap(int stoneId)。真正麻烦的是跨层通信,比如编辑页保存了一块石料,列表页需要刷新,设置页里的统计数字也要跟着变,这时候一层层回调就会把代码搞得像意大利面条。
我最后选用的是 Provider + ChangeNotifier 这套方案。石料仓库类继承ChangeNotifier,暴露addStone、updateStone、deleteStone等方法,方法内部操作数据库之后调用notifyListeners()。列表页监听这个类,数据一变它就重建,完美解决跨页面刷新问题。这个方案的优点是理解成本低,没有事件总线的“幽灵消息”问题,调试时可预测性非常强。
顺手回答一个搜索热词里很多人困惑的问题:“flutter future的then回调是放入微任务队列吗”。答案是:Dart 的 Future 回调确实会进入微任务队列(Microtask queue),在当前同步代码执行完之后、下一个事件之前执行。这直接决定了then回调的时序。实际编码中你会在编辑页await保存操作,然后Provider.of去取最新数据,注意到 UI 不会立刻刷新,因为notifyListeners()触发的重建是放到微任务里跑的,页面会在当前帧结束后统一刷新。理解这一点对排查“为什么数据改了界面不变”特别有帮助。
4.3 图片选择、压缩与 EXIF 旋转问题
图片选择用image_picker插件,Android 上调用系统相机和相册是常规操作。鸿蒙平台上我在适配层补了一个 MethodChannel 实现,选择完图片之后返回本地文件路径。这里的接缝点在于:Flutter 层拿到的路径是鸿蒙侧传过来的,格式和 Android 原生的content://或者/storage/emulated/0/路径不同,需要统一转换成绝对路径再使用。
压缩代码核心就几行:
final compressed = await FlutterImageCompress.compressWithImage( File(imagePath).readAsBytesSync(), quality: 85, minWidth: 1920, format: CompressFormat.jpeg, );但这里有一个特别隐蔽的坑:EXIF 旋转信息。手机拍竖图,很多相机会把旋转信息写进 EXIF,图片本身的像素是横着的。如果压缩库重新编码 JPEG 时丢掉了 EXIF 旋转信息,图片就会显示成横着的。这不是 Shutter 级的大问题,但对用户来说非常碍眼。解决办法是先读取原图的 EXIF 旋转角度,如果为 90 或 270,就先用图片编码库旋转回来再压缩。我的处理函数里这段逻辑大概占了二十行,但避免了所有竖拍图片横躺的尴尬。
4.4 搜索与筛选:SQL 动态拼接的技巧
搜索的核心是一句 SQL,但条件需要动态拼接。用户可能只输关键词,也可能关键词加状态再加品种,全选。最稳妥的做法是用 Map 构建查询条件,避免手动拼字符串导致 SQL 注入。
Future<List<Stone>> search({String keyword = '', int? status, String? variety}) async { final where = <String>[]; final args = <Object?>[]; if (keyword.isNotEmpty) { where.add('(name LIKE ? OR variety LIKE ? OR tags LIKE ?)'); final kw = '%$keyword%'; args.addAll([kw, kw, kw]); } if (status != null) { where.add('status = ?'); args.add(status); } if (variety != null && variety.isNotEmpty) { where.add('variety = ?'); args.add(variety); } final sql = 'SELECT * FROM stones WHERE ${where.join(' AND ')} ORDER BY updated_at DESC'; return db.query('stones', where: where.join(' AND '), whereArgs: args); }搜索结果的即时刷新有一点要注意:TextField 的onChanged回调触发频率非常高,每次输入都会触发 SQL 查询。我给搜索请求加了一个 300 毫秒的防抖,也就是说用户在停手 300 毫秒之后才会真正发起查询。这个体验上的细节,比很多人想象的更重要,实际用下来输入过程非常跟手。
5. 实操问题排查与避坑清单
5.1 flutter 新建项目后跑不起来,先别急着怪代码
搜索热词里有一条特别扎心:“flutter新建项目后 跑不起来”。这个问题的概率远比你想象的高,但你遇到的 90% 情况其实和业务代码无关。我的排查路径是固定的:先跑flutter doctor看环境状态;再看 Gradle 下载是否失败,因为国内网络环境下 Gradle 发行版经常下不动;最后确认 Android SDK 的 Build-Tools 版本是不是被 Android Studio 更新搞丢了。
另外一个高频报错是e/flutter ... dart_vm_initializer.cc unhandled,报错信息指向 Dart VM 初始化器。这个错误通常是设备或模拟器的 CPU 架构和 Flutter 引擎产物不匹配,或者引擎启动参数有问题,跟你的 Dart 代码没什么关系。遇到这附近的问题,先换一个模拟器试试,再看适配版本的构建配置,不要一头扎进业务代码里找原因。
5.2You are applying Flutter's main Gradle plugin imperatively报错
这条报错的完整提示是“You are applying Flutter’s main Gradle plugin imperatively using the apply method”。新版本 Flutter 已经把 Gradle 插件改成声明式应用方式,但老项目模板或者网上拷贝的旧配置还会用apply plugin:这种命令式写法,两者一冲突就报错。修复方式是把android/settings.gradle里的插件管理方式改成 plugin DSL,或者直接把项目模板升级到新版。
我给你的建议是:遇到 Gradle 相关报错,第一反应不要抄网上两年前的老代码,而是打开 Flutter 版本对应的 changelog,看看这个版本的构建系统发生了什么变化。你在这上面花十分钟看的文档,可能比搜两小时解决方案更能解决问题。
5.3 新增石料后列表不刷新:微任务队列的陷阱
这是一个实际发生过的 bug。我在编辑页保存石料成功后,调用Navigator.pop返回列表页,但列表没有刷新。排查之后发现,问题出在保存操作的时序上。save方法内部先 await 了数据库插入,然后notifyListeners(),再返回。但调用方在 await 之后立刻pop,页面的build方法跑到一半,Provider 的监听还没来得及处理,视觉上就是“保存了但没刷新”。
解决办法是把状态变更和 UI 导航分离。保存数据之后不要立刻pop,而是先等 Provider 的notifyListeners()执行完帧回调,再执行导航。实操中我用了一个最简单有效的方式:在编辑页的按钮回调里先await addStone(),然后在下一个微任务里执行pop。理解了 Dart 的事件循环,你就能解决这一类看着很奇怪的状态问题。
5.4 数据迁移与备份:不要删库重来
版本迭代必然涉及表结构变更,比如第二版我想给石料表增加一个“耗材成本”字段。sqflite 的onUpgrade回调就是干这件事的,它会携带oldVersion和newVersion,你在这个回调里执行字段增量变更,保留已有数据。新手最容易踩的坑是图省事,直接 DROP TABLE 再重建,用户的存量数据连个招呼都不打就没了,这种体验基本等于劝退。
备份功能还有一个平台差异需要考虑:Android 11 之后,应用外部文件的路径访问权限收紧了很多。网上大量老教程里写的“直接往 /sdcard/ 下写文件”现在已经不可行。我的备份方案是把数据库文件先复制到应用的外部私有目录,然后调用系统分享组件让用户选择保存位置,比硬编码一个绝对路径要稳妥得多,也避免了沙箱权限踩雷。
整个项目做下来,我最大的体会是:Flutter 跨鸿蒙这条路已经可以走了,但你还得提前做好心理建设——适配层时不时会给你挖坑,社区资料也比 Android 那边少。只要主线思路清晰,数据模型先想透,再一步步把功能填进去,这套方案就能真正变成你自己的跨平台产品能力。遇到 PlatformView 闪烁、Gradle 报错、图片旋转这类问题,回来翻翻这篇教程里的排查思路,应该能帮你省下几个晚上的时间。