舞萌DX 板块实现解析:从 Score Hub 同步到 B50 成绩图
本文拆解 rRanker(React Native 应用,代码位于 apps/mobile)中舞萌DX 板块的完整实现。假设读者熟悉 B50、DX Rating、FC/FS、达成率、定数、宴会场、物量等 maimai 概念,也具备前端/客户端开发经验。文中所有事实均来自代码本身,引用格式为 apps/mobile/src/…:行号。
1. 引言:碎片化的舞萌数据生态
maimai 与大多数音游一样,官方并不提供玩家成绩的开放查询 API。想拿到自己的 B50、全曲成绩表,社区只能走三条路:
- Score Hub(
api.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('已取消')(不可重试),超时则标记 retryable。sleep 同样用 250ms 的 watcher 来提前中断(score-hub-client.ts:265),保证用户点取消后最多 250ms 内所有轮询全部收敛。
错误统一封装为 ScoreHubError,带 status 与 retryable(score-hub-client.ts:68)。isRetryableScoreHubError(:94)按关键词判定”单次请求失败不应终止整次拉成绩”的瞬时错误(terminated、fetch failed、network、timeout、AbortError 等),网络错误消息会被 normalizeNetworkErrorMessage 翻译成用户可读的中文(:82)。另外所有请求携带自定义 UA rRanker-mobile/1.0(:135),这也符合”客户端应用而非浏览器”的识别定位。
2.2 好友码登录:bot_sends_request 模式
好友码登录是三步状态机,入口在 loginScoreHubWithFriendCode(upload-maimai-from-friend-code.ts:234):
- 创建任务:
createFriendLoginJobPOST/auth/login-requests,body 为{ friendCode, method: 'bot_sends_request' }(score-hub-client.ts:288)。若响应带skipAuth则直接返回内联 token(服务端判定无需人肉验证,比如同一机台短时间内重复登录),否则拿到jobId与 Bot 好友码。 - 轮询 + 保活:
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 吞掉错误)。 - 阶段感知:响应中的
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;图片则构造 FormData 以 image 字段上传(: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 分钟。状态流转:pending → adding_rival → waiting_snapshot → matched/failed,UI 文案见 QR_LOGIN_STATUS_LABEL(:15-19),其中 waiting_snapshot 的提示是”确认好友身份中(通常需要 1 分钟)“。设计细节:
- 连续失败退避:网络类错误累加
consecutiveFailures,连续 5 次直接放弃(:374),避免弱网下无限空转;中途恢复则计数清零。 - 失败语义:
failed状态携带服务端error字段;超时抛出”请刷新二维码后重试或改用好友码”(:410)。上游的qr_expired错误码会被识别并翻译成”二维码已过期,请在公众号重新打开”(isQrExpiredErrorBody,:232)。 - 登录成功后还要
fetchMe拿hasCabinetUserId确认机台绑定状态,并写回本地存储(upload-maimai-from-friend-code.ts:747-761)。
2.4 成绩任务:update_score 轮询与难度进度
拿到 token 后进入拉分阶段 uploadMaimaiAfterScoreHubToken(upload-maimai-from-friend-code.ts:303):
createUpdateScoreJobPOST/me/dxnet-jobs,body{ jobType: 'update_score', friendshipJobId? }(score-hub-client.ts:496)。friendshipJobId仅在本次登录新建过好友申请时传递——服务端要用它确认 Bot 好友关系;若返回 400needs_friendship,直接提示”尚未与 Bot 成为好友”。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)。- 结束后
fetchLatestSyncGET/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 在普通与反色两种通道下各试一次,兼容深色模式截图。
解码出的字符串未必是干净码串,extractMaimaiQrPayload(src/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 归一化
normalizeSongId(src/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 归一化
mapHubFcToCanonical→normalizeMaimaiFc(src/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 恒为 0 | score-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 固定 0 | score-hub-sync-map.ts:167 |
值得注意的差异:水鱼接口以曲名匹配(buildMusicTitleMap 还额外注册了 +10000 偏移别名,score-hub-sync-map.ts:216-227),落雪以 ID 匹配,本地则要求曲库里有对应 Chart——所以三种转换各自独立跳过,同一批数据可能在某一路被跳过而在另一路成功。
4.3 本地快照:曲库富化与 B50 联动
本地分发用 buildScoreSnapshot(src/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 上传后的反向回读
“写进去”不等于”应用内能看到”。编排在分发之后做了两件事让本地数据保持新鲜:
- 落雪:上传成功后立即用
LxnsScoreProvider反向拉取 player + records,重建快照写入 SQLite(:409-432);这一步失败不致命,只记入failedAccountNames。 - 水鱼:
refreshDivingFishAccounts(src/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.ts 与 src/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/right(Buddy 双人谱,用 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.load(src/services/catalog-service.ts:11)与 ResourceService.load(src/services/resource-service.ts:9)是同一模板方法的两个实例:先在线拉取并落库,失败回退缓存并把 source.kind 标记为 cache、isStale: 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-imageprefetch(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-shot 的 captureRef(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}.txt 与 music/{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 波形”按各自偏移加法混叠进一个 AudioBuffer(bakeRun,: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),谱面参数通过 applyChartPreviewConfigToHtml 把 window.__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. 结语:这套实现的几个亮点
回看整条链路,最值得借鉴的工程决策有五点:
- 状态机 + 回退:好友码登录(创建→轮询→20s 保活)、二维码登录(fast/async 两态、5 次失败放弃)、成绩任务(难度进度轮询)都是显式状态机;JWT 失效自动回退好友码登录,WebView 渲染有 phase 机与超时/崩溃检测。每个可能挂起的地方都有超时、都有兜底路径。
- 数据卫生先于业务:
normalizeSongId、parseHubAchievement、normalizeMaimaiFs(SYNC→null、fdx→fsd)、0~101 合法性检查、脏数据逐条跳过计数——上游有多脏,映射层就有多防御。 - 一次同步三路分发:拉取只做一次,三种目标契约各自独立映射、独立容错,单目标失败不阻塞整体,最后反向回读保证本地与上游最终一致(含水鱼最终一致性窗口的退避处理)。
- Rating 只信自己的公式:分段系数表 + 钳制 + floor,二分反查任意定数/目标分,B50 的版本归属精确到谱面而非曲目。
- HTML 渲染管线:CSS 等比缩放、素材全量 data URI、JS 测高协议(height/ready 两段式 + 兜底超时)把”WebView 何时可截屏”这个跨端难题变成了可协商的协议,配合 view-shot 的平台差异处理(software 层、PixelRatio、renderInContext 降级),产出了与机台截图几乎无差的成绩图。
如果你在做一个类似的”社区数据聚合”应用,这套”拉取 → 归一化 → 多路分发 → 回读校验”的骨架,以及”凡异步必有超时与回退”的态度,是比任何单点技巧都更值得复制的部分。
