先交代一下背景。去年年底我开始做「GreenSort 绿分家」这个智能垃圾分类应用,定位很简单:让用户举起手机拍一下、或者输入一个物品名字,立刻知道它属于可回收、有害、厨余还是其他垃圾。项目从立项到第一个可体验版本,前后花了三个月,其中最核心的是「快速分类」模块——也就是从拍照/输入到出分类结果这一段链路。
这篇文章我把快速分类模块从选型、实现到优化的完整过程写出来。包括为什么在 HarmonyOS 6.0 上仍然选 Flutter 做跨端框架、本地方案怎么设计、踩过的那些 Flutter 工程化坑、以及最后是怎么把一次分类耗时从 5 秒压到 1.5 秒的。对 Flutter 开发、鸿蒙应用适配、轻量 AI 应用落地感兴趣的读者,这篇文章应该能给你一些可以直接用的经验。
1. 为什么「快速分类」值得作为独立模块优先做
「GreenSort 绿分家」整个 App 有垃圾分类识别、投放点地图、环保知识科普、积分任务这些功能。但我在产品设计之初就定了一条原则:快速分类模块必须最先做、做最深。理由有三层。
1.1 产品侧:用户打开 App 的第一诉求就是“这个东西扔哪”
垃圾分类 App 不是社交产品,用户不会每天刷。真实场景是:手里拎着一袋垃圾站在垃圾桶前,不确定某个东西该扔哪个桶,这时候掏出手机,期望 3 秒内得到答案。地图、积分、科普都是低频功能,唯独“这是什么垃圾”是最高频、最刚需的入口。
所以我要求快速分类模块必须做到“单次操作不超过三步”:打开 App,拍照或输入,获得结果。任何多余注册、跳转、广告都会让用户流失。
1.2 技术侧:分类模块是整个 AI 链路的试金石
垃圾分类识别看起来是个小功能,但它牵扯到完整的移动端 AI 链路:摄像头调用、图像预处理、模型推理、结果后处理、离线缓存、性能优化。做透这个模块,等于把后续所有智能功能的骨架都搭好了。
比如模型推理部分,我们直接在 HarmonyOS 设备上通过 Flutter 插件通道调用本地 TFLite 模型。这个链路涉及 Dart 层、原生层(Java/Kotlin 或 ArkTS)、C++ 层(JNI/Napi)三层协作。一旦跑通,后续加任何 AI 能力都是复制粘贴这套架构。
1.3 架构侧:模块边界的划分决定了团队协作效率
快速分类模块最终被拆成四个子模块:
- UI 层:相机预览页、结果展示页、搜索页,全部用 Flutter 编写
- 服务层:分类请求的统一入口,负责调用图像识别或文本搜索
- 推理层:封装 TFLite 模型加载、输入预处理、输出解析,通过 MethodChannel 暴露给 Dart
- 数据层:本地垃圾类别数据库、用户分类历史缓存
边界划清楚的直接好处是:UI 层可以并行开发,推理层可以单独用原生工程调试,数据层可以脱离 UI 做单元测试。后续如果要把推理层换成端侧 NPU 方案,只需要改推理层内部实现,对 UI 层完全透明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:Flutter 和 HarmonyOS 6.0 是怎么走到一起的
很多人看到“Flutter + HarmonyOS”会觉得矛盾:Flutter 不是跨平台吗?HarmonyOS 不是要推 ArkTS 吗?这里我把决策过程说清楚。
2.1 为什么不是纯 ArkTS,也不是纯原生 Flutter
HarmonyOS 6.0 原生开发首推 ArkTS + ArkUI,这个方向本身没问题。但 GreenSort 的定位是跨 Android、HarmonyOS、iOS 三端,而且团队只有两个人,纯 ArkTS 意味着 Android 和 iOS 端要另外维护两套代码,人力撑不住。
Flutter 的优势恰恰在这:一套 Dart 代码覆盖三端,UI 一致性高,动画性能好,生态成熟度高。HarmonyOS 侧通过社区适配层可以跑 Flutter 引擎,虽然不完全等同于官方支持,但实际体验已经可以达到生产可用水平。
2.2 鸿蒙 6.0 在项目里承担的角色
HarmonyOS 6.0 这个版本有几个值得注意的特性:更强的端侧算力调度、更严格的权限管控、新的应用签名机制。对 GreenSort 来说,它提供了两大价值:
第一,端侧 AI 加速能力。 鸿蒙设备对端侧模型的调度做了优化,NPU 加速路径比 Android 同硬件更顺畅。虽然 Flutter 跨端层暂时用不上 NPU,但后续如果做实时识别,原生侧可以借助这个能力。
第二,统一的应用分发入口。 一个 HAP 包即可安装到手机、平板、智慧屏等设备。对垃圾分类这种工具型应用,以后拓展到智能终端场景有很大想象空间。
2.3 环境搭建:镜像源和版本匹配是第一个坎
这一块几乎每个 Flutter 开发都会遇到。HarmonyOS 上跑 Flutter,首先是 Flutter SDK 要用社区适配版本(基于 OpenHarmony 分支),不能直接用官方主线。命令类似:
bash复制git clone -b ohos https://gitee.com/openharmony-sig/flutter_flutter.git
git clone -b 3.22.0-ohos https://gitee.com/openharmony-sig/flutter_engine.git
然后配置本地镜像。国内网络环境不用镜像基本跑不动,我的 .bashrc 里长期放着:
bash复制export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
这个配置对应了很多人刷到过的热搜词“flutter assets will be downloaded from https://storage.flutter-io.cn”——出现这行提示就是 Flutter 在通过镜像源拉编译产物,只要网络稳定、版本匹配,等它下载完就行。实际经验是:首次执行 flutter doctor 和 flutter create 时,尽量开代理或保持网络稳定,否则很容易卡在 artifacts 下载。
版本匹配也很容易翻车。Firebase、摄像头相关插件在不同分支下兼容性不一样。我的策略是锁定一个经过验证的 Flutter ohos 分支版本,然后全部插件固定在它兼容的版本区间,绝不追新。等项目稳定后再统一升级,避免插件版本发散导致排查困难。
3. 快速分类核心链路:拍照识别的完整实现
分类模块的完整链路是:相机预览 → 拍照 → 图像缩放/归一化 → 本地模型推理 → 结果解析 → 展示。下面拆开讲每一步。
3.1 相机入口与自研 CameraView
一开始我图省事,直接用 Flutter 生态里现成的相机插件。但在 HarmonyOS 设备上测试时发现两个问题:一是部分插件对鸿蒙适配不完善,预览画面方向不对;二是插件体积大,启动时初始化慢。最后我自己包了一层 CameraView,底层用 MethodChannel 调原生相机能力,Dart 侧只负责控制页面状态。
核心思路是:原生侧开一个 PreviewView 作为相机预览层,Flutter 侧通过 PlatformView 把原生视图嵌入到页面中。这样预览帧不走 Flutter 渲染管线,延迟低很多。Dart 侧代码大致是这样:
dart复制Widget buildCameraPreview() {
if (Platform.isHarmonyOS) {
return HarmonyCameraPreview(
onFrameAvailable: _handleFrame,
onError: _handleCameraError,
);
}
return DefaultCameraPreview(
onFrameAvailable: _handleFrame,
onError: _handleCameraError,
);
}
3.2 图像预处理:别小看这一步
拍下来的照片通常是 1080p 甚至更高分辨率,直接丢给模型推理既慢又占内存。我的做法是三步:
- 将图像缩放到模型输入尺寸(本项目用 224×224)
- 转成 RGB 格式,移除 Alpha 通道
- 归一化到 [-1, 1] 区间(或 [0, 1],取决于模型训练时的设置)
图像缩放最忌讳在 Dart 层做,Dart isolate 里做图像运算不仅慢,还可能因为内存拷贝导致 OOM。正确姿势是在原生层完成,然后把字节列表传给推理层。
3.3 本地模型推理与 TFLite 集成
模型选型上,我对比过 MobileNetV3-Large、EfficientNet-Lite 和 RepVGG。最终选了 MobileNetV3-Large,因为它在端侧推理速度快、体积小(量化后约 8MB),准确率也能满足需求。分类范围覆盖 40 类常见生活垃圾,包括塑料瓶、纸箱、电池、厨余残渣等。
HarmonyOS 端接入 TFLite 的过程,本质上是通过 MethodChannel 调原生推理代码。Dart 侧发起请求:
dart复制final result = await _channel.invokeMethod('classifyImage', {
'bytes': imageBytes,
'width': width,
'height': height,
'rotation': rotation,
});
原生侧拿到字节流后,交给 TFLite Interpreter 运行推理,最终返回一个包含类别索引和置信度的 JSON 字符串。为了保证 UI 不卡顿,原生侧的推理放在独立线程中执行,Dart 侧则用 await 等待结果,期间页面显示加载状态。
3.4 置信度阈值:识别结果不能“硬给”
垃圾分类和通用图像分类不同,猜错一次带来的信任损失非常大。用户把电池扔进可回收桶,后果很严重。所以我给快速分类模块加了两道保险。
第一道是置信度阈值。 推理结果中最高置信度低于 0.6 时,不直接显示分类结果,而是提示用户“识别不确定,请换个角度再拍一次或手动搜索”。0.6 这个值是通过 500 张真实用户场景测试图调出来的,低一点误判多,高一点识别率下降。
第二道是“四分类概率展示”。 即使置信度通过阈值,页面也会展示四个类别各自的概率,让用户看到可回收 82%、其他垃圾 35% 这样的信息,增加确定性。用户可以根据自己对物品的了解做二次判断。
4. 文本搜索与兜底策略:没有摄像头也能分类
拍照识别不是万能。用户可能面对的是没有纹理的纯色物品、光线极差的环境,或者拍照不方便的场景。这时候文本搜索就派上用场。
4.1 关键词库设计:不是简单的字典
我建了一个垃圾名词库,每条记录包含三部分:物品标准名(如“矿泉水瓶”)、别名集合(如“塑料瓶”“饮料瓶”“矿泉水瓶子”)、类别标签(可回收垃圾)。搜索时先做精确匹配,再对用户输入做模糊匹配。
难点在于中文输入的多样性。 同一个东西叫法千差万别:“薯片袋”和“膨化食品包装袋”,“一次性筷子”和“木筷”。为了覆盖这些场景,我从社区众包和公开垃圾分类指南里扒了 3000 多条词条,做了大量的别名整理。
4.2 拼音匹配与结构化存储
为了处理用户错别字或者输入法差异,我在搜索模块里加入了拼音匹配。每个物品在入库时同时存储一个拼音字段,搜索时先转拼音,再做前缀或包含匹配。比如用户输入“su liao ping”,也能匹配到“塑料瓶”。
数据存储用 SQLite,建了核心索引:
sql复制CREATE TABLE garbage_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pinyin TEXT,
aliases TEXT,
category TEXT,
tips TEXT,
score INTEGER DEFAULT 0
);
CREATE INDEX idx_garbage_name ON garbage_items(name);
CREATE INDEX idx_garbage_pinyin ON garbage_items(pinyin);
这里有个经验:搜索时不要直接 LIKE '%keyword%',中文分词场景下性能不够好。更稳的做法是先在内存里维护一份全量词表的倒排索引,搜索时优先查内存,命中不了再走 SQLite 兜底。因为词表总量不大(几千条),内存方案完全可以覆盖 99% 的搜索场景。
4.3 无结果时的用户引导
当搜索结果为空时,不做干巴巴的“暂无结果”提示。我设计了一个二段式引导:
- 先提示用户“换个关键词试试”,并展示几个热门搜索词,比如“湿纸巾”“奶茶杯”“小龙虾壳”
- 如果用户 30 秒内仍然没有找到,提供“拍照识别”按钮,一键跳转到相机
这个引导逻辑在测试阶段效果不错,用户反馈“即使没搜到也觉得 App 有智能感”,其实是小小的交互设计带来的加分。
5. 打磨「快」的体验:从 5 秒到 1.5 秒的优化实录
快速分类,核心就是一个“快”字。第一版做到从拍照到出结果平均 5 秒,那段时间我每天的使用感受就是:憋屈。你要知道用户站在垃圾桶前根本等不了 5 秒。后来我做了一整套优化,最终平均 1.5 秒拿到分类结果。优化过程如下。
5.1 首帧优化:冷启动不给用户黑屏
App 冷启动时 Flutter 引擎初始化 + 首页 Frame 渲染通常要 1~2 秒。对快速分类这种“即开即用”需求,这是致命的。优化的核心是减少首帧前的同步工作。
第一,去掉启动时的所有网络请求,首页数据全部本地完成。 第一版我在 main() 里初始化了远程配置、检查更新、请求弹窗,全挤在首帧前,导致白屏时间很长。后来全部改成了异步加载,首帧只需渲染一个静态的可点击相机按钮。
第二,减少首帧要加载的 Widget 数量。 首页从复杂布局(地图预览、推荐资讯、导航栏)改成极简四宫格:拍照分类、文字搜索、分类指南、投放点地图。少渲染一个列表项,首帧就能快几十毫秒。
5.2 模型量化 + 预热:把推理时间压到 100ms 内
模型推理本身是耗时的主战场。MobileNetV3-Large 浮点模型在部分中低端设备上推理一次需要 300~500ms,这个数字我无法接受。后来做了两件事:
第一是量化。 用 TensorFlow Lite 的量化工具把模型从 FP32 转成 INT8,重量从 20MB 降到 8MB,推理时间降为原来的一半左右。量化后精度损失在可接受范围内,实测分类准确率从 92.1% 降到 90.6%,影响不大。
第二是预热。 第一次推理时模型需要加载权重、创建 interpreter,这个过程最慢。我在 App 启动后立即在后台线程初始化一次推理器,后续使用者直接拿到已加载好的模型。这样把“第一次慢”的痛感转移到用户感知不到的后台。
优化后的实测数据,在一台中端 HarmonyOS 设备(麒麟芯片)上:
- 图像缩放+归一化:约 30ms
- 模型推理:约 120ms
- 结果解析+UI 渲染:约 80ms
- 总耗时(不含拍照操作):约 230ms
加上拍照和页面跳转,整体 1.5 秒是跑得出来的。
5.3 结果页缓存与联想:同一物品不重复计算
用户连续拍摄同一类物品(比如一连拍 5 个塑料瓶),每次都跑模型纯属浪费。我在本地加了一层结果缓存:以图像的感知哈希(Perceptual Hash)作为 key,命中后直接返回上次的分类结果。
感知哈希的做法是把图像缩小到 32×32,转成灰度,计算 64 位哈希值,两个哈希的海明距离小于 10 就认为是相似图片。这个方案计算量很小,Dart 层就能完成,用来防重复识别非常有效。
另外一个优化是:结果页展示分类信息的同时,预加载“常见同类别物品”。 比如用户查了“矿泉水瓶”,结果页下方就会展示“同类物品:塑料油壶、洗发水瓶、饮料瓶”。用户很可能接着查第二件,提前把数据备好,点击就能进详情。
5.4 热重载与调试效率:开发期也求快
热重载是 Flutter 的优势,但很多人遇到过“热重载后浏览器没更新”或“状态没复位”的问题。我在团队里定下规矩:涉及 UI 样式修改,直接用热重载(r);涉及状态初始化、路由跳转、数据流变更,必须热重启(R)。
遇到“热重载后页面没更新”的情况,先看是不是 State 对象里的旧状态没有清理,再看是不是 const 构造函数导致编译器认为 Widget 没变化。排查完这两个方向,90% 的热重载异常都能解决。
6. 踩坑清单:Flutter 工程化和鸿蒙构建的真实问题
这部分都是真金白银踩出来的,挑几个高频问题分享。
6.1 Plugin 解析错误:dev.flutter.flutter-plugin-loader
开发中期遇到了一个非常经典的报错:
code复制Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: ...]
第一次遇到时我整个人是懵的——前一天还好好的,隔天就解析失败。查了很久发现是 Flutter SDK 分支切换后,之前工程里的 .plugin_symlinks 和 pubspec.lock 还在引用旧插件版本。
解决办法是把 .plugin_symlinks、pubspec.lock、build/ 目录全部删掉,重新 flutter pub get。如果还不行,就检查插件是否支持当前 Flutter 版本。HarmonyOS 适配分支的插件版本往往滞后于官方,最好的办法是查插件的 changelog,确认适配分支版本后再升级。
6.2 底部弹窗内嵌 TextField:键盘弹出被遮挡
搜索功能用到了底部弹窗,弹窗内嵌输入框。HarmonyOS 上键盘弹出后,弹窗会被顶到屏幕外,TextField 被键盘完全遮挡。这个问题在 Android 上一般设置 adjustResize 就行,但鸿蒙的窗口焦点了模式不一样。
最后采用的方案是:监听键盘高度变化,动态计算弹窗最高可到的高度。Dart 侧代码大致如下:
dart复制WidgetsBinding.instance.addObserver(
_KeyboardVisibilityObserver(
onChanged: (isVisible, height) {
setState(() {
_keyboardHeight = height;
});
},
),
);
弹窗的 maxHeight 设为 screenHeight - keyboardHeight - 80,保证输入框始终可见。实测下来,键盘弹出和收起的动画跟弹窗高度变化同步,体验还算顺滑。
6.3 CMake 与 Gradle 工具链的版本死结
在某一次同步代码后,构建突然报 CMake 错误:
code复制CMake Error at CMakeLists.txt:3 (project): Generator Visual Studio 16 2019 ...
这个问题的根源是工程里的原生插件(比如图像处理插件)需要 C++ 工具链,而本机或 CI 机器上的 CMake、JDK、Gradle 版本组合不一致。后来我统一了版本:
- JDK 17
- Gradle 8.4
- CMake 3.22.1
- Android SDK Build-Tools 34.0.0
然后在 android/gradle.properties 里固定:
properties复制org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
android.useAndroidX=true
android.enableJetifier=true
从那次之后,构建环境再也没出过幺蛾子。我的建议是:项目启动时就把这些工具链版本写进 README,不要相信“大家已经统一了”这种话。
6.4 打包 APK 时的常见误区和签名注意
打包阶段最容易忽略的是多渠道打包后的体积和权限声明。Flutter 应用默认会把所有 ABI(armeabi-v7a、arm64-v8a、x86_64)打进去,体积直接翻倍。我只保留 arm64-v8a,模型文件和原生库都能瘦身。
另外鸿蒙的 HAP 包对权限的声明比 Android 更严格,必须在 module.json5 里逐项声明。 拍照权限、麦克风权限(如果做语音输入)、本地存储权限都要写清用途说明。在这方面我和应用市场审核打过几次交道,经验是:权限用途描述要具体到“用于拍摄垃圾照片进行识别”,不要写模糊的“获取存储空间”。
7. 最后再给几个具体建议
7.1 小型团队做端侧 AI,先啃一块硬骨头
如果你也想做一个带 AI 能力的跨端应用,我的建议是先选定一个最核心功能做端到端跑通,不要一开始就铺开多端、多模型。快速分类模块作为 GreenSort 的“硬骨头”,跑通之后给后续功能提供了非常稳定的基座。
7.2 关于 HarmonyOS 适配,提前想好降级方案
HarmonyOS 的 Flutter 适配目前还在快速迭代期,插件生态不完全跟得上。 我给 GreenSort 定的策略是:核心三端共用一套 Dart 代码,平台相关的差异全部收口到独立适配层,一旦某个平台的插件出现问题,可以快速替换而不影响其他端。
7.3 用户为先,技术是服务的
最后一点体会:不要为了用新技术而用新技术。我们选 Flutter、本地模型、HarmonyOS 6.0,都是为了“让用户一秒知道垃圾怎么扔”这件事。技术选型再炫,用户等 5 秒也会直接卸载。做产品,永远先问“这个技术省了用户多少时间”,再问“这个技术难不难”。
「GreenSort 绿分家」的路还很长,快速分类模块只是第一站。后面我会继续写垃圾分类知识图谱、投放点地图的离线支持、以及多端适配的进阶内容,欢迎一起交流。
