ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter+OpenHarmony实战:家具记录App分类管理模块设计与实现

Flutter+OpenHarmony实战:家具记录App分类管理模块设计与实现

1. 项目背景与方案选型:为什么是Flutter + OpenHarmony + 分类管理

1.1 家具记录App到底需要记录什么

先交代一下需求来源。我自己一直有个毛病,大件家具买回家之后,保修卡、发票、购买凭证到处塞,等到真要找某张床的保修单时,翻遍整个柜子都找不到。后来干脆决定写一个家具购买记录App,把所有家具的信息统一收进去:名称、类型、价格、购买日期、保修月数、商家、备注,以及后面要扩的凭证照片。

这件事看起来简单,但实际做起来你会发现,没有“分类管理”这个基础能力,整个App就是一团乱麻。你买了一堆沙发、床、餐桌、灯具、收纳柜,如果所有记录平铺在一个列表里,时间一长根本没法看。所以第一个版本我就把分类管理的增删改查做扎实,再围绕它做列表、筛选和统计。这篇就重点讲分类管理这一块从数据模型到界面交互是怎么落地的。

这个项目适合谁看?如果你是第一次接触Flutter for OpenHarmony,或者手头正好要做类似的“记录型工具App”,可以参考我的整体结构和代码思路。如果你只是想把 Flutter 在 OpenHarmony 上的开发流程跑通,分类管理也是一个特别合适的练手模块——它不复杂,但涉及数据库、列表渲染、表单校验、关联表操作这些高频知识点,做完一遍基本能把整套开发链路摸熟。

1.2 Flutter for OpenHarmony的适配现状

先说明白为什么选 Flutter。这套 UI 框架本身是跨平台的,我用同一套 Dart 代码,既能跑在移动端,也能构建到 OpenHarmony 设备上。具体做法是使用社区维护的 flutter_flutter OpenHarmony 分支,把 Flutter 引擎和 Dart 运行时的能力映射到 OpenHarmony 的系统接口上。

我在动手前最担心的是“能跑起来吗”,实际测下来,Material 组件、列表、表单、路由这些日常用到的东西基本都能正常工作。真正的坑主要集中在平台插件上——凡是依赖原生能力(比如数据库、文件存储、相机)的第三方包,都要确认是否有对应的 OpenHarmony 适配实现,不能直接照搬 pub.dev 上的通用版本。这也是为什么我在分类管理这个模块里,数据库层特意选了一条保守路线:尽量只用基础 SQL 能力,少绕复杂插件。

开发流程上也有一个明显的区别:Flutter for OpenHarmony 不能像安卓那样一条 flutter build apk 直接出包,而是先把 Dart 代码编译成可供 OpenHarmony 工程引用的产物,再通过 DevEco Studio 构建成 HAP 安装包。换句话说,你的日常迭代节奏可以分成两部分:Futter 侧改完代码后用 Flutter 工具链编译验证,最终集成和调试再回到 OpenHarmony 工程里。

1.3 分类管理在需求中的位置

分类听起来是个小功能,但它其实是整个数据组织的核心。家具记录的第一维度是“这件家具是什么”,然后才轮到价格、日期这类属性。没有分类,后面的按类型筛选、按类型统计、按类型做预算分析全部无从谈起。

所以我在功能排期上把它放在最先做,而且要求三个基本能力必须同时具备:分类的增删改查、分类和家具记录的关联、分类维度的统计。增删改查是基础操作,分类和家具的关联决定了删除分类时怎么处理已有数据,统计则检验数据模型是否好用。这三个点都打通之后,后面的图像凭证、保修提醒、年度花费统计,都是在这个架子上加面板而已。

2. 数据模型设计:分类与家具的关系怎么定

2.1 分类表结构:字段、约束与默认值

分类表本身不复杂,但字段设计要一次想清楚,否则后面返工很麻烦。我的表结构如下:

字段名类型说明
idINTEGER主键,自增
nameTEXT分类名称,非空,如“沙发”“床”“餐桌”
iconTEXT分类图标标识,存的是资源名或符号文本,不存图片二进制
color_valueINTEGER分类对应的色值,存 ARGB 数值,用于列表和图表展示
sort_orderINTEGER排序权重,数值越小越靠前
created_atTEXT创建时间,ISO 8601 格式字符串

