做移动端开发这些年,我有一个习惯:凡是项目里最折腾的模块,做完之后一定会单独复盘一次。最近完整走完的,是一个剧本杀组队App里的“游戏记录展示”模块,开发环境锁定在 Flutter for OpenHarmony。这套技术组合不像普通 Flutter 工程那样开箱即用,从数据建模到页面渲染,都有很多和常规移动端不太一样的门道。这篇文章会从需求拆解、数据设计、页面实现、适配排坑完整过一遍,适合正在尝试把 Flutter 项目移植到 OpenHarmony 设备的开发者,也适合做剧本杀、组队社交类应用、需要实现组队记录模块的产品和技术同学参考。它不会给你一套能直接复制粘贴上线的源码,但会把关键判断逻辑和踩坑点讲清楚,至少能让你少走几个弯路。
1. 项目背景与技术选型思路
1.1 剧本杀组队场景的核心诉求
剧本杀App不是简单的聊天工具。用户在门店拼场、组队、约时间,真正高频使用的场景其实是两个:出发前确认队友,结束后复盘体验。“游戏记录展示”模块承载的就是第二个场景——每场游戏结束后,自动生成一条包含剧本名、开本时间、游戏时长、玩家阵容、角色分配、结局类型的结构化记录。它既是玩家个人的“游戏履历”,也是组队信任度的基础。下次有人喊组队,看到某个玩家已经积累了三十多场有效记录、推凶成功率高,放心的程度完全不一样。这个隐形价值,往往比单纯做聊天群更有粘性。
从产品层面拆,这个模块至少要回答三个问题:记录怎么存、记录怎么展示、记录怎么让别人看懂。这三点看起来简单,实际上环环相扣。最开始的模型设计如果只考虑“存得下”,后面加功能时一定会难受。比如我第一版就把记录简单建模成剧本名加日期两个字段,结果后期要加“阵营对比”“MVP结算”“结局分支”时,差点把表结构推翻重来。所以做记录类功能的第一条经验是:模型设计宁可做得比当前需求大一圈,也不要只够当前页面用。
1.2 为什么锁定 Flutter for OpenHarmony
团队当时面对的情况很现实:需要同时交付多平台的客户端,原生开发成本过高,尤其剧本杀这种业务逻辑偏重页面交互的应用,如果每个端都写一套UI,维护成本会直接吃掉利润。Flutter的跨端能力正好能覆盖大部分UI逻辑,这是选它的基础。同时,OpenHarmony作为开源生态里一个活跃的方向,社区里一直有项目在做 Flutter 引擎向 OpenHarmony 移植的适配分支,简单说就是把Dart代码渲染到OpenHarmony的图形栈上,同时保留Flutter的声明式UI开发体验。
选择这套组合,看中的是三点:UI代码复用率高、热门插件有移植版本、适配分支在持续更新。劣势也很明显:部分原生插件不能直接用,需要找替代方案或者自己包一层原生能力。尤其在游戏记录展示这种带较多图片、时间格式化、列表滚动的场景里,适配版本的行为表现需要额外验证,不能拿常规 Flutter 的假设直接套上去。
2. 游戏记录模块的数据层设计
2.1 记录模型与角色关系拆解
数据层是整个展示功能的底座。我先给记录实体设计了一个相对完整的数据模型,字段比 UI 需要的多一些,目的是给后续扩展留余地。实际跑起来之后,这个“留余地”的决定帮我省了非常多的事。
enum RecordStatus { waiting, ongoing, finished } class GameRecord { final String id; final String scenarioName; // 剧本名 final String coverUrl; // 剧本封面 final DateTime playedAt; // 开局时间 final Duration duration; // 游戏时长 final int playerCount; // 人数 final List<PlayerRole> players; // 玩家阵容 final RecordStatus status; // 当前状态 final String endingType; // 结局类型:推理/情感/机制 final int mvpPlayerIndex; // 本场MVP在players中的下标 final Map<String, dynamic> extra; // 扩展字段,留给阵营、成就等 } class PlayerRole { final String nickname; final String roleName; final bool isHost; // 是否主持人 final int score; // 个人评分 }这里有几个字段值得说明。duration一定要用Duration类型,不要偷懒存成 int 分钟数。因为展示时可能需要“小时+分钟”“分钟”“精确到秒”三种格式,存成 Duration 后格式化函数随便写,数据层不受 UI 影响。extra这个 Map 字段表面看起来随便,实际是兼容后续版本的缓冲垫。某次版本要加“每轮搜证线索数”,如果每次改模型定义并做数据迁移,成本很高;用 extra 先兜底,等数据结构稳定后再提升为正式字段,是最稳妥的做法。
2.2 持久化与状态管理的取舍
在 OpenHarmony 的 Flutter 适配分支上,原生插件生态并不完整。初期我尝试过直接套用 sqflite 做本地数据库,结果发现适配版本对部分原生通道的支持不稳定,某些机型上偶发打开即闪退。考虑到游戏记录的数据量一般只有几百条,我最终改成了“本地JSON文件 + 内存缓存”的方案,用 Provider 做状态管理。几百条记录序列化成 JSON 文件,体积充其量几 MB,读写都很快,完全够用。这个方案最大的好处是避开了数据库迁移的痛苦——文件结构升级时,写一个迁移函数把旧字段映射到新结构就行,不会出现表结构锁死的问题。
class GameRecordRepository { Future<List<GameRecord>> loadRecords() async { final path = await _getLocalFilePath(); final raw = await File(path).readAsString(); final list = jsonDecode(raw) as List<dynamic>; return list.map((e) => GameRecord.fromJson(e)).toList(); } Future<void> saveRecords(List<GameRecord> records) async { final path = await _getLocalFilePath(); final data = jsonEncode(records.map((e) => e.toJson()).toList()); await File(path).writeAsString(data); } }写文件时有个容易忽略的坑:一定要先写入临时文件,再通过 rename 替换正式文件。否则应用在写入中途被杀掉,主文件会变成半截 JSON,下次启动直接解析失败。为了这个细节,我还专门做了一个启动时的兜底校验:如果 JSON 解析异常,自动把损坏文件重命名为带时间戳的备份文件,再生成一个空的默认记录文件,避免用户因为一条坏数据永远打不开首页。
3. 游戏记录展示的核心实现
3.1 记录列表:卡片+分页加载
列表页是整个模块的门面。需求上要求一屏能看到关键决策信息:剧本封面、名称、状态标签、开局日期、玩家头像组。我用ListView.builder做懒加载,每页拉取 20 条,下滑触底后自动加载下一页。这里不建议一次性加载全部数据,虽然数据量不大,但当封面图较多时,一次性 build 几百个复杂卡片会造成明显卡顿。
class RecordListPage extends StatefulWidget { @override State<RecordListPage> createState() => _RecordListPageState(); } class _RecordListPageState extends State<RecordListPage> { final List<GameRecord> _records = []; int _page = 0; bool _hasMore = true; bool _isLoading = false; @override void initState() { super.initState(); _fetchNextPage(); } Future<void> _fetchNextPage() async { if (_isLoading || !_hasMore) return; setState(() => _isLoading = true); final next = await repository.fetchPage(_page, size: 20); setState(() { _records.addAll(next); _page++; _hasMore = next.length == 20; _isLoading = false; }); } @override Widget build(BuildContext context) { return ListView.builder( itemCount: _records.length + (_hasMore ? 1 : 0), itemExtent: 136, itemBuilder: (context, index) { if (index >= _records.length) { return const Center(child: CircularProgressIndicator()); } return RecordCard(record: _records[index]); }, ); } }这段代码里有一处值得专门讲:itemExtent: 136。如果卡片高度固定,用itemExtent能显著提升列表滚动性能,因为 Flutter 不需要动态测量每一项的高度。折中的做法是给每张卡片设置一个 minHeight,避免内容差距过大导致的参差不齐。我实测下来,加了 itemExtent 之后,快速滑动时的帧率明显稳定,尤其是配合封面图预加载,基本能保持在流畅水平。
卡片本身的布局我用了 Stack 做封面区、信息区、状态标签的叠放。状态标签我用不同颜色区分:待开局用橙色、进行中用蓝色、已完结用灰色。这里不要再造一套颜色系统,直接复用主题里的colorScheme语义色,否则后面做深色模式时会相当痛苦。
3.2 记录详情页:时间线与阵容区
用户从列表点进详情,看到的不只是一堆字段,而是一局游戏的完整回顾。详情页我采用了CustomScrollView加SliverToBoxAdapter的组合,从上到下依次是封面头图、基本信息区、阵营对比区、游戏时间线、玩家阵容列表、MVP 标识和评论记录区。
时间线是详情页里比较有辨识度的部分。每局游戏按“集合—选本—搜证—推凶—复盘”几个阶段展示时间点,用一条纵向的线条把节点串起来。实现上不复杂,就是一个 Column 加左侧竖线,每个节点由圆点、时间和标题组成。关键在于时间数据的来源:必须存“阶段实际发生时间”,而不是只存开始和结束。最初版本我只存了一个总时长,导致时间线完全画不出来,后来补字段时才体会到第一节说的模型预留的重要性。
阵容展示我用了两列对比的方式:主持人和玩家分开。开局前显示“等待加入”的占位,进行中显示当前角色,已完结则显示每个玩家的评分和是否为MVP。这个页面数据更新频繁,因为每场游戏结束后主持人会批量修改玩家的评分,所以详情页要监听记录状态的变化。我用Consumer包裹需要刷新的区块,而不是整个页面一起刷新,这样能明显减少 rebuild 开销。
Consumer<GameRecordProvider>( builder: (context, provider, child) { final record = provider.currentRecord; return Column( children: [ _Timeline(stages: record.extra['timeline'] ?? []), const Divider(), _PlayerList(players: record.players, mvpIndex: record.mvpPlayerIndex), ], ); }, )3.3 空状态、错误态与下拉刷新
记录列表和详情页一共处理四种页面状态:有数据、无数据、加载失败、刷新中。无数据和加载失败如果只是简单弹一个提示,用户会以为应用坏了。我专门做了三套兜底界面:没有记录时显示一个带剧本封面插画和副文案的引导页,文案是“约一车好友,开启你的第一场本”;加载失败时显示错误图标和重试按钮;加载中则显示骨架屏,用灰块模拟卡片布局,而不是转圈圈。
下拉刷新用的是RefreshIndicator,逻辑上和分页加载是共存的。这里有一个必须处理的边界条件:下拉刷新成功后,要同时清空已加载的列表数据并重置页码,否则会出现“旧数据前面拼上新数据”的错位。代码里我放在_refresh()里统一处理,确保 pagination 和 refresh 不会互相干扰。
Future<void> _refresh() async { _page = 0; _hasMore = true; final fresh = await repository.fetchPage(0, size: 20); setState(() { _records ..clear() ..addAll(fresh); _page = 1; }); }4. Flutter 在 OpenHarmony 上的适配要点
4.1 引擎适配分支与工程配置
Flutter for OpenHarmony 的工程和标准 Flutter 工程有区别。首先要保证本地的 Flutter SDK 是适配分支对应的版本,然后把 OpenHarmony 工程作为宿主工程接入。具体操作上,需要修改宿主工程的依赖配置,加入适配分支提供的 Flutter 引擎依赖,同时把 Dart 侧的工程作为构建源。这个流程在不同版本的适配分支上略有差异,我当时的做法是先跑一遍自带的示例工程,确认引擎能正常启动、能在真机上渲染出 Flutter 页面,再往里面拆业务代码。
这里有几条工程上的提醒:环境变量要显式指定适配分支的 SDK 路径,避免本机同时安装了标准 Flutter 时产生冲突;产物构建时要区分 debug 和 release,debug 模式启动速度明显更慢,不要因为启动慢就怀疑代码问题;OpenHarmony 工程的权限配置不能照搬 Android 模板,存储、网络等权限需要在对应配置文件中单独声明,否则运行时会静默失败。
4.2 安全区、字体与图片的三座大山
适配分支上最容易翻车的三个点,我总结为“安全区、字体、图片”。
安全区问题主要集中在带挖孔和圆角的设备上。Flutter 默认的SafeArea在标准 Android 上表现良好,但在 OpenHarmony 适配分支上,部分设备上报的padding值会和实际显示区域不一致,导致页面底部被手势条遮挡。我的处理方案是不依赖全局 SafeArea,而是针对具体页面用MediaQuery.viewPadding获取当前设备的真实内边距,并和系统提供的一个“显示区域”参数做差值校准。详情页的底部操作栏就显式留出了bottomPadding + 8的间距,实测在几台不同形态的设备上都能正常显示。
字体问题更隐蔽。适配分支预置的中文字体资源有限,如果代码里对fontFamily写了某个特定字体名,而这个字体在 OpenHarmony 上不存在,渲染时并不会报错,而是直接显示为系统默认字体,严重时会变成豆腐块。更好的做法是不要指定 fontFamily,让系统自动回退,如果业务上必须用特定字体,则在加载字体资源后,用FontLoader手动注册,并做一层加载失败的降级。
图片是第三个坑。常规 Flutter 项目里用Image.network一般没什么问题,但适配分支上部分设备的网络图片解码走的是不同通道,遇到 WebP、特定编码的 JPEG 时会出现解码失败或花屏。我的做法是:列表页的封面图统一在服务端生成缩略图,压缩质量控制在 0.8 以内,客户端再做一次内存缓存;详情页的大图则用PrecacheImage提前加载,避免在页面切换时出现突兀的白底。
Future<void> _precacheImages(List<GameRecord> records) async { for (final record in records) { final url = record.coverUrl; if (url.isNotEmpty) { await precacheImage(NetworkImage(url), context); } } }4.3 性能监控与滚动优化
适配分支下不能用常规工具链里所有功能直接分析性能,但基本的能力还是够用的。我通过性能面板抓了一次游戏记录列表的滚动帧率,发现主要瓶颈是封面图片解码。优化可以从两个方向入手:降低单张图片的解码尺寸,以及将列表项从动态高度改为固定高度。前者通过服务端缩略图解决,后者通过itemExtent解决,两个改动合起来之后,列表滚动的掉帧情况少了非常明显。
还有一个性能细节容易被忽略:详情页里玩家头像如果使用网络图,且每个头像都是独立请求,会在页面打开瞬间产生大量并发网络连接,造成加载缓慢。我的方案是引入一个简单的图片加载缓存层,用内存 + 磁盘两级缓存,并把头像尺寸固定为 96 像素。这个小改动看似不起眼,却让详情页的打开速度提升了不少。
5. 实战问题排查速查与避坑心得
5.1 典型问题排查实录
项目过程中遇到了一些比较典型的问题,整理成表格,方便大家直接对号入座。
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 列表快速滑动时出现白屏 | 图片解码耗时过高,Main isolate 被阻塞 | 列表封面统一用缩略图,开启图片缓存,必要时把图片解码放到 compute 中 |
| 部分中文姓名显示为方块 | 系统字体缺少对应字符,或指定的 fontFamily 不存在 | 去掉 fontFamily 设定,改用系统字体回退;自定义字体需用 FontLoader 注册 |
| 图片加载失败但无报错 | 网络图片编码格式不被适配分支支持 | 服务端统一转码为 JPEG/PNG,客户端加错误回退占位图 |
| 开局时间比实际差 8 小时 | 时区未做本地化处理 | 存储时使用 UTC,展示时通过本地时区格式化 |
| 打开软键盘后页面整体顶起 | 适配分支对 viewInsets 的处理差异 | 使用 Scaffold 的 resizeToAvoidBottomInset,对输入框区域单独做保护 |
时间差 8 小时这个问题值得单独说一句。不是适配分支特有,但在这个平台更容易被忽略。存储层统一用 UTC 时间,展示层用DateFormat加本地时区格式化。同时要明确playedAt的时区语义,否则服务端和客户端互相传时间时会非常混乱。后来我在模型序列化时强制在 toJson 里把 DateTime 转成带Z结尾的 UTC 字符串,彻底解决了两端时间不一致的问题。
5.2 工程规范与协作细节
游戏记录展示虽然是 Flutter 社区里很常规的功能,但如果换到 OpenHarmony 适配分支,很多工程规范就要重新明确。
首先是模型层的统一。云上、本地、UI 三层对游戏记录的定义必须完全一致,不能 UI 层读字段时自己拼一个 Map。我在项目里把模型类放在独立目录,所有序列化、反序列化逻辑都用统一方法,并且加了一层fromJson的兜底逻辑——当某个字段缺失或类型不对时,给一个默认值而不是直接抛异常。别看这一点小事,真机上用户数据千奇百怪,一个异常的字段就能拖垮整个页面。
其次是日志规范。适配分支上的原生层日志和 Dart 层日志是分开的,排查问题时要同时看两边的输出。我在代码里约定,所有网络请求和文件读写都要在关键节点打印日志,并且加上统一的 tag,比如RecordLog:,这样无论是跑在模拟器还是真机上,都能快速从日志里找到流程断在哪里。
最后是跨平台差异标记。如果团队里有人用标准 Flutter 写代码,有人用适配分支,一定要在代码里用条件标记区分平台特有实现,避免把标准 Flutter 平台性的逻辑直接带进来。比如文件存储路径的获取方式,在适配分支上就与 Android 不同,需要单独封装。
6. 落到项目里的一点长尾优化
6.1 记录数据迁移与兼容
迭代到后期,绝对会遇到字段调整。比如最初版本没有mvpPlayerIndex,后面要加;或者老板要求记录里能展示玩家是否中途退场。如果每次改字段都让用户清空数据,口碑会崩。所以记录文件里必须带上version字段,每次结构升级时编写对应的迁移函数。
List<GameRecord> migrateRecords(List<dynamic> rawList, int oldVersion) { if (oldVersion < 2) { for (final item in rawList) { item['mvpPlayerIndex'] ??= -1; } } if (oldVersion < 3) { for (final item in rawList) { item['endingType'] ??= 'unknown'; } } return rawList.map(GameRecord.fromJson).toList(); }迁移函数要保证幂等:即使同一个文件被迁移两次,结果也不能出错。这是生产环境最基本的安全底线。
6.2 后续可以扩展的玩法
游戏记录展示这个模块做完之后,很多功能都可以在此基础上长出来。比如“个人成就墙”,直接用记录里的剧本类型、MVP次数、时长聚合就能生成;比如“好友组队亲密度”,通过记录里的玩家阵容交叉统计;再比如“剧本推荐”,按照历史评分和结局类型做简单的协同过滤。这些都不需要重新设计数据层,因为记录模型在最开始预留了 extra 空间。我个人的体会是:做这种偏工具向的功能模块,真正值钱的部分不是 UI 多花哨,而是数据模型能不能在业务变化时平滑演进。如果让我重做一次,我会在动手写第一张列表卡片之前,先花一整天把模型、迁移、时区、图片缓存这四个底层问题全部定清楚。那个时候画页面的速度,反而是最快的。