WP7有约(二):课后作业
插曲 #1
究竟发生了什么事?示例数据和绑定表达式应该都没问题啊,否则Expression Blend和Visual Studio的设计器也不会正常显示,那么问题到底出在哪里呢?突然,一个想法在我的脑子里闪过,如果我在DateConverter类的Convert方法里设个断点,你觉得会怎么样?试一下吧……结果是,没有到达这个断点,换句话说,Convert方法根本没被调用!这种情况有点像数据绑定找不到分组对象的Key属性,比如说,我故意把绑定表达式的Key改为Key1,结果Expression Blend的设计器就变成这样了:
图 24
我们知道,分组对象实现了IGrouping<TKey, IElement>接口,因此Key属性肯定存在,否则编译器会报错,那么,什么情况下这个属性是不可见的,或者说,有什么办法可以让它不可见?想到这里,一个词儿突然在我的脑子里冒出来——显式接口实现!如果Key属性是显式实现的,仅当变量的类型是IGrouping<TKey, IElement>时Key属性才是可见的。看到这里,你可能会说,Silverlight不可能直接调用分组对象的Key属性,它应该是通过反射获取这个属性的。没错,当我们在绑定表达式里以字符串的形式给出属性路径,PropertyPathConverter对象将会把这个字符串转换成PropertyPath对象,那么,PropertyPath对象又是如何找到对应的属性呢?在微软公开的.NET Framework 4.0源代码里,我找到了PropertyPath类的实现,里面有个GetPropertyHelper方法负责获取指定的属性:
代码 17
如果Key属性是显式实现的话,GetProperty方法就会返回null!换句话说,数据绑定和显式实现的属性一起工作的话会出问题。那么,group XXX by YYY返回的分组对象是不是显示实现Key属性的呢?我们知道,使用group XXX by YYY实质上就是调用Enumerable类的GroupBy方法,经过一番查找,我发现它返回的分组对象就是Lookup类内部的Grouping类的实例,但Grouping类的Key属性是隐式实现的,有趣的是,Key属性上方有一段注释:
代码 18
除了Key属性之外,Grouping类的其它属性都是显式实现的,我猜Key属性原来也是显式实现的,后来由于数据绑定的问题才改为隐式实现。
这些代码是WPF 4.0的,而Key属性上面的注释也明确提到了WPF,这是不是说Key属性的值在WPF里可以正确显示?我们可以设计一个简单的实验来验证一下:
- 创建一个ListBox。
- 定制ListBox的ItemTemplate,里面只放一个TextBlock。
- 把TextBlock的Text属性设为"{Binding Key}"。
- 通过GroupBy方法创建分组对象的集合,并把它绑到ListBox的ItemsSource属性。
- 按F5。
我分别在WPF 4.0、SL 4.0和SL for WP7上执行这个实验,发现只有WPF 4.0能够正确显示Key属性的值,其它两个的ListBox是一片空白的。我怀疑SL的分支是在这个问题得到修复之前创建的,但我没有代码证实这个猜想。
还有一个问题我没弄明白的,为什么设计器能够正确显示而程序真正运行的时候却不能?难道设计器对显式实现的属性有什么特别的照顾?为了验证这个猜想,我又做了一个实验,我不直接返回分组对象,而是通过下面这个Grouping类包装一下再返回:
代码 19
结果,设计器也不显示了……我不知道为什么设计器能够正确显示GroupBy方法返回的分组对象的Key属性,这里面肯定有些东西是我不知道的,如果你知道原因,或者先我一步找到原因,那你一定要告诉我哦!
连接前端和后端
既然显式实现的属性会对数据绑定造成不良影响,那我们就换成隐式实现吧。首先,在ViewModels文件夹里创建AssignmentGroupViewModel类,并让它继承ObservableCollection<Assignment>类:
代码 20
为什么要继承ObservableCollection<Assignment>类呢?前面说过,LongListSelector控件硬性规定分组对象至少实现IEnumerable接口,不过,要想获得更好的效果,仅仅实现IEnumerable接口是不够的,LongListSelector控件通过内部的GetItemsInGroup方法来获取分组内容:
代码 21
从上面代码不难看出,如果分组对象实现了IList接口,那么每次获取分组内容时都会免掉一次遍历。此外,我们还希望当分组内容发生改变时,比如新建/删除一项作业,分组对象能够自动通知LongListSelector控件做出相应的更新,为了实现这个效果,分组对象需要实现INotifyCollectionChanged接口。毫无疑问,能够一次过满足我们所有要求的最简单做法就是继承ObservableCollection<Assignment>类了。
看到这里,你可能会问,IGrouping<TKey, TElement>接口不用实现吗?不用,LongListSelector控件没有规定分组对象必须实现这个接口,我们只需简单地创建一个Key属性,配合绑定表达式里的属性路径就行了:
代码 22
需要说明的是,ObservableCollection<Assignment>类也实现了INotifyPropertyChanged接口,所以我们可以直接使用它的OnPropertyChanged方法。
接下来是分组对象的初始化,这个过程的主要任务有两个:
- 查询数据源,把满足条件的作业内容添加到自身。
- 监听数据源,把满足条件的内容更改反映到自身。
执行这两个任务的前提是有个可用的数据源,我们可以仿效课程表的做法,在App类里通过静态属性提供JsonDataStore<Assignment>对象:
代码 23
有了数据源我们就可以着手执行第一个任务了:
代码 24
需要说明的是,这里把判断条件单独提取出来了,因为执行第二个任务时还要用到:
代码 25
需要说明的是,e参数的NewItems和OldItems两个属性看起来好像可能包含多个元素,但事实上它们只会包含一个,因为NotifyCollectionChangedEventArgs类的构造函数限制了这个可能,不过这个限制仅存在于Silverlight的现有版本(SL3、SL4、SL for WP7)。另外,这里使用了Lambda语句来创建CollectionChanged事件的处理程序,虽然你也可以通过一个单独的方法做到,但使用Lambda语句可以利用闭包的特点重用前面的判断条件,当然,使用匿名方法的语法也是可以的。
还差什么呢?噢,对了,LongListSelector控件内部会调用分组对象的Equals方法进行判等,我们可以重写AssignmentGroupViewModel类的Equals和GetHashCode两个方法,使之根据Key属性来判等以及获取哈希值。这个任务留给你当课后作业吧。
既然分组对象的类型改了,那AssignmentListViewModel类的AssignmentGroups属性也得做出相应的调整吧:
代码 26
由于AssignmentListViewModel类对应用户界面上的Pivot项,我们还需要给它创建一个Title属性:
代码 27
有了这些准备,我们就可以着手实现AssignmentListViewModel类的构造函数了:
代码 28
看到这里,你可能会说,这条LINQ语句看起来有点复杂嘛!其实不然,想想看,我们的最终目的是什么?创建分组对象并把它们添加到AssignmentGroups属性。那创建分组对象需要什么条件?课程名称和创建日期。课程名称已经有了,创建日期来自哪里?来自数据源。那我们对创建日期有些什么要求?我们只要和指定课程相关的,而且不要重复的。现在,你再看看上面这条LINQ语句,从上往下看,有没有觉得它像下面这条"流水线"?
图 25
前面我们说过,当用户新建一项作业时,它会自动添加到"今天"的分组里,但如果"今天"的分组还没创建出来呢?那AssignmentListViewModel类就应该为这项新的作业创建"今天"的分组,并把它添加到AssignmentGroups属性:
代码 29
当用户删除一项作业时,如果这项作业是所属分组的唯一一项作业,LongListSelector控件会自动隐藏这个分组。而当用户撤销所有更改时,AssignmentListViewModel类得把AssignmentGroups属性清空。
到目前为止,AssignmentBookPage页里的每个组成部分都有对应的ViewModel类了,现在是时候为它创建一个了。在ViewModels文件夹里创建一个AssignmentBookViewModel类,并创建一个AssignmentLists属性:
代码 30
AssignmentBookViewModel类的任务是读取课程表的数据,然后创建对应的AssignmentListViewModel对象:
代码 31
看到这里,你可能会问,为什么这里不用监听数据源的更改?如果你要编辑课程表,一定要进入课程表的用户界面,一旦离开课程表的用户界面,课程表的数据就会冻结下来,换句话说,在AssignmentBookViewModel对象的整个生命周期里,课程表的数据是稳定的。
现在,我们可以着手处理数据绑定了。打开AssignmentBookPage.xaml文件,切换到XAML模式,在页面的资源字典里添加两个数据模板:
代码 32
接着,把现有的Pivot项删除,并在Pivot控件上设置数据模板和数据绑定:
代码 33
最后在AssignmentBookPage的构造函数里创建一个AssignmentBookViewModel对象,并它把赋给DataContext属性:
代码 34
好了,不知不觉又到看效果的时候了!按F5运行应用程序:
图 26
单击"课程表"菜单项进入课程表,新建两个课程,保存,然后按Back键返回主菜单:
图 27
在主菜单里单击"作业本"菜单项进入作业本,此时,你会看到作业本已经为刚才创建的两个课程准备了两个Pivot项:
图 28
只是作业本上没有任何内容,也没有任何途径可以添加内容……