Silverlight 本地化
加载 Silverlight 区域设置资源的技巧与诀窍
Matthew Delisle
Silverlight 是一种用于创建丰富的 Internet 应用程序 (RIA) 的优秀框架,但是它尚未提供您在 Microsoft .NET Framework 的其他组件方面可以享受到的针对本地化的强大支持。Silverlight 的项目文件中确实有一些 .resx 文件、一个简单的 ResourceManager 类和一个元素。不过,除了这些之外,您就要靠自己了。没有自定义标记扩展,也没有为 DynamicResource 类提供任何支持。
在本文中,我将向您介绍如何弥补所有这些缺憾。我将介绍一种解决方案,它允许开发人员在运行时加载资源集、使用任何格式来存储资源、在不进行重新编译的情况下更改资源并演示资源的延迟加载。
本文分为三部分。首先,我会利用 Microsoft 详细介绍的本地化流程开发一个简单的应用程序。接下来,我会介绍另一种本地化解决方案,它具备一些标准流程不具备的优点。最后,我会通过讨论完成此解决方案所需的后端组件来完善此解决方案。
标准本地化流程
首先,我要构建一个利用 Microsoft 介绍的本地化流程的 Silverlight 应用程序。msdn.microsoft.com/library/cc838238(VS.95) 上提供了关于该流程的详细说明。
UI 包含一个 TextBlock 和一幅图像,如图 1 所示。
图 1 应用程序
Microsoft 介绍的本地化流程使用 .resx 文件存储资源数据。.resx 文件嵌入在主程序集中或附属程序集中,并且仅加载一次,即在应用程序启动时加载。您可以通过修改项目文件中的 SupportedCultures 元素来构建针对特定语言的应用程序。此示例应用程序将会针对两种语言(英语和法语)进行本地化。添加了两个资源文件和两个表示标志的图像后,项目结构如图 2 所示。
图 2 添加了 .resx 文件后的项目结构
我将图像的构建操作更改为内容构建,这样我就可以利用比较简练的语法来引用图像。我将分别为每个文件添加两个条目。TextBlock 是通过一个名为 Welcome 的属性引用的,而 Image 控件则是通过一个名为 FlagImage 的属性引用的。
在 Silverlight 应用程序中创建资源文件后,生成的资源类的默认修饰符是内部修饰符。遗憾的是,XAML 无法读取内部成员,即使这些成员位于同一个程序集中也是如此。为解决此问题,需要将生成的类修饰符更改为公共修饰符。可以在资源文件的设计视图中完成此操作。“访问修饰符”下拉菜单使您能够指定生成的类的范围。
资源文件准备就绪后,您需要在 XAML 中绑定资源。为此,您需要使用引用资源类的一个实例的静态字段创建一个包装类。该类非常简单,如下所示:
public class StringResources {
private static readonly strings strings = new strings();
public strings Strings { get { return strings; } }
}
要使该类可以从 XAML 进行访问,您需要创建一个实例。 在本例中,我将在 App 类中创建这个实例,以便在整个项目期间都可以访问这个实例:
<Application.Resources>
<local:StringResources x:Key="LocalizedStrings"/>
</Application.Resources>
现在,可以在 XAML 中进行数据绑定了。 TextBlock 和 Image 的 XAML 如下所示:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Strings.Welcome, Source={StaticResource LocalizedStrings}}"
FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Source="{Binding Strings.FlagImage, Source={StaticResource LocalizedStrings}}"/>
路径是 String 属性后跟资源条目的关键字。 源是 App 类的 StringResources 包装的实例。
设置区域性
必须为应用程序配置三个应用程序设置,以便选取浏览器的区域性设置并显示相应的区域性。
第一个设置是 .csproj 文件中的 SupportedCultures 元素。 Visual Studio 中目前没有用于编辑该设置的对话框,因此必须手动编辑项目文件。 您既可以通过在 Visual Studio 外部打开项目文件来对其进行编辑,也可以通过卸载项目并从 Visual Studio 中的上下文菜单中选择编辑选项来编辑项目文件。
要为此应用程序启用英语和法语,SupportedCultures 元素的值如下所示:
<SupportedCultures>fr</SupportedCultures>
区域性值由逗号分隔。 您不必指定非特定区域性;它会被编译到主 DLL 中。
必须执行这些后续步骤才能选取浏览器语言设置。 必须为网页中嵌入的 Silverlight 对象添加一个参数。 参数值是取自服务器端的当前 UI 区域性。 这要求网页必须是 .aspx 文件。 参数语法是:
<param name="uiculture"
value="<%=Thread.CurrentThread.CurrentCulture.Name %>" />
此流程中的最后一个强制性步骤是编辑 web.config 文件,并在 system.web 元素内部添加一个 globalization 元素,将该元素的属性值设置为 auto:
<globalization culture="auto" uiCulture="auto"/>
正如前面提到的,Silverlight 应用程序拥有一个非特定语言设置。可以通过转到项目属性的“Silverlight”选项卡,然后单击“程序集信息”来查看该设置。非特定语言属性位于对话框底部,如图 3 所示。
图 3 设置非特定语言
我建议将非特定语言设置为一种没有地区限制的语言。这种设置是一种回退,如果它涵盖广泛的潜在区域设置,则更为有用。设置非特定语言后会向 assemblyinfo.cs 文件添加一个程序集属性,如下所示:
[assembly: NeutralResourcesLanguageAttribute("en")]
执行完上述所有操作之后,最后会获得一个本地化的应用程序,该应用程序可在启动时读取浏览器语言设置并加载相应的资源。
自定义本地化流程
标准本地化流程的局限是由于使用 ResourceManager 和 .resx 文件而产生的。 ResourceManager 类不会在运行时根据环境中的区域性变化更改资源集。 使用 .resx 文件时,针对每种语言,开发人员只能拥有一个资源集,而且资源维护也不灵活。
为了消除这些局限,让我们看一个利用动态资源的备选解决方案。
要使资源具有动态性,资源管理器需要在活动资源集发生更改时发送通知。 要在 Silverlight 中发送通知,您需要实现 INotifyPropertyChanged 接口。 在内部,各个资源集将分别是一个带有关键字和字符串类型的值的字典。
Prism 框架和托管可扩展性框架 (MEF) 是 Silverlight 开发过程中常用的框架,这些框架会将应用程序分解为多个 .xap 文件。 对于本地化,每个 .xap 文件都需要其自己的资源管理器实例。 要向每个 .xap 文件(资源管理器的每个实例)发送通知,我需要记录创建的各个实例并在需要发送通知时循环访问该实例列表。 图 4 显示了此 SmartResourceManager 功能的代码。
图 4 SmartResourceManager
public class SmartResourceManager : INotifyPropertyChanged {
private static readonly List<SmartResourceManager> Instances =
new List<SmartResourceManager>();
private static Dictionary<string, string> resourceSet;
private static readonly Dictionary<string,
Dictionary<string, string>> ResourceSets =
new Dictionary<string, Dictionary<string, string>>();
public Dictionary<string, string> ResourceSet {
get { return resourceSet; }
set { resourceSet = value;
// Notify all instances
foreach (var obj in Instances) {
obj.NotifyPropertyChanged("ResourceSet");
}
}
}
public SmartResourceManager() {
Instances.Add(this);
}
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged(string property) {
var evt = PropertyChanged;
if (evt != null) {
evt(this, new PropertyChangedEventArgs(property));
}
}
正如您所看到的,创建了一个静态列表以保留资源管理器的所有实例。 活动资源集存储在字段 resourceSet 中,而已加载的各个资源存储在 ResourceSets 列表中。 在构造函数中,当前实例存储在 Instances 列表中。 类以标准方式实现 InotifyPropertyChanged。 当活动资源集发生更改时,我会循环访问此实例列表并触发每个实例的 PropertyChanged 事件。
SmartResourceManager 类需要一种可以在运行时更改区域性的做法,这种做法与带有 CultureInfo 对象的方法一样简单:
public void ChangeCulture(CultureInfo culture) {
if (!ResourceSets.ContainsKey(culture.Name)) {
// Load the resource set
}
else {
ResourceSet = ResourceSets[culture.Name];
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture =
culture;
}
}
该方法可用于检查是否已经加载了请求的区域性。 如果尚未加载,则该方法会加载请求的区域性,然后将其设置为活动区域性。 如果已经加载了请求的区域性,则该方法会直接将相应的资源集设置为活动资源集。 此时会省略用于加载资源的代码。
为了完整起见,我还会向您介绍两种用于以编程方式加载资源的方法(请参见图 5)。 第一种方法只带一个资源关键字,返回活动区域性的资源。 第二种方法带有一个资源和一个区域性名称,返回特定区域性的资源。
图 5 加载资源
public string GetString(string key) {
if (string.IsNullOrEmpty(key)) return string.Empty;
if (resourceSet.ContainsKey(key)) {
return resourceSet[key];
}
else {
return string.Empty;
}
}
public string GetString(string key, string culture) {
if (ResourceSets.ContainsKey(culture)) {
if (ResourceSets[culture].ContainsKey(key)) {
return ResourceSets[culture][key];
}
else {
return string.Empty;
}
}
else {
return string.Empty;
}
}
如果您立即运行应用程序,则所有本地化字符串都会为空,因为没有下载任何资源集。 为了加载初始资源集,我会创建一个名为 Initialize 的方法,该方法会选取非特定语言文件和区域性标识符。 在应用程序的整个生存期内,仅应调用该方法一次(请参见图 6)。
图 6 初始化非特定语言
public SmartResourceManager() {
if (Instances.Count == 0) {
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
Instances.Add(this);
}
public void Initialize(string neutralLanguageFile,
string neutralLanguage) {
lock (lockObject) {
if (isInitialized) return;
isInitialized = true;
}
if (string.IsNullOrWhiteSpace(neutralLanguageFile)) {
// No neutral resources
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
else {
LoadNeutralResources(neutralLanguageFile, neutralLanguage);
}
}
绑定到 XAML
自定义标记扩展将提供适用于已本地化资源的最流畅的绑定语法。 遗憾的是,Silverlight 中不提供自定义标记扩展。 Silverlight 3 及更高版本提供绑定到字典功能,语法如下所示:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Path=ResourceSet[Welcome], Source={StaticResource
SmartRM}}" FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Source="{Binding ResourceSet[FlagImage], Source={StaticResource SmartRM}}"/>
路径包含字典属性的名称和关键字(在方括号中)。 如果您使用的是 Silverlight 2,则有两种选择:创建 ValueConverter 类或使用反射生成强类型化对象。 使用反射创建强类型化对象不在本文讨论范围。 ValueConverter 的代码如图 7 所示。
图 7 自定义 ValueConverter
public class LocalizeConverter : IValueConverter {
public object Convert(object value,
Type targetType, object parameter,
System.Globalization.CultureInfo culture) {
if (value == null) return string.Empty;
Dictionary<string, string> resources =
value as Dictionary<string, string>;
if (resources == null) return string.Empty;
string param = parameter.ToString();
if (!resources.ContainsKey(param)) return string.Empty;
return resources[param];
}
}
LocalizeConverter 类带有字典和传入的参数,返回该关键字在字典中的值。 创建了转换器的实例后,绑定语法如下所示:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Path=ResourceSet, Source={StaticResource SmartRM}, Converter={StaticResource LocalizeConverter}, Convert-erParameter=Welcome}" FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2" HorizontalAlignment="Center"
Source="{Binding ResourceSet, Source={StaticResource SmartRM}, Converter={StaticResource LocalizeConverter}, ConverterParameter=FlagImage}"/>
使用转换器,语法更加冗长,但您的灵活性提高了。 不过,在下文中,我不再使用转换器,而是使用字典绑定语法。
区域设置回顾
必须为区域性配置两个应用程序设置,以便被 Silverlight 应用程序选取。 讨论的这些设置与标准本地化流程的设置相同。 必须将 web.config 文件中 globalization 元素的 culture 和 uiCulture 的值设置为 auto:
<globalization culture="auto" uiCulture="auto"></globalization>
而且,需要将 .aspx 文件中的 Silverlight 对象作为参数传入当前线程 UI 区域性值:
<param name="uiculture"
value="<%=Thread.CurrentThread.CurrentCulture.Name %>" />
为了展示应用程序的动态本地化,下面我要添加一对按钮,以便更改区域性,如图 8 中所示。 “英语”按钮的 click 事件如下所示:
(App.Current.Resources["SmartRM"] as SmartResourceManager).ChangeCulture(
new CultureInfo("en"));
图 8 区域性更改按钮
通过一些模拟数据,应用程序将运行并显示相应的语言。此处的解决方案允许在运行时进行动态本地化,且扩展范围足以使用自定义逻辑加载资源。
下一部分将着重介绍弥补剩余漏洞的问题:资源存储在哪里以及如何检索它们。
服务器端组件
现在,让我们创建一个存储资源的数据库,和一个检索这些资源的 Windows Communication Foundation (WCF) 服务。在更大的应用程序中,您需要创建数据和业务层,但对于此示例,我不会使用任何抽象概念。
我之所以选择 WCF 服务,是因为创建过程非常简单,而且 WCF 非常可靠。我之所以选择将资源存储在关系数据库中,是因为这样维护和管理起来都非常简单。可以创建一个管理应用程序,该应用程序使翻译人员能够轻松地修改资源。
对于此应用程序,我使用的是 SQL Server 2008 Express。数据架构如图 9 所示。
图 9 SQL Server 2008 Express 中的本地化架构表
Tag 是一组命名的资源。StringResource 是表示资源的实体。LocaleId 列表示资源所属的区域性的名称。添加 Comment 列是为了与 .resx 格式相兼容。添加 CreatedDate 和 ModifiedDate 列是为了进行审核。
StringResource 可与多个 Tag 关联起来。这样做的优点在于:您可以创建特定的组(例如,单个屏幕的资源)并仅下载这些资源。其缺点在于:您可能分配多个具有相同 LocaleId、Key 和 Tag 的资源。在这种情况下,您可能需要编写一个触发器,以便对资源的创建或更新进行管理,或在检索资源集以确定最新资源时使用 ModifiedDate 列。
下面,我要使用 LINQ to SQL 检索数据。第一项服务操作将带来一个区域性名称并返回所有与该区域性关联的资源。这就是该接口:
[ServiceContract]
public interface ILocaleService {
[OperationContract]
Dictionary<string, string> GetResources(string cultureName);
}
以下是其实现:
public class LocaleService : ILocaleService {
private acmeDataContext dataContext = new acmeDataContext();
public Dictionary<string, string> GetResources(string cultureName) {
return (from r in dataContext.StringResources
where r.LocaleId == cultureName
select r).ToDictionary(x => x.Key, x => x.Value);
}
}
此操作只是查找所有其 LocaleId 等于 cultureName 参数的资源。 dataContext 字段是挂接到 SQL Server 数据库的 LINQ to SQL 类的实例。 就是这样了! LINQ 和 WCF 使操作如此简单。
现在,该是将 WCF 服务链接到 SmartResourceManager 类的时候了。 向 Silverlight 应用程序添加了服务引用后,我进行注册以接收构造函数中 GetResources 操作的已完成事件。
public SmartResourceManager() {
Instances.Add(this);
localeClient.GetResourcesCompleted +=
localeClient_GetResourcesCompleted;
if (Instances.Count == 0) {
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
}
回调方法应该向资源集列表添加资源集,并使资源集成为活动集。 代码如图 10 所示。
图 10 添加资源
private void localeClient_GetResourcesCompleted(object sender,
LocaleService.GetResourcesCompletedEventArgs e) {
if (e.Error != null) {
var evt = CultureChangeError;
if (evt != null)
evt(this, new CultureChangeErrorEventArgs(
e.UserState as CultureInfo, e.Error));
}
else {
if (e.Result == null) return;
CultureInfo culture = e.UserState as CultureInfo;
if (culture == null) return;
ResourceSets.Add(culture.Name, e.Result);
ResourceSet = e.Result;
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture = culture;
}
}
需要修改 ChangeCulture 方法,以调用 WCF 操作:
public void ChangeCulture(CultureInfo culture) {
if (!ResourceSets.ContainsKey(culture.Name)) {
localeClient.GetResourceSetsAsync(culture.Name, culture);
}
else {
ResourceSet = ResourceSets[culture.Name];
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture = culture;
}
}
加载非特定区域设置
此应用程序需要一个在无法访问 Web 服务或 Web 服务超时的情况下进行恢复的方法。 应将包含非特定语言资源的资源文件存储在 Web 服务以外,并在启动时加载。 这将起到回退作用,并通过服务调用实现性能改进。
下面,我要用两个参数再创建一个 SmartResourceManager 构造函数:一个指向非特定语言资源文件的 URL 和一个标识资源文件的区域性的区域性代码(请参见图 11)。
图 11 加载非特定区域设置
public SmartResourceManager(string neutralLanguageFile, string neutralLanguage) {
Instances.Add(this);
localeClient.GetResourcesCompleted +=
localeClient_GetResourcesCompleted;
if (Instances.Count == 1) {
if (string.IsNullOrWhiteSpace(neutralLanguageFile)) {
// No neutral resources
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
else {
LoadNeutralResources(neutralLanguageFile, neutralLanguage);
}
}
}
如果没有非特定资源文件,则将进行执行 WCF 调用的标准进程。 LoadNeutralResources 方法使用 WebClient 从服务器检索资源文件, 然后,解析文件并将 XML 字符串转换为 Dictionary 对象。 我在这里就不展示代码了,因为它有些长,而且没有什么价值,但是如果您感兴趣,可以在本文的代码下载部分中查看此代码。
为了调用参数化的 SmartResourceManager 构造函数,我需要将 SmartResourceManager 的实例化移动到 App 类的隐藏代码(因为 Silverlight 不支持 XAML 2009)。 但是,我不想对资源文件或区域性代码进行硬编码,因此我必须创建一个自定义的 ConfigurationManager 类,您可以在代码下载部分中查看此类。
将 ConfigurationManager 集成到 App 类中后,Startup 事件回调方法如下所示:
private void Application_Startup(object sender, StartupEventArgs e) {
ConfigurationManager.Error += ConfigurationManager_Error;
ConfigurationManager.Loaded += ConfigurationManager_Loaded;
ConfigurationManager.LoadSettings();
}
现在,启动回调方法可加载应用程序设置并注册进行回调。 如果您选择使加载配置设置成为后台调用,请注意您可能遇到的争用情况。 下面是 ConfigurationManager 事件的回调方法:
private void ConfigurationManager_Error(object sender, EventArgs e) {
Resources.Add("SmartRM", new SmartResourceManager());
this.RootVisual = new MainPage();
}
private void ConfigurationManager_Loaded(object sender, EventArgs e) {
Resources.Add("SmartRM", new SmartResourceManager(
ConfigurationManager.GetSetting("neutralLanguageFile"),
ConfigurationManager.GetSetting("neutralLanguage")));
this.RootVisual = new MainPage();
}
错误事件回调方法加载 SmartResourceManager 时不带非特定语言,而 Loaded 事件回调方法加载时带非特定语言。
我需要将资源文件放在一个适当的位置,当我更改该文件时不必重新编译任何内容。 我要将它放在 Web 项目的 ClientBin 目录中,创建了资源文件后,我要将其扩展名更改为 .xml,以便它能够被公开访问,而且 WebClient 类能够从 Silverlight 应用程序访问它。 由于该文件能够被公开访问,因此不要在其中放入任何敏感数据。
ConfigurationManager 也从 ClientBin 目录读取。 它查找一个叫做 appSettings.xml 的文件,该文件如下所示:
<AppSettings>
<Add Key="neutralLanguageFile" Value="strings.xml"/>
<Add Key="neutralLanguage" Value="en-US"/>
</AppSettings>
appSettings.xml 和 strings.xml 准备就绪后,ConfigurationManager 和 SmartResourceManager 便可协同工作,以加载非特定语言。 此过程有待改进,因为如果线程的活动区域性与非特定语言不同,且 Web 服务出现故障,则线程的活动区域性将与活动的资源集不同。 我会把它留给您作为练习。
总结
我在本文中没有复习如何在服务器端规范化资源。 假设 fr-FR 资源缺少两个关键字,而 fr 资源拥有这两个关键字。 当请求 fr-FR 资源时,Web 服务应从更普遍的 fr 资源插入缺少的关键字。
另一个已内置于此解决方案中但我刚才没有讲到的方面是:按照区域性和资源集加载资源,而非仅按照区域性加载。 这对于按屏幕加载资源和按 .xap 文件加载资源都很有用。
然而,我在本文介绍的解决方案允许您进行一些有用的操作,包括在运行时加载资源集、使用任何格式来存储资源、在不进行重新编译的情况下更改资源,以及延迟加载资源。
本文介绍的解决方案是通用的,您可以在多个时间点挂接该应用程序并彻底更改实现。 我希望这会有助于减少您每天的编程工作量。
要进一步深入了解实现方面的信息,请参阅 Guy Smith-Ferrier 撰写的“.NET Internationalization:The Developer’s Guide to Building Global Windows and Web Applications”(Addison-Wesley, 2006)。 Smith-Ferrier 在其个人网站上还制作了一个关于国际化的精彩视频,该视频名为“Internationalizing Silverlight at SLUGUK”(bit.ly/gJGptU)。
Matthew Delisle既喜欢学习计算机软件知识,也喜欢学习计算机硬件知识。他的第一个女儿出生于 2010 年,他认为女儿差不多可以开始自己的编程职业生涯了。通过 Delisle 的博客 mattdelisle.net 了解他的最新情况。
衷心感谢以下技术专家对本文的审阅:John Brodeur