
剧本杀组队App做到第22篇终于轮到大家催更很久的快速匹配功能。前面我们把登录、剧本库、房间列表、开团下单这些骨架都搭完了但剧本杀组队最大的痛点始终没解决——人凑不齐。于是这一篇就把Flutter for OpenHarmony实战的时间线推到了快速匹配上。目标很明确玩家点一次按钮系统帮他找到合适的局少让他在微信群里喊破喉咙。这个功能做完之后我发现快速匹配并不是随机拉人那么简单它牵扯到状态机设计、轮询方案、匹配算法权重、还有OpenHarmony平台的通知与后台适配。这篇实战记录会完整讲清楚我在这套Flutter for OpenHarmony环境下的实现思路、代码结构、踩坑过程适合正在做社交类App匹配功能、或者想了解Flutter跨端工程如何适配鸿蒙生态的朋友参考。1. 剧本杀组队场景下的需求拆解快速匹配不是随机拉人1.1 临时缺人是组局失败的第一大原因做这个功能之前我拉过一段时间的线上组局日志。剧本杀门店最主要的流失点不是剧本不好而是约好了6个人到场只有4个。鸽一个人可能还好鸽两个这局基本就黄了。玩家临时放了鸽子剩余的人要么干等要么各回各家门店损失的是整桌的单。这个时候最需要的不是再来一个完整的新队伍而是一个快速补位通道。快速匹配要解决的本质上就是已开局的队伍缺人和单人想上车之间的撮合。这两个群体都有明确意图一个想要人一个想要局。和相亲软件的长期画像匹配不同剧本杀匹配追求的是短平快——你按下匹配按钮的几分钟内系统需要告诉他有局了或者暂时没有稍后再试。所以我在需求评审时没有把快速匹配设计成一套复杂的推荐系统而是聚焦在三个词上快、准、稳。1.2 三个入口大厅手动找、一键匹配、邀请补位当时的App里已经有大厅房间列表了玩家可以手动翻、筛、筛选剧本类型。但手动的结果是翻了三页没有合适位置玩家就走了。所以需要更主动的入口。我规划了三个入口来解决不同场景大厅列表手动找适合不急、有时间慢慢挑的玩家这是已有的功能。一键快速匹配适合临时想上车、愿意接受系统推荐的玩家这是本篇要做的核心功能。邀请补位适合已经组队5缺1、向好友圈发补位链接的场景这个我放在下一阶段做。快速匹配的按钮放在两个位置——首页的悬浮按钮和房间详情页底部。玩家点进去之后只需要选择期望人数偏好剧本类型和预计开玩时段就可以开始匹配。不需要填详细偏好否则就违背了快速的初衷。1.3 从需求到技术指标响应时间、并发与异常容忍度把需求翻译成工程指标我给快速匹配定了三个硬指标单次匹配响应感知玩家按下按钮后3秒内必须看到明确的等待反馈比如进入排队队列而不是转菊花卡住。匹配周期上限单次匹配等待80秒没有结果就自动提示超时玩家可以继续等或者退出。并发容错默认支持同一区域内最多50个并发匹配请求这个体量足够覆盖单门店高峰时段。这三个指标决定了后面很多设计选择。比如为什么用轮询而不是长连接因为这阶段并发量不需要websocket级别的实时性为什么匹配等待上限设80秒因为我看了实际组局数据玩家愿意等待补位的耐心窗口就是1分钟出头。设计文档里还特别标注了一个容忍底线可以接受偶尔匹配慢几秒但不能接受玩家点了按钮无响应或者重复开匹配会话。2. 匹配状态机与数据流把“等队友”变成可控流程2.1 五态流转模型与边界条件快速匹配最怕的是状态乱。用户到底是在等待中还是匹配到了还是已经过期了如果App没有清晰的状态约束就会出现匹配成功弹窗出来后玩家又点了取消这种逻辑矛盾。我把整个匹配会话设计成五个状态类似一个迷你状态机idle空闲态玩家还没发起匹配。matching匹配中客户端发起请求等待服务端返回结果。success匹配成功拿到了一个roomId准备跳转。timeout超过80秒无结果状态机自动终止。canceled玩家主动取消或者服务端判定会话失效。约束规则只有一条同一个玩家同一时间只能存在一个匹配会话。无论从哪个入口进入只要当前处于matching或success就不允许再发起新匹配。这样做的直接好处是避免按钮重复点击造成服务端创建多个匹配任务。状态流转还有一个边界情况需要处理匹配成功和用户取消同时发生。比如用户在第79秒失去耐心点了取消服务端此时刚好撮合成功并返回了roomId。我的处理方式是客户端收到取消命令后把这个会话标记为取消中等正在进行的轮询请求回来时如果发现是success且这个session已经被标记terminated就丢弃结果并通知服务端释放这单匹配关系。虽然这个情况在测试中只出现了两三次但处理不好会直接把玩家带到一个他已经不想进的房间。2.2 MatchController一个ChangeNotifier撑起全部状态项目里很多页面状态用的是Provider ChangeNotifier这次匹配功能也沿用这个模式。我写了一个MatchController它持有匹配状态、剩余秒数、轮询Timer所有UI都监听它变化。class MatchController extends ChangeNotifier { MatchStatus _status MatchStatus.idle; Timer? _pollTimer; int _remainSeconds 0; String? _sessionId; MatchStatus get status _status; int get remainSeconds _remainSeconds; Futurevoid startMatch(QuickMatchParams params) async { if (_status MatchStatus.matching) return; _status MatchStatus.matching; _remainSeconds 80; notifyListeners(); _sessionId await matchApi.createMatchSession(params); _startCountdown(); _startPolling(); } Futurevoid cancelMatch() async { if (_status ! MatchStatus.matching) return; _pollTimer?.cancel(); _status MatchStatus.canceled; notifyListeners(); await matchApi.cancelSession(_sessionId!); } void _startPolling() { _pollTimer Timer.periodic(const Duration(seconds: 2), (timer) async { final result await matchApi.pollMatchResult(_sessionId!); if (result null) return; // 还在匹配中 _pollTimer?.cancel(); _status MatchStatus.success; _matchRoomId result.roomId; notifyListeners(); }); } void _startCountdown() { Timer.periodic(const Duration(seconds: 1), (timer) { if (_status ! MatchStatus.matching) { timer.cancel(); return; } _remainSeconds--; if (_remainSeconds 0) { timer.cancel(); _status MatchStatus.timeout; notifyListeners(); } else { notifyListeners(); } }); } }这段代码有几个容易翻车的地方后面联调篇我会再展开。但这里先提醒一个设计细节轮询Timer和倒计时Timer必须分开管理。一开始我把它们合成了一个Timer每2秒减一次秒数但倒计时跳变很明显1.9秒和2秒之间的视觉差异让测试的同学直接反馈倒计时卡顿。所以我后来把界面刷新频率和轮询频率解耦了。2.3 轮询轮询还是推送内部Demo阶段的选型逻辑快速匹配的实时反馈方案业界无非三种短轮询、长轮询/SSE、WebSocket。最终我选的是最简单的2秒短轮询。放在正式产品里可能不够高级但它恰好匹配当前阶段的需求。原因有三条实现成本低服务端只需要提供一个查询接口返回当前匹配状态不用维护常连接。故障隔离好WebSocket断线重连在OpenHarmony上的适配还要额外处理心跳、网络切换等一摊子事轮询天然免疫大部分连接问题。这个阶段数据量不大单次轮询的请求体很小2秒一次的频率对服务器压力可以忽略。当然轮询也有明显毛病——最坏情况下有2秒的状态延迟。快速匹配里玩家第79秒匹配成功客户端可能第80秒才收到然后界面先弹超时再弹成功。我的处理是收到成功结果时不管当前状态是matching还是timeout都以成功为准因为服务端的撮合结果比本地倒计时更权威。这个优先级倒置后面我们也会聊到。3. Flutter端匹配界面实现倒计时、排队动效与结果跳转3.1 防重复点击与请求幂等匹配按钮的自我修养快速匹配按钮是整条链路里被点击最频繁的控件也是最容易出问题的控件。玩家手速快一点双击一下就会同时触发两次startMatch。虽然Controller里加了if (_status MatchStatus.matching) return;的防护但网络层如果没做幂等还是会往服务端创建两个会话。所以我在服务端做了一个简单约束创建匹配会话时如果该用户已存在未完成的活跃会话直接返回该会话ID而不是新建。这比单纯依赖客户端防抖要可靠得多——客户端防抖只防自己的用户服务端幂等才能防所有入口的重复操作。UI层我也做了双保险。按钮的onPressed在matching状态下直接置空同时把它切换成一个带loading状态的圆形按钮。FloatingActionButton.extended( onPressed: _controller.status MatchStatus.matching ? null : _onStartMatch, label: Text(_controller.status MatchStatus.matching ? 匹配中... : 快速匹配), )这个改动让按钮在等待期间变得不可点玩家视觉上有明确反馈。加了一个很不起眼但体验重要的细节点击按钮时触发一次HapticFeedback.lightImpact()让玩家知道这次点击被系统接收了而不是没反应又点了一次。3.2 等待中的反馈倒计时、队列位置与动画切换匹配等待界面是快速匹配体验的重中之重。文字提示正在匹配中是最低标准但它留不住人。我参考了一些游戏匹配排队的设计给等待页加了三个元素倒计时数字显示剩余秒数给玩家一个我还得等多久的预期。动态提示轮播底部循环播放正在为你寻找合适的车队同城玩家正在加入即将进入房间每隔3秒切换一条。粒子动效背景用Flutter的AnimationController做一圈由外向内扩散的圆环模拟信号在找队友的感觉。这些动效的实现不复杂关键是用AnimatedSwitcher统一管理状态切换的动画过渡。AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: _buildMatchingWidget(_controller.status), )_buildMatchingWidget根据状态返回不同的Widgetidle显示开始匹配的大按钮matching显示倒计时排队动画success显示找到队友的过渡页timeout显示超时重试页canceled直接pop。这样整个匹配流程的转场都通过一个AnimatedSwitcher完成而不是散落在各个页面的Navigator调用里状态一目了然。顺带排掉一个坑粒子动效如果一直开着CPU占用会比较高低端机上等等待页就卡了。我后来给动画加了一个降级条件——检测到连续三帧渲染时间超过50ms时自动把粒子动效切成静态渐变背景。这个后面在第5章会细说。3.3 匹配成功后的数据传递与异常回滚匹配成功后要跳转到房间详情页并且把roomId传过去。这个场景和普通的列表点击进详情不太一样——玩家是从匹配页跳来的他可能对房间一无所知所以详情页需要展示这是为你匹配的房间而不是普通的房间详情。我的做法是给详情页增加一个可选的matchedRoomId字段同时通过构造参数传入一个matchSource标记。Navigator.push( context, MaterialPageRoute( builder: (_) RoomDetailPage( roomId: matchRoomId, matchSource: MatchSource.quickMatch, ), ), );详情页内部如果发现matchSource MatchSource.quickMatch就在顶部展示一个来自快速匹配的横幅并把加入按钮的文案改成确认加入队伍。如果玩家进入详情页后反悔不加入了直接返回即可匹配会话虽然已经成功但服务端会把这局的颜色重新标记为待补位不会影响其他玩家这就叫异常回滚——成功不等于终局玩家离开房间时要把占用的名额释放掉。还有一个容易忽略的跳转场景匹配成功时用户恰好切到了后台。Flutter的AnimatedSwitcher在App从后台恢复时可能会丢失动画状态导致页面停在一个中间帧。我实测下来最稳妥的方式是使用WidgetsBindingObserver监听didChangeAppLifecycleState如果发现从后台恢复且当前状态是success就直接强制跳转不等动画播完。4. 服务端匹配算法三层过滤加权重排序4.1 第一层剧本和时段过滤快速匹配的核心不在客户端界面而在服务端那张数据库查询和那份排序算法。这里我用的是三层过滤加权重排序。第一层过滤是硬性条件不符合直接踢出候选集。包括三个维度剧本匹配如果玩家指定了某个剧本只匹配这个剧本的房间如果玩家只选了剧本类型比如欢乐本恐怖本就匹配同类型房间。时段匹配玩家期望19:30开玩那么18:30到21:30之间已开局的房间都在候选集内。理由是剧本杀一局通常3-4小时允许前后15分钟的浮动是玩家能接受的。人数组团玩家选择我要加入6人局那6人局缺1人和缺2人的房间都算候选4人局直接排除。这三个过滤条件写进SQL里的方式很直接核心逻辑是动态拼接条件。SELECT r.room_id, r.play_start_time, r.player_count, r.current_count, r.room_type, r.score, TIMESTAMPDIFF(MINUTE, r.created_at, NOW()) AS wait_minutes FROM room r WHERE r.status waiting AND r.current_count r.player_count AND r.room_type ? AND r.play_start_time BETWEEN DATE_SUB(?, INTERVAL 60 MINUTE) AND DATE_ADD(?, INTERVAL 90 MINUTE) AND r.player_count - r.current_count ? ORDER BY ? LIMIT 1;?占位符由服务端代码填充。时段过滤我给了比较大的跨幅是因为剧本杀局的开始时间经常因为等人而顺延晚半小时是常态。写得太死会把很多明明能成的局过滤掉。4.2 权重排序公式等待时长为什么要多给分候选集过滤完之后如果只有一两个房间直接返回就行但门店高峰期往往有几十个房间在等人这就需要排序。我用的权重公式长这样finalScore wait_minutes * 2 distance_score * 0.5 rating_score其中wait_minutes房间从创建到现在等待了多少分钟。每等待1分钟得2分。distance_score玩家位置和房间所属门店的距离按公里折算分越近分越高。rating_score房间内已有玩家的平均评分作为信用参考分。三个维度里wait_minutes的权重最高。理由很简单匹配要考虑的不是这个房间最好而是这个房间最可能成。一个已经等了40分钟的房间说明里面的人凑局意愿极强不太会临时放鸽子而一个新创建的房间虽然玩家评分可能很高但他可能随时因为组不起来而散掉。所以排序上优先推荐等了很久的老房间这盘棋对双方都是最优解。这里有个经验分享一开始我把rating_score权重设的很高结果匹配率反而降了。因为高评分的老玩家通常挑剧本、挑队友而凑局意愿最强的是那些评分中等但急着玩的人。后来我砍掉了rating_score的权重匹配成功率反而涨了大约12%。评分更适合放在详情页展示不适合作为撮合决策的依据。4.3 空结果扩参给玩家一个“还差一人”的兜底即使过滤和排序都做了高峰期之外还是可能出现一个候选都没有的情况。这时候不能直接给玩家泼冷水我设计了三级扩参策略一级扩参把时段跨幅从前后15分钟扩到前后30分钟。这一级只需要改SQL参数基本不牺牲体验。二级扩参如果玩家选了指定剧本但同剧本房间为空就扩展到同类型剧本的房间。玩家看到的提示是没有找到《拆迁》的车队但相近类型的欢乐本还有位置。三级扩参实在没有返回空结果客户端提示当前没有合适的车队建议稍后再试或去大厅看看同时给玩家发一张等待券下次开团减5元的权益把挫败感转化成留存动作。三级扩参是我从运维数据里学到的。第一次上线时没有扩参策略超时率达到33%玩家流失很严重。加上扩参后超时率降到14%。虽然还是不算特别理想但玩家至少感受到了系统在努力去大厅看看的引导也能把一部分流量导回房间列表。4.4 并发一致性匹配请求为什么会“撞车”最后一个服务端问题是并发。两个玩家同时发起匹配服务端查到同一个房间空缺同时把他们都加到房间里这就撞车了。处理方式我在前面2.2节提过——用数据库的行锁或者Redis分布式锁保证查询-加入-更新三步操作是原子的。我的具体做法是在服务端房间表上加一个乐观锁版本号UPDATE room SET current_count current_count 1, version version 1 WHERE room_id ? AND current_count player_count AND version ?;如果更新影响的行数为0说明当前房间已经被占满了换下一个房间重试即可。这个方案不用引入额外的分布式锁组件在单门店的数据库环境完全够用。5. OpenHarmony平台上的适配细节通知、后台与性能5.1 切后台时匹配成功通知横幅的桥接实现快速匹配有个天然使用场景玩家点了匹配切出去刷了会儿短视频等切回来发现已经匹配到了。如果不做系统通知玩家就会错过成功结果等回来时匹配已经超时了。OpenHarmony有通知服务能力但Flutter的插件生态里直接支持OpenHarmony通知的还不多。我是通过MethodChannel桥接系统API实现的const platform MethodChannel(match/notification); Futurevoid showMatchResultNotification(String roomName, String roomId) async { await platform.invokeMethod(showMatchNotification, { title: 快速匹配成功, content: ${roomName}正在等你上车, roomId: roomId, }); }在OpenHarmony侧需要在ohosTest模块或者Stage模型中调用通知API并申请ohos.permission.NOTIFICATION权限。这里有一个适配重点API版本不同通知请求的写法不一样。老版本用ohos.notification直接发布新版需要先请求通知使能再发布。我这边用的API 9以上的写法通过notificationManager.isNotificationEnabled()做权限检测没开权限就静默降级为App内弹窗提醒不能直接崩。5.2 后台保活与Timer停止的实测差异这是我踩得最深的一个坑。Flutter的Timer在App进入后台之后不一定按照预期继续跑。在OpenHarmony上进程被挂起后Timer会停摆等应用回到前台才继续执行。这就导致一个很尴尬的现象玩家点了匹配切到后台倒计时停在37秒不动等他回前台的时候倒计时突然跳到0并且弹了超时。解决办法是以服务端的时间为准前端倒计时只做展示。我改动了两处服务端在返回匹配状态时同时返回会话的绝对剩余秒数。客户端App从后台恢复时重新调用一次poll接口用服务端返回的剩余时间重置本地倒计时。代码上只需要加一个生命周期监听override void didChangeAppLifecycleState(AppLifecycleState state) { if (state AppLifecycleState.resumed) { _controller.syncServerTime(); } }syncServerTime会请求一次服务端把最新的剩余时间和匹配状态拉回来覆盖本地可能已经失真的状态。这样玩家从后台回来后看到的永远是最新数据而不是本地那个停滞的虚假倒计时。后台锁屏这块我也做了处理。玩家如果希望一直保持前台匹配可以在匹配页开启保持屏幕常亮的开关。Flutter里有个wakelock_plus插件可以用设置WakelockPlus.enable()就可以。但要注意这个会持续消耗电量匹配结束或者页面销毁时一定要记得关闭。5.3 低性能机型的动画降级策略Flutter在OpenHarmony上的渲染性能和Android原生Form上还是有点区别的尤其是中低端设备。我测试用的开发板是rk3568跑粒子动效时帧率只有25fps左右肉眼可见的掉帧。这促使我加了动画降级机制。实现方式不复杂用一个回调监听每一帧的渲染耗时如果连续三帧平均耗时超过16ms也就是帧率低于60fps就把粒子动画切换成静态渐变背景同时把圆环动画的刷新频率从每帧降到每两帧。_animationController.addListener(() { _frameCount; if (_frameCount % 3 ! 0) return; final fps _renderedFrames / _elapsedTime; // 简化示意 if (fps 45 !_degraded) { _degraded true; setState(() {}); } });降级后帧率回到50fps以上等待页的体验反而更稳定。玩家不会注意到粒子动效有什么变化但页面有没有卡顿他们一秒就能感受到。这个小优化很值得做建议所有从Android转Flutter for OpenHarmony的朋友都考虑一下。6. 联调测试与真实踩坑记录6.1 边界场景用例表手动构造的20分钟测试开发完快速匹配功能后我拉了一轮比较完整的边界测试。这里列几个最关键的场景和预期结果方便你复现测试时对照场景操作预期结果正常匹配点击快速匹配等待40秒倒计时正常匹配成功后跳转详情页并发匹配两个账号同时匹配同一房间只有一个加入成功另一个匹配到别的房间或者等待匹配成功切后台匹配成功后立刻按Home键5秒内收到系统通知横幅点击横幅跳转房间取消竞态匹配到第79秒时点取消如果服务端已成功丢弃结果并提示已经为你找到车队无结果超时凌晨2点匹配80秒内无候选则显示超时页带重试按钮断网恢复匹配过程中关闭网络轮询接口报错但状态不重置网络恢复后继续轮询重复点击双击快速匹配按钮只创建一个匹配会话第二次点击被忽略其中断网恢复这个场景花了我比较多时间。一开始把网络报错直接当成匹配失败来兜底结果玩家网络闪断一下就跳超时页体验很差。后来改成轮询请求失败时只静默重试连续失败3次才提示网络不稳定保证了一般的网络抖动不影响匹配。6.2 三个印象最深刻的bugTimer泄漏、取消竞态、页面销毁后setState这一节值得单开一讲因为这三个bug几乎把所有做匹配功能的人都坑过一遍。Timer泄漏MatchController里如果startMatch之后玩家直接退出页面Controller虽然会随页面销毁但里面那两个Timer还在跑。Timer会在_status还处于matching时继续请求网络直到服务端返回结果或者崩溃。修复方式是在Controller的dispose方法里显式取消两个Timer并且把当前状态置为canceled。override void dispose() { _pollTimer?.cancel(); _countdownTimer?.cancel(); super.dispose(); }取消竞态这个上文提过第79秒玩家取消同时服务端返回成功。我的处理是给取消操作加一个取消中的标记如果轮询回来的结果是success但标记已存在就丢弃结果并告知服务端释放这个房间名额。没有这一步的话玩家明明取消了还弹个匹配成功他会觉得这个App在耍他。页面销毁后setState这也是Flutter老生常谈的问题。匹配成功的回调里如果直接调用Navigator.push但此时页面已经pop掉了就会报setState called after dispose。我在所有异步回调里都加了一个if (!mounted) return;的判断确保页面还在才继续执行导航逻辑。这三个bug虽然都不复杂但Debug阶段非常费时间。写代码的时候多留一个心眼把资源释放和生命周期检查做在前面能省下后面一大半排查时间。6.3 后续优化方向当轮询变成推送匹配体验还可以更顺现在的2秒轮询足够内部测试和小范围使用但如果产品真的要铺开门店规模性能瓶颈迟早会出现。我的后续计划是把轮询改成WebSocket或者用OpenHarmony的推送服务同时把匹配算法里加入基于位置的LBS排序让附近3公里有局这种提示变成可能。另外一个想做但还没做的点是组队群的社交裂变——匹配成功后让玩家一键把房间分享给微信好友这个和第1章提到的邀请补位入口正好衔接。这些优化方向的优先级排序是先换推送再做LBS最后做社交裂变。因为换推送解决的是实时性的根本问题LBS解决的是匹配精准度社交裂变是增长层面的锦上添花。做技术的都知道基础体验永远比运营玩法重要。