WPF 国际化方案
WPF 国际化方案:外部 JSON + MarkupExtension + 热更新
1. 背景
WPF 做国际化时,常见方案有很多:
.resx资源文件ResourceDictionary- 第三方国际化库
- ViewModel 中暴露多语言属性
- 外部 JSON / XML / 数据库配置
如果只是普通多语言,.resx 是很经典的选择。
但如果希望做到:
修改翻译不重新编译
程序运行中修改翻译文件
界面自动刷新
XAML 写法尽量简洁
那么可以考虑使用:
外部 JSON
+
内存翻译字典
+
MarkupExtension
+
INotifyPropertyChanged
+
FileSystemWatcher
这篇笔记记录的就是这种方案。
2. 核心思路
整体思路是:
页面上只写翻译 Key
翻译文本放在外部 JSON 文件中
程序启动时把 JSON 加载到内存字典
XAML 通过 MarkupExtension 绑定到内存字典
语言切换或文件变化时更新内存字典
通知 WPF Binding 自动刷新界面
可以简单理解为:
JSON 文件
↓
内存字典
↓
XAML Binding
↓
界面文本
3. 翻译文件设计
可以使用扁平的 key-value JSON 文件。
中文:
{
"App.Title": "工业监控平台",
"Common.Save": "保存",
"Common.Cancel": "取消",
"Device.Title": "设备管理",
"Device.TotalCount": "当前共 {0} 台设备"
}
英文:
{
"App.Title": "Industrial Monitor Platform",
"Common.Save": "Save",
"Common.Cancel": "Cancel",
"Device.Title": "Device Management",
"Device.TotalCount": "{0} devices"
}
推荐 key 命名方式:
模块.含义
例如:
Common.Save
Common.Delete
Device.Title
Device.SearchPlaceholder
User.LoginName
这种命名方式简单、直观,也方便后期查找。
4. 项目文件配置
如果 JSON 文件放在项目中,例如:
Languages/
zh-CN.json
en-US.json
需要让它们复制到输出目录:
<ItemGroup>
<Content Include="Languages\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
程序运行时通常读取的是:
bin/Debug/netX/Languages/zh-CN.json
而不是源码目录里的:
Languages/zh-CN.json
这是很多人第一次做热更新时容易踩的坑。
5. 内存翻译仓储
可以定义一个 LocalizationStore,负责保存当前语言的翻译字典。
public sealed class LocalizationStore : INotifyPropertyChanged
{
private IReadOnlyDictionary<string, string> _translations =
new Dictionary<string, string>();
public event PropertyChangedEventHandler? PropertyChanged;
public int Version { get; private set; }
public string this[string key] => GetString(key);
public string GetString(string key)
{
return _translations.TryGetValue(key, out var value)
? value
: $"[{key}]";
}
public string Format(string key, params object?[] args)
{
var format = GetString(key);
try
{
return string.Format(CultureInfo.CurrentUICulture, format, args);
}
catch (FormatException)
{
return format;
}
}
public void Update(IReadOnlyDictionary<string, string> translations)
{
_translations = new Dictionary<string, string>(
translations,
StringComparer.OrdinalIgnoreCase);
Version++;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(Binding.IndexerName));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Version)));
}
}
几个关键点:
public string this[string key] => GetString(key);
这是为了让 XAML 可以通过索引器绑定:
Binding("[Device.Title]")
缺失 key 时返回:
[Device.Title]
这样界面上可以直观看出哪个 key 没有配置。
6. 本地化服务
LocalizationService 负责:
- 读取 JSON 文件
- 切换语言
- 设置当前 Culture
- 更新
LocalizationStore - 监听文件变化
简化版本:
public sealed class LocalizationService
{
private readonly string _languageDirectory;
private readonly LocalizationStore _store;
public LocalizationService(string languageDirectory, LocalizationStore store)
{
_languageDirectory = languageDirectory;
_store = store;
}
public CultureInfo CurrentCulture { get; private set; } =
CultureInfo.GetCultureInfo("zh-CN");
public void ApplyCulture(string cultureName)
{
var filePath = Path.Combine(_languageDirectory, $"{cultureName}.json");
var json = File.ReadAllText(filePath);
var values = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new Dictionary<string, string>();
var culture = CultureInfo.GetCultureInfo(cultureName);
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
CurrentCulture = culture;
_store.Update(values);
}
}
使用:
localizationService.ApplyCulture("zh-CN");
localizationService.ApplyCulture("en-US");
7. MarkupExtension
为了让 XAML 写法更自然,可以封装一个 MarkupExtension。
希望 XAML 写成:
<TextBlock Text="{i18n:Tr Device.Title}" />
背后其实等价于:
<TextBlock Text="{Binding [Device.Title], Source=LocalizationStore}" />
实现:
[MarkupExtensionReturnType(typeof(string))]
public class TranslateExtension : MarkupExtension
{
public TranslateExtension()
{
}
public TranslateExtension(string key)
{
Key = key;
}
[ConstructorArgument("key")]
public string Key { get; set; } = string.Empty;
public override object ProvideValue(IServiceProvider serviceProvider)
{
var binding = new Binding($"[{Key}]")
{
Source = LocalizationProvider.Store,
Mode = BindingMode.OneWay
};
return binding.ProvideValue(serviceProvider);
}
}
这里的关键是:
return binding.ProvideValue(serviceProvider);
不是直接返回字符串,而是返回一个 Binding 表达式。
这样当语言变化时,界面可以自动刷新。
8. 静态 Provider
为了让 MarkupExtension 能拿到全局的 LocalizationStore,可以提供一个静态入口:
public static class LocalizationProvider
{
public static LocalizationStore Store { get; } = new();
}
然后 TranslateExtension 使用:
Source = LocalizationProvider.Store
实际项目中,也可以结合 DI 容器、应用启动流程做得更严谨。
9. XAML 使用方式
先声明命名空间:
xmlns:i18n="clr-namespace:YourApp.Markup"
普通文本:
<TextBlock Text="{i18n:Tr Device.Title}" />
按钮文本:
<Button Content="{i18n:Tr Common.Save}" />
窗口标题:
<Window Title="{i18n:Tr App.Title}" />
表格列头:
<DataGridTextColumn Header="{i18n:Tr Device.Code}" />
ToolTip:
<Button ToolTip="{i18n:Tr Common.Refresh}" />
10. 格式化文本
有些文本需要参数:
{
"Device.TotalCount": "当前共 {0} 台设备"
}
XAML 希望这样写:
<TextBlock Text="{i18n:TrFormat Device.TotalCount, Value={Binding TotalCount}}" />
可以用 MultiBinding 实现。
简化示例:
public class TranslateFormatExtension : MarkupExtension
{
public string Key { get; set; } = string.Empty;
public BindingBase? Value { get; set; }
public override object ProvideValue(IServiceProvider serviceProvider)
{
var binding = new MultiBinding
{
Converter = new TranslateFormatConverter(Key)
};
binding.Bindings.Add(new Binding(nameof(LocalizationStore.Version))
{
Source = LocalizationProvider.Store
});
if (Value != null)
{
binding.Bindings.Add(Value);
}
return binding.ProvideValue(serviceProvider);
}
}
Converter:
public sealed class TranslateFormatConverter : IMultiValueConverter
{
private readonly string _key;
public TranslateFormatConverter(string key)
{
_key = key;
}
public object Convert(
object[] values,
Type targetType,
object parameter,
CultureInfo culture)
{
var args = values.Skip(1).ToArray();
return LocalizationProvider.Store.Format(_key, args);
}
public object[] ConvertBack(
object value,
Type[] targetTypes,
object parameter,
CultureInfo culture)
{
throw new NotSupportedException();
}
}
为什么要绑定 Version?
因为格式化文本不仅依赖业务值,例如 TotalCount,还依赖当前语言。
语言切换时,Version 改变,MultiBinding 会重新计算。
11. 运行时热更新
如果希望修改 JSON 文件后界面自动刷新,可以使用 FileSystemWatcher。
示例:
private FileSystemWatcher? _watcher;
private Timer? _reloadTimer;
private void Watch(string cultureName)
{
_watcher?.Dispose();
_watcher = new FileSystemWatcher(_languageDirectory, $"{cultureName}.json")
{
NotifyFilter = NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.CreationTime
};
_watcher.Changed += (_, _) => ScheduleReload();
_watcher.Created += (_, _) => ScheduleReload();
_watcher.Renamed += (_, _) => ScheduleReload();
_watcher.EnableRaisingEvents = true;
}
为了避免保存文件时触发多次事件,可以加 debounce:
private void ScheduleReload()
{
_reloadTimer ??= new Timer(
_ => ReloadCurrentCulture(),
null,
Timeout.Infinite,
Timeout.Infinite);
_reloadTimer.Change(250, Timeout.Infinite);
}
WPF 中更新 UI 相关数据最好回到 UI 线程:
private void ReloadCurrentCulture()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
{
dispatcher.BeginInvoke(() => ApplyCulture(CurrentCulture.Name));
return;
}
ApplyCulture(CurrentCulture.Name);
}
12. 为什么界面能自动刷新
核心是 LocalizationStore.Update 中触发了:
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(Binding.IndexerName));
因为 XAML 里的翻译绑定本质是:
Binding("[Device.Title]")
它绑定的是索引器。
通知 Binding.IndexerName 后,WPF 会重新读取:
LocalizationStore["Device.Title"]
于是界面刷新。
格式化文本还需要触发:
PropertyChanged?.Invoke(
this,
new PropertyChangedEventArgs(nameof(Version)));
因为格式化文本使用 Version 作为刷新触发器。
13. JSON 写错怎么办
热更新时可能会出现 JSON 临时写错的情况。
建议加载失败时不要清空旧翻译。
try
{
var json = File.ReadAllText(filePath);
var values = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
_store.Update(values ?? new Dictionary<string, string>());
}
catch (JsonException ex)
{
// 记录日志
// 保留旧翻译
}
这样用户界面不会突然变成空白,也不会因为翻译文件写错而崩溃。
14. 优点
这种方案的优点:
- 翻译文件可以外部维护
- 修改翻译不需要重新编译
- 支持运行时热更新
- XAML 写法简洁
- 不污染 ViewModel
- 支持普通文本和格式化文本
- 缺失 key 容易发现
- JSON 对非开发人员比较友好
15. 缺点
缺点也很明显:
- 比
.resx方案复杂 - 需要自己维护加载、监听、刷新逻辑
- 没有
.resx那种成熟工具链 - 复杂复数规则、性别、日期货币格式需要额外扩展
- 翻译 key 改名时,需要同步修改 XAML
16. 和其他方案对比
| 方案 | 是否需要重新编译 | 运行时刷新 | XAML 使用体验 | 适合场景 |
|---|---|---|---|---|
| .resx | 通常需要 | 不方便 | 一般 | 标准多语言 |
| ResourceDictionary | 看是否外置 | 支持 | 好 | WPF 内部资源 |
| ViewModel 属性 | 不一定 | 支持 | 一般 | 小项目 |
| 第三方库 | 看库能力 | 通常支持 | 好 | 快速接入 |
| 外部 JSON + MarkupExtension | 不需要 | 支持 | 好 | 翻译需要热更新 |
17. 一句话总结
这种 WPF 国际化方案的本质是:
页面只声明翻译 key,程序把外部 JSON 加载成内存字典,MarkupExtension 把 key 转换成 Binding,语言切换或文件变化时更新字典并通知 WPF 自动刷新界面。