最近在做的一个盲盒抽奖App,目标终端是OpenHarmony设备,团队已有的技术栈是Flutter。接到需求之前我其实有点打鼓——毕竟OpenHarmony生态起步晚,插件和工具链都不算成熟,真把一个带抽奖、带订单体系的C端应用跑上去,心里没底。但一套完整的项目做下来,我的结论是:flutter for openharmony这条分支已经完全能撑起生产级应用了,关键是要把平台差异摸透。
这篇博文把两条核心业务链路完整拆开讲:一是盲盒抽奖的概率、库存、幂等设计,二是订单管理从模型到状态机再到列表实现的完整闭环。顺带会聊OpenHarmony上Flutter的适配经验,包括插件兼容、网络证书校验、动画性能这几块。如果你正打算把Flutter项目迁到OpenHarmony,或者要在上面从零起一个带抽奖玩法的应用,这篇可以直接拿来当参考。
1. 为什么选Flutter for OpenHarmony:选型逻辑与工程准备
1.1 先拆需求:盲盒App真正的技术难点在哪儿
盲盒App的核心用户流程跟普通电商不太一样,用户路径里藏着一个很关键的节点:抽奖动作。完整的链路大概是:
- 用户浏览盲盒系列列表,查看奖池公示和中奖概率
- 选择某个系列进入详情页,点击抽奖按钮
- 服务端完成随机中奖、库存扣减、订单生成
- 客户端播放开盒动画,最终揭晓奖品
- 已获得的奖品进入"我的盲盒/我的订单"
- 订单支持查看状态、地址管理、发货物流、确认收货
这个流程拆下来你会发现,它的本质是"电商交易 + 概率游戏 + 动画体验"的三合一。所以技术难点也对应有三块:订单状态多且流转复杂、抽奖的高并发放重和防超卖、以及开盒动画的流畅度。选型时必须同时考虑这三件事,只解决其中一个都不够。
1.2 方案对比:纯原生、混合开发、Flutter适配分支怎么选
当时组里讨论过三个方向,各自的优缺点我用表格整理过:
| 方案 | 优势 | 劣势 |
|---|---|---|
| 纯ArkTS原生 | 系统能力调用最直接,性能上限最高 | Android和OpenHarmony两端代码完全不互通,团队要重新学一套UI框架,成本最高 |
| Flutter + 原生混合 | 可以在页面级嵌入Flutter,同时保留原生能力 | 导航和通信桥接复杂度高,多一层维护成本,抽奖页面嵌入原生的体感边界感明显 |
| Flutter for OpenHarmony | 业务逻辑全部复用,一套Dart代码跑多个平台 | 部分三方插件尚未适配,个别系统能力需要开发原生Plugin |
最终选Flutter for OpenHarmony,最核心的原因不是性能,而是团队已有的Flutter技术积累可以直接平移。Dart侧的业务代码、状态管理、UI层几乎不需要改动,只是重新生成了ohos平台目录,再把涉及平台通道的插件换成适配版本。对需要同时保住Android和OpenHarmony两个端的业务来说,这是性价比最高的路线。
1.3 工程初始化:版本组合和环境配置
踩坑最多的其实是版本搭配。OpenHarmony SDK、Flutter ohos分支、IDE三个东西必须对齐同一个发布周期,否则编译期会出现各种奇怪错误。
我当时使用的组合是OpenHarmony SDK 4.1以上、官方维护的Flutter ohos分支、IDE版本跟着SDK走的。初始化步骤不算复杂:
# 拉取flutter的ohos分支并切换 flutter channel ohos flutter doctor -v # 打开ohos平台支持 flutter config --enable-ohos # 创建同时支持ohos和android的工程 flutter create --platforms ohos,android .如果是从已有Flutter工程迁移,操作更简单:删除旧的平台目录,重新用上面的命令生成即可,lib/下的业务代码完全不需要动。这也是Flutter这套体系最值钱的地方——跨端复用不是口号,是真能省下大半工作量。
有一个细节必须注意:工程创建完先跑一次flutter doctor,确认OpenHarmony工具链被正常识别。另外,OpenHarmony的模拟器建议用API 10以上的版本,低版本模拟器跑Flutter引擎很容易崩。
2. 抽奖链路设计:概率配置、库存扣减与幂等防重
2.1 概率体系:权重配置比固定百分比更好维护
抽奖的随机算法本身不复杂,关键是概率的维护方式。很多项目喜欢在后台配置"隐藏款1%、稀有款4%",这种固定百分比看起来直观,但一旦要加一个档位,所有比例都要重新算,容易出错。
我用的方案是权重值。后台配置每个奖品的权重,中奖概率由权重占总权重的比例决定:
def draw_by_weight(prizes): total = sum(p.weight for p in prizes) rand = random.uniform(0, total) upto = 0 for p in prizes: upto += p.weight if rand < upto: return p这个逻辑说白了就是把总权重看成一条线段,每个奖品占一段,随机数落在哪段就中哪个奖。调概率时只需要改某一个奖品的权重值,不需要改其他奖品。要注意的是,权重一定要用整数配置,不要用浮点数,浮点累加会出现精度偏差,时间久了可能导致实际概率和配置不一致。
盲盒App还有一个特殊要求:概率公示。法规和平台规范都要求盲盒商家明示抽取概率,所以后台配置的权重一定要留一份快照给前端展示,不能口头说"隐藏款1%"实际上却是另一个数值。
2.2 库存扣减:原子操作是底线
抽奖不是只做随机就够了,还必须管库存。比如隐藏款全平台只有10个,第10个抽完之后,后续点击必须明确提示"该奖品已售罄"。
库存扣减最不能犯的错是先查库存,在内存里判断有没有货,再更新数据库。这两个操作之间一定有并发窗口,高并发下多个人同时通过检查,库存就扣成负数了。
我的方案分两层。第一层是Redis或内存缓存做预算,缓解数据库压力;第二层是数据库层的原子扣减。如果你的项目并发量没那么夸张,直接上数据库的原子更新就够了:
UPDATE prize SET stock = stock - 1 WHERE id = 'hidden_01' AND stock > 0;受影响行数为0就说明库存已经没了,这个方案简单可靠,不需要额外引入Redis。
如果并发确实高,比如热门系列首发抢购,那就用Redis的Lua脚本做扣减:
local stock = tonumber(redis.call('GET', KEYS[1]) or 0) if stock <= 0 then return -1 end redis.call('DECR', KEYS[1]) return stock - 1Lua脚本在Redis里是原子执行的,不用担心DECR和其他操作之间的并发问题。但要注意一点:缓存里的库存和数据库里的真实库存必须做一致性校准,我的做法是每天定时任务把数据库的库存快照同步到Redis,并核对订单数是否超卖。
2.3 幂等防重:防止用户点一次出两个单
移动端抽奖最常见的线上事故就是"用户双击抽奖按钮,结果生成两笔订单"。客户端做按钮防抖是基本要求,但绝对绝对不能只依赖客户端,服务端必须做幂等。
我的设计是:客户端每次抽奖生成一个requestId(UUID),服务端以"用户ID + requestId"作为唯一键去重。同一requestId的请求只处理一次,后面的重复请求直接返回第一次的结果。
抽奖接口的响应体长这样:
{ "code": 0, "data": { "orderId": "BX202503120001", "prizeId": "hidden_01", "prizeName": "隐藏款-星光独角兽", "requestId": "7d3f2a9e-81c2-4f5b-9a10-3f2e6d0c8b21", "status": "pending_show" } }这里有一个容易被忽略的设计细节:接口返回的status是"pending_show",意思是"抽奖结果已确定但客户端还没播完开盒动画"。用户在网络抖动时退出App再进来,订单列表里能看到这个盲盒处于"待开盒"状态,点击后再播放动画揭晓奖品。这样处理比接口直接返回"success"要严谨得多,因为用户一旦在中途退出,订单状态和服务端是严格对得上的。
抽奖状态机的核心流转设计如下:
| 状态 | 含义 | 触发动作 |
|---|---|---|
| idle | 待抽奖 | 点击抽奖按钮 |
| drawing | 抽奖中/动画播放中 | 请求返回,结果已锁定 |
| reveal | 可揭晓 | 开盒动画播完 |
| done | 已完成 | 用户看到奖品详情 |
客户端在这几个状态之间通过一个bloc或riverpod的StateNotifier管理,页面根据状态渲染对应的UI组件。
3. 订单管理实现:订单模型、状态流转与列表页落地
3.1 订单数据模型:字段设计要预留扩展空间
盲盒订单比普通电商订单特殊的地方在于:一张订单在抽奖成功时就已经生成,后续可能要经历支付、开盒、发货、售后全流程。所以字段不能只想着"下单时够用",要给售后和运营留出空间。
最终用的字段是这样一组:
- orderId:订单号,唯一索引
- userId:用户ID
- requestId:抽奖请求ID,幂等用于防重
- seriesId:盲盒系列ID
- prizeId:中奖奖品ID
- prizeName:奖品名称
- prizeImage:奖品图片
- amount:实付金额,单位用分,避免浮点误差
- status:订单状态
- createTime / payTime / openTime / shipTime / finishTime:各节点时间
- addressId:收货地址ID
- extra:JSON扩展字段,放优惠信息、活动信息等
本地存储我用的是drift,它在Flutter里是编译到SQLite的,OpenHarmony上跑没有兼容问题。订单表的核心定义长这样:
class Orders extends Table { TextColumn get orderId => text().unique()(); TextColumn get userId => text()(); TextColumn get requestId => text()(); TextColumn get seriesId => text()(); TextColumn get prizeId => text()(); TextColumn get prizeName => text()(); TextColumn get prizeImage => text()(); IntColumn get amount => integer()(); IntColumn get status => intEnum<OrderStatus>()(); DateTimeColumn get createTime => dateTime()(); DateTimeColumn get payTime => dateTime().nullable()(); DateTimeColumn get shipTime => dateTime().nullable()(); TextColumn get extra => text().nullable()(); }金额用整数分而不是浮点数,这是电商项目的老规矩,盲盒App同样适用。涉及支付对账一律用分做单位,展示时再除以100。
3.2 状态流转:用状态机驱动,不用散落的if-else
订单状态从源头梳理下来有七种,覆盖了正常流程和售后流程:
- 待支付(PENDING_PAYMENT)
- 已支付(PAID)
- 待发货(PENDING_SHIP)
- 已发货(SHIPPED)
- 已完成(COMPLETED)
- 已取消(CANCELLED)
- 退款中(REFUNDING)
注意盲盒App的订单流转顺序跟普通电商不太一样:支付完成后不是直接待发货,而是先开盒,开盒动画结束后才进入待发货。这是因为"开盒"本身是一个需要用户互动的环节,订单状态必须记录这个中间态。
状态迁移用sealed class来做最合适,编译器能帮你在switch时检查所有分支:
sealed class OrderStatus {} class PendingPayment extends OrderStatus {} class Paid extends OrderStatus {} class PendingShip extends OrderStatus {} class Shipped extends OrderStatus {} class Completed extends OrderStatus {} class Cancelled extends OrderStatus {} class Refunding extends OrderStatus {}状态转移关系我用一张表维护,比四处改if-else清晰得多:
| 当前状态 | 触发事件 | 迁移后状态 |
|---|---|---|
| 待支付 | 支付成功回调 | 已支付 |
| 待支付 | 超时取消 | 已取消 |
| 已支付 | 开盒完成 | 待发货 |
| 待发货 | 后台发货 | 已发货 |
| 已发货 | 用户确认收货 | 已完成 |
| 已支付/待发货 | 用户发起退款 | 退款中 |
| 退款中 | 退款完成 | 已取消 |
在客户端展示时,这个状态机直接决定了按钮区怎么渲染:待发货状态显示"查看物流"按钮、已发货状态显示"确认收货"按钮,已完成状态只显示"再次抽奖"入口。状态判断全都走统一方法,不要在多个页面各自写判断。
3.3 订单列表与详情页:分页、筛选与空状态
订单列表页是用户最常用的页面,设计上核心要处理三点:分页加载、状态筛选、空状态。
接口分页参数我定的是page从1开始、pageSize固定20,返回hasMore供客户端判断是否还有下一页。这个设计很简单,但一定要在接口里返回总页数或hasMore字段,否则客户端不知道什么时候停止加载。
Future<List<OrderItem>> fetchOrders(int page, int pageSize) async { final res = await httpClient.get('/order/list', queryParameters: { 'page': page, 'pageSize': pageSize, }); final data = jsonDecode(res.data) as Map<String, dynamic>; return (data['items'] as List) .map((e) => OrderItem.fromJson(e as Map<String, dynamic>)) .toList(); }列表页我是用Riverpod的StateNotifier管理,下拉刷新用RefreshIndicator,滚动到底自动加载下一页。这里有个实践教训:Flutter的ListView.builder搭配controller做无限滚动时,要记得在加载中给一个底部loading指示器,避免用户以为到底了。
订单筛选方面,横向Tab(全部/待发货/已发货/已完成)直接切换时,不要重新请求整个列表,而是把已加载的数据按状态在内存里过滤,配合"下拉到底再加载"的接口分页,体验会自然很多。
最容易翻车的反而是空状态。用户第一次进来没有订单,如果只显示一个空白页面,用户大概率直接退出。空状态要带引导文案"还没有盲盒订单,去抽一个吧",加上跳转按钮直达抽奖页。这个细节看着不起眼,但对新用户留存的影响非常直接。
订单详情页相对简单,核心是把状态和流转信息展示清楚:待发货就展示"商家正在打包"、已发货就展示物流轨迹、已完成就展示再来一单入口。千万不要把所有字段一锅端堆在页面上,订单状态的每一步其实用户只关心两件事:现在到哪一步了,下一步什么时候发生。
4. 盲盒开箱动效:从点击抽奖到卡片翻转的完整实现
4.1 交互节奏:动画和网络请求并行,不等接口
盲盒开箱的体验设计有一个核心原则:动画和请求并行,让用户永远感觉不到"等接口"。如果页面先转圈等接口返回,再播放开盒动画,用户会明显感觉到卡顿;如果先播动画再请求,动画播完还没拿到结果,用户会更烦躁。
我的节奏设计如下:
- 0ms:用户点击抽奖按钮,页面立即进入动画状态,盒子出现并开始摇晃
- 0~800ms:网络请求发出,动画持续播放,盒子来回晃动并逐渐加大幅度
- 800~1200ms:盒子打开,画面先变白光,暂不揭晓
- 1200ms:结果拿到后,卡片开始翻转,展示奖品
这个设计的关键在于动画总时长大约1.2秒,而抽奖接口在正常情况下500ms内就能返回。到了1200ms节点,几乎必然已经拿到结果。即使接口异常慢,也要保证动画播完时能展示一个"开盒失败重试"的状态,而不是让用户卡在动画里。
4.2 摇晃和开盖动画:TweenSequence组合
盒子摇晃动画用Flutter的AnimationController加TweenSequence实现,把摇晃幅度放在一个序列里控制,看起来才有"越摇越带劲"的手感:
final controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), ); late final Animation<double> shake = TweenSequence<double>([ TweenSequenceItem(tween: Tween(begin: -0.06, end: 0.06), weight: 40), TweenSequenceItem(tween: Tween(begin: 0.06, end: -0.06), weight: 40), TweenSequenceItem(tween: Tween(begin: -0.06, end: 0.0), weight: 20), ]).animate(controller);把shake这个值传给Transform.rotate,盒子就会围绕中心点左右摇摆。开盖动画我用的方式是把盒子拆成"盒身"和"盒盖"两个图层,盒盖做一个向上的位移和旋转组合,视觉上是盒子盖子被掀开。这里不用做特别复杂的3D建模,两层素材叠加就够真实了。
4.3 3D翻转揭晓卡片:Matrix4透视是关键
结果揭晓是整套动效的视觉高潮。我用的方案是3D翻转卡片:正面是一个金色问号,背面是奖品图,翻转180度后露出结果。
核心代码:
Transform( transform: Matrix4.identity() ..setEntry(3, 2, 0.001) ..rotateY(angle), alignment: Alignment.center, child: angle < pi / 2 ? frontWidget : backWidget, )这里最容易犯的错是忘了setEntry(3, 2, 0.001)。不加这一行,Matrix4的默认透视是正投影,卡片翻转时会完全垂直于屏幕,看起来像一张纸片贴上去,没有任何立体感。加上这一行后,就有了近大远小的透视效果,翻转过程才真的有"卡片在空间里转了一圈"的感觉。
翻转角度我用controller.value映射到0到pi,配合Curves.easeOut,前段快后段慢,视觉上更接近真实物理惯性。前后两个面在角度越过pi/2时切换,代码里用了个简单的三元判断,但要注意两个面的宽度保持一致,否则翻转交接瞬间会有跳变闪动。
4.4 音效与震动:仪式感的最后一公里
开盒动画跑得再炫,没有对应的音效和震动反馈,体感还是差一截。音效我在两个节点触发:动画开始时的"摇盒"低频音,和结果揭晓时的高频"叮"音。震动用系统能力触发,短一下即可,持续震动会很烦。
OpenHarmony上的震动调用需要声明权限,这个跟Android的VIBRATE权限逻辑基本一样,在ohos的module.json5里配一下就行。音效文件一定要做压缩,每个控制在100KB以内,否则首次加载会拖慢动画启动时间。
5. OpenHarmony适配实录:插件兼容、网络层与性能优化
5.1 常用Flutter插件的兼容落差与替代方案
OpenHarmony上跑Flutter,最大的痛点是三方插件。很多在Android上随手用的插件,在ohos上要么没适配,要么适配不完整。我用过的几个需要特别处理的:
- shared_preferences:早期版本在ohos上会回退到原生实现失败,需要换成适配过的版本,或者自己封装本地KV存储
- path_provider:能跑,但返回的目录结构和Android不完全一致,拼接缓存目录时容易踩坑
- cached_network_image:图片缓存路径依赖path_provider,所以要等path_provider先适配好
- dio:基础请求没问题,但涉及证书校验的配置需要单独处理,后面详说
- flutter_webview:如果App里有H5活动页,这个插件要在ohos上找对应的适配分支,不要直接用默认版
我在项目里为了减少插件依赖,本地KV存储直接用了一个轻量方案:自己封装文件读写,把用户偏好写成json存放在应用目录下。这种选择在数据量不大时完全没有问题,还能避免三方插件的兼容风险。
5.2 网络层:证书校验和测试环境的坑
OpenHarmony的网络安全配置比Android更严格,尤其是自签名证书的测试环境,很容易出现"Android能跑、ohos上请求全失败"的情况。
原因在于Flutter默认的HttpClient在OpenHarmony上会走系统级的证书校验逻辑,测试服务器的自签名证书不在信任链里。有两种解决办法:一是给测试服务器配上正式的CA签发证书,二是开发阶段通过HttpOverrides临时放宽校验:
class DevHttpOverrides extends HttpOverrides { @override HttpClient createHttpClient(SecurityContext? context) { return super.createHttpClient(context); } } void main() { HttpOverrides.global = DevHttpOverrides(); runApp(const MyApp()); }注意这个写法只在开发阶段用,正式打包发布时一定不能保留。我的做法是用编译环境变量控制:debug模式下注入DevHttpOverrides,release模式下保留系统默认校验,防止有人把绕过校验的代码带到生产。
网络层还有一个细节:dio的本地缓存目录是基于path_provider的,而ohos上path_provider返回的目录可能和Android不同。如果你在Android上硬编码了绝对路径,迁移过来必炸。统一用path_provider取绝对路径再拼接文件名,才是跨端唯一正确姿势。
5.3 性能调优:首帧耗时与动画帧率
OpenHarmony设备如果配置一般,Flutter应用的性能表现就要靠开发者精细控制。我有三个实际经验:
第一,启动时不要做大量同步IO。App启动首帧只要保证页面骨架能渲染,本地缓存、订单数据这些全部异步加载,否则首帧容易掉到1000ms以上。我做过对比,把本地数据库初始化改成懒加载后,首帧耗时下降将近一半。
第二,动画期间避免大范围setState。开盒动画播放时,页面里如果有一个整页setState,很容易导致掉帧卡顿。我当时的做法是抽奖页拆成两个组件层级:动画层用独立的StatefulWidget管理AnimationController,外层页面只更新状态文本,不让整棵组件树参与重建。
第三,图片预加载。抽奖页在用户进入时就把奖品图片用precacheImage提前加载到内存,这样揭晓动画播放到翻转那一步时,图片能即时显示,不会出现先空白后出图的尴尬。资源充足的条件下,这也算最便宜的性能优化手段之一。
另外一个与性能相关的问题是热重载。OpenHarmony上手写热重载在复杂页面偶尔会失效,表现为改了代码但界面没反应。我的建议是别完全依赖热重载,经常手动重新运行整个工程,排查问题的速度快很多。
最后分享一点项目里的真实体会
盲盒App做下来,我最大的感受是:这套业务真正难的不是UI或者动画,而是"抽奖结果、订单状态、开盒状态"这三者的一致性。只要用户抽到了但网络断了一下、或者抽到后没看完动画就杀掉了App,订单状态和展示状态就可能对不上。所以设计一开始就把"pending_show"这种中间态引入了订单状态机,后续所有页面都围绕它做文章,才没有在联调阶段出现不可控的状态黑洞。
如果你也要做类似项目,我建议先把状态流转图画完整再动手写代码,尤其是异常分支(超时、重复点击、中途退出)一定要提前设计,不然后期补会非常痛苦。另外一个建议是抽奖服务端的逻辑无论如何要做压测,盲盒App到了首发活动那一刻流量曲线非常陡,库存扣减层的原子性和幂等设计扛不住的话,线上事故分分钟出现。