资讯动态

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

发布时间:2026/10/13 10:37:33 来源:尧图企业网站定制
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(MapString, 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, ); } MapString, 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; FutureDatabase get database async { _db ?? await _init(); return _db!; } FutureDatabase _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 { FutureListCategory 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(); } Futureint insert(Category category) async { final db await DatabaseHelper.instance.database; final map category.toMap()..remove(id); return db.insert(categories, map); } Futureint 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 StateCategoryListPage { final CategoryDao _dao CategoryDao(); ListCategory _categories []; bool _loading true; override void initState() { super.initState(); _load(); } Futurevoid _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).pushbool( 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 删除分类数据保护与事务处理删除分类是分类管理里最需要谨慎的地方因为一旦删除关联家具会被置为未分类。我在列表项的编辑按钮旁边没有放直接删除图标而是把删除放到编辑页底部防止误触。删除前必须先查关联家具数量Futureint 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 置空再删除分类记录。Futurevoid 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。DropdownButtonFormFieldint?( initialValue: _categoryId, hint: const Text(请选择分类), items: [ const DropdownMenuItemint?( value: null, child: Text(未分类), ), ..._categories.map( (c) DropdownMenuItemint?( value: c.id, child: Text(c.name), ), ), ], onChanged: (value) { setState(() { _categoryId value; }); }, decoration: const InputDecoration(labelText: 分类), )注意在较新的 Flutter 版本里DropdownButton 的 value 写法已经调整过如果你拿到旧代码编译不过换成 initialValue 或检查控件本身支持哪一版参数。我在 OpenHarmony 分支上编译时也遇到过这种 API 差异最终按当前适配分支的语法改。4.2 按分类筛选家具列表家具记录列表页需要支持按分类筛选。我的做法是在列表顶部放一排横向滚动的分类筛选标签最前面是“全部”后面跟着所有分类。用户点某个分类后列表只显示该分类下的家具。筛选逻辑可以写在 FurnitureDaoFutureListFurniture 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 ASCLEFT JOIN 和 INNER JOIN 的区别在这里很重要。用 INNER JOIN 的话没有家具的分类根本不会出现在结果里用 LEFT JOIN 才能把“0 件家具”的分类也带出来。如果你想继续做花费统计也很自然把 COUNT(f.id) 换成 SUM(f.price_cents)就能得到每个分类的累计花费。这笔数据对家居支出分析很有用比如“沙发类花了多少钱”“灯具类花了多少钱”做年度总结时直接复用这个查询结构即可。分类统计可以放在分类列表页里同步展示不必单独做一个页面减少不必要的跳转层级。拿数据时CategoryDao 提供一个 countAll() 方法返回 MapString, intkey 是分类 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 的有记录类应用建议先把分类这个地基打好后面会省非常多的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