
前阵子朋友问我你天天喷香水、点香薰有认真记录过自己每天闻到什么味道吗我当时一愣。后来刷到一个 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。我把它们从几个关键维度做了对比维度FlutterArkTS 原生uni-appReact 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 项目也搞坏。所以第一步就是用 FVMFlutter Version Management做多版本隔离。安装 FVM 很简单macOS 上可以直接用 Homebrewbrew 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 StudioAndroid 验证偶尔开一下 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 Flutters 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 数据模型一条气味记录包含什么气味日记的数据模型我设计得比较克制单表存储字段如下字段类型说明idStringUUID主键scentNameString气味名称必填categoryString类别香氛/饮食/自然/居家/其他tagsString自定义标签逗号分隔moodScoreint情绪分1-5moodEmotionString情绪关键词如平静怀旧intensityint气味强度1-5locationString地点weatherString天气photoPathsString图片路径JSON 数组字符串notesString备注createdAtint记录时间毫秒时间戳这里有个设计细节值得解释为什么同时保留moodScore和moodEmotion因为情绪分是数值型的可以做趋势统计比如画出这周情绪分走势而情绪关键词是文本型的能表达用户更细腻的感受。光有分数会丢掉为什么光有关键词又没法量化。两个字段配合统计页和列表页都能各取所需。3.2 存储方案选型为什么最终选 DriftFlutter 本地存储常见选择有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列表。把记录日期转成一个SetDateTime传给日历组件的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 多端差异对照表和签名发布把这次开发中观察到的多端差异整理成一张表给后面做类似项目的人一个参考能力AndroidiOSHarmonyOSpath_provider正常正常偶发空目录需兜底照片选择image_picker 正常权限弹窗严格需鸿蒙适配版本权限声明不同明文网络请求需配置网络安全默认禁明文需 ATS 配置需配置网络安全蓝牙外设权限动态申请权限种类多且严格适配分支能力有限日历插件正常正常依赖社区适配最后说发布。鸿蒙 HAP 包打出来后可以本地安装到测试机验证。正式上架要申请签名证书这个过程需要开发者实名认证提前准备好比临时弄从容得多。我在测试阶段用调试签名跑通全流程后发现还有几个界面在鸿蒙上的间距和状态栏高度与 Android 不一致这些属于不到真机永远发现不了的细节所以真心建议尽早拿真机跑别等 UI 全部写完再适配。6. 性能优化复盘Isolate 解析、图片压缩与 Lottie 动画加载6.1 为什么把图片批量处理丢给 IsolateFlutter 是单线程模型主 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-infobuild/symbols--obfuscate混淆 Dart 代码--split-debug-info把调试符号单独拆出来能有效减小安装包体积。如果之后包还是太大可以考虑把气味百科这类低频模块用懒加载方式拆出去等用户真正点进去时再加载 Dart 代码。启动速度方面我控制了一件事启动时绝不同步执行数据库迁移和照片扫描先把首帧画出来数据通过异步流慢慢填到页面上用户体感会明显更顺。最后再分享一个我在这个项目里反复体会到的道理个人跨平台项目最大的成本不是写功能而是面对每个平台都有自己的小脾气这个事实。鸿蒙的存储路径、iOS 的相册权限、Android 的网络安全配置哪一个单独拎出来都不难放在一起就会消耗大量耐心。我的经验是把每个平台都当成一等公民来对待而不是先做 Android其他平台有空再适配。从第一天起就让 iOS、鸿蒙的真机参与联调后面省下来的时间远比前期配环境的折腾多得多。如果后续你还想让气味日记变得更聪明可以在本地跑一个小模型做气味文本的情绪识别或者接上蓝牙香薰设备做联动——但那是另一个量级的项目了。至少目前这个版本用 Flutter 把气味和心情装进口袋这件事我已经能每天都在用了。