事故:活动弹窗里露出了 key
《潮汐方舟》案例设定首包 350MB、总资源 1.8GB,首发覆盖简体中文、繁体中文、日文、韩文、英文、德文与阿拉伯文。一次版本活动把新增礼包按钮的文案只写进了英文语言包,日文与阿拉伯文客户端没有对应 key。按钮最终显示成 shop.bundle.limit_reached,玩家截图在社区里传播;更麻烦的是,这个 key 由三段字符串拼接而成,日志只能看到整串,无法判断缺的是哪个语言、哪个参数。
第二次事故更隐蔽:德文翻译上线后按钮文字溢出,被 overflow: hidden 直接裁掉,玩家看到的是「Kaufen Sie je…」。两次事故指向同一组问题:key 怎么设计、语言包怎么加载与回退、文本膨胀怎么留空间、字体与排版怎么在包体和脚本方向之间取舍。 这四件事必须一起定,缺一件就会在某个语言上破。
key 是接口,不是文案索引
key 一旦发布就进入客户端、配置、埋点与客服工具,改名的成本远高于改文案。设计原则是语义稳定、层级可读、与英文原文解耦。
| 反例 | 正例 | 判断 |
|---|---|---|
btn_ok | common.action.confirm | 按动作命名,不绑定按钮位置 |
text_20261105_new | shop.bundle.limit_reached | 日期与版本号不进 key |
shop_bundle_limit_reached_v2 | shop.bundle.limit_reached | 版本由语言包版本承担 |
欢迎回来 | home.welcome.back | 中文不能当稳定标识 |
msg_1 | battle.result.defeat | 序号无法表达语义 |
key 至少两段,推荐「模块.对象.状态」三段。段内只用小写字母、数字与下划线,避免大小写和空格在不同平台被规范化成不同结果。文案中的变量一律用命名占位符:{name}、{count},禁止把「剩余」、数字、「次」拼成一句话。词序在日文、德文和阿拉伯文里都可能变化,拼接字符串等于把英文语序写死。
复数不能靠 count > 1 判断。英文只有单复数,俄文有 one/few/many,阿拉伯文有 zero/one/two/few/many/other。模型用 Intl.PluralRules 选择类别,语言包为每个类别提供整句;没有 other 的复数条目直接判为非法。性别、敬语、格变化同理,要么拆成不同 key,要么引入受控的 ICU 子集,不要在业务代码里写语法分支。
语言包加载与回退链
语言包按完整目录加载,而不是在英文目录上打补丁。补丁模型看似省体积,实际会让「哪些 key 已被翻译」变成运行时才知道的状态;完整目录可以在构建期做 key 集合对比,缺一条就报错。体积差异由资源包分层解决:基础 UI 文案随首包,其余语言包按需下载,但登录、错误提示与商店关键路径必须在首帧前可用。
回退链按「精确语言 → 父语言 → 英语」排序:zh-Hant → zh-Hans → en,en-GB → en,未知语言直接落到 en。回退必须留下遥测:哪个 key、请求语言、实际语言、调用位置。没有遥测的回退等于把问题藏起来,直到玩家截图。
缺失 key 的展示策略要分环境。开发与灰度包显示 ⟦key⟧,让测试一眼看见;正式包优先显示回退语言,仍没有则显示中性占位并上报,绝不显示空白按钮或原始 key。回退不是错误处理的终点,而是下一次翻译排期的输入。
文本膨胀与排版预算
同一句话在不同语言里的长度差异是结构性的。按钮、页签、红点旁的数字、商店价格行,必须按最坏语言留空间,而不是按英文设计完再逐语言补丁。
| 语言 | 规划系数 | 主要风险 | 排版动作 |
|---|---|---|---|
| 德文 | 1.3 | 复合词长、按钮溢出 | 按钮按 1.3 倍预留,最多两行 |
| 俄文 | 1.2 | 词形变化、大写变宽 | 允许 0.9 缩放,禁用截断关键动词 |
| 阿拉伯文 | 1.15 | 连写形态、方向翻转 | 独立字体与 bidi 隔离 |
| 英文 | 1.0 | 基准 | 作为宽度基线 |
| 日文 / 韩文 | 0.9 | 字重与行高 | 不因变短而缩小点击区 |
| 简体中文 | 0.85 | 信息密度高 | 不要用宽度反推字号 |
表中的系数是 mga-lab 的排版估算口径,用于设计预算,不是案例实测值。模型把文本宽度换算成 em:CJK 字符按 1.0,拉丁小写按 0.5,大写按 0.62,空格按 0.28。fitText 依次尝试单行容纳、缩小到最小 0.8 倍、换成两行、最后才截断并加省略号。截断只允许用于非关键信息,价格、按钮动词和错误原因不允许被省略。
上线前用伪本地化做回归:把英文逐字符替换成带重音的字符,并在两侧加括号、按 1.3 倍补点。任何被裁切、重叠或换行错位的界面都会在伪本地化下暴露,不需要等七种语言全部翻译完。
字体、RTL 与包体约束
字体是文本层里最容易被低估的包体成本。拉丁字体一个子集可能只有几百 KB,而一个覆盖常用汉字的 CJK 字重会大一个数量级;再乘上多套字重,首包很快被吃掉。方舟 350MB 的首包可以容纳一套按用字表裁剪的 CJK 字体,但不能容纳五套完整家族;消消 120MB 的首包更要把字体当成硬预算。
策略是按脚本选字体、按用字表子集化、按字重收敛。正文与强调各一套,标题复用正文粗体;CJK 用构建期扫描内容与配置表得到的字符集裁剪;阿拉伯文必须使用支持连写形态的字体,不能用拉丁字体兜底,否则字母会断成孤立形态。字体加载失败要有可观测的 fallback,而不是静默变成方块。
RTL 不只是把界面镜像。布局方向、列表起点、返回箭头、进度条方向需要镜像;数字、时间、价格、品牌标识和带方向含义的图标不镜像。玩家昵称混排中英文时,要用 Unicode 隔离符包住,避免 bidi 算法把冒号、括号搬到错误的一侧。不要在代码里手工反转字符串,反转会破坏阿拉伯文的连写与数字顺序。
可运行模型:catalog.ts
下面的模型把 key 校验、回退链、参数填充、复数选择、宽度估算、伪本地化、排版决策与字体预算放在同一个文件里。语言包由调用方注入,模型不依赖真实翻译文件。
// file: src/i18n/catalog.ts
// tier: L1
// verify: node --test test/i18n/catalog.test.ts
export type Script = "latin" | "cjk" | "arabic" | "cyrillic" | "other";
export type Direction = "ltr" | "rtl";
export interface LocaleMeta {
readonly code: string; readonly language: string; readonly script: Script;
readonly direction: Direction; readonly fallback: readonly string[];
readonly fontFamily: string; readonly fontSubset: string;
}
export const LOCALES: Readonly<Record<string, LocaleMeta>> = {
en: { code: "en", language: "en", script: "latin", direction: "ltr", fallback: [], fontFamily: "Noto Sans", fontSubset: "latin" },
de: { code: "de", language: "de", script: "latin", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans", fontSubset: "latin" },
es: { code: "es", language: "es", script: "latin", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans", fontSubset: "latin" },
ru: { code: "ru", language: "ru", script: "cyrillic", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans", fontSubset: "cyrillic" },
"zh-Hans": { code: "zh-Hans", language: "zh", script: "cjk", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans SC", fontSubset: "cjk-common" },
"zh-Hant": { code: "zh-Hant", language: "zh", script: "cjk", direction: "ltr", fallback: ["zh-Hans", "en"], fontFamily: "Noto Sans TC", fontSubset: "cjk-common" },
ja: { code: "ja", language: "ja", script: "cjk", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans JP", fontSubset: "cjk-common" },
ko: { code: "ko", language: "ko", script: "cjk", direction: "ltr", fallback: ["en"], fontFamily: "Noto Sans KR", fontSubset: "cjk-common" },
ar: { code: "ar", language: "ar", script: "arabic", direction: "rtl", fallback: ["en"], fontFamily: "Noto Naskh Arabic", fontSubset: "arabic-shaping" },
};
export function fallbackChain(locale: string): readonly string[] {
const meta = LOCALES[locale];
if (meta === undefined) {
return ["en"];
}
const chain: string[] = [locale];
for (const candidate of meta.fallback) {
if (!chain.includes(candidate)) {
chain.push(candidate);
}
}
return chain;
}
export interface Message {
readonly text: string;
readonly plural?: Readonly<Partial<Record<Intl.LDMLPluralRule, string>>>;
}
export interface LocaleCatalog {
readonly locale: string;
readonly messages: Readonly<Record<string, Message>>;
}
export type MessageParam = string | number;
export type IssueKind = "missing-key" | "missing-param" | "bad-plural";
export interface ResolveIssue {
readonly key: string; readonly kind: IssueKind;
readonly locale: string; readonly detail: string;
}
export interface ResolvedMessage {
readonly text: string; readonly locale: string; readonly requestedLocale: string;
readonly fallbackUsed: boolean; readonly direction: Direction;
readonly issues: readonly ResolveIssue[];
}
const PLURAL_RULES = new Map<string, Intl.PluralRules>();
export function pluralCategory(locale: string, count: number): Intl.LDMLPluralRule {
let rules = PLURAL_RULES.get(locale);
if (rules === undefined) {
rules = new Intl.PluralRules(locale);
PLURAL_RULES.set(locale, rules);
}
return rules.select(count);
}
function fillParams(
template: string, params: Readonly<Record<string, MessageParam>>,
key: string, locale: string, issues: ResolveIssue[],
): string {
return template.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (_match, name: string) => {
const value = params[name];
if (value === undefined) {
issues.push({ key, kind: "missing-param", locale, detail: name });
return `{${name}}`;
}
return String(value);
});
}
export function resolveMessage(
catalogs: Readonly<Record<string, LocaleCatalog>>, key: string,
params: Readonly<Record<string, MessageParam>> = {}, requestedLocale = "en",
): ResolvedMessage {
const meta = LOCALES[requestedLocale] ?? LOCALES.en;
const chain = fallbackChain(requestedLocale);
const issues: ResolveIssue[] = [];
let sourceLocale = requestedLocale;
let message: Message | undefined;
for (const locale of chain) {
message = catalogs[locale]?.messages[key];
if (message !== undefined) {
sourceLocale = locale;
break;
}
}
if (message === undefined) {
issues.push({ key, kind: "missing-key", locale: requestedLocale, detail: chain.join(" -> ") });
return { text: `⟦${key}⟧`, locale: sourceLocale, requestedLocale, fallbackUsed: sourceLocale !== requestedLocale, direction: meta.direction, issues };
}
let template = message.text;
if (message.plural !== undefined && typeof params.count === "number") {
const category = pluralCategory(sourceLocale, params.count);
const selected = message.plural[category] ?? message.plural.other;
if (selected === undefined) {
issues.push({ key, kind: "bad-plural", locale: sourceLocale, detail: category });
} else {
template = selected;
}
}
return {
text: fillParams(template, params, key, sourceLocale, issues), locale: sourceLocale,
requestedLocale, fallbackUsed: sourceLocale !== requestedLocale,
direction: meta.direction, issues,
};
}
export const EXPANSION_FACTORS: Readonly<Record<string, number>> = {
de: 1.3, ru: 1.2, fr: 1.2, ar: 1.15, en: 1, ko: 0.9, ja: 0.9, "zh-Hans": 0.85, "zh-Hant": 0.85,
};
export interface FitOptions { readonly maxLines?: number; readonly minScale?: number; readonly ellipsis?: string }
export interface FitResult {
readonly text: string; readonly mode: "fits" | "shrink" | "wrap" | "truncate";
readonly scale: number; readonly maxLines: number;
readonly widthEm: number; readonly overflowEm: number;
}
export function estimateWidthEm(text: string): number {
let width = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (char === " ") width += 0.28;
else if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xac00 && code <= 0xd7af) || (code >= 0xf900 && code <= 0xfaff)) width += 1;
else if (code >= 0x0600 && code <= 0x06ff) width += 0.5;
else if (code >= 0x0400 && code <= 0x04ff) width += 0.55;
else if (code >= 0x30 && code <= 0x39) width += 0.56;
else if (code >= 0x41 && code <= 0x5a) width += 0.62;
else if (code >= 0x61 && code <= 0x7a) width += 0.5;
else if (code >= 0x21 && code <= 0x2f) width += 0.32;
else width += 0.9;
}
return width;
}
export function fitText(text: string, budgetEm: number, options: FitOptions = {}): FitResult {
const maxLines = options.maxLines ?? 1;
const minScale = options.minScale ?? 0.8;
const ellipsis = options.ellipsis ?? "…";
if (budgetEm <= 0 || maxLines < 1) throw new RangeError(`budgetEm and maxLines must be positive: ${budgetEm}, ${maxLines}`);
const width = estimateWidthEm(text);
const capacity = budgetEm * maxLines;
if (width <= budgetEm) return { text, mode: "fits", scale: 1, maxLines, widthEm: width, overflowEm: 0 };
if (width <= capacity) return { text, mode: maxLines > 1 ? "wrap" : "shrink", scale: 1, maxLines, widthEm: width, overflowEm: 0 };
const requiredScale = capacity / width;
if (requiredScale >= minScale) return { text, mode: maxLines > 1 ? "wrap" : "shrink", scale: requiredScale, maxLines, widthEm: width, overflowEm: 0 };
const visibleBudget = capacity * minScale - estimateWidthEm(ellipsis);
let kept = "";
let used = 0;
for (const char of text) {
const next = used + estimateWidthEm(char);
if (next > visibleBudget) break;
kept += char;
used = next;
}
return { text: `${kept}${ellipsis}`, mode: "truncate", scale: minScale, maxLines, widthEm: width, overflowEm: width - capacity * minScale };
}
export function pseudoLocalize(text: string, factor = 1.3): string {
const accents: Readonly<Record<string, string>> = { a: "á", e: "é", i: "í", o: "ó", u: "ú", n: "ñ", c: "ç", s: "š", y: "ý" };
const translated = Array.from(text, (char) => accents[char] ?? char).join("");
const target = Math.ceil(Array.from(text).length * factor);
return `⟦${translated}${"·".repeat(Math.max(0, target - Array.from(text).length))}⟧`;
}
const KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){1,}$/;
const PLACEHOLDER_PATTERN = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
function placeholders(text: string): readonly string[] {
const found: string[] = [];
for (const match of text.matchAll(PLACEHOLDER_PATTERN)) {
if (!found.includes(match[1])) found.push(match[1]);
}
return found.sort();
}
export type CatalogIssueKind = "unknown-locale" | "bad-key" | "empty-text" | "plural-without-other" | "placeholder-mismatch";
export interface CatalogIssue {
readonly locale: string; readonly key: string;
readonly kind: CatalogIssueKind; readonly detail: string;
}
export function validateCatalog(base: LocaleCatalog, catalogs: readonly LocaleCatalog[]): readonly CatalogIssue[] {
const issues: CatalogIssue[] = [];
for (const catalog of catalogs) {
if (LOCALES[catalog.locale] === undefined) {
issues.push({ locale: catalog.locale, key: "*", kind: "unknown-locale", detail: catalog.locale });
continue;
}
for (const [key, message] of Object.entries(catalog.messages)) {
if (!KEY_PATTERN.test(key)) issues.push({ locale: catalog.locale, key, kind: "bad-key", detail: key });
if (message.text.trim().length === 0) issues.push({ locale: catalog.locale, key, kind: "empty-text", detail: key });
if (message.plural !== undefined && message.plural.other === undefined) {
issues.push({ locale: catalog.locale, key, kind: "plural-without-other", detail: key });
}
const source = base.messages[key];
if (source !== undefined) {
const expected = placeholders(source.text).join(",");
const actual = placeholders(message.text).join(",");
if (expected !== actual) issues.push({ locale: catalog.locale, key, kind: "placeholder-mismatch", detail: `${expected} != ${actual}` });
}
}
}
return issues;
}
export interface FontAsset {
readonly family: string; readonly script: Script;
readonly weight: 400 | 700; readonly bytes: number; readonly subset: string;
}
export interface FontPlanInput {
readonly locales: readonly string[]; readonly assets: readonly FontAsset[]; readonly budgetBytes: number;
}
export interface FontPlan {
readonly families: readonly string[]; readonly assets: readonly FontAsset[];
readonly totalBytes: number; readonly budgetBytes: number;
readonly overBudget: boolean; readonly missingScripts: readonly Script[];
}
export function planFonts(input: FontPlanInput): FontPlan {
const needed = new Set<Script>();
for (const locale of input.locales) {
const meta = LOCALES[locale];
if (meta !== undefined) needed.add(meta.script);
}
const selected: FontAsset[] = [];
const families: string[] = [];
const missingScripts: Script[] = [];
for (const script of needed) {
const asset = input.assets.find((candidate) => candidate.script === script && candidate.weight === 400);
if (asset === undefined) {
missingScripts.push(script);
continue;
}
selected.push(asset);
if (!families.includes(asset.family)) families.push(asset.family);
}
const totalBytes = selected.reduce((sum, asset) => sum + asset.bytes, 0);
return { families, assets: selected, totalBytes, budgetBytes: input.budgetBytes, overBudget: totalBytes > input.budgetBytes, missingScripts };
}
配套测试 test/i18n/catalog.test.ts 覆盖 13 条路径:回退链、缺 key 可见、缺参数上报、复数选择、RTL 方向、CJK 宽度、四种排版模式、伪本地化、目录结构校验与字体计划的缺失脚本。运行命令是 node --test test/i18n/catalog.test.ts。
模型只做排版估算,不测量真实字形宽度、行高与字体 shaping 结果。真机上的阿拉伯文连写、系统字体回退与低端机字体栅格化仍要进入兼容性矩阵;这里是模型,不是设备实测。
双案例:350MB 与 120MB 的首包差异
方舟 350MB 首包、七种语言,重点是「全」:key 校验、回退遥测、字体子集与 RTL 回归都要在首发前闭环,任何一个语言缺 key 都会直接出现在商店主路径。
消消 120MB 首包、语言较少,重点是「省」:字体只保留实际用字,语言包按活动按需下载,但 key 设计与回退规则不能因为包小就省掉。
与相邻章节的关系
key 设计的通用原则可以参考国际化资源 key 设计 ,日期、数字、货币与 RTL 的显示细节可以参考日期、数字、货币与 RTL ,字体与语言包作为资源包管理可以参考资源句柄与生命周期 。本章 11.2 处理时间,11.3 处理区域与内容分级,三者共同决定「同一个界面在不同地区长什么样」。
小结
- key 是稳定接口,按语义分层,不抄文案、不带日期与版本号。
- 语言包按完整目录加载,回退链固定为精确语言、父语言、英语,并记录遥测。
- 缺失 key 必须可见或可回退,不能静默成空白,也不能把原始 key 直接给玩家。
- 排版按最坏语言预留空间,用伪本地化在翻译前暴露溢出与截断。
- 字体按脚本与用字表子集化,阿拉伯文必须使用支持连写的字体,RTL 只镜像该镜像的内容。
阅读导航:上一节:10.3 防篡改与回滚 · 下一节:11.2 时区、服务器时间与本地时间 。
常见错误与边界
- 用中文或英文原文当 key。文案一改 key 就变,历史埋点与客服话术全部断链;key 必须是语义标识。
- 把句子拆成片段再拼接。词序、格变化和标点都会出问题;变量只能进整句占位符。
- 只测英文就发布。英文最短,最容易通过;伪本地化和最长语言才是排版回归的基线。
- 缺 key 时返回空字符串。空白按钮比错误文案更难定位,至少要有占位与遥测。
- 一个字体家族覆盖所有脚本。阿拉伯文会失去连写,CJK 会落到系统字体;按脚本声明字体链。
- 手工反转 RTL 字符串。反转会破坏数字、括号与连写;使用布局镜像和 bidi 隔离符。
- 等首帧渲染完再加载语言包。登录页会先闪英文再变中文;关键路径文案必须首帧可用。
- 边界:本节不测量真实字形宽度与系统字体回退。
estimateWidthEm是设计预算模型,最终排版要在目标机型与目标字体上验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。