家里东西越堆越多,找什么都像开盲盒。前阵子搬家,我在客厅翻来翻去找一把六角螺丝刀,最后在储物间第三格的工具箱里才找到,前后折腾了二十分钟。这件事让我认真考虑做一个居家好物收纳应用,把每个物件的位置记下来,平时搜索一下就能知道在哪。
想要的体验很简单:新增物品时拍张照、选好位置,平时打开App快速搜,最好在桌面不用点进去就能看到最近存取记录。数据要全部存本地,不注册不登录,不依赖云端。当时手里的工作环境正好在研究跨平台方案,查了一圈之后锁定用Flutter,而且直接尝试把它跑在鸿蒙平台上。这篇文章就把完整的选型、开发和踩坑过程整理出来,给同样想做跨端到鸿蒙、又不想重写业务逻辑的朋友一条可参考的路线。
1. 项目缘起:一次搬家引发的收纳管理需求
1.1 收纳App的常见痛点
市面上的收纳管理工具我不是没试过,问题集中在三处:第一,很多应用强制要求注册账号,数据默认上传云端,物品信息本身就是隐私,我不想让家里有几个箱子、买了什么东西都被平台拿走;第二,交互过度设计,添加一个物品要填十多个字段,还要选自定义图标,日常记录成本太高,用两次就懒得维护了;第三,搜索能力普遍偏弱,要么只能按固定分类找,要么干脆只能翻列表,根本无法做到“模糊搜名称、按位置筛、按分类看”的组合条件。
所以这个项目的需求边界从一开始就很明确:纯本地数据、拍照录入、空间路径搜索、桌面卡片快速查看。不搞云端同步,不做社区分享,不给用户加任何负担。这个定位也决定了后续技术选型的方向,本地数据库要高可靠、查询要快,UI操作最好能做到三步内完成一条物品录入。
1.2 为什么非要用跨平台方案
如果只做一个鸿蒙版本,直接学鸿蒙原生开发当然可以,但问题是我不可能只维护一个平台。手机是安卓,家里还有淘汰下来的旧平板,后面大概率还要再出iOS版,如果每个平台重写一遍,光是数据模型、搜索逻辑、界面状态管理这些代码就要复制三份。跨平台方案的价值在于业务层只写一次,UI层尽量复用,平台相关的部分才单独处理。
在跨平台框架里选型时,我关注的是三件事:渲染层能不能保持一致的视觉效果、现有代码能不能低成本迁移、以及目标平台有没有可用的适配路线。最终选Flutter不是因为它在某一方面特别出彩,而是因为它的自绘架构决定了它适配新平台的成本相对更低,这一点在后面讲鸿蒙适配时会具体展开。
2. 跨端选型盘点:为什么最终选了Flutter来碰鸿蒙
2.1 几家跨端方案的筛选逻辑
先泼一盆冷水,没有任何一套跨端方案是零成本的,差别只在于成本花在什么地方。我把主流选项放在一起过了一遍,各自的取舍非常明显:
| 方案 | UI渲染方式 | 鸿蒙适配现状 | 适合场景 | 主要顾虑 |
|---|---|---|---|---|
| ArkTS原生 | 系统自有组件 | 原生支持 | 鸿蒙单平台应用 | 业务逻辑难复用,多端要重写 |
| uni-app | 编译到系统WebView | 有一定适配基础 | 偏H5业务的应用 | 复杂列表和动画性能有瓶颈 |
| React Native | 桥接原生组件 | 适配成熟度参差 | 已有RN技术栈的团队 | 依赖原生控件,鸿蒙控件差异需处理 |
| Flutter | Skia自绘引擎 | 社区适配可走通 | 高质量UI、多端复用 | 包体积偏大、平台能力需通道调用 |
我的判断是:uni-app适合业务偏表单、偏后台管理的应用,放到高交互的本地工具里不太顺手;React Native的跨端思路依赖原生组件,鸿蒙原生控件的成熟度会直接影响最终效果;ArkTS原生单平台体验确实好,但如果目标是多端覆盖,等于每个端都做一遍,不满足我的前提。
2.2 Flutter自绘架构带来的适配优势
Flutter的UI不是把Dart代码翻译成系统控件,而是在底层用自己的引擎直接绘制每一个像素。这意味着同一套界面代码在安卓、iOS、鸿蒙上画出来的效果是一致的,不会因为某个组件在不同系统里的默认样式不同而产生偏差。
跨端适配的关键就落在引擎层:新平台只需要支持Flutter的渲染环境、线程调度、事件分发、平台通道这几件事,业务层代码几乎不用动。鸿蒙侧已经有了对应的适配路线,能把Flutter引擎跑起来,Dart侧的项目结构、数据层、状态管理直接沿用,需要处理的只是原生能力调用这部分。
我的最终选择因此很明确:Flutter负责80%的业务与UI,鸿蒙原生代码只负责Flutter覆盖不到的系统能力,两边通过统一的通道通信。这个结构既能满足收纳应用这种工具型产品的体验需求,又不必在鸿蒙上从头造一遍轮子。
3. 搭建开发环境:版本搭配与首次真机构建的三次踩坑
3.1 版本选型:别用最新的,用适配好的
开发环境的搭建是第一个容易掉进去的坑。Flutter官方主线的迭代节奏很快,但鸿蒙平台的适配由社区维护,主线和适配版之间存在滞后。直接拉最新版Flutter工程,很可能会在编译阶段遇到引擎库不匹配的问题。
我的做法是:Flutter SDK选取社区适配鸿蒙的特定分支,鸿蒙SDK选取与开发工具配套的版本,两者版本尽量绑在一起,不要随意单独升级。创建工程时用适配分支自带的工程模板,因为模板里已经预置了鸿蒙侧需要的原生壳和引擎引用,比自己手动往普通Flutter工程里加鸿蒙支持要省很多事。
环境变量部分,需要把鸿蒙SDK的路径配置给Flutter工具链。这一步看似简单,但每台机器的配置方式可能不一样,终端窗口不重启的话,新配置不生效,容易造成“明明配了却找不到SDK”的假象。配完之后建议先执行一下版本检查命令,确认工具链能识别出鸿蒙 SDK再继续,能省掉后面一大串麻烦。
3.2 真机调试的三次典型故障
第一次跑真机的时候,最典型的问题是设备识别失败。开发工具能识别到设备,但执行构建时一直提示找不到目标平台。查了配置后发现,工程文件里缺少鸿蒙平台的应用标识配置,从模板创建时如果改过包名,需要同时在原生侧工程里同步修改,两边不一致就会挂掉。
第二次是执行依赖拉取时一直失败,反复卡在下载阶段。这种问题通常和仓库可达性有关,多试几次或者换一个时段重跑就能过去,离线依赖包也可以用来代替在线拉取。当时我已经在集成一部机器上拿到了所需依赖的离线文件,导入之后就正常了。这个经验是:不要在拉取依赖失败时反复清缓存重试,优先确认能不能使用离线包,路径更短。
第三次是应用跑起来后页面白屏,控制台里没有明显报错,只是Flutter侧不断输出初始化失败的日志。排查到最后,问题不在Dart代码里,而是构建配置里某些原生模块被裁剪掉了,导致引擎启动时找不到对应节点。修复方式是回到工程配置里把那几个模块补回来,清理构建缓存后重新编译,立竿见影。
3.3 一套有效的调试排查顺序
经过这段时间的折腾,我总结出一个相对有效的排查方式:先定位是“工具链问题”还是“代码问题”,再看是“Dart侧”还是“原生侧”,最后对比模板工程和当前工程的差异。顺着这个顺序走,大部分环境问题都能在半小时内定位到根因。
特别提醒一件事:不要被长长的报错日志吓到,Flutter的报错信息很多时候只是表象,真正的根因往往在日志最前面的几行。比如页面白屏的报错看似是引擎初始化失败,实际上是因为原生工程模块缺失;如果你只盯着反复出现的错误行改代码,根本解决不了问题。先把报错从头到尾读一遍,把提到的文件名、工程名、配置项逐个对照检查,比盲目搜解决方案快得多。
4. 核心功能落地:从物品表设计到搜索体验的完整实现
4.1 收纳场景的业务模型拆解
居家收纳的表面需求是“记东西”,但真正要解决的是“找东西”的效率。我拆业务时没有直接做一张大表,而是把“空间位置”抽出来做成独立的层级模型,因为收纳场景里位置是有从属关系的:房间包含柜子,柜子包含层格,层格里面放物品。空间路径比单纯的关键词更符合找东西的直觉。
数据模型大致分两张表。一张是空间位置表,记录位置路径和显示顺序;另一张是物品表,关联到具体位置,记录名称、分类、数量、备注、封面图路径和时间信息。两张表都加上创建时间字段,后续做排序和备份都方便。
CREATE TABLE IF NOT EXISTS storage_places ( id INTEGER PRIMARY KEY AUTOINCREMENT, place_path TEXT NOT NULL, sort_order INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS home_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT DEFAULT '', place_id INTEGER NOT NULL, quantity INTEGER DEFAULT 1, note TEXT DEFAULT '', cover_path TEXT DEFAULT '', created_at INTEGER DEFAULT 0 );4.2 本地数据库的落地与状态管理
本地数据库我直接选用了SQLite方案,数据文件放在应用私有目录下,不给用户弹权限请求,也避免多端数据路径不一致。数据库操作封装成一个仓库类,所有读写都走这一层,后续如果想换成本地文件存储,界面代码不需要动。
状态管理用的是Riverpod,原因很实际:它把数据依赖关系写得非常显式,界面组件用到的数据源一目了然,测试时也容易替换。对于收纳应用来说,状态管理模式不需要花哨,关键是让列表、搜索、新增页之间的数据一致,别出现刚录入的物品在列表里找不到的情况。
final itemListProvider = StateNotifierProvider<ItemListNotifier, List<HomeItem>>((ref) { return ItemListNotifier(ref.read(itemRepositoryProvider)); }); class ItemListNotifier extends StateNotifier<List<HomeItem>> { ItemListNotifier(this._repository) : super([]); final ItemRepository _repository; Future<void> loadAll() async { state = await _repository.fetchAll(); } Future<void> addItem(HomeItem item) async { await _repository.insert(item); await loadAll(); } }4.3 搜索与筛选的组合实现
搜索是这个应用的核心体验。名称模糊匹配是基本功,但多数人也记不住所有物品的准确分类,所以搜索必须同时覆盖物品名称、分类名称和位置路径。这样输入“螺丝刀”能搜到,输入“储物间”也能把该位置下的所有物品列出来。
List<HomeItem> searchItems(String keyword) { if (keyword.trim().isEmpty) return state; final kw = keyword.toLowerCase(); return state.where((item) { final place = places[item.placeId]?.placePath ?? ''; return item.name.toLowerCase().contains(kw) || item.category.toLowerCase().contains(kw) || place.toLowerCase().contains(kw); }).toList(); }实际操作中这个方案非常有效,因为收纳场景里位置的命名往往比物品名更有规律。我建议用户把位置路径按“房间-柜子-层格”的约定命名,比如“客厅-电视柜-第三层”,搜索时只要记得大概位置就能命中。很多一开始觉得“我根本想不起东西叫什么”的物品,反而能通过位置路径找到。
4.4 界面布局与多端适配
界面结构上用了底部导航加三个页面:首页放最近存取记录和常用物品,物品页放全量列表和搜索,新增页负责录入。这样把高频操作压缩到三步以内:打开App、搜索或点新增、完成操作。
布局上用LayoutBuilder做响应式切换,手机窄屏显示网格卡片,平板宽屏显示左侧分类、右侧列表的分栏结构。图片封面用压缩过的小尺寸版本,因为收纳应用里一个物品往往一张照片,原图占空间,加载也慢。压缩到宽边720像素以内,显示效果和加载速度都能接受。
4.5 图片处理的一个细节
论是拍照还是从相册选,图片都存在应用私有目录,数据库里只存相对路径。拍完立刻压缩,展示时读缩略图,详情页才加载原图。这个设计避免了图片文件堆积成几十兆,也方便做本地JSON备份时把图片和数据库分开打包。
5. 鸿蒙侧能力接入:服务卡片与平台通道的实战细节
5.1 Flutter的核心缺陷在哪里
Flutter再强,它也只是界面和业务逻辑层。系统底层的桌面卡片、快捷设置、消息通知、后台任务这类能力,Dart代码碰不到,必须回到鸿蒙原生工程里写。很多Flutter项目在普通平台上没有太大感觉,一旦上了新平台,平台能力的接入就变成主要工作量。
我的原则是:能不碰原生就不碰原生,碰原生就只做“接口”而不做“业务”。原生侧每个能力封装成独立方法,Dart侧统一通过通道调用,两边用同一份接口文档约束参数和返回值,不各自为政。
5.2 平台通道的最小实现
Dart侧通过MethodChannel发起调用,通道名保持和应用包名强相关,避免和其他模块冲突。以获取桌面卡片刷新状态为例,代码结构非常简洁:
static const _channel = MethodChannel('com.example.homestorage/native'); static Future<String?> getCardStatus() async { return await _channel.invokeMethod('getCardStatus') as String?; }原生侧在鸿蒙工程里注册同名通道,实现方法处理。这里最容易踩的坑是两边的方法名、参数类型、布尔值大小写不一致,错一个就静默失败,控制台久久没有输出。所以接口一旦定好,尽量别改签名,要改就两边同步改,测试用例也要跟着更新。
5.3 服务卡片带来的体验提升
接入桌面卡片是这次开发中体验提升最明显的一个决策。之前用户要看最近存取的物品,必须打开App再翻列表;有了桌面卡片,直接把最近取出或放回的几条记录展示在桌面区域,不用解锁手机点进应用就能看到,操作路径从三步缩短到一步。
卡片的核心信息我选了三类:最近存取物品名称、所在位置、操作时间。在2x2区域里显示两条记录,2x4区域可以显示四到五条。每次物品数据变更后,应用主动触发卡片内容更新,原生侧负责把数据推送到对应卡片。
5.4 卡片数据同步的稳定性处理
卡片和App共享一份本地数据库,最初我担心并发问题,想过维护内存缓存来同步状态。实测下来发现,简单方案反而是最优解:卡片每次刷新任务触发时直接读数据库,不维护任何中间缓存,避免两边不一致。唯一要注意的是数据库初始化顺序,卡片刷新在App进程还没完成初始化时触发会导致空数据,需要做一次初始化状态判断,确保数据库可用再取数。
日志方面,建议把卡片生命周期回调都打上日志。原因是卡片刷新涉及系统和App两个进程,肉眼看不到执行顺序,只有日志才能还原出完整链路。开发期我靠日志定位过好几次刷新不生效的问题,最后发现都是数据变更后没有正确调用卡片更新接口导致的。
6. 打包发布前的自检清单:权限、性能与易用性调整
6.1 签名、混淆与权限声明
开发调试期一切顺利,不代表Release包也能顺利跑起来。打包之前我重新检查了签名配置,并且开启了Dart代码混淆。这里提醒一下,开启混淆之后一定要在真机上完整跑一遍核心流程,特别是搜索和卡片刷新,因为混淆后一些通过字符串反射调用的逻辑可能会悄悄失效。
权限声明上,这个应用最终只需要相机权限和相册读取权限,网络权限完全没有申请。少一个权限就少了一层用户疑虑,审核时也省心。给用户的感觉也更舒服——一个工具应用不需要联网,隐私边界很清楚。
6.2 性能、体积与数据库维护
Release包的实际大小比预想的小一些,主要因为项目依赖控制得比较克制,没有引入无关插件。列表滚动基本流畅,首屏加载在本地数据量不算大的情况下表现良好,整体体验达到了日常使用的标准。
跨过代码之外的另一个隐藏点是SQLite的文件膨胀问题。频繁增删改后,数据库文件会留下不少空隙,体积慢慢变大。我加了一个每周自动执行的数据库压缩操作,在首次启动时检查时间戳,超过一周就执行一次整理。这个操作耗时很小,但对长期使用的健康度帮助很大。
6.3 给使用者的备份建议
最后额外分享一个从实际使用中得到的经验:纯本地存储最怕卸载应用,数据会连同照片一起消失。所以我在设置页里加了一个导出备份按钮,一键把数据库和图片列表打包成一个JSON文件,用户可以用系统分享功能存到其他地方。
备份动作本身不复杂,重点是导出的JSON格式要自解释,包含版本号、导出时间和完整的数据表内容。这样一年后如果想把数据迁移到新设备,或者换一套新的本地应用,也能用脚本导入。做工具型应用,数据永远是用户最在意的东西,能不能带走决定了他们敢不敢长期用你。