name 加非空约束是最基本的,但真正容易忽略的是 sort_order。如果不加这个字段,只能按插入顺序或名称排序,后续用户手动调整分类顺序时就得改数据表结构。我更习惯在一开始就给排序字段,默认值 0,新分类追加在末尾,以后要做拖拽排序也直接更新这个字段就行。

class Category { final int? id; final String name; final String icon; final int colorValue; final int sortOrder; final String createdAt; Category({ this.id, required this.name, this.icon = '', this.colorValue = 0xFF26A69A, this.sortOrder = 0, required this.createdAt, }); factory Category.fromMap(Map<String, dynamic> map) { return Category( id: map['id'] as int?, name: map['name'] as String, icon: map['icon'] as String, colorValue: map['color_value'] as int, sortOrder: map['sort_order'] as int, createdAt: map['created_at'] as String, ); } Map<String, dynamic> toMap() { return { 'id': id, 'name': name, 'icon': icon, 'color_value': colorValue, 'sort_order': sortOrder, 'created_at': createdAt, }; } Category copyWith({ String? name, String? icon, int? colorValue, int? sortOrder, }) { return Category( id: id, name: name ?? this.name, icon: icon ?? this.icon, colorValue: colorValue ?? this.colorValue, sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt, ); } }

提示:存分类图标时,我建议直接存一个字符串标识,比如 Font Awesome 的类名、自定义图标资源的 key、或者一段简单的符号文本,而不要把图片字节写进数据库。原因很简单,图标以后大概率会换,存路径或标识比存二进制好维护得多。

2.2 家具表与分类的关联方式

家具表是另一个核心表,它和分类的关系是“多对一”:一个分类下面有多个家具,一件家具只属于一个分类。所以我用 category_id 作为外键,关联到 categories 表的 id。

CREATE TABLE furniture ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category_id INTEGER, price_cents INTEGER NOT NULL DEFAULT 0, purchase_date TEXT, shop_name TEXT, warranty_months INTEGER NOT NULL DEFAULT 0, note TEXT, image_path TEXT, created_at TEXT NOT NULL );

这里有一个关键设计:category_id 允许为空。为什么要这样设计?因为分类是会被删除的,而家具记录是用户好不容易录入的历史数据,不能因为分类删除就跟着消失。所以删除分类时,我选择把关联家具的 category_id 置空,让它们变成“未分类”状态。这条规则必须在数据库层就定下来,否则 UI 层再怎么做保护都是隔靴搔痒。

如果你用的是 SQLite 的对外键约束,可以加一行 ON DELETE SET NULL,但我后面在 DAO 层自己用事务控制,这样行为更明确,也方便在删除前给用户提示“该分类下还有 N 件家具”。

价格用 price_cents 存整数分,而不是直接用 REAL 存元。这是记账类应用的老经验,浮点数做金额累计容易出精度问题,存分再除以 100 反而干净。

2.3 数据库封装与DAO分层

数据库选型上,我最终用了 SQLite。理由很直接:分类和家具记录都是结构化数据,用关系型数据库天然合适,查询筛选统计都好写。OpenHarmony 环境下,sqflite 这类插件需要确认有没有适配,我这边用的是社区适配版本,基本的 openDatabase、query、insert、update、delete 都正常。

封装结构我分了三层:DatabaseHelper 管连接和建表,CategoryDao 管分类表的读写,FurnitureDao 管家具表的读写。这样分类页面只需要依赖 CategoryDao,不会和家具的逻辑混在一起,后面加字段或者换数据库实现,改动面也小。

class DatabaseHelper { DatabaseHelper._(); static final DatabaseHelper instance = DatabaseHelper._(); Database? _db; Future<Database> get database async { _db ??= await _init(); return _db!; } Future<Database> _init() async { final path = join(await getDatabasesPath(), 'furniture_records.db'); return openDatabase( path, version: 1, onCreate: (db, version) async { await db.execute(''' CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '', color_value INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE furniture ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category_id INTEGER, price_cents INTEGER NOT NULL DEFAULT 0, purchase_date TEXT, shop_name TEXT, warranty_months INTEGER NOT NULL DEFAULT 0, note TEXT, image_path TEXT, created_at TEXT NOT NULL ) '''); }, ); } }

