背景描述

最近接到一个需求,就是要求我们的 WPF 客户端具备本地化功能,实现中英文多语言界面。刚开始接到这个需求,其实我内心是拒绝的的,但是没办法,需求是永无止境的。所以只能想办法解决这个问题。

首先有必要说一下我们的系统架构。我们的系统是基于 Prism 来进行设计的,所以每个业务模块之间都是相互独立,互不影响的 DLL,然后通过主 Shell 来进行目录的动态扫描来实现动态加载。

为了保证在不影响系统现有功能稳定性的前提下,如何让所有模块支持多语言成为了一个亟待解决的问题。

刚开始,我 Google 了一下,查阅了一些资料,很多都是介绍如何在单体程序中实现多语言,但是在模块化架构中,我个人觉得这样做并不合适。做过本地化的朋友应该都知道,在进行本地化翻译的时候,都需要创建对应语言的资源文件,无论是使用 .xaml .resx.xml,这里面会存放我们的本地化资源。对于单体系统而言,这些资源直接放到主程序下即可,方便快捷。但是对于模块化架构的程序,这样做就不太好,而是应该将这些资源都分别放到自己模块内部由自己来维护,主程序只需规定整个系统的区域语言即可。

设计思路

面对上面的背景描述,我们可以大致描述一下我们期望的解决方式,主程序只负责对整个系统进行区域语言设置,每个模块的本地化由本模块内部完成,所有模块的本地化切换方式保持一致,依赖于共有的一种实现。如下图所示:

实现方案

参照上述的思路,我们可以做一个小示例来展示一下如何进行多模块多语言的本地化实践。

在这个示例中,我以 DotNetCore 3.0 版本的 WPF 和 Prism 进行示例说明。在我们的示例工程中创建三个项目

  • BlackApp
    • 引用 Prism.Unity 包
    • WPF App(.NET Core 版本),作为启动程序
  • BlackApp.ModuleA
    • 引用 Prism.Wpf 包
    • WPF UseControl(.NET Core 版本),作为示例模块
  • BlackApp.Common
    • ClassLibrary(.NET Core 版本),作为基础的公共服务层

BlackApp.ModuleA 添加对 BlackApp.Common 的引用,并将 BlackApp 和 BlackApp.ModuleA 的项目输出修改为相同的输出目录。然后修改对应的基础代码,以确保主程序能正常加载并显示 ModuleA 模块及其内容。

上述操作完成后,我们就可以编写我们的测试代码了。按照我们的设计思路,我需要先在 BlackApp.ModuleA 定义我们的本地化资源文件,对于这个资源文件的类型选择,理论上我们是可以选择任何一种基于 XML 的文件,但是不同类型的文件对于后面是否是埋坑行为这个需要认真考虑一下。这里我建议使用 XAML 格式的文件。我们在 BlackApp.ModuleA 项目的根目录下创建一个 Strings 的文件夹,然后里面分别创建 en-US.xamlzh-CN.xaml 文件。这里建议最好以语言名称作为文件名称,这样方便到时候查找。文件内容如下所示:

  • en-US.xaml
<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:BlackApp.ModuleA.Strings"
    xmlns:system="clr-namespace:System;assembly=System.Runtime">
    <system:String x:Key="string1">Hello world</system:String>
</ResourceDictionary>
  • zh-CN.xaml
<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:BlackApp.ModuleA.Strings"
    xmlns:system="clr-namespace:System;assembly=System.Runtime">
    <system:String x:Key="string1">世界你好</system:String>
</ResourceDictionary>

资源文件定义好了,接下来就是如何使用了。

对于我们需要进行本地化的 XAML 页面,首先我们需要指当前使用到的资源文件,这个时候就需要在我们的 BlackApp.Common 项目中定义一个依赖属性了,然后通过依赖属性的方式来进行设置。由于语言种类有很多,所以我们定义一个文件夹目录的依赖属性,来指定当前页面需要用到的资源的文件夹路径,然后由辅助类到时候依据具体的语言类型来到指定目录查找指当的资源文件。
示例代码如下所示:

[RuntimeNameProperty(nameof(ExTranslationManager))]
public class ExTranslationManager : DependencyObject
{
    public static string GetResourceDictionary(DependencyObject obj)
    {
        return (string)obj.GetValue(ResourceDictionaryProperty);
    }

    public static void SetResourceDictionary(DependencyObject obj, string value)
    {
        obj.SetValue(ResourceDictionaryProperty, value);
    }

    // Using a DependencyProperty as the backing store for ResourceDictionary.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty ResourceDictionaryProperty =
        DependencyProperty.RegisterAttached("ResourceDictionary", typeof(string), typeof(ExTranslationManager), new PropertyMetadata(null));

}

本地化资源指定完毕后,我们就可以使用里面资源文件进行本地化操作。如果想在 XAML 对相应属性进行 标签式 访问,需要定义一个继承自 MarkupExtension 类的自定义类,并在该类中实现 ProvideValue 方法。接下来在我们的 BlackApp.Common 项目中定义该类,示例代码如下所示:

