rRanker 总体架构解析:一个多音游数据聚合应用的设计
引言:查分器生态的碎片化与聚合
音乐游戏玩家对”查分器”应该都不陌生:打一首歌,拍下成绩,去对应的站点上传、查询、对比。但问题也随之而来——每个游戏的查分生态是碎片化的。舞萌 DX 有”水鱼查分器”(diving-fish)与”落雪咖啡屋”(LXNS)两套社区站点,接口、认证方式、数据模型各不相同;CHUNITHM 的成绩只在落雪上;Phigros 的官方云存档则走 TapTap 登录。玩家手机上被迫安装多个 App、记住多套密码、反复切换登录,这本身就是一种隐性的用户流失。
rRanker 就是这个碎片化问题的解法:一个用 React Native 编写的跨平台移动应用,把多家查分器数据源聚合进一个统一的成绩浏览体验。它读取上游数据、计算评级(DX Rating / RKS / RATING)、展示 Best 列表,并且——作为聚合应用的底线——换一个数据源时,页面结构和交互体验完全不变。本文不是使用教程,而是以代码为据的架构分析:为什么这样分层、每个决策解决什么问题、边界设在哪里。
技术栈概览:Expo SDK 54 + Expo Router 6 + React Native 0.81,TypeScript strict 模式,状态层用 Zustand 5,服务端状态用 TanStack Query 5,持久化用 expo-sqlite(含 kv-store)与 expo-secure-store,API 契约校验用 zod,模式匹配用 ts-pattern。本文所有引用均指向 apps/mobile/ 下的真实代码。
目录结构与分层:一张依赖方向图
先看全局布局。Expo Router 的路由目录在 app/,业务代码全部在 src/ 下,共 15 个子目录。路由层只有薄薄一层——它不包含任何成绩计算逻辑,只负责页面注册与全局副作用(启动恢复、主题注入):
app/_layout.tsx:根布局,启动会话恢复、主题水合、AppState 与焦点管理app/(tabs)/:五个主 Tab——(overview)(总览/账号页)、b50、records、search、settingsapp/tools/、app/library/、app/songs/:工具、曲库、歌曲详情等堆栈页
src/ 的目录职责与依赖方向,是整篇架构分析的地图:
screens / components / features ← 视图层,只消费 hooks
hooks ← 数据接入层,唯一持有 TanStack Query
state ← 全局会话状态(Zustand)
services ← 用例编排:拉取、切换、缓存失效、上传
providers ← 数据源适配器,面向上游站点的"方言翻译层"
repositories ← 存储接口抽象(纯 interface)
storage ← 持久化实现:SQLite、SecureStore、KV
domain ← 纯模型与纯函数,零依赖(不 import 任何存储/网络代码)
依赖方向被刻意约束为单向:screens → hooks → state → services/providers → repositories → storage,而 domain/ 处于最底部,不 import 任何 React、存储或网络代码——它是纯 TypeScript 模型与纯函数(评级计算、成绩筛选、payload 构造),可以被任何一层甚至测试直接使用。这条约束直接由 import 路径保证:domain/ 下的文件只见 import type,且只引用其他 domain/ 文件。反过来,storage/ 从不 import screens/ 或 hooks/。
这种分层带来一个立竿见影的好处:“换数据源”与”换存储”是两个正交的维度。上游站点挂了不影响本地快照的读取路径;SQLite 实现要替换为文件存储,只动 repositories/ 的对接层。
数据源 Provider 契约化
聚合应用的第一个难题:多家上游站点协议完全不同,如何让它们”可互换”?rRanker 的答案是契约化——先定义业务方需要的形状,再让每个数据源去实现它。契约全部集中在 src/providers/contracts.ts,全文件仅 45 行,却是整个数据流的枢纽。
ProviderSession:五种可判别联合
会话是数据源的身份凭证,rRanker 用一个可判别联合(discriminated union)表达全部五种会话形态(src/providers/contracts.ts:5):
export type ProviderSession =
| { mode: 'jwt'; value: string; persistable: true } // 水鱼账密登录
| { mode: 'import-token'; value: string; persistable: true } // 水鱼导入令牌
| { mode: 'lxns-oauth'; accessToken: string; refreshToken: string; // 落雪 OAuth
expiresAt: number; persistable: true }
| { mode: 'phi-session'; sessionToken: string; playerId: string; // Phigros TapTap 存档
persistable: true }
| { mode: 'cookie-jar'; persistable: false }; // 水鱼浏览器 Cookie(不可持久化)
注意两点设计。其一,联合的判别字段是 mode,配合 TypeScript strict 的穷尽检查,任何针对 session 的 switch/if 分支若漏掉某种模式都会编译报错。其二,persistable 是一个编译期携带的持久化许可——cookie-jar 明确标记为不可持久化,存储层在写入前用 isPersistableSession 做运行时收口(src/storage/secure-session-store.ts:99),双重保险防止凭据落盘。
三个契约接口与运行时判别
export interface ScoreProvider { // contracts.ts:22
getPlayer(): Promise<Player>;
getRecords(): Promise<ScoreRecord[]>;
}
export interface CatalogDrivenScoreProvider { // contracts.ts:26
getPlayer(): Promise<Player>;
getRecordsFromCatalog(catalog: CatalogSnapshot): Promise<ScoreRecord[]>;
}
export interface CatalogProvider { // contracts.ts:37
getCatalog(): Promise<CatalogSnapshot>;
}
export interface DetailedCatalogProvider extends CatalogProvider { // contracts.ts:40
getDetailedCatalog(): Promise<CatalogSnapshot>;
getAliases(): Promise<AliasSnapshot>;
getPlates(): Promise<PlateSnapshot>;
getCollections(): Promise<CollectionSnapshot>;
}
ScoreProvider 是”自给自足”型:成绩直接从上游拉。CatalogDrivenScoreProvider 是”依赖曲库”型:它的成绩需要先有曲库快照才能生成(后文会看到示例账号的实现)。两者合称 AnyScoreProvider。由于 TypeScript 的 interface 在运行时不存在,判别只能靠鸭子类型——isCatalogDrivenScoreProvider 检查 'getRecordsFromCatalog' in provider(contracts.ts:32),这是全代码库极少数逃逸到运行时的类型判别之一,且刻意封装在 contracts.ts 里,服务层只认这个谓词。
为什么会有这两种形态?因为成绩数据有本质差异:水鱼/落雪的成绩是独立查询的结果,而”示例查分器”的成绩是由曲库派生的(全谱面满成绩)。让后者去实现 ScoreProvider 的 getRecords() 是无意义的——它没有独立的成绩源。于是服务层在并发拉取时做了分流(src/services/score-service.ts:103):
if (isCatalogDrivenScoreProvider(this.scoreProvider)) {
[player, catalog] = await Promise.all([
this.scoreProvider.getPlayer(),
this.loadCatalog(),
]);
rawRecords = await this.scoreProvider.getRecordsFromCatalog(catalog);
} else {
[player, rawRecords, catalog] = await Promise.all([
this.scoreProvider.getPlayer(),
this.scoreProvider.getRecords(),
this.loadCatalog(),
]);
}
这是”策略模式 + 运行时多态”的教科书用法:新增一种成绩来源,只需实现契约,服务层零改动。
六种数据源的互换装配
目前实际存在的数据源恰好六种,全部实现上述契约:
| 数据源 | 实现类 | 会话形态 |
|---|---|---|
| 水鱼查分器 | DivingFishProvider | jwt / import-token / cookie-jar |
| 落雪查分器 | LxnsScoreProvider | lxns-oauth |
| Phigros 云存档 | PhigrosScoreProvider | phi-session |
| 本地查分器 | LocalMaimaiScoreProvider | 无(读本地快照) |
| 示例查分器 | MaxedMaimaiTestProvider | 无(由曲库派生成绩) |
| 空数据(测试游戏) | EmptyScoreProvider | 无 |
其中 LocalMaimaiScoreProvider 有趣:它实现 ScoreProvider,但 getRecords() 的返回是 (await this.snapshot())?.records ?? [](src/providers/local-score-provider.ts:41)——直接读 SQLite 里上次拉取的成绩快照。它”假装”自己是一个数据源,让本地玩家的读路径与远程玩家完全一致。
互换发生在 session-store.ts 的装配函数里。maimaiProviders(src/state/session-store.ts:59)按 providerId 与 session 模式分发:
if (providerId === 'local') return { scoreProvider: new LocalMaimaiScoreProvider(...), catalogProvider: new LxnsCatalogProvider() };
if (providerId === 'maimai-test') return { scoreProvider: new MaxedMaimaiTestProvider(...), catalogProvider: new LxnsCatalogProvider() };
if (providerId === 'lxns' && session?.mode === 'lxns-oauth')
return { scoreProvider: new LxnsScoreProvider(session, rotationHandler), catalogProvider: new LxnsCatalogProvider() };
if (providerId === 'diving-fish' && session)
return { scoreProvider: new DivingFishProvider(session), catalogProvider: new LxnsCatalogProvider() };
return emptyProviders();
注意一个细节:曲库一律用落雪(LxnsCatalogProvider)。曲库是游戏的静态信息,与账号无关,选数据质量最高的源即可——聚合应用的典型手法:数据按来源分级,成绩绑定账号,曲库绑定质量。
会话与账号体系
SessionVault v3 与 SessionIndex v4
多账号是 rRanker 的硬需求:同一个玩家可能同时拥有舞萌、中二、Phigros 三个游戏的账号,甚至舞萌有多个查分器账号。持久化模型经历了四代演化,当前是”v3 凭据库 + v4 索引”的双结构(src/storage/secure-session-store.ts:21):
export type StoredProviderCredential = {
id: string; providerId: RemoteProviderId; session: ProviderSession;
};
export type StoredProviderAccount = {
id: string; gameId: GameId; providerId: RemoteProviderId;
credentialId: string; displayName: string; scoreDisplay: string;
challengeModeRank?: number | null; ratingPossession?: string | null;
};
export type SessionVault = {
version: 3; activeAccountId: string | null;
credentials: StoredProviderCredential[]; accounts: StoredProviderAccount[];
};
核心决策是凭据(credential)与账号(account)分离:账号只持有 credentialId 引用,凭据单独成表。这对应一个真实世界的场景——LXNS 的 OAuth token 是按玩家、跨游戏共享的:同一个人在舞萌和中二上登录落雪,用的是同一份凭据。分离之后,轮换一次 token 即可同时刷新所有关联账号,而且账号元数据(展示名、分数、头像)与敏感凭据的更新频率完全不同,混在一起只会增加写放大。
那么敏感凭据为什么不在 v3 里直接存?因为 v4 索引的出现:SecureStore 对单值大小有限制(下文详述),当凭据很长(OAuth token 对)时,v4 把”账号元数据 + 凭据指针”放进 expo-sqlite/kv-store,把真正的敏感载荷移到按需分片的安全存储(src/storage/secure-session-store.ts:68):
type SessionIndex = {
version: 4; activeAccountId: string | null;
credentials: StoredCredentialIndex[]; // { id, providerId, secretRef }
accounts: StoredProviderAccount[];
};
loadVault(secure-session-store.ts:391)实现了完整的降级迁移链:优先读 v4 索引并回读各 secretRef 校验完整性 → 无则读 v3 单键 → 再退到 v2 → 最后是 v1 单会话。每级迁移都经过 migrateLegacyVault 的写入-回读-指纹比对(secure-session-store.ts:373):先写新格式,再读回来用 vaultFingerprint(对凭据与账号排序后的序列化摘要,secure-session-store.ts:317)比对,不一致立即回滚清空。旧数据在新版本格式下必须被验证为无损迁移,这是”渐进式迁移”在移动端的标准打法——因为用户升级后很难回滚,迁移代码必须自己证明自己正确。
凭据多对多与 LXNS token 轮换
共享凭据的运行时体现在 applyLxnsTokenRotation(src/state/session-store.ts:29):
export async function applyLxnsTokenRotation(accountId: string, next: LxnsOAuthSession): Promise<void> {
const state = useSession.getState();
const credentialId = state.credentialIdsByAccountId[accountId];
const linkedAccountIds = credentialId
? Object.entries(state.credentialIdsByAccountId)
.filter(([, value]) => value === credentialId)
.map(([id]) => id)
: [accountId];
// 用新会话覆盖所有共享同一凭据的账号,并同步激活会话
...
await new SecureSessionStore().updateAccountSession(accountId, next);
}
当落雪 access token 过期时,LxnsScoreProvider 内部做自动刷新(src/providers/lxns-score-provider.ts:56):ensureFreshAccessToken 检查 expiresAt(含 LXNS_TOKEN_REFRESH_SKEW_SECONDS 提前量,src/providers/lxns-oauth.ts:168),过期则发起 refresh_token 换取,并用一个模块级 refreshPromise 去重并发——同一时刻只有一个刷新请求。刷新成功后通过构造时注入的回调 onTokensRotated(即 applyLxnsTokenRotation)把新会话广播到所有共享凭据的账号,并持久化。这是一个非常干净的”回调注入”模式:provider 不直接触碰全局状态,只对自己被注入的处理器负责。
providersForAccount:一次装配一个”数据栈”
内存中的会话状态由 Zustand 的 useSession 维护(src/state/session-store.ts:321)。它保存的不只是账号列表,还有为当前激活账号装配好的 provider 实例:
function providersForAccount(account: BoundAccount, sessionsByAccountId: SessionsByAccountId) {
if (account.gameId === 'test' || account.gameId === 'chunithm' || !account.providerId) return emptyProviders();
if (account.gameId === 'phigros') return phigrosProviders(account, sessionsByAccountId);
return maimaiProviders(account.providerId, sessionsByAccountId[account.id] ?? null, account.id, account.displayName);
}
providersForAccount(session-store.ts:170)的输入是 (gameId, providerId, session) 三元组,输出是一对 { scoreProvider, catalogProvider }。selectBoundAccount 切换账号时执行一次装配并整体 set 进 store(session-store.ts:469)——注意这里的微妙之处:provider 是有状态的对象(LXNS provider 内部持有 session 与刷新锁,Phigros provider 持有实例级缓存),所以它们必须被当作”装配产物”随账号切换而重建,而不能是全局单例。Zustand 在这里的用法是”可替换的依赖槽”,而不是简单的数据容器。
账号 ID 三段式
账号的标识是自描述的字符串 ID,格式统一为 game:provider:身份(src/domain/bound-account.ts):
// maimai(bound-account.ts:169)
id: input.accountId ?? `maimai:${input.providerId}:${input.playerId ?? input.displayName}`
// chunithm(bound-account.ts:146)
id: input.accountId ?? `chunithm:lxns:${input.playerId ?? input.displayName}`
// phigros(bound-account.ts:186)
id: `phigros:phi-taptap:${input.playerId}`
加上内置账号 maimai:local、maimai:test、chunithm:temp(bound-account.ts:25)与无绑定占位 maimai:unbound(session-store.ts:25)。三段式 ID 的意义不止于人类可读:它让很多逻辑无需查表即可判定。比如 boundFromStored 反序列化舞萌账号时,直接从 ID 里切出玩家身份——account.id.split(':').slice(2).join(':')(session-store.ts:282),容忍 displayName 中的冒号;removeBoundAccount 后重建状态时用 activeAccountId === accountId ? null : activeAccountId 决定是否回退(session-store.ts:492),ID 即身份,无需额外比对字段。
四级存储分层
rRanker 的持久化按”敏感度 + 变更频率”切成四层,每层选型都对应一个明确的物理约束。
第一层:敏感凭据 → SecureStore 分片
expo-secure-store 把数据放进系统钥匙串/Keychain,安全等级最高,但单值上限 2048 字节。一个 LXNS 的 OAuth token 对加上 Phigros 的存档会话通常就会逼近甚至超过这个值。rRanker 没有回避,而是写了一个分片适配器 LargeSecureValueStore(src/storage/large-secure-value-store.ts:100),核心思路:版本化 generation 分片 + 清单原子切换 + SHA-256 完整性校验。
- 分片:按 UTF-8 字节数切块,每块上限 1900 字节(
large-secure-value-store.ts:4),由utf8ByteLength逐字符计算(large-secure-value-store.ts:54)——多字节中文必须按字节而非字符切。 - 写入(
large-secure-value-store.ts:123):新值写入全新generation(Crypto.randomUUID()去横线,:125)命名空间下的分片,全部写完后最后才写清单({ version, generation, chunkCount, checksum },:140)——清单是原子切换点:旧清单存在则旧值仍有效,新清单落盘瞬间新值生效。中途失败则清理已写分片(:145)。 - 校验(
large-secure-value-store.ts:108):读取时按清单取齐分片,用 expo-crypto 的 SHA-256 摘要比对 checksum(:120),任一缺失或损坏即整体返回null——宁可丢数据,绝不返回被截断的凭据。 - 删除(
:156):先删分片再删清单,天然兼容”清单在、分片缺”的中间状态。
第二层:非敏感快照 → SQLite 单表 JSON
成绩快照、曲库、用户曲库等大块但非敏感的缓存,落入 rranker.db 的 account_score_snapshots 等四张表(src/storage/sqlite-snapshot-repository.ts:24)。每张表都是一个”单槽 JSON”:(id/account_id, schema_version, updated_at, payload)。关键设计是 schema_version 校验:读取时版本不符立即删除该行并返回 null(sqlite-snapshot-repository.ts:66),JSON.parse 失败同样删除(:71)。这与 React 界”render 失败也要有兜底”的哲学一致——缓存宁可作废重建,绝不让旧结构毒害新代码。
写入用 INSERT ... ON CONFLICT DO UPDATE 的 upsert(sqlite-snapshot-repository.ts:80),并顺手清理遗留的单槽旧表(:86)——每次保存都是一次渐进式清理,旧路径在迁移完成后自然死亡。
第三层:偏好 → expo-sqlite/kv-store
主题、筛选器、上传偏好等高频小键值对,走 expo-sqlite/kv-store(secure-session-store.ts:2 直接 import Storage from 'expo-sqlite/kv-store')。它本质是 SQLite 上的 KV,比 SecureStore 快得多,且不占用钥匙串空间。值得注意的决策是:v4 索引也放在这里(INDEX_KEY),而敏感载荷仍在 SecureStore——分层边界划在”是否敏感”,而不是”是否大对象”。
第四层:内存查询缓存 → TanStack Query
运行期内存缓存全部交给 TanStack Query,全局配置 staleTime: 5min, retry: 1, refetchOnWindowFocus: true(src/state/query-client.ts:3)。这里最重要的架构纪律是 queryKey 全部携带 accountId(见 use-game-data.ts:51):
queryKey: ['game-data', GAME_DATA_QUERY_VERSION, activeAccountId, activeGameId, activeProviderId, session?.mode ?? 'none']
账号、游戏、查分器、会话模式全部进入 key。这带来两个免费属性:账号之间天然隔离(A 账号的缓存不会泄漏到 B 账号的 UI);会话模式变化(如 token 轮换)会触发精确的重新拉取。GAME_DATA_QUERY_VERSION(当前 18)是数据形态的版本号,payload 结构变化时手动 bump,让旧缓存整体失效——与 SQLite 的 schema_version 是同一种心智模型的另一处实现。
数据流一:启动会话恢复
启动流程是上述各层的第一次协同。app/_layout.tsx 在根布局挂载后立即执行(app/_layout.tsx:125):
if (restoreStatus === 'restoring') {
void restoreSession(() => sessions.loadVault(), loadOptionalBoundAccounts)
.then(() => hydrateBoundAccountAvatars().catch(() => undefined));
}
restoreSession(src/state/session-store.ts:575)拿到 v4 索引解析出的 SessionVault 后交给 finishRestore(:526)→ activateAccount(:233)→ 内部调用 providersForAccount 装配数据栈。activateAccount 里最见功力的是 pickActiveAccount 的优先级(:214):显式偏好 → 有会话的舞萌账号 → 非本地非示例的舞萌账号 → 任意舞萌 → 第一个账号。这个排序保证了”上次用的账号优先,其次选真实数据源账号”,新用户即使没绑过任何账号,也会落回 maimai:unbound 的空状态而非报错。
loadOptionalBoundAccounts(_layout.tsx:87)并行加载四路”可选账号”:本地查分器账号(含从旧快照迁移逻辑,:49)、示例账号、中二示例与临时账号。它们与远程账号走不同的存储路径(KV 与 SecureStore 之外的第 4.5 层——local-account-store 等专用 store),且在 finishRestore 中与 vault 账号合并去重(session-store.ts:555)。最后 hydrateBoundAccountAvatars(src/services/hydrate-bound-account-avatars.ts:51)异步补齐账号头像:远程账号读快照里的 iconId 构造 URL,Phigros 读缓存的 avatar 资源。恢复阶段不阻塞渲染:根布局只在 restoreStatus === 'restoring' 时显示加载屏(_layout.tsx:140),一旦 ready 即渲染,头像随后静默补齐。
数据流二:maimai 成绩拉取管线
成绩拉取的总入口在 use-game-data.ts 的 useQuery(src/hooks/use-game-data.ts:50)。以舞萌为例,ScoreService 是这条管线的核心(src/services/score-service.ts:69):
async load(): Promise<ScoreSnapshot> {
try {
// 并发拉取 player + records + catalog(catalog-driven 分支:player + catalog)
const snapshot = buildScoreSnapshot(player, rawRecords, catalog);
await this.snapshotRepository?.save(this.accountId, snapshot);
return snapshot;
} catch (error) {
const cached = await this.snapshotRepository?.getLatest(this.accountId);
if (cached) {
// 返回缓存,但标记 isStale,并区分"登录失效"与"网络故障"
return { ...sanitized, source: { ..., kind: 'cache', isStale: true } };
}
throw error;
}
}
三条流水线动作值得拆开看:
- 并发结构:
Promise.all同时发起 player、records、catalog 三个请求(score-service.ts:110)。三者在数据上相互独立(catalog-driven 分支则先并发 player+catalog,再用 catalog 派生 records,:103)。网络往返是移动端最大的延迟项,串行化会直接把 1 秒的 API 延迟放大成 3 秒。 - 快照编织:
buildScoreSnapshot(score-service.ts:13)用曲库给原始成绩补全标题、定数等谱面信息(enrichRecordsWithCatalog),再喂给buildBest50计算 B35/B15 与 DX Rating。有一个隐蔽分支:当数据源是local或generated(player.source.kind)时,Rating 以本地计算结果为准(:23)——因为水鱼/落雪直接给 Rating,而本地与示例账号的 Rating 只能自己算。 - 失败回退:任何上游错误都会尝试读缓存快照。缓存并非原样返回——先经过
withoutInvalidUtageRecords洗掉失效的宴谱记录(:37),再在source上改写kind: 'cache'与isStale: true,并区分两种文案:ProviderError的authentication/permission会追加”登录已失效,请重新登录”(:123)。stale 标记是这条管线最重要的出口:UI 层拿到快照的同时就知道数据是否新鲜,use-game-data据此暴露isDataStale(use-game-data.ts:306),总览页得以呈现”离线缓存”的视觉状态,而无需感知错误细节。
快照最终通过 snapshotRepository.save(accountId, snapshot) 落 SQLite,写前用 shouldPersistScoreSnapshot 判断该数据源是否需要持久化——maimai-test、chunithm-test、phi-taptap 被排除(src/domain/provider-capabilities.ts:11):示例账号成绩可再生,Phigros 存档每次全量同步,缓存它们只是浪费空间。这是”按数据源特性做持久化策略”的另一个例子。
数据流三:账号切换与缓存失效
账号切换是最容易出错的高风险路径:UI 要换数据源,Query 缓存要作废,导航要跳转,如果顺序错了就会出现”旧账号数据闪一帧新账号 UI”的竞态。rRanker 的处理是先清缓存、再换账号、最后导航(src/services/switch-bound-account.ts:32):
export function switchBoundAccount(accountId: string, options?: { navigateToOverview?: boolean }): void {
...
if (activeAccountId !== accountId) {
clearAccountDataQueries(); // 1. 同步清掉 7 组账号相关查询
accountSwitchClearedCache = true; // 2. 置位,供 hook 兜底跳过
selectBoundAccount(accountId); // 3. 更新 Zustand 会话
void sessions.setActiveAccountId(accountId); // 4. 异步持久化
}
if (navigateToOverview) navigateToOverviewAccountPage(); // 5. dismissTo 总览页
}
清缓存为什么是同步的 removeQueries 而不是 invalidateQueries?注释写得很直白(invalidate-account-data.ts:29):切换后立刻更新 activeAccountId,若缓存仍在,下一帧可能用旧缓存渲染新账号 UI,同时后台重拉造成卡顿。removeQueries 直接删除,页面保持加载态直到新数据就绪。
被清的是 7 组 key 前缀:game-data、score-snapshot、detailed-catalog、chunithm-catalog、plates、collections、songs(invalidate-account-data.ts:5)。注意 detailed-catalog 与 songs 等曲库类查询本身与账号无关,但仍被清掉——因为曲库内容按版本滚动,切换场景下宁可重拉一次,保证跨账号一致性。
useSyncOnAccountSwitch(src/hooks/use-sync-on-account-switch.ts:14)是第二道防线:它监听 activeAccountId 变化,若变化且缓存未被 switchBoundAccount 提前清过(通过 consumeAccountSwitchCacheCleared 消费一个模块级 flag),就补一次 clearAccountDataQueries。两个细节:首次 restoreStatus === 'ready' 只记录基线不触发,避免启动双拉(:23);switchBoundAccount 清过就跳过,避免打断刚发起的请求(:31)。
还有一类更精细的缓存操作:本地玩家改名不应触发全量重拉(会卡死命名弹层),所以 patchMaimaiPlayerDisplayName 用 setQueriesData 精准改写缓存中的 player.displayName(invalidate-account-data.ts:42),并以 key[2] === accountId 为谓词只命中该账号的 game-data 查询——这是 TanStack Query 高阶用法的示范:失效(invalidate)与改写(setQueriesData)是两种不同的工具,按变更粒度选用。
多游戏扩展性设计
GamePayload:判别联合驱动的数据包
前面所有机制服务的最终产品是 GameDataBundle——“当前选中游戏的一份独立数据包”(src/domain/game-data.ts:91)。它的载荷部分 GamePayload 是又一个可判别联合(game-data.ts:33),五种 kind:
export type GamePayload =
| { kind: 'maimai'; player; records; bestSections; playerScore; currentVersionTitle;
unmatchedRecordCount; source; catalogSource; snapshot }
| { kind: 'phigros'; player; records; bestSections; playerScore; challengeModeRank;
source; saveUpdatedAt; catalogSource; avatarUrl?; dataAmount; progress }
| { kind: 'chunithm'; player: ChunithmPlayer | null; scores; bestSections;
selections; playerScore; source; hasSyncedData }
| { kind: 'empty'; gameId; displayName; source }
| { kind: 'unsupported'; gameId; displayName; message };
注释写明了扩展纪律(game-data.ts:27):新游戏新增 kind,不要往舞萌字段里塞无关数据。每个 kind 携带自己的完整视图数据(分区标题、评分标签、特有字段如 Phigros 的 progress 与 dataAmount),页面层用 ts-pattern 按 kind 穷尽匹配渲染。新增一款游戏需要改动什么?——加一个 kind、一个 provider 实现、一个 GameProfile 注册项,而查询管线(use-game-data)与存储层完全不动,因为它们只依赖 GameDataBundle 这个外壳。这就是判别联合 + 穷尽匹配在扩展性上的胜利:编译器把”漏掉的新游戏分支”变成错误。
GameProfile 与 game-toolbox:注册表化的游戏元数据
GAME_PROFILES(src/domain/game-profile.ts:36)是游戏的展示口径注册表:ratingLabel(DX RATING / RKS / RATING)、ratingDigits(5 / 4 / 0——零填充位数)、bestSections 定义(舞萌 B35+B15,Phigros Phi3+Best27)、capabilities(是否有曲库/成绩/Best/工具)。use-game-data 里 formatPlayerScore(value, profile.ratingDigits)(game-data.ts:98)把 Rating 转成固定位数的展示串——“DX RATING 五位、RKS 四位”这类差异被数据化,而不是散落在各页面组件里。
工具页同理:GAME_TOOLBOXES(src/domain/game-toolbox.ts:20)声明每个游戏可用工具的列表(舞萌 7 项、Phigros 4 项、中二 3 项、测试 0 项),每项带 href 指向路由。工具箱页面只消费这份配置渲染入口,“新游戏不需要在页面组件里增加 gameId 分支”(:18)。而 GameCapabilities.hasTools 甚至是从 toolbox 反推的(game-profile.ts:46:getGameToolbox('maimai').tools.length > 0)——注册表之间互相推导,单一事实来源。
这三件套(game-bind-options.ts 的 GAME_OPTIONS 提供”可选游戏/可选查分器”目录,game-profile.ts 提供展示口径,game-toolbox.ts 提供工具目录)共同构成了游戏注册化的三个正交维度:能连什么、长什么样、能干什么。新增游戏 = 填三张表 + 写一个 provider + 加一个 kind,页面层零改动。
其他亮点
SQLite 并发纪律
移动端 SQLite 的并发坑在 Android 上尤其致命:多个连接同时开会导致 NativeDatabase 崩溃。rRanker 的应对是双重纪律(src/storage/rranker-database.ts:11):
let databasePromise: Promise<SQLiteDatabase> | null = null; // 进程内单例连接
let schemaChain: Promise<void> = Promise.resolve(); // schema 初始化串行链
export function runSerializedSchemaInit(task: () => Promise<void>): Promise<void> {
const run = schemaChain.then(task, task);
schemaChain = run.then(() => undefined, () => undefined);
return run;
}
getRrankerDatabase 保证全进程只有一条连接;runSerializedSchemaInit 把首启的建表任务串到同一条 Promise 链上,避免并发 execAsync 卡住原生队列。更细的纪律在存储层:注释明示不切换 journal_mode 为 WAL(sqlite-snapshot-repository.ts:22),因为单例连接上改 WAL 容易与 withExclusiveTransactionAsync 另开的连接互锁。这些注释本身就是架构文档——每一行都是在和平台的已知陷阱作战的记录。
zod 契约校验的 API 边界
上游响应是不可信的,rRanker 在 provider 的”网络返回 → 内部模型”边界上放了 zod 校验。水鱼的 parseContract(src/providers/diving-fish-provider.ts:14)对每个接口响应做 safeParse,失败抛 ProviderError('upstream_schema', ..., true);落雪同样逐层校验信封与条目(lxns-score-provider.ts:89、:167)。upstream_schema 是 ProviderErrorCode 九种之一(src/providers/errors.ts:1),并且是可重试的(retryable: true)——上游改结构是暂时性故障,重试语义与网络错误不同。错误码体系贯穿整个错误路径:providerErrorFromStatus(errors.ts:17)把 HTTP 状态映射为语义化错误(401→authentication、403→permission、429→rate_limit、5xx→network),ScoreService 再据 authentication/permission 区分”登录失效”与”网络故障”的缓存文案(score-service.ts:123)。整个错误链:HTTP → 语义码 → 用户文案,层层降维,UI 永不直接面对裸状态码。
stale 标记贯穿数据流
DataSource(src/domain/models.ts:12)是贯穿所有快照的元数据:{ kind, label, updatedAt, isStale }。kind 有七种取值(fixture / diving-fish / lxns / dxrating / local / generated / cache),isStale 是全局一致性的锚点——曲库缓存回退时打 stale(score-service.ts:86),成绩快照回退时打 stale(:124),最终由 use-game-data 汇总为 isDataStale(use-game-data.ts:306)。“数据是真是假、多新鲜”以字段形式随数据流动,而不是靠 UI 猜,这是聚合应用最重要的数据诚实性设计。
fixture / 示例 / 临时账号:降级与演示的完整光谱
值得一提的还有”非真实数据”的工程化。fixtures/ 提供脱敏验收数据(fixture-provider.ts 直接 structuredClone fixture 快照);示例查分器由曲库派生全满成绩(maxed-maimai-test-provider.ts:15:每张谱面 achievements: 101, rate: 'sssp',连物量分都按 total * 3 算出满分);“测试游戏”用 EmptyScoreProvider 提供空数据(empty-provider.ts:14);clearSession 只保留 local/test/temp 类账号(session-store.ts:517),模拟”退出登录”时数据栈自动降级到空态。这套光谱让”登录前能逛、断网能看缓存、演示有数据、验收有依据”全部成立——聚合应用的冷启动体验,往往取决于空状态设计得多认真。
结语:架构取舍总结
回看 rRanker 的架构,可以提炼出几个反复出现的取舍模式:
- 契约先于实现。
contracts.ts的 45 行定义了一切数据流的形状;新数据源、新游戏、新存储都只是”实现契约”,这消除了聚合应用最大的风险——各站点的方言泄露到业务层。 - 类型即运行时文档。可判别联合(ProviderSession、GamePayload)配上 TypeScript strict 与 ts-pattern 穷尽匹配,把”漏分支”从运行时 bug 变成编译错误;
persistable标记、isCatalogDrivenScoreProvider谓词,都是在类型系统内表达跨界约束。 - 诚实的数据降级。四级存储各司其职、schema_version 作废旧缓存、stale 标记随数据流动、缓存回退有明确文案——系统从不假装数据新鲜,而是把新鲜度作为一等公民传递。
- 扩展性靠注册表与联合,不靠 if 分支。
GAME_PROFILES、GAME_TOOLBOXES、providersForAccount的装配点,把”新增一款游戏”的成本收敛到少数几处,且每一处都是显式、可编译检查的。
这套架构当然有代价:双重缓存(SQLite 快照 + RQ 内存)引入了状态一致性维护成本;provider 实例随账号切换重建、缓存键手工拼版本号,都需要纪律维持。但对于”聚合多家数据源、多账号、多游戏、可离线”这一组需求来说,这些取舍是值得的——它让 rRanker 的下一步扩展(新游戏、新查分器)变成例行公事,而不是推倒重来。
