当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。
尘言头像尘言 · CHNYNNYA

舞萌DX 板块实现解析:从 Score Hub 同步到 B50 成绩图

以 rRanker 开源项目为样本拆解舞萌DX 完整数据链路:好友码与神秘二维码登录的状态机、三路成绩分发与数据卫生、DX Rating 分段系数与 B50 构建、HTML+WebView 成绩图导出、Simai 谱面预览与超密段音频烘焙。

舞萌DX 板块实现解析:从 Score Hub 同步到 B50 成绩图

本文拆解 rRanker(React Native 应用,代码位于 apps/mobile)中舞萌DX 板块的完整实现。假设读者熟悉 B50、DX Rating、FC/FS、达成率、定数、宴会场、物量等 maimai 概念,也具备前端/客户端开发经验。文中所有事实均来自代码本身,引用格式为 apps/mobile/src/…:行号

1. 引言:碎片化的舞萌数据生态

maimai 与大多数音游一样,官方并不提供玩家成绩的开放查询 API。想拿到自己的 B50、全曲成绩表,社区只能走三条路:

  • Score Hubapi.maiscorehub.bakapiano.com):利用游戏内「好友VS」机制——玩家先添加一个 Bot 为好友,Bot 发起 VS 后即可抓取对方公开成绩。这是唯一能拿到”整张成绩单”的渠道,代价是需要人肉配合(在公众号里接受好友申请)。
  • 水鱼查分器(Diving-Fish):社区最老的查分站,提供 update_records 批量写入接口,用 Import-Token 鉴权,但没有官方成绩抓取能力。
  • 落雪咖啡屋(lxns.net):新一代查分站,提供 OAuth 授权与曲库、收藏品等公共 API,同样需要第三方把成绩喂进去。

三个服务互相不打通,玩家若要同时维护三处成绩,就得分别操作。rRanker 舞萌板块解决的正是这条链路:用 Score Hub 一次性拉取官方成绩 → 在本地做数据卫生与归一化 → 三路分发(本地快照 / 水鱼 / 落雪)→ 反向回读刷新本地 B50 → 生成成绩图与谱面预览。整个编排在 apps/mobile/src/services/upload-maimai-from-friend-code.ts 中完成,下文按数据流向逐段解析。

2. 数据获取:Score Hub 客户端

src/services/score-hub-client.ts 是全部 Score Hub 交互的单一入口,包括登录、轮询、成绩任务与统计接口。

2.1 请求基建:超时、取消与错误分层

所有请求走 requestRaw(score-hub-client.ts:119),统一挂 AbortController 实现双路超时/取消:

const controller = new AbortController();
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
const abortWatch = options?.signal ? setInterval(() => {
  if (options.signal?.aborted) controller.abort();
}, 100) : null;

自定义的 ScoreHubAbortSignal 只是一个 { aborted: boolean } 的普通对象(score-hub-client.ts:80),通过 100ms 的轮询桥接到真正的 AbortController;timedOut 标志区分”用户取消”与”请求超时”——取消抛 ScoreHubError('已取消')(不可重试),超时则标记 retryablesleep 同样用 250ms 的 watcher 来提前中断(score-hub-client.ts:265),保证用户点取消后最多 250ms 内所有轮询全部收敛。

错误统一封装为 ScoreHubError,带 statusretryable(score-hub-client.ts:68)。isRetryableScoreHubError(:94)按关键词判定”单次请求失败不应终止整次拉成绩”的瞬时错误(terminatedfetch failednetworktimeoutAbortError 等),网络错误消息会被 normalizeNetworkErrorMessage 翻译成用户可读的中文(:82)。另外所有请求携带自定义 UA rRanker-mobile/1.0(:135),这也符合”客户端应用而非浏览器”的识别定位。

2.2 好友码登录:bot_sends_request 模式

好友码登录是三步状态机,入口在 loginScoreHubWithFriendCode(upload-maimai-from-friend-code.ts:234):

  1. 创建任务createFriendLoginJob POST /auth/login-requests,body 为 { friendCode, method: 'bot_sends_request' }(score-hub-client.ts:288)。若响应带 skipAuth 则直接返回内联 token(服务端判定无需人肉验证,比如同一机台短时间内重复登录),否则拿到 jobId 与 Bot 好友码。
  2. 轮询 + 保活pollLoginUntilToken(score-hub-client.ts:432)每 3s GET /auth/login-requests/:id 一次,总超时 8 分钟LOGIN_TIMEOUT_MS)。关键设计是 20 秒保活:每次轮询时如果距上次 verifyLoginJob 超过 VERIFY_EVERY_MS = 20_000,就 POST /auth/login-requests/:id/verify(:482-489)——服务端任务可能因长时间不活跃而超时回收,客户端必须周期性确认”我还在等”;verify 失败不中断轮询(:486 的 catch 吞掉错误)。
  3. 阶段感知:响应中的 job.stage 区分 wait_acceptance / wait_user_request(等待用户在公众号接受 Bot 申请)与发送阶段,驱动 UI 在 sending_friend / awaiting_friend 两个 UploadPhase 之间切换,并且”等待同意”只提示一次(alerted 标志,upload-maimai-from-friend-code.ts:264-285)。

