☰
Flutter适配OpenHarmony:剧本杀组队App优惠券模块全流程实践
2026/10/13 3:56:41 网站建设 项目流程

剧本杀组队App 做到优惠券这一块的时候,我才意识到这个模块并没有想象中那么"小而美"。券的状态管理、跨端适配、组队结算时的边界条件,每一项都值得仔细打磨。尤其是用 Flutter 跑在 OpenHarmony 上时,很多想当然的写法都会被底层差异打脸。这篇文章就把我从环境准备到功能上线,完整走一遍优惠券管理的实现过程,包括业务模型设计、领券和核销逻辑、OpenHarmony 适配经验,以及最后上线前我自己整理的一整套测试清单。正在做类似跨端业务模块的朋友,可以直接拿来当参考。

1. Flutter 在 OpenHarmony 上跑通:这一步消耗的精力超出预期

1.1 为什么选择 Flutter 跑 OpenHarmony

剧本杀组队 App 的业务场景很典型:用户量不算大,但用户设备分散在多种操作系统上。我们团队早期只做了移动端,后来发现用户里有一批设备是 OpenHarmony 底座,再养一套原生开发的人力不现实。正好 Flutter 有了针对 OpenHarmony 的适配层,可以把原来的 Flutter 代码低成本移植过去,UI 和业务逻辑复用,只需要处理平台差异部分。

这个思路本身没什么问题,但实际操作下来,"低成本"三个字要打个引号。适配层确实帮你抹掉了很多底层差异,Push、存储、网络这些能力仍然需要逐个确认,尤其是依赖三方库时,兼容性问题会一个接一个冒出来。我的建议是:如果你想做这件事,先把预算时间定在普通 Flutter 平台适配的 1.5 到 2 倍,不然很容易被排期打乱。

1.2 环境准备的三步走

整个环境准备可以拆成三步:

  1. 获取带 OpenHarmony 适配的 Flutter SDK。官方渠道会提供独立版本,不要直接拿标准 Flutter SDK 去编译,会出现引擎层不匹配的问题。
  2. 安装对应的 IDE 和 SDK。这里要注意版本对应关系,IDE 版本、SDK API 版本和 Flutter 适配版本必须形成一条匹配链。我当时就是因为 IDE 装新了半级,导致编译时反复提示引擎缺失。
  3. 用官方配置工具自动替换 Flutter 引擎版本。这一步要耐心等,它会下载对应鸿蒙底座的引擎产物,然后配置环境变量。

跑通之后,你会看到一个默认计数 Demo 在真机上跑起来。别急着庆祝,这个 Demo 只是验证了引擎和工具链没问题,真正开始写业务代码之后,平台差异的坑才刚开始。

1.3 条件导入:处理跨平台差异的第一道防线

在 OpenHarmony 上用到真实业务代码时,我发现很多依赖纯 Dart 写的库没问题,但只要碰到底层能力,比如本地存储、网络会话、设备信息获取,就会出现不同实现之间的冲突。有些库可能还没有针对 OpenHarmony 的实现,有些实现不完整。这时候最干净的方案是给平台差异代码做条件导入。

import 'package:our_app/storage_interface.dart' if (dart.library.ohos) 'package:our_app/storage_ohos.dart' else 'package:our_app/storage_standard.dart';

这段代码的意思是:在 OpenHarmony 环境下导入 ohos 实现,其他平台照旧。这个写法的好处是业务层不感知平台差异,只需要依赖接口。我们在项目里把存储、渠道信息、埋点上报都做了类似封装,后面适配新平台时只新增一个实现文件,不动业务代码,整体维护成本降了很多。

注意:if (dart.library.ohos)这个条件标识要以你拿到的适配版 SDK 为准。版本不同写法和判断条件可能有细微区别,动手前先在你的 SDK 目录下确认一下支持列表。

2. 优惠券业务设计:先定义清楚状态,再碰 UI

2.1 剧本杀组队场景下的三类优惠券

做优惠券设计之前,得先搞清楚业务上到底需要哪些券。剧本杀组队和普通电商购物的优惠券差别挺大,电商的券核心是"满减",而剧本杀组队还要考虑"人数门槛"和"组队状态"。我们当时把场景收敛成三类:

