Flutter for OpenHarmony 商城App实战 - 结算实现这个话题我断断续续做了将近一个季度。从最开始在开源鸿蒙设备上把Flutter引擎跑起来到后面真正把购物车、订单确认、支付回调这一整套结算链路跑通中间踩过的坑比我预想的多得多。如果你正在做 Flutter 在 OpenHarmony 上的适配或者正准备接手一个商城类App的结算模块这篇文章应该能帮你少走不少弯路。先说清楚这个项目到底在解决什么问题。OpenHarmony 不是 Android也不是 iOS它有自己的 UI 框架 ArkUI 和应用打包格式 HAP。但 Flutter 社区早就有人把引擎移植到了 OpenHarmony 上于是你可以继续用 Dart 写业务代码把购物车、结算页、支付结果页这些页面跑在开源鸿蒙设备上同时又能在必要的时候通过 Platform Channel 调用原生能力比如拉起系统支付、读取安全存储等等。结算实现就是整个商城链路里最绕不开、也最容易出问题的一段页面多、状态多、异步回调多、金额计算还要谨慎。这篇文章适合谁看第一种已经能在 OpenHarmony 设备上跑起 Flutter Hello World但不知道怎么组织一个真实项目的页面和数据流转第二种在 Android/iOS 上写过 Flutter 商城现在想把同一套业务迁到 OpenHarmony 上第三种单纯想看结算模块的实战套路——订单确认、地址选择、优惠券、支付渠道对接、防重复提交这些都是通用的跟跑在哪个平台关系不大。1. 项目整体设计与数据流拆解1.1 结算模块的业务边界商城App的结算模块说白了就是从用户点击“去结算”开始到支付结果返回为止的这一整段流程。但实际工程里这段流程牵涉的东西比想象中多。我拆出来大概有这么几块购物车数据读取用户从购物车勾选了哪些商品数量和规格是什么这些数据要跟着“去结算”的动作带过来。订单确认页展示商品明细、收货地址、配送方式、优惠券、金额明细让用户确认。地址管理从地址列表里选择收货地址或者新增、编辑地址。优惠与金额计算商品小计、满减、优惠券抵扣、运费、实付金额每一步都要算清楚。提交订单把页面上的订单信息提交给后端创建订单拿到订单号。支付接入唤起支付方式模拟支付、三方支付SDK或系统支付能力处理支付成功/失败回调最终关单或跳转订单详情。这里有个很重要的设计原则结算页不应该自己持有购物车数据它只接收“已经选好的商品集合”。购物车页负责勾选和筛选结算页负责展示和确认。如果两者耦合在一起后续加个“立即购买”入口不走购物车直接下单代码就会很别扭。1.2 为什么在 OpenHarmony 上仍然值得用 Flutter 做结算页面有人可能会问OpenHarmony 自己不是有 ArkUI 吗为什么还要套一层 Flutter我实际做下来理由其实挺实在的。第一业务复用。很多团队的客户端代码早就用 Flutter 写了商城这种电商业务界面复杂、迭代频繁用 Flutter 跨端跑同一套代码能省掉一大半人力。OpenHarmony 虽然有自己的UI框架但如果你已经有成熟的 Flutter 商城代码库移植到 OpenHarmony 的 js/native 壳里比从零用 ArkTS 重写快得多。第二热更新和状态管理生态。Flutter 的 Provider、Riverpod、Bloc 这些状态管理库已经非常成熟直接拿来管结算流程的复杂状态。OpenHarmony 的 ArkUI 状态管理也不算弱但如果你已经有一支 Flutter 团队这些生态是现成的。第三渲染性能足够。结算页虽然动画不多但列表项多、图片多Flutter 的 Skia 渲染在开源鸿蒙设备上跑起来帧率和内存表现是可以接受的。我测试过中低端设备上滚动结算页商品列表帧率能稳定在 50 到 60 帧没有明显卡顿。当然代价也有OpenHarmony 官方并没有直接内置 Flutter 引擎需要用社区维护的 ohos 分支来构建初始化配置比 Android/iOS 繁琐不少这个下面会细讲。1.3 结算链路的数据流设计一个清晰的结算数据流关键是一张“单向流动”的图。我这里没有用任何复杂的状态库核心就是 ChangeNotifier Navigator 传参保持依赖最小CartPage(购物车勾选商品) - push ConfirmOrderPage(cartItems) - ConfirmOrderModel(持有一份商品快照、地址、优惠券、配送方式) - 地址选择页 push/return AddressInfo - 优惠券弹窗选择 Coupon - 点击提交 - 调用 OrderApi.createOrder() - 成功后 - push PaymentPage(orderId, amount) - 支付渠道回调 - 更新订单状态 - popUntil(首页/订单列表)这条链路里ConfirmOrderModel 是唯一可信状态源。所有子页面地址选择、优惠券返回的数据都通过它来更新然后再触发界面刷新。商品数据在进入结算页的那一刻要复制一份快照不能直接引用购物车里的对象否则用户在结算页停留期间购物车数据一变结算页金额也跟着乱跳这是很隐蔽的 bug。2. 核心组件通信与状态管理实战2.1 Flutter 组件通信在结算页的典型用法热词里有个“flutter组件通信”结算页恰好是组件通信最密集的地方。我用的套路很朴素页面之间用构造函数传参和 Navigator 返回值页面内部用 ChangeNotifier 做状态共享。一个典型的例子从结算页点“选择收货地址”跳到地址列表页。用户选完地址后我们想要的是把选中的地址“传回来”。在 Flutter 里至少有两种做法做法一用 then 回调接收返回值。final AddressInfo? selectedAddress await Navigator.of(context) .pushAddressInfo( MaterialPageRoute( builder: (_) AddressListPage(), ), ); if (selectedAddress ! null) { _model.selectAddress(selectedAddress); }做法二用全局状态管理比如 Provider 里的一个地址模型地址页选中后直接修改状态结算页通过 watch 自动重建。两种都可以但我的实际经验是结算页这种低频互跳场景用做法一更干净。因为它天然表达了“地址页是一个独立流程返回的是结果”不会把地址状态变成一个全局单例后续如果要做“下单后修改地址”这种业务直接再 push 一次就行不用绞尽脑汁清理旧状态。2.2 Future 的 then 回调是微任务队列——这解释了 90% 的订单回调顺序问题热词里有一条很刁钻“flutter future的then回调 是放入微任务队列吗”。答案是是。Dart 的 Future.then 回调会被调度到微任务队列Microtask Queue在当前同步代码执行完之后、下一个 Event 事件处理之前执行。这个知识点在结算实现里特别要命。举个实际场景。提交订单时我写了这样一段代码Futurevoid submitOrder() async { setState(() _submitting true); final orderId await _orderApi.createOrder(_model.snapshot()); // 这里拿到的 orderId 一定是在微任务里跑的 if (orderId ! null) { await _paymentService.pay(orderId, _model.totalAmount); } setState(() _submitting false); }如果你不了解微任务队列你可能会疑惑为什么 createOrder 的网络响应已经回来了但界面上的 loading 按钮迟迟不恢复其实是因为 await 后续的代码全都挂在微任务队列上而在这之前事件循环还要处理渲染、手势事件等。更实际的影响是不要在 then 回调里去调用 Navigator.push 加上一个超长的同步逻辑因为在那之前你可能已经丢失了用户的点击上下文。当然Dart 的事件循环还分事件队列和微任务队列微任务永远优先于事件队列执行。这个机制保证了当支付回调到达时Dart 会先把所有微任务里的状态更新执行完再回到事件队列取下一个事件。也就是说你可以在 await 后面放心地更新 UI不会出现“卡了一半”的状态。2.3 Navigator 切换页面后状态会不会丢另一个热词是“flutter navigator切换页面后,会丢失状态吗”。这个问题要分两种场景。场景一用 Navigator.push 从一个页面跳到另一个页面。原页面不会销毁只是不在树里显示它的 State 对象和里面的状态还保留着。所以从结算页 push 地址页再返回结算页的滚动位置、输入框内容、选中的优惠券都还在。但如果原页面在栈里被移除了比如提交订单成功后 popUntil 把路由弹出栈那它的 State 就会销毁重新进入时要重建。场景二用 IndexedStack 或 PageView 做底部 Tab 切换。这里的“丢失状态”多数发生在页面被 dispose 之后重新 initState比如购物车页切到首页再切回来购物车数据要重新拉。解决办法是在 State 里混入AutomaticKeepAliveClientMixin把wantKeepAlive返回 true这样 Tab 切换时页面 State 不会销毁。回到结算链路我在支付成功后用popUntil一次性弹出所有结算相关页面这种场景下不需要保留结算页状态所以我不做 keepalive主动释放内存反而更好。如果你用的是底部 Tab 里的购物车页那才需要 KeepAlive注意区分业务场景。2.4 用 ChangeNotifier 管理订单确认模型订单确认页的状态有好几类商品快照、地址、优惠券、配送方式、金额明细、提交状态。如果用 setState 管理页面稍微复杂一点就会到处传回调很容易乱。我这里用了一个轻量 ConfirmOrderModelclass ConfirmOrderModel extends ChangeNotifier { final ListCartItemSnapshot _items; AddressInfo? _address; Coupon? _coupon; DeliveryType _deliveryType DeliveryType.express; bool _submitting false; ConfirmOrderModel(this._items); ListCartItemSnapshot get items List.unmodifiable(_items); AddressInfo? get address _address; Coupon? get coupon _coupon; DeliveryType get deliveryType _deliveryType; bool get submitting _submitting; void selectAddress(AddressInfo address) { _address address; notifyListeners(); } void selectCoupon(Coupon? coupon) { _coupon coupon; notifyListeners(); } void selectDeliveryType(DeliveryType type) { _deliveryType type; notifyListeners(); } void setSubmitting(bool value) { _submitting value; notifyListeners(); } int get goodsAmount _items.fold(0, (sum, e) sum e.price * e.quantity); int get couponDiscount _coupon?.discountAmount(_goodsAmount) ?? 0; int get freight _deliveryType DeliveryType.selfPickup ? 0 : _freightCalculator.calc(_goodsAmount); int get totalAmount goodsAmount - couponDiscount freight; }这里要特别强调金额用整数“分”存储不要用 double 做乘法累加。电商结算最忌讳 0.1 0.2 等于 0.30000000000000004 这种事虽然界面显示没问题但给后端传金额时出一点偏差都是大事故。折算成“厘”或“分”做整数运算只在最后展示时转成带两位小数的字符串这个习惯一定要从第一天就养成。3. 核心页面实现与结算UI细节3.1 订单确认页面的布局骨架一个典型的商城结算页从上到下大概是地址卡片、商品清单、优惠券入口、配送方式、金额明细、底部结算栏。我用一个 CustomScrollView SliverList 来做顶部地址卡片可以做一个可点击的整块区域没有地址时显示“请添加收货地址”点击后跳地址列表页有地址时显示姓名、电话、详细地址尾部加一个“”图标暗示可点。标签布局上有一个容易忽略的细节电话要做隐私处理。地址列表返回的 AddressInfo 里既有完整手机号也有脱敏手机号结算页展示时用脱敏字段到提交订单时传完整字段。如果接口没有提供脱敏字段自己在模型里写一个函数处理把中间四位替换成星号这是个有经验的人都懂的小细节。3.2 优惠券选择与满减计算优惠券入口一般放在商品清单下面显示当前已选优惠券的标题或者“未选择优惠券”。点击后弹出一个 BottomSheet列出可用券和不可用券。这里有个产品逻辑要先想清楚优惠券能不能叠加绝大多数商城一次只能用一张券所以模型里_coupon是单数。如果后续做“平台券 店铺券”叠加模型要改成 List。从开始写代码时就把 CouponModel 设计成带discountAmount(int goodsAmount)方法而不是让页面去 switch 各种券类型这样改起来会舒服很多。满减计算要注意的边界商品小计是否包含运费实际上不应该包含运费在优惠券计算之后才加。所以我在模型里写的是goodsAmount - couponDiscount freight顺序错了金额就错了。3.3 配送方式切换时的金额联动配送方式我做了“快递配送”和“门店自提”两种。这两种模式不只是运费不一样还影响地址卡片的展示。选自提时页面顶部不应再显示收货地址摘要而是显示“自提门店XX店”所以 ConfirmOrderModel 里要有个方法根据当前配送方式返回展示文案String get addressHint { if (_deliveryType DeliveryType.selfPickup) { return _pickupStore?.name ?? 请选择自提门店; } return _address?.fullAddress ?? 请添加收货地址; }运费计算我抽了一个 FreightCalculator规则写死在里面其实也可以但为了可测试我一般写成独立 class。规则示例商品小计满99元免运费不满则收8元自提永远0元。这个逻辑在单测里非常好覆盖。3.4 底部结算栏与防重复提交底部结算栏左边显示“合计¥xx.x”右边一个“提交订单”按钮。注意提交按钮在_submitting为 true 时置灰并显示“提交中...”同时整个页面用 PopScope 拦截 Android 返回键防止用户提交过程中退出页面。OpenHarmony 设备上 PopScope 的返回键拦截在 Flutter 适配层也同样有效实测过。还有一点要提醒提交按钮的点击处理要做“不可重入”保护光靠 UI 置灰还不够最好在函数开头也判断一次if (_model.submitting) return; _model.setSubmitting(true);因为连续快速点击可能在你第一次 setState 之前就触发了第二次点击回调这个防护在真机上非常有用。4. 提交订单、支付接入与 PlatformView 实战4.1 订单创建与提交的异步处理提交订单的调用链一般分三步先创建订单拿 orderId再拉起支付最后根据支付结果同步订单状态。但这里有个产品细节要确定清楚是“创建订单后进入支付页”还是“创建订单成功后留在当前页弹支付面板”我这次做的是前者点击提交订单 - 显示 loading - 后端返回 orderId - push 到支付页。这样有个好处支付页可以独立处理支付渠道回调不需要和订单确认页共用一张页面。代码逻辑大致是这样Futurevoid handleSubmit() async { if (_model.submitting) return; _model.setSubmitting(true); try { final req CreateOrderRequest.fromModel(_model); final order await OrderApi.createOrder(req); if (!mounted || order null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) PaymentPage(orderId: order.id, amount: order.amount), ), ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(订单提交失败$e)), ); } } finally { _model.setSubmitting(false); } }这里有两个坑要重点讲。第一个坑是mounted判断。因为在 await 网络回调之后页面可能已经被用户返回键弹出了如果不判断 mounted 直接调用 Navigator 或 ScaffoldMessenger会直接抛异常。这在 Android 上常见OpenHarmony 的 Flutter 适配层也一样不是平台差异是 Flutter 异步编程的基本素养。第二个坑是 loading 指示和按钮状态恢复。_model.setSubmitting(true)虽然会让按钮置灰但如果你在 push 到支付页之后才在 finally 里恢复 false那等用户从支付页返回结算页时按钮才恢复。这个顺序是可以的但要注意不要把这个 false 写早否则用户在支付页停留期间结算页的按钮已经恢复可点的状态如果他用返回键退回来马上再点一次就可能重复下单。4.2 支付渠道接入MethodChannel 与 EventChannel 的选择支付环节要接原生能力。在 OpenHarmony 上Flutter 跑在 ArkUI 的壳工程里和原生侧的通信依然是通过 Platform Channel 那一套。我的做法拉起支付用 MethodChannelDart 侧调用原生支付 SDK 的唤起接口比如扫码支付、余额支付。支付结果回调用 EventChannel原生侧在支付完成时主动推消息给 Dart 侧。为什么不把支付结果也用 MethodChannel 返回因为支付流程可能经过系统 UI、三方 App 跳转用户可能切走再切回来支付结果往往不是同步返回的。EventChannel 的流式推送更适合这种异步回调。Dart 侧监听 EventChannel 的代码class PaymentManager { static const _eventChannel EventChannel(app/pay_channel); StreamMapObject?, Object? _stream; StreamPaymentResult startListen() { _stream ?? _eventChannel.receiveBroadcastStream() .map((event) PaymentResult.fromMap(event)); return _stream; } }这里有个重要细节receiveBroadcastStream第一次 listen 是在调用 pay 方法之前最好就建立。也就是说你要先 listen 支付回调流再调用 MethodChannel 唤起支付。否则可能出现原生已经回调了支付成功但 Dart 侧还没有监听流消息丢了。实际工程里我见过不少这个 bug表现就是“用户已经完成支付但App一直停在支付页转圈”。4.3 OpenHarmony 上的 PlatformView 集成实战热词里有“flutter platformview”这个在 OpenHarmony 支付场景尤其常见。比如某些银行支付 SDK 要求显示安全键盘或者某个原生组件要嵌入 Flutter 页面都需要 PlatformView。Flutter 在 Android 上的 PlatformView 有过 Hybrid CompositionOpenHarmony 适配版也继承了类似机制。我踩过的一个坑是PlatformView 覆盖在 Flutter 渲染内容之上时如果 Flutter 页面里有滚动视图PlatformView 的触摸事件偶发会被 Flutter 侧吃掉。表现就是你在原生密码框里点一下键盘弹出来了但按返回键关不掉或者点击输入框没反应。我的解决方案比较笨但稳定把 PlatformView 放在一个尽量不滚动的区域比如支付方式选择弹窗里而不是放在整个页面滚动列表里。如果产品非要放在可滚动区域可以考虑在滚动开始的时候把 PlatformView 销毁或者用 Container 替换成一个占位图滚动停止时再重建。这个体验上有一点闪烁但至少不会出现触摸事件错乱。另外 PlatformView 的适配版配置文件要记得加到ohos目录下的原生工程里比如module.json5里注册对应的 Ability 或 Service这一步如果漏了在 OpenHarmony 侧会直接报找不到组件的错误。4.4 支付结果的最终处理与页面跳转支付结果回到 Dart 侧后一般有三种情况支付成功、支付失败、支付结果未知用户取消或者网络超时。真实的商城业务里支付结果的最终依据永远以服务端对账为准客户端只是展示“已收到支付通知请稍后查看订单状态”或者直接给一个“支付成功”的静态页。我在实现时拿到 EventChannel 的 success 回调后会再请求一次/order/status接口确认防伪和防订单状态不同步双保险。页面跳转顺序是支付成功页显示成功 icon 订单号 “查看订单”按钮 - 点击后 popUntil 回到订单列表页。注意 popUntil 时要把结算页、支付页、确认页全部弹出我用的 predicate 是Navigator.of(context).popUntil((route) route.isFirst);如果首页就是订单列表的话这样最干净。如果首页是 Tab 页订单列表在子 Tab 里那就不要 popUntil 到 first可以 popUntil 到某个指定的 RouteName这个根据你路由设计来定。5. 常见报错与排查实录5.1 e/flutter (31173)Dart VM 初始化时的 Unhandled Exception这个报错在 OpenHarmony 真机上特别常见形式是E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: ...从日志看是 Dart VM 初始化阶段就崩了但实际上大部分情况是某个插件在注册的时候抛了异常导致整个引擎启动失败。我在排查时先看异常类型如果是 MissingPluginException说明原生侧没有实现对应 MethodChannel 的 handler。检查ohos目录下有没有对应依赖比如ohos/flutter_ohos_plugin这类适配库有没有被正确添加到 Hvigor 配置里。如果是找不到符号的 error大概率是 Flutter 引擎版本和插件编译版本不匹配。OpenHarmony 的 Flutter 分支更新快插件也要跟着同步 build。另外检查一下 HAP 包里有没有把libflutter_engine_ohos.so打进去。我用 DevEco Studio 打包时默认的 Hvigor 配置不会自动带上 Flutter 引擎 so 文件需要在build-profile.json5的 native library 配置里显式 include。5.2 You are applying Flutters main Gradle plugin imperatively using the apply script这个报错严格来说是 Flutter 工程构建时的典型报错不是 OpenHarmony 特有但我在把老项目迁移到新版 Flutter OpenHarmony 分支时也遇到过一次。完整提示是You are applying Flutters main Gradle plugin imperatively using the apply script method, which is no longer supported...原因是项目里android/app/build.gradle用了旧的apply from: $flutterRoot/packages/flutter_tools/gradle/flutter.gradle这种写法。解决办法是把它迁移到新版的pluginsDSLplugins { id com.android.application id dev.flutter.flutter-gradle-plugin }同时确保settings.gradle里通过pluginManagement正确引入了 Flutter Gradle Plugin。这个问题在热词里出现了说明不少人还在用旧模板。我的建议是别在旧工程上打补丁直接按新版模板起一个新工程把业务代码拷过来因为 Flutter Gradle 插件的 DSL 迁移是一次性的不彻底的修补会让你后续每次构建都踩同一块石头。5.3 打包时 java.lang.AssertionError: could not close input这是一个打包时较新的报错完整堆栈类似java.lang.AssertionError: java.lang.Exception: could not close input第一次看到时我以为是 JDK 环境有问题排查半天无果。最后定位到是Gradle 缓存文件损坏或 IO 权限问题。这个一般发生在Gradle 缓存目录被清理工具误删了一部分文件。多用户共用同一台 CI 机器缓存目录权限不一致。Flutter 的~/.gradle/caches里某个 jar 文件被占用Windows 上多见。我的排查顺序先删~/.gradle/caches下对应版本目录再执行flutter clean最后重新构建。如果还报错就换一个 Gradle 版本或者检查 Gradle Daemon 是否异常执行gradlew --stop停掉所有守护进程再打一次。这个报错和业务代码无关纯构建环境问题见到先别慌。5.4 OpenHarmony 上的 Payment Service 功能不可用排查实录里再补一条和 OpenHarmony 原生能力相关的。如果支付用到了系统级支付能力比如PaymentServiceAbility需要在 HAP 的module.json5里声明对应的 ability 权限并且要在requestPermissions里申请。我遇到过一种比较隐蔽的情况HAP 里 payment ability 已经声明了但拉起支付时返回“服务不存在”。后来发现是 HAP 的签名没关联到对应服务的真机权限。OpenHarmony 的权限管理比较严格调试时用自动签名但付款服务这种系统能力在部分测试机上需要额外的allowSystemService配置。这种问题很难通过日志直接看出来建议先把 DEMO 的 hello world 版支付跑通再往上叠业务不要一上来就把复杂支付流程扔进去排错。最后说几句经验整个结算模块做下来我最深的体会是在 OpenHarmony 上做 Flutter 开发最耗时间的不是 Flutter 本身而是 Flutter 与 OpenHarmony 工程体系的衔接——引擎初始化、插件适配、HAP 打包配置、权限声明每一层都可能绊你一下。如果团队打算长期在这个方向投入建议把“环境基础镜像”沉淀成一个标准化工程模板把引擎 so 文件、插件依赖、权限配置都固化下来新项目直接基于模板改能省掉大量重复排错时间。另外一个建议是结算模块的金额计算、优惠券规则、运费规则一定要写单元测试。这套业务逻辑不依赖任何 UI 和平台能力纯 Dart跑起来又稳又快。我在项目里给 ConfirmOrderModel 写了 30 多个测试用例覆盖各种边界情况比如满 99 免运费、不满 99 收 8 块、优惠券抵扣后实付为负实际上要限制为不低于 0、自提不产生运费等等。这些用例帮我挡回了不少集成测试阶段才能发现的 bug。真等到界面上发现金额错了排错成本会高十倍八倍。