
做剧本杀组队App最容易被低估的一个模块就是钱包。表面上它就是个余额展示加转账流程但真把需求收上来一看组队要交报名费、开赛要冻结爽约金、结束要AA分摊、退款要按规则算比例还全部得跟组队状态联动。我这次接到的任务是给一套存量Flutter代码跑上OpenHarmony平台钱包模块顺势成为第一个完整落地的核心功能。这篇文章就围绕“Flutter for OpenHarmony环境下钱包功能怎么具体实现”展开把架构拆分、数据模型、状态通信、平台差异和实际踩过的坑一并记录下来。先说说项目背景剧本杀组队App的典型用户路径是这样的用户刷到有人发起的剧本杀车队想加入第一步就是支付报名费占坑到约定时间后全队到店开始游戏这中间可能涉及临时改时间、有人放鸽子、有人中途跳车游戏结束再由房主发起结算按人头把主持人费用和场地费A掉。这一条链路走下来钱包的职责早就超出了“充钱、花钱”的范畴它实际上成了整个组队业务的状态机。技术选型几乎没有悬念。原始代码就是Flutter写的需要适配OpenHarmony手机、平板和带屏设备求稳求快就只能走Flutter for OpenHarmony这条路。说句实话Flutter在OpenHarmony上的适配已经比早两年靠谱太多了但依然有不少和Android/iOS不一样的细节。尤其是一些报错、渲染引擎、平台通道问题不实际跑一遍真不知道。这一篇我把钱包功能的完整实现思路写清楚也会顺带把组件通信和OpenHarmony平台差异的部分讲透。1. 项目背景与钱包需求拆解1.1 剧本杀组队场景下的支付刚需剧本杀的“组队”和普通拼车不一样它的付费规则是分阶段的。我梳理出来的核心流程有四段参团付费用户点击“确认加入车队”系统生成一笔报名费订单钱从钱包余额里冻结而不是直接扣走。爽约金冻结从“确认参团”到“游戏开始前24小时”之间如果用户主动退团报名费全额退回但如果在24小时内退团要扣掉20%作为爽约金剩下的退回余额。自动结算游戏结束后房主点击“结束并结算”系统按人数把全场总费用AA分摊到每个人头上再结合每人之前是否交过报名费算出每个人实际应付的补差金额。信用联动累计爽约次数会影响用户组队的信用等级信用等级又会影响可组队时长和最大可报名场次。钱包里的流水记录里需要带上“爽约”这种类型方便后续信用系统取数。这些需求刚拿到手的时候看着不复杂真正拆开才发现钱包不是简单的加减法它得和业务状态机深度耦合。冻结、退款、分摊、补差每一步都要有流水都要有状态变化记录。1.2 Flutter for OpenHarmony的选型理由为啥不直接用ArkUI原生开发存量代码是一个原因团队技术栈是另一个原因。3万多行Flutter代码涉及组队、房间、聊天、钱包四个大模块全部用ArkUI重写太伤了。Flutter for OpenHarmony目前的思路是复用Flutter引擎能力把Dart代码跑在OpenHarmony的OS上通过平台通道调用底层系统能力这正好符合“一套代码多端跑”的诉求。实际开发中我也确实尝到了甜头钱包页、组队页、结算页的UI和业务逻辑在Android和OpenHarmony之间基本零改动。需要改的只是支付SDK接入、系统参数读写这类边界部分。团队里新来的同学只要会Flutter不用额外学ArkTS也能快速上手改需求。1.3 需求优先级与开发节奏钱包这种模块涉及钱优先级最高的是“不能错账”其次是“不能超卖”。我排的开发节奏是这样的先做数据层数据库表结构、金额处理工具、流水记录逻辑保证账目模型稳固。再做核心链路充值入账、参团冻结、结算分摊、退团退款把主流程跑通。最后做外围钱包首页卡片、流水筛选、优惠券展示、信用分提示这些偏展示形态可以后置。实战下来这个节奏是对的。很多团队一上来就把页面做得花里胡哨结果底层数据模型是错的返工成本巨大。钱包功能底层账目模型永远是地基。2. 钱包模块架构设计与数据模型2.1 模块化设计钱包服务与页面解耦钱包不能只是一个页面它应该是独立的一层服务。我的做法是把钱包拆成两层钱包服务层WalletService负责充值、支付、退款、查询流水、余额变更只暴露Dart层方法不依赖任何UI组件。钱包展示层WalletPage / WalletCard / BalanceView负责余额展示、充值档位选择、流水列表渲染通过状态管理连接服务层。这样做的好处是组队模块调用钱包能力时直接依赖WalletService就好不用去关心按钮和弹窗。比如用户点击“参团”后组队模块调用WalletService.freezePayment()钱包模块内部把事情做完通过通知机制告诉组队模块“冻结成功”或“余额不足”。模块之间的边界就非常干净。2.2 数据库选型与表结构设计本地数据存储我用的是sqflite_common_ffi这个库在OpenHarmony上能正常跑SQLite兼容性实测稳定。为啥要本地放一份数据因为钱包余额、流水和组队状态强关联如果每次都走网络接口断网场景下用户连自己的余额都看不到体验太差。我的方案是本地缓存为主、服务端对账为辅。核心设计了三张表wallet_account 账户表字段类型说明user_idTEXT用户ID主键balanceINTEGER可用余额单位分frozen_balanceINTEGER冻结余额单位分total_rechargeINTEGER累计充值额total_spentINTEGER累计消费额updated_atINTEGER更新时间戳wallet_transaction 流水表字段类型说明tx_idTEXT流水号全局唯一user_idTEXT用户IDamountINTEGER金额正数入账负数出账balance_afterINTEGER交易后可用余额typeINTEGER1充值 2参团 3结算 4退款 5爽约扣款biz_idTEXT关联业务ID比如组队IDstatusINTEGER1成功 2处理中 3失败create_timeINTEGER创建时间戳group_payment 组队支付订单表字段类型说明group_idTEXT组队IDuser_idTEXT用户IDfreeze_amountINTEGER冻结金额final_amountINTEGER结算后最终扣款refund_amountINTEGER退款金额statusINTEGER1已冻结 2已结算 3已退款 4已扣爽约金把这三张表放在一个数据库文件里用数据库外键关系做关联查询。实际开发中流水记录的balance_after字段帮了大忙任何一笔错账都能通过流水回溯到当时的状态排查问题非常快。2.3 金额精度永远用“分”做整数运算第一个必须说的经验Flutter里钱包金额绝对不能用double。0.1 0.2在浮点数体系里不等于0.3这是固有的二进制精度问题支付场景一旦出现分毫误差就是事故。我的做法是所有金额字段统一用int类型以“分”为最小单位存储。页面展示的时候再转成“元”并且做格式化处理。我自己封装了一个MoneyUtilclass MoneyUtil { /// 分转元保留两位小数 static String fenToYuan(int fen) { if (fen 0) return 0.00; final int yuan fen ~/ 100; final int jiao (fen % 100) ~/ 10; final int fenPart fen % 10; return $yuan.$jiao$fenPart; } /// 元字符串转分输入校验过滤非法字符 static int yuanToFen(String yuanText) { final cleaned yuanText.trim(); final parts cleaned.split(.); final int yuanPart int.tryParse(parts[0]) ?? 0; if (parts.length 1) return yuanPart * 100; final decimalPart parts[1].padRight(2, 0).substring(0, 2); final int fenPart int.tryParse(decimalPart) ?? 0; return yuanPart * 100 fenPart; } }这段代码里最容易被忽视的是~/整除运算Dart里不能用/否则得到的是浮点数。MoneyUtil在项目里被复用了几百次像余额展示、充值输入、结算分摊都直接调用从源头杜绝了精度问题。3. 钱包核心功能实现与源码拆解3.1 余额展示首页钱包卡片与自动刷新余额展示看似简单难点在“及时刷新”。我的首页钱包卡片通过ConsumerWalletModel监听余额变化任何一笔交易成功后WalletModel内部更新余额并调用notifyListeners()整个UI树中依赖余额的地方自动刷新。钱包卡片的核心代码如下class WalletCard extends StatelessWidget { const WalletCard({super.key}); override Widget build(BuildContext context) { return ConsumerWalletModel( builder: (context, walletModel, _) { final balanceFen walletModel.balance; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text(我的余额), Text( MoneyUtil.fenToYuan(balanceFen), style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, ), ), Row( children: [ TextButton( onPressed: () Navigator.push( context, MaterialPageRoute( builder: (_) const RechargePage(), ), ), child: const Text(充值), ), TextButton( onPressed: () Navigator.push( context, MaterialPageRoute( builder: (_) const TransactionPage(), ), ), child: const Text(流水), ), ], ), ], ), ); }, ); } }这里有个小细节Consumer的builder方法里walletModel发生变化时只重建当前组件不重建整个页面性能上是OK的。如果首页有多个地方需要监听钱包状态就各自包Consumer避免组件树顶层重建。3.2 充值流程订单生成与支付渠道对接充值流程我设计成四步生成订单 - 拉起支付 - 确认结果 - 刷新余额。class RechargeService { FutureRechargeResult recharge({ required int amountInFen, required PaymentChannel channel, }) async { // 1. 通过服务端生成充值订单 final orderId await OrderApi.createRechargeOrder(amountInFen); // 2. 调用平台支付渠道 final launched await channel.launchPayment(orderId: orderId, amountInFen: amountInFen); if (!launched) { return RechargeResult.fail(支付渠道拉起失败); } // 3. 轮询订单状态等待支付成功 final pollResult await _pollOrderStatus(orderId, timeoutSeconds: 60); // 4. 刷新本地余额 if (pollResult) { await WalletDb.refreshAccount(); } return pollResult ? RechargeResult.success() : RechargeResult.fail(支付超时); } }轮询这步有个容易犯错的地方。千万不能用while死循环加Future.delayed的方式去阻塞逻辑那样会非常丑陋。我用的方式是递归调用自身避免阻塞事件循环Futurebool _pollOrderStatus(String orderId, {required int timeoutSeconds}) async { final sw Stopwatch()..start(); Futurebool poll() async { final status await OrderApi.queryOrderStatus(orderId); if (status OrderStatus.paid) return true; if (sw.elapsedSeconds timeoutSeconds) return false; await Future.delayed(const Duration(seconds: 2)); return poll(); } return poll(); }说到Future.delayed这里顺带提一个Dart异步的细节then回调默认是放在微任务队列里的而Future.delayed的回调是放到事件队列里的。这意味着你在then里写的逻辑会比事件队列里的任务更早执行。刚接触Flutter的同事有时候会在轮询场景里对执行顺序感到困惑搞明白微任务和事件队列的区别就很容易理解了。OpenHarmony上的支付渠道和Android略有差别。Android上直接接微信/支付宝SDK就完事OpenHarmony上部分设备需要走系统统一的支付服务三方SDK的支持情况也参差不齐。我的做法是封装了一个抽象的PaymentChannel接口针对不同平台实现不同的子类上层业务完全感知不到差异。3.3 参团支付的“冻结”与“确认”事务组队模块调用钱包服务时最核心的是“冻结”和“确认”两个动作。用户点击“确认参团”系统先把报名费从可用余额转入冻结余额这笔钱没有被花掉但也不能被其他消费使用。游戏结束后结算时再把冻结余额转成实际扣款。冻结操作的数据库事务如下Futurebool freezePayment({ required String groupId, required String userId, required int amountFen, }) async { final db await WalletDatabase.instance.database; try { return await db.transaction((txn) async { // 1. 检查余额是否充足这里加了一个行锁保护 final account await txn.query( wallet_account, where: user_id ?, whereArgs: [userId], ); if (account.isEmpty) return false; final balance account.first[balance] as int; if (balance amountFen) { throw WalletException(余额不足); } // 2. 扣可用余额加冻结余额 await txn.update( wallet_account, { balance: balance - amountFen, frozen_balance: (account.first[frozen_balance] as int) amountFen, updated_at: DateTime.now().millisecondsSinceEpoch, }, where: user_id ?, whereArgs: [userId], ); // 3. 写流水 await txn.insert(wallet_transaction, { tx_id: generateTxId(FRE), user_id: userId, amount: -amountFen, balance_after: balance - amountFen, type: 2, biz_id: groupId, status: 1, create_time: DateTime.now().millisecondsSinceEpoch, }); // 4. 写组队支付订单表 await txn.insert(group_payment, { group_id: groupId, user_id: userId, freeze_amount: amountFen, status: 1, }); return true; }); } on WalletException { return false; } }这段代码有几个重点第一db.transaction保证原子性。比如扣可用余额成功、但写流水失败整个操作回滚账户不会出现“钱扣了但没记录”的情况。第二查询和更新在同一个事务里SQLite的写事务默认加锁并发情况下不会出现两个请求同时读到同一个旧余额的问题。第三业务上这里用的是“冻结”而不是直接扣款意味着游戏没结束时这笔钱还是用户的查询余额时前端展示的是balance而不是balance frozen_balance。UI层要有提示把冻结金额展示出来避免用户疑惑“我明明有50块怎么不能支付40块的订单”。3.4 结算分摊多人AA的金额计算逻辑游戏结束后房主发起结算系统把全场总费用按人数分摊。分摊金额要处理“除不尽”的情况不能简单四舍五入到分否则总账会对不上。我的做法是“先除后补差”Listint splitAmount(int totalFen, int count) { if (count 0) return []; final base totalFen ~/ count; final remainder totalFen % count; // 前remainder个人多出1分钱保证总和精确等于totalFen return List.generate(count, (index) { return index remainder ? base 1 : base; }); }比如总费用100.01元10001分4人分摊base 250025元remainder 1结果是第一个人付25.01元其余三人各付25.00元总额正好等于100.01元。这个方案比单纯四舍五入靠谱得多。分摊完还要结合每个人之前的冻结金额算“补差”。假如某人的报名费冻结了20元人均分摊是25元他就需要再补5元如果分摊是18元多冻结的2元就退回余额。整个计算过程我在服务端做本地只做展示关键账目以服务端为准。3.5 爽约金和退款逻辑退款在业务上要区分场景开赛前24小时之外退团冻结金额全额退回。开赛前24小时之内退团扣掉20%爽约金剩余80%退回。游戏结束后结算产生的退款走补差逻辑。退款操作本质上是冻结余额的逆操作同样走事务。里面有一个小坑就是爽约金扣掉的部分也要写流水类型标记为5方便信用系统读取。我之前遇到过只退钱没记流水结果信用系统的爽约次数统计不出来排查了半天才发现是流水类型漏了。4. 组件通信与钱包状态联动4.1 组件通信的几种方式怎么选Flutter社区关于组件通信的方案讨论一直很多热搜上也经常看到“flutter组件通信”这个关键词。做钱包和组队联动时我根据场景不同用了三种方式Provider ChangeNotifier适合跨页面的状态共享钱包余额、交易列表这种全局状态。回调函数适合父子组件之间的单向通信比如充值页把“充值成功”的事件回调给钱包页。事件总线EventBus适合完全解耦的模块通信比如组队模块发起结算后钱包模块需要感知并刷新但两者没有直接的父子关系。在OpenHarmony上Provider是纯Dart实现没有任何Native依赖兼容性最稳定所以做主状态管理没有问题。4.2 组队详情页如何调用钱包冻结能力组队详情页点击“参团”后流程是这样的class GroupDetailModel extends ChangeNotifier { final WalletService _walletService; Futurebool joinGroup(String groupId) async { final feeFen await GroupApi.getJoinFee(groupId); final canPay await _walletService.hasEnoughBalance(feeFen); if (!canPay) { _joinStatus JoinStatus.insufficientBalance; notifyListeners(); return false; } final frozen await _walletService.freezePayment( groupId: groupId, userId: currentUser.id, amountFen: feeFen, ); if (frozen) { _joinStatus JoinStatus.joined; notifyListeners(); return true; } return false; } }这里组队模块完全不关心钱包内部怎么实现只知道“冻结成功”还是“余额不足”。钱的事归钱包管队的事归组队管边界清晰。后续如果要把冻结改成花呗扣款或者改成外部支付组队模块一行代码都不需要改。4.3 “参团成功”之后如何刷新钱包卡片一个非常典型的联动场景用户在组队详情页参团成功后回到首页钱包卡片上的余额要立刻变化。这里的实现我做了两步第一步WalletModel在冻结成功后更新内存中的余额字段。class WalletModel extends ChangeNotifier { int _balance 0; int get balance _balance; void updateBalance(int newBalance) { _balance newBalance; notifyListeners(); } }第二步首页钱包卡片和组队页共用同一个WalletModel实例通过上层Provider注入组队页调用freezePayment成功后再手动调用walletModel.updateBalance(newBalance)首页的Consumer立刻感知到变化UI自动刷新。有同学问为什么不用EventBus让钱包自己广播“余额变了”。实测下来在这个场景里EventBus反而不够直观因为余额更新的时机是明确由当前操作触发的直接调方法更可控。EventBus适合的是那种“不知道谁会引起变化”的场景比如信用分变动同时影响多个页面。4.4 异步链路的状态管理钱包和组队通信的异步链路比单页面复杂得多。用户点击“参团”之后要经历本地冻结、服务端确认、本地余额更新三个阶段任何一步失败都要回滚。为了不让用户等得焦虑我在UI层维护了三种状态待提交、提交中、提交完成。提交中的按钮置灰并显示加载动画防止重复点击。状态管理方案对钱包这种强交互强状态模块来说最核心的要求是“状态可预测”。Provider的ChangeNotifier模型刚好满足这一点每次状态变化都走notifyListeners逻辑完全是线性的。我个人不建议在这种强业务模块里引入太重的事件流方案调试成本会高很多。5. Flutter for OpenHarmony踩坑实录5.1 e/flutter错误Dart VM初始化失败搜热词的时候看到很多人遇到过e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled...这类报错我在OpenHarmony设备上也踩过。这个错误通常是Flutter引擎在Native层初始化失败或者Dart代码里有全局非空的变量没有被初始化导致VM启动直接崩掉。排查思路一般是确认.so库是否打入包内尤其是libflutter.so检查构建后的APK/HAP里是否存在。确认abiFilters是否和目标设备的CPU架构匹配。常见的是设备是arm64-v8a但包只打了armeabi-v7a。在main()入口处加全局异常捕获先定位是不是Dart层代码问题。void main() { WidgetsFlutterBinding.ensureInitialized(); FlutterError.onError (details) { // 把错误上报到自己的日志系统 Logger.report(details.exceptionAsString()); }; runApp(const MyApp()); }这个习惯我从第一版就开始保持了后面排查问题省了太多时间。5.2 Impeller渲染引擎要不要开Flutter 3.x默认启用Impeller渲染引擎它是在Skia基础上的优化设计目标是解决Skia的GPU线程卡顿问题。但我在OpenHarmony设备上实测部分国产GPU对Impeller的支持并不好表现是页面偶发花屏、启动黑屏。遇到这种情况可以在构建或运行时关闭Impeller改用Skia兜底flutter run --no-enable-impeller如果你已经打包成HAP可以在项目的AndroidManifest.xml或者对应的配置里加上io.flutter.embedding.android.EnableImpellerfalse。但注意OpenHarmony上具体配置的位置可能不同建议在Flutter引擎初始化参数里处理兼容性更好。我的建议是如果你的App对渲染性能要求不极端先在OpenHarmony目标设备上关掉Impeller跑一阵观察稳定性。钱包页面有大量文本、列表、卡片这类普通UISkia渲染完全够用。5.3 减少PlatformView的使用OpenHarmony的PlatformView适配比Android要费劲一些。传统WebView内嵌H5页面的方案在OpenHarmony上遇到输入框弹不出键盘、滚动卡顿的概率很高。我用钱包功能的“用户协议”页面测试过WebView方案在部分设备上体验很差。最终钱包模块里只有支付结果页用了PlatformView其余协议、公告、帮助中心全部用Flutter原生组件重写了。这个决策大大减少了后续的兼容工作。如果你必须用PlatformView建议把WebView的初始化放晚一点避免页面启动时主线程卡死。5.4 OpenHarmony XTS认证对钱包应用的影响OpenHarmony上架应用前要做XTS兼容性认证其中和钱包功能最相关的是权限声明和应用隐私政策。钱包涉及用户余额、交易记录、支付敏感操作对权限的要求比普通App严格。我踩过的坑是初版隐私弹窗只给了“同意/不同意”没有列出具体采集的数据项XTS认证直接不过。后来把隐私协议按规范拆成数据收集列表明确写明“余额信息、交易记录用于展示账户状态支付信息仅用于完成交易”才顺利过审。提醒所有做OpenHarmony钱包类应用的同学这块一定要前置到开发阶段别等要上架了才临时改。6. 常见问题速查表与避坑技巧问题现象可能原因排查方向支付成功后余额没刷新轮询超时或回调顺序错乱检查支付回调、轮询状态确认余额刷新是否被并发覆盖并发点两次“参团”没有做事务和状态标记检查数据库事务、加行锁、UI层防重复提交金额显示精度错乱用了double存储金额统一改int分存储用MoneyUtil转换组队页冻结成功但流水缺失事务中途失败检查事务是否回滚流水写入是否和余额更新同一事务首页钱包卡片不刷新Provider实例不唯一检查是否创建了多个WalletModel实例OpenHarmony设备花屏Impeller兼容问题关闭Impeller回退Skia渲染6.1 并发点击“参团”导致余额被扣穿这个是我实战中遇到的最严重问题之一。用户在弱网环境下连续点了两次参团按钮两个请求先后发出去如果没有事务保护就会发生“余额被扣穿”的情况第一次请求读到余额50元冻结20元剩30元 第二次请求也读到余额50元第一次还没提交事务再次冻结20元结果余额变30元但实际应该只剩10元。虽然第二次冻结会因为“余额只有30”而失败但极端情况下两个事务交错执行就可能出现脏读。解决方案是三层防护UI层提交中按钮置灰防止连点。服务端对同一用户的未完成订单做幂等校验已存在待支付的参团订单就不让重复下单。数据库层db.transaction事务内做查询和更新SQLite的写锁会把这个场景卡住。6.2 微任务和事件队列执行顺序干扰页面刷新这是热搜词“flutter future的then回调 是放入微任务队列吗”相关的题。Dart中Future的then回调默认放进微任务队列而Future.delayed的回调放进事件队列。微任务会优先于事件队列执行。在钱包支付轮询场景里我曾经写过一个bugawait Future.delayed(const Duration(seconds: 2)); setState(() { ... });看起来是2秒后刷新但因为setState在then里实际刷新时机比预期早很多。原因是外层代码被微任务调度抢先了。正确写法是把刷新逻辑放到明确的异步上下文里或者使用Future.microtask保证边界清晰。6.3 组队和钱包模块的联调自检清单钱包功能开发完我总结了一份联调自检清单分享给大家新用户首次进入钱包余额显示0元不闪白屏。余额不足时点击参团页面提示“余额不足请充值”并且不可继续操作。充值流程中杀进程重新打开App后订单状态通过服务端恢复正常。参团成功后首页钱包卡片、组队详情页、流水页三处余额一致。结算分摊后所有玩家的流水金额总和等于业务总流水分毫不差。退款后冻结余额清零可用余额增加流水记录完整。弱网环境下断网本地余额仍能展示网络恢复后自动同步服务端对账结果。这张清单我们每次发版前都会过一遍虽然不复杂但能防止大量低级回归。我个人在这一轮Flutter for OpenHarmony实战里最大的体会是钱包功能真正的难点从来不在Flutter本身而在数据一致性和跨模块通信的设计上。金额精度、事务边界、状态刷新、并发控制这些基本功扎实了无论后面接什么样的支付渠道、跑什么平台都能稳得住。OpenHarmony适配带来的额外工作量主要集中在渲染引擎和平台通道这些属于一次性成本沉淀成排查手册后后续项目基本可以直接抄作业。最后再补充一个小技巧钱包相关的State和Model我习惯在main.dart里用MultiProvider统一注入而不是在每个页面单独创建。这样可以保证全局只有一份钱包状态任何页面刷新都是同一份数据调试的时候不用来回找实例在哪里省了很多不必要的麻烦。