注意:不同适配版本的导入包名可能不一样,我这边是直接 import 适配版 sqlite 接口。如果你的项目里编译报找不到包,先确认依赖源是 pub.dev 通用版还是 OpenHarmony 适配版,不要盲目升级版本号。

3. 分类管理核心功能实现

3.1 分类列表展示:从查询到UI

分类列表页是整个模块的入口。我计划的 UI 结构是:顶部一个总览卡片(显示“共 N 个分类,M 件家具”),下面是分类列表,每条 ListTile 显示图标、名称、家具数量,右侧给一个编辑入口按钮,底部悬浮“新增分类”按钮。

列表数据从 CategoryDao 的 getAll() 拿,这个方法按 sort_order 和 id 双重排序,保证分类顺序稳定。

class CategoryDao { Future<List<Category>> getAll() async { final db = await DatabaseHelper.instance.database; final rows = await db.query( 'categories', orderBy: 'sort_order ASC, id ASC', ); return rows.map(Category.fromMap).toList(); } Future<int> insert(Category category) async { final db = await DatabaseHelper.instance.database; final map = category.toMap()..remove('id'); return db.insert('categories', map); } Future<int> update(Category category) async { final db = await DatabaseHelper.instance.database; return db.update( 'categories', category.toMap(), where: 'id = ?', whereArgs: [category.id], ); } }

页面里我把加载逻辑写成了 _load(),所有增删改操作完成后都调用它重新拉数据。这里有个很容易踩的坑:如果只是增删改后不刷新,列表就一直显示旧数据,用户会以为操作没生效。

