前阵子朋友问我:你天天喷香水、点香薰,有认真记录过自己每天闻到什么味道吗?我当时一愣。后来刷到一个 idea,叫气味日记——把一天里闻到的气味记下来,连同当时的心情、天气、地点一起存着,隔一阵翻出来,其实就是一份带着嗅觉的回忆账本。市面上的笔记类应用要么太泛,没有气味维度,要么是垂直香评社区产物,不适合日常随手记录。我决定自己做一个轻量 App。
做这个项目的技术选型一开始就很明确:Flutter。原因很简单——我需要同时覆盖 Android、iOS 和鸿蒙,个人开发者没有精力维护三套原生代码。Flutter 的跨平台能力在 UI 一致性上比 RN 和 uni-app 更稳,而随着鸿蒙生态逐步开放,Flutter 对鸿蒙的适配也进入了可用状态。接下来的内容就是我用 Flutter 框架开发"气味日记"这款跨平台鸿蒙应用的完整记录,包括环境搭建、数据层设计、核心页面、鸿蒙适配的坑,以及我在性能优化上实际做过的事,希望对打算做鸿蒙跨平台小项目的人有点参考价值。
1. 从"记录味道"这个小需求聊起:为什么敢用 Flutter 碰鸿蒙
1.1 气味日记的核心需求拆解
一个日记类产品,最难的不是功能多,而是"记录动作足够轻"。我给气味日记划定的核心场景是这样:早上出门前喷了哪支香水,午休时楼下咖啡店飘出来的豆香,晚上回家点了什么味道的香薰蜡烛,或者一阵雨后空气里的泥土味。用户只需花十几秒,把气味名称、类别、当下的情绪、强度、位置和一张照片存下来,之后可以按日期回看,也可以看统计图表了解自己最近被哪些味道围绕、心情如何波动。
功能清单我控制在六项以内:
- 新建气味记录:名称、类别、气味强度、情绪分、位置、照片、备注、天气。
- 日历回顾:在日历上标记有记录的日期,点开某天查看当天的气味时间线。
- 统计图表:气味类别分布柱状图、情绪走势折线图。
- 标签搜索:按类别和自定义标签过滤历史记录。
- 图片管理:一张或多张照片,自动生成缩略图,控制内存占用。
- 数据备份:把数据库文件导出来,避免换手机时记录丢失。
这个范围对一个个人项目来说刚好。功能再多,比如加入社交、气味社区,开发和维护成本会成倍上涨,而且很容易偏离"记录"这个初衷。
1.2 技术选型:Flutter 对比 ArkTS 原生、uni-app、React Native
做鸿蒙应用,摆在面前的路其实有几条:纯鸿蒙 ArkTS 原生、uni-app、React Native、Flutter。我把它们从几个关键维度做了对比:
| 维度 | Flutter | ArkTS 原生 | uni-app | React Native |
|---|---|---|---|---|
| 一套代码覆盖 iOS | 支持 | 不支持 | 支持 | 支持 |
| 一套代码覆盖鸿蒙 | 需适配分支 | 原生支持 | 社区方案 | 社区方案 |
| UI 跨端一致性 | 高(自绘引擎) | 单端最佳 | 中 | 中 |
| 第三方库生态 | 丰富 | 一般 | 中 | 丰富 |
| 个人开发上手成本 | 中 | 中偏高 | 低 | 中 |
| 鸿蒙适配成熟度 | 可用但需调试 | 最稳 | 一般 | 一般 |
我最后选 Flutter,核心理由是 UI 一致性和生态。Flutter 用自绘引擎渲染,同样的组件在 Android、iOS、鸿蒙上几乎长一个样,这对日记类应用很重要——用户要的是"每天都在用的那个界面",而不是换一个系统就换一种操作肌肉记忆。再加上 Flutter 的第三方包数量庞大,很多通用能力(日历、图表、图片选择)都有成熟方案,个人开发不用从零造轮子。
ArkTS 原生当然在鸿蒙上体验最好,开发工具链也最完善,但它意味着我要为 Android 和 iOS 再单独开发一遍。uni-app 上手最快,但遇到复杂交互和动画时,渲染层限制会让人很难受。综合考虑下来,Flutter 是这个项目最稳的中间路线。
1.3 这个项目更适合谁参考
这篇文章主要适合三类读者:已经有一定 Flutter 基础、想试试鸿蒙开发的人;手里有 Android/iOS 老项目、考虑要不要往鸿蒙迁移的开发者;以及想做跨平台小工具但被环境配置劝退的新手。如果你对 Flutter 完全陌生,建议先跑通一个官方 counter demo 再回来看本文,否则环境部分的报错会显得有点跳跃。
2. 环境搭建实录:FVM 多版本管理、鸿蒙 SDK 对接与编译器选择
2.1 FVM:多版本 Flutter 的刚需
鸿蒙适配是我在这个项目里遇到的第一道门槛。OpenHarmony 社区维护的 Flutter 适配分支,和官方 Flutter 的版本更新并不同步,通常落后一两个大版本。如果机器上只装一个全局 Flutter,切来切去很容易把 Android 项目也搞坏。所以第一步就是用 FVM(Flutter Version Management)做多版本隔离。
安装 FVM 很简单,macOS 上可以直接用 Homebrew:
brew install fvm fvm release # 查看所有可安装的版本 fvm install 3.16.0 # 安装稳定版然后在项目根目录固定版本:
fvm use 3.16.0 --local这条命令会在项目下生成.fvmrc和.fvm目录,团队协作时其他人 clone 项目后执行fvm use就能切到同一个版本,避免"我本地能跑,你本地报错"的版本不一致问题。
我在这步踩过的坑是:早期图省事,直接把全局 Flutter 换成鸿蒙适配分支,结果 Android 编译时遇到一堆版本不兼容的报错,最后只能又重新装回官方稳定版。建议从一开始就坚持fvm flutter xxx的方式执行命令,别用全局 flutter。顺便说一句,网上已经有人在讨论 Flutter 3.44 的新特性,但鸿蒙适配分支往往不会第一时间跟进,检查版本时一定要看适配分支自己的 release 说明,而不是盯着官方最新版。
2.2 鸿蒙侧 SDK 与工具链
Flutter 要构建鸿蒙应用,光有 Flutter SDK 不够,还需要完整安装 DevEco Studio。DevEco Studio 里集成了鸿蒙 SDK 和模拟器管理,首次启动时会引导你下载 HarmonyOS SDK,这个过程比较大,需要耐心等。装完之后要额外配置的是命令行的 SDK 路径,确保构建脚本能找到hdc、ohos这些工具。
字段位置一般在环境变量里,例如:
export DEVECO_SDK_HOME=/path/to/DevEcoStudio/sdk export PATH=$PATH:$DEVECO_SDK_HOME/toolchains配置完可以用hdc list targets验证设备连接是否正常。个人经验是,鸿蒙 SDK 和 Flutter 的配合还远不如 Android SDK 那样开箱即用,很多问题出在"Flutter 不知道去哪找鸿蒙工具链",所以环境变量写清楚、写单一,比装很多辅助工具更重要。
2.3 编译器怎么选:VS Code、Android Studio 还是 DevEco Studio
很多新手在这个阶段会纠结用哪个 IDE。我的实际组合是:日常写 Dart 代码用 VS Code,跑鸿蒙构建和看设备日志用 DevEco Studio,Android 验证偶尔开一下 Android Studio。
VS Code 胜在轻量和插件生态好,装 Dart 和 Flutter 两个插件后,代码补全、热重载、调试都够用。但网上很多人反馈在 VS Code 里新建 Flutter Android 项目时遇到unable to find suitable visual studio toolc这类报错,这个报错本质是 Flutter 在检测 Windows 桌面工具链时找不到合适的 Visual Studio C++ 工具链,即便你只是打开 Android 项目,也会因为环境变量不干净被误伤。我的建议是:如果你不做 Flutter Windows 桌面版,检查系统里是否装了 Visual Studio Build Tools,没有就装一个 C++ 桌面开发组件,或者在环境变量里移除那些指向不完整工具链的路径。这个问题和 VS Code 本身关系不大,完全是 toolchain 检测的锅。
DevEco Studio 则承担鸿蒙工程的入口角色。鸿蒙侧的 hap 打包、签名、真机部署都从它里面触发,Flutter 模块和鸿蒙原生壳工程的对接也在这里配置。所以三个 IDE 的关系不是"选一个",而是"配合用"。
2.4 一个新手必看的 Gradle 报错
环境搭建阶段我还遇到一个特别典型的报错,完整文案是:
You are applying Flutter's main Gradle plugin imperatively using the apply script method, which is no longer supported...这个报错不是鸿蒙特有,而是新版 Flutter 改变了 Gradle 插件声明方式。旧模板在 app 模块的build.gradle里用apply脚本方式引入 Flutter 插件,新版本要求改成在settings.gradle里用 declarative 的方式声明插件。遇到它时不用慌,看报错后面的提示,通常它会直接告诉你去用settings.gradle.kts的plugins块。把这部分模板对齐后重新构建就能解决。排查时我的经验是:不要手动乱删代码,先找一个同版本 Flutter 新建的 demo 工程,对比它生成的两个 build 文件差异,照着改最安全。
3. 数据层设计:气味标签体系、情绪维度和本地存储怎么落
3.1 数据模型:一条气味记录包含什么
气味日记的数据模型我设计得比较克制,单表存储,字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | UUID,主键 |
| scentName | String | 气味名称,必填 |
| category | String | 类别:香氛/饮食/自然/居家/其他 |
| tags | String | 自定义标签,逗号分隔 |
| moodScore | int | 情绪分,1-5 |
| moodEmotion | String | 情绪关键词,如"平静""怀旧" |
| intensity | int | 气味强度,1-5 |
| location | String | 地点 |
| weather | String | 天气 |
| photoPaths | String | 图片路径,JSON 数组字符串 |
| notes | String | 备注 |
| createdAt | int | 记录时间,毫秒时间戳 |
这里有个设计细节值得解释:为什么同时保留moodScore和moodEmotion?因为情绪分是数值型的,可以做趋势统计,比如画出"这周情绪分走势";而情绪关键词是文本型的,能表达用户更细腻的感受。光有分数会丢掉"为什么",光有关键词又没法量化。两个字段配合,统计页和列表页都能各取所需。
3.2 存储方案选型:为什么最终选 Drift
Flutter 本地存储常见选择有shared_preferences、hive、sqflite和drift。shared_preferences只适合存少量配置,列表查询和搜索完全不行;hive速度快,但复杂查询和迁移能力弱;sqflite是标准 SQLite 封装,稳定但裸写 SQL 容易出低级错误。
我最终选的是drift,它是基于sqflite的类型安全 ORM,表结构在 Dart 代码里定义,编译期会校验查询语句,改字段名不会出现改漏的情况。这对我这种边写边加功能的项目很重要——后期加字段或者改查询逻辑,编译器能先兜住一轮错误。
3.3 建表 SQL 与标签策略
用 Drift 定义这张表,生成的 SQL 大概是:
CREATE TABLE scent_records ( id TEXT PRIMARY KEY, scent_name TEXT NOT NULL, category TEXT NOT NULL, mood_score INTEGER NOT NULL DEFAULT 3, mood_emotion TEXT, intensity INTEGER NOT NULL DEFAULT 3, location TEXT, weather TEXT, photo_paths TEXT, notes TEXT, created_at INTEGER NOT NULL )标签字段我没拆表,直接用逗号分隔存一个字符串。很多人一看到标签就想建多对多关联表,但对气味日记这种轻量应用,拆表会增加大量 join 查询,而实际搜索场景无非是"找出所有带'木质调'标签的记录",一条LIKE '%木质调%'就够用了。等到标签数量上千万级、需要做标签统计报表时再拆表也不迟,过早设计复杂关系是个人项目最常见的过度工程。
3.4 照片存储与路径管理
照片是日记体验的重要组成部分。我用image_picker选择图片,选中后立刻压缩成网络图,原图保存到应用文档目录下,避免直接塞进数据库。数据库里只存一个 JSON 数组字符串,记录各张图片的相对路径。展示列表时优先加载缩略图,点开详情再加载原图,这能显著降低内存压力。
有一个跨平台路径的坑在这里提前提醒:iOS 的沙盒文档目录和 Android 的外部存储目录名称不同,鸿蒙的沙箱设计也和 Android 不完全一样。写路径的时候不要硬编码,永远通过path_provider类插件获取目录,后面第 5 节我会详细讲鸿蒙上拿不到路径的具体表现。
4. 记录页、日历和统计图:三个核心页面的实现思路
4.1 状态管理选 Riverpod 而不是 Bloc/GetX 的理由
Flutter 状态管理方案太多,容易让人选择困难。我这次压的是flutter_riverpod,原因有三个:第一,编译期类型安全,Provider 用错了类型编译器直接报错;第二,和 Drift 的响应式查询结合很顺畅,数据库有变化时StreamProvider会自动推送新数据,页面不用手动刷新;第三,测试友好,依赖注入比 Bloc 那套 Event/State 样板代码轻得多。
GetX 我承认上手快,但它把太多能力塞进一个类里,项目一旦大到一定程度,隐式依赖会让排查问题变得痛苦。Bloc 适合团队协作和复杂业务流,对个人项目略显仪式感过重。Riverpod 处在中间,个人用、小团队用都很舒服。
4.2 记录页:把"记录"做到最轻
记录页的交互逻辑很简单:顶部是气味名称输入框,中间是类别选择横向列表,下面依次是情绪分滑块、强度评分、地点、天气、备注,底部是图片添加和保存按钮。
一个值得说的实现细节是情绪滑块。我用Slider映射到 1-5 的整数,不是让用户二选一,而是让用户快速盲选。"今天心情如何"这个问题,如果用下拉框或输入框都会显得太重,滑块拖一下、松手即完成,记录路径就被压缩到最短。同样,气味强度用五档星星图标评分,一眼看过去就知道怎么操作。
保存按钮点击后,我做了两件容易被忽略的事:一是同时生成缩略图并写入记录字段,而不是等列表页显示时才去压缩,把耗时操作提前到写入阶段,避免首屏卡顿;二是用 Drift 的事务写入,确保记录和图片路径的一致性,不会出现记录存在但图片引用丢失的孤儿数据。
4.3 日历回顾:table_calendar 接入与标记策略
日历页我直接用table_calendar包。这个包功能完整,但默认样式偏复杂,需要花功夫调成和自己的设计一致。接入时我只需要做三件事:
- 用
StreamProvider订阅全部记录的createdAt列表。 - 把记录日期转成一个
Set<DateTime>,传给日历组件的eventLoader,让有记录的日期显示小圆点。 - 用户点击日期时,底层列表按当天日期过滤展示。
有个边界问题要处理好:DateTime不同时区容易偏移。过滤记录时,我统一用"当天零点到明天零点"的范围查询,而不是直接比较日期字符串,否则跨时区或夏令时区域会出现记录"消失"的诡异问题。
4.4 统计图表:用 fl_chart 做聚合展示
统计页我用fl_chart画两类图:气味类别分布柱状图、情绪走势折线图。关键不在怎么画图,而在于数据聚合怎么查。Drift 里我写了两个查询:
一个按类别分组统计条数:
SELECT category, COUNT(*) FROM scent_records GROUP BY category另一个按天算平均情绪分:
SELECT (created_at / 86400000) AS day, AVG(mood_score) FROM scent_records GROUP BY day ORDER BY daycreated_at存的是毫秒时间戳,除以一天的毫秒数就能得到"第几天",按这个分组得到天级趋势。很多图表项目的难点不在前端绘图,而在后端或本地查询层没把数据形状组织好。把聚合逻辑放在 SQL 里,Dart 端只需要做简单的List到图表数据结构的映射,代码量最少,也最容易排查。
5. 鸿蒙真机调试与多端差异:从 Gradle 报错到文件路径的坑
5.1 鸿蒙工程的接入方式
Flutter 鸿蒙开发的整体流程和 Android 类似,但入口不同。你需要用 DevEco Studio 创建一个 HarmonyOS 空工程,然后把 Flutter 模块作为依赖集成进去。构建产物最终是一个.hap安装包,通过hdc命令或 DevEco 的部署按钮安装到真机或模拟器。
真机调试前一定要先完成签名配置。鸿蒙应用签名分调试签名和发布签名,真机运行调试包需要在 DevEco 里配置自动签名,并确保设备的开发者模式已打开。这块如果不配置,构建能成功但安装到设备时会报签名校验失败,属于"查半天代码发现是签名没配"的经典问题。
5.2 报错一:Gradle 插件声明方式过期
前面第 2.4 节提到过这个报错,在鸿蒙适配分支上更容易触发,因为适配分支的工程模板更新频率更低。具体现象是构建刚启动就报错,提示apply方式不再支持。
排查链路是这样的:我先把这个报错完整贴到搜索里,确认是 Flutter 3.16 开始的新变化;然后新建一个官方 Flutter demo,对比settings.gradle和根build.gradle的差异;最后把差异合并进鸿蒙壳工程,重新构建通过。整个排查过程不到半小时,所以遇到这类问题第一反应应该是"对比官方模板",而不是到处搜答案。
5.3 报错二:path_provider 拿不到目录,页面白屏
这是我在鸿蒙真机上踩的最深的一个坑。App 跑起来后,照片列表页直接白屏,日志里报的是目录不存在。排查后发现原因有两点:一是鸿蒙适配分支对path_provider的支持没有 Android 那么完整,返回的目录在某些场景下是空值;二是我的代码在拿到 null 时没有兜底,直接往下执行导致异常。
解决方式是两层:代码层加一个兜底函数,优先用getApplicationDocumentsDirectory(),失败时手动拼接应用沙箱路径;工程层在鸿蒙的module.json5里检查有没有声明存储相关权限。权限声明这步很容易漏,因为 Flutter 开发者在 Android 上习惯了清单文件帮你自动处理,换到鸿蒙后这些都得自己盯。
5.4 报错三:Dio 请求发不出去,抓包也看不到
气味日记本身不依赖服务端,但我想加一个"气味百科"模块拉取公开资料,于是引入了dio。结果在鸿蒙真机上请求一直超时,日志什么也没有。
我用了一个老办法排查:给 Dio 加拦截器,把所有请求和响应打印到控制台。
dio.interceptors.add(LogInterceptor( request: true, requestBody: true, responseBody: true, ));加了之后发现请求根本就没发出去,问题不在接口,而在网络权限和网络安全配置。鸿蒙对明文 HTTP 请求默认是限制的,需要像 Android 的network_security_config一样,在工程配置文件里声明允许的域名。我在鸿蒙侧配置里加了对应的网络安全设置后,请求就通了。这里也顺带说明一下,所谓"抓包"在开发阶段不一定非要用外部工具,Dio 拦截器打印日志就是最快的方式,生产环境记得把LogInterceptor关掉,避免日志里带出敏感信息。
5.5 多端差异对照表和签名发布
把这次开发中观察到的多端差异整理成一张表,给后面做类似项目的人一个参考:
| 能力 | Android | iOS | HarmonyOS |
|---|---|---|---|
| path_provider | 正常 | 正常 | 偶发空目录,需兜底 |
| 照片选择 | image_picker 正常 | 权限弹窗严格 | 需鸿蒙适配版本,权限声明不同 |
| 明文网络请求 | 需配置网络安全 | 默认禁明文,需 ATS 配置 | 需配置网络安全 |
| 蓝牙外设 | 权限动态申请 | 权限种类多且严格 | 适配分支能力有限 |
| 日历插件 | 正常 | 正常 | 依赖社区适配 |
最后说发布。鸿蒙 HAP 包打出来后,可以本地安装到测试机验证。正式上架要申请签名证书,这个过程需要开发者实名认证,提前准备好比临时弄从容得多。我在测试阶段用调试签名跑通全流程后,发现还有几个界面在鸿蒙上的间距和状态栏高度与 Android 不一致,这些属于"不到真机永远发现不了"的细节,所以真心建议尽早拿真机跑,别等 UI 全部写完再适配。
6. 性能优化复盘:Isolate 解析、图片压缩与 Lottie 动画加载
6.1 为什么把图片批量处理丢给 Isolate
Flutter 是单线程模型,主 Isolate 既跑 UI 又跑业务逻辑。当用户一次选了三张照片,我需要做压缩、缩略图生成、EXIF 读取,这些操作如果全在主线程做,界面上会明显卡顿,甚至直接掉帧。
Flutter 提供了两种多线程方案:compute和Isolate.run。对于一次性任务,Isolate.run更简洁:
final thumbnails = await Isolate.run(() => generateThumbnails(images));generateThumbnails里做了压缩和缩略图生成,返回结果后主线程继续更新 UI。要注意的是,传给 Isolate 的数据必须是可跨 isolate 传递的类型,文件路径字符串和图片字节数组没问题,但 Flutter 组件对象不行。这个坑我踩过一次:一开始想把File对象直接传进去,结果运行时报"对象不可传输",改成传String路径就好了。
6.2 图片内存和加载优化
日记应用的列表页会展示大量缩略图,稍不注意内存就飙上去。我做了三件事控制内存:
一是用ResizeImage强制限制解码尺寸。默认情况下 Flutter 会把原图全尺寸解码到内存,一张 4000x3000 的照片就要占用约 48MB 内存,而列表里它只占不到 200 像素见方的位置,这是巨大的浪费。用ResizeImage把解码尺寸压到缩略图大小,内存占用能降低 95% 以上。
二是统一用cached_network_image处理网络图片,同时配置合理的缓存宽高和过期策略。这个包在 iOS 和 Android 上表现很稳,鸿蒙上我暂时用本地图片为主,网络图片功能等适配版本跑稳定再放开。
三是列表项复用时要清理大图引用。Flutter 的ListView.builder本身会回收项,但如果你在Image.file里传了原图路径,回收时大图可能仍然留在图片缓存里。我的做法是列表页永远只显示缩略图,详情页才加载原图,从根源上避免大图堆进列表。
6.3 网络 Lottie zip 包加载的坑
气味日记的空白页和统计页我用了 Lottie 动画做空状态展示,本地资源加载一切正常。后来想做成从网络拉取 Lottie zip 包,方便不更新 App 就能换动画,结果踩了个不大不小的坑。
这个包加载的时候报"数据无法解析",排查链路是:先确认 zip 包是否完整下载,再用本地同样的 zip 包测试加载,发现本地可以、网络不行,问题定位在缓存目录的临时文件生命周期。原来默认场景下,网络文件会下载到临时缓存目录,而 Lottie 加载器在某些版本里拿不到这个目录的访问权限。
我的解决方式是:手动把 zip 包下载到应用文档目录,确认文件完整后再用File路径创建LottieComposition,不用临时缓存目录。这样既解决了加载问题,也顺便能让动画在下次启动时直接从本地读取,减少网络请求。
6.4 包体积与启动速度
最后聊下包体积。Flutter 打包出来的体积天然比原生大,鸿蒙包也不例外。我做的最有效的一步是开启编译混淆和大小裁剪:
flutter build hap --obfuscate --split-debug-info=build/symbols--obfuscate混淆 Dart 代码,--split-debug-info把调试符号单独拆出来,能有效减小安装包体积。如果之后包还是太大,可以考虑把"气味百科"这类低频模块用懒加载方式拆出去,等用户真正点进去时再加载 Dart 代码。启动速度方面,我控制了一件事:启动时绝不同步执行数据库迁移和照片扫描,先把首帧画出来,数据通过异步流慢慢填到页面上,用户体感会明显更顺。
最后再分享一个我在这个项目里反复体会到的道理:个人跨平台项目,最大的成本不是写功能,而是面对"每个平台都有自己的小脾气"这个事实。鸿蒙的存储路径、iOS 的相册权限、Android 的网络安全配置,哪一个单独拎出来都不难,放在一起就会消耗大量耐心。我的经验是把每个平台都当成一等公民来对待,而不是"先做 Android,其他平台有空再适配"。从第一天起就让 iOS、鸿蒙的真机参与联调,后面省下来的时间远比前期配环境的折腾多得多。
如果后续你还想让气味日记变得更聪明,可以在本地跑一个小模型做气味文本的情绪识别,或者接上蓝牙香薰设备做联动——但那是另一个量级的项目了。至少目前这个版本,用 Flutter 把气味和心情装进口袋这件事,我已经能每天都在用了。