WM有约II(八):本地化
Written by Allen Lee
让用户界面支持多种语言
如果你不曾为.NET Compact Framework的应用程序做过本地化,我建议你先去阅读MSDN的《设备的本地化注意事项》,以便了解.NET Compact Framework在这方面的一些限制。
首先,在当前项目里创建一个Resources文件夹,并在里面创建若干资源文件:
图 1
接着,编辑Resource1.en-US.resx和Resource1.zh-CN.resx资源文件,分别提供英语和简体中文:
图 2
图 3
那么,剩下那个(默认)资源文件有什么用呢?把上面其中一个资源文件的Name列复制到Resource1.resx,留空Value列:
图 4
打开Resource1.Designer.cs,你会发现Visual Studio帮你创建了一个Resource1类,里面包含了获取语言资源的代码,比如说,上面的Form1_label1_Text和Form1_label2_Text可以通过如下所示的两个属性获取:
代码 1
我们可以在Form1里创建一个SetUpUITexts方法,在里面使用Resource1类:
代码 2
在使用Resource1类的这些属性之前,我们得先设置Resource1.Culture属性,正是这个属性指定了用户界面的语言。由于Resource1类并不保存这个属性的值,于是我们需要另外编写代码把它保存到配置文件里。在Options.xml里添加下面这行XML:
<option name="language" value="zh-CN" />
并在OptionManager类里添加如下属性:
代码 3
当然,选项窗体也需修改,以便用户选择语言:
图 5
那么,选项窗体最下面那个ComboBox里应该提供什么语言给用户选择?是不是应用程序支持的语言呢?当然不是,应用程序支持英语和简体中文,而操作系统可能不支持简体中文,却支持英语和其它语言,所以那个ComboBox里提供的语言应该是两者的交集。然而,.NET Compact Framework并没提供内置的方法,我们又如何获取系统支持的语言呢?这里有篇帖子给出了使用非托管API的办法:
http://stackoverflow.com/questions/435951/compact-framework-retrieve-a-list-of-countries-and-regions
我们可以直接使用CultureInfoHelpr.GetCultures方法获取系统支持的语言。于是,我们可以创建一个LanguageManager类,在里面提供一个AvailableLanguages属性,用于获取填充到那个ComboBox的语言:
代码 4
此外,LanguageManager还需提供获取/设置当前语言以及语言变更通知功能,当用户更改当前语言时,LanguageManager会把更改反应到配置文件和Resource1.Culture属性,然后发出语言变更通知:
代码 5
当应用程序启动时,我们需要设置Resource1.Culture属性,并订阅LanguageChanged事件和本地化主窗体,于是,主窗体的构造函数需要添加如下代码:
代码 6
其它窗体则不必订阅该事件,因为用户每次打开的都是"全新"的窗体,所以我们只需要为其它窗体添加类似于主窗体的SetUpUITexts方法,并在它们的构造函数里调用即可。
回到选项窗体,当用户打开选项窗体时,它会从LanguageManager.AvailableLanguages属性获取可选语言,并填充到ComboBox里,然后设置语言的显示方式:
代码 7
当用户单击OK菜单项关闭选项窗体时,ComboBox的当前选择会反应到LanguageManager.Language属性上:
LanguageManager.Instance.Language = (CultureInfo)cmxLanguages.SelectedItem;
下面我们来看看效果:
图 6
图 7
图 8
图 9
图 10
图 11
整体而言还算不错,带有方框的地方还需继续改善,其中,红色方框是多语言没有触及的地方,而蓝色方框则是因为不同语言文字长度不同导致的空白,图11还发现一个BUG,ComboBox的选中项和当前语言不吻合,这是因为我们还没把LanguageManager.Language属性的值赋给ComboBox.SelectedItem(代码7),改过来就好了。
让枚举支持多种语言
还记得我们如何描述主窗体的历史记录的过滤条件吗?使用FilterOptions枚举(参见《WM有约II(七):番外篇》的代码6和代码7)。这种做法的好处是简单直接,然而,一旦遇到多语言的需求就会力不从心,出现图6的情况,那么,我们应该如何改善这个问题呢?
首先,在资源文件里添加相应的条目:
Resource1.resx
InterceptionHistory_FilterOptions_All
InterceptionHistory_FilterOptions_Today
表 1
Resource1.en-US.resx
InterceptionHistory_FilterOptions_All
All
InterceptionHistory_FilterOptions_Today
Today
表 2
Resource1.zh-CN.resx
InterceptionHistory_FilterOptions_All
所有
InterceptionHistory_FilterOptions_Today
今天
表 3
我们的任务就是建立枚举和对应资源之间的关联,那么,它们两者又该如何关联起来呢?一个办法是通过Dictionary维护枚举值和资源键之间的关系,我们可以创建一个MultilingualFilterOptionsHelper类来管理这个Dictionary。由于FilterOptions是在InterceptionHistory类里定义的,使用时必须引用它的全称"InterceptionHistory. FilterOptions",不太方便,于是我们可以先给它一个别名:
using FilterOptions = Trombone.InterceptionHistory.FilterOptions;
再创建MultilingualFilterOptionsHelper类:
代码 8
由于MultilingualFilterOptionsHelper的主要任务是"计算"给定枚举在当前语言的显示文本,于是它需要提供一个GetLocalizedName的方法来负责这项工作:
代码 9
当然,就我们的问题而言,上面这个"一对一"的"计算"功能是远远不够的,因为我们最终要把枚举作为数据源绑定到主窗体的ComboBox上,所以我们需要一个能够计算所有枚举成员的显示文本的方法。然而,.NET Compact Framework没有提供Enum.GetValues方法,我们无法简单直接地获取枚举的所有成员,一个变通的做法就是让别人传给你:
代码 10
原本,如果"计算"结果只是作为主窗体的ComboBox的数据源,那么把GetLocalizedFilterOptions方法的返回值类型定为object是最简单直接的做法,因为这样我们就可以返回匿名类型数组,但事实上,当用户更改过滤条件时,我们需要把新的过滤条件传给InterceptionHistory,于是我们需要为返回值定义一个新的类型:
代码 11
并对GetLocalizedFilterOptions方法做相应的修改。MultilingualFilterOptionsHelper类是针对FilterOptions枚举的,但它的代码可以通过泛型一般化,使它可以处理任何枚举:
代码 12
当然,你也可以通过反射创建一个GetEnumValues方法:
代码 13
这样,你就可以免却别人向你传递枚举成员了:
代码 14
如果还没满足,希望可以通过特性在枚举成员上指定资源键,像这样:
代码 15
那么你可以创建一个MultilingualEnumAttribute:
代码 16
这样,你就可以免却别人向你传递关联关系了:
代码 17
回到主窗体,创建一个SetUpFilterOptions方法来初始化那个ComboBox:
代码 18
这个方法可以在应用程序启动时使用,也可以在用户更改当前语言时使用,对于后者,我们需要在重设那个ComboBox的数据源之后把原先选中的项选上。这样,我们就可以把初始化那个ComboBox的代码替换为这个方法的调用了:
代码 19
而处理LanguageManager.LanguageChanged事件和ComboBox.SelectedIndexChanged事件的代码也需要稍作修改:
代码 20
代码 21
下面我们来看看效果:
图 12
毫无疑问,我们已经实现了想要的功能,可这就行了吗?我相信,任何一个训练有素的程序员在完成一个设计或者实现一个功能之后都会反问自己这样一个问题:这个设计/代码有足够的弹性吗?不同的程序员对这个"足够的"的理解可能有着很大差异,那么,一般而言怎样才算"足够的"呢?拿本例来说吧,假如现在有一个新的需求,在主窗体上显示本周的历史记录,对于不懂程序开发的用户来说,他们可能认为只需在ComboBox里添加一个"本周",然后把根据这个条件查询到的数据显示在主窗体上,他们并不清楚这个需求会牵涉多大范围的改动,对于程序员来说,这个范围当然越小越好,最好就是只需创建一个包含过滤逻辑和返回显示文本的对象,然后把剩下的事情交给应用程序,这样的话,需求和实现的增长水平就相当了。然而,回顾当前的实现,我们不难发现它并不能很好地适应这种线性增长的需求,怎么办?
重构或许是一条出路,为什么说"或许"呢,试想一下,如果这是你一个人的项目,那么即使你把它推倒重来也只是你一个人的事,如果你在一个团队里,情况就不太一样了,你的重构行为会通过代码间接影响别人,而别人也会/要对这些影响作出回应。人们常说,懒惰是程序员的优秀特质,每个程序员都有懒惰的权利,然而,懒惰并不总是和产生高度重用的代码有关,它有时也会和倾向于保持现有代码不变有关,当你辩说重构能使应用程序更好地适应新的需求,别人也会举出重构带来的冲击和需求发生的几率来反驳,这种讨论常常从两个人发展成一伙人,中间伴随多次反复,从这个层面上看,重构已经不是单纯的技术之事了。团队里的每个成员都有选择懒惰的自由,但每个成员的自由又会影响其他成员的自由,这让我想起存在主义的其中一个哲学观点——他人是地狱,协调每个成员的自由是管理的艺术。就本例而言,重构并不会导致这些问题,所以我们不妨趁此机会观察一下重构会使现有代码如何演变。
重构:枚举 + 条件判断 => 策略模式
首先,创建一个IFilter接口:
代码 22
接着,创建一个FilterBase抽象类,负责INotifyPropertyChanged接口的实现,当用户更改当前语言时,它会通知ComboBox过滤器的Text属性改变了:
代码 23
然后就是两个具体的过滤器实现了:
代码 24
代码 25
接下来,在InterceptionHistory类里添加一个Filter属性:
代码 26
如果你读过之前的文章,你可能会觉得代码24和代码25似曾相识,事实上,它们是从原来的FilterOptions属性提取出来的。此外,我们还需要一组过滤器对象作为ComboBox的数据源:
代码 27
回到主窗体,我们需要一个SetUpFilters方法来设置ComboBox的数据源:
代码 28
接着,把上面的代码19改为SetUpFilters方法的调用,而上面的代码21也要做相应的调整:
代码 29
删除不要的代码并运行应用程序,效果和图12一样。
下面,我们试着在新的体系下添加一个新的过滤器——"本周"。首先,分别在三个资源文件里添加相应的条目;接着,创建一个ThisWeekPassFilter类:
代码 30
然后,在InterceptionHistory.AvailableFilters属性(参见代码27)里添加一个ThisWeekPassFilter实例:
代码 31
最后,运行一下看看效果:
图 13
图 14
现在,添加新的过滤器变得如此简便,以至于我不禁想添加更多的过滤器,比如说,我想查看发送方的等级为Contact或以上的历史记录,或者发送方的请求为PingSchedule的历史记录,又或者所有等候处理的历史记录等等。以上这些都是无需用户参与的,如果我希望添加涉及用户参与的呢,比如说,查看指定发送方的历史记录,显然,我们需要向用户提供一个输入参数的界面,这些参数可以看作过滤器的配置信息,当然也需要存储下来,以免用户每次使用都要重新输入。以上这些都是简单过滤,如果我需要比较复杂的过滤呢,比如说,我想查看指定发送方本周的历史记录,或者发送方的等级为Whitelist且请求为PingSchedule的历史记录,我们当然可以完全重新创建两个独立的过滤器,但由于它们都可以看作多个简单过滤的组合,于是我又不禁想把过滤器改为链式结构,这样,复杂过滤器就可以看作由简单过滤器组合的过滤链了,当然,这也意味着我们需要向用户提供一个更复杂的界面来管理这些过滤器……
一开始,我使用枚举和条件判断来实现这部分功能,我甚至不希望添加新的过滤器,因为这意味着要修改遍布各处的零散代码,一不小心就会找不着北;接着,在实现多语言的时候,我开始探讨如何重构这部分功能,使之更具弹性;后来,重构的价值被证明之后,我不但萌生了添加更多过滤器的想法,还想为用户提供更复杂的组合过滤链,而这在之前使用枚举和条件判断来实现的时候是无法想象的。在这个过程里,我们清晰地感受到实现的演进,然而,过滤链实现的呈现并非必然的,它实际上是在重构之后才(更容易)看到的可能,如果我们一直停留在原来的枚举和条件判断,或许我们会因为代码逻辑变得更加复杂纠缠而放弃,最终走向另一个方向。在一个更高的层面上看,程序员的想法影响了功能的实现,而实现的方式也会反过来影响程序员下一步的想法,接着,程序员下一步的想法又会影响功能的后续实现,而后续实现的方式也会反过来影响程序员再下一步的想法……细心思考这个过程,不难发现程序员的想法和功能实现的方式并非简单的一一对应,而是像下面这幅图那样相互影响、共同演进:
图 15
这个过程实际上体现了乔治·索罗斯的"反射理论(reflexivity)"。我们常常说需求总是在变,事实上,需求的演变过程也存在上述特征,很多时候,客户的后续需求都是在看了当前效果之后才萌生的,你可以说是客户的潜在需求,但你无法断定这个需求的必然性,就像上面提到的过滤链一样,它的出现并非必然的,任何期望以静态的方法在一开始把需求固定下来的努力都是徒劳的,因为它企图回避参与者的认知和客观事实之间的不对应问题。迭代方法似乎是我们的救命草,因为它承认双方的相互作用,不幸的是,我们永远无法到达终极需求,只能无限接近,因为人类的心智永远可以创造出新的需求,如果说凡是有源头的都不是永恒的,那么只有在我们废弃这个项目/产品时这个过程才会真正终结,从这点来看,如果我们还要为这个过程加上一个期限,那么迭代方法很可能是通往地狱的另一条路。
处理日期和时间
日期和时间的处理是本地化过程需要考虑的问题之一,它们的表现形式依赖于区域设置,一般情况下,日期和时间的处理包括存储、解析和显示三种操作,对于存储和解析,要根据固定区域设置来处理,而对于显示,则根据当前区域设置来处理。
听起来好像很复杂,但做起来其实很简单,拿PingSchedule(参见《WM有约II(四):你明天有空吗?》)来举例,发送查询短信息的代码(在Form1.cs的btn_Click方法里)现在是:
代码 32
由于我们没有指定区域设置,ToString方法将会使用当前区域设置,这样的话,如果接收方的区域设置和发送方的不同,解析过程就会出问题。若要解决这个问题,只需向ToString方法传递固定区域设置:
代码 33
而接收方也需要告诉Parse方法根据固定区域设置进行解析(原本代码参见《WM有约II(四):你明天有空吗?》的代码7):
代码 34
应用程序里涉及日期和时间的存储和解析的还有RegistrationQueue类和InterceptionHistory类,对于前者,我们可以套用上面的做法修改代码相应的地方,而对于后者,由于我们使用db4o直接存取DateTime对象,db4o会处理相关细节,无需我们动手。
接着,我们来看看日期和时间的显示问题,这次,我们拿RegistrationQueue来举例。首先,分别在三个资源文件里添加用于DataGrid表头显示的文本(参见图8)。接着,创建一个SetUpRegistrationQueue方法,这个方法将会完成三个工作,第一个是创建DataGrid的表格样式:
代码 35
我们通过DataGridTextBoxColumn.FormatInfo属性来指定区域设置,DataGridTextBoxColumn.MappingName属性则用于指定该列将会显示Registration对象的哪个属性,DataGridTableStyle.MappingName属性比较麻烦,它一般用于指定DataGrid将会显示DataSet的哪个表的,当数据源是对象(泛型)集合时,需要通过BindingSource作为中介,并把DataGridTableStyle.MappingName属性设为元素类型的名字,第二个是设置DataGrid样式和数据源:
代码 36
第三个是处理LanguageManager.LanguageChanged事件:
代码 37
最后,把初始化DataGrid的代码替换为SetUpRegistrationQueue方法的调用,运行一下看看效果:
图 16
图 17
应用程序里涉及日期和时间的显示还有InterceptionHistory,但处理方法是一样的,所以这里就不一一细说了。
另一个与日期和时间有关的问题是"一周的第一天",在实现ThisWeekPassFilter类时,我们人为地把它指定为星期一,然而,对于不同的区域设置来说,这个"一周的第一天"可能是不同的,所以不能硬编码,我们可以通过如下代码获取当前区域设置的"一周的第一天":
DayOfWeek firstDayOfWeek = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
当然,ThisWeekPassFilter.GetLowerBound方法需要据此做出相应的调整。
你还想要什么?
应用程序首次启动时,当前语言应该是不存在的,因为用户还没设置,但我们总得选择一个来使用,目前的做法是在配置文件里硬编码"zh-CN",这会导致应用程序在不支持简体中文的操作系统上出问题,硬编码"en-US"可能是最简单的方法,因为英文总是得到支持,但这对于简体中文的用户来说并非预期效果,所以我们可以在应用程序首次启动时,先检查操作系统的语言是否应用程序所支持的,若是,把当前语言设为此语言,否则,使用"en-US"。这部分逻辑可以放在LanguageManager的构造函数里:
代码 38
应用程序首次启动时,由于配置文件的language选项的值是空字符串,OptionManager将会返回固定区域设置,我们通过把OptionManager返回的区域设置和固定区域设置进行比较来判断是否首次启动,注意,如果你用"=="运算符来比较,结果总是false,即使两个都是固定区域设置,换用Equals方法来比较就没问题了。值得提醒的是,上面代码使用了Language和AvailableLanguages属性而不是对应的私有成员,这是因为这些属性包含了其它逻辑。
至此,应用程序的开发要暂告一段落了,下一集,我们将会探讨本系列的最后一个话题——部署。