Windows Phone 的逻辑删除和异步处理
你们是否曾经编写过一个应用程序,在即将完成之时,却希望自己从未写过这个应用程序? 正是这种直觉,让您觉得架构不太好。 简单的改变似乎遥不可及,或者至少要耗费长得多的时间。 然后就是 bug。 哦,有 bug! 您是一个正直的编程员。 您怎么写出有这么多 bug 的东西呢?
听起来是不是很熟悉? 嗯,当我编写我的第一个 Windows Phone 应用程序 NPR Listener 就是这种情况。 NPR Listener 与全国公共广播电台 Web 服务 (npr.org/api/index.php) 进行通讯,以获取其电台节目的可用新闻报道列表,然后让用户在 Windows Phone 设备上收听这些新闻报道。 当我开始编写时,我进行了大量 Silverlight 开发,并对自己将知识和技能移植到 Windows Phone 上感到非常满意。 很快我就完成了第一个版本,并提交到 Marketplace 认证流程。 那段时间我就在想,“哦,这很简单嘛”。但是之后我认证失败了。 失败的情况是这样的:
步骤 1: 运行您的应用程序。
步骤 2: 按下开始按钮,进入电话的主页。
步骤 3: 按下“后退”按钮,返回您的应用程序。
当您按下后退按钮时,您的应用程序应当正常恢复,在理想情况下,应当让用户回到退出您的应用程序时的屏幕。 在我的个案中,测试人员导航到全国公共广播程序(例如“All Things Considered”),单击进入任何一个最新报道,然后按下开始按钮,进入设备的主屏幕。 当测试人员按下后退按钮返回我的应用程序时,应用程序恢复了,但是充满了 NullReferenceExceptions。 这可不是什么好事。
现在我告诉您一些我如何设计基于 XAML 的应用程序的事情。 就我而言,全都是 Model-View-ViewModel 模式,我的目标是在 XAML 页与应用程序的逻辑之间做到近乎疯狂的分隔。 如果在我的页面的 codebehinds (*.xaml.cs) 中要有什么代码的话,最好有个充分的理由才行。 造成这一点在很大程度上是因为我对单元测试近乎变态的需求。 单元测试是至关重要的,因为可以有助于知道应用程序什么时候是有效的,更重要的是便于重构代码并改变应用程序的工作方式。
所以如果我对单元测试如此热衷,为何我得到这些 NullReferenceExceptions 呢? 问题在于,我编写 Windows Phone 应用程序就像 Silverlight 应用程序一样。 当然,Windows Phone 就是 Silverlight,但是 Windows Phone 应用的生命周期和 Silverlight 应用程序的生命周期完全不同。 在 Silverlight 中,用户打开应用程序,进行交互,直至完成,然后关闭应用程序。 相反在 Windows Phone 中,用户打开应用程序,使用应用程序,然后在应用程序与操作系统或任何其他应用程序之间来回跳转。 他用户离开您的应用程序,应用程序便停用了或“逻辑删除”。当您的应用程序被逻辑删除后,则不再运行,但是应用程序的导航“后退堆栈”——应用程序中按照访问顺序排列的页面——在设备上仍然可用。
您可能注意到在 Windows Phone 设备上,您可以在多个应用程序中导航,然后反复按后退按钮,以反向顺序在这些应用程序中向后行进。 这就是导航后退堆栈在起作用,每次您进入一个不同的应用程序,那个应用程序便从保持的逻辑删除数据中重新激活。 当您的应用程序将要被逻辑删除时,应用程序便会从操作系统收到一条通知,告知其将要被停用并应当保存其应用程序状态,以便稍后重新激活。 图 1 显示了 App.xaml.cs 中用于激活和停用应用程序的一些简单代码。
图 1 App.xaml.cs 中简单逻辑删除的实施
// Code to execute when the application is deactivated (sent to background).
// This code will not execute when the application is closing.
private void Application_Deactivated(object sender, DeactivatedEventArgs e)
{
// Tombstone your application.
IDictionary<string, object> stateCollection =
PhoneApplicationService.Current.State;
stateCollection.Add("VALUE_1", "the value");
}
// Code to execute when the application is activated
// (brought to foreground).
// This code will not execute when the application is first launched.
private void Application_Activated(object sender, ActivatedEventArgs e)
{
// Un-tombstone your application.
IDictionary<string, object> stateCollection =
PhoneApplicationService.Current.State;
var value = stateCollection["VALUE_1"];
}
我的 NullReferenceException 问题是由于完全缺乏对处理那些逻辑删除事件的规划和编码而造成的。 再加上我的综合性、丰富复杂的 ViewModel 实施,简直就是一场灾难。 想想当用户单击后退按钮重新进入您的应用程序时会发生什么情况。 用户最终不会达到某个开始页,而登陆到您应用程序中访问的最后一个页面。 在 Windows Phone 测试人员看来,当用户重新激活我的应用程序时,用户进入中间的应用程序,而用户界面假设 ViewModel 已经填充,可以支持那个屏幕。 因为 ViewModel 没有响应逻辑删除事件,几乎每个对象引用都是空的。 天哪。 我没有对那种情况进行单元测试,是吧? (砰!)
我得到的教训就是需要为向前和向后导航规划用户界面和 ViewModel。
事后添加逻辑删除
图 2 显示了我原来应用程序的结构。 为了让我的应用程序通过认证,我需要处理那个开始/后退按钮情况。 我可以在 Windows Phone 项目 (Benday.Npr.Phone) 中实施逻辑删除,或者将逻辑删除强行推入我的 ViewModel (Benday.Npr.Presentation)。 这两种方式都需要在架构上做出一些不舒服的妥协。 如果我在 Benday.Npr.Phone 项目中添加逻辑,用户界面会知道太多关于 ViewModel 如何工作的信息。 如果我在 ViewModel 项目中添加逻辑,我需要添加从 Benday.Npr.Presentation 到 Microsoft.Phone.dll 的引用,以访问 Microsoft.Phone.Shell 命名空间中的逻辑删除值字典 (PhoneApplicationService.Current.State)。 这会用不必要的实施详情污染我的 ViewModel 项目,并且违反关注点分离 (SoC) 原则。
图 2 应用程序的结构
我最终选择将逻辑放入 Phone 项目,并创建一些类,知道如何将我的 ViewModel 序列化到 XML 字符串中,以便我将这个字符串放入逻辑删除值字典中。 这种方法可让我避免从 Presentation 项目引用到 Microsoft.Phone.Shell,同时仍然为我提供遵守单一责任原则的干净代码。 我将这些类命名为 *ViewModelSerializer。 图 3 显示了将 StoryListViewModel 实例变成 XML 所需的一些代码。
图 3 StoryListViewModelSerializer.cs 中将 IStoryListViewModel 变成 XML 的代码
private void Serialize(IStoryListViewModel fromValue)
{
var document = XmlUtility.StringToXDocument("<stories />");
WriteToDocument(document, fromValue);
// Write the XML to the tombstone dictionary.
SetStateValue(SERIALIZATION_KEY_STORY_LIST, document.ToString());
}
private void WriteToDocument(System.Xml.Linq.XDocument document,
IStoryListViewModel storyList)
{
var root = document.Root;
root.SetElementValue("Id", storyList.Id);
root.SetElementValue("Title", storyList.Title);
root.SetElementValue("UrlToHtml", storyList.UrlToHtml);
var storySerializer = new StoryViewModelSerializer();
foreach (var fromValue in storyList.Stories)
{
root.Add(storySerializer.SerializeToElement(fromValue));
}
}
编写了这些序列化程序之后,我需要在 App.xaml.cs 中添加逻辑,以根据当前显示的屏幕触发这种序列化(请看图 4)。
图 4 触发 App.xaml.cs 中的 ViewModel 序列化程序
private void Application_Deactivated(object sender,
DeactivatedEventArgs e)
{
ViewModelSerializerBase.ClearState();
if (IsDisplayingStory() == true)
{
new StoryListViewModelSerializer().Serialize();
new StoryViewModelSerializer().Serialize();
ViewModelSerializerBase.SetResumeActionToStory();
}
else if (IsDisplayingProgram() == true)
{
new StoryListViewModelSerializer().Serialize();
new ProgramViewModelSerializer().Serialize();
ViewModelSerializerBase.SetResumeActionToProgram();
}
else if (IsDisplayingHourlyNews() == true)
{
new StoryListViewModelSerializer().Serialize();
ViewModelSerializerBase.SetResumeActionToHourlyNews();
}
}
我最终成功了并且应用程序获得认证,但是不幸的是,代码很慢又丑,脆弱而且多 bug。 我应当做的设计 ViewModel 使其不太需要保存,然后构建 ViewModel 让它在运行时保持自身,而不必在最后进行一次大型的逻辑删除事件。 我如何做到呢?
异步编程的控制狂学派
那么问您一个问题: 您是否有“控制狂”倾向? 您对什么都难以放手? 您是否选择对明摆的现实视而不见,而是完全通过意志力解决问题,忽略如白昼般清晰直接摆在眼前的现实? 对,这就是我在处理第一个版本 NPR Listener 的异步调用时的情景。 具体而言,这就是我在这个应用程序的第一个版本中处理异步联网的方法。
在 Silverlight 中,所有网络调用必须是异步的。 您的代码启动网络调用并立即返回。 稍后通过异步回调发送结果(或例外)。 这意味着联网逻辑始终包括两个部分——传出调用和返回调用。 这种结构有影响,Silverlight 有一个肮脏的小秘密,任何依赖于网络调用结果的方法不能返回一个数值并且必须返回空值。 这是有副作用的: 任何方法如果调用其他依赖于网络调用结果的方法,也必须返回空值。 正如您可以想像的,这对于分层架构是绝对残忍的,因为传统上实施分层设计模式,例如服务层、适配器和存储库,都在很大程度上依赖于方法调用的返回值。
我的解决方案是一个名为 ReturnResult<T> 的类(如图5 所示),以用作请求网络调用的方法与处理调用结果的方法之间的胶水,并提供一种方法让您的代码返回有用的值。 图 6 显示了某种存储库模式逻辑,调用 Windows Communication Foundation (WCF) 服务并返回一个填充的 IPerson 实例。 使用此代码,您可以调用 LoadById(ReturnResult<IPerson>, int) 并在 client_LoadByIdCompleted(object, LoadByIdCompletedEventArgs) 调用其中一种 Notify 方法时最终收到填充的 IPerson 实例。 基本上可以让您创建类似于可以返回数值的代码。 (有关 ReturnResult<T> 的详情,请参阅 bit.ly/Q6dqIv。)
图 5 ReturnResult<T>
图 6 使用 ReturnResult<T> 启动网络调用并返回已完成事件的值
public void LoadById(ReturnResult<IPerson> callback, int id)
{
// Create an instance of a WCF service proxy.
var client = new PersonService.PersonServiceClient();
// Subscribe to the "completed" event for the service method.
client.LoadByIdCompleted +=
new EventHandler<PersonService.LoadByIdCompletedEventArgs>(
client_LoadByIdCompleted);
// Call the service method.
client.LoadByIdAsync(id, callback);
}
void client_LoadByIdCompleted(object sender,
PersonService.LoadByIdCompletedEventArgs e)
{
var callback = e.UserState as ReturnResult<IPerson>;
if (e.Error != null)
{
// Pass the WCF exception to the original caller.
callback.Notify(e.Error);
}
else
{
PersonService.PersonDto personReturnedByService = e.Result;
var returnValue = new Person();
var adapter = new PersonModelToServiceDtoAdapter();
adapter.Adapt(personReturnedByService, returnValue);
// Pass the populated model to the original caller.
callback.Notify(returnValue);
}
}
当我编写完第一个版本的 NPR Listener 时,我很快就明白这个应用程序反应迟缓(或至少看上去迟缓),因为我没有做任何缓存。 在应用程序中真正需要是一种调用 NPR Web 服务的方法,获得特定节目的报道列表,然后缓存数据,使我不必每次需要拉动屏幕时返回这个服务。 然而,增加这种功能相当困难,因为我试图假装异步调用不存在。 基本上来说,作为一个控制狂并试图否认我的应用程序的根本上异步的结构,我是在局限自己的看法。 我在和平台抗争,从而扭曲了应用程序的架构。
在同步应用程序中,一切事情从用户界面开始,控制流穿过应用程序的各层,当堆栈展开时返回数据。 一切事情都发生在启动工作的单一调用堆栈中,然后处理数据,而返回值则返回到堆栈。 在异步应用程序中,流程更像是松散连接的四个调用: 用户界面请求某种事情;某种处理可能会或不会发生;如果发生处理,则用户界面订阅事件,处理通知用户界面一个操作已经完成;用户界面用异步操作的数据更新显示。
我可以想像自己给一些傲慢的年轻人讲述在异步和等待之前的时光有多么艰难。 “在我的时代,我们必须管理自己的异步联网逻辑和回调。 很残酷,但我们乐此不疲! 现在离开我的草地!”嗯,说实话,我们不喜欢这样。 很残酷。
下面是另一个教训: 与平台的底层架构抗争总是带来麻烦。
使用独立存储重新编写应用程序
我首先为 Windows Phone 7 编写应用程序,然后只对 Windows Phone 7.1 进行次要更新。 在唯一目的是流式播放音频的应用程序中,如果用户在浏览其他应用程序时无法收听音频,那总是让人失望的。 当 Windows Phone 7.5 出来时,我想利用新的背景流式播放功能。 我还想通过添加某种本地数据缓存,加速应用程序,删除大量不必要的 Web 服务调用。 当我开始思考如何实施这些功能时,但是逻辑删除、ViewModel 及异步实施的局限和脆弱性变得越来越明显。 是时候修复我之前的错误,并完全重写应用程序了。
在从我上一个版本应用程序中得到教训之后,我决定开始针对“逻辑删除能力”进行设计,并且完全接纳应用程序的异步特性。 因为我想添加本地数据缓存,所以我开始考虑使用独立存储。 独立存储是设备上的一个位置,应用程序可以读取和写入数据。 使用独立存储类似于使用任何普通 .NET 应用程序中的文件系统。
使用独立存储实现缓存及简化网络操作
独立存储的一大好处是这些调用不像网络调用,不一定要异步。 这意味着我可以使用依赖于返回值的传统架构。 我琢磨清楚之后,我开始思考如何将必须异步的操作与可以做到同步的操作进行分离。 网络调用必须是异步的。 独立存储调用可以是同步的。 那么我进行任何语法分析之前,我总是将网络调用的结果写入独立存储会怎么样呢? 这让我可以同步加载数据,并能够以简单经济的方法进行本地数据缓存。 独立存储一下子帮我解决了两个问题。
首先我重新处理网络调用,并接受这样一个事实,即网络调用是一系列松散关联的步骤,而不是一个重要的同步步骤。 例如,当我想获得特定 NPR 节目的报道列表时,我是这样做的(请看图 7):
- ViewModel 订阅 StoryRepository 上的 StoryListRefreshed 事件。
- ViewModel 调用 StoryRepository 以请求刷新当前节目的报道列表。 这个调用立即完成并回空值。
- StoryRepository 向 NPR REST Web 服务发出异步网络调用,以获取节目的报道列表。
- 在一定程度上,触发回调方法,现在 StoryRepository 可以访问服务的数据。 数据作为 XML 从服务返回,而不是把这个变成填充的对象,返回到 ViewModel,而 StoryRepository 立即将 XML 写入独立存储。
- StoryRepository 触发 StoryListRefreshed 事件。
- ViewModel 收到 StoryListRefreshed 事件并调用 GetStories 以获得最新的报道列表。 GetStories 读取独立存储中缓存的报道列表 XML,并转化为 ViewModel 需要的对象,然后返回填充的对象。 这种方法可以返回填充的对象,因为是同步调用读取独立存储。
图 7 刷新和加载报道列表的序列图
重点在于 RefreshStories 方法不会返回任何数据。 这种方法只是请求刷新缓存的报道数据。 GetStories 方法获取当前缓存的 XML 数据并转化成 IStory 对象。 因为 GetStories 不一定要调用任何服务,反应极快,所以报道列表屏幕可以快速填充,应用程序的反应似乎快过第一个版本。 如果没有缓存的数据,GetStories 只是返回一个空的 IStory 对象列表。 以下是 IStoryRepository 接口:
public interface IStoryRepository
{
event EventHandler<StoryListRefreshedEventArgs> StoryListRefreshed;
IStoryList GetStories(string programId);
void RefreshStories(string programId);
...
}
关于将这个逻辑隐藏在接口后面的另外一点是在 ViewModel 中产生干净的代码,并将 ViewModel 的开发努力与存储和服务逻辑分离。 这种分离使代码更容易进行单元测试和维护。
使用独立存储实现持续逻辑删除
应用程序第一个版本中的逻辑删除实施接收了 ViewModel 并将其转化成 XML,XML 存储在电话的逻辑删除值字典 PhoneApplicationService.Current.State。 我喜欢 XML,但是不喜欢 ViewModel 的存留是电话应用用户界面层的责任,而不是 ViewModel 层本身的责任。 我还不喜欢用户界面等待,直到逻辑删除停用事件存留我全套 ViewModel。 当应用运行时,实际上只有少数的值需要在存留,当用户在屏幕之间移动时,这些值便会逐步改变。 为何当用户在应用程序中导航时不将数值写入独立存储? 这样应用程序总是准备停用,而逻辑删除就不是大问题了。
而且,不保留应用程序的整个状态,为何不仅保存每个页面的当前选定值? 数据进行本地缓存,所以应当已经保存在设备上,我可以轻松从缓存重新加载数据,而不用更改应用程序的逻辑。 这样,必须存留的值数量从第 1 版的几百减少到第 2 版的可能四或五。 担心的数据大大减少,一切事情都简单得多了。
让所有存留代码读取和写入独立存储的逻辑封装到一系列存储库对象中。 有关报道对象的信息,将有一个相应的 StoryRepository 类。 图 8 显示了接收报道 Id,将其转化为 XML 文档并保存到独立存储中的代码。
图 8 保存当前报道 ID 的 StoryRepository 逻辑
public void SaveCurrentStoryId(string currentId)
{
var doc = XmlUtility.StringToXDocument("<info />");
if (currentId == null)
{
currentId = String.Empty;
}
else
{
currentId = currentId.Trim();
}
XmlUtility.SetChildElement(doc.Root, "CurrentStoryId", currentId);
DataAccessUtility.SaveFile(DataAccessConstants.FilenameStoryInformation, doc);
}
将存留逻辑包装在存储库对象,可以把存储和检索逻辑与任何 ViewModel 逻辑分离,并向 ViewModel 类隐藏实施详情。 图 9 显示了 StoryListViewModel 类中当报道选择变更时保存当前报道 ID 的代码。
图 9 当数值变更时 StoryListViewModel 保存当前报道 Id
void m_Stories_OnItemSelected(object sender, EventArgs e)
{
HandleStorySelected();
}
private void HandleStorySelected()
{
if (Stories.SelectedItem == null)
{
CurrentStoryId = null;
StoryRepositoryInstance.SaveCurrentStoryId(null);
}
else
{
CurrentStoryId = Stories.SelectedItem.Id;
StoryRepositoryInstance.SaveCurrentStoryId(CurrentStoryId);
}
}
以下是 StoryListViewModel 加载方法,当 StoryListViewModel 需要重新从磁盘填充时,反转流程:
public void Load()
{
// Get the current story Id.
CurrentStoryId = StoryRepositoryInstance.GetCurrentStoryId();
...
var stories = StoryRepositoryInstance.GetStories(CurrentProgramId);
Populate(stories);
}
提前规划
在本文中,我介绍了在初次开发 Windows Phone 应用程序 NPR Listener 期间所做出的一些架构决策和所犯的一些错误。 记住计划好逻辑删除并接受而不是抗拒 Windows Phone 应用程序中的异步。 如果您想查看 NPR Listener 前版和后版的代码,您可以在 archive.msdn.microsoft.com/mag201209WP7 下载。
Benjamin Day 是一名顾问和培训师,专门从事使用微软开发工具进行软件开发最佳实践,主要从事 Visual Studio Team Foundation Server、Scrum 和 Windows Azure。 他是 Microsoft Visual Studio ALM MVP,Scrum.org 的认证 Scrum 培训师,以及 TechEd、DevTeach 及 Visual Studio Live 等大会的发言人。 在开发软件工作之余,Day 喜欢跑步和划皮艇,他还喜欢奶酪、腌肉及香槟。 可通过benday.com与他联系。
衷心感谢以下技术专家对本文的审阅: Jerri Chiu 和 David Starr