[RuntimeNameProperty(nameof(ExTranslation))]
public class ExTranslation : MarkupExtension
{
    public string StringName { get; private set; }
    public ExTranslation(string stringName)
    {
        this.StringName = stringName;
    }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        object targetObject = (serviceProvider as IProvideValueTarget)?.TargetObject;

        ResourceDictionary dictionary = GetResourceDictionary(targetObject);
        if (dictionary == null)
        {
            object rootObject = (serviceProvider as IRootObjectProvider)?.RootObject;
            dictionary = GetResourceDictionary(rootObject);
        }

        if (dictionary == null)
        {
            if (targetObject is FrameworkElement frameworkElement)
            {
                dictionary = GetResourceDictionary(frameworkElement.TemplatedParent);
            }
        }

        return dictionary != null && StringName != null && dictionary.Contains(StringName) ?
            dictionary[StringName] : StringName;
    }

    private ResourceDictionary GetResourceDictionary(object target)
    {
        if (target is DependencyObject dependencyObject)
        {
            object localValue = dependencyObject.ReadLocalValue(ExTranslationManager.ResourceDictionaryProperty);
            if (localValue != DependencyProperty.UnsetValue)
            {
                var local = localValue.ToString();
                var (baseName,stringName) = SplitName(local);
                var str = $"pack://application:,,,/{baseName};component/{stringName}/{Thread.CurrentThread.CurrentCulture}.xaml";
                var dict = new ResourceDictionary { Source = new Uri(str) };
                return dict;
            }
        }
        return null;
    }

    public static (string baseName, string stringName) SplitName(string name)
    {
        int idx = name.LastIndexOf('.');
        return (name.Substring(0, idx), name.Substring(idx + 1));
    }
}

此外,如果我们的 ViewModel 中也有数据需要进行本地化操作的化,我们可以定义一个扩展方法,示例代码如下所示:

public static class ExTranslationString
{
    public static string GetTranslationString(this string key, string resourceDictionary)
    {
        var (baseName, stringName) = ExTranslation.SplitName(resourceDictionary);
        var str = $"pack://application:,,,/{baseName};component/{stringName}/{Thread.CurrentThread.CurrentCulture}.xaml";
        var dictionary = new ResourceDictionary { Source = new Uri(str) };
        return dictionary != null && !string.IsNullOrWhiteSpace(key) && dictionary.Contains(key) ? (string)dictionary[key] : key;
    }
}

通过在 BlackApp.Common 中定义上述 3 个辅助类,基本可以满足我们的需求,我们可以却换到 BlackApp.ModuleA 项目中,并进行如下示例修改

  • View 层使用示例
<UserControl
    x:Class="BlackApp.ModuleA.Views.MainView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:ex="clr-namespace:BlackApp.Common;assembly=BlackApp.Common"
    xmlns:local="clr-namespace:BlackApp.ModuleA.Views"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:prism="http://prismlibrary.com/"
    d:DesignHeight="300"
    d:DesignWidth="300"
    ex:ExTranslationManager.ResourceDictionary="BlackApp.ModuleA.Strings"
    prism:ViewModelLocator.AutoWireViewModel="True"
    mc:Ignorable="d">
    <Grid>
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <TextBlock Text="{Binding Message}" />
            <TextBlock Text="{ex:ExTranslation string1}" />
        </StackPanel>
    </Grid>
</UserControl>
  • ViewModel 层使用示例

"message".GetTranslationString("BlackApp.ModuleA.Strings")

最后,我们就可以在我们的 BlackApp 项目中的 App.cs 构造函数中来设置我们程序的语言类型,示例代码如下所示:

public partial class App
{
    public App()
    {
        //CultureInfo ci = new CultureInfo("zh-cn");
        CultureInfo ci = new CultureInfo("en-US");
        Thread.CurrentThread.CurrentCulture = ci;
    }
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {

    }

    protected override IModuleCatalog CreateModuleCatalog()
    {
        return new DirectoryModuleCatalog() { ModulePath = AppDomain.CurrentDomain.BaseDirectory };
    }
}

写到这里,我们应该就可以进行本地化的测试工作了,尝试编译运行我们的示例程序,如果不出意外的话,应该是可以通过在 主程序中设置区域类型来更改模块程序中的对应本地化资源内容。

最后,整个示例项目的组织结构如下图所示:

总结

对于模块化架构的本地化实现,有很多的实现方式,我这里介绍的只是一种符合我们的业务场景的一种实现,期待大佬们在评论区留言提供更好的解决方案。

补充

经同事验证,使用 .resx 格式的资源文件会更简单一下,可以直接通过

 BlackApp.ModuleA.Strings.zh_cn.ResourceManager("string1")
 BlackApp.ModuleA.Strings.en_us.ResourceManager("string1")

的方式来访问。但前提是需要将对应资源文件的访问修饰符设置为 public

参考

08-14 21:49