Repository Wiki
BeyondDimension/SteamTools

本地化与多语言(Crowdin)

本页介绍 SteamTools 客户端 UI 本地化子系统的完整机制:基于 Crowdin 平台的翻译同步流水线(由仓库根目录的 crowdin.yml 定义),基于 .NET ResX 资源文件的字符串存储模型(Strings.resx / Strings.xx.resx / Strings.Designer.cs),以及运行时按 CultureInfo 动态分派语言的文化匹配层(src/BD.WTTS.Client.AppHost/Strings.cs)。

Purpose and Scope

本页覆盖"从翻译协作平台到运行时字符串输出"的端到端链路,具体包括:

  • Crowdin 同步配置:crowdin.yml 如何声明源文件、翻译文件命名规则(%two_letters_code%)以及需要忽略的资源目录内代码文件。
  • ResX 资源模型:src/BD.WTTS.Client/Resources/ 下的 Strings.resx(默认英文源)与各语言卫星资源文件,以及由设计器生成的强类型 Strings.Designer.cs 访问器。
  • 运行时文化匹配:AppHost 进程内的静态 Strings 辅助类如何把任意 CultureInfo(含父级文化回退)映射到 8 种受支持的 Language 枚举值,并用 lambda switch 内联返回本地化字符串。

不属于本页范围、留给同级页面的内容:具体某个插件的业务字符串维护流程、更新程序(Plugins.Update)自身的输出格式化逻辑、以及设置页中语言切换 UI 的交互实现。这些属于各自的插件/设置页主题。

Overview

该子系统的目标是让一个多平台桌面客户端(Windows/macOS/Linux 桌面壳 + .NET 运行时组件)在 8 种语言下正确显示界面字符串,同时把翻译工作外包给社区翻译平台 Crowdin。整体设计分为三个层次:

  1. 翻译协作层(Crowdin):仓库根目录的 crowdin.yml 把 src/BD.WTTS.Client/Resources/Strings.resx 声明为唯一源文件(source of truth),Crowdin 平台据此上传源串、下发译文,并把各语言译文写回 Strings.%two_letters_code%.resx。ignore: '*.cs' 确保资源目录中的 C# 代码(如设计器文件)不会被当作可翻译内容上传。
  2. 资源存储层(ResX):默认资源 Strings.resx 承载源语言字符串;Strings.zh.resx、Strings.ja.resx 等按 .NET 标准的"基础名称 + 文化 + .resx"约定构成卫星资源。Strings.Designer.cs 是 Visual Studio 生成器产出的强类型包装,内部使用 ResourceManager 并遵循 CurrentUICulture 查找链。
  3. 运行时分派层(AppHost Strings):桌面壳(AppHost)中存在一批与 UI 框架解耦的字符串(运行时缺失提示、通用连词"and"等),它们不走 ResX,而是以 Func<Language, string> switch 表达式直接内嵌多语言文本,由静态 GetString 按当前文化选择分支。这一层服务于 AppHost 在极早期(如引导安装 .NET 运行时之前)就需要的字符串,避免依赖资源文件加载。

关键概念:

  • Language 枚举(8 值):English、Spanish、Italian、Japanese、Korean、Russian、ChineseSimplified、ChineseTraditional。
  • 文化匹配不是精确相等,而是沿父级文化链递归回退(zh-CN → zh → 中性文化),因此区域变体会命中对应的语言分支。
  • 未匹配任何受支持文化时,一律回退到 English。

Architecture

Loading diagram...