拿到 token 后立刻 scoreHubAccountStore.upsert 持久化(upload-maimai-from-friend-code.ts:289),JWT 存在 Expo SecureStore 派生的大值安全存储里,索引存 SQLite KV(src/storage/score-hub-account-store.ts)。之后用户无需重新登录——这引出了第三入口的”会话复用”(见第 5 节)。

2.3 神秘二维码登录:fast/async 两态状态机

「神秘二维码」是公众号里的玩家二维码(形如 SGWCMAID…),扫码可直接绑定机台身份,免去好友申请流程。loginByQr(score-hub-client.ts:318)支持文本粘贴图片上传两路:文本走 POST /auth/qr-login JSON;图片则构造 FormDataimage 字段上传(:335-342)。两路都使用 QR_LOGIN_POST_TIMEOUT_MS = 150_000 的更长超时——服务端要真的去解析扫码结果,可能很慢。

响应有两种形态,parseQrLoginInitBody(:245)解析:

type QrLoginInitResult =
  | { kind: 'fast'; token: string; friendCode: string | null }
  | { kind: 'async'; attemptId: string };
  • fast:二维码中直接携带了有效会话,立刻拿到 token(兼容旧版直接返回 token 的响应)。
  • async:服务端需要等待玩家侧确认(比如公众号推送到玩家手机),返回 attemptId,进入 pollQrLoginUntilToken(:352)的慢路径状态机。

慢路径每 1s 轮询 GET /auth/qr-login/:attemptId,总超时 5 分钟。状态流转:pendingadding_rivalwaiting_snapshotmatched/failed,UI 文案见 QR_LOGIN_STATUS_LABEL(:15-19),其中 waiting_snapshot 的提示是”确认好友身份中(通常需要 1 分钟)“。设计细节:

  • 连续失败退避:网络类错误累加 consecutiveFailures连续 5 次直接放弃(:374),避免弱网下无限空转;中途恢复则计数清零。
  • 失败语义failed 状态携带服务端 error 字段;超时抛出”请刷新二维码后重试或改用好友码”(:410)。上游的 qr_expired 错误码会被识别并翻译成”二维码已过期,请在公众号重新打开”(isQrExpiredErrorBody,:232)。
  • 登录成功后还要 fetchMehasCabinetUserId 确认机台绑定状态,并写回本地存储(upload-maimai-from-friend-code.ts:747-761)。

2.4 成绩任务:update_score 轮询与难度进度

拿到 token 后进入拉分阶段 uploadMaimaiAfterScoreHubToken(upload-maimai-from-friend-code.ts:303):

  1. createUpdateScoreJob POST /me/dxnet-jobs,body { jobType: 'update_score', friendshipJobId? }(score-hub-client.ts:496)。friendshipJobId 仅在本次登录新建过好友申请时传递——服务端要用它确认 Bot 好友关系;若返回 400 needs_friendship,直接提示”尚未与 Bot 成为好友”。
  2. pollUpdateScoreUntilDone(:521)每 5s 轮询 GET /me/dxnet-jobs/:id总超时 20 分钟SCORE_TIMEOUT_MS,score-hub-client.ts:8)——抓取全难度成绩在服务端是重活,超时窗口必须宽松。进度对象携带 completedDiffs(已完成难度的序号数组)与 totalDiffs,客户端把序号翻译成 BASIC/ADVANCED/EXPERT/MASTER/Re:MASTER 的进度文案(upload-maimai-from-friend-code.ts:85-99,DIFFICULTY_LABELS 还映射了 10: 宴会场)。轮询期间单次请求断连不整段放弃,而是继续重试(:542-555)。
  3. 结束后 fetchLatestSync GET /me/sync/latest(:583)取成绩明细,空数组则抛”未获取到成绩数据”。

3. 神秘二维码解析:从截图到 SGWCMAID

二维码登录的图片输入支持”从相册选图”,src/services/maimai-qr-decode.ts本地完成解码,不上传原图给服务端(这既是隐私考量也是性能考量)。管线分四步:

// decodeMaimaiQrFromImageUri(maimai-qr-decode.ts:33)
ImageManipulator.manipulateAsync(uri, [{ resize: { width: 1600 } }],
  { compress: 1, format: SaveFormat.JPEG, base64: true })   // ① 缩到 1600px 宽并重压
base64ToUint8Array(…)                                    // ② 手工 Base64 解码
  → jpeg.decode(bytes, { useTArray: true })                  // ③ jpeg-js 解出像素
jsQR(new Uint8ClampedArray(data), w, h,
      { inversionAttempts: 'attemptBoth' })                  // ④ 反色/正色都试

① 是关键的工程决策:相册大图(几千万像素)直接塞给纯 JS 解码器会让主线程卡死,先缩到 1600px 再以 compress: 1(无损 JPEG)重压,解码成本可控;④ attemptBoth 让 jsQR 在普通与反色两种通道下各试一次,兼容深色模式截图。