券类型触发场景使用门槛典型面额
单人满减券单人预约任意剧本订单满 58 元减 10 元
组队拼车券四人及以上本,组满发车需组满 4 人每人摊减 20 元
拉新邀请券邀请新用户完成首单被邀请人完成组队邀请人得 30 元券

这三类券分别命中不同的用户行为:单人预约是为了提高客单价,组队拼车券是为了解决"组队差人"的痛点,拉新券则是为了低成本获客。不同类型的券,在有效期、锁定方式和核销策略上有完全不同的要求。比如组队拼车券必须等到车队满员才能使用,这就引出了"锁券"的需求。

2.2 状态机的设计:五状态比三状态靠谱

我最初设计的优惠券状态只有三个:未领取、已领取、已使用。结果在联调阶段就被业务方问住了:用户在组队中把券锁定了,但队伍迟迟没组满,同一天他又想用这张券单独约一个小本,前后端对于一个"正在被组队占用的券到底能不能用"产生了分歧。

后来我们把状态扩展成五个:

  • 未领取:系统可发,用户还没拿
  • 已领取:用户持有,尚未使用,且未参与任何组队锁定
  • 已锁定:用户发起组队时被系统占用,不能用于其他订单
  • 已核销:订单完成,券被消耗
  • 已失效:过期或被系统回收

状态流转的规则也很清晰:未领取可以变已领取;已领取发起组队后变已锁定;组队成功并且结算完成后,已锁定变已核销;组队解散或超时未满员,已锁定回退成已领取;时间超过有效期,直接进入已失效。这套流转规则解决了之前"券状态归属不清"的问题。

现在回头看,状态机设计是优惠券模块里最重要的一件事。页面写错了可以改,接口联调出问题可以修,但如果状态流转一开始就没定义清楚,后面所有业务逻辑都会跟着歪。设计状态时建议多问一句:"这个状态在什么情况下会流转到哪个状态?触发条件是什么?由谁来触发?"把这三件事写成文档,再开始写代码。

2.3 数据模型与前后端字段对齐

状态机定下来之后,数据模型就顺理成章了。我这里给出前端模型的核心字段,后端字段与前端保持命名一致,只是常量定义在各自端维护。

