买家具的时候挺开心的,刷卡、下单、等送货。但半年之后想查某个床垫的保修期,翻遍聊天记录和邮箱,就是找不到当初那张订单截图——这种经历我猜不少人都有过。基于这个痛点,我一直在琢磨做一个家具购买记录App:把每件家具的品牌、型号、价格、购买日期、保修信息都整理到一个地方,随时能查。正好那段时间我在研究 OpenHarmony 应用开发,又比较熟 Flutter,就决定用 Flutter for OpenHarmony 把这个 App 落地。
这片文章我会聚焦其中一块最基础、也最容易被做丑的模块:品牌管理。品牌管理看似只是简单的增删改查,但当你把它放在"家具购买记录"这个场景下时,会发现它牵连着数据模型设计、本地数据库、列表页交互、状态同步等一系列问题。如果你也在搞 OpenHarmony 端的 Flutter 应用开发,或者纯粹想看看一个工具类 App 的模块是怎么一步步搭起来的,这文章应该能给你一些直接的参考。
1. 为什么我选择 Flutter for OpenHarmony 来做这个家具账本
1.1 痛点场景:买家具一时爽,查记录火葬场
先说场景。很多家庭买家具是分散在不同时间、不同渠道的:实体店下单、线上商城购买、老家的亲戚帮买然后快递过来,各种凭证散落在发票、微信截图、订单短信里。当前需要用的时候,比如某个床垫出了塌陷问题要报修、某个餐桌想翻新刷漆需要找原厂色号,往往要找半天,甚至干脆找不到,最后只能吃哑巴亏。
所以这个 App 的核心价值不是让人"记账"上瘾,而是把家具资产档案建立起来。每一件家具需要记录:它属于哪个品牌、什么型号、多少钱买的、什么时候买的、保修到什么时候、商家的售后电话是多少。有了这套档案,未来任何一次查询、报修、估价处理,都有据可依。
在这个前提下,品牌就不是一个普通的字典表那么简单了。用户对品牌有两个高频诉求:一是回忆"我家里都有哪些牌子的家具",二是"这个牌子的东西出问题了找谁"。因此品牌模块必须能存下联系方式,能关联到具体的家具条目,还要在列表上直观展示该品牌下已记录了多少件家具。
1.2 框架选型的对比与理由
关于技术选型,其实我在初期纠结过三条路线:
| 方案 | 优势 | 劣势 |
|---|---|---|
| 原生应用开发 | 系统能力调用最方便,性能最好 | 开发工作量明显偏大,UI 代码量大 |
| Web 容器方案 | 上手快,前端技术栈复用 | 体验和性能有瓶颈,一些系统服务接入不够顺 |
| Flutter for OpenHarmony | 跨端复用、开发效率高、UI 一致性好 | 部分插件生态还没跟上,需要自己补齐平台通道 |
最终选 Flutter for OpenHarmony,核心原因有三个。第一是我的 UI 需求并不复杂,大部分页面无非是列表、表单、卡片、弹窗,Flutter 在这类界面上开发效率非常高。第二是 Flutter 的布局和状态管理机制足够成熟,后续如果这个 App 要迁移到其他桌面端、移动端,主体工程可以直接复用。第三是 OpenHarmony 本身是面向多设备的系统,Flutter 的跨端渲染能力与它比较契合。
当然也要承认,Flutter for OpenHarmony 目前不是直接用官方稳定版就能跑通的,还得做一些环境上的处理。这部分我会在下一章详细说。
2. 开发环境与工程初始化:从安装 SDK 到跑通第一个页面
2.1 环境清单与版本匹配
很多人在这一步就卡住了,原因是 Flutter 多版本并存,OpenHarmony 相关支持往往在独立的 SDK 分支上,直接装官方正式版可能找不到 OpenHarmony 设备。我的做法是这样:
- 先安装 OpenHarmony 的系统开发环境,包括 SDK、IDE 和命令行调试工具,保证一个最简单的空白应用能在模拟器或真机上跑起来。
- 再准备 Flutter SDK 的 OpenHarmony 适配版本。这里要留意区分:官方 Flutter 主分支和 OpenHarmony 适配分支是两个东西,别搞混。
- 环境变量里单独指向这个适配 SDK,不要覆盖我平时做其他 Flutter 项目的路径。
环境准备好以后,先在设备上执行一遍"连接检测",确认调试工具能识别到设备,再往下走。这一步看似简单,但能省掉后面很多"代码没错但装不上设备"的迷茫时间。实际遇到连不上设备时,优先检查驱动的设备授权、开发者模式开关,以及宿主机和设备的网络连通性,而不是急着怀疑 Flutter 工程。
2.2 创建项目并验证开发链跑通
环境没问题之后,用 Flutter 的工程创建命令生成一个空白项目。这里有个经验:不要直接在项目根目录里堆业务代码,先把目录结构规划好。我当时的工程组织大致是这样的:
lib/ main.dart models/ # 数据实体类 repositories/ # 数据访问层 viewmodels/ # 状态管理 pages/ # 页面 brand/ # 品牌模块 services/ # 平台通道封装创建完空白工程,先不改业务代码,直接编译安装到设备上,确认最基础的一行 "Hello World" 能显示。这一步能验证整条开发链——编码、编译、打包、安装、运行——是否真的通了。如果这一步有问题,问题大概率出在环境配置,而不是业务代码。
我印象比较深的是第一次编译时,构建时间比普通 Flutter 项目长不少,而且中间有一些依赖包下载超时的告警。后来检查发现是网络镜像配置的问题,手动把依赖仓库地址切到国内镜像后,整个构建流程稳定了很多。这里建议大家别跳过这一步,先把编译链路跑顺,后面迭代才不痛苦。
3. 品牌模块的数据层设计:表结构、实体类与存储封装
3.1 业务实体梳理与 ER 关系
开始写品牌管理前,我先花半天时间把数据模型理清楚了。一个家具购买记录 App 最核心的表有两张:品牌表和家具记录表,它们天然是一对多的关系——一个品牌下面可以有多件家具记录。
品牌表需要哪些字段?我的答案是这样:
id:主键,自增。name:品牌名称,必填,建议加唯一索引,避免"顾家"和"顾家家居"这种重复录入。phone:品牌售后电话或门店电话,用于后续报修。logo_path:品牌 Logo 的本地路径,可空。remark:备注,比如"安装师傅服务不错""售后响应慢"这类主观经验。created_at:创建时间,主要用于列表按时间倒序展示。
对应的家具记录表至少要包含这些:
id:主键。brand_id:外键,关联品牌表。删除品牌时这个字段的处理非常关键,后面在删除交互部分我会细说。name:家具名称,比如"三人位皮沙发"。model:型号。price:购买价格。purchase_date:购买日期。warranty_months:保修月数。status:状态,比如在用、转卖、报废。
有了这两张表,品牌模块就能回答很多实际问题:品牌列表要展示"该品牌下有几件家具",就是一条分组统计 SQL;品牌详情页要展示该品牌的所有家具记录,就是一次条件查询。
3.2 本地存储落地:通过平台通道调用 OpenHarmony RDB
数据模型确定后,最麻烦的问题来了:存哪里?在 Flutter 项目里做本地存储,第一反应是用 Sqflite 这类数据库插件。但 Flutter for OpenHarmony 的插件生态并没有完全跟上,Sqflite 这类依赖系统原生能力的插件当时没有对应的 OpenHarmony 实现,强行引入只会编译报错。
我最后采用的方案是:通过 MethodChannel 调用 OpenHarmony 系统自带的 RDB(关系型数据库)能力,在 Dart 侧封装一套异步的增删改查接口。这个思路不算复杂,但需要两端配合。
Dart 侧的封装大致长这样:
import 'package:flutter/services.dart'; class RdbService { static const MethodChannel _channel = MethodChannel('com.example.furniture/rdb'); static Future<List<Map<String, dynamic>>> query( String sql, List<Object?> args, ) async { final result = await _channel.invokeMethod('query', { 'sql': sql, 'args': args, }); return (result as List).cast<Map<String, dynamic>>(); } static Future<int> execute(String sql, List<Object?> args) async { final result = await _channel.invokeMethod('execute', { 'sql': sql, 'args': args, }); return result as int; } }OpenHarmony 那一侧(ArkTS),做的就是接收 SQL 和参数,使用系统自带的 RDB 接口执行语句,再把查询结果集序列化成 JSON 返回给 Dart。这里还有一个细节:SQL 参数不能用拼接字符串直接嵌入,必须要用占位符传参,既防止注入问题,也避免引号转义的麻烦。
3.3 数据层接口设计(Repository)
数据层我习惯再包一层 Repository,业务页面不直接碰 SQL,而是面向实体对象操作。品牌模块的 Repository 接口设计如下:
class BrandRepository { final RdbService _rdb; Future<int> insert(Brand brand) async { ... } Future<int> update(Brand brand) async { ... } Future<int> delete(int id) async { ... } Future<Brand?> findById(int id) async { ... } Future<List<Brand>> findAll() async { ... } Future<int> countByBrand(int brandId) async { ... } }这样做的好处是,如果后续要切换存储方案(比如换成其他数据库服务),只需要替换 Repository 内部实现,页面层的代码完全不用动。另外,所有查询都明确返回实体对象,不会出现"一堆 Map 散落在页面里"的坏味道。
实体类我用了一个不可变的写法:
class Brand { final int? id; final String name; final String? phone; final String? logoPath; final String? remark; final DateTime createdAt; const Brand({ this.id, required this.name, this.phone, this.logoPath, this.remark, required this.createdAt, }); Brand copyWith({...}) { ... } }创建时间我没有用字符串,而是直接存毫秒时间戳,排序、格式化都方便,也不会因为时区问题显示错乱。
4. 品牌管理界面实战:列表、表单与删除交互的完整实现
4.1 品牌列表页:从模型到视图
界面部分从品牌列表页开始。列表页要完成三件事:展示所有品牌、显示每个品牌下的家具数量、提供跳转编辑页和新增页的入口。
页面结构我用 Scaffold + AppBar + 列表 + 新增按钮。核心代码如下:
class BrandListPage extends StatelessWidget { const BrandListPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('品牌管理')), body: Consumer<BrandListViewModel>( builder: (context, vm, _) { if (vm.isLoading) { return const Center(child: CircularProgressIndicator()); } if (vm.brands.isEmpty) { return const _EmptyView(); } return ListView.separated( itemCount: vm.brands.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) => _BrandCard(brand: vm.brands[index]), ); }, ), floatingActionButton: FloatingActionButton( onPressed: () async { final changed = await Navigator.push( context, MaterialPageRoute(builder: (_) => const BrandEditPage()), ); if (changed == true) { context.read<BrandListViewModel>().load(); } }, child: const Icon(Icons.add), ), ); } }品牌卡片长什么样也有讲究。我把每一个品牌做成一个横排卡片:左边是品牌首字的圆形 Logo(后续有图片就替换成图片),中间是品牌名和"共 N 件家具"的小字,右边是箭头跳转详情。列表的空态也不是简单的"暂无数据"一句话,而是给了一个引导按钮,让用户知道下一步可以去新增品牌。
_BrandCard里那个"N 件家具"信息,来自一张统计表,展示的是这个品牌关联了多少条家具记录。这里我刻意没有用嵌套子列表去查,而是用一条LEFT JOIN + GROUP BY的 SQL 把所有品牌和数量一次性查出来,避免列表项逐个查询数据库造成性能问题。
4.2 新增与编辑表单:校验、Logo 与提交流程
新增和编辑我用的是同一个页面,通过一个brandId参数区分,这是很常见的"编辑/新增合一"做法。表单包含四个输入项:品牌名称、联系电话、Logo、备注。
因为是表单页面,我把每一个字段都包在TextFormField里,配上明确的校验逻辑:
- 品牌名称必填,且要校验唯一性。如果新增时发现同名品牌已存在,不能直接入库,而是提示用户"这个品牌已经记录了,是否要跳转到已有品牌?"这种交互比简单拦截更有温度。
- 电话号码做一次格式校验,至少不能是一串乱写的字符。
- Logo 这块我最初想用系统相册选择器,但在 OpenHarmony 上当时没有现成的 Flutter 插件支持,临时方案是让用户输入一个颜色值 + 自动取品牌名称首字生成 Logo。这个方案我们在踩坑章节还会展开。
表单底部的保存按钮自带 loading 状态,点击后先做本地校验,再调用 Repository 入库,成功后返回上一页并把结果标记为"已修改",这样上一页就知道需要刷新列表了。
对手机号这类信息,注意页面不要明文散落太多无用输入框。品牌管理在这里的语义是"让人能快速联系上售后",所以电话号码比品牌官网地址更重要,UI 上我把它放在了第二显眼的位置。
4.3 删除与确认弹窗:级联策略怎么选
删除是品牌模块里最大的坑,因为表面上删的是一条品牌记录,实际上牵扯到一批家具记录。我的处理策略是:默认不级联删除家具记录,而是把家具记录置为"未知品牌"状态。
具体的交互是这样:
- 如果品牌下没有家具记录,弹出确认框后直接物理删除。
- 如果品牌下有家具记录,弹窗里明确提示"N 件家具关联了该品牌",并提供两个选项:"删除品牌并保留家具(家具变为未知品牌)"和"取消"。不提供"连家具一起删除"的选项,因为购买记录是用户长期积累的数据资产,误删代价太大。
这个取舍我强烈建议做购买记录类 App 的朋友认真对待。很多开发者在做删除功能时习惯给一个"全部删除"的选项,功能上省事,但对用户来说风险极高。家具购买记录不是商品购物车,删错了很难找回。
同时,品牌删除后,家具列表里的品牌显示要兼容空值。我在家具列表页对brandId == 0的情况统一渲染为"未知品牌"灰色标签,点击后提示"该品牌记录已删除,可修改此家具重新关联品牌"。这样既保留了数据,又给了用户修复杂质的路径。
5. 状态管理梳理:品牌列表如何与全局购物记录联动
5.1 状态管理选型:轻量且跨平台
这个 App 规模不算大,状态管理我选了 ChangeNotifier + Provider 这套组合,没有引入更重的状态管理框架。原因很直接:我们所有页面之间的共享状态,品牌列表和家具列表是典型的"父列表刷新,子页面跟着刷新"的场景,Provider 已经完全够用,而且它本身就是纯 Dart 实现,不依赖平台原生代码,在 Flutter for OpenHarmony 上使用没有额外的兼容负担。
状态仓库我建了一个BrandListViewModel:
class BrandListViewModel extends ChangeNotifier { final BrandRepository _repository; List<Brand> _brands = []; bool _isLoading = false; BrandListViewModel(this._repository); List<Brand> get brands => List.unmodifiable(_brands); bool get isLoading => _isLoading; Future<void> load() async { _isLoading = true; notifyListeners(); _brands = await _repository.findAllWithCount(); _isLoading = false; notifyListeners(); } Future<bool> addBrand(Brand brand) async { await _repository.insert(brand); await load(); return true; } }在main.dart里通过ChangeNotifierProvider注册后,页面里用Consumer监听变化。这个模式的好处是:当我从品牌详情页修改了关联家具的品牌、或者从家居记录页新增了一条家具后,只需调用对应 ViewModel 的load(),所有监听该状态的页面都会自动刷新,不需要手动传参层层回调。
5.2 品牌列表的发布-订阅刷新机制
这里有一个容易写错的细节:notifyListeners()之后,UI 并不是自动做到最高效的刷新。如果你在列表页里直接notifyListeners()而没有任何数据变化,Flutter 仍然会重建整个页面。我的经验是:
- 在
load()里先对比新旧数据:如果列表内容和数量都没变化,可以直接跳过notifyListeners(),减少无谓重建。 - 列表页的
Consumer尽量放在body层级,不要包住整个Scaffold,否则 AppBar 的标题也会跟着重建。 - 修改品牌名称后返回列表页,不要整页刷新,可以用
ListView的 item 局部更新,但为了简单稳定,我当时选择还是整体load()一次——这个 App 的数据量撑得住,用户体验差别不大。
另外,品牌列表和全局购物记录的联动是通过"共用同一个 RDB 数据源"实现的。品牌删除时把家具记录置为 0,这个操作要和"删除品牌本身"放在同一个事务里执行,否则一旦中间崩溃,就会出现品牌删了、家具记录还挂着失效外键的情况。事务的原子性在这类双写操作里非常重要。
6. 实测踩坑记录与后续优化:这几处坑建议直接抄
6.1 插件生态缺口与替代方案
前文多次提到 ImagePicker 这类插件在 OpenHarmony 上适配不完整,这里系统说一下我的绕行方案。Logo 录入这块,我最初确实是想做成"相册选图",但调研一圈后发现没有现成的插件可以用,自己写平台通道又涉及系统相册的权限申请和文件访问,短时间内做不完。
所以我改成了两步走:
- 第一步,先做"文字 Logo"方案:用户不传图片,系统根据品牌名称自动取第一个字符,配上一个从固定色板里随机选的颜色,生成一个圆形的文字头像。这个方案从设计角度看并不丑,甚至比很多 App 默认的灰色占位图更统一。
- 第二步,预留了
logoPath字段和平台通道接口,等后续拿到相册能力或者用户主动上传时,直接替换渲染逻辑即可,数据层不需要改动。
类似地,Toast 提示、版本更新弹窗这类依赖系统视觉风格的组件,在 OpenHarmony 上也不能直接使用 Material 风格想当然。这里我的建议是优先使用纯 Flutter 绘制的组件,或者通过 PlatformChannel 调用系统能力,不要在页面里混用两套视觉体系,否则界面风格会非常割裂。
6.2 中文字体渲染与滚动性能的实际问题
实际装机测试时,中文渲染和滚动性能也都是问题。
中文字体的第一个坑是行高不一致。Flutter 在 OpenHarmony 上默认的字体管线对中文字体渲染有时会产生较大的height差异,导致卡片里的品牌名和型号文字错位。解决方法是给所有文本组件显式设置行高:TextStyle(height: 1.3)之类,不要依赖默认值。
滚动性能的坑出现在品牌列表数据量上去以后。当品牌超过 200 条时,如果每个卡片里面还要计算"该品牌下家具件数"的文案,而数据源又没有做合并查询,就会出现滑动掉帧。我排查后的优化点有两个:
- 列表查询由"逐条查数量"改成"一次 SQL 分组统计",查询耗时从几秒降到几百毫秒。
ListView.builder的 item 回调里不要做重复的TextPainter计算,所有静态文本和样式提前提取为常量或成员变量。
另外,品牌卡片我用itemExtent固定了高度,也顺带解决了图片未加载时列表高度抖动的问题。这个做法在条目高度统一时非常实用,推荐直接抄。
6.3 数据库升级与数据兼容
最后聊一个被很多人忽略的问题:数据库升级。品牌表、家具记录表在第一版上线后,几乎必然会遇到加字段、加索引的情况。如果直接改建表 SQL,老用户的本地数据库就崩了。
我的做法是:在 RDB 服务里维护一个version字段,每次启动时读取数据库版本,如果发现旧版本,则执行对应的迁移脚本。比如后来我给品牌表加了phone字段,迁移脚本就是先判断列是否存在,不存在则执行ALTER TABLE brand ADD COLUMN phone TEXT。这种迁移代码要尽量幂等,重复执行不报错。
还有一个细节是:不要直接覆盖旧数据库,而是在ALTER TABLE之前先备份原文件。移动端的本地数据库虽然体量不大,但用户辛苦记录的数据是无价的,多一道备份就多一分安全。
在家具购买记录这个场景里,品牌管理确实不是最花哨的模块,但它决定了整个 App 的数据骨架是否稳定。我在实际开发中最深的感受是:模块虽小,但数据模型、存储方案、状态同步、删除策略、甚至中文渲染这些细节,一个都不能少。尤其是 Flutter for OpenHarmony 这个组合还处在快速迭代期,很多方案不能照搬普通 Flutter 的成熟插件,需要自己对底层机制和替代路径有足够的理解。
最后再分享一个实际操作中的小技巧:品牌名称的录入一定要做去空格、去首尾空白和简繁体统一处理,否则"顾家 "和"顾家"会被当成两个品牌,分组统计时数据会变得非常难看。这个小问题看起来微不足道,但一旦用户录了上百条记录后遇到,简直想砸手机。