前言
之前在 剁手党也有春天 -- 淘宝 UWP ”比较“功能诞生记 这篇随笔中介绍了一下 UWP 淘宝的“比较”新功能呱呱坠地的过程。在鲜活的文字背后,其实都是程序员不眠不休的血泪史(有血有泪有史)……所以我们这次就要在看似好玩的 UWP 多窗口实现背后,挖掘一些我们也是首次接触的干活“新鲜热辣”地放松给大家。希望能使大家在想要将自己的 APP 开新窗口的时候,能从本文中得到一些启发,而不是总是发现 C# 关于 UWP 开新窗口可供参考的文章只有 Is it possible to open a new window in UWP apps? 这一篇。
---------我是干(一声)活(四声)的分割线--------
多开窗口的实现
由于主窗口各功能趋于稳定,而且很难腾出一块较大的空间给比较功能,而且如果需要再额外划分出一块空间的话,势必会增加用户来回切换空间的操作,从时间成本和学习成本来说都是不够高效的,所以我们决定利用一下 UWP 的新的功能,新打开一个窗口,这样可以在新窗口中完整体验比较功能。
所以本文最主要的目的,当然就是借我们的新的比较功能,谈一谈 UWP 新窗口功能的实现,以及窗口直接信息的传递和互动。要实现多窗口操作,首先“你得有一个女朋友”……不对,是你得有一个新窗口。那么如何打开新窗口呢?
UWP 开启新窗口
UWP 新开启第二窗口的步骤不算难,
CoreApplicationView newView = CoreApplication.CreateNewView();
await newView.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { var newWindow = Window.Current; var newAppView = ApplicationView.GetForCurrentView(); frame = new Frame(); frame.Navigate(typeof(ComparisonPage)); newWindow.Content = frame; newWindow.Activate(); newViewId = newAppView.Id; }); viewShown = await ApplicationViewSwitcher.TryShowAsStandaloneAsync(newViewId);
简单几句就可以打开一个新窗口,并且在新窗口中切换到事先写好的“比较页面”。但是这样打开的新窗口还比较“粗糙”,很大的几率会出问题,例如打开了更多的窗口。那么需要我们一步一步完善:
1. 样式问题:
新窗口中,窗口的标题栏是 Windows 当前主题的颜色,和主窗口的淘宝主题色很不搭调。怎么办?
加入这么几行代码:
newAppView.Title = "商品比较"; ApplicationViewTitleBar titleBar = newAppView.TitleBar; titleBar.BackgroundColor = ......; titleBar.ForegroundColor = ......;
其中,titleBar 的参数是可以充分进行设定的。这样我们就可以实现和主窗口一样的色调,使新窗口看起来不那么“山寨”。
2. 用户回到主界面,再点击一次“去比较”按钮,又会新开好多窗口,这个怎么办呢?
这个问题其实不难解决,我们注意到,最后打开新窗口的 TryShowAsStandaloneAsync 方法会根据是否打开成功返回一个 bool 值,我们可以根据这个 bool 值进行判断,如果为 true,说明新窗口已经打开了,那我们只需要执行
await ApplicationViewSwitcher.TryShowAsStandaloneAsync(newViewId);
就可以切换到刚才的窗口了。
3. 要是打开的比较窗口被用户关闭了怎么办呢?
的确,要是打开新窗口成功,然后关闭的话,仅仅判断 TryShowAsStandaloneAsync 方法的返回值是不够的,很有可能出现跳转到一个不存在的窗口 id 的情况。所以我们再引入一个 bool 值,叫viewClosed,当 viewClosed 为 true 的时候,说明用户关闭了新的比较窗口,那么再次点击“去比较”的时候,我们就不能单纯跳转,而是要再次打开刚才的窗口。首次打开新窗口的时候,为新窗口的 Consolidated 事件触发方法,这样就可以在用户关闭新窗口的时候,将 ViewClosed 置为 true。这样,我们就可以根据 viewClosed 和 viewShown 来判断当前窗口的情况。从而做出正确的选择了。
newAppView.Consolidated += NewAppView_Consolidated;
......
}
private void NewAppView_Consolidated(ApplicationView sender, ApplicationViewConsolidatedEventArgs args) { viewClosed = true; }
这样,整体打开新窗口的较完整代码结构就变成了:
static bool viewShown = false; static bool viewClosed = false; static int newViewId; static int currentViewId; static Frame frame; private async void AppBarFontButton_ComparisonButtonTapped(object sender, bool e) { CoreApplicationView newView = CoreApplication.CreateNewView(); if (viewShown) { if (viewClosed) { await ApplicationViewSwitcher.TryShowAsStandaloneAsync(newViewId); viewClosed = false; } else { await ApplicationViewSwitcher.SwitchAsync(newViewId); } } else { await newView.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { var newWindow = Window.Current; var newAppView = ApplicationView.GetForCurrentView();
newAppView.Consolidated += NewAppView_Consolidated; newAppView.Title = "商品比较"; ApplicationViewTitleBar titleBar = newAppView.TitleBar; // Title bar setting ...... frame = new Frame(); frame.Navigate(typeof(ComparisonPage)); newWindow.Content = frame; newWindow.Activate(); newViewId = newAppView.Id; }); viewShown = await ApplicationViewSwitcher.TryShowAsStandaloneAsync(newViewId); } } private void NewAppView_Consolidated(ApplicationView sender, ApplicationViewConsolidatedEventArgs args) { viewClosed = true; }
这样,就基本可以做到在主窗口不管怎样点击,或者新窗口不管是不是关闭了,都可以一键切换到我们的比较窗口了。下一步,我们的目标就是要将当前的商品传递到比较窗口进行展示。
参数与事件的互相传递
主窗口向子窗口传递参数:
由于主窗口是商品详情页面,所以当前页面已经拥有了导航到此商品的全部导航信息。但是如何可以将这些信息传递到子窗口呢?我们注意到,刚才子窗口的页面的导航方法是:
frame = new Frame(); frame.Navigate(typeof(ComparisonPage)); newWindow.Content = frame;
这种导航方式,使得我们很难访问被导航页面的信息,从而难以传递信息。那是不是就没有办法了么?当然不是,这里提供两种思路,供不同场景下参考:
方法1:静态参数
将 ComparisonPage 页面的商品导航参数对象设置为静态,这样就可以通过
ComparisonPage._navArgs = _navArgs;
的方法,在主页面直接赋值。然后可以通过触发其他静态方法或者为这个导航参数对象继承 INotifyPropertyChanged 接口,这样当被赋值的时候可以触发事件,使得新窗口在比较栏中打开这个新的商品。由于每次只有一个主窗口,也只有一个页面可以点击去比较,所以不太可能出现多个页面同时向一个静态参数传递信息导致冲突的情况发生。
方法2:强行找到这个被导航到的页面的对象并赋值
这个方法说起来有点拗口,但其实就是找到 frame 实际导航到的页面,并对其对象(非静态)进行赋值。这样,我们需要用到一个方法叫做 FindVisualChildren,其实现如下:
public static IEnumerable<T> FindVisualChildren<T>(DependencyObject depObj) where T : DependencyObject { if (depObj != null) { for (int i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++) { DependencyObject child = VisualTreeHelper.GetChild(depObj, i); if (child != null && child is T) { yield return (T)child; } foreach (T childOfChild in FindVisualChildren<T>(child)) { yield return childOfChild; } } } }
通过这个方法,我们可以用
foreach (ComparisonPage cp in FindVisualChildren<ComparisonPage>(frame)) { cp._navArgs = _navArgs; }
来找到这个页面的参数。我们还可以用这个方法来调用这个页面的非静态方法,这样也就可以很方便地触发页面下的商品跳转功能了。
子窗口与主窗口交互:
子窗口有两个机会,十分有幸地向上和主窗口进行交互:
一是在商品未填满所有比较窗口的时候,我们可以一键返回主窗口,继续挑选商品加入比较。
二是点击待比较商品的店铺,会在主窗口跳转到店铺。
1. 子窗口切换到主窗口
这个问题相对简单,其实在子窗口就是一句代码的事:
private async void SwitchToMasterWindow(object sender, int e) { await ApplicationViewSwitcher.SwitchAsync(masterWindowId); }
但是问题在于,子窗口怎么知道主窗口的 masterWindowId 呢?所以,还是要靠主窗口在创建子窗口的时候,把自己的 id 无私地告诉子窗口:
var currentView = ApplicationView.GetForCurrentView(); currentViewId = currentView.Id; ... frame.Navigate(typeof(ComparisonPage), currentViewId);
这样子窗口就可以一键回家吃饭了!
2. 子窗口通知主窗口跳转店铺
这个问题就比单纯窗口切换要难一些了。在试过多次子窗口跳转主窗口然后跳转店铺被报线程错误但是解决无果后,我只能祭出笨却实用的老办法:事件通知。子窗口点击店铺的时候,触发跳转店铺事件,同时参数是店铺的 id,主页面创建子页面的时候,注册这个事件,一旦触发,就捕捉事件参数(店铺 id)进行跳转。至于注册这个事件,既可以用刚才提过的静态参数法,也可以用 FindVisualChildren 这个好用的方法,直接把事件从页面里抓出来进行注册:
private void Frame_LayoutUpdated(object sender, object e) { foreach (ComparisonPage cp in FindVisualChildren<ComparisonPage>(frame)) { cp.GoToShop -= Cp_GoToShop; cp.GoToShop += Cp_GoToShop; } } private async void Cp_GoToShop(object sender, string e) { await this.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { if (!string.IsNullOrEmpty(e)) { Nav.To(DataHelper.DataSource.ShopDS.GetH5ShopIndexUrlByShopId(e)); } }); }
3. 未登录状态打开比较窗口遇到的问题
这是一个在写作过程中被报的 Bug, 如果在未登录状态下打开比较页面,那么在点击“登陆”和“加入购物车”的时候程序会崩溃。“哦!我的天哪!我的老伙计,这确实是我的问题。非常感谢你们能把它提出来。”(央视翻译腔)。由于我在写代码和测试过程中,一直是有账号登陆的状态,所以确实忽略了未登录状态可能遇到的问题。那么为什么会出现这个问题呢?是因为默认的页面设计是:如果遇到“收藏”或“购物车”这些需要登录才能进行的操作时,会调用另外的登陆控件填充屏幕,使用户登录。而在新窗口中,受到线程的制约(具体情况下文会讲到),在调用另外的控件会出现线程间调用的错误。而这些“收藏”或“加入购物车”都是控件级别的事件,难以用页面级别的 UI 线程处理这个问题;同时为了避免在三个比较窗口都弹出登陆提示框(用户到底登陆哪个算?),我们决定将登陆事件向上传,传到比较页面的顶层,然后提示用户是否要登陆?如果登陆,则切换回主窗口进行登陆,否则则暂不登陆。
所以这里的处理方法和刚刚提到的子窗口通知主窗口跳转店铺很相似,提示跳转 -> 跳转 -> 传递事件:
private async void Tdp_UserNotLogin(object sender, string e) { bool ret = await ShowDialog(string.Format("亲,你还没有登陆,是否要切换到主窗口登陆?"), "去登陆", "先不登陆"); if (ret) { await ApplicationViewSwitcher.SwitchAsync(masterWindowId); UserWantstoLogin?.Invoke(this, e); } }
然后由主页面处理登陆事件,这样可以避免同时打开多个登陆窗口造成混乱的情况。
4. 子窗口随主窗口关闭
这也是一个在写作过程中被报的 Bug。那就是,关闭了主窗口,子窗口不会随之关闭,导致整个进程不结束,只有关闭了子窗口才算是全部关闭完成。这个问题其实不难解决,我们首先获得主窗口的“View”,然后在这个“View”的 Consolidated 事件上加入关闭程序的指令(静态方法)即可:
var currentView = ApplicationView.GetForCurrentView(); currentView.Consolidated += CurrentView_Consolidated; ...... private void CurrentView_Consolidated(ApplicationView sender, ApplicationViewConsolidatedEventArgs args) { CoreApplication.Exit(); }
当然如果你是一个怀旧的人,也可以使用较为老派的(非静态方法)
Application.Current.Exit();
参考:
How to exit or close an UWP app programmatically? (Windows 10)
和线程作斗争,一头乱麻
相信大家都听过这个关于多线程的著名笑话:“从前我有一个问题,后来我用多线程去解决这个问题,现在我有了两问个题”。
这个笑话告诉我们多线程最容易带来混乱,尤其是 UWP 这些数不清的异步方法,稍微一不注意就会抛出异常。很多细心的读者应该注意到了,我在之前的很多地方的代码都用到了:
await newView.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { ...... });
或
await this.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { ...... });
这其实就是在通知 UI 线程进行异步操作(这里用 Lambda 表达式代替了过时的代理方法),在新窗口和老窗口的一些交互的地方,例如老窗口创建新窗口,新窗口展示待比较宝贝页面,如果不使用线程代理的话,都是会提示出错导致 App 崩溃的,所以都需要用这个方法来通知 UI 线程进行异步操作。如果要写成同步的,代码就要麻烦许多。或者还有刚刚提到的新窗口未登录状态需要打开登陆页面的情况,涉及到线程过于复杂,所以干脆就用事件传递到主窗口进行处理。如果详细展开说的话,仅仅这一段就可以再写好几篇博客了。所以我们在这里不再讨论过于底层的东西,因为这些和 WPF 都是技术相通的,很多人都写过关于这个的文章,因此我们不再赘述。如果读者感兴趣的话,不妨读一下关于 UWP 或 WPF 线程的文章,获取更深层的知识。如果可以达到这个目的,那么也算是我们抛砖引玉了。
总结
UWP 开新窗口不难,但是要想很好的让新窗口和主窗口老老实实为你工作,就需要花一点心思和不断地调教他们了(其实都是程序员的自我调教)。我们不但要注意各个窗口的状态,知道在什么时候使用跳转什么时候使用打开窗口,还需要通过各种办法在窗口之间传递信息和事件。但即使我们每一点都测试到了,还是容易受到多线程的拖累或者产生一些意想不到的问题。我只能说,和多窗口打交道的日子,绝对是痛并快乐着。
参考:
[UWP]Is it possible to open a new window in UWP apps?
Find all controls in WPF Window by type
How to exit or close an UWP app programmatically? (Windows 10)