class _CategoryListPageState extends State<CategoryListPage> { final CategoryDao _dao = CategoryDao(); List<Category> _categories = []; bool _loading = true; @override void initState() { super.initState(); _load(); } Future<void> _load() async { setState(() => _loading = true); final categories = await _dao.getAll(); if (!mounted) return; setState(() { _categories = categories; _loading = false; }); } // ... }

渲染时我用 ListView.separated 而不是 ListView.builder 直接铺,因为分类卡片之间加一点间距看起来更清爽。每个卡片左边放一个圆形容器,里面填分类图标和背景色,中间是名称和家具数量,右边放一个编辑图标按钮。这样列表本身就能完成“查看和进入编辑”两个操作,不需要额外的操作菜单。

3.2 新增与编辑:表单校验与保存

新增和编辑我共用一个页面 CategoryEditPage,通过构造函数传入的 Category 是否为空来区分模式。为空是新增,非空是编辑。

表单字段主要是名称、图标、颜色、排序。名称用 TextFormField,加必填校验和长度限制;图标用一个网格选择器,预置十多个常见家居类目图标,点选后回填;颜色用色块选择,预置几个低饱和度的色值,避免用户自己取色搞得花里胡哨;排序用一个简单的数字输入框,默认 0。

保存逻辑上,新增走 insert,编辑走 update。为了减少代码重复,我在页面里组装好 map 后统一调用:

final result = await Navigator.of(context).push<bool>( MaterialPageRoute( builder: (_) => CategoryEditPage(category: editing), ), ); if (result == true) { _load(); }

CategoryEditPage 内部的核心是校验和保存。校验注意点有两个:名称不能为空;名称在当前分类列表里不能重复。重复校验不能只在数据库层做唯一约束,因为那样报错信息不友好。我的做法是在保存前先查一次同名分类,如果存在且 id 不是当前编辑的分类,就提示“已有同名分类”。

TextFormField( initialValue: _nameController.text, maxLength: 10, decoration: const InputDecoration(labelText: '分类名称'), validator: (value) { if (value == null || value.trim().isEmpty) { return '分类名称不能为空'; } return null; }, )

保存按钮点击后,先调用 Form.validate(),再走重复检查,最后根据 _category == null 决定 insert 还是 update。弹窗里左上角的返回按钮也要处理:如果表单没做过任何修改,直接返回 null;否则提示“放弃修改?”,避免用户辛苦填了一半误触返回丢掉内容。

3.3 删除分类:数据保护与事务处理

删除分类是分类管理里最需要谨慎的地方,因为一旦删除,关联家具会被置为未分类。我在列表项的编辑按钮旁边没有放直接删除图标,而是把删除放到编辑页底部,防止误触。

删除前必须先查关联家具数量:

Future<int> furnitureCount(int categoryId) async { final db = await DatabaseHelper.instance.database; final result = await db.rawQuery( 'SELECT COUNT(*) AS total FROM furniture WHERE category_id = ?', [categoryId], ); if (result.isEmpty) return 0; return result.first['total'] as int? ?? 0; }

如果数量大于 0,弹 Dialog 明确告诉用户:“该分类下还有 N 件家具,删除后这些家具将变为未分类。”用户点确认后,CategoryDao 的 delete 方法在一个事务里完成两件事:先把关联家具的 category_id 置空,再删除分类记录。

Future<void> delete(int id) async { final db = await DatabaseHelper.instance.database; await db.transaction((txn) async { await txn.update( 'furniture', {'category_id': null}, where: 'category_id = ?', whereArgs: [id], ); await txn.delete( 'categories', where: 'id = ?', whereArgs: [id], ); }); }

为什么用事务?因为如果先删分类再更新家具,中间任何一步失败都会让数据不一致。事务保证要么全部成功,要么全部回滚。这件事值得养成习惯,凡是涉及多表联动的写操作,都放进事务。

4. 分类在其他模块的联动:选择器、筛选与统计

4.1 家具表单里的分类选择器

分类做出来之后,下一步就是让家具录入表单里能用上。我用的选择控件是 DropdownButtonFormField,数据源来自 CategoryDao.getAll(),用户选中后 category_id 就绑定到家具对象上。

这里有两个细节值得提。第一,当分类列表为空时,下拉框要禁用或显示提示“请先添加分类”,否则用户进来根本没法给家具归类。第二,编辑家具时要根据 Furniture.categoryId 回填下拉框的 value,并且 value 必须能在 items 里匹配到,否则 Flutter 会直接报错。我的做法是:如果 categoryId 对应的分类已经被删了,选择器显示“未分类”,保存后 category_id 写 null。

DropdownButtonFormField<int?>( initialValue: _categoryId, hint: const Text('请选择分类'), items: [ const DropdownMenuItem<int?>( value: null, child: Text('未分类'), ), ..._categories.map( (c) => DropdownMenuItem<int?>( value: c.id, child: Text(c.name), ), ), ], onChanged: (value) { setState(() { _categoryId = value; }); }, decoration: const InputDecoration(labelText: '分类'), )

注意:在较新的 Flutter 版本里,DropdownButton 的 value 写法已经调整过,如果你拿到旧代码编译不过,换成 initialValue 或检查控件本身支持哪一版参数。我在 OpenHarmony 分支上编译时也遇到过这种 API 差异,最终按当前适配分支的语法改。

4.2 按分类筛选家具列表

家具记录列表页需要支持按分类筛选。我的做法是在列表顶部放一排横向滚动的分类筛选标签,最前面是“全部”,后面跟着所有分类。用户点某个分类后,列表只显示该分类下的家具。

筛选逻辑可以写在 FurnitureDao:

Future<List<Furniture>> getByCategory(int? categoryId) async { final db = await DatabaseHelper.instance.database; if (categoryId == null) { final rows = await db.query('furniture', orderBy: 'created_at DESC'); return rows.map(Furniture.fromMap).toList(); } final rows = await db.query( 'furniture', where: 'category_id = ?', whereArgs: [categoryId], orderBy: 'created_at DESC', ); return rows.map(Furniture.fromMap).toList(); }

筛选 UI 我用了 ChoiceChip 而不是普通按钮,因为 ChoiceChip 自带选中态,用户能清楚看到当前筛选的是哪个分类。同时筛选标签的选中态要和列表空态配合:如果该分类下没有家具,显示“这个分类还没有家具记录”,并给一个“去添加”按钮直接跳转到家具编辑页。

这里有个体验细节:删除一个分类后,如果当前筛选项正好是这个被删的分类,要立刻把筛选状态重置回“全部”,否则界面会卡在一个不存在的分类上,列表一片空白,用户完全不知道发生了什么。

4.3 分类维度统计:COUNT与JOIN的配合

分类管理的价值不只体现在筛选,统计也是刚需。我在分类列表页的每个分类右侧显示“N 件”,用的是一条 LEFT JOIN 查询:

SELECT c.id, c.name, COUNT(f.id) AS total FROM categories c LEFT JOIN furniture f ON f.category_id = c.id GROUP BY c.id ORDER BY c.sort_order ASC

LEFT JOIN 和 INNER JOIN 的区别在这里很重要。用 INNER JOIN 的话,没有家具的分类根本不会出现在结果里,用 LEFT JOIN 才能把“0 件家具”的分类也带出来。

如果你想继续做花费统计,也很自然:把 COUNT(f.id) 换成 SUM(f.price_cents),就能得到每个分类的累计花费。这笔数据对家居支出分析很有用,比如“沙发类花了多少钱”“灯具类花了多少钱”,做年度总结时直接复用这个查询结构即可。

分类统计可以放在分类列表页里同步展示,不必单独做一个页面,减少不必要的跳转层级。拿数据时,CategoryDao 提供一个 countAll() 方法返回 Map<String, int>,key 是分类 id 的字符串,value 是家具数量。列表页在 _load() 里同时加载分类列表和统计结果,然后合并渲染。

5. 常见问题与避坑记录

5.1 OpenHarmony插件适配问题

最常被问到的问题就是“第三方库能不能直接用”。我的经验是:先分类。纯 Dart 写的库基本没问题,比如状态管理、日期格式化、颜色处理;凡是依赖通道调用原生能力(数据库、相机、定位、文件选择)的库,必须查有没有 OpenHarmony 的适配实现。

我这次分类管理用到的 sqflite 就是典型例子。pub.dev 原版假设的是安卓/iOS 的原生实现,在 OpenHarmony 工程里运行时,Dart 侧通过 MethodChannel 发请求,如果没有原生侧对应实现,就会一直超时或者报 MissingPluginException。

排查路径我建议这样走:先看依赖库的仓库里有没有 openharmony 或 ohos 相关目录,再看项目的 ohos 工程侧有没有注册对应的插件,最后在 Flutter 侧打印日志确认通道名和调用链。这套排查方法不局限于数据库,别的插件出问题也适用。

5.2 页面状态刷新不同步

分类管理这类 CRUD 页面,最容易出的问题就是操作后界面不刷新。我一开始写新增分类时,编辑页返回 true 后忘了调 _load(),导致列表里看不到新分类,还以为是数据库插入失败,折腾了十分钟才发现是刷新问题。

解决思路很简单:所有跳转到编辑页的地方都 await Navigator.push,根据返回结果决定是否拉数据;删除操作完成后直接 _load();筛选状态变化时同步请求新数据。还有一点容易被忽略,_load() 里 setState 之前一定要判断 mounted,否则在异步回调里 setState 会抛异常,OpenHarmony 的 Flutter 分支也一样需要这个保护。

5.3 体验细节:重名校验与交互提示

分类管理的隐藏坑都在边界情况里。我整理一个速查表,方便你自测:

场景问题正确行为
新增空名称分类数据库可能插入空字符串表单校验拦截,提示“不能为空”
录入同名分类列表出现两个一样名字的分类保存前查重,提示“已有同名分类”
删除有家具的分类家具数据丢失事务里置空 category_id,提示后再删
编辑分类时列表刷新分类顺序变化但 UI 没更新保存后重新查询并按 sort_order 排序
删掉的分类仍被筛选筛选标签指向不存在分类删除后重置筛选状态到“全部”

重名校验里还有一个边界:编辑一个分类时,如果分类本身名字没变,不应该提示“已有同名分类”。判断条件是“存在同名分类,且该分类 id 不等于当前编辑分类 id”,否则你只是想改个颜色都保存不了。

最后再分享一个从这次实战里得到的体会:分类管理看似是一个 App 里最不起眼的模块,但它把数据建模、连表查询、表单校验、状态管理全串起来了。把它做瓷实,后面的模块基本就是复制同样的套路。我自己在做的时候,最花时间的其实是删除策略和刷新机制这两块,想清楚之后代码写起来反而很顺。要是你也在写 Flutter for OpenHarmony 的有记录类应用,建议先把分类这个地基打好,后面会省非常多的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进