架构说明:

  • 左上(翻译协作层):crowdin.yml 是 Crowdin CLI 与平台的唯一契约文件。它声明"哪个文件是源、译文写到哪里、资源目录里哪些文件不要碰"。%two_letters_code% 是 Crowdin 的占位符,会被替换为目标语言的 ISO 639-1 双字母码(如 zh、ja、es),正好对齐 .NET 卫星资源命名约定。
  • 中部(资源存储层):Strings.resx 与各语言 .resx 构成标准的 .NET 回退链(具体文化 → 中性文化 → 默认资源)。Strings.Designer.cs 提供编译期强类型属性,其文档注释明确说明它通过重写 CurrentUICulture 来影响"使用此强类型资源类的所有资源查找"。
  • 右下(运行时分派层):AppHost 的静态 Strings 类不依赖 ResourceManager,而是把每种字符串写成一个 switch 表达式属性。GetString(Func<Language, string>, CultureInfo?) 先把传入文化归一到 8 种 Language 之一,再把枚举交给调用方提供的 lambda。这种"枚举 + lambda"的形状让字符串定义与语言分派解耦:新增字符串只需写一个 switch,不需要新增文件或修改分派逻辑。
  • 为什么会有两套机制并存:ResX + Designer 覆盖主体 UI(量大、由 Crowdin 批量翻译),而 AppHost 内嵌 switch 覆盖引导期/系统级弹窗(如"此应用程序必须安装 {0} 才能运行"这类在依赖资源加载之前就要显示的字符串),并兼容 #if NET35 || NET40 的老目标框架条件编译(见 Strings.cs 中的 MethodImpl((MethodImplOptions)0x100) 分支)。

Main Content

1. Crowdin 同步配置(crowdin.yml)

仓库根目录的 crowdin.yml 只有 4 行有效配置,但完整定义了翻译流水线:

yaml
1files: 2 - source: /src/BD.WTTS.Client/Resources/Strings.resx 3 ignore: 4 - '*.cs' 5 translation: /src/BD.WTTS.Client/Resources/Strings.%two_letters_code%.resx

Source: crowdin.yml

逐行解读:

配置项值作用
files[].source/src/BD.WTTS.Client/Resources/Strings.resx唯一的翻译源文件(源语言基准)。所有待翻译键值对都从这里读取。
files[].ignore'*.cs'排除资源目录中的 C# 文件。资源目录里还有 Strings.Designer.cs,如果不排除,设计器代码会被当成可翻译文本上传,污染翻译记忆库。
files[].translation/src/BD.WTTS.Client/Resources/Strings.%two_letters_code%.resx译文写回规则。%two_letters_code% 是 Crowdin 占位符,替换为目标语言 ISO 639-1 双字母码。

设计意图(WHY):%two_letters_code% 生成的是中性文化文件名(Strings.zh.resx 而非 Strings.zh-CN.resx)。这配合运行时 IsMatch 的父级文化递归回退:无论是 zh-CN、zh-Hans 还是 zh-SG,都会沿父链回退到 zh 命中同一份资源。这样只需维护每语言一份文件,而不需要维护每个区域变体,显著降低翻译与文件管理成本。

2. 运行时文化匹配(AppHost Strings 类)

src/BD.WTTS.Client.AppHost/Strings.cs 是桌面壳内的本地化核心。三个关键成员:

csharp
1public static CultureInfo Culture 2{ 3 get 4 { 5 return resourceCulture ?? CultureInfo.CurrentUICulture; 6 } 7 set 8 { 9 resourceCulture = value; 10 } 11}

Source: Strings.cs

Culture 是一个可全局覆盖的当前文化访问点:默认回落到线程/进程级的 CultureInfo.CurrentUICulture,但可以在启动早期被显式设置为用户在设置页选择的语言。私有字段 resourceCulture 为 CultureInfo?,null 表示"尚未覆盖、跟随系统"。