enum CouponType { fullReduction, groupRide, inviteReward } enum CouponState { available, claimed, locked, used, expired } class Coupon { final String couponId; final String title; final CouponType type; final double threshold; // 门槛金额 final double amount; // 优惠金额 final int minMembers; // 组队券:最低人数 final DateTime validStart; final DateTime validEnd; final CouponState state; bool isUsableFor(OrderInfo order) { if (state != CouponState.claimed && state != CouponState.locked) return false; final now = DateTime.now(); if (now.isBefore(validStart) || now.isAfter(validEnd)) return false; return order.totalAmount >= threshold; } }

字段对齐这件事看着简单,实际很磨人。前端是validEnd,后端文档写expireTime,接口返回end_time,光这个就得适配三层。建议项目开始时就定一个约定:接口字段用 snake_case,客户端模型转 camelCase,由统一的fromJson完成转换。别嫌这一层多余,后面排查问题时会帮你省不少时间。

3. 券列表与领券页:交互细节里藏着用户体验

3.1 列表页布局:让用户一眼看到最想看的

优惠券列表页是用户接触这个模块的第一站,布局上要遵循"重点信息优先"的原则。券的面值和适用条件是最重要的,我用的是左侧大字号金额、右侧信息区上下排列的方式。组队拼车券因为有人数门槛,额外在右上角加了一个标签,比如"需组满 4 人",这样用户在列表页就能快速判断这张券适不适合自己。

页面分成了三个 Tab:可领取、我的优惠券、已失效。已失效的券我特意保留了,因为有些用户会回来问"我的券怎么没了",有这个列表能减少大量客服咨询。失效券放在列表里视觉上做灰度处理,按钮位置显示"已过期",不再做点击操作。

列表页的数据来源做了分页加载。翻页使用常规的 scroll controller 触底加载,每次请求 20 条。这里有一个值得提醒的点:切 Tab 时不要重新拉全量数据,我的方案是三个 Tab 各自维护数据源,只在首次进入时加载,后续通过下拉刷新更新。

3.2 领券的幂等处理:防重复点击只是个开始

领券按钮看起来简单,实际最容易出问题。一个用户在网络慢的情况下连续点两次领券,后端会收到两个请求。虽然后端可以靠幂等键去重,前端仍然要主动避免重复请求,至少不能让用户看到按钮反复转圈。

Future<void> claimCoupon(String couponId) async { if (_claiming.contains(couponId)) return; // 请求进行中,直接忽略 _claiming.add(couponId); notifyListeners(); try { final result = await repository.claim(couponId); updateCouponState(couponId, CouponState.claimed); } catch (e) { errorMsg = '领取失败,请稍后重试'; } finally { _claiming.remove(couponId); notifyListeners(); } }

这个方案的核心是用一个集合记录正在请求中的券 ID,请求完成前重复点击直接被拦截。它不是设置按钮不可点那种简单粗暴的方案,因为用户可能快速切换 Tab 再切回来,按钮状态不一定能同步到位,状态集合这种方案更可靠。

领券成功之后,我选择了"等待接口响应后再更新状态"。起初也考虑过乐观更新,觉得先变"已领取"再悄悄同步,体验会更流畅。真的做了一版之后发现,如果后端返回失败,UI 还要回滚,这个回滚过程带来的状态闪动反倒更伤体验。领券这种操作本身响应速度够快,等一等完全可接受。

3.3 列表刷新卡顿的优化

优惠券列表页面在联调阶段出现了一个让人头疼的问题:每次状态变化,整个列表都重建,导致滚动时明显卡顿。原因很直接,我用一个大的 ChangeNotifier 管理了所有券数据,任何一张券变了,所有监听者都收到通知。

解决办法是把每个 Tab 的数据拆成独立的 ChangeNotifier,再用Selector泛型细化监听粒度。代码层面最直观的变化是:列表项从"整个列表刷新"变成"对应券 ID 的条目刷新",滚动帧率一下子就上来了。

再说一个容易被忽视的点:优惠券列表页的滚动性能还受卡片复杂程度影响。我在卡片里放了渐变背景、圆角边框和阴影,这些装饰在 OpenHarmony 上的渲染开销比普通平台更大。如果列表滚动性能不达标,除了状态管理,先检查一下卡片是否使用了过多的阴影和半透明效果,能省则省。

4. 核销与结算:优惠计算和并发边界才是硬骨头

4.1 组队结算中的优惠计算逻辑

组队场景下的优惠计算比单人场景复杂很多,核心原因在于:订单金额不是固定的,组队人数会影响单价,而且券的补贴方式在不同类型的券之间完全不同。

以组队拼车券为例,队伍满 4 人后,整单总金额是 4 个人的预付总和,如果队长有一张满 200 减 40 的券,实际支付金额变成 160 元,那么四个人的分摊金额就变成人均 40 元。这里要处理的是分摊精度:如果总金额不能被人数整除,余数要归到队长头上,避免出现几分钱的差额。

前端计算逻辑我用了一段很直白的代码:

double calcDiscount(OrderInfo order, Coupon coupon) { if (!coupon.isUsableFor(order)) return 0; switch (coupon.type) { case CouponType.fullReduction: if (order.totalAmount < coupon.threshold) return 0; return min(coupon.amount, order.totalAmount); case CouponType.groupRide: if (order.memberCount < coupon.minMembers) return 0; return min(coupon.amount, order.totalAmount); case CouponType.inviteReward: return min(coupon.amount, order.totalAmount); default: return 0; } }

券是否可用,不只要看金额门槛,还要看人数门槛和当前状态。我把这几项检查全部收敛在isUsableFor一个方法里,避免在页面里散落各种if判断。真实项目里,券规则只会越来越复杂,本月可能有"周五晚场专属券",下个月可能出"新本首周五折券",规则集中管理是唯一能扛住长期演进的方案。

4.2 前端预计算和后端核准的分工

前端计算了优惠金额,是不是直接把金额传给后端扣减就行?绝对不行。前端计算只是给用户看的即时反馈,真正的核销必须以后端计算为准。如果前端把算好的优惠金额直接传给后端,用户只要改一下请求字段,就能把一张券变成无限金额,这是一个非常低级但非常致命的漏洞。

正确流程是:前端提交订单时只传优惠券 ID,后端自己拉取券信息、校验状态、计算优惠、扣减额度、返回最终金额。前后端双算会造成一个常见问题:结果不一致。明明是同一笔订单,前端算的优惠金额和后端返回的对不上,页面崩没崩先不说,用户一定来投诉。解决这个问题只需要一条规则:前端展示的金额以后端返回为准,前端计算仅仅用于下单前的预览。

核销接口必须做幂等。同一个订单只能提交一次核销请求,用户犹豫时点了两下"确认支付"或者网络超时自动重试,都不能造成一张券被核销两次。后端可以用订单号做唯一约束,前端可以在提交成功后把按钮置为不可点并跳转结果页。

4.3 锁券、退款与过期券处理

前面状态机里提到过"已锁定",这个状态在结算流程里的作用很大。用户发起组队时,前端可以调一个锁券接口,把券从"已领取"变为"已锁定"。锁定之后,这张券在锁定期内不能用于其他订单。如果队伍在规定时间内没有组满自动解散,或者用户主动解散队伍,锁券接口要自动释放,让券回到"已领取"状态。

这里最容易出问题的场景是:用户发起组队时锁了券,然后换了队长,或者把队伍转让给别人,锁券关系没有同步变更,导致原券一直挂在旧队伍名下。所以在队伍转让逻辑里,必须加上"券归属人变更"的处理。

退款场景也需要单独设计。订单取消并退款时,优惠券要不要归还?我们的规则是:未核销的订单取消,锁定状态直接释放;已核销的订单退款,券不归还,但可以按比例返还等额平台积分。这个规则避免了"用户反复下单再退款刷券"的漏洞,具体业务可以根据自己的风控需求调整。

过期券的处理我们分了两层:服务端每天跑定时任务批量失效;客户端在 App 进入前台时刷新一次时间状态,避免用户一直开着 App 看到一张早该过期的券还能点"去使用"。前端判断过期要统一用服务器时间,不要拿设备本地时间,不然用户改个系统时间就能把过期券"复活",这种低级问题在真实项目里真的发生过。

5. OpenHarmony 适配实战:三个让我意外的差异

5.1 本地缓存依赖不兼容

项目里原本用了一个比较流行的本地存储库来做优惠券草稿和用户浏览记录的缓存,在 Android 上跑得好好的,换到 OpenHarmony 上直接运行不起来。排查了很久,发现是插件层没有针对 OpenHarmony 平台的原生实现,引擎根本加载不了。

解决方案是把存储能力抽象成接口,用条件导入分别提供 OpenHarmony 实现和其他平台实现。OpenHarmony 上我们直接改用它自带的轻量级偏好存储能力,数据量不大时性能足够。这段经历给我留下的经验是:用第三方 Flutter 插件之前,先去它的源码目录看一下有没有对 OpenHarmony 的实现,没有的话尽早做隔离层,别等到最后联调阶段才来补课。

5.2 键盘与安全区的表现差异

适配过程中最直观的差异发生在输入场景。在结算页填写备注时,OpenHarmony 上的软键盘会把底部输入框整个顶出屏幕,页面整个错位,按钮也点不到。Android 上同样的代码逻辑是正常的。

排查后确认是viewInsets的处理差异。OpenHarmony 适配层对键盘高度的上报时机和 Android 不同,需要在键盘状态变化时主动给滚动视图添加一个底部 padding。

final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.only(bottom: bottomInset), child: ListView(children: children), );

除了键盘,底部横条手势区域也需要注意。组队详情页底部的"发起组队"按钮如果直接放在页面底部,会在 OpenHarmony 上被手势条遮挡一段。处理方式是使用 SafeArea,或者根据系统底部安全区域高度做 padding。这类问题在视觉走查阶段才会暴露,但一旦上线影响就是大批用户的支付按钮点不到,必须提前测。

5.3 图片缓存和字体渲染的差异

图片加载踩了一个不小的坑。项目里用了常见的网络图片缓存组件,在 OpenHarmony 上初始化阶段直接失败,现象是所有网络图片都不显示,控制台报错指向插件底层方法未实现。我换成了基础的Image.network先保证功能可用,然后在应用层自己做了一层内存缓存,避免页面频繁重建时重复加载网络图。

字体渲染差异也让设计同事崩溃了一阵。同一套字体,在 OpenHarmony 上的行高和字间距跟 Android 显示不完全一致,中文引号和特殊符号偶尔会错位。这个不是代码能完全解决的,适配版本对字体渲染的实现逻辑不同。我们的做法是给设计规范里加了一条备注:关键页面按 OpenHarmony 实际渲染效果微调字号和行高,以"看得清、不遮挡"为验收标准。

6. 优惠券链路的测试清单:上线前必须过一遍

6.1 状态流转用例

优惠券模块的测试核心是状态流转,我整理了一张基本的用例表,梳理状态机的正常路径和异常路径。

场景操作预期结果
正常领取点击"立即领取"状态变为已领取,按钮变为"去使用"
重复领取连点两次"立即领取"只发一次请求,第二次被前端拦截
领券失败网络断开时点击领取提示"领取失败",券状态保持不变
发起组队锁券使用已领取的券发起组队状态变为已锁定,其他订单不可用该券
组队解散解散未满员的队伍锁定状态释放,券回到已领取
订单取消未核销订单取消如果券处于锁定状态,自动释放
过期展示将服务器时间调到过期后券显示"已过期",不能进行任何操作

这张表要作为前后端联调的基线,任何新加的状态流转场景,都要先落到这张表里评审,再写代码。

6.2 并发和边界条件的测试

除了状态流转,并发和边界条件是优惠券最容易出事故的地方。

