APP的挂起状态我在前面两篇关于导航的博客里面已经有提到,我这么说吧,目前版本(包括最新的RTM版)都是有一个bug的。下面我会给你演示这个bug。在这之前我先讲下这个挂起问题的临床表现吧。
不知道你们有没有注意过,就是当你打开一个APP的时候浏览了一会然后切换到其他APP, 过一段时间以后再切换回原来的APP的时候你会发现原来的APP回到首页了,并不是离开APP的时候那个页面,这里有两个原因会发生这种情况。这种情况在调试里面叫“挂起并关闭”,怎么查看APP是否处于这种状态,很简单,就是屏幕左边弹出一列你所有打开的APP列表,如果有APP的缩略图变成启动页图标的时候,那么说明这个APP处于这种状态,如果APP的缩略图是你离开APP的时候的页面的截图那么APP处于正常运行状态。下面我介绍下引起上面提到的问题的原因。
1.APP开发的时候根本就没有处理挂起状态
2.APP开发的时候处理了挂起状态,但是由于系统的一个Bug导致APP在挂起的时候crash,所以当你从挂起状态恢复的时候由于没有数据恢复只能从首页开始
这个导致Crash的API是Frame.GetNavigationState()方法(只有当你导航的时候传递的参数是复杂类型的时候才会引发这个bug,这个就是我在前面两篇博客中提到的问题),如果你用了VS的项目模版,SuspensionManager这个类里面的SaveFrameNavigationState这个方法会调用Frame.GetNavigationState()方法,这个方法主要的作用就是保存Frame的导航状态,这样当你从挂起状态恢复的时候APP才能正确的恢复状态,也就是你离开APP的时候是哪个页面回来的时候还会在那个页面(这个是非常重要的,如果你没有恢复导航状态,那么可以说你的数据就算保存了也是没用的,因为APP在恢复的时候根本就没用到你保存的数据),恢复导航状态是调用 Frame.SetNavigationState这个方法。
下面我演示这个bug。
首先使用VS创建一个GridAPP类型的项目。
因为项目模版的三个页面的传递的参数的类型都是字符串,所以不会出现这种问题,这里我们需要做一些改动。先改下GroupedItemsPage里面的ItemView_ItemClick方法的代码,原来的代码是:
void ItemView_ItemClick(object sender, ItemClickEventArgs e) { // 导航至相应的目标页,并 // 通过将所需信息作为导航参数传入来配置新页 var itemId = ((SampleDataItem)e.ClickedItem).UniqueId; this.Frame.Navigate(typeof(ItemDetailPage), itemId); }
现在我们要改成
void ItemView_ItemClick(object sender, ItemClickEventArgs e) { // 导航至相应的目标页,并 // 通过将所需信息作为导航参数传入来配置新页 this.Frame.Navigate(typeof(ItemDetailPage), e.ClickedItem); }
就是把原来传递ID的现在直接把对象传递过去,下面我们还要改下ItemDetailPage里面LoadState方法的代码,原来代码如下:
protected override void LoadState(Object navigationParameter, Dictionary<String, Object> pageState) { // 允许已保存页状态重写要显示的初始项 if (pageState != null && pageState.ContainsKey("SelectedItem")) { navigationParameter = pageState["SelectedItem"]; } // TODO: 创建适用于问题域的合适数据模型以替换示例数据 var item = SampleDataSource.GetItem((String)navigationParameter); this.DefaultViewModel["Group"] = item.Group; this.DefaultViewModel["Items"] = item.Group.Items; this.flipView.SelectedItem = item; }
现在代码如下:
protected override void LoadState(Object navigationParameter, Dictionary<String, Object> pageState) { // TODO: 创建适用于问题域的合适数据模型以替换示例数据 var item = (SampleDataItem)navigationParameter; this.DefaultViewModel["Group"] = item.Group; this.DefaultViewModel["Items"] = item.Group.Items; this.flipView.SelectedItem = item; }
现在可以直接运行了,运行后我们点击一个项进入详情页面。下面就开始调试挂起状态。
在调试的时候在VS的工具栏点击鼠标右键会出来一个toolbar列表,这里面把调试位置这个toolbar选上(默认是未选择状态),如图
这时候来调试挂起状态,点击“挂起并关闭”,如图:
这时候就出问题了,APP直接Crash
因为SaveAsync这个方法调用了前面我提到的Frame.GetNavigationState方法导致的Crash,各位可以自己断点设置过去看看。由于Frame.GetNavigationState这个bug存在,可以这么说,你开发的APP几乎是没法正真的实现数据保存和恢复的。而事实上目前商店中的很多APP都有这样的情况,国外的不说,我只说国内的,国内很多的APP基本上都有这样的情况(包括我目前开发的一款APP),只要APP进入挂起状态,那么你重新切换回来的时候就是从首页开始的。这里要说下,APP何时会进入挂起状态,这个是系统来决定的,如果内存不够了那么除了当前运行的APP,其他的APP肯定会进入挂起状态。
那么这个问题有没有解决方法呢?答案是有的,但是不完美,如何不完美我后面会提到,我下面先说下如何解决这个问题。
既然我们的参数不能传递复杂类型,那么只能传递简单类型或者没有参数传递。而我目前提供的方法就是“不传递参数”,这里说的“不传递参数”并不是真的就不传了,只是我们需要换一种传递参数的方法,也就是我们在使用Frame.Navigate方法的时候不会传递参数了,只能自己写一个方法来完成传递参数的目的。
当我们使用VS自带的模版创建项目的时候,都会有一个Common文件夹的,里面有一个LayoutAwarePage类,这个类也是我们创建页面的基类,我们需要对这个类进行改动下以便达到我们的目的。首先我们需要在LayoutAwarePage这个类里面添加两个方法,代码如下:
private static object nextPageParam; /// <summary> /// 如果传递的对象是复杂类型,那么使用本方法来导航页面 /// </summary> /// <param name="pagetype"></param> /// <param name="obj"></param> public void Navigate(Type pagetype, object obj) { nextPageParam = obj; this.Frame.Navigate(pagetype); } public void Navigate(Type pagetype) { this.Frame.Navigate(pagetype); }
下面还要对里面的OnNavigatedTo方法中的代码进行改动,以便我们能正确的传递参数,并且能保存我们传递的参数,这样页面恢复的时候还能使用原来的参数。代码如下:
protected override void OnNavigatedTo(NavigationEventArgs e) { // 通过导航返回缓存页不应触发状态加载 if (this._pageKey != null) return; var frameState = SuspensionManager.SessionStateForFrame(this.Frame); this._pageKey = "Page-" + this.Frame.BackStackDepth; if (e.NavigationMode == NavigationMode.New) { // 在向导航堆栈添加新页时清除向前导航的 // 现有状态 var nextPageKey = this._pageKey; int nextPageIndex = this.Frame.BackStackDepth; while (frameState.Remove(nextPageKey)) { nextPageIndex++; nextPageKey = "Page-" + nextPageIndex; } //如果nextPageParam不为空,那么我们需要保存这个参数以便恢复的时候能正常恢复 if (nextPageParam != null) { string key = this._pageKey + "_NextPageParam"; frameState[key] = nextPageParam; this.LoadState(nextPageParam, null); nextPageParam = null; } else // 将导航参数传递给新页 this.LoadState(e.Parameter, null); } else { string key = this._pageKey + "_NextPageParam"; if (frameState.ContainsKey(key)) { this.LoadState(frameState[key], (Dictionary<String, Object>)frameState[this._pageKey]); } else // 通过将相同策略用于加载挂起状态并从缓存重新创建 // 放弃的页,将导航参数和保留页状态传递 // 给页 this.LoadState(e.Parameter, (Dictionary<String, Object>)frameState[this._pageKey]); } }
只要用上面这段代码替换原来的代码就可以了。下面我们得修改下调用的方法,还是修改GroupedItemsPage里面的ItemView_ItemClick方法,把原来的 this.Frame.Navigate(typeof(ItemDetailPage), e.ClickedItem);改成现在的 this.Navigate(typeof(ItemDetailPage), e.ClickedItem);因为我们在基类里面添加了Navigate方法,所以我们在使用的时候可以直接使用this.Navigate来导航,现在试着运行APP,你会发现还是Crash,但是Crash的原因不同了,这次的Crash报的错误信息是无法序列化对象SampleDataItem。为什么无法序列化SampleDataItem对象呢?因为SuspensionManager在保存数据的时候是使用DataContractSerializer来把一个字典集合序列化保存到文件中的,而这个字典的类型是Dictionary<string, object>,也就是说SuspensionManager在序列化字典的时候根本不知道这个字典保存的类型是什么类型,这时候就需要手动添加KnownTypes了,也就是我们要把所有保存到字典中的类型添加到KnownTypes集合中,这样SuspensionManager在序列化的时候就能正确序列化集合了,这里我选择在APP.cs中添加,在APP的OnLaunched方法里面添加,SuspensionManager.KnownTypes.Add(typeof(Data.SampleDataItem));把这段代码加进去就行了。
SuspensionManager.RegisterFrame(rootFrame, "AppFrame"); SuspensionManager.KnownTypes.Add(typeof(Data.SampleDataItem)); if (args.PreviousExecutionState == ApplicationExecutionState.Terminated) { // 仅当合适时才还原保存的会话状态 try { await SuspensionManager.RestoreAsync(); } catch (SuspensionManagerException) { //还原状态时出现问题。 //假定没有状态并继续 } }
到这里还没完,因为能被序列化的只有是被标记了[DataContract]的类才能被序列化(包括所有的父类),到这当然还没完,既然标记了[DataContract]那么肯定是要对属性做标记的,不然没有被标记的属性是不会被序列化的。对于做过WCF的肯定会很熟悉如何标记了。标记完了现在就可以直接运行,你会发现现在可以正常挂起了。并且离开的时候是哪个页面,回来的时候还是在那个页面。
其实这里面的标记有点复杂,因为SampleDataGroup和SampleDataItem涉及到循环引用,所以直接用[DataContract]标记是没用的,必须使用 [DataContract(IsReference = true)]这个来标记。具体看我源码
好了,到这里对于数据的保存方面的内容告一段落。