
家里东西越堆越多找什么都像开盲盒。前阵子搬家我在客厅翻来翻去找一把六角螺丝刀最后在储物间第三格的工具箱里才找到前后折腾了二十分钟。这件事让我认真考虑做一个居家好物收纳应用把每个物件的位置记下来平时搜索一下就能知道在哪。想要的体验很简单新增物品时拍张照、选好位置平时打开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技术栈的团队依赖原生控件鸿蒙控件差异需处理FlutterSkia自绘引擎社区适配可走通高质量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 StateNotifierProviderItemListNotifier, ListHomeItem((ref) { return ItemListNotifier(ref.read(itemRepositoryProvider)); }); class ItemListNotifier extends StateNotifierListHomeItem { ItemListNotifier(this._repository) : super([]); final ItemRepository _repository; Futurevoid loadAll() async { state await _repository.fetchAll(); } Futurevoid addItem(HomeItem item) async { await _repository.insert(item); await loadAll(); } }4.3 搜索与筛选的组合实现搜索是这个应用的核心体验。名称模糊匹配是基本功但多数人也记不住所有物品的准确分类所以搜索必须同时覆盖物品名称、分类名称和位置路径。这样输入“螺丝刀”能搜到输入“储物间”也能把该位置下的所有物品列出来。ListHomeItem 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 FutureString? 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格式要自解释包含版本号、导出时间和完整的数据表内容。这样一年后如果想把数据迁移到新设备或者换一套新的本地应用也能用脚本导入。做工具型应用数据永远是用户最在意的东西能不能带走决定了他们敢不敢长期用你。