田敏
返回博客列表
WPF

WPF 国际化方案

田敏
2026-07-014 分钟阅读

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 自动刷新界面。

版权协议:MIT返回列表