资讯详情 个人游戏资料库架构设计:离线优先与多端同步实战指南
📅 2026/10/14 10:24:46
打开手机里的官方 App确实能查到自己买过哪些数字版能看玩耍时长也能翻奖杯列表。可一旦我想回答“《某大作》我是哪年打折买的、通关了没有、有没有把 DLC 奖杯也补完、当时在哪个档位留下什么记忆”它就哑巴了。实体盘更麻烦买回来拆封玩了几个小时、后来借给朋友、再后来二手出掉这个过程在官方数据里完全不存在。我就是为了把这些零零碎碎的东西全部收进自己的口袋才做了 AnyPS5——一套跑在手机和电脑上的个人游戏资料管理工具。它不管主机系统里任何底层的事不做模拟器不做备份只专注一件事让“我的游戏库长什么样、玩到什么进度、花了多少钱、什么时候借给了谁”这些私人记录都能脱离某个固定平台永久地留在我能控制的数据库里。这篇文章会完整拆一遍 AnyPS5 的架构设计和踩坑过程。如果你也想给某个特别喜欢的平台做一套私人资料库或者正在纠结“离线记录 多端同步”怎么做、数据表怎么设计、同步冲突怎么处理那这篇应该对你有用。我不打算讲那种“我做了个无敌的工具”的故事反而会把第一版怎么翻车的、后来怎么重构的、以及四个让我反复改代码的坑原原本本写出来。1. 为什么把游戏库存“自己记一笔”值得做成一个项目很多人会觉得游戏记录而已云文档里建个表格不就行了我一开始确实这么干用一个在线表格记录购买日期、价格、通关状态。坚持不到一个月就放弃了原因有三填起来太麻烦字段改来改去对不上在手机上看体验很差几十列横向拖来拖去眼睛受不了最致命的是表格底层的“行记录”模型根本不适合表达“一款游戏有多个游玩阶段”这种关系。AnyPS5 的出发点是“拥有自己数据的欲望”。平台方给你的查询接口通常只覆盖它想让你看到的东西数字版购买记录、最近玩过的游戏、已获得的奖杯。可是“我愿不愿意给这款游戏写一句短评”“我是在哪个周末打通了它”“这张实体卡现在放在哪个柜子的第几格”这些私人语境平台既不关心也不记录。把这类数据握在自己手里才是这个项目真正想解决的问题。也是因为这样这个项目的定位一开始就非常收敛只做资料记录与进度管理。不碰主机系统接口不依赖任何需要破解或改动设备的通道所有同步对账逻辑都在我自己搭的轻量后端上跑。这样既保证了工具本身完全绿色无风险也给后续扩展留下了安全边界。对于想复制这套思路的朋友我的建议是先别急着写界面先把你到底要记录“哪些实体”想清楚。举例来说AnyPS5 的领域模型只有四样东西游戏条目这个游戏是什么、游玩记录我怎么玩它的、标签/清单我怎么分类它、操作日志每次改动是谁在何时做的。四样东西想透后面所有功能都是在这四张表上做增删改查。2. 第一版表结构翻车后我重算了数据模型2.1 当年那张“万能大表”是怎么撑爆的第一版我图省事所有信息塞一张表games里面有id、title、region、price、rating、status、tags、notes、image_path、purchase_date……将近二十个字段。tags直接存成通关,神作,DLC未补这种逗号拼接的字符串。一开始确实很爽插入一条记录只要一个INSERT查询也只要SELECT * WHERE statusplaying界面代码极简。等记录量超过三百条问题开始浮出水面。第一标签没法统计。我想知道“买了但没开始玩”的游戏里有多少是“实体版”SQL 就得同时去LIKE两个字段性能差不说写出来自己都嫌丢人。第二一款游戏可能玩多遍。比如某游戏先在 PS4 上通关一次后来在 PS5 上又玩了一遍拿白金这时候status字段到底填哪一个status只有一个值填了“已通关”就丢掉了“第二遍进行中”的语境。第三字段越来越宽平均每条记录有七八个字段是空的维护起来非常分裂。2.2 最终落定的三张核心表撑到某天夜里我决定半夜重写数据模型。思路也简单把“东西本身”和“它的状态”分开。最终落地的核心结构长这样-- 游戏条目描述“这游戏是什么”不随进度变化 CREATE TABLE games ( id TEXT PRIMARY KEY, -- 全局唯一 ID title TEXT NOT NULL, -- 主标题通常用中文名 title_normalized TEXT NOT NULL, -- 归一化后的标题用于搜索去重 region TEXT NOT NULL, -- 港版/日版/欧版/美版等 platform TEXT NOT NULL, -- PS4 / PS5 / 双平台 sku TEXT DEFAULT , -- 平台内唯一编号可为空 cover_key TEXT DEFAULT , -- 封面图的缓存键 raw_data TEXT, -- 原始解析到的 JSON 字段 created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); -- 游玩记录回答“我怎么玩它的” CREATE TABLE playthroughs ( id TEXT PRIMARY KEY, game_id TEXT NOT NULL REFERENCES games(id), status TEXT NOT NULL, -- backlog / playing / beaten / plat started_at INTEGER, finished_at INTEGER, rating INTEGER, -- 个人评分 notes TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); -- 标签绑定多对多关系 CREATE TABLE tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE ); CREATE TABLE game_tags ( game_id TEXT NOT NULL, tag_id TEXT NOT NULL, PRIMARY KEY (game_id, tag_id) );这套结构带来的直观好处是一款游戏可以拥有多条playthroughs记录比如“2021 年通关过一遍”和“2023 年重玩白金了”是两条独立记录。而games.title_normalized负责汇总统计和搜索region和sku则保证即使两款游戏名字很像也能靠产地编号区分开。2.3 JSON 列和标准列的分工原则很多朋友第一次看到raw_data TEXT这种“JSON 塞进列”的做法会觉得不专业。但我要认真说在这个场景里它恰恰是务实的选择。从公开页面抓到的内容经常长这样{ media_count: 1, localized_titles: { zh-Hant: 某游戏中文名, en: Another Game }, editorial: 已下架, release_date: 2022-11-11, provider_notes: [首发版, 特典版] }这些字段的结构会随着页面改版而变化今天的localized_titles明天可能变成l10n。如果我把每个字段都固化成标准列一旦上游改了结构我得陪着改表结构、写迁移脚本没完没了。JSON 列的定位是给“来源不确定、结构不固定、我只想留着备查”的信息用的。标准列和 JSON 列的划分原则我用一句话概括凡是需要参与查询、排序、统计的字段必须是标准列凡是只为了留档、显示原始上下文、防止以后追溯时两眼一抹黑的字段就丢进 JSON 列。具体到游戏条目“标题、平台、地区、发售日、价格”要参与筛选和统计所以是标准列而“原始页面元数据、某些临时状态摘要、来源页链接”则放进raw_data。 这样既不牺牲查询性能也不怕上游格式漂移。3. 离线优先 多端同步最费心思的部分3.1 为什么选“操作日志”而不是直接同步表数据我可以老实告诉你第一版同步我做了个很蠢的设计手机端和桌面端各自维护一份完整数据库每次联网时互相比较整张表的updated_at谁新就覆盖谁。听起来合理吧实际跑起来全是毛病。首先全表比对每次要传几千条记录流量和时间都浪费在根本没改过的老数据上。其次并发修改时没法判断因果关系我在手机上改了评分是 9 分你在电脑上同一秒把笔记删了一行最后谁能赢后来改成“以操作日志为中心”的架构。任何改动都不直接去覆盖字段而是先写入一张operations表CREATE TABLE operations ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- 全局单调递增序号 op_uuid TEXT NOT NULL, -- 全局唯一操作 ID entity_type TEXT NOT NULL, -- game / playthrough / tag... entity_id TEXT NOT NULL, -- 对应记录 ID field_path TEXT NOT NULL, -- 改的是哪个字段 old_value TEXT, -- 旧值JSON 编码 new_value TEXT, -- 新值JSON 编码 happened_at INTEGER NOT NULL, -- 客户端本地时间戳 device_id TEXT NOT NULL, -- 哪台设备发起的 applied_at_origin INTEGER DEFAULT 0, -- 服务端首次见到该事件的序号 synced_flag INTEGER DEFAULT 0 -- 是否已传到服务端 );有了这张表同步就变成了“大家对账的是日志而不是直接覆盖业务表”。每台设备先把操作写进本地operations同时更新业务表连上网时把synced_flag0的增量日志推给服务端。服务端只负责存储和按序转发不负责决定谁对谁错。真正怎么应用这些日志由每个客户端的合并策略来定。这个设计最大的好处是“可重放”。万一某台设备把业务表搞坏了我只要能拉回完整的操作日志就能从空库开始按happened_at顺序把所有操作重放一遍数据自然恢复到最新状态。这不是“备份”而是一个真正可追溯的数据还原链路。3.2 每台设备一个“水印”服务端只出增量每台设备会记录一个整数水印last_seen_op_id表示“我已经看过的服务端最大操作序号”。拉取同步时带上这个水印GET /api/sync/pull?device_idxxxafterlast_seen_op_id服务端回答{ next_after: 9527, items: [ ... 一批 operations 记录 ... ] }客户端收到后用after继续翻页拉直到拿到next_after等于服务端当前最大序号为止。整个算法非常简单但碰到的真正难点是“单调递增”这个前提。只要服务端给操作编号时发生任何乱序——比如事务又回滚又重试跳号可以容忍但绝对不能出现后到的事件拿到较小序号——整个对账就会错乱。所以AUTOINCREMENT本身就带有“下次编号一定比上次大”的保证不要手贱去删操作日志表里的历史记录哪怕看着数据冗余也别删。3.3 冲突策略LWW 之外还要给人留个仲裁位所有同步系统都躲不开冲突问题。我的默认策略很朴素每个字段独立做 LWWLast-Write-Wins但一旦检测到“同一字段在同一秒内被两台设备改动过”就把这条记录标记为possible_conflict1在界面上给出一个待办卡片由用户自己决定保留哪个值。这里的关键是自动合并解决的是 99% 的无关痛痒场景比如手机端改了 A 游戏的评分电脑端改了 B 游戏的笔记互不干扰。剩下 1% 的“同字段同时改”情况交给算法去猜那简直是在制造数据事故。我见过不少项目为了追求“全自动同步”的酷炫效果把冲突也自动解决了几个月后用户发现某条笔记被覆盖得干干净净想找回都没门。给用户一个仲裁入口是离线优先工具最起码的体面。4. UI 层别把资料库做成数据库报表4.1 卡片化、搜索、筛选三层配合有了稳定的数据模型界面设计才敢放开手脚。我的做法是让 UI 像一本“电子记账本加书架”第一层是概览统计总游戏数、正在玩的有几款、今年通关了几款、总花费多少钱。第二层是筛选区标签下拉、状态按钮、平台按钮、地区单选。第三层才是具体卡片列表。筛选逻辑非常简单但有一个性能细节值得注意。我最开始用一句“大而全”的 SQL 来支持所有筛选组合比如SELECT g.*, p.status, p.rating FROM games g JOIN playthroughs p ON p.id (SELECT id FROM playthroughs WHERE game_id g.id ORDER BY updated_at DESC LIMIT 1) WHERE (?1 IS NULL OR g.region ?1) AND (?2 IS NULL OR p.status ?2) AND g.title_normalized LIKE % || ?3 || % ORDER BY g.updated_at DESC LIMIT 50 OFFSET 0;这个写法逻辑没问题但playthroughs子查询在记录上千条后会明显拖慢响应。后来我加了一张物化视图式的汇总表game_latest_status每款游戏的最新状态由操作日志在应用时同步维护查询时直接 JOIN 这张汇总表速度从几百毫秒降到几十毫秒。给筛选优化最好的手段不是缓存而是把“算出最新状态”这件事从查询时挪到写入时。对个人工具的数据量来说这几乎是零成本却能把体验提升一大截。4.2 大列表不卡的三件事列表一旦有上千张卡片渲染就是大问题。我踩过的体验坑有三个第一封面图无脑加载。早期列表里所有卡片一起请求封面手机直接发烫。解决方案是虚拟滚动只渲染可视区附近的几十个卡片滚动时动态替换用浏览器/移动端的列表组件即可实现。第二图片没有本地缓存键。每次重进页面都是重新从图床下载离线时全变空白。解决方案是给每张封面一个内容哈希文件名存到本地缓存目录cover_key字段就是干这个的。第三动画过度。卡片入场动画、移入移出动画在列表场景里并不增色反而会让快速滚动时界面闪烁。最终我把动画砍掉七成只保留状态切换时的一个小过渡。5. 四个掉过的坑修复记录坦白说项目最花时间的不是“写功能”而是修这些看似很小、藏得很深的坑。下面四个每一个都让我至少熬夜到凌晨两点。5.1 用“本地时间”记“几点开玩”跨端永远差几小时第一个坑是我把started_at、finished_at直接存成“本地时间的字符串”比如2025-11-09 23:30。问题在于手机在时区 A电脑在时区 B两边记录同一个时刻字符串相差好几小时排序就乱了。后来统一改成INTEGER存 UTC 时间戳展示时再按当前设备的时区格式化。改这个坑教会我一件事凡是跨设备的绝对时间一律存 UTC凡是给人看的相对时间比如“今天”“三天前”才在展示层根据本地时区计算。5.2 同一款游戏港版和欧版是两个主键但都叫“一场很好玩的梦”第二坑来自标题去重。某游戏在港版叫《一场很好玩的梦》欧版叫另一个名字中文社区则习惯用繁体或简体混着叫。我把它们当成两款游戏存了进去随后统计里出现了两个条目。真正要做的不是“合并”因为物理上它们可能确实是不同 SKU数字版和实体版还可能属于不同发行版本。正确的做法是保留games表里的多条记录但增加一个group_id字段表示“这哪几条物理 SKU 属于同一个人心里的同一款游戏”。筛选时默认按group_id聚合用户展开一组才能看到下面的具体版本。这个改动虽然小却彻底解决了“同作品多版本”的元数据混乱。5.3 图片缓存路径手机拍的封面在电脑上拉不出图第三个坑很蠢也能气死人。我先在手机上进了一张实体盘封面图片路径写的是绝对路径/storage/emulated/0/...。同一份数据同步到电脑后桌面端的图片组件就按这个路径去读自然读不出来。解决方案分两层数据库里永远只存cover_key即图片内容哈希真正的二进制文件放本地缓存目录两台设备各自持有自己的缓存目录拉到cover_key后再从文件缓存里查查不到就回落到图床下载下载完再写缓存。同步只同步“键”不同步“二进制文件本体”这是管理类工具最干净的图片处理方式。5.4 中文搜索繁简不分英文搜索大小写不认第四个坑出在搜索归一化上。我一开始在title_normalized里只做了lowercase()和去空格结果中文用户输入“龙与地下城”库里存的是“龍與地下城”时完全搜不到。修复时引入了 Unicode 的 NFKC 归一化同时把繁体统一转成简体作为title_normalized的存储值。import unicodedata def normalize_title(title: str) - str: # 先把 Unicode 归一到 NFC/NFKC 组合形式 s unicodedata.normalize(NFKC, title.lower().strip()) # 再加一层简繁转换这里示意用一张映射表做整体替换 for zh_tw, zh_cn in CONVERSION_MAP.items(): s s.replace(zh_tw, zh_cn) return s搜索时用户输入也用同一个函数归一化再去做包含匹配简体繁体、英文大小写的问题一次全解。这是那种“不修看着难受修起来又很无聊”的坑但对中文用户的重要性排在所有细节的前三名。6. 稳定运行半年后我能给你最诚实的建议AnyPS5 从开始写到今天已经持续跑了半年多。库里的游戏条目超过四百条游玩记录三百多条操作日志接近两千条多端同步基本没出过乱子。最让我惊讶的是给我最大安全感的不是 UI 多漂亮而是那三张干净的表和有序的操作日志无论哪台设备数据坏了我能一眼看出哪里错、怎么重放、怎么修。回头看我会给想做同类工具的朋友几条具体建议先写离线模式再想同步。个人工具不比团队协作绝大多数操作发生在单台设备上先把单机体验做到流畅顺手后面同步再难也有个稳定的地基。操作日志不是日志是你最重要的资产。只要日志还在业务表随便坏随时能重建。别迷信全自动合并。你去问三个用户“同步冲突怎么办”三个回答可能都不一样但至少有一个会说“别乱动我的数据”。给 AI 或算法太多裁决权最终只会换来一堆不可解释的覆盖。界面少做动画多做反馈。一个内容管理工具操作成功的反馈远比入场动画重要。如果之后还有余力我计划给它加上简单的时间线统计比如按年份画出“今年开了多少新坑、填了多少旧坑”再把实体盘的借出归还记录做成一个带提醒的小模块。这些都不复杂但因为底层的数据模型足够干净扩展起来就像搭积木。希望这套思路对你也有点参考价值。