csharp
1public static bool IsMatch(this CultureInfo cultureInfo, string cultureName) 2{ 3 if ( 4#if NET35 5 Program 6#else 7 string 8#endif 9 .IsNullOrWhiteSpace(cultureInfo.Name)) 10 { 11 return false; 12 } 13 if (string.Equals(cultureInfo.Name, cultureName, StringComparison.OrdinalIgnoreCase)) 14 { 15 return true; 16 } 17 else 18 { 19 return cultureInfo.Parent.IsMatch(cultureName); 20 } 21}

Source: Strings.cs

这是本子系统最核心的算法:递归父级文化匹配。

  • 入参 cultureInfo 是待判定的运行时文化(可能是 zh-Hans-CN、en-GB 等),cultureName 是受支持的文化名(如 AssemblyInfo.CultureName_SimplifiedChinese)。
  • 若当前文化名为空白(如固定区域性/不变文化 Invariant Culture 的 Name 为空串),直接返回 false,防止无限递归并让调用方落到默认英文分支。
  • 若忽略大小写完全相等则命中;否则取 cultureInfo.Parent(去掉区域后缀的父文化)继续匹配,直到命中或 Name 变空返回 false。
  • 注意 #if NET35 分支使用 Program.IsNullOrWhiteSpace,因为 .NET Framework 3.5 时代 string.IsNullOrWhiteSpace 尚不存在——这是该文件同时面向老目标框架编译的证据,也是 MethodImpl((MethodImplOptions)0x100)(AggressiveInlining 的数值形式)条件编译存在的原因。
csharp
1public static string GetString(Func<Language, string> getString, CultureInfo? resourceCulture = null) 2{ 3 resourceCulture ??= Culture; 4 if (resourceCulture.IsMatch(AssemblyInfo.CultureName_SimplifiedChinese)) 5 return getString(Language.ChineseSimplified); 6 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_TraditionalChinese)) 7 return getString(Language.ChineseTraditional); 8 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_Spanish)) 9 return getString(Language.Spanish); 10 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_Italian)) 11 return getString(Language.Italian); 12 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_Japanese)) 13 return getString(Language.Japanese); 14 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_Korean)) 15 return getString(Language.Korean); 16 else if (resourceCulture.IsMatch(AssemblyInfo.CultureName_Russian)) 17 return getString(Language.Russian); 18 else 19 return getString(Language.English); 20}

Source: Strings.cs

GetString 的职责被刻意压缩为一件事:把任意 CultureInfo 归一到 Language 枚举,然后回调调用方的取值 lambda。它不做字符串拼接、不做格式化、不做缓存。调用方以 switch 表达式提供映射:

csharp
1public static string And => GetString(l => l switch 2{ 3 Language.ChineseSimplified or Language.ChineseTraditional => "和", 4 Language.Spanish => "y", 5 Language.Italian => "e", 6 Language.Japanese => "と", 7 Language.Korean => "및", 8 Language.Russian => "и", 9 _ => "and", 10});

Source: Strings.cs

设计意图(WHY):分派逻辑与字符串内容彻底分离。GetString 内的 if-else 链是全进程唯一一处"文化 → 语言"的归一化点;每个字符串属性只描述"每种语言下该显示什么"。新增字符串时不需要触碰分派代码,新增语言时只需要在枚举、if-else 链与各 switch 的 _ 之外补分支(编译器会对非穷尽 switch 报警,天然防止漏翻)。

3. 语言枚举与格式化占位符约定

csharp
1public enum Language : byte 2{ 3 English, 4 Spanish, 5 Italian, 6 Japanese, 7 Korean, 8 Russian, 9 ChineseSimplified, 10 ChineseTraditional, 11}

Source: Strings.cs

枚举底层类型显式声明为 byte(8 个值足够),隐含"受支持语言集合是封闭小集合"的设计约束。带占位符的字符串通过 string.Format 风格的 {0} 表达,且占位符在各语言分支中位置一致:

csharp
1public static string AspNetCoreRuntimeFormat1 => GetString(l => l switch 2{ 3 Language.ChineseSimplified => $"ASP.NET Core 运行时 {Program.dotnet_version} ({{0}})", 4 Language.ChineseTraditional => $"ASP.NET Core 運行時 {Program.dotnet_version} ({{0}})", 5 Language.Japanese => $"ASP.NET Core ランタイム {Program.dotnet_version} ({{0}})", 6 _ => $"ASP.NET Core Runtime {Program.dotnet_version} ({{0}})", 7});

