做“享家社区”鸿蒙版需求时,最先让我觉得“这项目有意思”的功能,居然是一个很小很小的删除操作:用户在房屋卡片上长按,点“删除房屋”,确认后卡片消失。表面上看,这不过是Flutter里的一个列表删除动作,但真正动手后我才发现,这个操作是整个App里少有的“一端改动、全链路感知”的功能——从Flutter层的数据状态、Provider组件通信,到后端DELETE接口,再延伸到HarmonyOS原生侧的门禁数据清理,每一步都不能少。这篇文章就把删房屋这个功能的完整实现复盘一遍,从需求拆解、工程改造、UI交互、状态管理,再到鸿蒙原生桥接,适合正在把Flutter项目适配到HarmonyOS、或者准备做社区/物业类跨端App的团队参考。
1. 从产品需求到技术方案:删除房屋为什么值得单独写一篇文章
1.1 享家社区的房屋模型与业务规则
先复述一下业务背景。“享家社区”是一个偏物业场景的App,用户名下会绑定多套房屋,每套房屋对应一个社区、楼栋、房间号。首页展示的是房屋卡片列表,顶部还有一个当前房屋切换器。用户切换到某套房子后,才能看门禁、报修、账单这些功能。
所以“删除房屋”不是单纯把一条列表数据挪走,它还牵扯到后续逻辑:这个房屋的门禁凭证是否要清除?如果删的是当前正在使用的房屋,顶部切换器要切到哪一套?删除后用户在鸿蒙钱包里存的开门卡要不要同步失效?这些问题从一开始就必须列清楚。
我在项目里把房屋模型简化成下面这个结构:
| 字段 | 含义 |
|---|---|
| houseId | 房屋唯一ID,后端生成,前端不做拼接 |
| communityName | 小区名称 |
| buildingNo | 楼栋号 |
| roomNo | 房间号,例如 1804 |
| deviceCount | 绑定的智能设备数量 |
| isCurrent | 是否是当前选中房屋 |
| coverUrl | 小区封面图地址 |
业务上还有一条硬规则:用户至少保留一套绑定房屋,否则无法正常使用社区服务。这条规则前端要做,后端也要反过来做。产品文档里写得很清楚:删除操作必须有二次确认,并且弹窗文案要说明“该房屋的门禁权限、报修记录将不可见”。这其实也直接影响了最后的弹窗设计。
1.2 方案选型:Flutter for HarmonyOS 与 ArkTS 的协作边界
团队里经常有人争论“ArkTS和Flutter谁更流行”,但实际做需求的时候,这根本不是二选一的问题。我们的判断很简单:业务页面整体用Flutter,鸿蒙的系统能力下沉给ArkTS原生层,中间用平台通道桥接。
原因很实际。享家社区本身是Flutter开发的,覆盖Android、iOS,现在要加HarmonyOS端,直接复用Flutter业务代码是最省成本的路。删除房屋这种页面交互,如果用ArkTS重新实现一遍,等于把房屋列表、弹窗、状态管理全部重写,投入产出比很低。反过来,如果所有系统能力都非要Flutter插件化,也过于复杂。像删除房屋之后要清理门禁凭证、同步系统钥匙包,这类能力用ArkTS在宿主工程里做才是正常姿势。
所以技术方案在我这里最终定下来:Flutter负责UI和业务状态,MethodChannel走鸿蒙原生侧清理逻辑,两侧通过一组服务接口配合。后面我会详细写桥接的部分。
2. 让Flutter跑在HarmonyOS上:工程接入与基础配置
2.1 工具链版本与鸿蒙宿主工程
我这边用的工具链大致如下,不同时期版本可能更新,但思路不变:
| 工具 | 版本 | 说明 |
|---|---|---|
| Flutter SDK | 3.10+ 的鸿蒙支持分支 | 社区维护的分支,支持生成ohos平台工程 |
| DevEco Studio | 4.0 及以上 | 用来编译鸿蒙宿主工程 |
| ArkTS API | 9+ | module.json5 和 ets 代码都按这个版本编写 |
创建项目的时候,我一般先执行flutter create --platforms ohos .,然后确认生成了harmony/目录。这个目录放鸿蒙宿主工程,Flutter的页面代码依然在lib/下面。我习惯把两端代码分开,结构大致是这样的:
harmony/ entry/ src/main/ ets/ resources/ module.json5 lib/ main.dart pages/ state/ services/ widgets/ pubspec.yaml有一点要注意:鸿蒙宿主工程不是简单的“iOS Runner替换版”。它在构建时需要加载Flutter模块产物,所以DevEco Studio的工程配置和以前的Android工程很不一样。最稳妥的方式是先用flutter create生成,再根据官方文档用DevEco Studio打开harmony/目录,不要去手动拼工程文件,否则各种奇奇怪怪的编译错误会浪费你很多时间。
2.2 把共享业务模块拆出来
工程接入完,接下来是“边界确认”。鸿蒙端要复用Flutter的页面,Flutter侧不能把平台相关的代码写得到处都是。删除房屋这个功能,我把它拆成了三层:
- UI层:房屋卡片、弹窗、列表动画,全部在
lib/pages/下。 - 状态层:
HouseStore管理房屋集合、删除状态、当前选中房屋。 - 平台服务层:抽象出
HousePlatformBridge,由各平台实现删除本地数据、门禁清理等能力。
这样的好处是,后续如果Android端也想做同样的清理逻辑,只需要在Android工程里实现同一个接口,Flutter页面一行都不用改。这个抽象后面专门在桥接章节展开。
2.3 module.json5 里的网络与存储声明
鸿蒙开发里有一个容易被忽略的坑:权限没声明,运行时报的错还不太直观。删除房屋依赖网络请求,所以module.json5必须声明INTERNET权限。我们当时漏掉了这一步,结果真机调试时,删除接口一直返回网络失败,查了半天才发现是权限缺失。
{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }这里多说一句:删除房屋这个操作本身不需要定位、相册、蓝牙这些权限,千万别顺手全申请了。鸿蒙应用市场上架审核对权限申请很敏感,权限越多,隐私声明越麻烦,用户拒绝权限的概率也越高。我们的原则是,哪个功能用到了才申请,删除房屋就只要网络权限。
3. 删除房屋的前端交互:从长按菜单到卡片移除的完整链路
3.1 长按卡片呼出删除入口
交互路径上,我们最开始设计了两种入口:左滑删除和长按删除。后来真机测试时发现,左滑在鸿蒙上和返回手势、边缘滑动有冲突,经常滑到一半直接退出了页面。所以最终改成长按卡片,弹出底部操作菜单。
InkWell( onTap: () => _switchHouse(house), onLongPress: () => _showHouseActions(house), child: HouseCard(house: house), );菜单用showModalBottomSheet实现,里面显示房屋简要信息和一个红色“删除房屋”按钮。
Future<void> _showHouseActions(HouseModel house) async { final action = await showModalBottomSheet<String>( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: Text('${house.communityName} ${house.buildingNo}栋 ${house.roomNo}室'), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: const Text('删除房屋', style: TextStyle(color: Colors.red)), onTap: () => Navigator.pop(context, 'delete'), ), ], ), ), ); if (action == 'delete') { await _confirmDeleteHouse(house); } }这样交互上比较稳妥:长按触发是明确的主动行为,不会误触;底部菜单又给了用户足够的操作空间,不会像侧滑那样容易触发系统手势。
3.2 二次确认弹窗与防连点处理
删除操作属于破坏性操作,必须二次确认。我们做弹窗时,文案不是简单写“确定删除吗”,而是把后果写清楚:
Future<void> _confirmDeleteHouse(HouseModel house) async { if (store.deleting) return; final confirmed = await showDialog<bool>( context: context, builder: (context) => AlertDialog( title: const Text('删除房屋'), content: Text('确定删除 ${house.communityName} ${house.buildingNo}栋${house.roomNo}室吗?\n删除后,该房屋的门禁权限、报修记录将不可见。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: const Text('确认删除', style: TextStyle(color: Colors.red)), ), ], ), ); if (confirmed != true) return; try { await store.deleteHouse(house); } catch (e) { _showDeleteError(e); } }这里我特意加了if (store.deleting) return;,因为弹窗关闭的动画和点击事件之间有延迟,用户如果连续点击,后面的点击会再次触发删除。等deleteHouse里的_deleting标志置为true后,所有入口都会直接短路,从根源上防连点。
顺带说一个产品细节:弹窗的确认按钮文案固定用“确认删除”,不用“确定”。因为“确定”这个词太中性,用户可能没意识到这是不可逆操作。红色按钮也给了心理暗示,这是血泪教训换来的文案规范。
3.3 删除中的UI状态:按钮loading与卡片移除动画
确认删除之后,如果接口比较慢,UI不能毫无反馈。我处理的方式是用一个_deletingHouseId字段,当前删除中的房屋卡片会显示一个半透明遮罩和loading转圈。
class HouseCard extends StatelessWidget { final HouseModel house; final bool deleting; @override Widget build(BuildContext context) { return AnimatedOpacity( opacity: deleting ? 0.6 : 1.0, duration: const Duration(milliseconds: 200), child: Card( child: Stack( children: [ // 原有房屋信息 if (deleting) const Positioned.fill( child: Center(child: CircularProgressIndicator()), ), ], ), ), ); } }卡片移除动画我用了AnimatedList,而不是直接对ListView重新赋值。原因是ListView在数据源变化后会瞬间重建,没有过渡动画,用户体验比较生硬。AnimatedList里可以手动控制移除动画:
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>(); void _removeHouseWithAnimation(HouseModel house) { final index = store.houses.indexOf(house); store.houses.remove(house); _listKey.currentState?.removeItem( index, (context, animation) => SizeTransition( sizeFactor: animation, child: HouseCard(house: house), ), duration: const Duration(milliseconds: 300), ); }这个动画看起来不花哨,但能明确告诉用户“删除动作已经生效”,视觉反馈和状态更新是一致的。
4. 状态管理与组件通信:用Provider把删除结果扩散到整个App
4.1 HouseStore的职责划分
之前也有同事问我“flutter provider 怎么用”,我一般不会直接丢文档,而是让他看我们这个删除房屋的例子。Provider在这里做的事情很简单:所有页面共享同一个HouseStore,谁需要数据就通过context.watch或context.select去拿,删除完成后所有依赖方自动更新。
HouseStore的核心代码长这样:
class HouseStore extends ChangeNotifier { final HouseRepository _repository; final HousePlatformBridge _platformBridge; List<HouseModel> _houses = []; String? _selectedHouseId; bool _deleting = false; String? _deletingHouseId; HouseStore(this._repository, this._platformBridge); List<HouseModel> get houses => List.unmodifiable(_houses); bool get deleting => _deleting; String? get deletingHouseId => _deletingHouseId; HouseModel? get selectedHouse => _selectedHouseId == null ? null : _houses.where((h) => h.houseId == _selectedHouseId).firstOrNull; Future<void> deleteHouse(HouseModel house) async { if (_deleting) return; _deleting = true; _deletingHouseId = house.houseId; notifyListeners(); try { // 先通知后端删除 await _repository.deleteHouse(house.houseId); // 再清理鸿蒙原生侧数据 await _platformBridge.deleteLocalHouseData(house.houseId); _houses.removeWhere((h) => h.houseId == house.houseId); if (_selectedHouseId == house.houseId) { // 如果删的是当前房屋,自动切到第一套剩余房屋 _selectedHouseId = _houses.isNotEmpty ? _houses.first.houseId : null; } // 记录埋点 Analytics.track('house_deleted', {'houseId': house.houseId}); } finally { _deleting = false; _deletingHouseId = null; notifyListeners(); } } }这里有一个设计细节值得说:删除顺序是先调后端,再清原生,最后删内存列表。为什么不是先删本地内存?如果后端删除失败,本地列表已经移除了,用户会看到一个“删除成功”的假象,但刷新后房子又回来了,这种体验非常糟糕。所以“后端成功,本地才变”是铁律。
4.2 删除当前默认房屋时,其他模块如何感知
删除房屋不是单个页面的操作。如果删的是当前选中房屋,顶部房屋切换器、首页的社区名、门禁页面都要跟着变。如果用传统的事件回调去通知,页面一多,链路就会变成一团乱麻。Provider的优势就在这里体现出来了。
我在HouseStore里维护_selectedHouseId,任何地方想读取当前房子,直接用context.select关联依赖:
final selectedHouseId = context.select<HouseStore, String?>( (store) => store.selectedHouse?.houseId, );这样只有selectedHouseId变化时,使用context.select的组件才会重建,其它不依赖这个字段的组件不受影响。这一点在优化列表页和头部选择器时特别重要,不然删一次房子,整个首页所有组件都会重建一遍,鸿蒙低端机上明显能感到卡顿。
我见过不少团队用Provider时喜欢直接context.watch<HouseStore>(),一个小改动就重建整个页面。其实大多数场景用context.select就够了,只有确实需要监听整个store变化的页面才用watch。
4.3 删除中的错误处理与重试策略
删除接口不是每次都能成功。断网、服务端500、后端校验不通过,都要区分处理。deleteHouse里的异常会抛到UI层,由弹窗显示错误原因。
void _showDeleteError(Object error) { String message = '删除失败,请稍后重试'; if (error is DioException) { if (error.type == DioExceptionType.connectionTimeout) { message = '网络超时,请检查网络后重试'; } else if (error.response?.statusCode == 409) { message = '该房屋正在办理相关业务,暂时不能删除'; } else if (error.response?.statusCode == 400) { message = '至少需要保留一套房屋,不能删除'; } } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); }这里的前后逻辑很有意思。即使前端已经在最后一个房屋时隐藏了删除按钮,后端还是会返回400,因为规则必须两端都维护。后端是真正的安全屏障,前端只是体验优化。上线后我们发现,后端400的触发场景是并发场景:两个设备同时登录,一个设备删了别的房屋,另一个设备首页还显示着最后一套房,这时用户在那台设备上点删除,不过是前端提示,后端也会拦截。
5. 与鸿蒙原生的最后一次握手:MethodChannel桥接删除结果
5.1 Flutter侧封装一个跨平台删除服务
删除房屋如果只是把后端数据删掉,其实不需要鸿蒙原生参与。但“享家社区”里,房屋绑定着门禁凭证,删除后必须让鸿蒙侧把本地缓存的门禁数据也清掉,不然门禁卡还在钱包里,就容易出安全问题。所以在Flutter侧,我定义了一个平台服务接口:
abstract class HousePlatformBridge { Future<bool> deleteLocalHouseData(String houseId); } class MethodChannelHouseBridge implements HousePlatformBridge { static const _channel = MethodChannel('com.xiangjia.house/platform'); @override Future<bool> deleteLocalHouseData(String houseId) async { try { final result = await _channel.invokeMethod<bool>( 'deleteHouseLocalData', {'houseId': houseId}, ); return result ?? false; } on PlatformException catch (e) { throw HousePlatformException(e.message ?? 'native delete failed'); } } }为什么要抽象一层,而不是直接在HouseStore里写MethodChannel?因为以后Android端也要实现同样的“清理本地缓存”逻辑。Android的清理方式和鸿蒙完全不同,但HouseStore不关心底层实现,只要调用_platformBridge.deleteLocalHouseData就行。这种依赖倒置的思路让整个项目在多平台适配时非常清爽。
5.2 ArkTS宿主侧实现原生清理逻辑
鸿蒙原生侧相对直接。在EntryAbility或者一个专门的HouseMethodChannel类里注册平台通道,然后在回调里处理deleteHouseLocalData方法。下面是一个ArkTS端的示意代码:
import MethodChannel from '@ohos.ability.methodChannel'; const CHANNEL_NAME = 'com.xiangjia.house/platform'; export function registerHouseChannel(context: common.UIAbilityContext) { const channel = new MethodChannel(CHANNEL_NAME, context); channel.onMethodCall((call) => { if (call.method === 'deleteHouseLocalData') { const houseId = call.arguments['houseId']; // 这里做真正的系统数据清理: // 1. 删除本地数据库中的房屋索引 // 2. 删除门禁卡片缓存图片 // 3. 通知后台服务刷新钥匙串 deleteLocalHouseData(houseId); call.result(true); } else { call.resultError('UNSUPPORTED', 'method not found'); } }); }注意方法名和通道名必须和Flutter侧完全一致,大小写错了会直接进PlatformException。这个错误很隐蔽,因为运行时才会报,而且报错信息不一定能直接看出是名字不匹配,建议两边用一个常量文件维护,不要手写两遍。
5.3 桥接中的线程与Result陷阱
鸿蒙原生侧的call.result(true)不是想怎么调就怎么调的。我踩过一个大坑:在异步任务里把result对象传给了子线程,子线程执行完后再调result,结果Flutter侧拿不到返回值,或者直接异常。
正确的做法是:在onMethodCall回调里启动异步任务,但result的返回值要么在同一个线程同步返回,要么确保调用回到原生主线程后返回。一般我的处理方式是把耗时操作放到TaskPool或子线程,然后在主线程里回调result。
还有,result只能回调一次。如果业务逻辑里既调了result.error,又在后面调了result.success,鸿蒙侧会直接报错。所以我会在写原生代码时给异步调用加一个状态位,确保只回调一次:
let hasResult = false; deleteLocalHouseData(houseId).then((ok) => { if (!hasResult) { hasResult = true; call.result(ok); } }).catch((err) => { if (!hasResult) { hasResult = true; call.resultError('DELETE_FAILED', err.message); } });另外建议所有从Flutter传到原生的参数都走Map或者简单字符串,不传复杂对象。MethodChannel的消息编解码对复杂结构支持有限,传模型对象容易出现字段丢失。
6. 上线前必须处理的边界与踩坑实录
6.1 最后一套房屋保护逻辑
产品规则要求用户至少保留一套房屋,前端必须在入口处就堵住。我在房屋卡片的长按菜单里做了判断:如果当前房屋数量小于等于1,就不弹出“删除房屋”按钮,只显示一个不可点的置灰按钮,或者干脆隐藏。
if (store.houses.length <= 1) { return; // 不展示删除入口 }但这只是体验保护。真正的安全边界在后端接口。因为用户可能在另一个设备上已经删掉了其它房屋,当前设备的数据不是最新的,这时候前端判断“还有两三套房”,后端再校验一次就能拦住。两边的代码都不复杂,但缺一不可。我问过很多出过线上事故的团队,最后发现“删除最后一个房屋导致用户无法登录App”这类问题,基本都是前后端只做了一边校验。
6.2 Dismissible的key问题与恢复动画
如果列表项用了Dismissible实现左滑删除,key的选择非常关键。千万不要用列表索引index作为key。删除第一项后,第二项的index从1变成0,Flutter会认为它是原来的第一项,于是动画和状态全部错乱,甚至出现两张卡片重叠的情况。
我自己一开始用的就是ValueKey(index),删了两张卡片之后列表直接崩了。正确做法是使用房屋ID:
Dismissible( key: ValueKey(house.houseId), confirmDismiss: (_) => _confirmDeleteHouse(house), onDismissed: (_) { // 在这里不真的从store里删,因为confirmDismiss已经调了接口 }, child: HouseCard(house: house), )还有,confirmDismiss返回true时卡片会滑出,返回false时卡片会弹回原位。所以如果删除接口失败,我们让confirmDismiss返回false,实现“旧状态恢复”的效果,用户会看到卡片像被拉了一把又弹回去,配合错误提示非常直观。
6.3 断网、接口异常时的用户引导
删除操作本质上是不可逆的,所以我对网络异常的处理比较保守。默认策略是:删除失败,本地数据不动,卡片原样保留,只弹提示。
有些团队为了“体验流畅”会选择乐观删除,也就是用户点了就直接从列表移除,后台静默重试。这个方案在删除房子这种强一致场景下风险太高。如果后台没删干净,下次刷新房子又回来了,用户会认为App有bug,甚至可能因为本地已经“删了”但门禁还在,引发安全漏洞。
我在这个项目里还做了一个小优化:如果检测到是网络不可用导致的删除失败,会弹出一个“稍后重试”还是“加入离线删除队列”的选项。选“加入离线删除队列”后,请求会存到本地数据库,等网络恢复再自动执行。当然这个功能实现起来又要加一套同步机制,如果你项目周期紧,可以先不做,但至少要让用户在失败时不至于完全无助。
6.4 从删除房屋看鸿蒙上的Flutter性能优化
删除房屋这个功能做久了,会顺带发现一些鸿蒙上Flutter性能的坑。比如卡片封面图是网络图片,删除时图片异步解码还在进行,如果列表刷新太快,内存里会堆积多个未完成的图片请求。我尽量用cached_network_image配合cancelToken去取消请求。
列表页的每个HouseCard尽量用const构造,减少重建开销。鸿蒙端的Flutter渲染引擎如果你发现某些真机滑动掉帧,可以在启动时尝试关闭Impeller,用回Skia渲染管道的兼容模式。具体命令就是flutter run --no-enable-impeller,不过这不是必须的,按真机实测情况来。
还有一个容易被忽略的点:DevEco Studio自带的Profiler能看到鸿蒙侧的CPU/内存占用。删除房屋涉及网络、原生桥接、列表动画,如果发现原生侧内存有明显上涨,重点看deleteLocalHouseData里有没有把图片缓存对象一直持有。
最后再分享一点我自己的体会。删除房屋这类“破坏性操作”,一旦做扎实了,整个App的状态管理框架基本也就稳了。它逼着我想清楚数据流的每一跳:UI点击、确认弹窗、接口调用、原生清理、列表动画、多页面联动。踩了一圈坑之后,我最大的收获不是写代码写得多顺,而是养成了一个习惯:每次做删除前都问自己三句话——这条数据删了之后,哪些页面要跟着变?删失败了怎么办?原生侧的关联数据要不要一起清?想清楚这三句话,再小的功能都能做得经得起线上考验。