一个简单的 Windows Phone 7 MVVM Tombstoning 示例





5.00/5 (3投票s)
这篇博文展示了如何在遵循 Model-View-ViewModel 模式的 Windows Phone 7 应用程序中实现 tombstoning(墓碑化)。
这篇博文展示了如何在遵循 Model-View-ViewModel 模式的 Windows Phone 7 应用程序中实现 tombstoning(墓碑化)。
我不得不承认,Windows Phone 7 的 tombstoning 一度让我感到有些困惑,有太多的地方可以存储状态,生命周期和导航模型也很令人费解。我读过的大多数博文要么详细介绍了非 MVVM 应用程序的 tombstoning,要么描述了如何使用或改造现有的 MVVM 框架以实现 tombstoning。直到我编写了自己的简单 MVVM 应用程序后,我才真正理解 tombstoning 的来龙去脉。我想在这里分享这个应用程序,希望能帮助其他同样困惑的开发人员!
什么是 Tombstoning?
与台式计算机相比,手机的资源有限。因此,大多数智能手机操作系统都会限制当前加载到内存中并执行的应用程序数量。对于 Windows Phone 7,这个限制是单个应用程序!
如果用户在您的应用程序运行时点击手机开始按钮,屏幕锁定,或者您从应用程序中调用了选择器/启动器,那么您的应用程序就会终止。然而,当用户导航回您的应用程序、屏幕解锁或选择器/启动器关闭时,用户期望再次看到您的应用程序处于其原始状态。
为了支持这一点,WP7 操作系统会维护状态信息,允许您通过启动一个新的应用程序实例并使用此状态信息来将应用程序恢复到与终止时相同的状态。有关此过程的完整概述,我建议阅读 MSDN 上的《Windows Phone 执行模型概述》,或 Windows Phone 开发者博客上的三部分系列文章(1)、(2)、(3)。
这听起来可能工作量很大,坦白说,确实如此。您可能想知道 Mango 更新(在 MIX11 第二天主题演讲中演示,可能在 2012 年底发布)将带来的新多任务处理功能是否意味着 tombstoning 将会消失。我还没有看到任何官方确认,但是,我个人认为在 Mango 中仍然需要进行 tombstoning。很可能并发应用程序的数量仍将受到限制,因此应用程序仍然需要进行 tombstoning。
示例应用程序
我在这篇博文中使用的示例应用程序如下图所示。该应用程序显示推文列表,点击推文会全屏显示。Twitter 应用程序是新的 Hello World!
ViewModel
视图模型非常简单,每条推文都由一个 FeedItemViewModel
表示
public class FeedItemViewModel
{
public FeedItemViewModel()
{
}
public long Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
public DateTime Timestamp { get; set; }
}
上述视图模型不会改变状态,因此无需实现 INotifyPropertyChanged
。
顶层视图模型仅公开上述 feed 项的集合。它还有一个 Update
方法,通过查询 Twitter 的搜索 API 获取包含 #wp7 标签的推文来填充此列表。
public class FeedViewModel
{
// Twitter search for #WP7
private readonly string _twitterUrl = "http://search.twitter.com/search.atom?rpp=100&&q=%23wp7";
private WebClient _webClient = new WebClient();
private readonly ObservableCollection<FeedItemViewModel> _feedItems =
new ObservableCollection<FeedItemViewModel>();
public FeedViewModel()
{
_webClient.DownloadStringCompleted += WebClient_DownloadStringCompleted;
}
/// <summary>
/// Parses the response from our twitter request, creating a list of FeedItemViewModelinstances
/// </summary>
private void WebClient_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
{
var doc = XDocument.Parse(e.Result);
var items = doc.Descendants(AtomConst.Entry)
.Select(entryElement => new FeedItemViewModel()
{
Title = entryElement.Descendants(AtomConst.Title).Single().Value,
Id = long.Parse(entryElement.Descendants(AtomConst.ID).Single().Value.Split(':')[2]),
Timestamp = DateTime.Parse(entryElement.Descendants(AtomConst.Published).Single().Value),
Author = entryElement.Descendants(AtomConst.Name).Single().Value
});
_feedItems.Clear();
foreach (var item in items)
{
_feedItems.Add(item);
}
}
/// <summary>
/// Gets the feed items
/// </summary>
public ObservableCollection<FeedItemViewModel> FeedItems
{
get { return _feedItems; }
}
/// <summary>
/// Gets a feed item by its Id
/// </summary>
public FeedItemViewModel GetFeedItem(long id)
{
return _feedItems.SingleOrDefault(item => item.Id == id);
}
/// <summary>
/// Update the feed items
/// </summary>
public void Update()
{
_webClient.DownloadStringAsync(new Uri(_twitterUrl));
}
}
View
用于渲染 FeedViewModel
(即推文列表)的 FeedView
页面只是一个 NavigationList 控件(一个针对 WP7 导航场景优化的 ItemsControl
),它有一个渲染每个项的 ItemTemplate
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<l:NavigationList x:Name="navigationControl"
ItemsSource="{Binding FeedItems}"
Navigation="NavigationList_Navigation">
<l:NavigationList.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical"
Height="100">
<TextBlock Text="{Binding Author}"
Style="{StaticResource PhoneTextNormalStyle}"/>
<TextBlock Text="{Binding Title}"
Margin="20,0,0,0"
Style="{StaticResource PhoneTextSmallStyle}"
TextWrapping="Wrap"/>
</StackPanel>
</DataTemplate>
</l:NavigationList.ItemTemplate>
</l:NavigationList>
</Grid>
用于渲染 FeedItemViewModel
的 FeedItemView
甚至更简单
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<StackPanel Orientation="Vertical">
<TextBlock Text="{Binding Author}"
Style="{StaticResource PhoneTextLargeStyle}"
Foreground="{StaticResource PhoneAccentBrush}"/>
<TextBlock Text="{Binding Title}"
Style="{StaticResource PhoneTextLargeStyle}"
TextWrapping="Wrap"/>
</StackPanel>
</Grid>
现在我们有了 ViewModels
及其各自的 Views
,我们需要通过将 ViewModel
设置为每个 View
的 DataContext
来将它们连接起来。有多种不同的方法可以做到这一点(Paul Stovell 在他的 MVVM 实例化方法博文中记录了 8 种不同的方法!),但是,我发现对于 WP7 应用程序来说,最简单的方法是将 ViewModel
与应用程序关联起来。因此,我们将视图模型添加为 App
类的属性,并在调用 Application_Launching
方法(处理 Launching
生命周期事件)时实例化它
public partial class App : Application
{
/// <summary>
/// Gets the ViewModel
/// </summary>
public FeedViewModel ViewModel { get; private set; }
...
private void Application_Launching(object sender, LaunchingEventArgs e)
{
ViewModel = new FeedViewModel();
ViewModel.Update();
// set the frame DataContext
RootFrame.DataContext = ViewModel;
}
...
}
RootFrame
的 DataContext
设置为我们的“顶层”视图模型。RootFrame
是 PhoneApplicationFrame
的一个实例,它包含当前的 PhoneApplicationPage
实例,因此当您从一个页面导航到下一个页面时,PhoneApplicationFrame
的内容会在导航到的页面中被替换。结果,您的每个页面都将从应用程序框架继承 DataContext
。
导航
RootFrame
的 DataContext
由 FeedView
页面继承,因此一旦推文加载,它们将显示在我们的 NavigationList
中。为了在用户点击推文时导航到该推文,我们需要处理 Navigation
事件
public partial class FeedView : PhoneApplicationPage
{
...
private void NavigationList_Navigation(object sender, NavigationEventArgs e)
{
var selectedItem = e.Item as FeedItemViewModel;
// navigate to the feed items page
NavigationService.Navigate(new Uri("/FeedItemView.xaml?id=" + selectedItem.Id, UriKind.Relative));
}
}
以上代码使用 NavigationService
导航到 FeedItemView
页面。我们使用 querystring
传递所选推文的 id
。我们也可以通过添加 SelectedItemId
属性将此信息从 View
传递到 ViewModel
,但是,稍后我们会发现使用 querystring
有一些优势。
当 FeedItemView
页面加载时,我们需要从 querystring
中获取此 id,并使用它来找到正确的 FeedItemViewModel
实例。具体操作如下:
public partial class FeedItemView : PhoneApplicationPage
{
public FeedItemView()
{
InitializeComponent();
}
protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// parse the querystring to extract the id
long feedItemId = long.Parse(NavigationContext.QueryString["id"]);
// obtain this item from the view model.
this.DataContext = App.Current.ViewModel.GetFeedItem(feedItemId);
}
}
Tombstoning
有了上面的代码,我们的简单应用程序就可以正常工作了,您可以浏览推文列表,点击其中一条在 FeedItemView
页面中查看,然后使用返回按钮返回原始推文列表。如果我们的工作到此为止,那就太好了,但是,如果应用程序被 tombstoned(墓碑化),它就会彻底失败。为了测试这一点,加载应用程序,然后点击启动按钮,接着点击返回按钮返回应用程序。您会看到以下内容:
Tombstoning 会终止应用程序,因此 ViewModel 及其包含的所有状态都将丢失。因此,我们返回到一个空列表。
那么我们如何解决这个问题呢?
当您的应用程序被 tombstoned 时,在应用程序终止之前会触发一个 Deactivated
事件,当用户导航回您的应用程序时,会创建一个新实例并触发 Activated
事件。Visual Studio WP7 应用程序模板会在您的 App
类上为此类事件添加事件处理程序,这很有帮助。
请注意,当应用程序重新激活时,Launching
事件不会触发,因此我们添加的用于构建新视图模型的代码在此情况下不会执行。
解决此问题的方法非常简单,框架提供了一个 PhoneApplicationService
类,该类具有一个 State
属性,允许您将应用程序状态存储在 dictionary
中。当您的应用程序被 tombstoned 时,WP7 操作系统将代表您持久化此状态 dictionary
。您可以将任何可序列化的内容放入此 dictionary
中。因此,解决此问题的简单方法是简单地将我们的视图模型放入此 dictionary
中,并在应用程序重新激活时检索它
private readonly string ModelKey = "Key";
private void Application_Deactivated(object sender, DeactivatedEventArgs e)
{
PhoneApplicationService.Current.State[ModelKey] = ViewModel;
}
private void Application_Activated(object sender, ActivatedEventArgs e)
{
if (PhoneApplicationService.Current.State.ContainsKey(ModelKey))
{
ViewModel = PhoneApplicationService.Current.State[ModelKey] as FeedViewModel;
RootFrame.DataContext = ViewModel;
}
}
通过这个简单的更改,当用户点击返回按钮时,应用程序现在可以恢复到以前的状态。请注意,当应用程序在 FeedItemView
页面上被 tombstoned 时,这也适用。
这是因为当应用程序重新激活时,记录已访问页面的 NavigationService
日志也会恢复。“后退堆栈”包含每个页面的 URI,在我们的例子中,该 URI 包含查询字符串,其中保存着当前正在查看的推文的 Id
。
在会话之间持久化状态
在上面的示例中,应用程序状态被保存为 tombstoned 状态。但是,如果用户只是不断点击返回按钮,直到他们导航出我们的应用程序呢?在这种情况下,您的应用程序是关闭的,而不是 tombstoned。我们用于存储视图模型的 PhoneApplicationService.Current.State
dictionary
在这种情况下不会持久化。为了长期持久化,您应该将数据保存到手机的独立存储中。
我们可以在应用程序退出时通过向处理 Closing
事件的 Application_Closing
方法添加代码,将视图模型序列化到独立存储中。在下面的示例中,我使用的是框架 XmlSerializer
,它与用于持久化应用程序状态字典中数据的序列化器相同。但是,由于您可以完全控制序列化,因此您可能选择使用性能更好的序列化器。
// Code to execute when the application is closing (eg, user hit Back)
// This code will not execute when the application is deactivated
private void Application_Closing(object sender, ClosingEventArgs e)
{
// persist the data using isolated storage
using (var store = IsolatedStorageFile.GetUserStoreForApplication())
using (var stream = new IsolatedStorageFileStream("data.txt",
FileMode.Create,
FileAccess.Write,
store))
{
var serializer = new XmlSerializer(typeof(FeedViewModel));
serializer.Serialize(stream, ViewModel);
}
}
现在需要修改启动应用程序的代码,首先尝试从独立存储加载数据。如果未找到数据,则会创建一个新的视图模型
// Code to execute when the application is launching (eg, from Start)
// This code will not execute when the application is reactivated
private void Application_Launching(object sender, LaunchingEventArgs e)
{
// load the view model from isolated storage
using (var store = IsolatedStorageFile.GetUserStoreForApplication())
using (var stream = new IsolatedStorageFileStream
("data.txt", FileMode.OpenOrCreate, FileAccess.Read, store))
using (var reader = new StreamReader(stream))
{
if (!reader.EndOfStream)
{
var serializer = new XmlSerializer(typeof(FeedViewModel));
ViewModel = (FeedViewModel)serializer.Deserialize(reader);
}
}
// if the view model is not loaded, create a new one
if (ViewModel == null)
{
ViewModel = new FeedViewModel();
ViewModel.Update();
}
// set the frame DataContext
RootFrame.DataContext = ViewModel;
}
注意:这并非一个功能齐全的 Twitter 应用程序。在实际应用程序中,您可能希望更新从独立存储加载的数据以添加最新的推文,但这篇博客文章是关于 tombstoning,而不是 Twitter 应用程序开发!
Tombstoning UI 状态
所以,现在我们的应用程序在 tombstoning 期间保存状态,并在退出时保存到独立存储。我们现在都完成了吗?
嗯……差不多了。
如果您启动应用程序并向下滚动推文列表,然后点击开始按钮,将应用程序 tombstoning,然后点击返回按钮重新激活它,我们的所有推文都在那里,但列表又滚动回顶部了。
这是为什么呢?嗯,当您在应用程序中从一个页面简单地导航回另一个页面时,原始页面仍然存在于内存中,因此,应用程序 UI 中控件所持有的任何状态都会保持。
然而,正如我们已经看到的,当应用程序被 tombstoned 时,它会被终止。唯一保留的状态是您手动放入 State
字典中的状态。因此,为了保持滚动位置(我们应该这样做),我们需要确定它的位置并将其存储在 tombstone 状态中。
前段时间,我写了一篇关于将 ScrollViewer
的滚动位置公开为附加属性以允许数据绑定的博客文章。这种方法可以在这里用于将滚动位置绑定到视图模型上的属性。然而,在这种情况下,我认为更轻量级的方法更合适。
在前面的章节中,我们使用应用程序级代码 > PhoneApplicationService.Current.State
来存储 tombstone 状态。您还可以通过 PhoneApplicationPage.State
属性在页面级别存储状态。这比存储与应用程序业务逻辑更密切相关的状态更适合存储与页面内 UI 控件相关的状态。
从列表控件中提取所需的状态信息仍然有些棘手。下面的代码使用 Linq-to-VisualTree 来定位 VirtualizingStackPanel
,它可以用来获取/设置滚动位置。当用户离开页面时,滚动位置会被放置在 State
字典中。
/// <summary>
/// Gets the NavigationList ItemsPanel
/// </summary>
private VirtualizingStackPanel ItemsPanel
{
get
{
return navigationControl.Descendants<VirtualizingStackPanel>()
.Cast<VirtualizingStackPanel>()
.SingleOrDefault();
}
}
private static readonly string ScrollOffsetKey = "ScrollOffsetKey";
protected override void OnNavigatedFrom(System.Windows.Navigation.NavigationEventArgs e)
{
base.OnNavigatedFrom(e);
// persist the scroll position within the page state
var scroll = ItemsPanel;
if (scroll != null)
{
State[ScrollOffsetKey] = ItemsPanel.ScrollOwner.VerticalOffset;
}
}
恢复此状态仅仅是在导航到页面时检查此键在 State
字典中是否存在的问题。
protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// restore the scroll position
this.Loaded += (s, e2) =>
{
if (State.ContainsKey(ScrollOffsetKey))
{
var scroll = ItemsPanel;
if (scroll != null)
{
ItemsPanel.SetVerticalOffset((double)State[ScrollOffsetKey]);
}
}
};
}
请注意,状态在 Loaded
事件触发后恢复,因为管理滚动位置的 VirtualizingStackPanel
定义在 NavigationList
模板中,因此在页面最初未构建时(使用 ListBox
也是如此)不会出现在可视树中。
所以……终于,我们完成了!
结论
这篇博文感觉变成了一部史诗!正如我在引言中提到的,WP7 应用程序中的 tombstoning 很复杂,而且很容易在各种存储状态的地方感到困惑(甚至似乎让 Silverlight 大师 Jesse Liberty 也感到有些困惑!)。
值得注意的是,这个示例将整个 ViewModel
状态都进行了墓碑化,而在更复杂的应用程序中,可能更适合对 ViewModel
的抽象进行墓碑化,尤其是在它不可序列化的情况下。然而,我认为这篇博客文章中介绍的简单示例仍然是理解墓碑化过程的一个有用的起点。
您可以在此处下载此示例应用程序的源代码。
此致,
Colin E.