做这个“Flutter for OpenHarmony 剧本杀组队App”的记录展示功能,前后磨了两个多星期,踩了不少OpenHarmony生态里特有的坑。这篇文章把游戏记录展示从数据模型、本地存储、列表UI到状态管理的实现过程完整梳理一遍,重点说清楚“为什么这么设计”和“真机上遇到什么问题”,给正在做Flutter跨端适配、尤其是要做社交类App记录页的朋友一些可直接参考的方案。
我是某开发团队里负责移动端的,剧本杀组队App的定位是让玩家快速找车、组队、约本,顺便在打完之后沉淀一份“游戏履历”。用户玩完一个本,最想知道三件事:我玩过哪些本?我在每局里扮演了什么角色、阵营胜没胜?我给自己和这个本打了多少分?所以游戏记录展示页,本质是把一次剧本杀经历结构化地呈现出来。
先说结论:这个页面看起来是个普通的列表页,但真正难的点不在UI,而在数据层和状态时序。尤其当你把Flutter跑在OpenHarmony上的时候,原本在安卓上无感知的很多细节,都会变成必须处理的坑。下面按我在项目里的推进顺序来写。
1. 项目背景与功能定位
1.1 记录页到底解决什么问题
剧本杀这个场景和普通社交App有个明显区别:它的核心用户行为是“组队前选本”和“玩完后复盘”。如果你只在组队阶段做功能,那用户约完一局、离场之后,App对他就不再产生价值。游戏记录展示页就是用来补上这最后一段体验闭环的。
具体来说,这个页面要满足三个需求:
- 完整性:用户所有参与过的局都要出现在列表里,包括还没打完的“进行中”状态。原因很实际——组队之后不一定当场开局,可能约到周末,这中间用户想看到“我还有一局待进行”,并且能从列表直接进入详情查看队友和剧本信息。
- 可读性:每一条记录要有足够的可视化信息。用户扫一眼列表,至少能判断出“这局我玩的角色是什么阵营”“我赢了还是输了”“我当时给这个本的评价如何”。纯文字列表是灾难,必须用卡片、色块、图标组合表达。
- 持久性:记录要存本地,不依赖网络。用户在弱网或者离线状态下打开App,看不到历史记录,信任感会直接崩塌。所以数据不能是纯内存态,必须落盘。
这三个需求直接决定了后续的功能设计优先级。我最开始只想做个“历史战绩列表”,但和产品沟通后发现,如果漏掉“进行中”的状态,用户在周一打开App会以为自己被清空了记录。这个状态机设计,是整个功能的隐性根基。
1.2 为什么用Flutter接OpenHarmony而不是重新写一套
团队手里的代码资产是现成的Flutter工程,里面有完整的登录、组队、聊天模块,移动端目标平台除了常见的安卓/苹果外,还需要适配开源的OpenHarmony系统。摆在面前有两条路:用ArkUI重写一套,或者把Flutter跑在OpenHarmony上。
选Flutter路线,核心原因是成本。我们粗略估了一下,组队模块和聊天模块加起来将近三万个有效代码行,如果重写,光UI逻辑就要多排两个人力,还要重新解决推送、存储、权限这些底层问题,整个排期至少多一个半月。而Flutter for OpenHarmony这个方向已经沉淀了一段时间,社区有对应的引擎和运行时适配,Flutter本身是跨平台的UI框架,理论上只要底层通道不通的插件数量可控,核心业务代码可以原样跑。
但这里必须说清楚一个现实:Flutter跑在OpenHarmony上和跑在安卓上,不是完全一致的体验。Dart运行时有适配版本的限制,插件生态也不是全部兼容。所以我在项目里做了一个硬性约定:所有涉及系统原生能力的调用,一律走抽象接口,不在业务代码里直接依赖某个平台的插件实现。这样一来,即使OpenHarmony上某个插件能力缺失,也可以通过条件编译或者实现类替换来兜底。
实际开发中还遇到一个很多人会忽略的问题:Flutter for OpenHarmony的引擎和SDK版本是绑定的,不能随便升级Flutter。我们工程里锁死了一个经过真机验证的组合版本,后续所有成员安装环境都用统一配置文件拉取,这避免了“我本地能跑你本地报错”的典型协作事故。
2. 数据层:先把“记录”的结构定死
2.1 游戏记录模型的设计
一个剧本杀记录,需要承载的信息比预想的多。我第一版想得很简单,就是一个标题加一个时间。真做起来才发现,列表卡片上每个信息位都对应一个字段,而且这些字段直接影响UI能不能顺利展示。
最终落地的模型字段大致如下:
- 记录唯一ID(局ID)
- 剧本ID与剧本名称
- 剧本封面图URL
- 玩家在本局中的角色名
- 角色阵营(分为“水阵营”和“陆阵营”这类自定义枚举,或者更通用的“好阵营/坏阵营”)
- 本局状态(进行中、已完结、已归档)
- 本局结果(胜利、失败、平局)
- 角色扮演时长(分钟)
- 参与玩家昵称列表
- 个人评分(1到10的整数)
- 备注文本
- 创建时间和更新时间
用Dart代码表达的话,大概是这样的结构:
enum GameStatus { inProgress, completed, archived } enum CampType { good, bad, neutral } enum GameResult { won, lost, drawn } class GameRecord { final String id; final String scriptId; final String scriptName; final String coverUrl; final String roleName; final CampType camp; final GameStatus status; final GameResult result; final int playDurationMinutes; final List<String> playerNames; final int rating; final String note; final DateTime createdAt; final DateTime updatedAt; const GameRecord({ required this.id, required this.scriptId, required this.scriptName, required this.coverUrl, required this.roleName, required this.camp, required this.status, required this.result, required this.playDurationMinutes, required this.playerNames, required this.rating, required this.note, required this.createdAt, required this.updatedAt, }); GameRecord copyWith({GameStatus? status, GameResult? result, int? rating, String? note}) { return GameRecord( id: id, scriptId: scriptId, scriptName: scriptName, coverUrl: coverUrl, roleName: roleName, camp: camp, status: status ?? this.status, result: result ?? this.result, playDurationMinutes: playDurationMinutes, playerNames: playerNames, rating: rating ?? this.rating, note: note ?? this.note, createdAt: createdAt, updatedAt: DateTime.now(), ); } }这里有个设计取舍:为什么玩家列表直接用List存储,而不是单独建一张关联表?因为在组队场景里,一次局内玩家数通常是5到8个人,数据量很小,冗余存储完全能接受。如果以后要做跨用户的数据分析,再拆表也不迟。当前最重要的是让列表页的查询足够简单,减少一次表关联。
值得一提的是,copyWith里我统一把updatedAt更新成当前时间。这个细节一开始没做,导致用户修改备注后,列表的排序还是老的,看起来像“改了没更新”。任何变更都应该推动updatedAt变化,这是记录类功能最容易踩的小坑。
2.2 本地存储选型:从JSON文件切到RDB
我最初想着数据量小,用JSON文件存储就行,利用path_provider拿到应用文档目录,把List序列化后写进一个record.json文件。实现很快,但问题很快暴露出来:
第一,随着局数积累到几百条,全量读文件再全量反序列化的耗时开始明显,真机上列表首帧加载卡顿。第二,更新单条记录也要重写整个文件,多次连续操作可能踩到文件写入并发,产生脏数据。第三,没有索引机制,想按状态筛选“进行中”的记录,只能在内存里filter。
所以我切到了OpenHarmony的关系型数据库(RDB)来做本地存储。这里说明一下,Flutter生态里本来有sqflite这类SQLite插件方案,但我查了一圈,Flutter for OpenHarmony的插件通道对原生SQLite的适配还不太稳定,而且团队也不想额外引一堆原生依赖,干脆直接封装OpenHarmony RDB的能力。
我把数据访问抽象成了一个仓储接口,业务代码只依赖这个接口:
abstract class GameRecordRepository { Future<List<GameRecord>> fetchPage({required int limit, String? cursor}); Future<GameRecord?> fetchById(String id); Future<void> insert(GameRecord record); Future<void> update(GameRecord record); Future<void> delete(String id); Future<int> countByStatus(GameStatus status); }RDB实现类负责把GameRecord映射到表结构。表设计很简单,一张game_record表,字段和模型一一对应,唯一索引是id,同时给status和updatedAt各建了一个索引。
在OpenHarmony的RDB里做分页查询,我最开始用的是offset/limit,结果数据量上来之后,越往后翻页越慢。后来换成游标分页:按updatedAt降序排列,以“上一页最后一条记录的updatedAt和id”作为游标条件,查询下一页。实测下来性能稳定很多。下面是一个简化的查询写法:
SELECT * FROM game_record WHERE status = ? ORDER BY updated_at DESC, id DESC LIMIT ?然后游标条件部分用(updated_at < 上一页最后值)来翻下一页:
SELECT * FROM game_record WHERE status = ? AND (updated_at < ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at DESC, id DESC LIMIT ?这里为什么要带上id作为第二排序和游标条件?因为updatedAt精确到毫秒,理论上存在两条记录时间戳完全相同的情况,不加id做次级排序,翻页会出现重复数据或者漏数据。
从JSON切RDB,UI层代码几乎没动,因为仓储接口没变。这个抽象层的价值在切换时体现得淋漓尽致——我只需要替换一个实现类,列表页、详情页完全不用改。
3. 列表页的UI设计与状态流转
3.1 卡片式布局怎么拆
剧本杀记录的信息密度比较高,如果用纯分割线列表,整个页面会显得杂乱。我最终选了卡片式布局,一张卡片代表一局游戏,信息分层排布。
卡片结构分三个区域:
- 顶部区域:剧本封面小图加剧本名称,右侧显示创建时间。封面图来自网络,切到OpenHarmony后,图片加载这块要注意域名白名单,后面章节会细说。
- 中部区域:角色名、角色阵营、本局结果。阵营和结果用图标加文字的组合表达,胜负不只用颜色区分。我在真机上调试时发现,红绿灯色块方案对色弱用户不友好,后来改成“胜利显示盾牌图标,失败显示破碎图标,平局显示握手图标”,再把底色作为辅助信息。
- 底部区域:个人评分和备注预览。评分用星标展示,备注超过一行省略。
列表的骨架代码:
ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: records.length, itemBuilder: (context, index) { final record = records[index]; return GameRecordCard( key: ValueKey(record.id), record: record, onTap: () => _openDetail(record.id), ); }, )一个容易被忽略但很关键的点:itemBuilder里必须用ValueKey(record.id)绑定卡片。没有这个key,真机上滚动后再快速下拉刷新,会出现卡片内容错位闪烁。原因是Flutter在复用元素时,没有稳定的key就不知道哪个子组件对应哪条数据。
卡片的点击区域要尽量大。一开始我把详情入口做成了卡片右上角的一个小箭头,点击命中率低,用户在真机上经常点了没反应。后来改成整张卡片可点击,右上角小箭头退化成装饰图标,体验正常了。
3.2 空态、加载态、失败态,一个都不能少
列表页最忌讳的就是“白屏加载半天,然后什么都没有”。我处理了三种非正常状态,用枚举统一管理:
enum ViewState { loading, ready, error, empty }- loading:显示骨架屏。不要用居中转圈,而是渲染和真实卡片尺寸一致的灰色占位块,让用户感觉页面结构已经出来了。骨架屏在OpenHarmony真机上没有兼容问题,纯粹是普通Widget。
- error:显示错误图标、错误文案和一个重试按钮。文案不能写得像技术报错,要像人话,比如“记录加载失败,请检查网络后重试”。
- empty:显示一句引导文案加一个“去组队”按钮。空态其实是最好的引导机会,用户没有历史记录,那就带他去首页找局。点击按钮跳转首页,对应tab切换。
ready状态下才真正渲染ListView。
这个状态机的实现我放在Model层而不是UI层,后面讲状态管理会提到。这里只想强调:状态枚举是UI和数据的桥,Model根据加载结果切换状态,UI根据状态渲染不同分支,逻辑清晰,也方便后续加状态。
还有一个细节:切换到后台再回到App时,要不要自动刷新?我的做法是注册App生命周期监听,从后台恢复到前台时执行一次静默刷新,但只在当前数据是ready状态时刷新,避免打断用户正在看的列表。这算体验上的加分项。
4. 状态管理与刷新时序
4.1 Provider + ChangeNotifier,按规模选方案
记录列表页面、记录详情页、个人主页的成绩概览,三处都要读同一个数据源。如果只用setState,每处都自己去拉数据,数据一致性就很难保证。我选了Provider加ChangeNotifier这套方案,原因一是项目规模不需要上Bloc,二是团队里多数成员对Provider更熟,Stream在“低频数据改写下”属于过度设计。
核心Model代码:
class GameRecordListModel extends ChangeNotifier { final GameRecordRepository _repository; ViewState state = ViewState.loading; List<GameRecord> records = []; String? _cursor; bool _hasMore = true; bool _isLoadingMore = false; GameRecordListModel(this._repository); Future<void> loadInitial() async { state = ViewState.loading; notifyListeners(); try { final page = await _repository.fetchPage(limit: 20); records = page; _cursor = page.isNotEmpty ? page.last.id : null; _hasMore = page.length == 20; state = records.isEmpty ? ViewState.empty : ViewState.ready; } catch (e) { state = ViewState.error; _errorMessage = '记录加载失败,请检查网络后重试'; } notifyListeners(); } Future<void> refresh() async { try { final page = await _repository.fetchPage(limit: 20); records = page; _cursor = page.isNotEmpty ? page.last.id : null; _hasMore = page.length == 20; state = records.isEmpty ? ViewState.empty : ViewState.ready; } catch (e) { // 刷新失败时保留旧数据,并提示一次错误 _lastError = '刷新失败:' + e.toString(); } notifyListeners(); } Future<void> loadMore() async { if (_isLoadingMore || !_hasMore) return; _isLoadingMore = true; try { final page = await _repository.fetchPage(limit: 20, cursor: _cursor); final existingIds = records.map((r) => r.id).toSet(); final newRecords = page.where((r) => !existingIds.contains(r.id)).toList(); records = [...records, ...newRecords]; _cursor = page.isNotEmpty ? page.last.id : _cursor; _hasMore = page.length == 20; } finally { _isLoadingMore = false; } notifyListeners(); } }这里有几个点是我实际踩过坑才补上的:
- loadInitial和refresh的差别:加载态只出现在首次加载,下拉刷新时不应该看到全屏骨架屏。所以refresh里不会把state改成loading。
- loadMore里的去重逻辑:没有这行代码,游标边界和并发插入场景下会出现极小概率的重复数据。用existingIds去重虽然多了一次内存遍历,但换来了数据的绝对干净。
- 刷新失败保留旧数据:弱网环境下,下拉刷新如果失败就把列表清空,用户心态会炸。保留旧数据,只弹一条提示,才是合理的交互。
在页面侧,加载Model后包一个ChangeNotifierProvider,列表组件内部用Consumer按需重建。这里要注意拆粒度:整页最简单,但每次刷新都会重建全部卡片;拆成卡片级别的Consumer会好一点,不过对当前数据量来说收益不大。我最后选择了页面级重建,OpenHarmony真机验证流畅度没问题。
4.2 下拉刷新与分页的时序细节
下拉刷新用的是Flutter自带的RefreshIndicator。看起来简单,实际有两个坑。
第一个坑是刷新动画的结束时机。如果数据刷完后立刻notifyListeners,RefreshIndicator的收线动画和列表重建挤在同一帧,真机上会出现“刷新条闪了一下就被顶掉”的诡异效果。解决方法是,让刷新完成后的notifyListeners延后约300毫秒,等动画走完再通知UI重建:
Future<void> _handleRefresh() async { final future = model.refresh(); await future; await Future.delayed(const Duration(milliseconds: 300)); }严格来说这不是最优解,但这属于“用可控的延期换平滑体验”的务实做法,在真机上测试很稳。
第二个坑是RefreshIndicator和滚动控制器同时使用时的冲突。如果列表外层还套了一个CustomScrollView,RefreshIndicator需要包在ScrollView外面,onRefresh参数由Model提供。早期代码里我在ListView内部又加了一个监听滚动位置的controller,结果下拉刷新手势和滚动手势互相抢事件,偶尔出现“下拉一半被吃掉”的情况。排查后,删掉多余的滚动监听,RefreshIndicator恢复正常。
分页加载我用的是滚动到底部自动触发,监听逻辑:
_scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { model.loadMore(); } });这里200像素是预加载缓冲区,让用户还没滚到底,下一批数据就已经开始加载了。如果改成“等于maxScrollExtent才加载”,在OpenHarmony真机上会出现明显白屏等待,因为滚动事件触发时机和列表渲染帧之间存在延迟。
分页时序还有最后一个细节:loadMore是异步的,但底部加载提示应该保证只出现一次。我加了_isLoadingMore标志位,防止用户快速上下滚动时重复触发多次查询。这个标志位必须在finally里重置,否则一次异常会卡死后续所有分页请求。
5. 兼容性适配与性能优化
5.1 长列表在OpenHarmony上的性能差异
同样一套ListView代码,安卓上滚动流畅,换到OpenHarmony真机上却掉帧。这个问题我排查了很久,最终定位在图片解码和纹理上传这条链路上。
OpenHarmony的Flutter适配层对网络图片的解码路径和安卓不完全一致,大图一次性解码上传GPU纹理的开销更高。具体表现是:列表里封面图多的时候,滑动明显一顿一顿;单页只有文字时,滚动很流畅。
解决方案做了三件事:
- 缩略图优先:封面图URL拼接了自定义缩略图参数,让服务端出图时直接给一张两倍于卡片显示尺寸的小图。卡片显示宽高大概是80x100,真机像素密度按3倍算,也就240x300,网络图直接给这个尺寸,避免原图解码。
- RepaintBoundary隔离重绘区域:每张卡片的外层包一个RepaintBoundary,让卡片在滚动时不是整页重绘。实测加了这个之后,滚动掉帧改善明显。注意RepaintBoundary不要包ListView本身,那会失去隔离的意义,要包在item内部。
- 条件缓存:封面图用带LRU策略的内存缓存,我直接用Flutter的ImageProvider自带缓存,只是在页面销毁时不需要清理,交给底层。
文字渲染这一侧,OpenHarmony的中文字体默认下发和安卓有差异。实际表现是,列表里同时有中文和英文时,英文单词的基线上下偏移,导致卡片内文字底部对不齐。这个问题的解法比较朴素:在TextStyle里显式指定fontFamilyFallback,让英文字体优先使用系统无衬线体,中文字体落到系统默认中文字体上。
另一个值得注意的点:OpenHarmony设备上,如果列表项的高度不固定,刷新指示器会计算错位置。我让卡片高度基本固定,备注、评分这些可能变长的字段都做了最大行数截断。这样ListView.builder的懒加载布局更稳定,滚动性能也好很多。
5.2 字体与屏幕适配的实测经验
屏幕适配这块,OpenHarmony设备形态比手机更复杂。我们测试的机器里有平板类设备,也有带挖孔屏的手机类设备,同一套代码在两台机器上表现不一样。
核心是避免硬编码。底部tab栏高度如果写死60,在带全面屏手势的设备上,会把手势条区域盖住。这块我统一用MediaQuery.padding.bottom做补偿:
final bottomPadding = MediaQuery.of(context).padding.bottom;列表页底部预留空间,至少是bottomPadding + 16,确保最后一张卡片不会被系统导航条挡住。
字体缩放是另一个容易忽略的点。OpenHarmony系统设置里有字体大小调节,如果用户调成大字号,App里的固定行高布局会被打乱。我用MediaQuery.textScaler对文本做统一缩放,但列表卡片里的关键指标(评分数字、胜负图标)用的是等比缩小的固定字号,因为这类信息卡片作为导航入口,放大后反而会破坏排版。
适配完成后,我列了一个验收清单,每个真机版本都要过一遍:
- 小屏手机:列表卡片是否有挤压变形
- 大屏平板:卡片是否被拉伸到很宽,需要限制最大宽度
- 横屏:列表是否还流畅,刷新指示器位置是否正常
- 系统字体放大:关键操作按钮是否被遮挡
横屏这个点差点漏掉。OpenHarmony平板横屏的时候,卡片宽度如果跟随屏幕无限拉伸,一行放太多文字看起来很怪。我把卡片外层包了一个ConstrainedBox,最大宽度设为560,超过后居中显示。这样横屏大屏上,列表变成一个居中窄栏,视觉上更舒服。
6. 真机调试与问题排查
6.1 常见问题速查表
我整理一张排查表,按我们真机调试中实际遇到的问题来写,频率从高到低:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 下拉刷新后列表闪白 | notifyListeners触发时机过早,RefreshIndicator动画未结束 | 刷新完成后延迟300毫秒再通知UI重建 |
| 封面图加载失败,显示空白 | 网络图片域名未加入OpenHarmony应用网络白名单 | 在应用配置文件里声明网络权限和域名白名单 |
| 滚动时卡片内容错位闪烁 | 未给列表项设置稳定的key | itemBuilder中绑定ValueKey(record.id) |
| 分页请求重复发送 | 滚动监听和刷新动画同时触发loadMore | 加_isLoadingMore标志位,在finally中重置 |
| RDB查询随数据量变慢 | 缺少索引,或使用offset分页导致全表扫描 | 对updated_at和status建索引,改用游标分页 |
| 返回列表页时状态丢失 | Model生命周期与页面绑定,页面销毁后Model被回收 | 在上一级组件持有Model,页面通过Provider获取 |
| 个别系统中文引号显示为方块 | OpenHarmony字体回退路径与安卓不一致 | TextStyle显式设置fontFamilyFallback回退中文字体 |
| 平板横屏时卡片过宽 | 未限制最大卡片宽度 | 卡片外层包ConstrainedBox,最大宽度设为560 |
最后一行提到“Model生命周期与页面绑定”,这个问题排查了很久。因为我在页面Widget里初始化Model,用户从A页面跳到详情页,再返回A页面时,页面重建,Model也重建,数据又要重新拉一遍。后来改成了App顶部的依赖注入容器持有单例Model,页面只负责读状态。代价是内存常驻,但换来秒开体验,对记录页这种低频数据来说很值。
6.2 值得写进团队文档的几个坑
有些坑不属于“速查”级别,但值得写进团队内部文档,避免下一个开发重复踩。
第一个是沙盒路径差异。OpenHarmony的应用文档目录路径和安卓不是一回事。如果之前从网页或者旧文档里拷贝过一个“/data/data/包名/files”的写法,在OpenHarmony上直接拿不到路径。正确做法是通过系统能力接口去拿应用沙箱路径,不要手写拼接。
第二个是热重载在OpenHarmony上的限制。我调试过程中,热重载在OpenHarmony上不是每次都生效,尤其是改动原生相关配置后,界面没有任何变化。此时不要怀疑代码,直接冷启动一次。后来养成了习惯:每次改完配置类文件,都直接冷启动,省了不少“我以为改了但没生效”的时间。
第三个是平台判断不能依赖dart:io的Platform。在OpenHarmony上,Platform.operatingSystem的值和安卓看起来一样,容易造成判断混淆。建议使用foundation库里的defaultTargetPlatform来做条件编译,或者干脆用抽象接口隔离平台差异。我们在代码里写了一个判断工具类,统一返回当前平台枚举,业务侧全部走这个枚举。
第四个是RDB事务的提交时机。批量写入记录时,如果一条一条插入,性能会很难看。我用事务把整批写入包起来,插入耗时从原来的“肉眼可见的慢”降到基本无感。这里要注意OpenHarmony RDB的事务嵌套问题,不要在一个事务里再开启子事务,框架会直接报错。
不过这些坑都属于“一次解决,长期受益”的类型,写进文档之后,团队后续做其他跨端模块,遇到同类问题时能直接查到答案,不用再花半天去搜社区。
我个人在实际操作中最大的体会是:游戏记录展示页看起来只是个普通列表,但它牵涉到模型设计、存储选型、状态时序、设备适配和真机调试,每一层都有值得抠的细节。尤其是Flutter for OpenHarmony这种“半新生态”,不能拿安卓的经验直接套,必须真机验证。
最后再分享一个受用的小技巧:把数据模型和仓储接口彻底分开,后续不管是用ArkUI重写关键页面,还是出Web版本,模型层可以原样复用,UI层推倒重来也不心疼。现在团队里的其他新功能都默认按这个分层方式写,开发效率提升了不少。