本地化
启动器支持简体中文和英文两种界面语言。方案只有一句话:源语言即键——词条键就是简体中文原文。
源语言即键
public static string T(string key) => _map.TryGetValue(key, out var v) ? v : key;调用处直接写中文:Loc.T("暂无已安装版本")。中文(zh-CN)下根本不加载词典,一律回退原文。于是:
- 源语言界面不可能出现「漏翻」——没词条就显示中文,那本来就是源语言;
- 只需维护一份
Strings/en-US.json(约 2000 条,嵌入式资源),不需要为中文维护一份; - 新增界面文案不需要「先加 key 再加翻译」两步,改一处中文就完事,翻译滞后也不会破坏界面。
切到英文时词典缺失或损坏也只回退中文,绝不阻断启动(catch 里把 _map 换成空字典)。
为什么不走 x:Uid / .resw / PrimaryLanguageOverride
那套是 Windows 应用的正规做法,但在非打包应用下有三个问题:PrimaryLanguageOverride 不跨会话持久化、也无法清空回默认;ResourceLoader 还有不遵循 override 的历史问题(WindowsAppSDK#5335 / #2806)。源语言即键没有这些问题,代价是要自己写取词和重放机制——下面就是。
麻烦一:占位符反解
Core 层不引用 Loc(Starcloud.Core 不该依赖 UI 层)。更麻烦的是,Core 里的文案大多已经用运行时值格式化过了:
$"游戏进程刚启动就退出了(退出码 {exitCode}),且没有产生任何日志。"到 UI 层时拿到的已经是 游戏进程刚启动就退出了(退出码 1),且没有产生任何日志。——一个具体字符串,不是模板。词典里的键是 游戏进程刚启动就退出了(退出码 {0}),且没有产生任何日志。,两者对不上。
所以 Loc.TCore 除了精确匹配,还要按词典里的 {0} 模板反解:把词典条目按「前缀 {0} 中缀 {1} 后缀」切成字面量段(Template.SplitLiterals),然后在目标文本里按这些字面量定位实参——先比对前缀,再依次 IndexOf 找中间字面量,最后比对后缀,抽出实参后 string.Format(value, args) 回填译文。{name} 之类不参与反解(切不出纯数字占位符就整条跳过)。
中间字面量用 IndexOf 找,遇到前缀重复的条目会有歧义,但词典是自己维护的,写的时候避开就行。
麻烦二:XAML 里没有自定义标记扩展
WinUI 3 的 XAML 不支持自定义 Markup Extension,所以 XAML 侧取词用附加属性:
<TextBlock loc:Loc.Text="保存设置" />
<Button loc:Loc.Name="最小化" loc:Loc.Tip="最小化" />Loc 上挂了十来个附加属性(Text / Header / Placeholder / Tip / Name / Title / Message / OnContent / OffContent / Subtitle / ActionText),每个属性对应一种「目标类型 → 写到哪个槽位」的映射:
private static void ApplyText(DependencyObject d, string s)
{
switch (d)
{
case EmptyStateView es: es.Title = s; break;
case TextBlock tb: tb.Text = s; break;
case ButtonBase bb: bb.Content = s; break;
case ContentControl cc when d is not UserControl: cc.Content = s; break;
}
}同一个 Loc.Text 挂在 TextBlock 上写 Text,挂在 Button 上写 Content——靠运行时的类型分派,而不是在 XAML 里区分。
语言切换要即时生效,所以每个被本地化的元素都要登记下来,切换时重放一遍。登记用的是弱引用——否则页面导航几次之后,静态表里会攥着几千个已经不在可视树里的元素,内存只涨不落:
private static void Track(DependencyObject d, Action<DependencyObject> apply)
{
foreach (var e in _entries)
{
if (e.Target.TryGetTarget(out var t) && ReferenceEquals(t, d))
{ e.Apply = apply; return; }
}
_entries.Add(new Entry { Target = new WeakReference<DependencyObject>(d), Apply = apply });
}注意 Track 里存的是「重新取当前值的闭包」而不是「当初那个字符串」——元素上如果被代码改过文案,重放时应该取改后的值再翻译,而不是把旧字符串盖回去。
导航项也要重建
导航标题是在构建时经 Loc.T 解析的,切语言后光换文本不够,得重建导航树。所以 MainWindow 在 Loaded 时订阅了 Loc.LanguageChanged:
Loc.LanguageChanged += () => DispatcherQueue.TryEnqueue(BuildNavFromContributions);刻意不本地化的东西
有些字符串翻译了反而会坏:
- 品牌词:Starcloud、Nova。
- 内部标识符:设置键名(
ui_motion_level)、插件 id、协议字段。 - XAML Tag:控件 Tag 是代码里的匹配依据(
tag="recording"对应 Nova 权限开关)。 - Nova 意图路由关键词——这条最隐蔽:
if (lower.Contains("时刻") || lower.Contains("录屏") || lower.Contains("录像") ||
lower.Contains("录制"))
return new NovaReply(MomentsOverviewText(), null, false);Nova 的本地意图路由靠中文子串匹配。把「录屏」翻成 "recording",lower.Contains("录屏") 就再也匹配不上——用户用英文说 "recording" 会落到别的分支甚至落到模型。所以路由关键词保持中文原样,不进词典。