基於 WPF 模組化架構下的本地化設計實踐

  • 2019 年 10 月 3 日
  • 筆記

背景描述

最近接到一個需求,就是要求我們的 WPF 客戶端具備本地化功能,實現中英文多語言介面。剛開始接到這個需求,其實我內心是拒絕的的,但是沒辦法,需求是永無止境的。所以只能想辦法解決這個問題。

首先有必要說一下我們的系統架構。我們的系統是基於 Prism 來進行設計的,所以每個業務模組之間都是相互獨立,互不影響的 DLL,然後通過主 Shell 來進行目錄的動態掃描來實現動態載入。

為了保證在不影響系統現有功能穩定性的前提下,如何讓所有模組支援多語言成為了一個亟待解決的問題。

剛開始,我 Google 了一下,查閱了一些資料,很多都是介紹如何在單體程式中實現多語言,但是在模組化架構中,我個人覺得這樣做並不合適。做過本地化的朋友應該都知道,在進行本地化翻譯的時候,都需要創建對應語言的資源文件,無論是使用 .xaml .resx.xml,這裡面會存放我們的本地化資源。對於單體系統而言,這些資源直接放到主程式下即可,方便快捷。但是對於模組化架構的程式,這樣做就不太好,而是應該將這些資源都分別放到自己模組內部由自己來維護,主程式只需規定整個系統的區域語言即可。

設計思路

面對上面的背景描述,我們可以大致描述一下我們期望的解決方式,主程式只負責對整個系統進行區域語言設置,每個模組的本地化由本模組內部完成,所有模組的本地化切換方式保持一致,依賴於共有的一種實現。如下圖所示:

實現方案

由於如何使用 Prism 不是本文的重點,所以這裡就略過主程式和模組程式中相關的模板程式碼,感興趣的小夥伴可以自行在園子里搜索相關技術文章。

參照上述的思路,我們可以做一個小示例來展示一下如何進行多模組多語言的本地化實踐。

在這個示例中,我以 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

參考