本地化与多语言(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。整体设计分为三个层次:
- 翻译协作层(Crowdin):仓库根目录的
crowdin.yml把src/BD.WTTS.Client/Resources/Strings.resx声明为唯一源文件(source of truth),Crowdin 平台据此上传源串、下发译文,并把各语言译文写回Strings.%two_letters_code%.resx。ignore: '*.cs'确保资源目录中的 C# 代码(如设计器文件)不会被当作可翻译内容上传。 - 资源存储层(ResX):默认资源
Strings.resx承载源语言字符串;Strings.zh.resx、Strings.ja.resx等按 .NET 标准的"基础名称 + 文化 + .resx"约定构成卫星资源。Strings.Designer.cs是 Visual Studio 生成器产出的强类型包装,内部使用ResourceManager并遵循CurrentUICulture查找链。 - 运行时分派层(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
架构说明:
- 左上(翻译协作层):
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 行有效配置,但完整定义了翻译流水线:
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%.resxSource: 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 是桌面壳内的本地化核心。三个关键成员:
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 表示"尚未覆盖、跟随系统"。
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的数值形式)条件编译存在的原因。
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 表达式提供映射:
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. 语言枚举与格式化占位符约定
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} 表达,且占位符在各语言分支中位置一致:
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 使用:
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
流程解读:
- 翻译侧(离线):源串改动 → Crowdin 上传 → 译者翻译 → 译文按
%two_letters_code%写回仓库 → 编译期生成卫星资源。ignore: '*.cs'保证设计器文件不会进入翻译流程。 - 运行时(在线):读取字符串属性 →
GetString解析当前文化(覆盖值优先,否则CurrentUICulture)→IsMatch沿父级文化链逐一比对 8 个受支持文化名 → 命中后把对应Language枚举传给 lambda → switch 表达式返回文本 → UI 显示。整条链路无 I/O、无锁、无缓存失效问题,纯内存计算。
Data Model(资源文件关系)
资源模型要点:每个 .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 统一完成:
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 使用)
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:获取带版本插值的运行时名称
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:强制指定文化获取字符串
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[].source | string | /src/BD.WTTS.Client/Resources/Strings.resx | 翻译源文件,唯一 source of truth |
crowdin.yml → files[].ignore | string[] | ['*.cs'] | 上传 Crowdin 时排除的文件模式,保护 Strings.Designer.cs 不被当作翻译文本 |
crowdin.yml → files[].translation | string | /src/BD.WTTS.Client/Resources/Strings.%two_letters_code%.resx | 译文写回路径模板,%two_letters_code% 替换为 ISO 639-1 双字母码 |
Strings.Culture | CultureInfo? | 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 处固定改动):
- 在
Language枚举追加值(Strings.csL19-L29); - 在
GetString的 if-else 链中插入对应CultureName_*判定(保持与AssemblyInfo常量对齐); - 在所有内嵌字符串 switch 的
_之外补分支——switch 表达式非穷尽时编译器会报错,等价于编译期强制补翻。 - Crowdin 侧由
%two_letters_code%自动生成新语言文件,仓库无需手工建文件。
- 在
- AppHost 内嵌字符串的边界:这套 switch 字符串只覆盖 AppHost 引导/系统级场景;主 UI 大批量字符串请继续走
Strings.resx+ Crowdin,避免在代码里手写多语言 switch 造成维护负担。
Related Links
- crowdin.yml — Crowdin 同步契约(source / ignore / translation)
- Strings.cs (AppHost) — 运行时文化匹配与内嵌多语言字符串
- Strings.Designer.cs — ResX 强类型资源访问器
- Strings.resx — 源语言资源文件(Crowdin source of truth)
- 关于插件业务字符串与设置页语言切换 UI 的实现,见各自对应的插件 / 设置页目录页。