解码出的字符串未必是干净码串,extractMaimaiQrPayloadsrc/services/maimai-qr-payload.ts:2)用正则 SGWCMAID[0-9A-Za-z+/=_-]+ 从任意文本中截取玩家码,截不到再回退”整串以 SGWCMAID 开头”的宽松匹配。识别失败抛出 QrDecodeError,文案区分”未识别到二维码”与”识别到的不是舞萌玩家二维码”。

4. 一次同步,三路分发

uploadMaimaiAfterScoreHubToken 拉回原始成绩后,只做一次映射计算,然后三路分发(upload-maimai-from-friend-code.ts:331-336):

const divingFishMapped = convertHubScoresToDivingFishRecords(scores, buildMusicTitleMap(input.catalog));
const localMapped = convertHubScoresToLocalRecords(scores, input.catalog);
const lxnsMapped = convertHubScoresToLxnsRecords(scores, input.catalog);

三个转换函数都在 src/services/score-hub-sync-map.ts,各自返回 records + skippedNoSong/NoTitle + skippedBadScore + skippedUnsupportedChart 计数——脏数据跳过而不是报错,是这条管线的核心卫生策略。

4.1 原始格式与数据卫生

Score Hub 的原始成绩字段(score-hub-client.ts:30):

type ScoreHubSyncScore = {
  musicId: string;          // 可能带 DX 偏移(+10000)或宴谱偏移(+100000)
  chartIndex: number;       // 0=BASIC … 4=Re:MASTER
  type: string;             // 'standard' | 'dx' | 'utage'
  dxScore?: string | number | null;   // 字符串!
  score?: string | number | null;     // '100.2618%',带百分号!
  fs?: string | null;       // 'SYNC' 等,上游常丢
  fc?: string | null;
};

