5.2 语言包加载、回退与体积

键设计好了,运行时还得决定「加载哪些包、缺了怎么回退、总共多少字节」。这一节把语言包拆成 core 与 content 两个包,用回退链解析每个键,算出一款游戏在某个语言下真正要加载的字节数;并用 Python 仿真与 TypeScript 内核跨语言逐位对拍。

上一节5.1 i18n 资源结构与键设计 把键定好了,这一节讲运行时怎么把它加载进来。站内《独立游戏多语言本地化指南》 讲的是本地化的行业与流程——找谁翻、怎么验收、合同怎么签;本节只做加载器工程:一份资源定好之后,运行时怎么按语言把包拉进来、缺了怎么回退、总体积是多少。这两件事一个在仓库外、一个在仓库内,互不替代。上一节处理「结构」,这一节处理「运行时」——键查得到,还得保证包拉得对、拉得省。

加载的三个问题:加载什么、缺了怎么办、多大

一个多语言小游戏启动时,语言资源要解决三件事:加载什么(一次性全拉还是拆小包按需拉)、缺了怎么办(某个键没翻,运行时怎么兜底)、多大(给定一个语言,真正要加载的字节数是多少,会不会因为回退反而更大)。

先解决「加载什么」。我们把每种语言的资源拆成两个包:core(按钮、菜单这类启动就要用的键)与 content(关卡名、活动文案这类延迟加载的键)。拆包的意义是首屏只付 core 的代价,content 的体积不压在冷启动上(这和第二卷讲首包加载是同一套账)。

回退链:不是一个语言,是一条链

「缺了怎么办」的答案是回退链(fallback chain):每个语言有一条链,首位是自己,末位是基准语言。查一个键时沿链依次找,命中第一个定义它的语言——resolve("settings.sound", [de-DE, en-US, zh-CN]) 会落到 en-US,因为 de-DE 没翻这个键。回退链让「不完整的翻译」不至于崩:缺的键自动落到上游语言,界面永远有字显示。但它有个代价——回退会把你拖去加载别的语言的包。如果 de-DE 完全没翻 content,那所有 content 键都得从 en-US 的 content 包解析,于是你既要加载 de-DE.core、又要加载 en-US.core + en-US.content。

S 层仿真:加载计划与字节

