WP7有约(二):课后作业
作业本
上节课布置的作业有做吗?没人吭声啊,看来大家都忘了哦,没事,我们这次弄个作业本出来,大家就有地方记作业了。在开始设计应用程序之前,我们先来看看通常的作业本是怎样记作业的:
图 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控件压根不知道如何使用我们提供的数据,而把使用方法告诉它正是我们的责任。