WP7有约(二):课后作业
Written by Allen Lee
I'm tired of being what you want me to be, feeling so faithless, lost under the surface.
– Linkin Park, Numb
作业本
上节课布置的作业有做吗?没人吭声啊,看来大家都忘了哦,没事,我们这次弄个作业本出来,大家就有地方记作业了。在开始设计应用程序之前,我们先来看看通常的作业本是怎样记作业的:
图 1
从上图可以看到,作业本有点像日记本,每次记录时都会写下当天的日期,每天的作业又会根据课程进行归类。慢着!我怎么知道这些作业什么时候交?一般情况下,中小学生的作业都是第二天上课时交的,但大学生就不同了,他们的作业可能第二天交,也可能一周之后交,有时甚至几周之后才交,更重要的是,不同的作业可能在不同的时间交。换句话说,我们的应用程序还需要支持记录交作业的时间。此外,每当完成一项作业,我们可以在旁边做个记号,这样,当我们打开作业本时,即使作业再多也能马上知道哪些还没做完。
现在,用Visual Studio打开项目,在Models文件夹里创建一个Assignment类,和上节课的Course类一样,它也需要实现INotifyPropertyChanged接口。由于我们有很多类都需要实现INotifyPropertyChanged接口,为了避免不必要的重复,你可以考虑创建一个类专门实现这个接口,然后让有需要的类继承这个类。这个需求似乎比较常见,因此Prism提供了一个NotificationObject类,我们只需继承它就行了:
代码 1
继承之前别忘了引用Bin\Phone\Microsoft.Practices.Prism.dll类库和Microsoft.Practices.Prism.ViewModel命名空间哦。根据前面的讨论,Assignment类应该包含以下属性:
属性名字 | 属性类型 | 备注 |
Id | Guid | 唯一标识 |
CourseName | string | 课程名称 |
StartDate | DateTime | 创建日期 |
DueDate | DateTime | 截止日期 |
Content | string | 作业内容 |
IsCompleted | bool | 完成状态 |
表 1
我们知道,Id属性作为唯一标识,其值一旦生成就不会改变,因此我们只需在构造函数里初始化它就行了:
代码 2
而其它属性则需要在它们的set访问器里调用从NotificationObject类继承过来的RaisePropertyChanged方法,比如说,我们可以这样实现IsCompleted属性:
代码 3
看到这里,你可能会说,作业的状态应该不止"已完成"和"未完成"两种啊,比如说,当老师刚把作业布置下来时,它应该是"未开始";当我们开始做某项作业时,它应该是"进行中";有时候准备工作还没好,我们不得不把作业推迟,此时它应该是"已推迟";有时候老师可能大发慈悲说某些作业不用做了,此时它应该是"已取消",等等等等。照这样说,我们是否也该考虑把现在的两个日期细化为"计划开始日期"、"计划结束日期"、"实际开始日期"和"实际结束日期",然后加上一个"作业进度"什么的?千万不要这样,没有学生愿意采用这么细致的作业管理方案,再说这样做也会分散他们的注意、加重他们的负担,作业本的主要目的只有一个,就是让学生对要做哪些作业一目了然,所有功能的设计都应该围绕这点展开,所有功能的取舍也应该以此为标准。
保存作业本
数据存储方面,我打算仿效课程表的做法,通过JSON序列化把作业本的数据保存到独立存储区,实现这个并不难,你可以照搬课程表的做法,创建一个IAssignmentStore接口和一个JsonAssignmentStore类。当你实现完JsonAssignmentStore类之后,你将会发现它和JsonCourseStore类有99.9%的代码是相同的,事实上,你可以把JsonCourseStore.cs文件复制一份,并重命名为JsonAssignmentStore.cs,然后把里面的"Course"字眼都替换成"Assignment"就可以了。不过,这种重复着实让人不爽啊,看来是时候重构一下了。
ICourseStore接口和IAssignmentStore接口的区别只在于集合元素的类型和集合属性的名字,前者可以通过泛型统一起来,至于后者,我们可以把属性的名字统一为Items,这样,两个接口就能统一起来了:
代码 4
而实现方面,我们可以创建一个JsonDataStore<T>类,并让它实现IDataStore<T>接口:
代码 5
需要说明的是,之前我们把文件名硬编码在JsonXXXStore类里,那是因为它对于JsonXXXStore类来说是固定的、一对一的,而现在的JsonDataStore<T>类不再仅仅对应一个文件,因此我们把它保存在一个私有字段里。其它的和JsonAssignmentStore类没有太大出入。
看到这里,有些同学可能会问,ICourseStore接口和JsonCourseStore类已经投入使用了,现在换用IDataStore<T>接口和JsonDataStore<T>类会不会造成很大影响?这个问题问得好,如果你确实不想修改其它代码,那你可以把JsonCourseStore类改造成JsonDataStore<Course>类的"马甲":
代码 6
需要说明的是,我们通过继承JsonDataStore<Course>类获得Rollback和Commit两个方法的实现,此外,由于其它代码是通过ICourseStore接口间接使用JsonCourseStore类的实例的,于是我们保留了ICourseStore接口,并把Courses属性重定向到Items属性。
不过,就项目现在的规模而言,我们可以把重构做的更彻底一些,我们可以把ICourseStore.cs和JsonCourseStore.cs两个文件删除,如果你想保险一点,可以先把它们从项目排除出去,然后重新编译,此时Visual Studio会告诉你找不到ICourseStore接口和JsonCourseStore类,分别把它们替换成IDataStore<Course>接口和JsonDataStore<Course>类,调用后者的构造函数时记得提供文件名,即Courses.json,重新编译,此时Visual Studio会显示一堆错误,全部都是说找不到Courses属性的,把它们都替换成Items属性,重新编译,好了,如果那两个文件还没删除的话,现在可以安全删除了。
原型
现在是时候考虑一下用户界面了,仔细观察我们的作业本(图1),是否觉得这种布局方式有种似曾相识的感觉?如果你一直关注WP7的相关消息,你可能已经看过类似的用户界面了——People Hub的联系人列表。下面我们把它们两个放在一起看看:
图 2
从上图可以看到,作业列表和联系人列表刚好能够对应起来,课程名称对应姓氏首字母,作为分组标题,而作业内容则对应联系人,作为分组内容。看到这里,你可能会问,WP7的Silverlight貌似没有这样的控件啊,难道要我们自己动手弄一个?原本是没有的,不过十一月发布的SL for WP Toolkit已经增加了这个控件,名字叫做LongListSelector。上节课我们使用了Silverlight for Windows Phone Toolkit的TimePicker控件,当时引用的是九月份发布的版本,现在你可以下载新的版本,然后重新引用一下。
仔细观察上图,你会发现作业列表上面有个日期没法对应到联系人列表,我们该怎么处理这个日期呢?这个问题问得好,事实上,这正是作业列表和联系人列表的最大区别,我们知道,联系人列表只有一份,但作业列表却会有很多份,每份都会有一个不同的日期,这些作业列表共同组成了一本作业本。如果把每份作业列表看作一个由标题和LongListSelector控件组成的页面,那么整个作业本就可以看作由N个这样的页面组成的应用程序了,但我们不必真的创建N个这样的页面,我们可以仿效课程表的做法,利用Pivot控件的特点,让每个Pivot项显示一份作业列表,这样Pivot项的标题可以用来显示作业列表上面的日期,而标题下面则通过LongListSelector控件显示每个课程的作业。不过,这样的设计是否真的妥当呢?
试想一下,如果我们首先通过日期来划分Pivot项,接着通过课程来划分作业,那么每次我们要新建作业的时候,我们可能得先创建一个Pivot项,如果对应今天的Pivot项还没有的话,接着指定作业所属的课程,最后才填写和作业相关的信息,这个过程显然有点繁琐,我们应该尽可能简化其中的步骤。说到这里,有些同学可能会建议,不如让应用程序自动创建今天的Pivot项,这样至少可以省掉一个步骤。嗯,这个主意值得考虑,不过,并非每天都会有作业,比如说,今天是星期天,我进入作业本只是看一下这个周末有哪些作业,但应用程序却自动为我创建了今天的的Pivot项,而这并非我想要的,这意味着应用程序不得不在退出的时候把这个空的Pivot项删除。事实上,对于大学生来说,尤其是大三、大四的,今天有课明天没有是很常见的,难道要让用户设置哪天有课哪天没课,或者干脆直接解释课程表的数据,看看哪天有课哪天没课?从上面讨论不难看出,日期这个因素很不稳定,不太适合用来划分Pivot项,但课程就不同了,一旦课程表创建好了,作业本上会有哪些课程的作业也就定下来了,既然这样,何不把分组的顺序换一下?如果我们通过课程来划分Pivot项,那就不用考虑Pivot项的创建和删除了,因为用户在访问作业本的过程中会涉及到哪些课程是确定的,此外,当用户新建作业时也无需额外的步骤来指定今天的日期,因为这可以从DateTime的Today属性获取,这样我们就为用户省下两个步骤了。从这里我们可以看到,应用程序的设计绝对不是把控件堆砌起来显示数据就完事了,它包含的是一组完整的用户体验,而不同的组织方式可能会产生完全不一样的用户体验,有时候多一两个步骤好像没什么大不了,但假如这一两个步骤要重复十次的话,用户就要额外执行十几二十个这样的步骤了,要么你为用户省下这些步骤,要么你让竞争对手为用户服务。
现在让我们切换到Expression Blend,创建一个Windows Phone Pivot Page,并把它命名为AssignmentBookPage.xaml,完了之后把Pivot控件的Title属性设为"作业本",把两个Pivot项的Header属性分别设为"数学"和"英语",最后把一个LongListSelector控件拖到第一个Pivot项里:
图 3
接下来我们要为LongListSelector控件定制作业的显示方式,而执行这个任务的最佳场所是Expression Blend,但要发挥Expression Blend的潜能,我们需要准备一些示例数据,那么我们是否可以像上节课那样导入一些XML数据,然后把它们拖到LongListSelector控件上呢?很遗憾,不行,因为LongListSelector控件对于需要进行分组显示的数据源有特别要求。你可能以为我们只需把一个作业集合赋给ItemsSource属性,然后指定集合元素的某个属性作为分组依据,LongListSelector控件就会自动为我们分组,但事实并非如此,LongListSelector控件要求我们先把数据分组好,然后把这些分组凑成一个集合赋给ItemsSource属性,而且硬性规定每个分组至少实现IEnumerable接口,否则初始化时将会因为转换失败而抛出InvalidCastException异常,此外,为了便于显示分组标题,每个分组最好有个属性保存标题的内容,那么我们如何创建这样的数据源?其实创建这样的数据源并不难,LINQ的group XXX by YYY完全可以胜任这项任务,难处在于我们还想让它在Expression Blend的设计器上显示,所以我们得费一点儿周折了。
首先,切换到Visual Studio,在ViewModels文件夹里创建一个AssignmentListViewModel类,并让它继承NotificationObject类:
代码 7
接着,创建一个GetAssignments方法,返回一些Assignment对象:
代码 8
然后,再创建一个AssignmentGroups属性,通过LINQ选取全部数学作业并根据创建日期进行分组:
代码 9
做好这些准备工作之后,我们就可以着手把示例数据关联到用户界面上了。打开AssignmentBookPage.xaml文件,创建一个资源字典,并在里面创建一个AssignmentListViewModel对象:
代码 10
好了之后就把第一个Pivot项的DataContext属性设为上面创建的AssignmentListViewModel对象,并把LongListSelector控件的ItemsSource属性绑到这个对象的AssignmentGroups属性:
代码 11
此时,如果你切换到Expression Blend,它会提示你重新加载文件,因为刚才我们在Visual Studio里做了修改。加载完毕之后,你会看到LongListSelector控件里多了一些东西:
图 4
从上图可以看出,示例数据已经绑上去了,但为什么显示出来的是"Iridescent.Models.Assignment",而且每个都是一样?这是因为LongListSelector控件并不知道如何显示Assignment对象,所以直接调用它们的ToString方法获取可以显示的内容,而我们在创建Assignment类的时候并未重写ToString方法,所以LongListSelector控件调用的是从Object类继承下来的版本,这个版本返回的是对象的类型的完全限定名,也就是我们刚才看到的"Iridescent.Models.Assignment"。那么分组标题又哪去了?事实上,分组标题并未显示出来,因为LongListSelector控件并不知道分组的哪个属性表示分组标题。换句话说,LongListSelector控件压根不知道如何使用我们提供的数据,而把使用方法告诉它正是我们的责任。
定制数据模板
首先是定制分组标题的数据模板,右击LongListSelector控件里的任何地方,选择Edit Additional Templates\Edit GroupHeaderTemplate\Create Empty:
图 5
在弹出的Create DataTemplate Resource对话框里输入模板名字,然后按OK关闭对话框:
图 6
进入模板的编辑状态之后,你会看到一个空的Grid,从Tools面板把一个TextBlock拖到Grid里,确保TextBlock处于选中状态(而不是编辑状态),单击Text属性右边的小正方形,并选择Data Binding:
图 7
在弹出的Create Data Binding对话框里选中Use a custom path expression,并在旁边的编辑框里输入Key:
图 8
为什么输入Key呢?因为通过LINQ的group XXX by YYY创建的分组对象实现了IGrouping<TKey, TElement>接口,而这个接口有个Key属性保存了分组的依据——创建日期,也就是这里需要的分组标题了。
当你按OK关闭对话框之后,你将会看到:
图 9
奇怪了!我们明明提供了示例数据啊,而且数据绑定也没弄错啊,为什么TextBlock没有任何显示?仔细观察Text属性下面的DataContext属性:
图 10
此时的值应该是分组对象而不是AssignmentListViewModel对象啊!我怀疑LongListSelector控件没有正确处理DataContext在设计时的传递(bug?),导致Expression Blend无法获取正确的数据。既然这样,我们只好再弄点示例数据了,单击Text属性右边的编辑框,选择Reset,然后把Text属性的值改为"2010/11/29"。接着,在Objects and Timeline面板上选中Grid,单击Background属性右边的小正方形,并选择System Resource\PhoneAccentBrush:
图 11
此时,你的Artboard应该是这样的:
图 12
退出模板的编辑状态,保存所有修改,然后重新编译项目,好了之后就能看到分组标题了:
图 13
不要奇怪分组标题都是"2010/11/29",这是我们刚才为了编辑的方便硬编码上去的结果,暂时忍耐一下吧。
接下来是列表项的数据模板,右击LongListSelector控件里的任何地方,选择Edit Additional Templates\Edit ItemTemplate\Create Empty,在弹出的Create DataTemplate Resource对话框里输入模板名字(itemTemplate),然后按OK关闭对话框。现在,我们要思考的问题是,如何更好地显示作业数据呢?回顾表1,Id属性为了便于应用程序搜索Assignment对象而创建的,用户并不需要知晓它的存在,所以我们不必把它呈现在用户面前,Pivot项的标题已经显示了CourseName属性,分组标题也显示了StartDate属性,剩下的就是DueDate、Content和IsCompleted三个属性了,那么我们应该如何显示这三个属性?此时,我的脑子里浮现出的第一个想法是这样的:
图 14
整个Grid分为两个Column,左边是作业内容,自动换行,右边从上到下分别是截止日期的月、日和完成状态,一般情况下,创建日期和截止日期的年份都是一样的,所以我们没有必要提供重复的信息,即使碰到跨年的情况,用户也不会因为缺少年份而感到疑惑,除非有个老师布置了一个跨越两年或以上的作业。想到这里,我的脑子里突然闪出一个问题,表示完成状态的TextBlock能否去掉,并以其它方式表达这个信息呢?此时,我的脑子里迅速浮现出各种各样的图标,但是,还有更好的方式吗?颜色,突然这个词儿从我的脑子里掠过,一般而言,与文字相比,我们的大脑对颜色的反应更快更准。有鉴于此,我把列表项的模板改成这样:
图 15
右边部分将会根据作业的不同状态显示不同底色。退出模板的编辑状态,保存所有修改,然后重新编译项目,好了之后就能看到效果了:
图 16
显然,字体的大小、控件之间的间距还不能让人满意,我们需要调整一下,这个过程可能有点反复和枯燥,但这却是我们体贴用户的重要途径,我们不但要让用户的眼睛感到满意,还要让用户的手指感到满意(别忘记我们开发的是触屏应用程序哦),下面是我调整之后的效果:
图 17
现在,我们可以再次进入模板的编辑状态,为对应的控件设置数据绑定了,做法和前面为分组标题设置数据绑定的一样(图7和图8),各个控件对应的自定义路径表达式如下图所示:
图 18
好了之后就可以看到我们前面准备的示例数据了:
图 19
噢,分组标题!我希望只显示日期,而且是符合中国区域设置的短日期格式,还有月份的显示,我希望是"十一月"而不是"11"。
这个时候又轮到转换器出场了。首先,切换到Visual Studio,在Utils文件夹里创建下面两个类:
代码 12
代码 13
需要说明的是,因为我们的绑定是单向的,所以没有必要实现ConvertBack方法。接着,在AssignmentBookPage.xaml的资源字典里创建它们的实例:
代码 14
看到这里,你可能会问,这两个转换器的Convert方法都使用了culture这个参数,但我们没有直接调用Convert方法啊,那我们怎么把这个参数传给它?这可以通过设置绑定表达式的ConverterCulture属性做到,现在,把那两个TextBlock的Text属性的绑定表达式改为"{Binding Key, Converter={StaticResource dateConverter}, ConverterCulture=zh-CN}"和"{Binding DueDate.Month, Converter={StaticResource monthNameConverter}, ConverterCulture=zh-CN}"。
剩下的就是截止日期的底色了,既然转换器可以把DateTime对象转换成字符串,它也应该可以把Assignment对象转换成SolidColorBrush对象,不过,在创建这个转换器之前,我们得先弄清楚什么状态对应什么底色。前面我们说过,作业本的主要目的是让学生对要做哪些作业一目了然,而"未完成"的作业里可能存在一些已经过了截止日期的,这类作业需要马上处理,所以我们应该单独为这类作业设置一种底色,以便用户及时知晓并采取行动。假设这三种状态及其对应的底色如下表所示(你也可以换成其它底色):
状态 | 底色 |
已逾期 | Red |
未完成 | #FF1BA1E2 |
已完成 | Green |
表 2
那么转换器的Convert方法可以这样实现:
代码 15
接着,在AssignmentBookPage.xaml的资源字典里创建它的实例(参考代码14),并把那个StackPanel的Background属性的绑定表达式改为"{Binding Converter={StaticResource assignmentToBrushConverter}}"。
好了之后就编译一下,没问题的话就可以看到效果了(你也可以在Visual Studio里看):
图 20
看到这里,你可能会问,"未完成"的底色和分组标题的底色是一样的,为什么不直接使用PhoneAccentBrush这个系统资源呢?这是因为用户有可能在手机的Settings里把Accent Color设成和其它状态一样的颜色,这会导致两种不同的状态应用相同的底色,而用户也有可能因此获得错误的信息。
现在,如果你试图编辑列表项的数据模板,你将会看到此番情景:
图 21
怎么回事?!从上图不难看出,底色转换器的Convert方法抛出一个InvalidCastException异常,而异常的信息也明确告诉我们无法把对象从AssignmentListViewModel类型转换成Assignment类型(把滚动条向右拖动就可以看到了)。在Convert方法里,我们只做过一次转换,就是在开始的时候把value参数转换成Assignment类型(参见代码15),因为此时的DataContext应该是Assignment对象,但上面这个异常却告诉我们value参数不是Assignment对象!为什么会这样?还记得前面编辑分组标题的数据模板时,即时我们设置好数据绑定也看不到示例数据(参见图9),当时我们猜测LongListSelector控件没有正确处理DataContext在设计时的传递(参见图10),而这个猜测在这里得到了证实。明白为什么会这样,问题就不难解决了,把Convert方法开始那行强制转换换成下面这段代码:
代码 16
编译一下。现在,如果你尝试编辑列表项的数据模板,你不会再看到上面的异常了。
接下来干嘛?你懂的!打开MainPage.xaml,添加一个菜单项,并让它导航至AssignmentBookPage页:
图 22
好了,按F5吧……
图 23
啊?!分组标题哪去啦??
插曲 #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
只是作业本上没有任何内容,也没有任何途径可以添加内容……
编辑作业本
作业本支持的操作和课程表一样,包括新建、编辑、删除、保存所有更改和撤销所有更改,其中,新建和保存以ApplicationBarIconButton的方式放在Application Bar上,撤销所有更改以ApplicationBarMenuItem的方式放在Application Bar上,而编辑和删除则放在上下文菜单里:
图 29
为什么这样安排?当老师布置作业时,我们会掏出作业本记下作业,下课之后,当我们要做作业时,我们会掏出作业本看看要做哪些作业,换句话说,新建、保存和显示作业内容这三个功能已经可以满足用户绝大多数的需求了。新建和保存作为最常用的两个操作自然应该放在最显眼的位置,删除和撤销所有更改这两个操作基本上不会用到,至于编辑,一般情况下我们只是用来修改作业的完成状态,由于编辑和删除是针对特定作业的,我们把它们放在上下文菜单里,当用户长按某项作业时将会显示出来,而撤销所有更改则隐藏在Application Bar的菜单里。
接着,创建一个Windows Phone Page,并把它命名为NewOrEditAssignmentPage.xaml,这个页面会在用户单击Application Bar上的新建按钮或者上下文菜单上的编辑菜单项时显示。完了之后把ApplicationTitle的Text属性值改为"作业本",但PageTitle保留原样:
图 30
那么,这个页面应该放些什么控件呢?想想看,创建一个完整的Assignment对象需要哪些数据?Id是自动生成的,课程名称可以从上下文获取,创建日期可以从DateTime的Today属性获取,剩下的就是截止日期、作业内容和完成状态了。截止日期可以使用SL for WP Toolkit的DatePicker控件,作业内容可以使用TextBox控件(上面的标题需要额外添置TextBlock控件),而完成状态则可以使用CheckBox控件:
图 31
看到这里,你可能会问,为什么不把其它信息也显示出来呢?你可以这样做,但是,请注意,这个页面的主要目的是收集而不是显示信息,我们应该尽可能简化用户的输入过程,在这里放置控件显示其它信息,尤其是可编辑的控件,可能会耗费用户额外的注意力,比如说,有些用户会下意识地检查所有数据是否输入正确。创建作业的过程应该是既简单又快速的,而我们也希望用户能有这样的感受,但耗费用户额外的注意力意味着增加整个操作过程的时间,从而可能导致用户的感受和我们期望的刚好相反,这是我们不希望看到的。
ViewModel类方面,我们将会仿效课程表的做法,创建NewOrEditAssignmentViewModel、NewAssignmentViewModel和EditAssignmentViewModel三个类:
图 32
我们知道,NewOrEditAssignmentPage页有两个模式,一个是新建模式,另一个是编辑模式,前者对应NewAssignmentViewModel类,而后者则对应EditAssignmentViewModel类。当用户新建一项作业时,NewAssignmentViewModel类可以从DateTime的Today属性获取创建日期,但它没法获取课程名称,所以我们需要通过参数传给它:
代码 35
为什么DueDate属性也要设置呢?想想看,如果我们不给它设置一个值,由于DateTime是值类型,将被自动初始化为"1/1/0001",当用户看到页面上的DatePicker控件显示这样一个日期可能会感到不友好,再者,老师布置下来的作业一般不会当天交(课堂作业除外),而第二天交的情况则比较常见(当然,计算下一个"上课日"可能更加合理)。而当用户编辑一项作业时,EditAssignmentViewModel类将会从数据源里查找这项作业的数据,但前提是我们把作业的Id告诉它:
代码 36
需要说明的是,Assignment类的Id属性是只读的,而Assignment类原来的构造函数会在每次调用时创建一个新的Id,这导致了我们无法使用现有的Id,所以我们需要在Assignment类里添加下面这个构造函数:
代码 37
创建好ViewModel类之后,我们就可以着手处理它们和NewOrEditAssignmentPage页之间的关联了。首先是设置数据绑定,需要设置的控件以及对应的绑定表达式如下表所示:
描述 | 类型 | 属性 | 绑定表达式 |
页面标题 | TextBlock | Text | {Binding Title} |
截止日期 | DatePicker | Value | {Binding Assignment.DueDate, Mode=TwoWay} |
作业内容 | TextBox | Text | {Binding Assignment.Content, Mode=TwoWay} |
完成状态 | CheckBox | IsChecked | {Binding Assignment.IsCompleted, Mode=TwoWay} |
表 3
接着,重写OnNavigatedTo方法:
代码 38
最后,为两个按钮创建事件处理程序:
代码 39
由于这部分内容和上节课的大同小异,这里就不详细解释了。
接下来是实现前面提到的五个操作。首先是最常用的新建和保存。保存操作非常简单,我们只需为它创建一个事件处理程序就行了:
代码 40
而新建则有点难度,我们需要获取课程名称,怎么获取?我们知道,课程名称实际上就是Pivot项的标题,也就是AssignmentListViewModel的Title属性,只要我们知道当前显示的是哪个AssignmentListViewModel对象就可以了。为此,我们需要在AssignmentBookViewModel类里添加一个SelectedListIndex属性:
代码 41
并为它和Pivot控件的SelectedIndex属性设置双向绑定。此外,需要说明的是,为了使用RaisePropertyChanged方法,我们需要让AssignmentBookViewModel类继承NotificationObject类。有了这些准备,我们就可以创建事件处理程序了:
代码 42
需要说明的是,如果课程表里面没有课程,Pivot控件就不会创建Pivot项,所以在做进一步处理之前,我们需要判断AssignmentLists里面有没有东西。
好了,又到看效果的时候了!按F5运行应用程序,在主菜单里单击"作业本"菜单项进入作业本:
图 33
单击Application Bar上的新建按钮创建一项作业:
图 34
单击确定返回:
图 35
Oh,My Lady Gaga!我的作业呢??
插曲 #2
究竟发生了什么事?是数据没有添加进去?是事件通知没有发出?还是出现线程安全的问题?我调试了一下,数据已经正确添加进去了,事件通知也正确发出去了,所有操作都在UI线程里执行,而且没有出现并发问题,那么问题到底出在哪里呢?
带着这个疑问,我从codeplex.com上下载了SL for WP Toolkit的最新代码(Change Set 57505),然后调试进去看看。在调试的过程中,我发现每次从NewOrEditAssignmentPage页返回AssignmentBookPage页时,LongListSelector控件都会调用Balance方法,但每次都会"跳过"本应执行的大部分代码,一开始我没怎么留意,觉得这个方法一下子就返回实在太神奇了,仔细观察,原来它是通过第一个if里的return悄悄返回的:
代码 43
难怪LongListSelector控件什么也没显示,因为Balance方法后面那些负责调整显示的代码一句都没执行。为什么会这样?关键在于IsReady方法,因为它每次都返回false。当我单步进入IsReady方法时,发现_itemsPanel和ItemsSource都不为null,但ActualHeight的值却为0.0,从而导致IsReady方法返回false:
代码 44
为什么会这样?这是因为,当我们打开NewOrEditAssignmentPage页时,由于AssignmentBookPage页暂时无需显示,Silverlight会把它从主对象树移除,于是ActualHeight会被"清零",当我们从NewOrEditAssignmentPage页返回时,Silverlight需要重新测量每个控件的大小(包括页面本身),并安排它们的位置,ActualHeight的值为0.0意味着Silverlight还没完成布局处理的工作,换句话说,LongListSelector控件还没准备好,IsReady方法返回false是正确的。奇怪的是,每次我们从NewOrEditAssignmentPage页返回时,Balance方法里的IsReady方法没有一次返回true的,这可能意味着Balance方法的调用时机不对,那什么时候调用才对呢?控件加载完毕的时候,即Loaded事件触发的时候,那么,LongListSelector控件在Loaded事件触发的时候做了些啥呢?其实没什么,只是简单地把_isLoaded设为true,然后调用EnsureData方法:
代码 45
这么看来,问题的关键就在于EnsureData方法有没有正确调用Balance方法了。我们来看看EnsureData方法的代码:
代码 46
FlattenData和Balance是两个很重要的方法,前者负责从ItemsSource把数据初始化到_flattenedItems,而后者则负责确定哪些数据需要显示以及如何显示。显然,当我们从NewOrEditAssignmentPage页返回时,如果我们创建了作业,if里面的语句是不可能执行的,因为_flattenedItems里面包含了我们的作业!?这听起来很别扭,不是吗?毫无疑问,LongListSelector控件没有考虑我们的情况,即打开一个另一个页面操作数据源,这是不应该的,你不可能指望我们把所有事情都放在同一个页面里处理吧?
既然知道了原因,问题就不难解决了,把LongListSelector控件的Loaded事件处理程序改成下面这样:
代码 47
看到这里,你可能会问,_isLoadedRaisedBefore是干嘛的?我们知道,第一次进入AssignmentBookPage页和从NewOrEditAssignmentPage页返回时都会触发Loaded事件,这是两种需要区别处理的情况,因为Balance方法里包含了重设_resolvedFirstIndex和_resolvedCount的代码(参见代码43),如果我们在后面那种情况下执行这行代码,LongListSelector控件的显示就会乱掉,因为它计算不出正确的显示索引,_isLoadedRaisedBefore的存在就是为了防止这种情况的发生。接着,在Balance方法里用if把重设_resolvedFirstIndex和_resolvedCount的那行代码包围起来:
代码 48
值得提醒的是,每次调用FlattenData方法都会重设_flattenedItems,这对于从NewOrEditAssignmentPage页返回的情况来说是没有必要的,所以Loaded事件处理程序里的FlattenData方法需要放在if里,否则,使用ObservableCollection就会变得毫无意义了。
改好之后,编译一下。注意,如果你是通过MSI安装SL for WP7 Toolkit的话,你需要先在项目属性里修改一下版本再编译,否则待会重新添加引用的时候Visual Studio会自作聪明的引用原来那个dll文件,因为MSI在注册表里做了手脚。
一切准备就绪之后就可以按F5了。单击Application Bar上的新建按钮打开NewOrEditAssignmentPage页:
图 36
输入作业内容,然后按确定返回:
图 37
噢,终于看到我的作业啦!
编辑作业本·续
回到作业本的操作,接下来我们要实现编辑和删除两个操作。前面提到,我打算把它们放在上下文菜单里,那么,如何创建上下文菜单?非常简单,我们可以使用SL for WP Toolkit的ContextMenu控件:
代码 49
正如你所看到的,ContextMenu控件只需嵌入目标对象就能工作了,非常方便。
接下来的问题是如何实现它们的事件处理程序。我们知道,这两个操作有一个共同点,就是要获取用户当前选中的作业,怎么获取呢?有些同学可能会建议,在AssignmentListViewModel类里添加一个SelectedAssignment属性,并为它和LongListSelector控件的SelectedItem属性设置双向绑定,这样,一旦用户选中某项作业,我们就可以通过SelectedAssignment属性获取作业的Id了。你可以这样做,不过,这个做法会带来一个小小的问题,就是用户在长按某项作业之前得先单击一下。什么意思?我们知道,手机没有鼠标右击的概念,我们是通过长按(Touch and Hold)打开上下文菜单的,但从触摸手势的角度来看,长按和单击(Tap)是两个不同的触摸手势。LonglistSelector控件只会在单击的时候设置SelectedItem属性,它不处理长按,所以当我们通过长按打开上下文菜单时,SelectedItem属性可能为null或者之前选中的其它作业,前者会引发异常,而后者则会为用户带来困扰。为了避免这些问题,要么我们再次修改LongListSelector控件的代码,要么用户不得不执行一步额外的操作,显然,这都不是什么好办法,还有没有别的选择?
当然有!你知道吗,DataContext属性是一个很特别的属性,子元素可以从父元素那里继承这个属性的值,对照代码49来看,这意味着MenuItem的DataContext和Grid的有着相同的值,而这个值正是我们苦苦寻找的作业!换句话说,只要我们获取到用户单击的MenuItem对象,就可以通过它的DataContext属性获取用户想要操作的作业。我们知道,事件处理程序的第一个参数就是引发该事件的对象,于是我们可以通过这个参数来访问MenuItem对象:
代码 50
这样,我们既不需要在AssignmentListViewModel类里添加一个SelectedAssignment属性,也不需要修改LongListSelector控件的代码,更不需要委屈用户执行额外的操作,真是一举三得啊!
现在只剩一个操作了——撤销所有更改,我相信这对于你来说不是问题,所以我决定把它留给你当课后作业。
好了,又到看效果的时候了!按F5运行应用程序,新建三项作业:
图 38
长按第三项作业,你会看到这项作业以外的所有东西都缩小了,给人一种向后移动的感觉,这个动画生动地突出了正在操作的作业以及上下文菜单,不过,不知道是不是动画的bug,第三项作业的截止日期上面有个瑕疵(试了几次都是这样):
图 39
单击编辑将会打开NewOrEditAssignmentPage页,修改一下截止日期:
图 40
然后按确定返回,你会看到刚才修改的截止日期:
图 41
接着,长按第二项作业(Textbook. P20. Ex 2),并选择删除:
图 42
作业成功删除。但是,如果你尝试删除(剩下的)第二项作业,你会发现它还在那里!为什么!?我调试了一下,发现此时MenuItem对象的DataContext属性的值居然是已故的前任第二项(Textbook. P20. Ex 2),而不是我们期望的现任第二项(Textbook. P21. Ex 3)!因为前任第二项已被删除,所以Remove方法不会触发CollectionChanged事件,LongListSelector控件自然不会更新显示。如果你现在尝试删除第一项作业(既是前任也是现任),你会成功的,但是,在删除之后,如果你再次尝试删除剩下的唯一一项作业,你会发现此时MenuItem对象的DataContext属性的值变成刚故的前任第一项(Textbook. P10. Ex 9, 10)!从此以后,剩下的唯一一项作业就再也删除不了了,除非你返回MainPage页重新打开AssignmentBookPage页。由于编辑操作采用了相同的实现思路,如果一项作业删除不了,那么它也编辑不了。
究竟发生了什么事?是ContextMenu控件的bug吗?我另外创建了一个新的项目,在同等条件下,分别在ListBox和LongListSelector上测试了ContextMenu,结果,Listbox一方表现正常,而LongListSelector一方问题依旧,有趣的是,即使不用打开新的页面,结果还是一样。这让我不得不再一次怀疑是LongListSelector控件的问题。
我重新运行应用程序,然后单步执行第一次删除的整个过程。在这个过程里,我发现一个很奇怪的事情,当我删除第二项时,LongListSelector控件先把第三项的ContentPresenter和Assignment分离开来,并把分离出来的ContentPresenter推入内部的_recycledItems(类型为Stack<ContentPresenter>),接着对第二项做相同的事,然后把第二项从_flattenedItems里删除,最后重新关联第三项的ContentPresenter和Assignment,问题就出现在最后一步,它居然直接使用_recycledItems顶部的ContentPresenter,换句话说,它把第二项的ContentPresenter和第三项的Assignment关联了!见鬼!此时,如果我删除第一项的话,它会把第一项的ContentPresenter和第三项的Assignment关联!从这里不难看出,它应该在关联之前把_recycledItems顶部那个垃圾扔掉!既然知道了原因,问题就不难解决了,在OnRemove方法的相应地方加上红框里面那句:
代码 51
重新编译所有东西,然后运行应用程序,这次没问题了。
写完这篇文章之后,我的第一感觉是LongListSelector控件远未达到产品级别的质量,它的问题导致我无法专注于应用程序本身的功能设计和实现,如果你是本着学习和研究的态度去用它,那没问题,如果你想用它来做产品,那你就要做好心理准备了。不管怎样,这次我还是学到了不少东西。LongListSelector控件的补丁我已经提交到codeplex.com了,在官方发布修正版本之前,我只能使用自己修改的版本了→_→
下课了……