恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Flutter for OpenHarmony:家具记录App添加房间功能开发实录
首页
资讯中心
/
Flutter for OpenHarmony:家具记录App添加房间功能开发实录
Flutter for OpenHarmony:家具记录App添加房间功能开发实录
发布时间:2026/10/15 4:50:17
最近在折腾 Flutter for OpenHarmony趁机用一套“家具购买记录”的练手项目把跨端开发完整流程跑了一遍。这个 App 的需求很朴素把家里每个房间买过什么家具、花了多少钱、什么时候买的都记下来免得以后再买同类型家具时心里没数。“添加房间”是整条业务链上的第一个核心功能房间数据管不齐后面的家具录入、预算统计全都跟着乱。这篇文章我按实际开发顺序来写先说为什么选 Flutter for OpenHarmony 和项目环境搭建再讲房间数据模型怎么设计然后到添加房间页面的完整实现接着看房间列表怎么跟后续家具功能联动最后把真机测试中遇到的几个兼容性坑点一次性列出来。适合两种人看一种是准备把现有 Flutter 项目迁到 OpenHarmony 的开发者另一种是想找个中等复杂度项目把数据层、状态管理、图片处理串起来的新手。1. 项目选型背后的逻辑以及环境搭建的三个卡点1.1 为什么是 Flutter for OpenHarmony而不是 ArkUI先明确一点如果你的目标设备只有 OpenHarmony纯用 ArkUI 当然没问题。但像我这类从 Flutter 生态过来的人往往手里已经有一批 Flutter 代码、几十个熟悉的自定义组件甚至线上项目还在跑。与其从头用 ArkUI 重写一遍 UI不如找 Flutter 的适配方案。Flutter for OpenHarmony 是开源社区基于 Flutter 框架做的平台适配它让 Flutter 的 Dart 代码能直接跑在 OpenHarmony 设备上。这到底意味着什么意味着你核心的 UI 代码、业务逻辑、状态管理方案都还可以沿用只要处理掉平台通道相关的部分就能满足“一套代码、多端运行”的需求。为什么拿家具购买记录 App 来练手因为这个场景的复杂度刚好踩在甜点上只有两个核心实体房间、家具但又需要关系映射、图片存储、金额汇总、搜索排序这些真实业务里常见的能力。既有 Form 交互可以写又有 SQLite 查询可以调又不至于像电商项目那样动辄几十张表花一个周末就能完整跑通。1.2 环境搭建的三个卡点如果你之前只装过 Flutter 官方 SDK第一次搭 OpenHarmony 的 Flutter 环境多半会在下面这三个地方卡住。第一SDK 版本匹配。这个最容易踩坑。并不是说你装个最新版 Flutter 就能直接跑 OpenHarmony得用仓库里指定的 Flutter 版本分支手动构建 SDK。我当时装的就是某个已经验证过稳定性的版本而不是最新版。建议严格按官方 README 的flutter --version校验输出确保 Dart 版本和引擎版本对齐。第二模拟器 vs 真机。模拟器在跑普通 UI 没问题但涉及相机拍照、相册选图这类能力时模拟器的表现跟真机差别非常大。我们的封面图功能用了 image_picker在模拟器上点击拍照直接崩检查日志发现是模拟器缺摄像头 HAL这在真机上完全不存在。建议从第一天开始就用真机调试宁可连线麻烦一点。第三第三方包的适配情况。Flutter 生态一大优势是 pub.dev 上包多但 OpenHarmony 适配不等于所有包都能跑。原因是很多包底层通过 Flutter 平台通道调用 Android/iOS 的原生能力而 OpenHarmony 侧的通道接收方不同这部分必须过滤。我的做法是凡是涉及平台的包先去仓库看有没有 OpenHarmony 相关 issue 或者 PR 记录再决定是否引入纯 Dart 包则基本放心。2. 房间数据模型先想清楚再写界面写功能代码之前我最喜欢先做一件事把表结构放在纸上画一遍。别觉得这一步多余“添加房间”看起来只有名字和照片两个字段但如果后续要做预算统计字段设计在第一步就定死后面改起来很痛苦。2.1 房间表需要哪些字段先给最终版本的 Room 模型代码class Room { final int? id; final String name; final String? coverPath; final double budget; final int createdAt; const Room({ this.id, required this.name, this.coverPath, this.budget 0, required this.createdAt, }); MapString, dynamic toMap() { return { id: id, name: name, cover_path: coverPath, budget: budget, created_at: createdAt, }; } factory Room.fromMap(MapString, dynamic map) { return Room( id: map[id] as int?, name: map[name] as String, coverPath: map[cover_path] as String?, budget: (map[budget] as num?)?.toDouble() ?? 0, createdAt: map[created_at] as int, ); } Room copyWith({int? id, String? name, String? coverPath, double? budget}) { return Room( id: id ?? this.id, name: name ?? this.name, coverPath: coverPath ?? this.coverPath, budget: budget ?? this.budget, createdAt: createdAt, ); } }简单过一遍字段设计idSQLite 自增主键没什么好说的注意插入成功后才赋值。name房间名称。这个字段看似简单却是所有查重逻辑的核心。用户大概率会输入“主卧”“次卧”“客厅”“书房”这类名字如果不去重列表里会出现两个主卧统计时会乱。所以我最终在逻辑层做了去重校验而不是完全依赖数据库索引。coverPath封面图的本地绝对路径。这里我选择了存路径而不是存二进制 BLOB。原因很简单图片压缩后也有几百 KB全塞进数据库会导致 SQLite 文件膨胀、查询变慢。存路径配合应用沙箱目录数据迁移时也容易处理。注意 App 被系统清理后沙箱目录会变所以路径不要写死要从接口动态取。budget房间预算。这个字段是调试统计功能时加上的。用户可以在添加房间时填一个预估预算后续家具总价超过预算时列表卡片会变红提醒。float 类型够用真要追求精确金额计算可以做成长整型存分但练手项目用 double 问题不大。createdAt存毫秒时间戳而不是存字符串。为什么排序和区间查询都更方便尤其后面做月度报表一条WHERE created_at BETWEEN ? AND ?就能搞定字符串日期还得先解析。2.2 为什么选 sqflite 而不是 SharedPreferences 或其他在 Flutter 里做本地存储方案无非几个存储方案数据模型关联查询OpenHarmony 适配推荐程度SharedPreferenceskey-value不支持稳定不推荐sqflite关系型原生 SQL稳定推荐drift类型安全封装支持待验证可选Hive / Isar对象存储较弱不稳定不推荐我最终选了 sqflite。理由说来也简单第一SQLite 在 OpenHarmony 上的兼容性经过足够多的验证很多数据库相关的包底层依赖它第二sqflite API 是 Flutter 生态的老朋友网上资料多第三后面家具表需要外键关联房间SQL 原生支持关系查询这点比 Hive 顺手。建表语句CREATE TABLE rooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cover_path TEXT, budget REAL NOT NULL DEFAULT 0, created_at INTEGER NOT NULL );注意一个小细节name字段我没有加 UNIQUE 约束而是在应用层做查重。原因是同一套数据库未来可能要支持多用户或者导入导出如果全局唯一卡死用户导入数据时会有一堆冲突要处理。应用层做查重提示语可以写得更好。2.3 数据库初始化与连接管理我习惯写一个单例的数据库管理类避免每个页面都重复初始化。核心代码class AppDatabase { AppDatabase._(); static final AppDatabase instance AppDatabase._(); Database? _db; FutureDatabase get database async { _db ?? await _open(); return _db!; } FutureDatabase _open() async { final path await getDatabasesPath(); return openDatabase( $path/furniture_app.db, version: 1, onCreate: (db, version) async { await db.execute( CREATE TABLE rooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cover_path TEXT, budget REAL NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ) ); await db.execute( CREATE TABLE furniture ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, purchase_date INTEGER, brand TEXT, note TEXT, FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE ) ); }, ); } }这里有一个很关键的设计furniture表建了外键并且在房间删除时级联删除下面所有家具。这个设计在“删除房间”功能时特别省心不需要在业务代码里手动清理子表数据。预算统计时也方便用GROUP BY room_id做聚合。3. 添加房间页面的完整实现3.1 页面结构与交互逻辑添加房间页面整体就是三块基本信息区房间名称、外观区封面图选择、费用区预算。为了保持逻辑清晰我用了一个Form包住两个输入用StatefulWidget管理图片选择和保存状态。页面结构大致如下class AddRoomPage extends StatefulWidget { const AddRoomPage({super.key}); override StateAddRoomPage createState() _AddRoomPageState(); } class _AddRoomPageState extends StateAddRoomPage { final _formKey GlobalKeyFormState(); final _nameController TextEditingController(); final _budgetController TextEditingController(); String? _coverPath; bool _saving false; // 表单校验 保存逻辑 }用Form而不是手写几个 TextField 的好处是每个输入项可以声明自己的validator点击保存时一个validate()就把所有字段一次性校验完不用自己写布尔状态的维护。3.2 表单校验的两个细节房间名称的校验逻辑很简单但有一个坑我要专门提一下validator: (value) { if (value null || value.trim().isEmpty) { return 请输入房间名称; } if (value.trim().length 12) { return 房间名称不能超过12个字; } return null; }为什么要限制长度因为列表卡片上名称会跟预算进度条挤在一行超长文本在卡片上要两行省略视觉上很丑。12 个字足够覆盖“儿童乐园储物间”这种极限场景。预算校验则要处理数字解析validator: (value) { if (value null || value.trim().isEmpty) { return null; // 预算可以为空默认为 0 } final num double.tryParse(value.trim()); if (num null || num 0) { return 请输入正确的金额; } return null; }这里有意让预算为空时合法默认记 0。因为很多用户添加房间时不关心预算只想先记录家具。校验发生在点击保存那一刻而不是边输入边校验。边输入边校验会让用户还没填完就满屏红字体验很差。3.3 封面图选取与压缩处理封面图这块我踩的坑最多。先是 image_picker 在 OpenHarmony 模拟器上崩溃然后是图片太大导致页面卡顿最后才加上压缩逻辑。选取流程Futurevoid _pickCover() async { final picked await ImagePicker().pickImage( source: ImageSource.gallery, maxWidth: 1280, maxHeight: 1280, imageQuality: 80, ); if (picked null) return; // 拷贝到应用沙箱目录避免引用系统相册的临时文件 final dir await getApplicationDocumentsDirectory(); final ext picked.path.split(.).last; final targetPath ${dir.path}/room_covers/${DateTime.now().millisecondsSinceEpoch}.$ext; await File(picked.path).copy(targetPath); setState(() { _coverPath targetPath; }); }这里有两个细节值得解释。第一image_picker返回的临时路径如果直接用App 退出后再进来可能指向已经被系统回收的缓存文件。所以一定要在选取后立刻copy()到自己应用的沙箱目录。这也是我建议所有类似场景统一的处理方式不持有系统临时文件的引用。第二maxWidth和imageQuality参数能省掉后续大部分压缩工作。1280 的宽度在手机封面图场景下完全够用imageQuality: 80能把 JPEG 压到照片原图 30% 左右的大小。如果省略这步一张 48MP 原图可能是十几 MB直接往 SQLite 里塞路径虽然没问题但列表打开时每张图都要加载一遍十几 MB 的文件滑动流畅性必崩。某些场景下你需要更精准控制输出尺寸比如房间封面在列表卡片上只有 80×80而详情页最多 640。那就在展示层再用cacheWidth参数做一层缩略Image.file( File(coverPath), width: 80, height: 80, fit: BoxFit.cover, cacheWidth: 160, )cacheWidth: 160让 Flutter 在解码时直接生成 160px 宽度的位图内存占用从 640×640 缩小到 160×160差距是 16 倍。这个优化在房间数量多了以后列表滑动是否掉帧就看它了。提示如果你的项目要处理大量图片建议在初始化阶段预生成多种尺寸的缩略图。保存在本地的 App 也要在写入后做一次尺寸统计避免极端尺寸文件拖垮列表性能。3.4 保存流程与返回结果点击保存按钮后的完整流程是校验表单 → 查重房间名 → 插入数据库 → 返回上一页并带上新房间数据。这里我想强调一个返回结果的设计模式很多新手会忽略。Navigator.pop可以直接传一个结果对象上一页用await push等待。这样列表页不需要在添加成功后重新打开数据库查询直接用返回的房间对象插到列表头部即可视觉上响应更快。Futurevoid _save() async { if (!_formKey.currentState!.validate()) return; setState(() _saving true); try { final db await AppDatabase.instance.database; // 查重 final existing await db.query( rooms, where: name ?, whereArgs: [name], limit: 1, ); if (existing.isNotEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(已有同名房间换个名字吧)), ); } setState(() _saving false); return; } // 插入 final room Room( name: name, coverPath: _coverPath, budget: budget, createdAt: DateTime.now().millisecondsSinceEpoch, ); final id await db.insert(rooms, room.toMap()..remove(id)); final savedRoom room.copyWith(id: id); if (!mounted) return; Navigator.pop(context, savedRoom); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(保存失败${e.toString()})), ); } } finally { setState(() _saving false); } }注意_saving状态保存过程中按钮要禁用防止用户连点两次插入两条重复数据。这是我实际测试时遇到的问题连着点两次保存列表里直接冒出两间“主卧”。加一个_saving标志位之后这个问题彻底解决。4. 房间列表与家具录入的联动设计房间列表是用户看到的第一屏也是所有其他功能的入口。我单独聊聊列表页的设计因为“添加房间”这个功能看似独立其实跟列表页、家具页都有数据契约。4.1 列表卡片的信息密度房间卡片我放了四个信息封面图、房间名称、家具数量、预算进度。其中家具数量要靠关联查询FutureMapint, int _countFurnitureByRoom() async { final db await AppDatabase.instance.database; final rows await db.rawQuery( SELECT room_id, COUNT(*) AS cnt FROM furniture GROUP BY room_id ); return { for (final r in rows) r[room_id] as int: r[cnt] as int, }; }这样一条 SQL 就能拿到所有房间的家具数量映射比一条条查 MySQL N1 式的循环高效得多。卡片布局用普通的ListView.builder就好不必上复杂的懒加载框架因为房间总数理论上不会超过几十个。在这里纠结性能优化属于过度设计有这个时间不如把交互打磨好。4.2 房间→家具的导航衔接每个房间卡片点击后进入家具列表页实现方式Navigator.push( context, MaterialPageRoute( builder: (_) FurnitureListPage(roomId: room.id!, roomName: room.name), ), );这里传roomId和roomName而不是传整个 Room 对象。为什么因为家具列表页是独立页面如果前后端数据打通后支持分享房间链接这个页面可能从别的入口进来那时只有 ID 而没有 Room 对象。传 ID 会让页面更健壮也方便后续做深链。家具页面本身也要支持继续添加家具添加成功后回到家具列表页。这里用的还是我在 3.4 里提到的返回数据模式。整个交互链路添加房间 → 返回列表页 → 点击房间 → 家具列表页 → 添加家具数据传递全部走返回值不走全局状态管理。对中小型 App 来说这个模式的依赖关系最清晰也好调试。4.3 列表刷新的两种时机房间列表刷新有两个时机必须覆盖首次进入页面时加载数据库数据。从添加房间页返回时插入新房间。第二种时机如果使用Navigator.push的返回值在then回调里做更新Futurevoid _openAddRoom() async { final newRoom await Navigator.pushRoom( context, MaterialPageRoute(builder: (_) const AddRoomPage()), ); if (newRoom ! null) { setState(() _rooms.insert(0, newRoom)); } }插入到列表头部而不是追加到末尾因为用户刚刚创建完一个新房间视觉上立刻能看到反馈比默默出现在列表底部舒服得多。5. 真机测试中踩过的兼容性坑到了真机环节才真正感受到 OpenHarmony 和 Android 的差别。以下四个问题都是我在实际调试时遇到的逐一列出排查过程和最终方案。5.1 中文输入法组合输入导致表单误校验在 OpenHarmony 设备上使用系统自带中文输入法输入房间名称时我遇到了一个现象拼音组合过程中TextField 的onChanged会多次触发而且value里会出现未上屏的拼音字母。如果对输入过程实时做校验就会出现用户刚打了“zhuwo”的“zhu”拼音就提示“房间名称已有”。解决方法是把校验完全收敛到点击保存那一瞬间触发不在onChanged里做任何校验逻辑同时在validator里对值先trim()再判断避免空格干扰。如果你一定要做实时校验可以用TextInputFormatter配合ComposingChanged事件判断输入是否处于组合状态。不过这个接口比较底层OpenHarmony 上的行为是否和 Android 完全一致还得实测。对练手项目来说只在保存时校验是最稳的方案。5.2 路由转场动画黑屏从列表页进入家具页时一开始使用MaterialPageRoute默认的转场动画在 OpenHarmony 真机上偶尔出现黑屏一闪。排查后发现不是路由代码的问题而是目标页面首帧构建时数据库查询耗时太长超过了一帧的预算时间导致首帧还没画出来就开始了转场观感上就是黑屏。两个对策一是把目标页面的耗时操作放到initState里异步执行页面先渲染骨架等数据返回后再填充内容。另一个是给路由设置一个浅色背景的页面容器黑屏主要是因为默认背景是黑色。把 Scaffold 的backgroundColor设为接近白色也能缓解视觉上的突兀。真正的根治方案还是后者加前者一起用。数据库查询超过 16ms 的情况在房间数量上百、家具上千时会出现不要在只有几条数据时就轻视它。5.3 相册权限申请差异Android 上用完image_picker选取相册照片前需要动态申请存储权限。OpenHarmony 在权限模型上有自己的一套如果按照 Android 的惯性思维处理会在回调里拿不到图片路径。我的建议是给 image_picker 单独包一层适配器。先尝试标准流程如果返回空或者抛异常降级使用原生文件选择器。class ImagePickAdapter { static FutureString? pickFromGallery() async { try { final picked await ImagePicker().pickImage( source: ImageSource.gallery, ); return picked?.path; } catch (e) { return _pickWithFallback(); } } static FutureString? _pickWithFallback() async { // 这里根据具体设备的文档实现文件选择逻辑 // 各版本 API 差异较大建议在真机上单独验证 return null; } }适配器模式下上层页面完全不感知底层是哪个平台后续 OpenHarmony 的 API 演进后也只需要改适配器一个文件。凡是平台相关的功能都隔离在一个文件里业务层不要出现Platform.isAndroid这样的分支。5.4 应用沙箱路径变化有个差点让我怀疑人生的 bug用户添加了十几条家具记录某天打开 App 后发现所有房间封面的图片都变成了空白。排查一圈发现原因是系统清理了应用缓存目录而我之前存封面图时用的是getTemporaryDirectory()返回的路径。getTemporaryDirectory()是临时目录系统环境一紧张就可能被清理。封面、用户头像这些需要持久保存的资产一定要放进getApplicationDocumentsDirectory()或getApplicationSupportDirectory()。这个区别在新手开发时特别容易被忽略等到用户数据丢了才追悔莫及。6. 后续扩展预算统计与数据导出最后聊聊这个练手项目可以怎么继续做。家具购买记录 App 如果只是添加房间和记录几件家具充其量是个本地表单工具。真正有价值的部分在数据沉淀之后的统计和回溯。6.1 预算超支预警现在 Room 模型里已经存了budget字段家具表里有price字段两者一拼就能做超支预警。查询所有房间总花费SELECT f.room_id, r.name, r.budget, COALESCE(SUM(f.price), 0) AS total FROM rooms r LEFT JOIN furniture f ON r.id f.room_id GROUP BY r.id, r.name, r.budget HAVING total r.budget;一条 SQL 直接查出所有预算超支的房间。房间卡片上的进度条颜色可以据此变化绿色低于 70%、黄色70%100%、红色超支。这样用户扫一眼列表就知道哪个房间该收手了。6.2 数据导出与备份考虑到本地数据对用户很重要建议做数据导出。最简单的方案是把整个 SQLite 文件拷贝到用户指定的目录或者生成一份 JSON 导出。JSON 的好处是以后若迁移到别的平台可以自己写脚本解析导入。furniture_app.db文件本身包含了所有房间和家具数据备份时用File复制到图片相册或文件管理器的某个目录即可。恢复数据就是反向复制回来。这个功能虽然只有几十行代码但在用户心中的价值往往比不少新功能都高。我还想提醒一点如果做导出功能记得在导出前先把数据库close()掉或者做好事务处理否则导出过程中有新写入备份文件可能处于不一致状态。轻量做法是导出时先调checkpoint确保 WAL 文件里的数据合并进主库再复制文件。6.3 关于跨端方案的取舍如果你是在 OpenHarmony 单平台部署Flutter 未必是必须的选择。但我个人对 Flutter for OpenHarmony 的定位是“以一套 Dart 代码同时覆盖现有 Android/iOS 用户和 OpenHarmony 设备”。这个透镜下它最大的意义不是替代 ArkUI而是让团队已有的技术栈平滑扩展到另一个系统省去重复造轮子的成本。开发过程中一个深刻的体会是Flutter for OpenHarmony 的生态已经能覆盖大部分常见场景但遇到边界问题时要愿意“往下钻一层”去看平台通道的源码而不是停留在 Flutter API 层面抱怨。比如权限申请那个坑就是因为我把适配层源码翻了一遍才发现 OpenHarmony 的权限返回值和 Android 的枚举不一致问题的根源不在文档里而在两套平台对同一语义的不同实现。最后说个实在的经验我把这个 App 给家人用了几天收到最多的反馈不是“界面好看”而是“还能不能加一个退货提醒”。你看用户真正关心的永远是数据能帮他们解决什么而不是技术选型多时髦。所以不管后面你是加统计也好加导出也罢始终从记录数据、回溯数据这条主线出发就不会做偏。我建议你也拿这个思路改造成适合自己的练手项目。不一定非要是房间和家具可以是书籍收藏、手办柜子、旅行足迹核心业务关系永远是“父容器 → 子条目”父容器做汇总统计。选一个你熟悉的生活场景把它完整跑通在 Flutter for OpenHarmony 上你会对跨端开发的差距和便利同时有很具体的感知。