2009年4月12日
#
WM有约II(九):再谈部署
Written by Allen Lee
当多语言应用程序遇到CabWiz……
创建安装包的方法非常简单,如果你对此没有了解,我建议你先去阅读《WM有约(五):部署应用程序》。安装包创建好后,拿到模拟器里安装,安装好后,你可以在"开始"菜单的"程序"里找到应用程序的图标:
图 1
因为我们在简体中文系统上运行应用程序,而简体中文又是应用程序支持的语言,所以应用程序会使用简体中文作为当前语言:
图 2
由于简体中文系统也支持英文,当我们在选项窗体里把当前语言改为英文时,应用程序应该能够正确显示英文界面,然而,事实并非如此!
图 3
问题究竟出在哪里?是当前语言设置出错吗?我修改代码,通过MessageBox显示当前语言,发现已被正确设为英文。当我通过Visual Studio部署到模拟器时,一切正常,而当我通过安装包部署到模拟器时,英文界面就变成上面这样了,这意味着资源文件本身应该是没问题的,但安装包里的资源文件可能出错或者损坏了。我不知道有没有第三方的安装包创建工具,如果没有的话怎么办?难道无法为多语言应用程序创建安装包?
在Windows Mobile 6 SDK文档里找到这么一段话:
Files that are packaged into a .cab file to be installed on a device are stored by file name, without regard to their installation directory. For this reason, if multiple files within a .cab are given the same name but different install directories, only one of the files will be installed in all locations. To work around this behavior, be sure to use unique names for all files within a single .cab file.
这么看来,CabWiz在创建安装包的时候,误把简体中文和英文资源文件看作一样的了,因为它们的名字是一样的。怎么解决这个问题?Jose A. Garcia Guirado给出了解决方案:
你可以按照他的方法手动修改INF文件和运行CabWiz,也可以使用他的工具自动化这个过程。创建好正确的安装包后,拿到模拟器里测试,这次就正常了:
图 4
值得提醒的是,如果你修改了应用程序,想重新创建安装包,你可以在命令行里运行CabWiz,并向它传递修正的INF文件,或者使用Jose在上面那篇文章里提到的方法,不要重新编译安装包项目,因为这样会覆盖修正的INF文件,并产生有问题的安装包。
让部署更简便
对于资深WM玩家来说,程序员的任务已经结束了,然而,如果你让普通WM用户把CAB文件拿到手机里安装,他们要么用问题轰炸你,要么把应用程序打进冷宫,显然,这些都不是你想要的结果,怎么办?
一个办法是为应用程序创建一个MSI安装程序,协助用户把应用程序安装到手机里,怎么创建呢?Christopher Tacke写了一篇文章,详述整个创建过程:
然而,这个过程非常繁琐,虽然客户乐了,可也不能苦了程序员,再说,这个办法会把包含Custom Action的DLL安装到桌面电脑里,显得有点多余,说白了,它其实就是调用CeAppMgr.exe,并把描述应用程序的INI文件作为参数传给它,这样的话,为什么不直接创建一个简单的应用程序来执行这个任务呢?
于是,我用Expression Blend创建了一个Cab Installer:
图 5
Cab Installer在启动时会判断ActiveSync是否已经安装,如果没有安装,它会禁用Install按钮:
代码 1
而CeAppManager则通过查找注册表来确定CeAppMgr.exe是否已经安装:
代码 2
当用户单击Install按钮时,Cab Installer会调用CeAppManager.Install方法来安装应用程序,为了避免用户多次单击Install按钮,Cab Installer在调用CeAppManager.Install方法后会禁用Install按钮:
代码 3
而CeAppManager.Install方法则负责启动CeAppMgr.exe,并把INI文件的完整路径传给它:
代码 4
编译Cab Installer项目,把CabInstaller.exe、Trombone.cab和Trombone.ini三个文件放在同一个文件夹里,然后运行CabInstaller.exe。当用户单击Install按钮时,Cab Installer将会启动CeAppMgr.exe来完成后续工作:
图 6
目前,Cab Installer和应用程序紧密耦合,不过,要让它服务其它应用程序也是很容易的,我们也可以把它改成通用安装程序,通过配置文件来指定应用程序的图标和名称、安装程序上显示的文字以及Cab文件等。说到这里,你可能会问:"能否通过它来部署.NET Compact Framework?"我试了一下,答案是可以的,但我不知道如何获知目标设备是否已经安装了所需版本的.NET Compact Framework,这可能会导致重复安装,如果有办法获取这个信息,那么Cab Installer就有一般化的价值了。
尼古丁解决方案
既然安装了应用程序,不妨运行一下,看看上次的本地化是否足够彻底。噢,有点不妥:
图 7
这个日期和时间格式是英文的,查看DateTimePicker的属性窗口,发现这个格式是当初硬编码进去的,怎么处理?一个办法是通过CultureInfo.DateTimeFormatInfo获取格式信息,并设置DateTimePicker.CustomFormat属性:
代码 5
修改一下主窗体的构造函数,然后重新部署应用程序,再来看看运行效果:
图 8
图 9
简体中文的格式没问题了,却轮到英文的格式出问题了!为什么会这样?原来,DateTimePicker只是使用我们提供的格式,而格式里面指代的"上午符号"和"下午符号"则从设备的区域设置里获取,假如我们在设备的区域设置里把它们分别设为"OK"和"KO",那么我们的应用程序也会跟着改:
图 10
.NET Compact Framework的区域设置是基于每个设备而不是每个线程的,而我们的应用程序却允许不同于设备的区域设置,于是出现了假设性冲突,怎么处理?既然我们的假设和.NET Compact Framework的有冲突,那就惟有放弃使用它提供的日期和时间格式,我们可以把这个格式放在对应的资源文件里,然后在当前语言更改时把它读到DateTimePicker.CustomFormat属性。
这个办法虽然解决了我们的问题,却引入了另一个问题,就是从今以后我们要自己管理日期和时间的格式了,这不禁让我想起温伯格在《你的灯亮着吗?》里的一句话:
每种解决方案都会带来新的问题。
从本质上来说,我们没有解决问题,而是对它应用"变换"和"转移"这两种操作,正如温伯格在《你的灯亮着吗?》里说的:
有时候,我们使问题变得不那么棘手,其实只是把问题放在"别人家的后院儿里"。
温伯格把这种技巧叫做"转嫁问题",事实上,我们每天都和"转嫁问题"打交道,当客户把项目交给你时,他其实是在使用"转嫁问题",只不过他需要为此付你报酬,从这点来看,"转嫁问题"实质上是"价值交换",正因为这些层出不穷的问题,人类社会才得以延续下去。
"转嫁问题"有一个有趣的推论,QQ空间的抢车位是这个推论的其中一个完美的体现。这个游戏是免费的,除了你的时间之外,它"似乎"无需其它支出,纵观到目前为止的众多更新,你不难发现它在努力创建一个更好的"竞争视图",你可以很方便地看到自己在整个圈子里处于一个什么竞争状态,利用人的攀比心态是腾讯成功的秘诀之一,当大家都沉浸在这个游戏里时,它推出了"管家服务",如果你没空停车,但又不希望排名落后于他人,那么你可以通过支付一定的费用雇佣管家来帮你停车,免除你的烦恼,这是一个非常水到渠成的解决方案,然而,当你沉浸在这个游戏里时,你怎么也想不到,你花钱雇用管家来解决的烦恼正是这个游戏导致的!噢,这不禁让我想起Allen Carr在《这书能让你戒烟》里的一句话:
吸烟者点燃香烟的目的是缓解尼古丁戒断症状,而这症状正是由吸烟导致的。
我把这种做法称为"尼古丁解决方案":
你现在为人们解决的问题,正是你当初为他们种下的问题。
为了打破人们的心理防线,"尼古丁解决方案"通常会和"免费策略"结合使用,虽然人们通常不会认为免费的东西是什么好东西,但免费这个特性很容易让人们产生"不高兴可以扔掉"的想法,这种派生出来的"零成本自由"恰恰就是让人们上当的诱饵。噢,感觉我们像在探讨魔鬼的手段,内心似乎多少有些抗拒,哈哈,这其实是因为社会文化让我们有了先入为主的观念,正如李子勋在《幸福从心开始》里说的:
物质存在的本身没有对错的,但人类社会的有序存在需要秩序,所以对错就被发明出来。
我本人并不吸烟,我看《这书能让你戒烟》是想了解"戒瘾原理"和"成瘾机制",前者可以用来打破旧有规则,后者则可以用来建立新的规则,当然,我主要想知道"戒瘾原理"是否适用于其它成瘾行为。
最后……
如果你足够细心,或许你会发现,在这个系列文章里,我从未使用Trombone来称呼这个应用程序,这是故意的,因为我想把这宝贵的第一次留给此刻,之所以取名Trombone是为了纪念我和一个人的关系,而这个人是玩长号的。
和那些WM高手相比,我只是一个接触WM开发还不到半年的初学者,在这个过程里,我学到很多东西,也得到很多乐趣,我希望这些文字能对其他WM初学者有所帮助,当然,如果WM高手也能有所得益就更好了。
嗯,我知道你想说什么,源代码是吧,我已经把它放在Codeplex上了,单击下面连接就能找到了:
那么,下一季玩点啥呢?嘘,萨斯顿三原则第一条:
魔术表演之前绝对不透漏接下来的表演内容。
2009年4月6日
#
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属性而不是对应的私有成员,这是因为这些属性包含了其它逻辑。
至此,应用程序的开发要暂告一段落了,下一集,我们将会探讨本系列的最后一个话题——部署。
2009年3月26日
#
F#与FP
Written by Allen Lee
做回你自己
每当提到内向的性格,人们就会联想到"沉默,不爱说话"、"孤僻,不善交际"、"神秘,不够open"等个性特征。就连一些知名的心理学词典也使用了消极的描述来定义内向,比如说,《心理学词典》(Dictionary of Psychology)把内向描述为"专注于自己的思想,回避社会交往,倾向于逃离现实世界",而《心理学国际词典》(The International Dictionary of Psychology)则把内向定义为"一种主要的人格特质,其特征是专注于自我,缺少社交能力,以及较为消极被动"。人们把内向的性格视为一种有问题的人格特质,而我们的社会也不断强调外向的性格才是健康发展的自然结果,无怪乎很多性格内向的人都羞于承认自己的本真,并承受着巨大的社会压力。
类似的情况也发生在函数式编程的身上。我曾经在hubFS.net看到一篇帖子,作者说某天他告诉同事自己在业余时间里做了一些函数式编程,而得到的回应却是:"Functional programming - isn't that the long haired, dope smoking end of computing?"这个描述让我联想到一个性格非常内向的人,整天把自己关在房间里做计算,不理发、不洗澡、吃喝随便,偶尔出来买点东西就像野人出山,招来无数奇怪目光。如果这就是人们对函数式程序员的印象,那么你就不必对人们说函数式编程语言是数学家的语言或者只适合在大学里做研究时使用感到奇怪了。
难道内向的性格真的一无是处吗?非也。最近在读《内向者优势》,它不但让我了解到内向的性格和外向的性格之间的差异只是由于不同的大脑机制,而且让我认识到内向的性格所具有的珍贵品性——"高度集中注意力的能力,对每个相关人员因变化而受到影响的体会,观察力,摆脱限制、思考问题的习性,作出不寻常决定的意志力,以及使外界放缓脚步的潜力"。我曾经在郑辛遥的《智慧快餐》上读到这样一句话:
做人累,大多是因为扮演了另一个自己。
或许,性格内向的人应该重新发现自身固有的价值,而不是试图把自己改造成性格外向的人。
同样地,函数式编程也应该有它自己的一片天空。Michael L. Scott在《Programming Language Pragmatics》里介绍函数式编程的概念时曾经提到"Many programmers—probably most—who have written significant amounts of software in both imperative and functional styles find the latter more aesthetically appealing"。这段时间,由于F#的学习,我也如饥似渴地阅读着各种介绍函数式编程的文章,有时候我不禁在想:我们是否对函数式编程有太多先入为主的偏见,以至于我们无法更深入、更完整地了解它呢?如果是的话,那就太可惜了,因为我们否定函数式编程的同时也会错过那些本应得到重视的价值。或许,我们应该尝试了解函数式编程在哪些方面能有更出色的表现,而不是一味地排斥它。
今天的主角——函数
在我印象里,map函数通常用来把一组元素按照一定的规则映射成另一组元素,例如,下面的代码把一个整数列表映射成由对应元素的平方组成的列表:
图 1
有一次,我在《Programming Language Pragmatics》介绍高阶函数(10.5 Higher-Order Functions)的那节里看到map函数的一个"新玩法":
图 2
箭头左边是这个map函数在Scheme里的用法,而箭头右边则是输出结果。这次,它接受两个输入列表,映射规则是一个二元函数,将会应用到这两个列表里位置对应的两个元素,而运算结果将会放在输出列表的对应位置上。想想看,如果让你来实现这个map函数,编程语言不限,你会怎么做?(嘿,我建议你先想一下如何在你喜欢的语言里实现这个map函数,然后再继续读下去。)
那天晚上,我躺在床上睡不着,突然来劲,就起来写下这段代码:
代码 1
这是什么?函数吗?哪些是参数?类型是什么?返回值呢?函数体怎么看?……冷静点!且听我慢慢道来。
首先,map确实是一个函数,而且是一个高阶函数,你可能已在别的地方听过这个术语了,所谓的高阶函数就是指至少满足下列一个条件的函数:(1)接受一个或多个函数作为输入;(2)输出一个函数。读到这里,你可能会感到疑惑:"map函数什么时候接受函数作为输入啦?"没看出来是吧?仔细观察代码1,是不是充满了"类型不明"的符号?当你试图从头到尾阅读这段代码时,有没有头晕、胸闷、呼吸急速、手腕无力等感觉?如果有,那么你很可能患了"显式类型声明依赖综合症"。哈哈,开个玩笑而已。
接着,把map函数的签名和图2里的代码做一个匹配,不难看出,f、l和r就是map函数的参数,其中,f是用于表示映射规则的二元函数,l和r则是两个参与运算的输入列表,然而,f、l和r具体又是什么类型呢?或许,你曾听说,F#的一大亮点就是它的类型推断系统,现在正是考验这个系统的最佳时机,看看它会把f、l和r三个参数以及函数的返回值推断成什么类型。在F# Interactive里输入map函数的代码,你将会看到如下输出:
图 3
这就是map函数的类型,你没听错,这确实是map函数的类型,它包含了参数和返回值的类型信息:
- f:('a -> 'a -> 'b)
- l:'a list
- r:'a list
- 返回值:'b list
其中,'a和'b是类型参数,'a list也可以写成list<'a>。由此可见,F#的类型推断系统已经成功推断出l和r两个参数以及返回值的类型是F#的(泛型)列表,至于f,类型推断系统则从map的代码中分析出它是一个二元函数。由于map函数的代码并未表明f的返回值和参数的类型相同,于是类型推断系统分别用两个不同的类型参数来表示它们,换句话说,f的返回值和参数的类型可以相同,也可以不同。
然后,我们来看看map函数的实现。如果你对F#的语法还不是很了解,看不懂map函数的代码,那不要紧,我们暂时把语法放下,用我们自有的逻辑思维去尝试理解。"match…with…",字面意思就是"把…和…做个匹配",于本例,我们可以把它理解成"把 (l, r) 和下列情况做个匹配",紧接着,代码列出了三种情况以供匹配:
- 第一种情况:l和r皆为空列表(在F#里,"[]"用于表示空列表,事实上,类型推断系统正是看到"[]"才得知l和r的类型是F#的列表),此时,map函数将会返回一个空列表。
- 第二种情况:这里是整个map函数最有意思的地方,我们不再停留在列表的表面特征上,而是深入到它的内部结构,我们通过"lh::lt"来描述可以进一步分解成"头"(lh)和"尾"(lt)的列表,把分解出来的"头"(lh和rh)传给f函数(f lh rh),把分解出来的"尾"(lt和rt)递归传给map函数(map f lt rt),并通过"::"运算符把这两个函数的运算结果连接起来。在这里,我们可以看到,"::"运算符既可以用来分解列表,又可以用来合成列表,"->"左边是变换之前的形态,右边则是变换之后的形态,短短的一句话却包含了"理解"、"分解"和"再合成"三个步骤,如果你看过《钢之炼金术师》这部动画,你会发现,这其实就是动画里面描述的炼金术的三大步骤。
- 第三种情况:其实它描述了两种情况,一种是l比r长,另一种是r比l长,即一个可以继续分解,另一个不能,无论是哪种情况,都意味着运算无法继续,于是我们抛了一个异常。
实际上,这种匹配的实现方式有个正式的名字,叫做"模式匹配",你可以试着把每种情况都理解成从一种形态到另一种形态的变换,或许这样更容易接受。由于我们要以递归的方式使用map函数,于是我们需要在声明它的时候使用rec关键字。
最后,我们在F# Interactive里仿照图2的做法试一下map函数:
图 4
乘法运算符的参数和返回值的类型是相同的,下面,我们换一个参数和返回值的类型不同的函数来看看:
代码 2
这个函数返回一个Tuple,里面包含了a、b的值和它们的积,把图4的乘法运算符换成g函数再试一下:
图 5
现在,假如我要把输出列表以"2 * 3 = 6"(拿第一个元素来举例)的方式显示,那么我应该怎样做呢?F#提供了一个List.iter函数,它用途和List<T>.ForEach方法相似,我们可以向List.iter函数传递一个匿名函数,对每个元素调用printfn函数,问题是,我如何才能从每个元素里分解出我想要的三个数值呢?细心观察输出列表,你会发现,每个元素都符合 (x, y, z) 模式,于是,我们可以通过把每个元素"匹配"到这个模式,提取我们想要的数据(假设result是输出列表):
图 6
假如我只需输出每个元素的第三个数值,那么我可以通过"_"符号忽略其余两个数值:
图 7
从这里可以看出,模式匹配的更深层意义其实是在理解数据结构的基础上对数据进行分解和提取,而非通常认为的switch的山寨版。
调用函数
一般而言,在调用函数(或方法)时,我们都会提供它所需的全部参数,或许,这种做法已经作为一种常识固化到我们的行为了,以至于我在这里显式地提及它可能让人有点不可思议。
试想一下,假如函数的参数是在不同的时间从不同地方获取的呢?比如说,map函数的三个参数分别有三个不同的用户在三个不同的时间提供,换言之,每次只能提供一个参数。
这时候,有同学建议创建两个辅助函数来"固定"map函数的头两个参数:
代码 3
这个方案不错,够简单,但存在一个小小的约束,拿map_with_g_and_l函数来举例,它的创建要在l这个参数已经存在的情况下才能完成,换言之,我得先把这些参数以变量的形式定义在某个地方,然后用它们来创建这些辅助函数,当我需要更改函数的参数时,我只需改变这些变量的值。说到这里,熟悉面向对象的同学可能会说:"你应该把这三个参数封装到一个对象里!"这个主意不错,我们可以用F#的Record Type来试一下(如果你对它不熟悉,可以先阅读《从C# 3.0到F#》的相关章节补充一些基础知识):
代码 4
接着,我们实例化一个Map<'a, 'b>对象:
代码 5
值得提醒的是,在F#里,null字面量对于函数和列表来说并非正常的值,所以它们不允许你直接使用null字面量,包括比较和赋值,但null字面量可以作为它们在异常情况下的值,代码4和代码5示范了两种使用null字面量的变通做法(事实上,F#不推荐使用null字面量,如果你确实有需要表达"可空"值,你可以使用F#的Option Type,有兴趣的同学可以使用Option Type重构代码4试试看)。然后,我们分别设置l和r的值,并调用Invoke方法:
代码 6
对于熟悉命令式编程和面向对象编程的同学来说,上面这个思维过程是自然而然的,但熟悉函数式编程的同学可能会觉得我们把简单的问题复杂化了。在函数式编程语言里,函数默认支持柯里化(Currying),这使得分阶段提供参数成为可能:
代码 7
说到这里,你可能会问:"代码7和代码3有什么区别?"最简单的回答是:代码7的函数是计算出来的,而代码3的函数是定义出来的。无法理解?别着急,要理解这个区别,你得先搞清楚函数为何可以接受部分参数,而柯里化又是怎么一回事。
拿g函数(代码2)来举例,要使它支持分开提供参数,我们应该把它写成这样:
代码 8
从上面代码可以看出,g函数的参数实际上只有一个,它会返回一个匿名函数,这个匿名函数的参数也是一个。换言之,如果我的函数有N个参数,我就要在里面嵌套N-1个匿名函数,这种写法显然不够直观(比较一下代码2和代码8)。那么,柯里化又是什么呢?它在这里起到什么作用?在HaskellWiki上,柯里化的定义是这样的:
Currying is the process of transforming a function that takes multiple arguments into a function that takes just a single argument and returns another function if any arguments are still needed.
读到这里,我想你已经明白了,柯里化使以代码2的方式写的g函数获得以代码8的方式写的效果。换言之,在F#里,代码2和代码8是等效的。
那么,计算出来的函数和定义出来的函数又有什么区别呢?为了让你能够看清它们的区别,我们把代码8的g函数修改一下:
代码 9
接着,我们在F# Interactive里分别以代码7和代码3的方式使用这个g函数:
图 8
h1是计算出来的函数,h2是定义出来的函数,请留意"slot"的输出位置,当我们计算h1时,g函数里的"printfn "slot""已被执行,而往后对h1的调用将不再执行这句;当我们定义h2时,g函数里的"printfn "slot""未被执行,而往后每次调用h2都将执行这句。发现区别了吗?当我们计算h1时,g函数的一部分已被执行了!你能想象得出一个函数能被分部执行意味着什么吗?事实上,计算h1的过程有个正式而且非常贴切的名字,叫做"Partial Application"。至此,我想你应该感受到代码7和代码3有着本质的不同了。
组合函数
接下来,我们考虑一个新的需求,我在一个文件里保存了这些数据:
图 9
我要在控制台输出如下结果:
图 10
对照图9和图10,我们不难发现,图10输出的是图9的两列数据的积为偶数的算式,从图9到图10经历了如下过程:
图 11
假如上述过程的每个步骤都对应着一个函数,那么,完成整个过程将会需要如下六个函数:
代码 10
现在的问题是,你会如何调用这些函数?一个常见的做法是:
图 12
然而,F# 提供了一个很特别的运算符——"|>",它使你可以用如下方式调用这些函数:
图 13
嘿!发现什么了吗?看看图11,再看看图13,我相信你已经看到我所看到的东西了。假如我要用一个函数来表示图11的过程呢?一个常见的做法是:
代码 11
此刻,我相信你应该很想知道process_and_print函数能否像图13那样保留图11的"形状",当然可以!F#提供了另一个很特别的运算符——">>",它使你可以用如下方式组合这些函数:
代码 12
如果说供应链体现了从采购原材料到把最终产品送到消费者手中的整个过程,那么代码12的"数据链"则体现了从读取原始数据到把最终结果输出控制台的整个过程。当我们把整条链架好后,剩下的就是"提供原材料"了:
图 14
高级话题
回到我们的map函数,有同学说它可能会导致堆栈溢出,嗯,的确有这种可能,怎么办?我们可以把它改成"尾递归"(Tail Recursion):
代码 13
此外,你还可以通过本地函数"隐藏"它的实现:
代码 14
如果你想更深入地理解尾递归,我推荐你去看Chris Smith的《Understanding Tail Recursion》。
你也可以把它改成CPS(Continuation-Passing Style):
代码 15
如果你对CPS没有了解,我推荐你去看Matthew Podwysocki的《Recursing on Recursion - Continuation Passing》和wesdyer的《Continuation-Passing Style》。
接下来干嘛呢?布置作业!假设我有一个列表的列表:
let have = [['a';'b';'1'];['c';'d';'2'];['e';'f';'3']]
我想把它变成这样:
let want = [['a';'c';'e'];['b';'d';'f'];['1';'2';'3']]
我该怎么做?这道题目是在Jomo Fisher的博客上找到的,那里有很多人给出不同语言的实现,你可以用你最擅长的语言来试一下。
包容"新"事物
函数式编程已经不算什么新事物了,可它为何就是普及不起来呢?是因为它不贴近实际?是因为它的理论门槛太高?还是因为我们有了更好的选择?在给出我的看法之前,我想和你分享一个我在杰拉尔德·温伯格的《咨询的奥秘——咨询师的百宝箱》里看到的小故事:
某铁路公司的官员拒绝了民众在某处设立停靠站的要求,因为在进行一番研究后,他们发现没有任何旅客在停靠时间内在该站台等候——当然,因为那时该列车并不准备停靠该站,旅客没有任何等待的理由。
你能看出个中的矛盾吗?因为某个站台没有设立停靠站,所以旅客没有在此等候,因为没有旅客在此等候,所以该站台无需设立停靠站,这是什么逻辑?温伯格把这个"逻辑"总结为"铁路悖论":
因为服务太差,人们对更好服务的要求被予以拒绝。
试想一下,如果用人单位总以缺乏经验为由拒绝招收应届毕业生,那么他们就会失去增长工作经验的机会,从而导致更多用人单位以此为由拒绝招收他们。经验是过去的写照,它无法代表现在,更不能预示未来,很多企业一边用狭窄的眼光来看待人才,一边又大喊没有人才,每当此时,我都会不禁想起郑辛遥在《智慧快餐》里提到的一个问题:
缺乏人才,还是缺乏容纳人才的机制?
函数式编程通常被认为是"学院之物",它只适用于学术研究,所以人们认为没有必要让它进入"现实世界",它也因此失去踏出学院大门的机会。试问,你可曾静下心来了解过函数式编程?抑或是跟随众人的看法重演一次"小马过河"?
自从微软宣布把F#产品化,很多人就开始问F#和C#哪个更好?为何他们如此关注这个问题?为何他们认为F#和C#是相斥而不是相承的?当今社会,竞争激烈之程度远胜以往,优胜劣汰的观念也深深扎根于人们的思想之中,新旧更替的现象更是见惯不怪,以至于人们在面对新事物时的第一反应几乎都是"它将要取代什么"。当今社会,发展迅猛之程度令人咂舌,人们要承受的东西似乎已经太多了,以至于稍稍停下来做点有益思考的时间都支付不起,更别说了解另一个可能有着巨大差异的世界观。无力包容多个不同的世界观会迫使你必须从中选择一个,而这又会在你的潜意识种下这样一个观念:我必须选择最好的那个。这个观念的异化会导致你选择编程语言就像奴才选主子一样,必须谨慎决定,以免选错了主子会连累自己的将来。试问,是编程语言为程序员服务,还是程序员为编程语言奴役?
牛顿说物体具有维持原来状态的惯性,人的思维和行为又何尝不是呢?李子勋在《心灵飞舞》里说:
每一种理论给人的视觉与感知觉创建了一种关联现实,我们越是欣赏一种理论,我们受到这种理论的制约也越多,我们的视觉与感知觉也越窄。如果我们还有无意识地否定其他理论的心理倾向,那么我们基本上就可以被称为某种理论的"囚徒"。越是深刻地相信和依赖一种理论,人们的认知能力越会被理论慢慢地缩窄到一个非常可怜的境地,直到完全失去心灵与感知的自由。
还记得自己有过多少次为了符合某个理论而采取某个措施而不是为了解决某个问题而选择某个理论吗?不同的编程思想就像看待事物的不同角度,无论你今天用哪个角度来看待事物,多一个选择总是有好处的,只要你没有在这些选择中迷失方向。
2009年3月3日
#
WM有约II(七):番外篇
Written by Allen Lee
别让软键盘遮住屏幕!
如果你的手机带有数字键盘或者Qwerty键盘,那么你可能不会遇到这种问题;但若你和我一样偏爱全触摸手机,那么你可能已经受到这种问题困扰多时了。当我们打开软键盘时,它会把屏幕的下部遮住,继而妨碍我们的操作:
图 1
这时候就轮到Orientation Aware Control的SipAwareContainer出场了,用法非常简单,把SipAwareContainer"夹"在父窗体和子控件中间就行了!我们来看看使用了SipAwareContainer的主窗体是怎样的(注意:每个TabPage的AutoScroll属性的值都已设为true):
图 2
这次,窗体懂得显示一个滚动条了,而且TabControl的选项卡也没有被软键盘遮住了,很好!我们再来看看Whitelist Editor:
图 3
从上图可以看到,没有控件被软键盘遮住,只是控件和边缘之间的距离没有了,有点不好看,如果此时我把软键盘关闭,将会出现一个很奇怪的现象:
图 4
难道这是……一个BUG?但在主窗体上却又一切正常,我试过在SipAwareContainer和其它控件之间再放一个Panel,并把Panel的AutoScroll属性的值设为true,结果还是一样。无奈之下,只好到官方论坛逛一下,虽然论坛上的帖子少的可怜,不过我还是惊喜地发现有人提到SipAwareContainer有问题,贴子上有人建议把控件的Anchor属性调整为Top和Right,事不宜迟,我们来试一下这个建议能否帮上忙。
经过一番调整,Whitelist Editor终于正常了:
图 5
关闭软键盘,控件回到原来的位置上:
图 6
在调整的过程中,我发现把所有控件的Anchor属性都设为Top和Right并非最佳方法,因为这样会导致有些控件超出窗体左边缘,而水平滚动条却没有出现,经过一番测试,我发现使用下面的设置效果最佳(即图5和图6的效果):
|
控件
|
Anchor属性的值
|
|
"Whitelist:"Label
|
Top、Left
|
|
ListBox
|
Top、Left、Right
|
|
Add按钮
|
Top、Right
|
|
Remove按钮
|
Top、Right
|
|
"Contact name:"Label
|
Top、Left
|
|
TextBox
|
Top、Left、Right
|
|
"..."按钮
|
Top、Right
|
|
OK按钮
|
Top、Right
|
|
Cancel按钮
|
Top、Right
|
表 1
有没有想过直接存取对象?
到目前为止,应用程序的所有数据都是存储在XML或TXT文件里的,今天,我想换一下口味,试一下db4o。下载并安装db4o 7.4 for .NET 3.5,接着向项目添加对Db4objects.Db4o.dll的引用,默认情况下,这个DLL在"C:\Program Files\Db4objects\db4o-7.4\bin\compact-3.5"目录里。
接下来,我会拿InterceptionHistory类来开刀,如果你不清楚它之前是如何实现的,可以先看看《WM有约II(五):区别对待不同的手机号码》。在修改任何代码之前,先把db4o的命名空间引用进来:
using Db4objects.Db4o;
InterceptionHistory类有一个LoadInterceptions方法,之前,我们用它来读取XML里的数据,创建Interception对象并添加到BindingList<Interception>里,现在,我们要把它改造成从db4o数据库里读取Interception对象,并添加到BindingList< Interception >里:
代码 1
首先,我们通过Db4oFactory.OpenFile方法创建数据库对象,接着,我们通过下面这行代码获取所有类型为Interception的对象:
db.Query<Interception>()
Query方法返回的已经是IList<Interception>集合了,为什么还要调用ToList方法呢?这是因为返回的集合是只读的,如果直接把它传给BindingList< Interception>集合的构造函数,那么当我们通过Add方法向BindingList< Interception>集合添加对象时将会引发异常。为什么会这样呢?我们可以用Reflector反编译BindingList<T>的构造函数,发现它只是简单地把参数传给它的父类:
代码 2
而当我们通过"base"追踪到Collection<T>的构造函数时,发现它也只不过是把参数简单地赋值给类型为IList<T>的私有成员,而不是像List<T>那样做个浅拷贝:
代码 3
于是,当我们调用Add方法时,将会引发NotSupportedException:
代码 4
这个问题在MSDN里并非没有描述,但比较隐晦,如果我没中招,可能这辈子都不会想到这么一个问题了:
图 7
当BindingList<Interception>发生改变时,我们只需把发生改变的对象保存到数据库就可以了:
代码 5
最后,别忘了把数据库的路径改过来:
m_FilePath = Helper.MapPath("InterceptionHistory.yap");
好了,又到测试的时候了,下面通过Cellular Emulator使用若干手机号码发送查询短信息:
图 8
接着,我们来看看应用程序的主窗体,嗯,历史纪录也对了:
图 9
到此为止啦?呃,我才不要呢,我还想为主窗体的历史纪录加一个过滤功能,通过这个功能,用户可以选择查看所有历史纪录或者今天的。首先,我们需要在主窗体上添加一个ComboBox:
图 10
过滤选项将会使用FilterOptions枚举来表达:
代码 6
当应用程序启动时,我们需要向上面那个ComboBox填充FilterOptions:
代码 7
当ComboBox的当前选中项发生更改时,将会通知InterceptionHistory,并重新绑定历史纪录:
代码 8
当FilterOption属性的值改变时,InterceptionHistory将会根据过滤选项重新装载数据:
代码 9
而LoadInterceptions方法也会做出相应的调整:
代码 10
因为Query方法不接受null作为参数,所以我们没办法把代码10里的两个条件分支统一起来。目前,过滤条件是在FilterOption属性的set访问器里构建的,将来如果有其它过滤需求,我们可以用一个Dictionary<FilterOptions, Predicate<Interception>>来存放过滤条件,然后在FilterOption属性的set访问器里根据属性值获取对应的过滤条件,并传给LoadInterceptions方法。
好了,又到测试的时候了,首先通过Cellular Emulator使用若干手机号码发送查询短信息,应用程序的主窗体默认显示所有历史纪录:
图 11
把系统时间修改为明天,然后把过滤条件改为Today看看:
图 12
嗯,很好!不过,在我更改过滤条件时,明显感觉得出应用程序的"迟钝",这可能是和我们每次操作都重新打开数据库连接有关,于是,我修改InterceptionHistory的实现,在构造函数里初始化一个IObjectContainer,并以私有成员的方式把它存到InterceptionHistory里,然后让InterceptionHistory实现IDisposable接口,并在Dispose方法里调用IObjectContainer.Close方法。重新运行应用程序,哇,情况不是一般的改善!另外,我还在网上找到一篇有趣的文章:
由于目前数据库的数据不多,无法体会出上面这篇文章所给的建议的好处,但我想历史纪录应该不会很多,因为每条纪录背后都可能意味着"一脚"的付出……说到这里,我们不难想象有些用户会要求应用程序支持自动回复的限额功能,至少在到达某个水平时发出警告……
别把我的短信耗光了!
目前,应用程序会不加限制地挥霍短信,这使部分用户感到担忧,他们非常希望应用程序能够按照他们的意愿节制一点,于是……废话少说,先来看看新的选项窗体:
图 13
请把注意力集中在窗体的下半部分,我们看到一个CheckBox和两个TextBox,那个CheckBox是用来启用配额策略的,上面那个TextBox是只读的,它告诉用户应用程序已经发出了多少条自动回复,它的右边有个Reset按钮,可以把计数清零,下面那个TextBox则是用于设置自动回复的配额上限。这些配置信息将会存储在Options.xml里:
代码 11
当选项窗体打开时,它会通过OptionManager读取配置信息;当用户单击OK菜单项时,它会通过OptionManager保存配置信息,这些功能的实现和之前的一样,如果你不清楚如何实现,可以先看看《WM有约II(三):整合Outlook Mobile的约会信息》。
那么,如何使用这些配置信息?一个最简单的做法就是在自动回复时检查是否超标(即usedReplyQuota的值大于totalReplyQuota的值),若是,什么也不做,否则,照常回复。但如果我们这样做的话,用户可能又要发飙了(是这个"飙"吗?):要不要回复应该由我说了算!
好吧,我们隆重请出今天的主角(确切地说应该是本节的主角)——NotificationWithSoftKeys!首先,确保我们的配额设置没有问题:
图 14
接着,通过Cellular Emulator使用若干手机号码发送查询短信息,当我发到第三条时,通知气球就冒出来了,当我发送第四条时,通知气球更新它的标题栏,告诉我们一共有多少等待发送的自动回复以及当前是第几条:
图 15
通知气球右上角的"2 of 2"旁边有两个导航按钮,分别用于向左和向右浏览等待发送的自动回复,而下面有两个菜单项,通常被称为Soft Key,分别用来发送和忽略当前显示的自动回复。默认情况下,通知气球的显示时间是10秒,在通知气球消失之后,用户可以通过屏幕最上面的通知图标重新打开通知气球。
那么,这个东西怎么实现呢?首先,我得感谢Christopher Fairbairn,要不是这个家伙搞了个NotificationWithSoftKeys,恐怕今天我的日子就难过了!下载他提供的压缩包,里面只有源代码没有DLL,你可以把它们编译成DLL,然后添加到你的项目里,你也可以直接把源代码添加到你的项目里。由于NotificationWithSoftKeys里我们现在的要求还有一段距离,所以我创建了一个NotificationQueue来扩展它。NotificationQueue应用了Singleton模式,我们在它的构造函数里配置NotificationWithSoftKeys:
代码 12
当用户单击通知气球右上角的导航按钮时,我们首先要判断能否执行对应的操作,即在第一页时不能向左导航,在最后一页时不能向右导航,接着更新当前索引和通知气球的标题和内容:
代码 13
其中,更新通知气球的标题和内容的工作由UpdateNotification方法来负责:
代码 14
当用户单击Send菜单项时,当前索引指向的自动回复将被发送,并更新UsedReplyQuota配置信息,然后删除通知气球的当前页;当用户单击Ignore菜单项时,将会删除通知气球的当前页:
代码 15
DeleteNotification方法会删除当前等待发送的自动回复,接着,如果没有等待发送的自动回复,就关闭通知气球,否则,更新当前索引,并更新通知气球的标题和内容:
代码 16
现在,万事俱备,只欠"排队"了:
代码 17
另外,NotificationQueue还实现了IDisposable接口,并在Dispose方法里调用NotificationWithSoftKeys.Dispose方法,以确保资源得到妥善的释放。
最后,我们还需要修改一下SmsProcessorBase.Process方法:
代码 18
至此,故事似乎有了一个完满的结局了,然而,就在此时,我却意外地发现,如果我一直放着通知气球不管,当应用程序退出时,它没有消失!
图 16
我明明调用了NotificationWithSoftKeys.Dispose方法,那为啥它还阴魂不散?原来,NotificationWithSoftKeys是通过把Visible属性设为false来关闭通知气球的:
代码 19
再来看看Visible属性的set访问器的代码:
代码 20
当我单击主窗体的Exit菜单项关闭应用程序时,通知气球是隐藏的,否则Exit菜单项那个位置应该是Send菜单项,换句话说,Visible属性的值是false的,此时,Dispose方法把Visible属性的值设为false将会怎样?Visible属性判断当前值和待设值是一样的,于是跳过整个代码20!难怪应用程序关闭后通知气球还健在……了解症结后,问题就不难解决了:
代码 21
至此,故事终于有个完满的结局了!
你还想要什么?
故事发展到现在,我想现有功能应该可以满足我老爸了吧(嘘,暂时还不能让他知道),慢着,我爸不看英文!噢,那么,下一集,我们来看看多语言支持?
2009年2月16日
#
WM有约II(六):分级限制
Written by Allen Lee
等级制度
在上一集里,我们把发送查询短信息的人分为"联系人"和"陌生人"两大类,应用程序会自动回复"联系人",忽略"陌生人",在这一集里,我们将会引入另外两种类型:"白名单"和"黑名单"。如果你看过上一集,你应该知道"联系人"和"陌生人"是互斥的,它们之间的区别是手机号码是否存在联系人里。"白名单"和"黑名单"是在"联系人"上发展出来的两个细分类型,它们也是互斥的,"白名单"允许执行所有查询,而"黑名单"则禁止执行所有查询。至于"陌生人",我们将会给它一个"注册"的机会,让它可以请求把自己加到联系人里。这四个等级将会通过AccessLevel枚举来表达:
代码 1
在这一集里,我们将会在这个"等级制度"的基础上实现如下效果:
|
访问级别
|
允许执行的操作
|
|
Whitelist
|
PingStatus、PingSchedule
|
|
Contact
|
PingStatus
|
|
Stranger
|
SignUp
|
|
Blacklist
|
N/A
|
白名单 & 黑名单
因为白名单和黑名单本质上只是两组姓名,所以我们选择最简单的文本文件来存储,每行一条记录。白名单和黑名单的管理分别由WhitelistManager和BlacklistManager来负责:
图 1
而读/写文本文件的任务则交给ReadAllLines和WriteAllLines两个方法:
代码 2
因为白名单和黑名单是互斥的,所以当我们向任一名单添加一个姓名时,必须确保该姓名不会出现在另一个名单里,比如说,当我们向白名单添加一个姓名时,如果该姓名已包含在黑名单里,则应先从黑名单删除该姓名,由于我们使用了BindingList<T>,于是检查工作可以在ListChanged事件触发时展开:
代码 3
接着,我们需要两个窗体来编辑白名单和黑名单:
图 2
是不是觉得这两个窗体很眼熟?事实上,它们仿照了Status Texts Editor(参见《WM有约II(二):持续改进》的图1)的做法,所以这里就不详述了。
最后,我们需要在主窗体放置两个菜单项,以便打开Whitelist Editor和Blacklist Editor:
图 3
现在,我们来看看运行效果,一开始,白名单和黑名单都是空的,我们打开Whitelist Editor,单击Add按钮:
图 4
单击TextBox右边的"..."按钮,ChooseContactDialog对话框将会打开:
图 5
选中一个联系人,ChooseContactDialog对话框将会关闭,选中的联系人的姓名将会出现在Whitelist Editor下面的TextBox里:
图 6
单击OK把该姓名添加到白名单里,重复上面步骤添加另一个联系人:
图 7
单击Whitelist Editor右上角的OK按钮关闭窗体并保存白名单。接着,打开Blacklist Editor,参照上面的步骤把Allen Lee添加到黑名单:
图 8
由于白名单和黑名单是互斥的,此时白名单应该没有Allen Lee了,打开Whitelist Editor验证一下:
图 9
如何界定访问级别?
首先,发送方要么在联系人里,要么不在,如果不在,那么他/她的访问级别就是Stranger,如果在,我们还要看看他/她是否在白名单或者黑名单里,如果也在,那么他/她的访问级别就是Whitelist或者Blacklist,否则就是Contact。就实现方式而言,我们应该优先考虑检查白名单和黑名单,因为从集合的角度来看,它们均是联系人的子集,如果发送方在任一名单里,我们就可以立即返回他/她的访问级别,而不必遍历所有联系人,于是,我们可以这样获取发送方的访问级别:
代码 4
那么,查询操作的访问级别又如何获取呢?我们知道,应用程序(目前)只支持三种查询操作,每种操作所需的最低访问级别如下表所示:
|
查询操作
|
所需的最低访问级别
|
|
PingStatus
|
Contact
|
|
PingSchedule
|
Whitelist
|
|
SignUp
|
Stranger*
|
*SignUp只针对陌生人开放。
不难看出,上表包含了查询操作和访问级别的映射关系,于是我们可以这样获取查询操作所需的最低访问级别:
代码 5
- A:且慢!为何要用switch?
- B:别激动,就目前而言,switch已经可以满足我们的需求了……
- A:目前?难道你不打算为将来做些什么吗?
- B:你是活在将来的吗?如果不是,你怎么知道将来会变成怎样?
不知道从什么时候开始,我也把思维的战线拉长了,比如说,当我写下上面那个表格时,我的脑子里就出现了代码5,接着,我直接在脑子里对它进行重构,想着设计模式,思考着如果不修改ISmsProcessor接口,要如何设计才可以使各个查询操作及其访问级别的关连变得更自然平滑,如果要让用户可以配置每个查询操作的访问等级,又要添加哪些类型,而这些配置信息又该以什么样的形式存储,如何管理等等。人们似乎对预测未来乐此不疲,也发明了各式各样的预测方法,或许,人们讨厌在毫无准备的状态下迎接未来的到来,但未来在到来之前只不过是一个虚幻,而且不稳定,如果我们过度依赖对未来的预测,那将会束缚现在,也会抹杀其它可能的未来……
回到我们的节目,假设我们继续使用代码5的GetSmsProcessorAccessLevel方法,我们如何才能判断发送方能否执行某个查询操作呢?很简单,我们可以通过GetSenderAccessLevel方法获取发送方的访问级别,接着判断这个级别是否在某个查询操作所需的最低访问级别之上(或者两者的访问级别相同),如果是,则可以执行该查询操作,于是,我们可以把SmsProcessorBase.IsAuthorized方法改成这样:
代码 6
好了,又到测试的时候了,沿用上面的测试结果,下表列出了本次测试所用的4个手机号码及其访问级别:
|
姓名
|
手机号码
|
访问级别
|
|
Jay Chou
|
15834561144
|
Whitelist
|
|
Eason Chan
|
15933449394
|
Contact
|
|
Leehom Wang
|
13813572468
|
Stranger
|
|
Allen Lee
|
13733449394
|
Blacklist
|
我们将会通过Cellular Emulator分别用这4个手机号码发送如下两个查询短信息:
- {Trombone:PingStatus}
- {Trombone:PingSchedule(2/15/2009 3:30 PM)}
图 10
从上图可以看到,只有3个自动回复,这是预期的结果,再来看看模拟器上的应用程序:
从上图可以看到,其它发出的查询短信息都被成功忽略了。
请把我加为好友……
下面,我们将会实现SignUp操作。首先,我们要定义这个操作的短信息,很明显,它包含的数据将用于创建Outlook联系人,最低限度应该提供姓名和手机号码:
{Trombone:SignUp(Leehom,Wang,13813572468)}
用户界面也需要做出相应调整:
图 11
这个用户界面仅向用户收集姓名,那么,我们如何获取本机的号码?你可以通过P/Invoke调用SmsGetPhoneNumber函数,也可以像我这样,使用OpenNETCF的Smart Device Framework。下载、安装Smart Device Framework 2.3,添加对OpenNETCF.Phone.dll的引用,接着就是修改Ping按钮的代码了(原本的代码参见《WM有约II(四):你明天有空吗?》的代码14):
代码 7
好了,运行一下看看效果:
图 12
再来看看Cellular Emulator,很好,注册短信息顺利发出了:
图 13
接下来就是注册短信息的处理了,因为它只针对陌生人,而且无需自动回复,所以我们不必遵守SmsProcessorBase类的套路,直接让SignUpProcessor类实现ISmsProcessor接口就可以了。在实现SignUpProcessor.Process方法之前,我们需要一个辅助方法来提取注册短信息里的数据:
代码 8
因为注册短信息的处理需要用户的参与,用户有权决定把发送方添加到联系人还是忽略他/她,所以应用程序应该把截获的注册短信息添加到某个队列里,以便用户过后查询和处理,避免用户在应用程序截获注册短信息时不得不马上处理的尴尬情景。为此,我们需要一个RegistrationQueue来暂存待处理的注册请求,由于每个注册请求都包含了接收时间、姓名和手机号码,于是我们用一个Registration类来存放这些数据:
代码 9
由于这些数据有可能跨越多个应用程序会话,于是我们需要把它们持久化到文件里,这里我还是选择使用XML:
代码 10
因为RegistrationQueue的实现和InterceptionHistory(参见《WM有约II(五):区别对待不同的手机号码》)相似,所以我就不在这里一一细说了。
回到SignUpProcessor.Process方法,在截获注册短信息时,我们需要判断发送方是否陌生人,若是,把相关数据添加到RegistrationQueue,否则,忽略该注册短信息。另外,我们还需要在InterceptionHistory里等级本次截获:
代码 11
最后,我们需要为此配备一个用户界面:
图 14
当应用程序启动时,我们要把RegistrationQueue绑定到上面的DataGrid:
代码 12
当DataGrid上选中的行发生改变时,下面的两个TextBox的内容要更新为当前选中行的姓名:
代码 13
当用户单击Add按钮时,应用程序将会用当前选中的注册信息来创建Outlook联系人,创建完毕后将会把注册信息从RegistrationQueue里删除:
代码 14
而当用户单击Remove按钮时,应用程序将会直接把当前选中的注册信息从RegistrationQueue里删除。
好了,又到了测试的时候了(在测试之前,别忘了把SignUpProcessor挂接到InterceptorManager里!),通过Cellular Emulator发送如下注册短信息:
{Trombone:SignUp(Leehom,Wang,13813572468)}
图 15
现在,我们把注意力集中到模拟器上:
图 16
从上图可以看到,注册短信息已被成功截获,单击Add按钮,应用程序将会用选中的注册信息创建Outlook联系人:
图 17
此时,如果我们通过Cellular Emulator使用刚才的手机号码发送如下查询短信息:
{Trombone:PingStatus}
将会收到应用程序的自动回复:
图 18
而主界面上的截获历史也能正确显示发送方的姓名了:
图 19
你还想要什么?
有一次,我和一个六年级的学生一起看动画片,当我们看到正义的代表向怪兽发动反击时,我问他:"你现在看到这种情节还会觉得体内有一股激动在奔流吗?"他说没有。曾几何时,当我们碰到可以使用新技术的机会时,我们也会异常激动……
或许,在很多人的眼里,技术只不过是用来解决问题的工具罢了,然而,我更倾向于通过学习/使用技术寻找乐趣。下一集,要不我们试一下db4o吧,然后,再试一下Windows Mobile上的Notification,就是当你收到短信息时屏幕下面弹出的提示框……