  • 同一张券在两台设备上同时下单。后端要保证只有一笔订单能核销成功,另一笔会收到明确错误提示。
  • 金额刚好等于门槛。满 58 减 10 的券,订单总额正好 58 元时必须能用。
  • 时间边界。券的有效期是 23:59:59 结束,用户 23:59:58 提交订单,前后端是否都能正确处理。
  • 组队人数边界。组队券要求满 4 人,第 4 个用户加入的瞬间才亮起"可使用",少一个人都不能亮。

这类用例很难通过手工反复测试,我建议写成集成测试脚本,每次发版前自动跑一遍。尤其时间边界用例,手工操作根本不可能等到那个秒级时刻,脚本才是可靠的。

6.3 双端回归清单

最后是发版前的双端回归。因为 Flutter 代码一套,很多人容易只测一个平台就发版,这个习惯要改掉。OpenHarmony 和 Android 的差异在细节里,不是大功能层面,而是体验层面。发版前的清单至少包含这几项:

  • 优惠券列表滚动流畅度,重点看卡片多的场景
  • 结算页输入备注时键盘是否遮挡按钮
  • 底部功能按钮是否被手势条遮挡
  • 网络图片在弱网环境下是否正常显示
  • 字体行高和描边效果是否符合设计稿

我这里有一个实在的体会:适配不是一锤子买卖。OpenHarmony 的适配层还在持续更新,升级 Flutter 引擎版本后,之前调好的布局和缓存方案可能要重新验证。所以建议在每个迭代计划里预留 10% 到 15% 的时间,专门用来跑一次双端回归,不然很容易出现"升级完引擎,优惠券列表在 OpenHarmony 上白屏"这种事。

如果你也在做类似的组队类 App 优惠券模块,最重要的建议是:先定状态机,再做页面;先定前后端计算边界,再写核销接口;先做平台差异隔离,再去铺页面功能。这三个顺序反过来,后期返工的成本会成倍增加。最后一个小技巧:优惠券相关的埋点一定要从第一天就开始接,领券率、核销率、过期率这些数据,后续优化券面规则时全都靠它们,等上线后想补就很痛苦了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询