房间列表这个功能是我在做家具购买记录 App 时第一个从单一页面迈向多房间数据管理的转折点。OpenHarmony 设备上跑 Flutter听着像是个折腾活儿但实际走通之后你会发现这套组合比想象中顺手。这篇文章不聊概念直接把我实现房间列表的完整过程拆开给你看——从环境准备到 Provider 状态管理再到 UI 布局和真机调试每一步都附上我踩过的坑和当时的思考。1. 为什么我用 Flutter 来写 OpenHarmony 应用先说结论如果你只想为鸿蒙生态写一个应用ArkTS 确实是最正统的选择。但如果你的目标是一套代码多个平台都能跑或者你本身已经熟悉 Flutter 而不想再学一套 UI 框架那 Flutter for OpenHarmony 是一条值得走的路。1.1 Flutter 与 ArkTS 的选型取舍我在决定技术方案时列过一张对比表维度FlutterArkTS跨平台能力同一套代码可编译到 Android、iOS、Web、OpenHarmony仅 OpenHarmony及鸿蒙系UI 渲染自绘引擎跨端表现一致基于鸿蒙原生组件系统风格强开发语言DartTypeScript 超集ArkTS组件生态沿用 pub.dev 大量 Flutter 包多数可直接用主要靠鸿蒙官方和贡献者维护的库状态管理Provider、Riverpod、Bloc 等丰富方案ArkData、ViewModel 等原生方案学习成本会 Flutter 几乎零额外成本需要学习 ArkTS 语法和鸿蒙 API对我这个场景来说核心需求是快速迭代、界面统一、数据管理清晰而且我手头已经有 Flutter 的开发经验于是选择了 Flutter。唯一需要留意的就是你最终要构建ohostarget这跟常规的apk或app构建流程不同后面会细说。1.2 Flutter for OpenHarmony 目前在实战中的成熟度这个适配项目从官方flutter/flutter的ohos分支拉出来社区一直在推进。我在实操过程中最有感触的一点是纯 UI 层面和 Dart 逻辑层面可以完全复用真正需要额外处理的是系统能力和原生插件。比如拍照、文件读写这种涉及原生能力的功能需要确认 Flutter 插件是否已经有 OpenHarmony 的适配实现或者是否要用通道自己写。但这篇文章要做的房间列表相对简单不涉及原生调用核心就是干净的数据管理和 UI 表现所以 Flutter 的适配问题基本不会碰到。2. 开发环境搭建与工程初始化这一步最容易劝退如果你不是第一次接触 Flutter环境搭建会很快但如果你在 Windows 上同时想处理 OpenHarmony target那有几个点需要特别小心。2.1 安装 Flutter SDK 的 ohos 分支这里必须强调一个容易踩的坑不要直接下载普通 Flutter SDK要使用适配了 OpenHarmony 的分支。常规 Flutter 的stable分支已经支持过了 OpenHarmony 的插桩构建但为了保险我使用的是社区维护的flutter_flutter项目。我的实际操作是这样的git clone -b dev https://gitee.com/openharmony-sig/flutter_flutter.git git clone -b dev https://gitee.com/openharmony-sig/flutter_engine.git git clone -b master https://gitee.com/openharmony-sig/flutter_packages.git这三个仓库分别对应 Flutter 框架、引擎和插件仓库。克隆完成后还需要准备 OpenHarmony 的 SDK我使用的是通过 DevEco Studio 下载的ets和toolchains目录。在配置完环境变量后可以用以下命令验证flutter doctor如果你在flutter doctor的输出里看到 OpenHarmony 的检测项说明环境基本就位。这里有一个我踩过的坑如果直接用官方渠道下载 Flutter SDK执行flutter create --platformsohos会失败因为它没有内置 ohos 的 platform 支持。2.2 创建工程时不能漏掉的参数工程创建这一步公式很简单flutter create --platformsohos,android .我加了android是为了方便在 Android 模拟器上快速调试 UI 表现毕竟 OpenHarmony 模拟器不是到处都有。如果你不需要 Android target可以只保留ohos。创建完之后检查pubspec.yaml默认依赖是cupertino_icons然后我追加了provider用于状态管理。dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.6 provider: ^6.1.22.3 DevEco Studio 与项目目录结构的关系在 Flutter 工程生成后你会看到一个ohos目录这就是 OpenHarmony 的壳工程。你在 Flutter 层写的 Dart 代码会被编进这个壳里最终打成.hap包。用 DevEco Studio 打开ohos目录时它会让你配置 SDK 路径同时可能触发 Gradle 和 Cmake 的下载这一步挺耗时。第一次打开时最好保持网络畅通耐心等进度条走完。如果中途失败直接在 DevEco 里执行菜单栏的File - Sync and Refresh Project重试即可。3. 房间数据模型与 Provider 状态管理设计一个房间列表看似只是把几个房间名字展示出来但实际上要考虑的数据问题不少每个房间有哪些家具、家具花了多少钱、购买时间是什么时候、房间的封面图用什么占位。数据模型理清楚后面的界面和交互才不会乱。3.1 先定义 FurnitureItem 还是先定义 Room我习惯从最细粒度的实体开始反推。这个 App 的核心实体是家具家具归属于房间所以先定义FurnitureItem再定义Room会更自然。class FurnitureItem { final String id; final String name; final String roomId; final double price; final String purchaseDate; final String imagePath; // 本地路径或网络 URL FurnitureItem({ required this.id, required this.name, required this.roomId, required this.price, required this.purchaseDate, this.imagePath , }); }房间模型里除了房间名和 ID还需要一个图标标识。考虑到客厅卧室书房这类固定分类我直接用roomType枚举配合预设图标避免让用户自己传图标文件。enum RoomType { livingRoom, bedroom, kitchen, study, bathroom, other } class Room { final String id; final String name; final RoomType type; final ListFurnitureItem furnitureList; Room({ required this.id, required this.name, required this.type, this.furnitureList const [], }); }3.2 Provider 的角色与为什么不用 setState房间列表页面有一个典型场景用户在房间 A 里新增了一件家具返回列表时房间 A 的总价和家具数量要同步变化。如果只用setState管理这种跨页面数据同步会变得很痛苦。于是我用ChangeNotifier Provider。这个方案不重不需要引入 Bloc 那种复杂的事件流学习成本低当前项目规模下完全够用。class RoomStore extends ChangeNotifier { ListRoom _rooms []; ListRoom get rooms List.unmodifiable(_rooms); void addRoom(Room room) { _rooms.add(room); notifyListeners(); } void addFurnitureToRoom(String roomId, FurnitureItem item) { final index _rooms.indexWhere((r) r.id roomId); if (index ! -1) { _rooms[index].furnitureList.add(item); notifyListeners(); } } void deleteFurniture(String roomId, String furnitureId) { ... } }在main.dart里注入 Storevoid main() { runApp( ChangeNotifierProvider( create: (_) RoomStore()..loadMockData(), child: const FurnitureApp(), ), ); }组件内部读取数据就非常清爽了final roomStore context.watchRoomStore();这里就回答了热门搜索里的 flutter provider 怎么用——它的核心用法就是定义 ChangeNotifier、在顶层传入、在需要的 Widget 里watch或read。3.3 模拟数据的组织方式在还没有接数据库之前我直接在 Store 里构造了一批初始数据用来验证 UI 效果。这里有一个值得分享的小技巧把模拟数据的构建独立成一个方法并在类型注解中统一使用ListRoom以后替换成本地数据库或后端接口时只需改动 Store 内部实现View 层不用动。void loadMockData() { _rooms [ Room( id: room_1, name: 客厅, type: RoomType.livingRoom, furnitureList: [ FurnitureItem( id: f_1, name: 双人布艺沙发, roomId: room_1, price: 3199, purchaseDate: 2025-03-12, ), FurnitureItem( id: f_2, name: 实木茶几, roomId: room_1, price: 1299, purchaseDate: 2025-03-12, ), ], ), Room(id: room_2, name: 主卧, type: RoomType.bedroom, furnitureList: [...]), ]; notifyListeners(); }4. 房间列表 UI 实现从卡片布局到空状态处理UI 这部分我走了三版第一版用ListView堆卡片比较保守第二版改成了GridView双列房间数量少时显得空旷最终折中方案是不固定双列而是让卡片自适应宽度并保留底部的添加房间入口。4.1 页面整体结构房间列表页主要由三块组成顶部的统计概览、中部的房间网格、右下角的悬浮添加按钮。统计概览和房间数据都来自 Provider这样就不会出现页面 A 加了房间、页面 B 总数没更新的问题。Scaffold( body: SafeArea( child: ConsumerRoomStore( builder: (context, store, child) { if (store.rooms.isEmpty) { return const EmptyRoomView(); } return CustomScrollView( slivers: [ SliverToBoxAdapter(child: SummaryHeader(rooms: store.rooms)), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 220, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.82, ), delegate: SliverChildBuilderDelegate( (context, index) RoomCard(room: store.rooms[index]), childCount: store.rooms.length, ), ), ), ], ); }, ), ), floatingActionButton: FloatingActionButton( onPressed: _showAddRoomDialog, child: const Icon(Icons.add), ), )注意我用的是Consumer而不是context.watch纯粹是因为 builder 里同时要使用 store 数据并处理空状态逻辑这样包裹范围更明确。4.2 房间卡片的信息层级与视觉表现每张房间卡片里我放了四类信息房间名称与类型、家具数量、总花费、封面图没有真实图片时用图标。信息层级要清楚不能平均分配。class RoomCard extends StatelessWidget { final Room room; const RoomCard({super.key, required this.room}); override Widget build(BuildContext context) { final totalPrice room.furnitureList.folddouble( 0, (sum, item) sum item.price); return Card( clipBehavior: Clip.antiAlias, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: InkWell( onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) FurnitureListPage(roomId: room.id), ), ); }, child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon(_iconForRoomType(room.type)), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(room.name, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(${room.furnitureList.length} 件家具 · ¥${totalPrice.toStringAsFixed(0)}, style: TextStyle( fontSize: 12, color: Colors.grey[600])), ], ), ), ], ), ], ), ), ), ); } }这里有个容易忽略的点点击卡片跳转到家具列表页面时一定要传roomId而不是传整个Room对象。因为家具列表页里可能会修改房间内的家具数据如果直接传对象引用会让数据流变得不可追踪传 ID 后家具列表页统一通过 Provider 获取最新房间数据能有效避免旧数据覆盖新数据的问题。4.3 自定义加载占位与空状态联网加载或首次进入没有数据时我设计了一个简易的骨架屏让界面不会一下子空白。骨架屏的做法很直接用灰底圆角块模拟房间卡片的尺寸再配合一个轻微透明度动画。class RoomCardSkeleton extends StatelessWidget { const RoomCardSkeleton({super.key}); override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.03), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _SkeletonBox(width: 40, height: 40, borderRadius: 12), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SkeletonBox(width: 80, height: 16), const SizedBox(height: 8), const _SkeletonBox(width: 120, height: 12), ], ), ), ], ), ], ), ); } }空状态页面也不复杂居中放一个暂无房间的图标和文字外加一个去添加房间按钮。这样的交互闭环虽然简单但实际体验会比白屏好很多。4.4 添加房间弹窗的实现细节默认条目里用的是中文类型所以新增房间时我用了底部弹窗加表单的方式而不是生硬的全屏页面。弹窗里包含房间名称输入框和类型选择器确认后调用store.addRoom()。一个细节表单校验要阻止房间名为空的情况用TextFormField的validator能顺手解决。同时如果用户输入重名房间我也是允许的——因为现实中可能有两个储物间。如果要做额外限制可以在addRoom方法里判断同名但这不是当前需求的重点。5. 运行到 OpenHarmony 设备/模拟器的流程与踩坑记录在写房间列表的过程中我把应用跑到了 OpenHarmony 模拟器上也插过真机。这个环节比普通 Flutter 开发要多几步但也完全在可控范围内。5.1 构建 ohos target 的正确姿势首先确认你当前设备已连接可以用hdc list targets查看。hdc 是 OpenHarmony 的命令行工具类似于 adb。接下来运行flutter build hap --debug如果你没有在--platforms里加ohos这一步会报错说找不到 ohos platform。如果正常构建你会在build/ohos目录下生成.hap文件。然后使用 hdc 安装hdc install build/ohos/app/build/outputs/default/xxx.hap这里有个常见问题hdc 安装 .hap 时提示签名不对。OpenHarmony 默认情况下只允许安装带签名的 hap。如果调试阶段遇到这个问题一种简单做法是在 DevEco Studio 里配置自动签名或者生成调试证书导入。我在做 UI 层调试时直接用 DevEco 的自动签名功能解决的没有去折腾手动签名。5.2 真机调试时的日志查看方法运行后想看print日志用hdc shell hilog即可。过滤 Flutter 的关键字的命令是hdc shell hilog | grep flutter由于 Flutter 引擎在 ohos 上会把 Dart 侧的输出转发到 hilog所以这个方式能覆盖绝大多数调试场景。相比之下Flutter 默认的flutter logs在当前 OpenHarmony 适配下并不总是可用优先用 hilog。对了还有一个很实用的小技巧你可以在工程里通过debugPrint打印带 tag 的内容然后 grep tag 的名称例如debugPrint([RoomList] refresh complete, total${store.rooms.length});然后hdc shell hilog | grep RoomList这样只看关心的日志不会被引擎日志淹没。5.3 与 Android 模拟器的差异点OpenHarmony 模拟器的某些渲染表现和 Android 有细微差别。最明显的是中文字体的字重渲染OpenHarmony 默认字体在某些像素密度下偏细如果你的卡片文字用了FontWeight.w600看起来可能不如 Android 上醒目。但这不影响布局和数据逻辑发布到真机时按需微调 fontFeature 或字体 fallback 即可。另外热重载hot reload在 ohos target 上可用但比 Android 稍微慢一点。我记得有一次修改了 Provider 的notifyListeners()调用时机热重载后 UI 刷新正常这得益于 Flutter 引擎的热重载机制在 OHOS 上已经跑通了。实测下来很稳但还是建议不要频繁热重载改模型结构容易遇到 Dart 侧 AOT/JIT 切换导致的编译缓慢。6. 房间列表后续的可扩展方向房间列表只是第一步。以我目前实现的数据模型后续可以很顺畅地扩展这几个功能按房间查看家具明细房间卡片点击进入家具列表页数据源从Room.furnitureList取即可。家具购买记录筛选按日期范围、按价格区间筛选可以在RoomStore上增加filteredRooms()方法不影响现有页面结构。房间排序与置顶在Room实体上加一个sortWeight字段然后在_rooms排序时处理。持久化存储现在loadMockData()是固定数据后续可以换成sqflite在 OpenHarmony 上可能需要找适配的数据库插件或者直接写 JSON 文件存到应用沙盒目录。我在写这个项目时最大的体会是Flutter for OpenHarmony 的应用开发已经不再是能跑 Demo的程度只要不碰深度系统能力纯 Flutter 的业务页面完全可以平滑迁移到 OpenHarmony。房间列表这种典型的 CRUD 状态管理场景恰好是验证这套技术栈融合是否顺手的最佳试金石。如果你也想在 OpenHarmony 上尝试 Flutter建议从类似名单管理记账列表这种小功能起步先打通环境、再补状态管理最后加交互细节。一条路径走顺了后面再加花瓣组件、动画都是锦上添花而已。