Source: Strings.cs

注意这里用插值字符串生成"含 {{0}} 转义占位符"的结果:版本号 Program.dotnet_version 在编译期/加载期就嵌入,而 {{0}} 保留给后续 string.Format 填充具体目标(如位数 x64/x86)。属性名后缀 Format1 表明该字符串含 1 个格式化占位符,这是仓库内的命名约定,便于调用方判断是否需要传参。

GetLang() 则反向把语言归一化为少数几个 UI 文化代码(仅 3 个分支,其余全部映射 en-us),供需要语言代码而非显示文本的下游 API 使用:

csharp
1public static string GetLang() => GetString(l => l switch 2{ 3 Language.ChineseSimplified => "zh-cn", 4 Language.Japanese => "ja-jp", 5 _ => "en-us", 6});

Source: Strings.cs

4. ResX 强类型资源访问器

主 UI 字符串经由 src/BD.WTTS.Client/Resources/Strings.Designer.cs 暴露。该文件由 Visual Studio 单文件生成器产出,其类注释明确描述了文化覆盖机制:

/// 重写当前线程的 CurrentUICulture 属性,对 /// 使用此强类型资源类的所有资源查找执行重写。

Source: Strings.Designer.cs

即:设计器内部持有静态 ResourceManager(指向 Strings.resx 基础名),每次属性读取都会按 .NET 标准回退链查找——先找具体文化卫星资源,再找中性文化,最后落到默认 Strings.resx。这与 Crowdin 写回的 Strings.%two_letters_code%.resx 中性文化命名精确对齐:zh-CN 用户最终读到的就是 Strings.zh.resx。

Core Flow

Loading diagram...

流程解读:

  1. 翻译侧(离线):源串改动 → Crowdin 上传 → 译者翻译 → 译文按 %two_letters_code% 写回仓库 → 编译期生成卫星资源。ignore: '*.cs' 保证设计器文件不会进入翻译流程。
  2. 运行时(在线):读取字符串属性 → GetString 解析当前文化(覆盖值优先,否则 CurrentUICulture)→ IsMatch 沿父级文化链逐一比对 8 个受支持文化名 → 命中后把对应 Language 枚举传给 lambda → switch 表达式返回文本 → UI 显示。整条链路无 I/O、无锁、无缓存失效问题,纯内存计算。

Data Model(资源文件关系)

Loading diagram...

资源模型要点:每个 .resx 都是同构的键值表(key → value),各语言文件之间靠相同的 key 对齐;Crowdin 以默认资源 Strings.resx 的 value 为翻译源,把译文写入同 key 的语言文件。Language 枚举与卫星文件并非一一对应(繁体在枚举中有独立值,其译文同样经由 %two_letters_code% 规则落盘),最终归一化由 GetString 的 if-else 链完成。

Usage Examples

示例 1:新增一条 AppHost 内嵌多语言字符串

在 src/BD.WTTS.Client.AppHost/Strings.cs 中新增字符串属性,只需提供 Language → 文本 的 switch 表达式,语言分派由 GetString 统一完成:

