导言
在WPF应用程序中搭建多语言支持(Multilingual Support)是我最近在做的一件事,对于不使用英语的人士而言,此举提高了程序的可用性。实现起来要完成以下目标:
-
一个版本容纳多种语言. 这就意味着不要创建单独的英语版本、法语版本、日语版本等等。 许多电子产品(例如电视和数码相机)在同一模块中支持多语言。你不需要购买不同模块或给软件打补丁来得到与默认设置不同的语言
-
允许在运行时切换接口语言. 这就是说不需要关闭应用程序并配置操作系统环境,一切都交给安装器。
-
***运行选择合适语言. W应用程序***次运行,就把接口语言设为操作系统的系统语言。这点很有意义--法国用户喜欢安装、运行、并马上使用软件,而不会再一个不熟悉的应用程序中找到切换语言的地方。
-
允许UI可拓展以便翻译,缩减可能的裁剪文本
此外,具体实现不应该随着用户界面的增长而越来越来难实现。(这是我觉得最困难的方面。)
所以这篇文章旨在提供一份我开发过程的详细解决方案的大纲,这基于一些我过去写过的博客和帖子(这里,这里和这里)。随着时间的推移,我将指出例子的相关部分并告诉你它们是如何适配在一起的。
声明:例子中的文本是使用自动在线翻译服务生成的。尽管尽了***的努力来确保这是尽可能准确(通过反向翻译校对),有可能翻译的内容有不准确或错误。特别是当它使用了一个我不清楚的完全不同的写作系统。
上层概述
这个为WPF应用所设计的实现遵循了一种MVVM(模型-视图-视图模型)样式。语言数据存储在嵌入式XML文件中,这些文件按照需求原则加载到内存中,即当接口语言发生改变的时候。这就是“模型”的部分。.
“视图模型”具有将当前语言的语言数据包含到整个WPF应用中的特性。它是XAML文件的集合,XAML文件形成了包含了关联该语言数据的“视图”。为了给一个特定的文本元素选择准确的值,每个关联都利用具有一个转换器参数的用户定制值转化器来查找文本键值。***,用一个用户定制标记扩展来抽取这个关联的细节,这样只有键值(即转换参数)需要指定。
例子
为了说明这个实现在实际中如何工作,我根据这个功能创建一个小的示例应用。这个叫做'RePaver'的应用用于清除路径标记表达式,并具有基本的翻转,反转,转换和缩放实际几何图形(即无需图层转换)的功能。在后台,该应用用正则表达式抽取路径段落,并就地对每个段落进行转换。
为了给你有个概念,看如下一个Path表达式的例子,这个表达式一般从导出为XAML格式的矢量图形文件中得到(这个路径表达式跟一些我目前经手项目的路径没有关系!):
- <Path Data="M 470.567,400.914 L 470.578,
- 390.903 L466.551,390.863 L 472.6,384.876 L472.598,400.888 Z" ... />
如果你复制黏贴(引号中的)数据表达式到输入框中并点击'Go',可以看到如下的输出:
- M 4,16 L 4,6 L 0,6 L 6,0 L 6,16 Z
在右边你还能即时看到形象化的"转换前"和"转换后"的结果。
你可以任意设置一些选项 - 可以看到这些操作是按照翻转/反转 -> 缩放到[根据边框尺寸] -> 偏移。当然,你可以用不同的语言试一下。
模块
XML
如上所述,每个组成用户界面的文本都保存在每种语言的XML文件的本地化表格中, 并把XML文件当做嵌入式资源来编译。每条text的父元素包含一个键属性用来检索本地化文本。下面是英语版本定义文件的例子,LangEN.xml:
- <LangSettings>
- <IsRtl>0</IsRtl>
- <MinFontSize>11</MinFontSize>
- <HeadingFontSize>16</HeadingFontSize>
- <UIText>
- <!-- Menu bar -->
- <Entry key="TransformLabel">Transform</Entry>
- <Entry key="LanguageLabel">Language</Entry>
- <!-- Common Operations -->
- <Entry key="ApplyLabel">Apply</Entry>
- <Entry key="UndoLabel">Undo</Entry>
- <Entry key="CancelLabel">Cancel</Entry>
- <!-- Section Headings -->
- <Entry key="InputLabel">Input</Entry>
- <Entry key="OutputLabel">Output</Entry>
- <Entry key="InfoLabel">Info</Entry>
- <Entry key="TransformPropertiesLabel">Transform</Entry>
- <!-- Item Labels -->
- <Entry key="FlipRotateLabel">Flip / Rotate</Entry>
- <Entry key="OffsetLabel">Offset</Entry>
- <Entry key="ScaleToLabel">Scale To</Entry>
- <Entry key="DimensionsLabel">Dimensions</Entry>
- <Entry key="WidthLabel">Width</Entry>
- <Entry key="HeightLabel">Height</Entry>
- <Entry key="GoLabel">Go</Entry>
- </UIText>
- </LangSettings>
在上述英文版本示例中,同样提到了 theIsRtl, MinFontSize, 和HeadingFontSize元素。字体大小用来决定渲染字体的尺寸,让字体更易分辨,尤其在显示日文,韩文和阿拉伯文的时候。IsRtlel元素决定语言是否从右往左读(阿拉伯文和希伯来语就是这样)。
注意到语言名称并没有出现在上面的XML文件中。这是因为本地化语言名称放在一个单独的XML文件中定义, LanguageNames.xml:
- <LangNames>
- <Language code="en">English</Language>
- <Language code="ar">العربية</Language>
- <Language code="de">Deutsch</Language>
- <Language code="el">Ελληνικά</Language>
- <Language code="es">Español</Language>
- <Language code="fr">Français</Language>
- <Language code="he">עברית</Language>
- <Language code="hi">हिन्दी</Language>
- <Language code="it">Italiano</Language>
- <Language code="jp">日本語</Language>
- <Language code="ko">한국어</Language>
- <Language code="ru">Русский</Language>
- <Language code="sv">Svenska</Language>
- </LangNames>
每种语言定义文件的命名遵循这样一个惯例, 'LangXX.xml'.其中,XX 与两个字母的 ISO语言代码相对应,LanguageNames.xml中的每个Language元素也该代码对应。当然,这一惯例可以拓展或修改为易于处理本地化(如 en-NZ, en-US),甚至改成三字母的ISO语言代码。
UILanguageDefn类
在语言定义文件中的当前界面语言数据被加载进一个内部类(UILanguageDefn)中是为了被剩下的应用消耗掉。主要的组件是一个<string, string>类型的字典。这个字典包含了从文本键到局部的文本值的映射。其它的属性显示:IsRtl(是否右对齐),MinFontSize(最小字体大小)和HeadingFontSize的值。
当你使用这个类的时候,局部语言文本会通过调用下面的方法重新取回:
- /// <summary>
- /// Gets the localised text value for the given key.
- /// </summary>
- /// <param name="key">The key of the localised text to retrieve.</param>
- /// <returns>The localised text if found, otherwise an empty string.</returns>
- public string GetTextValue(string key)
- {
- if (_uiText.ContainsKey(key))
- return _uiText[key];
- return "";
- }
除此之外,UILanguageDefn类有一个静态的从语言编码到局部的语言名称的映射(这个映射是从LanguageNames.xml中加载进来的),例如,“en”和“English”、“sv”和“Svenska”。这被用来填充到'Language'标签的可用语言列表中,而且被应用所支持的权威的语言列表过滤。因此,任何不再这个列表的语言不会被界面所显示。即使有一个语言定义文件或在LanguageNames.xml中有所对应的实体,也不会显示这个语言。这会在下面的章节中进一步介绍。
#p#
加载数据
类`UILanguageDefn`形成模型的一部分。模型里面的第二个主要的实体就是应用全状态,`MainWindowModel`。它包含了被整个应用程序使用的`UILanguageDefn`的授权的实例。这是在全部界面中获取文本元素的边界的实例。(通过ViewModel)。
当`MainWindowModel`被构造时,在加载当前语言之前,首先会注册语言列表的授权和从名字为LanguageNames.xml的资源文件中加载本地化语言。下面通过例子让我们看看它是如何工作的:
- public class MainWindowModel
- {
- private UILanguageDefn _languageMapping;
- public MainWindowModel(int maxWidth, int maxHeight)
- {
- RegisterLanguages();
- LoadLanguageList();
- //Settings are loaded here, where CurrentLanguageCode is decided.
- UpdateLanguageData();
- }
- public string CurrentLanguageCode
- {
- get
- {
- // Retrieves the current language code from
- // the Settings model (abstracted away)
- }
- }
- /// <summary>
- /// Registers the languages by their corresponding ISO code.
- /// </summary>
- private void RegisterLanguages()
- {
- // Defined in Constants class
- string[] supportedLanguageCodes =
- {
- "en", "ar", "de", "el",
- "es", "fr", "ko", "hi",
- "it", "he", "jp", "ru", "sv"
- };
- foreach(string languageCode in supportedLanguageCodes)
- UILanguageDefn.RegisterSupportedLanguage(languageCode);
- }
- /// <summary>
- /// Loads the list of available languages from the embedded XML resource.
- /// </summary>
- private void LoadLanguageList()
- {
- // Defined in Constants class
- string resourcePath = "RePaverModel.LanguageData.LanguageNames.xml";
- System.IO.Stream file =
- Assembly.GetExecutingAssembly().GetManifestResourceStream(resourcePath);
- XmlDocument languageNames = new XmlDocument();
- languageNames.Load(file);
- UILanguageDefn.LoadLanguageNames(languageNames.DocumentElement);
- }
- /// <summary>
- /// Updates the UI language data from that
- /// defined in the corresponding language file.
- /// </summary>
- /// <returns>
- public bool UpdateLanguageData()
- {
- string languageCode = CurrentLanguageCode;
- if (String.IsNullOrEmpty(languageCode)) return false;
- //This follows a convention for language definition files
- //to be named 'LangXX.xml' (or 'LangXX-XX.xml')
- //where XX is the ISO language code.
- string resourcePath =
- String.Format(Constants.LanguageDefnPathTemplate, languageCode.ToUpper());
- System.IO.Stream file =
- Assembly.GetExecutingAssembly().GetManifestResourceStream(resourcePath);
- XmlDocument languageData = new XmlDocument();
- languageData.Load(file);
- _languageMapping = new UILanguageDefn();
- _languageMapping.LoadLanguageData(languageData.DocumentElement);
- return true;
- }
- }
你可能注意到上面的代码提到了第三个主体 - 设置状态。在众多可在运行时调整的设置中,正是这个状态存储了当前正被使用的接口语言。大多数的设置项都在应用程序关闭后保存在磁盘中,当程序再次打开时就重新加载出来。
然而,如果应用程序是***次打开(没有设置文件存在),那么这些设置就会被设定为默认状态。对于语言来说,英语是默认的,但这并不是用户友好(user-friendly)的。所以呢,我们就这样检索当前系统语言:
- CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
找到相应的语言后,如果应用程序不支持该语言,就让英语作为默认语言。这样,只要你的本地语言受支持,UI就会在你程序***次运行时显示该语言。在Setting model hierarchy中,有如下代码
- public LanguageSettings()
- {
- // Initialise the default language code.
- // In most cases this will be overwritten by the
- // restored value from the saved settings, or that of the current culture.
- _uiLanguageCode = Constants.DefaultLanguageCode; //"en"
- string languageCode = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
- // If the system language is supported, this will
- // ensure that the application first loads
- // with the UI displayed in that language.
- if (UILanguageDefn.AllSupportedLanguageCodes.Contains(languageCode))
- _uiLanguageCode = languageCode;
- }
这个类中的另一种方法,姑且叫做后者吧 (有用户设置文件存在的时候使用),它会提取保存在文件中的设置项的值,并把它复写到_uiLanguageCode.
视图模型
这里出现了一个MVVM实现方法,它不同于WPF和Silverlight应用程序中的Model-View-Presenter(MVP).在MVP模式中,我们需要一个Presenter把当前语言的定义(或单个的本地化后的文本)传给视图(View),由视图负责UI中文本的显示与更新。考虑到我们在使用WPF,文本的更新可以很容易地通过数据绑定来实现;考虑到语言定义要在整个应用程序(组件或窗体)中使用,我们需要一个共享类来保存当前语言属性,这样当进行数据绑定时,就能使该属性在UI的任何一部分检索出来。
在MVVM模式中,这个共享类同其他视图模型(例如MainWindowViewModel)一道,将成为组成视图模型层的一部分。CommonViewModel这个类是作为单例模式(Singleton)来实现,这样静态实例属性Current就可以作为一个绑定的源属性来赋值了。非静态属性则通过绑定的Path属性来引用。还有一点很重要,ViewModel实现了INotifyPropertyChanged的接口,以致UI能在源数值发生改变时自动更新绑定。
这里是绑定到UI的CommonViewModel属性,UILanguageDefn类给出了数据的定义:
- /// <summary>
- /// Gets or sets the language definition used by the entire interface.
- /// </summary>
- /// <value>The language definition.</value>
- public UILanguageDefn LanguageDefn
- {
- get { return _languageDefn; }
- set
- {
- if (_languageDefn != value)
- {
- _languageDefn = value;
- OnPropertyChanged("LanguageDefn");
- OnPropertyChanged("HeadingFontSize");
- OnPropertyChanged("MinFontSize");
- OnPropertyChanged("IsRightToLeft");
- }
- }
- }
- public double HeadingFontSize
- {
- get
- {
- if (_languageDefn != null)
- return (double)_languageDefn.HeadingFontSize;
- return (double)UILanguageDefn.DefaultHeadingFontSize;
- }
- }
- public double MinFontSize
- {
- get
- {
- if (_languageDefn != null)
- return (double)_languageDefn.MinFontSize;
- return (double)UILanguageDefn.DefaultMinFontSize;
- }
- }
- public bool IsRightToLeft
- {
- get
- {
- if (_languageDefn != null)
- return _languageDefn.IsRightToLeft;
- return false;
- }
- }
MainWindowViewModel处在ViewModel架构最前端, 负责在MainWindowModel值发生变化时,更新CommonViewModel中的当前语言:
- /// <summary>
- /// Refreshes the UI text to display in the current language.
- /// </summary>
- public void RefreshUILanguage()
- {
- _model.UpdateLanguageData();
- CommonViewModel.Current.LanguageDefn = _model.CurrentLanguage;
- //Notify any other internal logic to prompt a refresh (as necessary)
- if (LanguageChanged != null)
- LanguageChanged(this, new EventArgs());
- }
#p#
视图
正如我所提到的,本地化文本通过数据绑定显示到视图中。然而WPF自身并不知道如何处理UILanguageDefn类,更不用说提取合适的本地化文本值。这也是***一个难题。
值转换器
请记住,CommonViewModel.Current.LanguageDefn是一个UILaunguageDefn,不是TextBlock的Text属性期待的一个字符串。因此,此时需要一个值转换器来完成这项转换工作。这个值转换器使用ConverterParameter来指定创建查找关键字,用来恢复来自UILanguage实例中局部符合条件的文本。记住,当接口改变了,UILanuageDefn也改变。
这项工作的优点在于对每一段局限在接口当中的文本,符合条件的元素需要被添加到language XML文件,确保ConverterParameter和元素名称匹配。此外不需要定义任何额外的属性——不管是在视图层,UILanguageDefn,还是在模型层的其他部分。
这个converter相对简单. 只需在类级别上指定 IValueConverter (在System.Windows.Data中)的 ValueConversion 属性:
- [ValueConversion(typeof(UILanguageDefn), typeof(string))]
并且实现类似如下的函数 Convert :
- public object Convert(object value, Type targetType,
- object parameter, CultureInfo culture)
- {
- string key = parameter as string;
- UILanguageDefn defn = value as UILanguageDefn;
- if (defn == null || key == null) return "";
- return defn.GetTextValue(key);
- }
绑定
现在我们获得了了一个 value converter, 我们可以将它放置在一个 Binding 表达式中:
- <TextBlock Text="{Binding Path=LanguageDefn,
- Converter={StaticResource UIText}, ConverterParameter=ApplyLabel,
- Source={x:Static vm:CommonViewModel.Current}}" />
如果想要它工作, 这个 XML 的 命名空间必须设置为 vm(指向 ViewModel的命名空间),并且 UIText 的资源需要被定义 (假设conv 是这个 value converter 的 XML 的命名空间):
- zlt;conv:UITextLookupConverter x:Key="UIText" />
简单明了——自定义标记扩展
如果你当前的状态(像我一样)又想要愉快的方式,在大多数的XAML文件中的长绑定表达式里,你发现它变得乏味,是同一样东西的重复。甚至不考虑重命名类或者把属性作为重构的一部分!
当然,有一种方式能使其更简洁,考虑到这些绑定之间的唯一变化就是ConverterParameter。解决方案是使用使用自定义标记扩展。
为了做到这一点,自定义标记扩展是一个简单的类,它派生自MarkupExtension(在System.Windows.Markup),按照惯例被命名为[name]Extension。在其核心处,关键点是需要重载ProvideValue方法。但是这该怎么做呢?
自定义标记拓展的重点就是在XAML中写下类似这样的代码:
- <TextBlock Text="{ext:LocalisedText Key=ApplyLabel}" />
因此,自定义拓展被称作LocalisedTextExtension,并添加一个Key,它的类型是public string.因为在后台中,绑定一直处于使用状态,所以我创建了一个private 绑定域,并从构造器中实例化它 :
- public LocalisedTextExtension()
- {
- _lookupBinding = UITextLookupConverter.CreateBinding("");
- }
而静态的CreateBinding方法定义在值转换器(value converter)中:
- public static Binding CreateBinding(string key)
- {
- Binding languageBinding = new Binding("LanguageDefn")
- {
- Source = CommonViewModel.Current,
- Converter = _sharedConverter,
- ConverterParameter = key,
- };
- return languageBinding;
- }
所以定义好了Binding后,可以通过ConverterParameter参数来获取和设置Key属性的值。这也使得ProvideValue方法可以大展身手:
- public override object ProvideValue(IServiceProvider serviceProvider)
- {
- return _lookupBinding.ProvideValue(serviceProvider);
- }
而一个Binding是一个MarkupExtension,所以它有自己的可以调用的ProvideValue方法。
#p#
Rinse and Repeat - 字体大小与流方向
某些语言的字符集包含十分复杂的图形元素,以致在拉丁文可以辨认的字符大小,用来显示这些语言的时候,变得模糊不清了。你注意到CommonViewModel提供了HeadingFontSize和MinFontSize属性。这就为本地化标题和剩余的本地化文本相应地提供了字体大小。例如日文的字体大小就大于英文。
幸运的是,使用类似下面的这个模式就可以把上述的文字尺寸绑定到共享的样式中,而不需要值转换器:
- <Style TargetType="{x:Type TextBlock}">
- <Setter Property="FontSize" Value="{Binding Path=MinFontSize,
- Source={x:Static vm:CommonViewModel.Current}}" />
- <!-- Remaining setters ... -->
- </Style>
下图显示的是两个同样界面不同语言下的差异:
也有一些语言是从右向左读的,例如阿拉伯语和希伯来语。为了让UI正确的定位到这些语言,反转接口是有意义的,否则会带来一些混淆,如果在使用程序的时候读取的顺序和逻辑的顺序不一致。
幸运的是,WPF有一个方便的属性可以完成反转整个UI的艰苦工作:
FrameworkElement.FlowDirection
是什么让这个功能相当强大,我只需要绑定一个包含在主窗口内的根级别控件,因为这个值是由它下面的每个FrameworkElement的在视觉层次继承。绑定仅仅需要查看CommonViewModel的IsRightToLeft属性,转换到(通过其他的值转换器)FlowDirection的枚举值。自定义的标记扩展被创建,遵循以前类似的模板,简化为XAML:
- <Window x:Class="RePaver.UI.MainWindow" ... >
- <DockPanel FlowDirection="{ext:LocalisedFlowDirection}">
- <!-- Contents -->
- </DockPanel>
- </Window>
鉴于到上述功能的强大,这里仍然要考虑一些陷阱和要点:
-
自定义面板自动反转布局,所以你不需要创建一个IsRevered属性(或者类似的)或者按照你的估算调整ArrangeOverride。
-
位图和形状(如线路)是反转的。如果您想要保留这些,呈现独立的流向(如公司的logo或者商标),那么你需要重写FlowDirection,设置它为LeftToRight。
-
如果接口有RightToLeft的FlowDirection,而元素(如Image)具有LeftToRight的FlowDirection,那么元素的Margin会以RightToLeft的方式展示。由于Padding展示在元素内部可视层次,所以一个padding将会以LeftToRight的方式展示。
-
TextBoxes包含语言恒定的数据,应当将FlowDirection设置为LeftToRight。理想情况下,此属性应设置为尽量减少重复并保证一致性的风格。
所以,下面就是赶时髦的“处理后”的截图:
注意路径,旋转选择控件,输入输出文本框是以从左至右的方式展示,这与语言无关。这是因为这些元素是特定的问题区域,如果它们以从右至左的方式展示,就没有道理了(可能会引起误解)。
总结
现在明白了——一个局部的WPF应用程序可以在运行时动态地改变UI。***次运行它是在法语的本地计算机环境中,瞧, il est affiché en Français. 它们都来自同一种语言版本。
***一个要点需要注意,这里不做详细介绍,整个UI布局以流体方式布局,这样的布局会自动调整以适应内容。 而不是显式地设置宽度和高度, 网格的行/列定义,等等。这些都是“自动”为左的,同时还可以定义最小和***值。这是很普通的实例中***的一个(而不是特定的本地化), 但当切换语言的时候,不允许这样的实例真的显示出来。
后记
软件开发中本地化是一个热门的话题,理所当然,我也不是唯一一个写这方面的人。事实上,我也发现了一些人在做同样的事:
-
Sebastian Przybylski (article) 也把UI文本存储在XML文件作为嵌入资源,而把XAML直接绑定到XML资源上而不是通过ViewModel.
-
David Sleeckx (article) 使用自定义标记拓展来检索本地缓存的翻译文本,或者调用Google语言API来实现实时翻译。
-
'SeriousM' 在CodePlex上更新了 WPF本地化拓展 . 它是通过提取资源文件/资源程序集中的本地化文本(或其他值)来实现的。
显然,实现WPF程序的本地化有很多种选择,它们并不互斥。根据你的权衡,我所提到的实现方法仅适用于你程序的部分,另一部分则会出现在其他的地方。所以你要根据你的需求,随意调整实现方法。
英文原文:Building Multilingual WPF Applications
译文来自:http://www.oschina.net/translate/building-multilingual-wpf-applications