《移动游戏产品的架构·客户端与移动运行时》11.1 文本、字体与排版

《潮汐方舟》七语言文本层的事故复盘:key 按语义设计而不是抄文案,语言包按完整目录加载并沿 zh-Hant→zh-Hans→en 回退,排版按 1.3 倍膨胀预留空间,字体按脚本子集化。给出 key 校验、回退链、宽度估算、RTL 与字体预算的可运行模型。

事故:活动弹窗里露出了 key

《潮汐方舟》案例设定首包 350MB、总资源 1.8GB,首发覆盖简体中文、繁体中文、日文、韩文、英文、德文与阿拉伯文。一次版本活动把新增礼包按钮的文案只写进了英文语言包,日文与阿拉伯文客户端没有对应 key。按钮最终显示成 shop.bundle.limit_reached,玩家截图在社区里传播;更麻烦的是,这个 key 由三段字符串拼接而成,日志只能看到整串,无法判断缺的是哪个语言、哪个参数。

第二次事故更隐蔽:德文翻译上线后按钮文字溢出,被 overflow: hidden 直接裁掉,玩家看到的是「Kaufen Sie je…」。两次事故指向同一组问题:key 怎么设计、语言包怎么加载与回退、文本膨胀怎么留空间、字体与排版怎么在包体和脚本方向之间取舍。 这四件事必须一起定,缺一件就会在某个语言上破。

key 是接口,不是文案索引

key 一旦发布就进入客户端、配置、埋点与客服工具,改名的成本远高于改文案。设计原则是语义稳定、层级可读、与英文原文解耦。

反例正例判断
btn_okcommon.action.confirm按动作命名,不绑定按钮位置
text_20261105_newshop.bundle.limit_reached日期与版本号不进 key
shop_bundle_limit_reached_v2shop.bundle.limit_reached版本由语言包版本承担
欢迎回来home.welcome.back中文不能当稳定标识
msg_1battle.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 是设计预算模型,最终排版要在目标机型与目标字体上验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查