这里的每一条都对应一个卫生函数:

  • 曲目 ID 归一化 normalizeSongIdsrc/domain/catalog.ts:5):ID > 100000 视为宴谱原样保留;> 10000 视为 DX 偏移,% 10000 还原;其余原样。这样 Score Hub 的 musicId 才能与曲库的 song.id 对齐。
  • 达成率解析 parseHubAchievement(score-hub-sync-map.ts:43):剥掉 %Number(),非有限数返回 null;随后统一做 0 ≤ a ≤ 101 的合法性检查(达成率超过 101 视为脏数据,因为 maimai 理论上限约 101%)。
  • FC 归一化 mapHubFcToCanonicalnormalizeMaimaiFcsrc/domain/maimai-filters.ts:53):白名单 fc/fcp/ap/app`,其余一律 null。
  • FS 归一化 normalizeMaimaiFs(maimai-filters.ts:61)是更脏的字段,三条规则:SYNC(Sync Play 模式,上游经常丢失/错标)→ null;fdx/fdxp(旧版叫法)→ 统一成 fsd/fsdp;白名单 fs/fsp/fsd/fsdp 之外 → null。
  • DX Score 解析 toDxScore(score-hub-sync-map.ts:68):要求整数且 ≥ 0。
  • 评级档位 scoreRateFromAchievement(score-hub-sync-map.ts:81)把达成率折叠成 sssp/sss/ssp/ss/…/d 的枚举值,阈值与机台一致:100.5/100/99.5/99/98/97/94/90/80/75/70/60/50

4.2 三种目标契约

三个目标的数据模型差异不小,转换是纯函数式、逐条独立:

目标契约要点位置
本地 ScoreRecord合并曲库 Chart(定数、难度、物量、版本),rating = calculateChartRating(...),宴谱 rating 恒为 0score-hub-sync-map.ts:115
水鱼 DivingFishUploadRecord曲名寻址(title + level_index + type),无曲名跳过;type 只有 SD/DX,宴会场不支持score-hub-sync-map.ts:235
落雪 LxnsUploadScore数字 ID 寻址,type 为 standard/dx/utage,宴会场 level_index 固定 0score-hub-sync-map.ts:167

值得注意的差异:水鱼接口以曲名匹配(buildMusicTitleMap 还额外注册了 +10000 偏移别名,score-hub-sync-map.ts:216-227),落雪以 ID 匹配,本地则要求曲库里有对应 Chart——所以三种转换各自独立跳过,同一批数据可能在某一路被跳过而在另一路成功。

4.3 本地快照:曲库富化与 B50 联动

本地分发用 buildScoreSnapshotsrc/services/score-service.ts:13)把原始成绩与曲库合并:enrichRecordsWithCatalog 回填定数、难度、谱师、物量、版本名(src/domain/catalog.ts:46),随后立即 buildBest50 计算 B50;且本地/generated 来源的玩家 Rating 由 B50 之和推导(score-service.ts:23-27),而不是信上游——Rating 永远只信自己算的。另外 isUtageSongId 的脏数据防线(宴谱 ID 必须配 UTAGE 类型)在 buildScoreSnapshot 与缓存回读时都会执行(score-service.ts:19, 37-67)。

5. 上传编排:UploadPhase 阶段机

整个上传流程用 UploadPhase 可辨识联合类型驱动 UI(upload-maimai-from-friend-code.ts:32):

type UploadPhase =
  | { kind: 'idle' }
  | { kind: 'logging_in'; message: string; authMode?: 'friend_code' | 'qr' | 'session' }
  | { kind: 'sending_friend' | 'awaiting_friend'; message: string; botFriendCode: string | null }
  | { kind: 'fetching_scores' | 'binding' | 'canceling'; message: string }
  | { kind: 'uploading' | 'syncing'; message: string; providerTitle: string }
  | { kind: 'done'; message: string; uploaded: number; skipped: number }
  | { kind: 'error'; message: string };

它同时是阶段机(UI 状态)与进度协议(用户看到的每一行中文),compactUploadPhaseLabel 提供折叠版文案。

5.1 三个入口

入口登录方式使用场景
uploadMaimaiFromFriendCode(:519)好友码 → 好友申请 → 轮询 token首次使用 / 会话失效回退
uploadMaimaiWithScoreHubSession(:546)直接复用本地持久化的 JWT已绑定机台后的快速拉分
uploadMaimaiFromQrLogin(:706)神秘二维码(文本/图片)已绑定玩家二维码后的快速拉分

三者最终都汇入同一个 uploadMaimaiAfterScoreHubToken(:303),这就是”登录方式与拉分-分发解耦”的结构。会话入口的设计要点:先 fetchMe 校验 token 有效性并顺手刷新 hasCabinetBound(:583-589);401/403(isScoreHubAuthExpired,:298)会被翻译成”登录已失效。将改用好友码重新登录”——由调用方捕获后引导用户回退到好友码流程。二维码入口默认要求 requireCabinetBound(:710-715),未绑定机台时抛出提示文案,避免产生”只传了成绩没绑身份”的悬空状态。

resolveUploadTargets(:166)在流程最前面就裁定每个绑定账号是否可写:本地查分器可写;测试账号(MAIMAI_TEST_ACCOUNT_ID)不可写;落雪要求 lxns-oauth 会话;水鱼要求 import-token 会话。未授权账号显示原因,而不是中途才失败。

5.2 目标级容错

分发循环对每个目标单独 try/catch(:345-455),产出 targetResults[]

  • 单个目标失败不阻塞其他目标,失败信息进 errorMessage,计数仍按映射阶段统计的 skipped 累计;
  • 全部失败才进入 error 阶段(:484-496);部分失败走 done,文案为”部分完成:写入 N 条;失败:××”(:498-509);
  • 用户取消(signal.aborted)在任何目标处都会优先抛出 ScoreHubError('已取消') 终止整次流程,而不是被当作单目标失败吞掉(:346, 444, 481)。

5.3 上传后的反向回读

“写进去”不等于”应用内能看到”。编排在分发之后做了两件事让本地数据保持新鲜:

  1. 落雪:上传成功后立即用 LxnsScoreProvider 反向拉取 player + records,重建快照写入 SQLite(:409-432);这一步失败不致命,只记入 failedAccountNames
  2. 水鱼refreshDivingFishAccountssrc/services/refresh-diving-fish-accounts.ts:79)按 [0, 2_000, 5_000, 10_000]ms 退避(:11)重试读取。水鱼有最终一致性窗口——刚写入的成绩可能读不到,所以每次读到快照后还要用 uploadedRecordsAreVisible 严格逐条回配刚上传的记录(:53-59),不匹配就继续重试;但若所有重试都因”读到了但没回配”而失败,仍保存最后一次可读快照(lastReadableSnapshot 回退,:67-70)——宁可展示旧数据,也不把一次成功的账号读取误报为同步失败。多个账号串行读取,避免并发触发水鱼限流(:90 注释)。

5.4 水鱼与落雪的上传语义

两个上传器在 src/services/diving-fish-upload.tssrc/services/lxns-upload.ts,结构同源但错误语义不同:

水鱼(diving-fish-upload.ts:37):POST /player/update_records,鉴权头 Import-Token,重试间隔 [0, 15s, 60s],单请求 120s 超时。一个特殊处理:HTTP 500 但响应体是 HTML 时按”降级成功”处理(:77-79)——水鱼服务偶发在写入成功后才返回 500 的 HTML 错误页,若按失败重试会导致重复写入。这是用响应体形态(<!DOCTYPE html>)区分”真故障”与”假 500”的务实技巧。

落雪(lxns-upload.ts:36):POST {API_ROOT}/user/maimai/player/scores,Bearer 鉴权。上传前检查 access token 过期则先 refreshLxnsAccessToken 并回调 onTokensRotated 持久化(:50-53);状态码语义映射精确:401 = OAuth 失效(不可重试,需重新授权)、403 = 缺 write_player 权限、429 = 限流(可重试)、5xx = 服务故障(可重试)(:77-91)。同为 [0,15s,60s] 重试节奏。

6. 评分体系:DX Rating 计算与 B50

src/domain/rating.ts 是纯函数模块,没有 IO,只有公式——这是它被处处复用的原因。

6.1 分段系数表

// rating.ts:4
const RATING_COEFFICIENTS: readonly [number, number][] = [
  [10, 0], [20, 1.6], [30, 3.2], [40, 4.8], [50, 6.4], [60, 8],
  [70, 9.6], [75, 11.2], [79.9999, 12], [80, 12.8], [90, 13.6],
  [94, 15.2], [96.9999, 16.8], [97, 17.6], [98, 20], [98.9999, 20.3],
  [99, 20.6], [99.5, 20.8], [99.9999, 21.1], [100, 21.4],
  [100.4999, 21.6], [100.5, 22.2], [Number.POSITIVE_INFINITY, 22.4],
];

系数语义是”达成率首次小于的档位阈值”:比如达成率 99.2% 命中阈值 99.5 → 系数 20.8;101% 命中 Infinity → 22.4。公式(rating.ts:12):

rating = floor( 定数 × (min(100.5, max(0, 达成率)) / 100) × 系数 )

达成率先钳制到 [0, 100.5](舞萌封顶约 101%,但 100.5 之后系数封顶 22.4,再高不追加收益),最后向下取整。这张表就是机台 Rating 曲线的直译,ratingTable/ratingTableDescending(:18-25)对外提供查表能力。

6.2 二分反查

minimumAchievementForRating(difficultyConstant, targetRating)(rating.ts:27)回答”定数 X 要打多少达成率才能到 Y 分”。由于 rating 是达成率的单调非降函数,直接二分:[0, 1_010_000] 的整数域(对应 0% ~ 101.0%,精度 0.0001%)上二分搜索首个 calculateChartRating ≥ target 的取值(:32-37)。前置剪枝:连 101% 都够不到目标时返回 null——比线性扫描快且语义清晰。

6.3 B50 构建

buildBest50(rating.ts:47)是 B50 的核心:

const classified = records
  .filter((r) => r.type !== 'UTAGE')                       // 宴会场不计入
  .map((r) => ({ record: r,
    version: catalog.chartVersionIndex[chartVersionKey(r.songId, r.type, r.levelIndex)] }));
// chartVersionIndex:`${normalizeSongId}:${type}:${levelIndex}` → 版本号(catalog.ts:38)
const b35 = rankScoreRecords(classified
  .filter(({ version }) => version !== undefined && version !== catalog.currentVersion.id)
  .map(({ record }) => record)).slice(0, 35);
const b15 = rankScoreRecords(classified
  .filter(({ version }) => version === catalog.currentVersion.id)
  .map(({ record }) => record)).slice(0, 15);

三个关键点:

  • 版本归属不挂在曲目上,而是挂在谱面上(chartVersionIndex曲目:类型:难度 为键)——同一首歌的 SD 与 DX 谱可以分属不同版本,这就是 chartVersionKey 存在的意义;
  • B35 = 非当前版本B15 = 当前版本,当前版本由曲库快照的 currentVersion 决定;
  • 排序键 rankScoreRecords(:40)是四级比较:rating ↓ → achievements ↓ → songId 字典序 ↑ → levelIndex ↑,保证并列时的确定性排序。

unmatchedRecordCount 统计查不到版本索引的成绩(曲库缺失导致),rating = b35 + b15 求和。配套的 mapCoverId(:72)把宴谱 ID(≥100000)与 DX 偏移 ID(10001~19999)还原成原曲 ID,供封面图寻址。

7. 曲库体系:LXNS 公共 API 与缓存

7.1 数据源与契约校验

曲库、别名、牌子、收藏品全部来自 LXNS 公共 API(https://maimai.lxns.net/api/v0/maimai,lxns-catalog-provider.ts:17),每个响应在入口用 zod schema 强校验(:19-98),getJson 统一 12s 超时(:126-141)。校验失败抛 ProviderError('upstream_schema')——宁可拒绝数据也不把坏结构流进领域层。

7.2 mapCatalog:定数、版本、宴会场

mapCatalog(lxns-catalog-provider.ts:143)是曲库映射的重头,处理四类边界:

  • 版本回填 versionAtOrBefore(:121):谱面的 version 字段(数值)映射到”版本列表里不晚于该数值的最新版本”,因为 LXNS 的谱面版本号与版本列表 ID 不是同一套坐标系;
  • 宴会场(:165-201):levelIndex 恒为 0(LXNS 的接口索引约定,注释明确”不能映射成 BASIC”),难度固定 utage;物量可能是 notes(单谱)或 left/rightBuddy 双人谱,用 zod union 探测,:174-183);标题要剥掉 【××】 前缀(stripUtageTitlePrefix,catalog.ts:25),并把原曲的 artist/bpm/genre/版本回填到宴谱条目(:213-230,originalSongIdForUtage = id % 10000);
  • 扁平版本索引 chartVersionIndex(:147-163)在映射时顺手建立,键即 chartVersionKey
  • 防呆护栏:若”最新版本一个谱面都没有”(currentChartCount === 0,:231)直接抛错拒绝猜测当前版本——B15 划分依赖它,错了整个 B50 就错了。

物量(notes)只随 getDetailedCatalog/song/list?notes=true 拉取(:243-245),普通 getCatalog 不带——物量用于成绩图的”理论 DX Score”展示,属于重资源,按需取用。

7.3 先在线后缓存

CatalogService.loadsrc/services/catalog-service.ts:11)与 ResourceService.loadsrc/services/resource-service.ts:9)是同一模板方法的两个实例:先在线拉取并落库,失败回退缓存并把 source.kind 标记为 cacheisStale: true——UI 据此展示”缓存(原:LXNS 公共曲库)“之类的状态而非静默过期。ResourceService 还带 schemaVersion 参数,缓存键随数据结构版本变化而失效,避免旧结构被新代码误读。

8. 成绩图生成:HTML + WebView + view-shot

成绩图(Best50/自定义成绩图)是 rRanker 舞萌板块最重的渲染管线,位于 src/features/best-image/app/best-image.tsx。思路是:HTML/CSS 精确复刻机台样式 → WebView 渲染 → 原生截屏,而不是用 RN View 手拼——CSS 的排版能力与缩放一致性远超原生手绘,代价是引入”WebView 就绪时序”问题,下文 8.3 节就是为此设计。

8.1 分页与内存预算

输出宽度三档 [1080, 1440, 2160](best-image.tsx:92)。paginateBestImageSections(best-image-custom.ts:157)按”每页最多 N 行、每行 5 张卡”把成绩列表切成多页,跨节的切分点用 rankOffset 保持排名连续(:185)。每页行数上限由内存预算模型决定:

// best-image-custom.ts:205 —— 位图内存随面积(宽²)增长,行数按宽²反比收缩
function maximumBestImageRowsForWidth(width: number): number {
  return Math.max(1, Math.min(50, Math.floor(50 * (1080 / width) ** 2)));
}

1080px 时 50 行;2160px 时只有 12 行。注释写得很直白:“Keep each exported bitmap in roughly the same memory range as a 1080 px, 50-row page”——导出位图是一次性整块内存,宁可多分页也不要让单张位图爆内存。

8.2 素材 data URI 化

WebView 的 originWhitelist={['*']}mixedContentMode="never"(best-image.tsx:764)意味着任何远程请求都会被拦死,所以所有素材必须内联:

  • 字体与 Rating 框ariblk.ttf(数字字体)与 rating_base_01..11.png 共 11 张 Rating 框素材(best-image.tsx:103-116)经 loadBestImageAssets 读成 data:font/ttf;base64,…data:image/png;base64,…(load-best-image-assets.ts:51)。Rating 框按当前 Rating 选档:BEST_IMAGE_RATING_FRAME_MINS = [0,1000,2000,4000,7000,10000,12000,13000,14000,14500,15000](build-best-image-html.ts:78)。
  • 曲封loadBestImageJackets 用 expo-image prefetch(url, 'disk') 落盘后读缓存转 data URI(load-best-image-jackets.ts:22-47),逐张带进度回调;取不到封面的曲目回退到 HTML 里的 占位符(build-best-image-html.ts:225)。Android release 下 expo-asset 可能只暴露资源标识符(如 assets_rating_rating_base_01),有专门回退路径先用 Image.resolveAssetSource 还原再拷贝(load-best-image-assets.ts:19-31)。
  • HTML 里所有尺寸都是宽度比例制px(width * 0.018) 之类的表达式在构建期算好),任意分辨率下布局严格等比。

8.3 内嵌 JS 测高协议

HTML 末尾内嵌的脚本(build-best-image-html.ts:514-731)负责测量真实内容高度并回报原生端,协议只有三个消息:

{ type: 'best-image-runtime', width, userAgent }   // 上报 WebView 内核版本
{ type: 'best-image-height', width, height }        // 内容高度变化
{ type: 'best-image-ready',  width, height }        // 资产全部就绪(最终高度)

测量链路:ResizeObserver(canvas 与所有 data-layout-content 子节点)+ MutationObserver(处理动态插入)+ requestAnimationFrame 节流 → measureAndFit 计算 max(child.offsetTop + scrollHeight) 得出逻辑高度,若与上次不同才 postMessage(避免消息风暴)。document.fonts.ready 与全部 <img> 的 load/error 事件汇入 assetReady,与 5 秒兜底 Promise.race(:719)——图片加载失败也要能出图。原生端(best-image.tsx:573-586)收到 ready 后还要延迟 320ms 再进入截屏(给 capture view 高度切换与 WebView 重排版留时间),30s 无响应判超时(:566-570)。

预览页另有 12s 超时与 loading/loaded/rendering/ready/timeout/error/crashed/terminated 八个阶段的 WebView 状态机(best-image.tsx:118),onRenderProcessGone 区分崩溃与终止。

8.4 原生导出细节

react-native-view-shotcaptureRef(best-image.tsx:588-652)做最终截屏,细节全是平台经验:

  • iOS:捕获尺寸要除以 PixelRatio(best-image-export.ts:6-17)——captureRef 返回的临时图是逻辑像素,除回去才得到物理像素尺寸正确的图;大图(宽 ≥1440 或高 ≥ 宽×4)改用 useRenderInContext: true(:19-25);若默认 drawViewHierarchyInRect 路径抛 view cannot be captured 则降级重试(best-image.tsx:609-612)。
  • Android:导出 WebView 显式 androidLayerType="software"(best-image.tsx:815)——硬件层上 WebView 内容可能截不到;预览页用 inlineBestImageWebViewSources(内存 HTML),Android 上改走 prepareAndroidBestImageWebViewSources 把 HTML 落到缓存文件再加载(prepare-best-image-webview-sources.ts:16-38),规避部分设备对 baseUrl 内存页的限制。
  • 导出循环逐页”等 ready → 截图 → 存相册”,文件名 rRanker-{玩家名}-{类型}-{时间戳}-{第几页}.png(best-image-export.ts:36-46),完成后清理临时文件(:67-70)。

9. 谱面预览:Simai 解析与时间窗口渲染

src/features/maimai-chart-preview/ 是内置的谱面播放器,从 LXNS 拉 Simai 文本在 WebView 里渲染,含练谱必需的全套功能(变速、镜子、判定线样式、A/B 循环点、双谱同屏)。

9.1 chartId 约定

src/domain/maimai-chart-preview.ts:5 定义了谱面资源 ID 的江湖规矩:

chartId = DX 谱:10000 + songId;SD 谱:songId;UTAGE:songId(原 ID > 100000)
预览曲 musicId = chartId % 10000   // 宴谱落到原曲
引擎难度 = levelIndex + 2          // BASIC=2 … Re:MASTER=6,与水鱼 difficulty+2 一致
Buddy 1P/2P → 引擎难度 2/3

main.ts:61-62 据此拼 https://assets2.lxns.net/maimai/chart/{chartId}.txtmusic/{musicId}.mp3

9.2 Simai 解析与时间窗口渲染

SimaiParser(engine/core/parser/SimaiParser.ts:64-169)支持三类输入:普通谱(parseSimaiChart,按难度取 &inote_X 段,找不到难度时按可难度降序取最高的)、Buddy 双人谱(parseSimaiBuddyCharts,1P/2P 分别在 &inote_2 / &inote_102 槽位,:91-94)、单侧谱(parseSimaiSideChart)。无任何 inote 段时判定为单难度谱并默认 MASTER(:76-86)。

播放器的时间基是拍数而非毫秒:PlaybackClock 维护音乐输出时钟,BPM 事件(bpmEvents)参与拍↔ms 双向换算(webview-player/main.ts:954-960)。每帧渲染 renderers[i].renderFrame(chart, preciseBeats, 4),拖动时间轴时”即拖即渲染”(:679)。信息栏的连击/BREAK 计数直接取渲染器逐帧累计的 frameOverlay。全屏模式动态重建底部音轨(buildFsTimeline,:1087-1155)与走带控件。

时间窗口的密度感知体现在两处:

  • 音轨直方图(:584-653):把整曲按 min(200, 宽度) 个桶做音符密度直方图,各音符类型按比例着色;刻度步长按”每刻度至少 4px / 每标签至少 24px”从 [1,5,10,50,100] / [5,10,20,50,100,200] 里自适应选择——窄屏自动加密尺。
  • 正解音调度AudioManager.schedule 只前瞻 1500ms(AudioManager.ts:5),配合 lowerBoundEvents 二分起始索引(:359)与 handledEvents 去重,避免对已过去的音符重复发声;MAX_PENDING_SOURCES = 96 的待播源上限让前瞻在超密段自适应收缩(:7, 325)。

9.3 超密段音频烘焙

高难度谱面(尤其 14+/15 级)的密集段会在几十毫秒内出现十几个音符,逐个建 AudioBufferSourceNode 会让 WebAudio 调度压力爆炸。AudioManager 的解法是离线烘焙(AudioManager.ts:203-245):

  • 扫描事件序列,间隔 ≤ 40ms(DENSE_GAP_MS)的连续事件段如果长度 ≥ 16 个(MIN_RUN_EVENTS,就把整段”正解音 tick 波形”按各自偏移加法混叠进一个 AudioBufferbakeRun,:226),播放时整段只挂一个 source(带 stopOnClear 标记,清场时整段停);
  • 其余散点走单发路径,密集但不足 16 个的段用 stopAfterMs = max(间隔×3, 30ms) 截尾(:335-337),避免 tick 余音互相叠成噪声;
  • 烘焙结果按事件数组 + 开关 epoch 缓存(preprocessedCache + toggleEpoch,:203-224, 388-401):用户切 touch/holdEnd 音效开关时 epoch 自增、在途烘焙 source 全停,下次调度重烘——烘焙内容与开关状态强绑定,必须失效重建

正解音音色是打包进 App 的 answer.wav,在 prepareChartPreviewWebViewSource 里读成 data URI 注入(prepare-chart-preview-webview.ts:75),配合 ANSWER_SOUND_BASE_OFFSET_MS = -50(engine/utils/constants.ts:5)的负偏移做听感对齐。整套预览资源(index.html/player.js/sensor.webp/answer.wav)会被 staging 到缓存目录以 file:// 加载(prepare-chart-preview-webview.ts:68-87),谱面参数通过 applyChartPreviewConfigToHtmlwindow.__CHART_PREVIEW__ 配置直接写进 HTML(chart-preview-inject.ts:75-81)——注释说明在 file:// 下这比 injectedJavaScriptBeforeContentLoaded 更可靠;Android release 的资源标识符回退路径在 chart-preview-asset-uri.ts:26-31。

10. 牌子与收藏品

src/domain/plates.ts 实现了版本牌(極/将/神/舞舞)的达成判定,核心是档位序比较而非字符串相等:

// plates.ts:4 —— 档位序数组
const RATE = ['d','c','b','bb','bbb','a','aa','aaa','s','sp','ss','ssp','sss','sssp'];
const FC = ['fc','fcp','ap','app'];
const FS = ['fs','fsp','fsd','fsdp','fdx','fdxp'];
// meets(:11):实际档位在序中的下标 ≥ 要求档位下标即达标

recordMeetsRequirement(:18)按”难度 ∈ 要求列表 && rate/fc/fs 三条件”判定;unmetDifficultiesForSong(:34)对”任意难度”要求(difficulties 为空)用 -1 哨兵表示”任意难度未完成”。calculatePlateProgress(:71)把牌子的 requirements 按曲目聚合成每曲的完成情况,产出 total/completed/completedSongIds/missingSongs/byDifficulty,其中 byDifficulty 按难度分桶统计——进度卡可以展示”还差哪些难度”。

parseVersionPlateName(:112)从姓名框名解析版本前缀与档位:按 ['舞舞','極','将','神'] 的顺序做后缀匹配(舞舞优先,因为”极舞舞”之类名字不能先匹配到”舞”)。舞代的特殊规则:覇者排在最前MAI_TIER_ORDER = [覇者, 極, 将, 神, 舞舞],:110),groupPlatesForPicker(:153)据此排序并丢弃非版本牌。档位文案映射 plateRequirementSpec(:123)定义了各档位要追的评价类型:極→FC、将→SSS 评级、神→AP、舞舞→FSD、覇者→A。

收藏品(src/domain/collections.ts)的曲目专属判定是”requirements 涉及曲目的并集恰好等于当前曲”(isSongExclusiveCollection,:23-30)——比如某首曲的 AP 称号只列了这首歌,才算该曲的专属收藏品;展示顺序固定头像→姓名框→背景→称号(:5)。

11. 版本名对照

src/domain/version-names.ts:11 硬编码了 20 条中日版本名映射(注释注明由 LXNS /song/list 与水鱼 /music_data 交叉统计核实),每条含 versionId / china / japan / code,code 是单字代号(真/超/檄/橙/暁/桃/櫻/紫/菫/白/雪/輝/熊/爽/宙/祭/双/鏡/彩)。

localizedVersionName(:34)的解析顺序很讲究:先按名字精确匹配(曲库版本名可能是”maimai でらっくす”或”舞萌DX”任一形态),再按 ID 区间回退——遍历映射表,versionId 落在 [当前项, 下一项) 之间就取当前项(:43-54),保证老版本曲目即使名字对不上也能归到正确世代。查不到就原样返回,绝不死于找不到。

12. 维护窗口

src/domain/maimai-maintenance.ts 处理机台侧的现实约束:舞萌服务器每日 04:00–07:00(UTC+8)例行维护,维护期间拉分必然失败。判定极简且无时区坑:

export function isMaimaiMaintenanceWindow(date = new Date()): boolean {
  const chinaHour = (date.getUTCHours() + 8) % 24;   // UTC+8 无夏令时,直接偏移
  return chinaHour >= 4 && chinaHour < 7;
}

MAIMAI_MAINTENANCE_MESSAGE 在维护窗口内拦截上传入口并给出”07:00 后重试”的明确指引(:1)。

13. 结语:这套实现的几个亮点

回看整条链路,最值得借鉴的工程决策有五点:

  1. 状态机 + 回退:好友码登录(创建→轮询→20s 保活)、二维码登录(fast/async 两态、5 次失败放弃)、成绩任务(难度进度轮询)都是显式状态机;JWT 失效自动回退好友码登录,WebView 渲染有 phase 机与超时/崩溃检测。每个可能挂起的地方都有超时、都有兜底路径。
  2. 数据卫生先于业务normalizeSongIdparseHubAchievementnormalizeMaimaiFs(SYNC→null、fdx→fsd)、0~101 合法性检查、脏数据逐条跳过计数——上游有多脏,映射层就有多防御。
  3. 一次同步三路分发:拉取只做一次,三种目标契约各自独立映射、独立容错,单目标失败不阻塞整体,最后反向回读保证本地与上游最终一致(含水鱼最终一致性窗口的退避处理)。
  4. Rating 只信自己的公式:分段系数表 + 钳制 + floor,二分反查任意定数/目标分,B50 的版本归属精确到谱面而非曲目。
  5. HTML 渲染管线:CSS 等比缩放、素材全量 data URI、JS 测高协议(height/ready 两段式 + 兜底超时)把”WebView 何时可截屏”这个跨端难题变成了可协商的协议,配合 view-shot 的平台差异处理(software 层、PixelRatio、renderInContext 降级),产出了与机台截图几乎无差的成绩图。

如果你在做一个类似的”社区数据聚合”应用,这套”拉取 → 归一化 → 多路分发 → 回读校验”的骨架,以及”凡异步必有超时与回退”的态度,是比任何单点技巧都更值得复制的部分。

上一篇Phigros 板块实现解析:TapTap 协议复刻、云存档解密与推分算法下一篇CHUNITHM 板块实现解析:落雪数据源与 Best30+New20+Selection 体系