ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter for OpenHarmony快速匹配实践:状态机与平台适配

Flutter for OpenHarmony快速匹配实践:状态机与平台适配 这个系列走到第 22 篇终于轮到快速匹配了。做过组队类 App 的朋友应该都有体会快速匹配这个功能需求文档上写出来就一句话点了按钮之后系统自动帮你找齐队友。可真到了 Flutter for OpenHarmony 这整套技术栈下动手实现你会发现从状态管理、轮询调度到平台通道跟鸿蒙插件的适配每一层都有坑在等着。我这儿说的快速匹配是剧本杀组队 App 里让用户一键进车队的功能用户按下匹配系统根据人数、剧本偏好、时间段等条件自动组队达到目标人数后直接拉起房间。这篇博文把整条链路拆开讲包括服务端接口设计、客户端状态机、倒计时与匹配动效以及 OpenHarmony 平台上 EventChannel、PlatformView 这些绕不开的适配点。适合正在做 Flutter 多端应用、尤其是准备往鸿蒙生态迁移的客户端开发参考也适合第一次接触匹配类功能的同学照着抄作业。1. 快速匹配功能整体设计与技术选型快速匹配看着像个普通交互其实它是一个需要“客户端状态机 服务端队列 超时兜底”三者配合的功能。这一章先说清楚业务闭环和为什么这么选型。1.1 先理清楚业务流程再谈实现快速匹配在剧本杀组队场景里是这样一条链路用户在首页点击“快速匹配”带上自己的偏好参数发起请求服务端把他放进一个等待队列按规则撮合撮合成功返回一个房间 ID用户进入房间页看到自己的车队和剧本信息。整个过程里用户能感知到的只有按钮、倒计时、匹配动效和结果页但真正决定体验的是中间那几次状态变更是否可靠。我在设计业务时把匹配需求拆成了三个维度。第一是人数维度剧本杀有 5 人本、6 人本也有更少人数的阵营类剧本我让客户端在发起匹配时传 targetCount服务端按目标人数分组撮合避免一律按 6 人处理。第二是偏好维度包括剧本类型偏好、难度偏好、期望开始时段这些参数会作为撮合时的权重条件我取“必须满足项”和“加分项”两种级别否则等半天也凑不齐一队人。第三是匹配策略维度比如是否需要老玩家带新玩家、同车队是否允许重复进入同一个房间这些策略如果不提前约定好后面很容易出现两个人同时进入两个房间的脏数据。实际做接口时我并没有把快速匹配做成一次性的 HTTP 请求。原因很简单匹配是一个可能持续几秒到几十秒的过程客户端如果一直挂着等待服务端响应连接管理、超时重试、弱网处理都会变得非常别扭。所以我采用了“发起匹配 轮询状态 超时取消”三个接口的组合下面详细说。1.2 为什么选“短轮询 超时兜底”不直接上 WebSocket我见过不少团队一听到快速匹配就想到 WebSocket、长连接、服务端推送。不是说这条路不对而是要做取舍。匹配这个场景客户端的实时性容忍度其实很高在真实剧本杀组队场景里匹配从发起到达成通常需要几秒到半分钟客户端 3 秒轮询一次用户体验上没什么差别。反而是如果用长连接就要额外处理断线重连、心跳保活、消息积压这些问题前期开发量会明显增大。我的选择是短轮询加超时兜底客户端发起匹配后拿到一个 matchToken然后每 3 秒调用一次状态查询接口服务端返回排队中、已匹配、已取消、超时这几种状态。轮询接口是轻量的 GET 请求只查匹配状态不做复杂计算。服务端接口实现起来也简单本地内存里放一个匹配队列就能跑通不需要引入消息队列或者 Redis 发布订阅。有朋友会说3 秒轮询如果用户规模大了服务端压力是不是扛不住我算过一笔账单用户在 30 秒的匹配周期内大约发起 10 次轮询假设同时有 500 人在线排队也就是 5000 次轻量查询分摊到 30 秒里对一个正常的后端服务来说压力不大。如果真到了几千人同时匹配的程度再升级成 WebSocket 或 Server-Sent Events 也不迟而且客户端的接口抽象层已经留好了切换成本可控。超时兜底是必须有的。匹配永远可能失败比如人数不够、偏好条件过窄、服务端队列异常如果客户端无限等下去用户只会觉得这个功能坏了。我设定单次匹配的容忍等待时间是 30 秒第 25 秒时 UI 给出“即将超时”的提示30 秒一到自动弹出继续等待或者取消的选项。服务端也会在 45 秒左右强制清理长时间未匹配成功的用户防止队列里堆积僵尸请求。1.3 匹配接口与数据模型约定接口设计我用了三个 REST 风格接口简单直接也方便联调时抓包排查问题。接口方法参数返回内容发起匹配POST /match/createuserId、matchTarget、targetCount、prefsmatchToken、queueState、serverTime查询状态GET /match/statusmatchTokenstate、roomId、matchedUsers、countDown取消匹配POST /match/cancelmatchTokencancelResult为什么发起匹配返回里要带 serverTime因为客户端倒计时和服务端超时时间必须对齐移动端本地时间可能被用户改过轮询时容易造成状态错乱。有了 serverTime客户端可以算出剩余秒数保证倒计时和服务端清理逻辑一致。数据模型上我把匹配会话单独提了一个实体不跟用户模型混在一起。这个实体包含 matchToken、targetCount、createTime、expireTime、participants、matchedRoomId 这几个字段。matchToken 用服务端生成的 UUID避免客户端用自增 ID 带来的并发问题。participants 字段在排队阶段是用户 ID 列表在匹配成功后会附带昵称、头像这些用于展示的数据让客户端拿一次接口就能把房间成员页渲染出来不用再回头查用户信息接口。2. 匹配状态机与轮询调度设计客户端最容易写乱的部分就是匹配过程中各种状态互相切换。这一章集中讲状态机怎么定义、轮询调度怎么控制以及我踩过的一个比较隐蔽的坑。2.1 匹配会话的状态流转我把匹配会话定义成一个封闭的状态集合客户端和服务端都遵循同一套状态约定。常用状态有这些idle 表示未匹配、matching 表示排队中、matched 表示已撮合成功待确认、roomReady 表示已进入房间、cancelled 表示已被取消、timeout 表示等待超时。这里要特别注意 matched 和 roomReady 的区别matched 只是服务端撮合成功客户端还没进入房间roomReady 才是真正完成整个匹配流程可以跳转到房间页了。状态流转只有几条合法路径idle 到 matching、matching 到 matched、matched 到 roomReady、matching 到 cancelled、matching 到 timeout。我在客户端专门用一个 sealed class 来表示匹配状态编译期就能限制非法状态跳转比用一个字符串枚举安全得多。这个经验比较重要因为匹配流程里后续迭代会加新状态比如“匹配失败原因”“正在重新匹配”用 sealed class 能让新状态加进去时逼着你处理所有分支不会漏掉某个 UI 场景。服务端的状态机也要严格对应。撮合成功时服务端不应该直接返回房间 ID而是返回一个待确认标记等客户端确认后再把用户正式写进房间成员表。为什么多这一步因为客户端可能在拿到 matched 状态后取消匹配或者用户切后台放弃了如果服务端在撮合成功的瞬间就把人锁进房间会导致房间里有大量不活跃的“幽灵成员”。我实际遇到过一次用户快速匹配成功进入房间后又退出房间显示 6 人但实际只有 4 人在线就是少了这个确认步骤。2.2 轮询调度与超时控制的实现细节轮询调度在客户端可以用 Timer.periodic 来做。我封了一个 MatchPollingService对外只暴露 start(token, onResult) 和 cancel() 两个方法内部维护一个 Timer 和当前状态。这样页面层不需要关心轮询细节只需要在匹配页创建服务、在销毁时取消服务逻辑集中也好排查问题。轮询间隔我设成 3 秒但实际请求不是严格每隔 3 秒而是加了一个 0 到 500 毫秒的随机抖动。这个抖动对单个用户无所谓但对大量客户端同时发起匹配的场景很关键可以避免所有客户端在同一秒打到服务端形成流量尖峰。有同事一开始不理解觉得加个随机延迟没必要后来模拟压测时发现没有抖动的情况下服务端在整点秒收到的请求量是平均值的两倍多加了抖动之后曲线就平了。超时控制我放在两个层面。第一层是服务端匹配队列里的 session 超过 45 秒会被强制清理并标记为 timeout。第二层是客户端拿到 serverTime 后算出本地剩余秒倒计时到 0 就停止轮询并提示用户。客户端这边还要处理一个比较麻烦的边界用户在排队过程中切到后台Timer 仍然会执行但网络请求可能被系统挂起。我在 App 生命周期里做了处理通过 WidgetsBindingObserver 监听前后台切换后台超过 15 秒就暂停倒计时和轮询回到前台再重新拉一次服务端状态避免产生无效请求和倒计时错乱。3. Flutter 客户端实现Cubit 状态、倒计时与匹配动效这一章讲客户端的实际代码实现。匹配页我用的状态管理方案是 flutter_cubit配合 sealed class 做状态建模倒计时和动效分开处理避免动画跟业务逻辑耦合在一起。3.1 为什么选 flutter_cubit sealed class我早期也用过完整的 flutter_bloc继承 Bloc 写 event 和 state 两套模板后来在快速匹配这种中小型功能上发现其实大量重复劳动。Cubit 是 Bloc 的简化版只保留 state 和 emit没有 Event 层对一个“发起匹配、轮询状态、展示结果”的功能而言足够清晰。它最大的好处是代码量少、阅读成本低团队成员拿到代码能很快看懂匹配这个流程的核心变化。状态类我用 sealed class 来定义。大概长这样sealed class MatchState { const MatchState(); } class MatchIdleState extends MatchState { const MatchIdleState(); } class MatchMatchingState extends MatchState { const MatchMatchingState({required this.countDown, required this.queueHint}); final int countDown; final String queueHint; } class MatchMatchedState extends MatchState { const MatchMatchedState({required this.roomId, required this.members}); final String roomId; final ListMatchUser members; } class MatchErrorState extends MatchState { const MatchErrorState({required this.message}); final String message; }匹配页的 Cubit 持有一个 MatchPollingService页面里只需要在按钮点击时调 matchCubit.startMatch()然后根据状态渲染不同 UI。这里有个组件通信的经验匹配成功后的结果不要只通过构造函数传给下一级页面而是应该把匹配结果写到 App 级别的 Repository 里再由下一级页面读取。原因很简单如果用户在匹配成功后马上杀掉 App 进程回到应用时要恢复匹配结果构造参数是拿不到残留数据的而 Repository 可以把结果持久化到本地冷启动后还能恢复。3.2 倒计时的计时逻辑与前后台处理倒计时我并没有在 UI 层用一个 Timer 自己去计数而是由 Cubit 在发起匹配时启动一个周期为 1 秒的 Timer每次触发就减少剩下秒数并 emit 新的状态。为什么放 Cubit 而不是 UI 层因为放到 Cubit 里即使页面因为路由被暂时覆盖或者被系统回收只要 Cubit 没有关闭倒计时的状态还在用户回到页面时依然能拿到正确的剩余秒数。如果把 Timer 放在 StatefulWidget 里页面重建或者被切走之后计时状态很容易丢。后台处理是必须写清楚的。我用 WidgetsBindingObserver 监听 App 生命周期在 paused 状态记录当前剩余秒数和暂停时间在 resumed 状态用服务端时间重新校正。这里有个坑如果只靠本地暂停恢复用户手动改系统时间会导致倒计时不准所以我每次恢复时都主动调用一次匹配状态接口以服务端返回的剩余秒数为准本地倒计时只作为界面展示。我还处理过一种情况用户在前台正常匹配倒计时还剩 20 秒时切到后台30 秒后才回来。如果客户端没有任何保护回来后会发现本地倒计时已经归零但服务端状态可能还是 matching因为服务端 45 秒才会清理。我现在的做法是回到前台立即重新拉状态如果服务端还是 matching 且没有超时就把本地倒计时重置为服务端剩余时间用户看到的情况是“匹配还在继续”不会出现误报超时的糟糕体验。3.3 匹配动效实现与页面跳转的状态保持匹配页的动效我做了两类一类是中间的雷达扫描动效一类是倒计时数字的滚动效果。雷达扫描用 AnimationController 加旋转和缩放每次匹配开始时重置 animationController结束后主动 stop。这里有个比较重要的经验动画控制器必须在 dispose 里显式销毁否则连续匹配十几次之后动画相关的资源没有释放内存会缓慢上涨。实测下来不销毁的情况连续匹配 20 次内存大概能涨 60 多 MB这在开发机上不明显但在低端测试机上很容易触发卡顿。页面跳转的状态保持是这个功能里让我印象最深的一个问题。Flutter 的 Navigator 在 pushReplacement 之后旧的页面状态默认会被回收如果匹配倒计时 Timer 还挂在旧页面的 State 上就会出现页面已经跳走了但 Timer 还在跑、偶尔还回调旧 UI 的诡异现象。这也是网上常见问题里“flutter navigator切换页面后会丢失状态吗”的典型来源。我的解决办法是把匹配生命周期放到 Cubit 中页面只是 Cubit 状态的可视化呈现页面销毁时 Cubit 并不会被关闭因为它在 App 顶层注册匹配的结果和中间状态完全不受页面生命周期的干扰。跳转前的交互细节我建议做成这样匹配成功后先在匹配页显示 500 毫秒的“匹配成功”动效再跳转房间页。这 500 毫秒既能给用户足够的成功反馈也给服务端留出把成员数据真正落库的时间。如果服务端返回匹配成功但房间还没完全创建好立刻跳转很容易出现房间页白屏。一个小提示匹配成功动效播放期间按钮要禁止重复点击否则用户连点会导致多次跳转同一个房间造成导航栈混乱。4. OpenHarmony 平台适配EventChannel、PlatformView 与插件改造快速匹配这个功能看似不涉及太多原生代码但真要在 OpenHarmony 设备上跑起来平台适配的坑一点不少。我把这一章的实践经验单独拿出来因为这是 Flutter for OpenHarmony 系列里最容易卡住的地方。4.1 在 OpenHarmony 上跑 Flutter 的整体判断目前 OpenHarmony 生态里跑 Flutter依赖的是社区维护的 Flutter SDK 分支不是官方主干直接支持。这意味着你在拉取依赖、构建产物时版本要跟 Flutter SDK 分支严格绑定不能随随便便升到最新的 Flutter 版本。我的经验是锁定一个大版本比如基于 Flutter 3.x 的适配分支然后一直在上面开发除非适配分支发布了明显修复否则不要频繁升级因为每一次升级都可能伴随底层引擎和平台通道的变化。架构上Flutter 在 OpenHarmony 上仍然采用自绘渲染绝大多数 Widget 能直接工作但跟原生交互的部分就得借助平台通道。快速匹配功能里我用到原生侧的能力是网络状态监听和推送唤醒网络从 Wi-Fi 切到移动网络、用户应用被系统回收前需要把匹配状态保存这些信息通过原生代码获取再传给 Flutter 层更可靠。所以在适配规划时我没有把所有逻辑都塞进 Dart而是让 Dart 层只处理业务状态原生侧负责系统能力。还要注意的是OpenHarmony 的 Flutter 适配对插件的支持不像 Android 那么完备。你平时在 Android 上直接用的第三方插件比如定位、推送、崩溃上报很可能没有现成的 Ohos 实现。我在项目里做了一件事把所有第三方能力调用统一抽象成一个 PlatformBridge 接口接口内部根据当前平台去加载对应实现这样可以避免在页面代码里到处判断平台分支也让快速匹配这种核心流程不依赖某个插件是否适配完整。4.2 用 EventChannel 承接原生侧事件推送快速匹配里为什么需要 EventChannel因为匹配轮询虽然是主动请求但有一些事件是原生侧主动发生的比如系统网络状态变化、应用退到后台的连接状态提醒。如果都用 MethodChannel 去轮询原生侧Dart 层就得频繁发起调用工程上很低效。EventChannel 是单向数据流原生侧作为事件源Flutter 侧作为监听者适合这种“原生主动推送”的场景。Dart 侧使用方式很简单const _matchEventChannel EventChannel(match/event); Streamdynamic matchEvents() { return _matchEventChannel.receiveBroadcastStream(); }在匹配页初始化时我订阅这个流收到网络变化事件后判断是否要暂停轮询或者增加重试次数。这种监听放在匹配 Cubit 启动时做放在页面里做。因为流订阅和 Cubit 生命周期绑定页面销毁时不会丢失也不会多次订阅。鸿蒙原生侧实现 EventChannel 时需要在插件的 StreamHandler 里注册事件处理器。我在 Ohos 的 Flutter 插件工程中实现了 onListen 和 onCancel 两个回调通过 sink.success 把事件数据推到 Dart 侧。这里的关键是事件必须由一个长生命周期对象持有比如匹配会话管理器不能放在临时创建的类里否则 onListen 触发时会发现事件源已经被释放了。早期我在适配时犯过这个错事件通道监听一直没有输出排查半天才发现是事件源对象被 GC 回收了。4.3 平台通道注册 PlatformView 渲染及插件适配经验快速匹配成功进入房间后房间页可能需要展示剧本简介的视频或 Web 页面这就绕不开 PlatformView。Flutter 在 OpenHarmony 上对 PlatformView 的支持比 Android 成熟得晚我建议把视频播放和 WebView 这类场景尽量用 Flutter 自绘组件替代比如视频用 video_player 的适配版本实在不行才走原生 PlatformView。原因是我实测过PlatformView 数量一多在部分低端鸿蒙设备上会出现画面闪烁和触摸响应延迟。如果必须用 PlatformView有一个经验可以分享开启混合合成模式让原生视图跟 Flutter 视图在同一层渲染而不是叠加合成。混合合成模式下滚动手势和动画的流畅度会有明显提升。另外我把 PlatformView 的创建做了懒加载处理只在房间页真正需要展示视频时才创建原生视图不要一进页面就把所有原生视图提前初始化。快速匹配成功后立刻加载房间页此时如果同时初始化多个重型 PlatformView很容易导致页面打开速度变慢这在设备上感知非常明显。关于 Impeller 渲染引擎我也想说一句。Flutter 的新渲染引擎 Impeller 在某些版本里能显著改善 skia 的锯齿问题但 OpenHarmony 适配分支对 Impeller 的支持程度需要你实测验证。我的项目在模拟器上开启了 Impeller发现部分字体渲染异常关掉之后用的是 Skia 路径反而更稳定。所以不要只看宣传先在目标设备上做一次完整回归再决定是否开启。适配插件还有一个通用经验鸿蒙上接入的插件包版本号要跟 Flutter SDK 分支保持一致。很多插件编译失败不是代码问题是版本号不匹配导致生成的原生代码接口对不上。我的做法是在 pubspec 里锁定插件的 Git 仓库和 commit而不是用不稳定的 ^version 区间依赖至少发布前要锁一个经过全量验证的版本组合。5. 常见问题与排查实录这个功能开发过程中我踩了不少坑这里选几个最有代表性和排查价值的记录下来方便大家遇到类似问题时能快速定位。5.1 匹配请求偶发无响应问题出在对端还是客户端有一次测试提了一个 bug快速匹配偶尔点了没反应连 loading 都不出现。我第一反应是接口问题但抓包发现发起匹配的请求根本没有发出去。再仔细排查发现是匹配按钮被一个全屏透明的动画层挡住了。那阵子匹配页做了一个入场动画动画层的 Stack 顺序没有处理好导致按钮区域接收不到点击事件。这个问题的排查思路是先看 UI 层事件是否触达再看业务逻辑是否执行最后才看网络请求。很多人一上来就抓服务端日志容易绕远路。如果请求确实发出去了但没响应那就要区分是超时还是失败。我在代码里给发起匹配请求设置了 5 秒超时超时后主动弹提示并恢复按钮状态。快速匹配这个场景里请求失败不要自动无限重试容易把服务端打挂。正确做法是提示用户“网络不稳定请重试”让用户决定是否再次发起。还有一个容易忽略的点页面在匹配过程中退出了但 Cubit 里的轮询没有取消。这会导致用户已经离开匹配页后台还在持续请求匹配状态既浪费流量又可能在下一次进入匹配页时恢复出旧的匹配状态。我后来把 poll 的取消逻辑放到了页面 dispose 和 Cubit close 两个地方双重兜底并且在进入匹配页时先检查是否存在未完成的匹配 session如果有就引导用户恢复而不是直接创建新的。5.2 “匹配成功但没进房间”数据时序问题排查这是我遇到的最隐蔽的一个问题用户点击快速匹配服务端也返回了 matched 状态和 roomId但跳转房间页后房间却是空的。我排查到最后发现问题出在服务端撮合确认流程不完整。撮合动作只是把几个用户标记为匹配成功但还没有真正写入房间成员表。客户端拿着房间 ID 去查询房间详情时查询得太早成员表里还没写入数据自然就是空房间。解决方法是两个层面同步做服务端在返回 matched 状态之前必须保证房间和成员数据已经落库客户端不能在收到 matched 后立刻查房间接口而是要先调用确认接口等服务端返回 roomReady 再跳转。跳转时机这个细节我在第三部分提到过留 500 毫秒的动效时间一方面是为了用户体验另一方面就是给数据落库留缓冲。如果业务上真的要求瞬间跳转就在服务端把“撮合成功”和“进入房间”变成一个不可分割的事务让客户端拿到 roomId 时数据一定已经写好了。5.3 连续匹配导致内存上涨和掉帧的治理连续快速匹配十几次之后测试机开始掉帧甚至出现内存上涨。内存问题的根源我定位在三个地方一个是 AnimationController 没有销毁一个是轮询 Timer 产生了重复实例还有一个是页面不断地重建匹配状态的 StreamSubscription。这些问题有个共同特点就是资源对象跟着页面和操作频繁创建却因为忘记释放而累积起来。治理方式其实不复杂。第一所有的 AnimationController 和 Timer 都在 dispose 里统一释放匹配页里我用一个集合保存所有需要释放的资源集中调用。第二轮询 Timer 在每次发起匹配前先取消旧的再创建新的防止前一次匹配的定时器没停掉。第三EventChannel 的 StreamSubscription 在 Cubit close 时取消订阅避免每个状态的切换都重复订阅同一事件流。修完这些之后我再测连续匹配 30 次内存曲线平稳多了。掉帧的问题跟另一件事有关匹配结果列表里用户头像网络图片频繁加载列表项没有加缓存和占位图策略。后来我把头像换成带缓存的图片加载组件并把列表项尽可能 const 化减少 rebuild 次数。这里提醒一句调试性能时别只看设备帧率最好同时看 Flutter DevTools 里的 Widget rebuild 数量和内存快照能更快定位到是重建问题还是泄漏问题。5.4 常见问题速查表现象可能原因处理方式点击匹配无响应动画层遮挡按钮、按钮状态未恢复检查 Stack 层级在动画结束后恢复交互请求发出但无返回超时时间过短、服务端队列堵塞加超时提示增加服务端日志和熔断倒计时和服务端不一致本地时间不准、前后台切换以服务端时间为准前后台恢复时重拉状态匹配成功但房间为空服务端先返回成功再写成员表服务端保证事务一致性客户端确认后再跳转连续匹配内存涨动画控制器、Timer、订阅未释放统一释放资源发起匹配前取消旧 TimerEventChannel 无事件事件源对象被回收、未触发 onListen用长生命周期对象持有事件源检查注册时序PlatformView 页面闪烁合成模式不对、视图数量过多用混合合成懒加载原生视图写在最后如果你也在做 Flutter for OpenHarmony 的项目尤其是带匹配、排队这类状态流转的功能我个人的建议是状态机一定要先定义清楚再动手写代码。快速匹配的复杂度不在动画不在轮询而在状态切换时那些“你以为不会发生”的边界情况用户切后台、重复点击、服务端返回成功但数据没落库、页面销毁但 Timer 还在跑。把这些边界都在状态机里覆盖住后面调试会轻松很多。另一个体会是平台适配要提前做抽象。我在对接鸿蒙插件时最大的成本不是写 EventChannel 那几行代码而是不断确认某个插件在鸿蒙分支上能不能用、版本匹不匹配。这就倒逼我把所有原生能力都收敛到 PlatformBridge 里Dart 业务代码完全不关心底层是 Android 还是 OpenHarmony。如果你现在还在项目早期建议也从第一天就做这层隔离别等后面功能多了再重构那时每一处适配都会变成成本翻倍的包袱。
返回列表