下面这段是 S 层仿真脚本 sim/g05_langpack.py。它读 config/g05_langpack.json,对每个候选语言沿回退链解析全部键,统计实际要加载哪些包、多少字节、多少键靠回退命中,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g05_langpack.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g05_langpack.py --config config/ --out out/
"""语言包加载、回退与体积仿真:读 config/g05_langpack.json,对每个候选语言沿回退链
解析全部键,统计「实际要加载哪些包、多少字节、多少键靠回退命中」。

    chain(active)      = cfg.chains[active]                  # [自身, 中间语言, ..., 基准]
    resolve(k, active) = chain 中第一个定义了 k 的语言
    loadedPacks        = { (loc, pack(k)) | 每个键 k 的 resolve 结果 }
    loadedBytes        = Σ_{(loc,pack)∈loadedPacks} packBytes[loc][pack]
    startupBytes/lazyBytes = 上面按 core / content 拆开(启动加载 / 延迟加载)
    fallbackKeys       = resolve(k) != active 的键数;unresolvedKeys = 全链都没定义的键数

回退不是免费的:语言包不完整会把加载拖到别的语言的包上,总字节反而更大。
产出 out/g05_langpack.json + .csv + .svg。真实包体由交付物与压缩算法决定,命令行不可复现(X)。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(d: str) -> dict:
    with open(os.path.join(d, "g05_langpack.json"), encoding="utf-8") as f:
        return json.load(f)


def resolve(key: str, chain: list, defined: dict) -> str | None:
    """回退链里第一个定义了该键的语言;都没有则 None。"""
    for loc in chain:
        if key in defined[loc]:
            return loc
    return None


def plan(cfg: dict, active: str) -> dict:
    chain = cfg["chains"][active]
    defined = cfg["defined"]
    packs, fallback, unresolved = set(), 0, 0
    for item in cfg["keys"]:
        loc = resolve(item["key"], chain, defined)
        if loc is None:
            unresolved += 1
            continue
        if loc != active:
            fallback += 1
        packs.add((loc, item["pack"]))
    loaded = startup = lazy = 0
    for (loc, pack) in sorted(packs):
        b = cfg["packBytes"][loc][pack]
        loaded += b
        if pack == "core":
            startup += b
        else:
            lazy += b
    return {"locale": active, "chain": chain, "packCount": len(packs),
            "packs": sorted(f"{l}.{p}" for (l, p) in packs), "loadedBytes": loaded,
            "startupBytes": startup, "lazyBytes": lazy,
            "fallbackKeys": fallback, "unresolvedKeys": unresolved}


def draw_svg(path: str, rows: list) -> int:
    """每个语言一根横条:core(启动)与 content(延迟)两段堆叠,标注总字节与回退数。"""
    W, PAD, ROW = 760, 220, 44
    H = 96 + ROW * len(rows)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "30", "font-size": "15",
                                "fill": "#111827"}).text = "各语言启动所需加载字节(core 启动 + content 延迟)"
    span = W - PAD - 120
    hi = max(r["loadedBytes"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        wc = span * r["startupBytes"] // hi
        wl = span * r["lazyBytes"] // hi
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
                                    "fill": "#374151"}).text = r["locale"]
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(wc),
                                    "height": "20", "rx": "3", "fill": "#2563eb"})
        ET.SubElement(svg, "rect", {"x": str(PAD + wc), "y": str(y + 4), "width": str(wl),
                                    "height": "20", "rx": "3", "fill": "#f59e0b"})
        ET.SubElement(svg, "text", {"x": str(PAD + wc + wl + 8), "y": str(y + 18),
                                    "font-size": "11", "fill": "#6b7280"}).text = (
            f'{r["loadedBytes"]} B · 回退 {r["fallbackKeys"]} 键')
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    rows = [plan(cfg, loc) for loc in cfg["chains"]]
    digest = "|".join(f'{r["locale"]}:{r["loadedBytes"]}:{r["startupBytes"]}:'
                      f'{r["lazyBytes"]}:{r["fallbackKeys"]}' for r in rows)
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g05_langpack.svg"), rows)
    with open(os.path.join(a.out, "g05_langpack.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["locale", "chain", "packCount", "loadedBytes", "startupBytes",
                    "lazyBytes", "fallbackKeys", "unresolvedKeys", "packs"])
        for r in rows:
            w.writerow([r["locale"], ">".join(r["chain"]), r["packCount"], r["loadedBytes"],
                        r["startupBytes"], r["lazyBytes"], r["fallbackKeys"],
                        r["unresolvedKeys"], "+".join(r["packs"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "baseLocale": cfg["baseLocale"], "plans": rows, "digest": digest,
           "csv": os.path.join(a.out, "g05_langpack.csv"),
           "svg": os.path.join(a.out, "g05_langpack.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g05_langpack.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest,
                      "plans": [(r["locale"], r["loadedBytes"], r["startupBytes"],
                                 r["lazyBytes"], r["fallbackKeys"]) for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/g05_langpack.json:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "语言包加载、回退与体积模型。口径是「公开可观察机制」的建模假设,不是任何发行包体的实测。chains=每个语言的回退链(首个是自身,末位是基准);packBytes=每个语言两个包(core 启动加载 / content 延迟加载)的字节数;keys=键与其所属包;defined=每个语言实际定义了哪些键(没定义的走回退)。真实包体由本地化交付物与压缩算法决定,命令行不可复现(X)。全整数,无浮点。",
  "baseLocale": "zh-CN",
  "chains": {
    "zh-CN": ["zh-CN"],
    "en-US": ["en-US", "zh-CN"],
    "ja-JP": ["ja-JP", "en-US", "zh-CN"],
    "de-DE": ["de-DE", "en-US", "zh-CN"]
  },
  "packBytes": {
    "zh-CN": { "core": 18000, "content": 32000 },
    "en-US": { "core": 17000, "content": 30000 },
    "ja-JP": { "core": 21000, "content": 36000 },
    "de-DE": { "core": 19000, "content": 33000 }
  },
  "keys": [
    { "key": "common.confirm", "pack": "core" },
    { "key": "common.cancel", "pack": "core" },
    { "key": "home.play", "pack": "core" },
    { "key": "settings.sound", "pack": "core" },
    { "key": "home.title", "pack": "content" },
    { "key": "shop.buy", "pack": "content" },
    { "key": "shop.coinPack", "pack": "content" },
    { "key": "result.share", "pack": "content" },
    { "key": "result.retry", "pack": "content" },
    { "key": "error.network", "pack": "content" }
  ],
  "defined": {
    "zh-CN": ["common.confirm", "common.cancel", "home.play", "settings.sound", "home.title", "shop.buy", "shop.coinPack", "result.share", "result.retry", "error.network"],
    "en-US": ["common.confirm", "common.cancel", "home.play", "settings.sound", "home.title", "shop.buy", "shop.coinPack", "result.share", "result.retry", "error.network"],
    "ja-JP": ["common.confirm", "common.cancel", "home.play", "settings.sound", "home.title", "shop.buy", "shop.coinPack", "result.share"],
    "de-DE": ["common.confirm", "common.cancel", "home.play"]
  }
}

在本机跑,四个候选语言的加载计划是(同机相对量级,绝对数值不可移植):

语言回退链实际加载的包加载字节启动(core)延迟(content)回退键
zh-CNzh-CNzh-CN.core + zh-CN.content5000018000320000
en-USen-US→zh-CNen-US.core + en-US.content4700017000300000
ja-JPja-JP→en-US→zh-CNja-JP.core + ja-JP.content + en-US.content8700021000660002
de-DEde-DE→en-US→zh-CNde-DE.core + en-US.core + en-US.content6600036000300007

P 层内核:跨语言逐位对拍

一张表看不出「是模型对」还是「碰巧看着对」。下面这段是 P 层 TypeScript 内核 core/src/g05/langpack.ts,复算同一套加载计划:同样的回退链、同样的包去重、同样的字节求和。它读同一份 config/g05_langpack.json,产出与 Python 逐字符一致的摘要。

// file: core/src/g05/langpack.ts
// tier: P
// verify: node --test 'core/test/g05_langpack.test.ts'
//
// 语言包加载 / 回退 / 体积内核:与 sim/g05_langpack.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g05_langpack.json;对每个候选语言沿回退链解析全部键,统计实际要
// 加载哪些包、多少字节、多少键靠回退命中。全整数、无浮点、无 Math.random
// (只用可擦除语法,禁 enum / namespace / 参数属性 / 装饰器)。

export interface KeyItem {
  readonly key: string;
  readonly pack: string;
}
export interface LangpackConfig {
  readonly baseLocale: string;
  readonly chains: Readonly<Record<string, readonly string[]>>;
  readonly packBytes: Readonly<Record<string, Readonly<Record<string, number>>>>;
  readonly keys: readonly KeyItem[];
  readonly defined: Readonly<Record<string, readonly string[]>>;
}
export interface LoadPlan {
  readonly locale: string;
  readonly chain: readonly string[];
  readonly packCount: number;
  readonly packs: readonly string[];
  readonly loadedBytes: number;
  readonly startupBytes: number;
  readonly lazyBytes: number;
  readonly fallbackKeys: number;
  readonly unresolvedKeys: number;
}

/** 回退链里第一个定义了该键的语言;都没有则 null。 */
export function resolve(key: string, chain: readonly string[], defined: Readonly<Record<string, readonly string[]>>): string | null {
  for (const loc of chain) if ((defined[loc] ?? []).includes(key)) return loc;
  return null;
}

/** 给定候选语言,算出加载计划:要哪些包、多少字节、多少键靠回退。 */
export function plan(cfg: LangpackConfig, active: string): LoadPlan {
  const chain = cfg.chains[active] ?? [active];
  const packs = new Set<string>();
  let fallback = 0, unresolved = 0;
  for (const item of cfg.keys) {
    const loc = resolve(item.key, chain, cfg.defined);
    if (loc === null) {
      unresolved += 1;
      continue;
    }
    if (loc !== active) fallback += 1;
    packs.add(`${loc}.${item.pack}`);
  }
  let loaded = 0, startup = 0, lazy = 0;
  for (const p of [...packs].sort()) {
    const dot = p.indexOf(".");
    const loc = p.slice(0, dot), pack = p.slice(dot + 1);
    const b = cfg.packBytes[loc]![pack]!;
    loaded += b;
    if (pack === "core") startup += b; else lazy += b;
  }
  return { locale: active, chain, packCount: packs.size, packs: [...packs].sort(),
    loadedBytes: loaded, startupBytes: startup, lazyBytes: lazy,
    fallbackKeys: fallback, unresolvedKeys: unresolved };
}

/** 跨语言对拍摘要:与 sim/g05_langpack.py 的 digest 逐字符一致。 */
export function digest(plans: readonly LoadPlan[]): string {
  return plans.map((p) => `${p.locale}:${p.loadedBytes}:${p.startupBytes}:${p.lazyBytes}:${p.fallbackKeys}`).join("|");
}

配套测试 core/test/g05_langpack.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等。在本机跑 node --test 'core/test/g05_langpack.test.ts',五条全过:

✔ 回退解析:命中链上第一个定义该键的语言
✔ 跨语言一致:四个语言的加载字节与回退键数 == Python 金标准
✔ 基准语言全定义:零回退、只加载自身两个包
✔ 不完整语言拖累体积:de-DE 回退最多、ja-JP 总字节最高
✔ 每个候选语言都没有无法解析的键(回退链以基准收口)
ℹ tests 5
ℹ pass 5
ℹ fail 0

两套独立实现给出同一个摘要(同机相对量级,绝对数值不可移植):

digest = zh-CN:50000:18000:32000:0|en-US:47000:17000:30000:0|ja-JP:87000:21000:66000:2|de-DE:66000:36000:30000:7

只要有一边的回退方向、包去重顺序或字节求和写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一份配置,在 Python 仿真、TS 内核、真实加载器里必须长得一模一样。

回退不是免费的

看那张表,一个反直觉的结论浮出来:包最大的 ja-JP 不是最贵的,包最小的 en-US 也不是最省的。

  • en-US 全部键自己翻,只加载自己两个包,47000 字节——它是最省的。
  • de-DE 只翻了 3 个键,其余 7 个全落到 en-US,于是既要 de-DE.core 又要 en-US.core + en-US.content,共 66000 字节,比 en-US 自己还重。
  • ja-JP 只缺 2 个键,但它的包本身最大,再叠上 en-US.content,冲到 87000 字节——最高。

回退用别的语言的包,替你补了没翻的键;翻译越不完整,回退越多,加载越杂。工程上的含义是:别把回退当免费的容错——它保住了界面有字,却可能让一个「翻了一半」的语言比「全翻」的语言更重。要么把关键语言的 content 翻全,要么把回退键集中到一个共享的公共包里。

自检表

检查项合格标准
包按需拆core 启动加载、content 延迟加载,首屏只付 core
回退链明确每个语言一条链,首位自身、末位基准;回退键数单独统计
字节可复算加载字节、启动/延迟拆分逐位可复算
跨语言一致Python 与 TS 两套实现的摘要逐字符相等
边界清晰真实包体由交付物与压缩算法决定,标 X

X 层:真实包体与压缩算法不可复现

边界必须划清。上面算的是模型侧:给定各包字节数与回退链,加载计划是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的包体字节数与压缩率(由译文长度、编码、gzip / brotli 收益决定,CJK 与拉丁文字差别明显)与真实的加载时机(首屏核心包、进入关卡时拉 content)。

工程上正确的做法是:把可复现的加载模型与不可复现的真实包体分开——模型用来回答「翻一半的语言会不会更重」,真实包体靠打包产物反标定(把真实字节数填回 config)。下一节处理回退与体积之外最容易被忽略的一件事:同一条文案在不同语言里长度不一样,UI 装不下怎么办。

阅读导航:上一节:5.1 i18n 资源结构与键设计 · 下一节:5.3 文案膨胀与 UI 适配 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制