csharp
1public static string FrameworkMissingFailureFormat1 => GetString(l => l switch 2{ 3 Language.ChineseSimplified => "此应用程序必须安装 {0} 才能运行,你想现在就下载并安装运行时吗?", 4 // ...其余语言分支 5});

Source: Strings.cs

属性名以 Format1 结尾(含 1 个 {0} 占位符),调用方用 string.Format(Strings.FrameworkMissingFailureFormat1, version) 填充。

示例 2:获取当前语言的短代码(供外部 API 使用)

csharp
1public static string GetLang() => GetString(l => l switch 2{ 3 Language.ChineseSimplified => "zh-cn", 4 Language.Japanese => "ja-jp", 5 _ => "en-us", 6});

Source: Strings.cs

注意这是有意的"粗粒度"映射:所有西语/意语/俄语等统一折叠到 en-us,只有需要本地化内容的下游(如帮助页跳转)才细分。

示例 3:获取带版本插值的运行时名称

csharp
1public static string NetRuntimeFormat1 => GetString(l => l switch 2{ 3 Language.ChineseSimplified => $".NET 运行时 {Program.dotnet_version} ({{0}})", 4 Language.ChineseTraditional => $".NET 運行時 {Program.dotnet_version} ({{0}})", 5 Language.Japanese => $".NET ランタイム {Program.dotnet_version} ({{0}})", 6 _ => $".NET Runtime {Program.dotnet_version} ({{0}})", 7});

Source: Strings.cs

示例 4:强制指定文化获取字符串

csharp
1public static string GetString(Func<Language, string> getString, CultureInfo? resourceCulture = null) 2{ 3 resourceCulture ??= Culture; 4 ... 5}

Source: Strings.cs

resourceCulture 参数允许一次性按任意文化取值(例如在语言预览界面显示"该语言下这条文本长什么样"),不影响全局 Culture 状态;不传时回落到全局覆盖值或系统 CurrentUICulture。

Configuration Options

配置项类型默认值说明
crowdin.yml → files[].sourcestring/src/BD.WTTS.Client/Resources/Strings.resx翻译源文件,唯一 source of truth
crowdin.yml → files[].ignorestring[]['*.cs']上传 Crowdin 时排除的文件模式,保护 Strings.Designer.cs 不被当作翻译文本
crowdin.yml → files[].translationstring/src/BD.WTTS.Client/Resources/Strings.%two_letters_code%.resx译文写回路径模板,%two_letters_code% 替换为 ISO 639-1 双字母码
Strings.CultureCultureInfo?null(回落 CultureInfo.CurrentUICulture)AppHost 全局语言覆盖点,由设置页语言切换写入
GetString(..., resourceCulture) 参数CultureInfo?null(回落 Strings.Culture)单次调用的临时文化覆盖

API Reference

Strings.GetString(Func<Language, string> getString, CultureInfo? resourceCulture = null): string

按当前文化返回本地化字符串。

Parameters:

  • getString (Func<Language, string>):以 Language 枚举为输入、返回该语言文本的取值委托,通常是 switch 表达式。
  • resourceCulture (CultureInfo?, 可选):本次调用使用的文化;不传则使用 Strings.Culture(再回落 CurrentUICulture)。

Returns: 命中语言对应的字符串;任何无法匹配受支持文化的情况(含空 Name 的不变文化)均返回英文分支结果。

Throws: 源码中未见显式抛出。当传入委托本身为 null 时会抛出 NullReferenceException(属常规委托调用行为,非本方法特有防护)。

Strings.IsMatch(this CultureInfo cultureInfo, string cultureName): bool

判断给定文化是否(含父级链)匹配指定文化名。

Parameters:

  • cultureInfo (CultureInfo):待判定文化。
  • cultureName (string):目标文化名,如 AssemblyInfo.CultureName_SimplifiedChinese。

Returns: 完全匹配(忽略大小写)或沿 Parent 链递归命中返回 true;Name 为空白或链尽头未命中返回 false。标记了 AggressiveInlining 以降低高频调用的开销。

Strings.Culture: CultureInfo(静态属性)

Getter: 返回 resourceCulture ?? CultureInfo.CurrentUICulture。 Setter: 覆盖全局当前文化;传 null 即恢复"跟随系统"。

Strings.GetLang(): string

Returns: 归一化的语言短代码:简中 zh-cn、日语 ja-jp、其余 en-us。

Strings.resx(ResX 侧)

经 Strings.Designer.cs 强类型属性访问,属性读取走 ResourceManager 标准回退链:具体文化 → 中性文化 → 默认 Strings.resx。类注释明确支持通过重写 CurrentUICulture 影响该类的所有资源查找。

Source: Strings.Designer.cs

Failure Modes, Edge Cases & Concurrency

  • 未支持语言回退:GetString 的 if-else 链最后一支无条件 return getString(Language.English)。任何未列出的文化(如德语 de)都会得到英文文本,绝不会抛出"未找到资源"异常——这是刻意选择的静默降级策略。
  • 不变文化 / 空 Name:IsMatch 首行检查 IsNullOrWhiteSpace(cultureInfo.Name),对不变文化(Name 为空串)直接返回 false,既避免对空串的无效递归,也终结了 Parent 链(根父级即自身时 Name 为空)防止无限递归。
  • 区域变体折叠:zh-Hans-CN、zh-SG、zh-MO 等都会经 Parent 链命中简中分支;繁体判定顺序在 if-else 链中位于简体之后,实际匹配顺序为:简中 → 繁中 → 西 → 意 → 日 → 韩 → 俄 → 英。
  • 大小写不敏感:string.Equals(..., StringComparison.OrdinalIgnoreCase) 使 ZH-CN 与 zh-CN 等价,降低因系统区域设置大小写差异导致的漏配。
  • 并发性:Strings 为纯静态无状态(仅一个静态 resourceCulture 字段),GetString/IsMatch 全程只读局部计算、无 I/O、无锁。静态 Culture 属性的写入是普通的引用赋值,读侧可能读到旧值(最终一致),对 UI 字符串场景可接受;该类未实现线程安全的切换通知——语言切换后已渲染的字符串不会自动刷新,需要由上层 UI 重建视图。
  • ResX 侧缺失键:.NET 资源回退链保证当某语言文件缺 key 时回退到默认 Strings.resx(英文),同样不会抛 MissingManifestResourceException 之外的运行时异常(该异常只在整组卫星资源彻底缺失时出现)。

Performance & Operational Notes / Extension Points

  • 热路径零开销设计:GetString 与 IsMatch 均标注 MethodImpl(MethodImplOptions.AggressiveInlining)(老目标框架下用数值 (MethodImplOptions)0x100 等价表达),配合 8 分支线性 if-else(枚举只有 8 值,线性比较足够快),使每条字符串读取都是纳秒级、无分配(除返回字符串本身)的纯内存操作。
  • 翻译运营流程:源串变更只需改 Strings.resx + 重新上传 Crowdin;译文回写由 Crowdin CLI 按 translation 模板落盘,ignore: '*.cs' 是防止设计器代码进入翻译流水线的运营护栏。每次译文合入即触发 CI 重建卫星资源并随发布分发。
  • 新增语言的扩展点(3 处固定改动):
    1. 在 Language 枚举追加值(Strings.cs L19-L29);
    2. 在 GetString 的 if-else 链中插入对应 CultureName_* 判定(保持与 AssemblyInfo 常量对齐);
    3. 在所有内嵌字符串 switch 的 _ 之外补分支——switch 表达式非穷尽时编译器会报错,等价于编译期强制补翻。
    4. Crowdin 侧由 %two_letters_code% 自动生成新语言文件,仓库无需手工建文件。
  • AppHost 内嵌字符串的边界:这套 switch 字符串只覆盖 AppHost 引导/系统级场景;主 UI 大批量字符串请继续走 Strings.resx + Crowdin,避免在代码里手写多语言 switch 造成维护负担。
  • crowdin.yml — Crowdin 同步契约(source / ignore / translation)
  • Strings.cs (AppHost) — 运行时文化匹配与内嵌多语言字符串
  • Strings.Designer.cs — ResX 强类型资源访问器
  • Strings.resx — 源语言资源文件(Crowdin source of truth)
  • 关于插件业务字符串与设置页语言切换 UI 的实现,见各自对应的插件 / 设置页目录页。

Sources

(2 files)
src/BD.WTTS.Client.AppHost