大多数传统的 Windows应用程序都以包含工具栏和菜单的窗口为中心。工具栏和菜单驱动应用程序–当用户单击它们时,动作发生,并且显示其他窗口。在基于文档的应用程序中,可能还有几个同样重要的立即打开的主窗口,但整个模型是相同的。用户将大部分时间都用在一个地方,并当需要时会跳到另一个单独的窗口。

  Windows应用程序非常普遍,以至于有时都很难想象出不同的方式来设计应用程序。然而Web 开发使用非常不同的基于页面的导航模型,并且桌面开发人员发现对于设计特定类型的应用程序这是非常好的选择。在为桌面应用程序开发人员提供构建类似 Web 的桌面应用程序能力的呼声下,WPF提供了自己的基于页面导航的系统。正如您将在本章看到的,它是一个极其灵活的模型。

  目前,基于页面的模型最常用于简单的轻量级应用程序(或用于更复杂的基于窗口的应用程序的子部分)。然而,如果希望精简应用程序部署,基于页面的应用程序是比较好的选择。这是因为 WPF 允许创建直接运行于Intemet Explorer 或 Firefox 浏览器中的基于页面的应用程序。这意味着用户不需要执行显式的安装步骤就可以运行应用程序——只需要将浏览器指定到正确的位置即可。本章将介绍该模型,称为XBAP。

基于页面的导航

  一般的 Web 应用程序看起来和传统的富客户端软件颇为不同。网站用户的大部分时间都花在从一个页面导航到另一个页面。除非非常不幸地遇到弹出的广告,否则一次只显示一个页面。当执行任务时(例如,下订单或执行复杂的查找操作),用户从始至终都会以线性方式在这些页面之间穿梭。

  HTML不支持桌面操作系统的高级窗口功能,因此最优秀的 Web 开发人员依赖于良好的设计以及直观清晰的界面。随着 Web 设计的日趋复杂化,Windows 开发人员也开始注意到这一方式的优点。最重要的是,Web模型简单流畅。因此,新用户会经常发现网站比 Windows应用程序更易用,尽管 Windows 应用程序的功能明显更加强大。

  在某些情况下,开发人员可使用 Internet Explorer 浏览器引擎创建类似 Web 的应用程序。这也是 Microsoft Money 采用的方法,但该方法对于非 Microsof 开发人员更加麻烦。尽管Microsoft 提供了可使用 Internet Explorer 浏览器的方法,如 WebBrowser 控件,但使用这些特性构建完整的应用程序仍非常复杂,而且还可能失去普通 Windows 应用程序可使用的最佳功能。

基于页面的界面

  要在 WPF 中创建基于页面的应用程序,不能使用 Window 类作为用户界面的顶级容器,而需要使用 System.Windows.Controls.Page类。

  在 WPF 中,用于创建页面的模型与创建窗口的模型非常类似。尽管可以只使用代码创建页面对象,但通常还是为每个页面创建 XAML 文件和代码隐藏文件。当编译应用程序时,编译器创建派生的页面类,该类将您的代码和一些自动生成的代码(例如,引用页面中已命名元素的字段)结合在一起。这一过程和编译基于窗口的应用程序的过程是相同的。

  尽管当设计应用程序时,页面是顶级的用户界面元素,但当运行应用程序时,它们不是顶级容器。页面被驻留于另一个容器中。这正是基于页面的WPF应用程序灵活性的秘密所在,因为它们可以使用几个不同的容器之一:

  • NavigationWindow,它时Windows类的一个稍经修改的版本
  • 位于另一个窗口中的框架(Frame)
  • 位于另一个页面中的框架(Frame)
  • 直接驻留于Inter Explorer或Firefox中的框架(Frame)

简单的导航窗口应用程序

  下面首先看一个非常简单的基于页面的应用程序,创建页面的标记如下:

1
2
3
4
5
6
7
<Application
StartupUri="Views/MainPage.xaml"
x:Class="WpfApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

</Application>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Page
Title="MainPage"
mc:Ignorable="d"
x:Class="WpfApp.Views.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WpfApp.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel Margin="3">
<TextBlock Margin="3">This is a simaple page.</TextBlock>
<Button Margin="2" Padding="2">OK</Button>
<Button Margin="2" Padding="2">Close</Button>
</StackPanel>
</Page>

  当运行该应用程序时,WPF非常智能,它能认识到正在运行的是页面而不是窗口。它会自动创建一个新的 NavigationWindow 对象作为容器,并在其中显示相应的页面。它还读取页面的 WindowTitle属性,并将该属性用作窗口的标题。

页面和窗口之间的一个区别在于一般不能设置页面的尺寸,因为页面的尺寸由包含它的宿主决定。如果设置页面的 Width 和 Height属性,页面确实会具有设置的精确大小,但如果宿主窗口比页面更小,那么页面中的一些内容就会被剪裁掉:如果宿主窗口比页面更大,那么页面就会在可用空间中居中显示。

  除了位于页面顶部横条上的前进和后退导航按钮外,NavigationWindow看起来和普通窗口多少有些类似。正如您可能希望的那样,NavigationWindow类继承自Window类,并添加了少量与导航相关的属性。可使用以下代码获取对所属的NavigationWindow对象的引用:

1
2
// Get a reference to the window that contains the current page.
NavigationWindow win = (NavigationWIndow)Window.GetWindow(this);

在页面的构造函数中,上面的代码不能工作,因为尚未将页面放到它的容器中——而至少要等到Page.Loaded事件触发以后,才能使用上面的代码

要尽量避免使用 NavigationWindow,而应使用 Page 类的属性(以及在本章后面将要介绍的导航服务)。否则,页面就会和NavigationWindow对象紧密耦合在一起,并且不能在不同的宿主中重用页面。

  如果希望创建只有代码的应用程序,那么为了得到上图所示的效果,不仅需要创建页面还需要自己创建导航窗口。下面是创建导航窗口的代码:

1
2
3
NavigationWindow win = new NavigationWindow();
win.Content = new MainPage();
win.Show();

Page类

  与 Window 类一样,Page 类只能包含一个嵌套元素。然而,Page 类不是内容控件–它直接继承自 FrameworkElement 类。Page 类更简单,也比 Window 类更加精简。它添加了少量属性,通过这些属性可定制其外观,以一种受限的方式与容器交互,以及使用导航功能。下表列出了这些属性。

Page类的属性
名称 说明
Background 设置属性使用画刷填充页面的背景
Content 该属性是在页面中显示的单一内容。通常是布局容器,如Grid面板或StackPanel面板
Foreground FontFamily FontSize 决定了页面中文本的默认外观。页面中的元素可继承这些属性的值。例如,如果设置了前景填充以及字体尺寸,那么默认情况下页面中的内容就会使用这些前景填充和字体尺寸细节
WindowWidth WindowHeight WindowTitle 决定了封装页面的窗口的外观。可以使用这些属性通过设置宽度、高度以及标题来控制页面的宿主。当只有当页面被驻留与窗口(而不是框架)中时它们才起作用
NavigationService 返回对NavigationService对象的引用,可通过代码使用该对象将用户导航到另一个页面
KeepAlive 当用户导航到另一个页面时,决定原来的页面对象是否保持存活
ShowNavigationUI 决定宿主是否为页面显示导航控件(前进和后退按钮)。默认情况下,该属性的值为true
Title 为页面设置在导航历史中使用的名称。宿主不实用该属性在标题栏中设置标题——而是使用WindowTitle属性设置标题

  还有一点非常重要,Page类未提供与Window类中的Hide()以及Show()方法等同的方法。如果希望显示另一个页面,需要使用导航。

超链接

  允许用户从一个页面移到另一个页面的最简单方式是使用超链接。在WPF中,超链接不是独立元素,而是内联的流元素,必须将此类元素放到支持它们的另一个元素中(这样设计的原因是超链接和文本通常结合使用)。

1
2
3
4
<TextBlock Margin="3" TextWrapping="Wrap">
This is a simaple page.
CLick<Hyperlink NavigateUri="Page2.xaml">here</Hyperlink>
</TextBlock>

  可使用两种方法处理链接单击操作。可响应Click事件并使用代码执行一些任务,或者直接导航到另一个页面。然而,还有更简便的方法。Hyperlink类还包含了NavigateUri 属性,可将该属性设置为指向应用程序中的其他任何页面。然后,当用户单击这个超链接时,它们就会自动导航到目标页面。

只有在页面上放置超链接时,NavigateUri属性才有效。如果希望在基于窗口的应用中使用超链接,让用户执行任务、加载网页或打开新的窗口,就需要处理RequestNavigate 事件并自行编写代码。

  超链接并非从一个页面移到另一个页面的唯一方法。NavigationWindow 提供了非常显眼的前进按钮和后退按钮(除非将 Page.ShowsNavigationU属性设置为 false 以隐藏它们)。每当单击这些按钮时,都可以在页面的导航序列中移动,每次移动一页。并且与浏览器类似,可单击前进按钮旁边的下拉箭头来查看整个序列,并且可以一次向前或向后跳过几个页面,如下图所示:

如果导航到一个新的页面,而且该页面没有设置 WindowTitle 属性,那么窗口会保持前一个页面的标题。如果所有页面都没有设置 WindowTitle 属性,那么窗口的标题会保持空白。

导航到网站

  有趣的是,还可创建指向 Web内容的超链接。当用户单击这一超链接时,就会在页面区域中加载目标网页:

1
2
3
4
<TextBlock Margin="3" TextWrapping="Wrap">
Visit the website
<Hyperlink NavigateUri="https://www.baidu.com">www.baidu.com</Hyperlink>
</TextBlock>

  然而,如果使用了这一技术,**务必将处理程序关联到Application.DispatcherUnhandledExceptionApplication.NavigationFailed 事件。这是因为如果计算机离线、站点不可访问或无法获得 Web 内容,将无法导航到网站。在这种情况下,网络堆栈会返回类似“404:没有找到文件”的错误,该错误会被转换成 WebException异常。**为了优雅地处理该异常,并防止应用程序不正常关闭,需要使用事件处理程序来消除问题,如下所示:

1
2
3
4
5
6
7
8
private void App_OnNavigationFailed(object sender, NavigationFailedEventArgs e)
{
if (e.Exception is System.Net.WebException)
{
MessageBox.Show("Website " + e.Uri.ToString() + " cannot be reached.");
e.Handled = true;
}
}

  NavigationFailed 只是 Application 类中定义的几个导航事件中的一个。在本章您将会看到所有这些事件,下表列出了这些事件。

NavigationService类的事件
名称 说明
Navigating 即将开始导航。可取消该事件,防止导航发生
Navigated 导航已经开始,但尚未检索到目标页面
NavigationProgress 导航正在进行,并且已经下载了一块页面数据。为了提供有关导航进度的信息,周期性地引发该事件。该事件提供了已经下载的信息量(NavigationProgrcssEvent.BytesRead)以及所需的信息总量(NavigationProgressEvent.MaxBytes)。每次检索到IKB 数据时,就会引发该事件
LoadCompleted 页面已解析完毕。然而,尚未引发Initialized和Loaded事件
FragmentNavigation 页面正被滚动到目标元素。只有当使用具有分段信息的URI时,才会引发该事件
NavigationStopped 使用StopLoading()方法取消了导航
NavigationFailed 因为找不到或无法下载目标页面而导致导航失败。可使用该事件处理异常,从而防止异常上传并转变为未处理的应用程序异常。只需要将NavigationFailedEventArg.Handled属性设置为 true 即可

  当显示来自外部网站中的页面时,有许多限制。不能阻止用户导航到特定的页面或站点。而且,也不能使用 HTML,文档对象模型(Document Obiect Model,DOM)与网页进行交互。这意味着不能浏览页面,从而查找链接或动态改变页面。可使用 WebBrowser 控件完成所有这些任务。

分段导航

  可使用的最后一种超链接技术是分段导航(fragmentnavigation)。通过在NavigateUi 属性的末尾添加数字符号(#),并在后面添加元素名称,就可以直接导航到页面中的特定控件。然而,只有当目标页面能够滚动时,这一技术才起作用(如果目标页面使用 ScrollViewer 控件或者页面驻留于 Web 浏览器中,页面就是可以滚动的)。下面是一个例子:

1
2
3
<TextBlock Margin="3" TextWrapping="Wrap">
Review the <Hyperlink NavigateUri="Page1.xaml#myTextBox">full text</Hyperlink>
</TextBlock>

  当用户单击此链接时,应用程序移动到名为Page2的页面中,并向下滚动到myTextBox元素。页面一直向下滚动,直至使 myTextBox出现在页面顶部为止(或是尽量靠近,具体取决于页面内容以及包含窗口的大小)。但目标元素不接受焦点。

在框架中驻留页面

  NavigationWindow 是一个非常方便的容器,但它不是唯一选择。还可直接在其他窗口甚至其他页面中放置页面。通过这一功能可实现非常灵活的系统,因为可根据需要创建的应用程序类型以不同方式重用相同页面,

  为在窗口中嵌入页面,只需要使用Frame 类。Frame 类是可包含任何元素的内容控件,但当用作页面的容器时,该控件非常有意义。它提供了 Source 属性,该属性指向希望显示的 XAML页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<Grid Margin="3">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Margin="3" TextWrapping="Wrap">
This is ordinary window content.
</TextBlock>
<Button Margin="3" Padding="3">Close</Button>
</StackPanel>
<Frame
BorderBrush="Blue"
BorderThickness="1"
Grid.Column="1"
Source="/Views/Page1.xaml" />
</Grid>

  如果希望永远都不显示导航按钮,可将NavigationUIVisibility属性改成 Hidden;如果希望一开始就让按钮是可见的,可将NavigationUIVisibility 属性改为 Visible。

在另一个页面中驻留页面

  通过框架可创建更复杂的窗口。刚刚介绍过,可在窗口中使用多个框架。此外,还可在另一个页面中放置框架,从而创建嵌套的页面。实际上,过程完全相同–在页面标记中简单地添加 Frame 对象。

嵌套的页面是更复杂的导航情形。例如,假设浏览页面,并且单击嵌套的框架中的链接。这时单击后退按钮会发生什么情况呢?

  本质上,所有位于框架中的页面都被放到一个列表中。所以第一次单击后退按钮时,会移到嵌入到框架中的前一个页面。下次单击后退按钮时,会移到前面浏览过的父页面。

  在大多数情况下,这一导航模型是非常直观的,因为在后退列表中会为浏览过的每个页面添加一项。然而在某些情况下,嵌入的框架并不那么重要。例如,嵌入的页面只是同一数据的不同视图,或是用于在帮助内容中跳过多个页面。在这些情况下,在嵌入的框架中逐个导航多个页面是笨拙的,也很浪费时间。您可能只希望使用导航控件来控制父框架的导航,从而当单击后退按钮时,立刻移动到前一个父页面。为此,需将嵌入框架的JouralOwnership 属性设置为 OwnsJoumal。这一设置告诉框架要保持自己不同的页面历史。在默认情况下,嵌入的框架现在会获取导航按钮,从而可以让用户在内容中后退和前进。如果这不是您所希望的行为,可结合使用JourmalOwnership 和 NavigationUIVisibility 属性,完全隐藏导航控件,如下所示:

1
2
3
4
5
6
7
<Frame
BorderBrush="Blue"
BorderThickness="1"
Grid.Column="1"
JournalOwnership="OwnsJournal"
NavigationUIVisibility="Hidden"
Source="/Views/Page1.xaml" />
具有自己的日志且支持导航的嵌入页面

在Web浏览器中驻留页面

  使用基于页面的导航的应用程序的最后一种方式是在Interet Explorer 或 Firefox 浏览器中驻留页面。然而,为使用这一方法,需要创建 XAML 浏览器应用程序(这种应用程序称为 XBAP)。在 Visual Studio 中,XBAP是单独的项目模板,并且为了使用驻留于浏览器的功能,在创建项目时必须选择该模板(而不能使用标准的WPF Windows应用程序)。本章后面将分析XBAP模型。

页面历史

深入分析WPF中的URI

  对于由松散的 XAML文件构成的、并且在浏览器中运行的应用程序,这是非常直观的–当单击超链接时,浏览器将页面引用看成相对 URI,并在当前文件夹中査找 XAML页面。但在已编译的应用程序中,页面不再作为单独资源,而是被编译为BAML(BinaryApplicationMarkupLanguage,二进制应用标记语言)并嵌入到程序集中。那么,如何使用 URI 引用它们呢?

  WPF 的应用程序资源寻址方式使该系统得以工作。当在编译过的XAML应用程序中单击超链接时,URI仍被视为相对路径。不过,此处是被视为相对于应用程序的基本 URI。所以,指向 Page1.xaml的超链接将被展开为如下所示的 pack URl:

1
pack://application:,,,MyApp;component/Page1.xaml

导航历史

  WPF 页面历史的工作原理和浏览器中的历史类似。每次导航到新的页面时,上一个页面就被添加到后退列表中。如果单击后退按钮,页面会被添加到前进列表中。如果从某个页面后退,然后导航到新的页面,就会清空前进列表。

  后退列表和前进列表的行为是非常直观的,但是它们的实现过程非常复杂。例如,假设测览一个具有两个文本框的页面,在其中输入一些内容并继续。如果后退回到该页面,就会发现WPF 恢复了这两个文本框的状态–这意味着不管在它们内部放置了什么内容,这些内容仍然保留在那里。

  您可能认为 WPF 通过在内存中保存页面对象,保以前浏览过的页面的状态。这种方法存在的问题在于,对于具有许多页面的复杂应用程序,为了进行导航,内存开销较大。为此,WPF不能假定维护页面对象是一种安全策略。相反,当离开页面时,WPF会保存所有控件的状态,然后销毁页面。当返回到页面时,WPF 根据原始的XAML 重新创建页面,然后还原控件的状态。这种策略的内存开销要小一些,因为只需要在内存中保存控件状态的少部分细节,这时所需要的内存比保存页面及其整个可视化树对象所需的内存要少得多。

  导航系统导致一个非常有趣的问题。WPF如何确定哪些细节需要保存呢?WPF检查页面的整个元素树,并且查看所有元素的依赖项属性。应当保存的属性具有少量额外的元数据,即日志标志,日志标志指示它们应当被保存在名为joumal 的导航日志中(当注册依赖项属性时,使用 FrameworkPropertyMetadata 对象设置日志标志,如第4章所述)。

  **功能最强大的方法是使用Page.KeepAlive属性,该属性的默认值是false。当将该属性设置为true 时,WPF 就不会使用前面介绍过的串行化机制,反而将保持所有页面对象有效。**因此,当导航回页面时,切信息照旧。当然,这种方法的缺点是增加了内存负担,因此只有当少数几个页面确实需要时,才应该使用该方法。

维护自定义的属性

  通常,当页面被销毁时页面类的所有字段都丢失了它们的值。如果希望为页面类添加自定义的属性,并且为了确保它们保持其数值,可以相应地设置日志标志。然而,对于普通属性或字段不能通过设置日志标志来保持其数值,反而需要在页面类中创建依赖项属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static readonly DependencyProperty MyPageDataProperty = DependencyProperty.Register(
nameof(MyPageData),
typeof(string),
typeof(MainWindow),
new FrameworkPropertyMetadata(default(string))
{
Journal = true , // 设置日志标志
}
);

public string MyPageData
{
get { return (string)GetValue(MyPageDataProperty); }
set { SetValue(MyPageDataProperty, value); }
}

  当用户离开该页面时,MyPageData属性的值会被自动串行化,而且当返回到该页面时会自动还原该属性的值。

导航服务

  您到目前为止看到的导航严重依赖于超链接。当使用该方法时,它非常简单而且很出色。然而,在某些情况下,可能希望进一步地控制导航过程。例如,如果正在使用页面构建固定的将用户从开始点以线性序列步骤导航到结束点的模型(如向导),那么超链接可以工作得很好。然而,如果希望用户完成一些小的顺序步骤并返回到通用页面,或者如果希望根据其他细节(如用户以前的操作)配置一系列步骤,这时超链接就不能满足需要了。

通过编程进行导航

  虽然可动态地设置 Hyperlink.NavigateUri和 Frame.Source 属性,但最灵活且功能最强大的方法是使用 WPF 导航服务。可通过驻留页面的容器(如Frame 或NavigationWindow)访问导航服务,但该方法仅允许将页面用于相应类型的容器中。访问导航服务的最佳方法是通过静态的NavigationService.GetNavigationService()方法。为该方法传递指向页面的引用,并且该方法返回-个有效的 NavigationService 对象,通过该对象可以使用代码进行导航:

1
NavigationService navigationService = NavigationService.GetNavigationService(this);

  NavigationService类提供了大量可用于触发导航的方法。其中最常用的是Navigate()方法,通过该方法可根据URI导航到某个页面:

1
navigationService.Navigate(new System.Uri("Page1.xaml",UriKind.RelativeOrAbsolute));

  或通过创建合适的页面对象进行导航

1
2
Page1 page1 = new Page1();
navigationService.Navigate(page1);

  WPF 导航是异步的。因此,在完成导航之前可通过调用NavigationService.StopLoading()方法来取消导航请求,还可使用 Refresh()方法重新加载页面。

  最后,NavigationService类还提供了GoBack()和 GoForward()方法,从而可在列表中向后和向前移动。如果创建自己的导航控件,这是非常有用的。如果试图导航到不存在的页面(例如,当处于第一个页面时试图后退),这两个方法都可能会引发 【nvalidOperationException 异常。为避免这些错误,在使用对应的方法之前应当检査 Boolean 类型的 CanGoBack和 CanGoFroward属性。

导航事件

  NavigationService 类还提供了一些非常有用的事件,可使用这些事件响应导航。响应导航最常见的原因是,当导航完成时执行一些任务。例如,如果页面驻留于普通窗口的框架中,当导航完成时可更新窗口状态栏中的文本。

  因为导航是异步的,所以Navigate()方法在目标页面显示之前就返回了。在某些情况下,时间差别可能是非常重要的,例如,当正导航到某个网站上松散的XAML 页面(或者位于另一个程序集中的触发 Web 下载的 XAML, 页面)时,或者当页面的 Imnitialized 或 Loaded 事件处理程序包含非常耗时的代码时。

  WPF导航处理的过程如下所示:

  1. 页面已经定位
  2. 已经检索页面信息(如果页面位于某个远程站点上,这时已经完成下载)
  3. 页面需要的所有相关资源(如图像)已经定位并已下载
  4. 页面已经解析,并已生成了对象树。这是,页面会引发其Initialized事件(除非从日志中还原页面,这是不引发该事件)和Loaded事件
  5. 页面被呈现
  6. 如果URI包含分段导航,WPF会导航到那个元素

  不能使用 RoutedEventArgs.Handled属性抑制导航事件,这是因为导航事件是普通的.NET事件,而不是路由事件。

可从 Navigate( )方法向导航事件传递数据,只需要查看使用额外对象参数的Navigate( )方法的重载版本即可。在 Navigated、NavigationStopped 以及 LoadCompleted 事件中,可通过NavigationEventArgs.ExtraData属性获取该对象。例如,可使用该属性持续跟踪请求导航的时间。

管理日志

  虽然可让导航过程具有适应性(例如,使用条件逻辑可使用户执行路径上不同的步骤),但仍然局限于从开始到结束的基本方法。下图显示了这种导航拓扑,
当构建简单的基于任务的向导时这种方式很常见。当用户退出代表逻辑任务的一组页面时——点划线指示了相关的步骤。

线性导航

  如果尝试使用 WPF导航实现这一设计,将发现缺少一些细节。换言之,当用户结束导航过程时(要么是因为在某个步骤中用户取消了操作,要么是因为用户完成了任务),需要擦除后退历史。如果应用程序是围绕某个不是基于导航的主窗口构建的,这不是什么问题。当用户启动基于页面的任务时,应用程序简单地创建新的NavigationWindow对象。任务结束时,可销毁窗口。然而,如果整个应用程序是基于导航的,就没这么容易了。当任务取消或完成时需要通过某种方法撤销历史列表,使用户不能后退到中间的某个步骤。

  但WPF不允许用户更多地控制导航堆栈。NavigationService 类只提供了两个方法:AddBackEntry()和 RemoveBackEntry()。在该例中需要使用 RemoveBackEntry( )方法。该方法从后退列表中获取最近的项并删除该项。RemoveBackEntry()方法还返回一个描述删除项的JournalEntry 对象。该对象提供了 URI(通过 Source 属性)以及在导航历史中使用的名称(通过 Name属性)。需要记住的是,该名称是根据 Page.Title 属性设置的。

  如果希望在任务结束后清除几个项,需要多次调用RemoveBackEntry()方法。可使用两种方式。如果决定删除整个后退列表,可使用CanGoBack属性决定何时到达了列表末尾:

1
2
3
4
while(navigationService.CanGoBack())
{
navigationService.RemoveBackEntry();
}

  另一种方式是不断删除项,直到删除任务的开始点。例如,如果有页面启动一项任务,该任务的开始页面是 ConfigureAppWizard.xaml,那么当任务结束时可使用下面的代码:

1
2
3
4
5
6
string pageName;
while(pageName != "ConfigureAppWizard.xaml")
{
JournalEntry entry = navigationService.RemoveBackEntry();
pageName = System.IO.Path.GetFileName(entry.Source.ToString());
}

  上面的代码使用保存在 JournalEntry.Source 属性中的完整 URI,并使用 Path 类的静态方法GetFileName()得到页面的名称(对于URI,该类同样工作得很好)。可使用 Title 属性方便代码的编写,但该方法不够可靠。因为页面标题显示在导航历史中,而且对用户可见,所以当本地化应用程序时,它是一段需要翻译成其他语言的信息。这会终止期望使用硬编码页面标题的代码,并且即使不准备本地化应用程序,也不难想象出修改页面标题使其更加清晰或更具有描述性的情况。

  此外,**可使用导航容器(如 NavigationWindow或Frame)的 BackStack和 ForwardStack 属性检查后退列表和前进列表中的所有项。**然而,一般不能通过 NavigationService 类获取这一信息。在任何情况下,这些属性都提供了 JournalEntry 对象的只读的简单集合。不能通过它们修改列表,并且很少需要使用它们。

向日志添加自定义项

  除了 RemoveBackEntry()方法外,NavigationService 类还提供了 AddBackEntry( )方法,该方法用于在后退列表中添加“虚拟的”项。例如,设想有一个单独页面,用户通过该页面执行一项非常复杂的配置任务。如果希望用户能够后退到窗口的前一个状态,可使用 AddBackEntry()方法对其进行保存。即使只有一个单独页面,在列表中也可能会有几个相应的项。

  与期望的相反,当调用 AddBackEntry()方法时,不能给该方法传递JournalEntry 对象(实际上,JournalEntry 类有一个受保护的构造函数,所以也不能在代码中实例化该类)。而是需要创建一个继承自抽象的 System.Windows.Navigation.CustomContentState 类的自定义类,并存储需要的所有信息。例如,分析下图中显示的应用程序,该应用程序允许用户将项从一个列表移到另一个列表中.

  现在假定每次从一个列表向另一个列表中移动项时,希望保存窗口的状态。首先需要一个继承自 CustomContentState 的类,并且需要保持跟踪所需的信息。在这个示例中,只需要记录两个列表的内容。因为该类将被保存到日志中(从而当需要时可以恢复页面),所以该类需要能够串行化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[Serializable()]
public class ListSelectionJournalEntry : CustomContentState
{
public ListSelectionJournalEntry(
List<string> sourceItems,
List<string> targetItems,
string journalEntryName,
ReplayListChange replayListChange
)
{
_sourceItems = sourceItems;
_targetItems = targetItems;
_journalEntryName = journalEntryName;
this.replayListChange = replayListChange;
}

private List<string> _sourceItems;

public List<string> SourceItems
{
get => _sourceItems;
}

private List<string> _targetItems;

public List<string> TargetItems
{
get => _targetItems;
}

private string _journalEntryName;

public override string JournalEntryName
{
get => _journalEntryName;
}

private ReplayListChange replayListChange;

public override void Replay(NavigationService navigationService, NavigationMode mode)
{
this.replayListChange(this);
}

public delegate void ReplayListChange(ListSelectionJournalEntry contentState);
}

为将该功能应用到页面中,需要执行以下三个步骤:

  1. 需要在合适的时机调用AddBackReference()方法,以便在导航历史中保存附加的项
  2. 当用户通过历史进行导航时,需要处理ListSelectionJournalEntry回调以还原窗口
  3. 需要在自己的页面类中实现IProvideCustomContentState结构以及该结构的单个方法GetContentState()。当用户通过历史导航到另一个页面时,可通过导航服务调用GetContentState()方法,从而可返回将存储为当前页面状态的自定义类的一个实例。

IProvideCustomContentState 接口是一个容易被忽视的细节,但却非常重要。当用户使用前进列表或后退列表进行导航时,需要进行两个操作–页面需要向日志中添加当前视图(使用IProvideCustomContentState 接口),然后需要还原选择的视图(使用 ListSelectionJoummalBntry 回调).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<Page
Height="300"
Title="MainPage"
Width="300"
mc:Ignorable="d"
x:Class="WpfApp.Views.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WpfApp.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ListBox Grid.Column="0" Name="lstSource">
<system:String>Red</system:String>
<system:String>Yellow</system:String>
<system:String>Blue</system:String>
<system:String>Green</system:String>
<system:String>Purple</system:String>
<system:String>Orange</system:String>
</ListBox>
<StackPanel
Grid.Column="1"
Margin="5,0"
VerticalAlignment="Center">
<Button Click="CmdAdd_OnClick" Name="cmdAdd">
Add -&gt;
</Button>
<Button
Click="CmdRemove_OnClick"
Margin="0,5"
Name="cmdRemove">
&lt;- Remove
</Button>
</StackPanel>
<ListBox Grid.Column="2" Name="lstTarget" />
</Grid>
</Page>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public partial class MainPage : Page,IProvideCustomContentState
{
public MainPage()
{
InitializeComponent();
}

private void CmdAdd_OnClick(object sender, RoutedEventArgs e)
{
if (lstSource.SelectedIndex != -1)
{
// Determine the best name tro use in the navigation history
NavigationService navigationService = NavigationService.GetNavigationService(this);
string itemText = lstSource.SelectedItem.ToString();
string journalName = "Added " + itemText;

// Update the journal (using the method show below)
navigationService.AddBackEntry(GetJournalEntry(journalName));

// Noew perform the change
lstTarget.Items.Add(itemText);
lstSource.Items.Remove(itemText);
}
}

private void CmdRemove_OnClick(object sender, RoutedEventArgs e)
{
if (lstTarget.SelectedIndex != -1)
{
// Determine the best name tro use in the navigation history
NavigationService navigationService = NavigationService.GetNavigationService(this);
string itemText = lstTarget.SelectedItem.ToString();
string journalName = "Removed " + itemText;

// Update the journal (using the method show below)
navigationService.AddBackEntry(GetJournalEntry(journalName));

// Noew perform the change
lstTarget.Items.Remove(itemText);
lstSource.Items.Add(itemText);
}
}

private ListSelectionJournalEntry GetJournalEntry(string journalName)
{
// Get the state of both lists (using a helper method).
List<string> source = GetListState(lstSource);
List<string> target = GetListState(lstTarget);

// Create the custom state object with this information.
// Point the callback to the Replay method in this class.
return new ListSelectionJournalEntry(source, target, journalName, Replay);
}

private void Replay(ListSelectionJournalEntry state)
{
lstSource.Items.Clear();
foreach (string item in state.SourceItems)
{
lstSource.Items.Add(item);
}
lstTarget.Items.Clear();
foreach (string item in state.TargetItems)
{
lstTarget.Items.Add(item);
}
}

private List<string> GetListState(ListBox listBox)
{
return listBox.Items.OfType<string>().ToList();
}

public CustomContentState GetContentState()
{
return GetJournalEntry("MainPage");
}
}

  当用户使用后退按钮或前进按钮导航到另一个页面时,WPF导航服务调用GetContentState()方法。WPF 使用返回的 CustomContentState对象,并为当前页面将该对象存储到日志中。在此存在一个潜在问题——如果用户执行了几个操作,然后通过导航历史返回这些操作,那么历史中的“undone”操作将具有硬编码的页面名称(MainPage),而不是更具有描述性的原始名称(如 Added Orange)。为更好地解决该问题,可在页面类中使用成员变量来保存该页面的日志名称。

使用页函数

  到目前为止,您已经学习了如何向页面传递信息(在代码中实例化页面并配置页面,然后将它传递给 NavigationService.Navigate()方法),但还没有看到如何从页面返回信息。最容易的方法(也是结构化程度最低的方法)是在一些静态的应用程序变量中保存信息,从而在应用程序的其他任何类中都可以访问变量。但如果只需要从一个页面向另一个页面传递少量信息,并且不希望在内存中长时间保存这些信息,那么这种设计不是最好的。而且如果在应用程序中使用全局变量,就会使应用程序变得混乱,难以区分相互之间的依赖关系(哪个变量由哪个页面使用),而且会使重用页面以及维护应用程序变得更难。

  WPF 提供的另一种方法是使用 PageFunction类。PageFunction 类是 Page 类的继承版本,该类添加了返回结果的功能。在某种意义上,PageFunction 对象类似于对话框,而Page对象类似于窗口。

  从技术角度看,PageFunction 是泛型类。它接受一个类型参数,该参数指定用于PageFunction返回值的数据类型。默认情况下,每个新的页函数都是通过字符串进行参数化(这意味着返回值是字符串)。但可以很容易地通过修改元素中的 TypeArguments 特性来改变这一细节。

下面的示例中,PageFunction对象返回名为Product的自定义类的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<PageFunction
Height="450"
Title="SelectProductPageFunction"
Width="800"
mc:Ignorable="d"
x:Class="WpfApp.Views.SelectProductPageFunction"
x:TypeArguments="models:Product"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WpfApp.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:WpfApp.Models"
xmlns:vm="clr-namespace:WpfApp.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListBox
DisplayMemberPath="ModelName"
ItemsSource="{Binding Products}"
Name="lstProducts" />

<StackPanel Grid.Row="1" Orientation="Horizontal">
<Button
Click="CmdOK_OnClick"
Height="35"
Name="cmdOK"
Width="100">
OK
</Button>
<Button
Click="CmdCancel_OnClick"
Height="35"
Name="cmdCancel"
Width="100">
Cancel
</Button>
</StackPanel>
</Grid>
</PageFunction>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public partial class SelectProductPageFunction : PageFunction<Product>
{
public SelectProductPageFunction()
{
InitializeComponent();

this.DataContext = new SelectProductPageViewModel();
}

private void CmdOK_OnClick(object sender, RoutedEventArgs e)
{
// Return the selection informatrion
OnReturn(new ReturnEventArgs<Product>(lstProducts.SelectedValue as Product));
}

private void CmdCancel_OnClick(object sender, RoutedEventArgs e)
{
// Indicate that nothing was selected.
OnReturn(null);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Page
Height="300"
Title="Page1"
Width="300"
mc:Ignorable="d"
x:Class="WpfApp.Views.Page1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WpfApp.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel>
<TextBlock Name="lblStatus" />
<Button Click="btnSelectProduct_Click">Select Product</Button>
</StackPanel>
</Page>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public partial class Page1 : Page
{
public Page1()
{
InitializeComponent();
}

private void btnSelectProduct_Click(object sender, RoutedEventArgs e)
{
SelectProductPageFunction pageFunction = new SelectProductPageFunction();
pageFunction.Return += SelectProductPageFunction_Returned;
this.NavigationService.Navigate(pageFunction);
}

private void SelectProductPageFunction_Returned(object sender, ReturnEventArgs<Product> e)
{
Product product = (Product)e.Result;
if (e.Result != null)
lblStatus.Text = $"You chose : {product.ModelName}";
}
}

  通常,OnReturn()方法表示任务结束,并且不希望用户能够向后导航到PageFunction 对象。可使用 NavigationService.RemoveBackEnty()方法实现该目的,但还有更简单的方法。每个PageFunction类还提供了RemoveFromJournal属性。如果将该属性设置为 true,当页面调用OnReturn()方法时会自动从历史中删除该页面。

XAML浏览器应用程序

  XBAP是运行于浏览器中的基于页面的应用程序。XBAP时彻头彻尾的WPF应用程序,但具有几个重要的不同点:

  • 它们运行于浏览器窗口中。它们可为网页使用整个显示器区域,也可使用<iframe>标记将它们放到普通HTML文档中的某些地方
  • 它们通常具有的权限时有限的。尽管可能配置XBAP应用程序,使其请求完全信任的权限,但目标时使用XBAP作为轻量级的部署模型,从而允许用户运行WPF应用程序,而不会执行潜在的危险代码。赋于XBAP应用程序的权限和赋予从Web或本地企业网运行的.NET应用程序的权限相同,并且强制这些限制的机制也是相同的(代码访问安全)。这意味着在默认情况下,XBAP应用程序不能写文件、不能和其他计算机资源(如注册表)进行交互、不能连接数据库,也不能弹出真正完备的窗口。
  • 它们不需要安装。当运行XBAP应用程序时,应用程序被下载并缓存到浏览器中。然而,它们不会安装到计算机中。这样就可以使用Web的立即更新模型——换句话说,每次用户运行应用程序时,如果有最新的版本,但尚在缓存中,就会将最新版本下载到缓存中。

  XBAP 应用程序的优点是它们提供了一种不会受到提示干扰的体验。如果安装了NET,客户就可以在浏览器中浏览 XBAP应用程序,并且开始使用它们,就像使用Javaapplet、Flash 影片以及JavaScript增强的网页那样。不会出现安装提示和警告。当然需要付出的代价是,XBAP应用程序要受到安全模型的严格限制。如果应用程序需要更强大的功能(例如,需要读写任意文件、与数据库进行交互、使用 Windows 注册表等),那么最好创建独立的 Windows 应用程序。然后可使用 ClickOnce 部署模型为应用程序提供流线型(但不是完全无缝的)的部署体验。

  目前,可使用 Intermet Explorer 和Firefox 这两个浏览器来启动XBAP 应用程序。Chrome不支持 XBAP 应用程序(不过,您可在 Google 上查找一些不受支持的技巧,一些开发人员将这些技巧应用于特定计算机)。与任意.NET应用程序一样,客户端计算机也需要目标.NET版本(在编译应用程序时)以便运行它。

创建XBAP应用程序

  尽管为了创建XBAP应用程序,Visual Studio强制使用WPF Browser Application模板创建新项目,但任何基于页面的应用程序都可以变成XBAP应用程序。不同之处时在.csproj项目文件中包含了4可关键元素,如下所示:

1
2
3
4
5
6
<PropertyGroup>
<HostInBrowser>true</HostInBrowser>
<Install>false</Install>
<ApplicationExtension>.xbap</ApplicationExtension>
<TargetZone>Internet</TargetZone>
</PropertyGroup>

  这些标签告诉WPF应当在浏览器(HostInBrowser)中驻留应用程序,将它与其他临时的Internet文件一起缓存而不是永久安装(Install),使用.xbap扩展名(ApplicationExtension),并且要求Internet区域(TargetZone)权限,第4部分时可选的。如稍后所述,创建具有更高权限的XBAP应用程序在技术上时可行的。然而,XBAP应用程序几乎总是运行在Internet区域提供的有限权限下,这是成功编写XBAP应用程序面临的重大挑战。

.csproj 文件还包含与 XBAP 相关的其他标签,以保证能够正确地进行调试。将 XBAP 应用程序改为基于页面的独立窗口应用程序(或将基于页面的独立窗口应用程序改成XBAP 应用程序)的最简单方法是,创建所需类型的新项目,然后从旧项目中导入所有页面。

  一旦创建XBAP应用程序,就可以通过和使用NavigationWindow相同的方式,设计页面以及为页面编写代码。例如,在App.xaml文件中设置StartUri以指向某个页面。当编译应用程序时,会生成一个.xbap文件。然后可在Internet Explorer或Firefox浏览器中请求该.xbap文件,并且应用程序会自动在受限的信任模式下运行(要求预先安装.NET Framework)。下图显示了一个在Internet Explorer中运行的XBAP应用程序。

浏览器中XBAP应用程序

  只要不是试图执行任何被限制的操作(例如,显示独立的窗口),运行 XBAP 应用程序和运行普通的 WPF 应用程序就是一样的。如果在 Imternet Explorer 中运行应用程序,浏览器中的按钮和 NavigationWindow 窗口中的按钮相同,并且它们显示后退和前进页面列表。在以前版本的Intermet Explorer 以及 Firefox 测览器中,在页面的顶部会有一组新的导航按钮,这些按钮不是非常好。

部署XBAP应用程序

  尽管可为 XBAP 应用程序创建安装程序(并且可从本地硬盘运行 XBAP 应用程序),但很少这么做,而只是将编译过的应用程序复制到网络上的某个共享位置或虚拟目录中。

  但部署 XBAP 应用程序不像复制.xbap 文件那么简单,实际上需要将以下三个文件复制到同文件夹中:

  • ApplicationName.exe:该文件包含编译过的中间语言代码,就像任何.NET应用程序那样
  • ApplicationName.exe.manifest:该文件时指示应用程序需求的XML文档(例如,用于编译代码的.NET程序集版本)。如果应用程序使用了其他DLL,可在应用程序所在的相同虚拟目录下得到它们,并且会自动下载这些DLL文件
  • ApplicationName.xbap:.xbap文件时另一个XML文档,代表应用程序的入口点——换句话说,该文件时用户在浏览器中请求XBAP应用程序所需的文件。.xbap文件中的标记指向应用程序文件,并包含数字签名,数字签名使用已经为项目选择的密钥。

 &emsp一旦将这些文件传输到合适位置,就可以通过请求.xbap文件在InternetExplorer 或 Firefox浏览器中运行应用程序。这些文件是在本地硬盘上还是在远程的 Web 服务器上没有区别——可使用相同的方式请求它们。

这非常诱人,但不能运行.exe 文件。如果运行.exe 文件,什么也不会发生。反而应当在Windows 资源管理器中双击,xbap 文件(或在 Web 浏览器的地址栏中键入,xbap 文件的路径)。不管使用哪种方式,都必须提供所有这三个文件,并且浏览器必须能够识别.xbap 文件扩展名。

  当创建新的XBAP应用程序时,Visual Studio还提供了能够自动生成的证书文件,证书文件的名称类似于 ApplicationName TemporaryKey.pfx。证书文件包含了一对用于为.xbap 文件添加签名的公钥/私钥。如果想发布应用程序更新,就需要使用相同的密钥进行标识以确保数字签名保持一致。

更新XBAP应用程序

  当调试 XBAP应用程序时,Visual Studio 总是重新构建 XBAP 应用程序并在浏览器中加载最新版本。不需要采取任何额外步骤。如果在浏览器中直接请求 XBAP 应用程序,情况就不同了。当以这种方式运行 XBAP应用程序时,存在一个潜在问题。如果重新构建应用程序,将它部署到同一位置,然后再在浏览器中重新请求它,不一定会得到更新过的版本。而将继续运行老版本应用程序的缓存备份。即使关闭并重新打开浏览器窗口,单击浏览器的Refresh按钮,以及递增XBAP应用程序的程序集版本号,情况也仍然如此。

  可手动清除ClickOnce 缓存,但这显然不是一种便捷的解决方法。相反,需要更新存储在.xbap 文件中的发布信息,使浏览器认识到新部署的 XBAP 代表了应用程序的新版本。更新程序集版本不足以触发更新——需要更新发布版本。

XBAP应用程序的安全性

  创建XBAP应用程序最有挑战性的方面是受安全模型的约束。通常,XBAP应用程序在Interet 区域权限下运行。即使从本地硬盘运行XBAP应用程序也同样如此。

  .NET Framework 使用代码访问安全模型(.NET 1.0版以来就具有的核心特性)来限制允许XBAP应用程序执行的操作。通常,对XBAP应用程序的限制和Java或JavaScript 代码在 HTMI页面中的限制类似。例如,可显示图形、执行动画、使用控件、显示文档以及播放声音,但不能访问计算机资源,如文件、Windows注册表和数据库等。

  检查是否能够运行某个操作的一种简单方法是编写一些测试代码并进行尝试。WPF帮助文档也提供了完整细节。表 24-3简要列出了支持和不支持的WPF重要特性。

WPF关键特性和Internet区域
允许的 不允许的
所有核心控件,包括RichTextBox控件 Windows窗体控件(通过interop)
页面、消息框和OpenFileDialog对话框 单独的窗口以及其他对话框如(SaveFileDialog)
2D和3D绘图、音频和视频、刘文档和XPS文档以及动画 位图效果和像素着色器(可能因为它们依赖于非托管代码)
“模拟的”拖放(响应鼠标移动事件的代码) Windows拖放
ASP.NET(.asmx)Web服务以及WCF服务 大部分高级WCF特性(非HTTP传输、服务端发起连接以及WS-*协议),以及与任何未驻留XBAP应用程序的服务器进行通信

  如果试图使用 Intermet 区域权限所不允许的特性,那会出现什么结果呢?通常,一旦运行有问题的代码,应用程序就会失败,并抛出SecurityException异常。如果运行普通的XBAP应用程序,试图执行不允许的操作,并且未处理产生的 SecurityException 异常,就会出现下图的结果。

完全信任的XBAP应用程序

  可创建具有完全信任级别的XBAP应用程序,但不推荐使用这种技术。为了创建具有完全信任级别的XBAP应用程序,在 SolutionExplorer 中双击 Properties 节点,选择 Security 选项卡,并选择 This IsaFull Trust Application选项。然而,用户不能从 Web 服务器或任何虚拟目录中运行 XBAP应用程序,而是需要采取如下方法之一,以确保允许 XBAP 应用程序以完全信任的级别运行:

  • 从本地硬盘运行应用程序(可像运行可执行文件那样,通过双击文件或其快捷方式启动.xbap 文件)。可能希望使用安装程序自动完成安装过程。
  • 将正在使用的用于给程序集签名的证书(默认情况下是.pfx 文件)添加到目标计算机的Trusted Publishers 存储库中。可使用 certmgr.exe 工具完成这一工作。
  • 将部署.xbap 文件的网站的URL或网络计算机指定为完全信任的。为此,需要使用Microsoft.NET 2.0 Framework 配置工具(可在 Start 菜单的 Control Panel 的 AdministrativeTools 中找到该工具)。

组合XBAP独立应用程序

  到目前为止,您已经学习了如何处理可能运行在不同信任级别上的XBAP应用程序。但还存在另一种可能,您可能希望将同一个应用程序同时部署为XBAP应用程序和使用 NavigationWindow 窗口的独立应用程序(如本章开头所述)。

  对于这种情况,并非一定要测试权限。编写条件逻辑以测试静态的BrowserInteropHelperIsBrowerHosted 属性,并假定驻留于浏览器的应用程序自动运行于 Internet区域权限下,可能就足够了。如果应用程序运行于浏览器中,那么IsBrowerHosted 属性的值为 true。

  但在独立应用程序和 XBAP 应用程序之间进行转换并不容易,因为 Visual Studio 没有提供直接的支持。不过,其他开发人员已创建了一些工具来简化这一过程。一个例子是位于http://scorbs.com/2006/06/04/vs-template-flexible-application 网址上的灵活的 Visual Studio 项目模板。可通过该模板创建项目文件,并使用生成配置列表在XBAP应用程序和独立应用程序之间进行选择。此外,它还提供了一个编译常量,用于在每种情况下对代码进行条件编译,并且提供了一个应用程序属性,用于创建绑定表达式,从而可根据生成配置有条件地决定是显示还是隐藏特定的元素。

  另一个选择是将页面放到可重用的类库程序集中。然后可创建两个顶级项目,其中一个项目创建一个NavigationWindow对象,并在该对象中加载第一个页面;另一个项目直接作为XBAP应用程序启动页面。这种方法使维护解决方案变得更容易些,但可能仍需一些条件代码,用于测试 IsBrowserHosted 属性以及检査特定的 CodeAccessPermission 对象。

为不同的安全级别编写代码

  在某些情况下,可能选择创建能够在不同的安全上下文中运行的应用程序。例如,可创建既可以在本地运行(具有完全信任级别)也可以从网站启动的 XBAP 应用程序。对于这种情况,关键是要编写能避免意外的 SecurityException 异常的灵活代码。

  代码访问安全模型中的每个权限都由一个继承自CodeAccessPermission的类表示。可使用该类检查代码是否运行在所需的权限之下。技巧是调用CodeAccessPermission.Demand()方法,该方法请求权限。如果权限不能授予应用程序,该调用就会失败(抛出 SecurityException 异常)。

下面是一个用于检查给定权限的简单函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
private bool CheckPermission(CodeAccessPermission requestedPermission)
{
try
{
// Try to get this permission
requestedPermission.Demand();
return true;
}
catch
{
return false;
}
}

  可以像下面这样使用该函数编写代码,下面的示例代码在进行所希望的操作之前,首先检查调用代码是否具有些文件的权限:

1
2
3
4
5
6
7
8
9
10
11
12
// Create a permission that represents writing to a file.
FileIOPermission permission = new FileIOPermission(FileIOPermissionAccess.Write,@"c:\highscores.txt");

// Check for this permission.
if(CheckPermission(permission))
{
// It's safe to write to the file
}
else
{
// It's not allowed. Do nothing ot show a message.
}

  上述代码的一个明显缺点在于依赖于异常处理来控制正常的程序流,所以不鼓励这么做(因为这样不仅会使代码不清晰,而且会增加开销)。另一种选择是简单地尝试执行操作(如写文件)并捕获产生的 SecurityException异常。然而,在执行任务时,这种方法在执行过程中更可能会出现问题,这时恢复或清除操作可能更困难。

示例隔离存储区

  在许多情况下,如果权限不允许,可退而求其次,使用不那么强大的功能。例如,尽管运行于 Intemmet 区域的代码不能向硬盘驱动中的任意位置执行写操作,但可以使用隔离存储区(isolated storage)。隔离存储区提供了虚拟文件系统,允许在特定应用程序的特定于用户的一小块空间中写入数据。隔离存储区在硬盘中的实际位置是不确定的(所以事先无法知道写入数据的确切位置),而且可使用的总空间通常是1MB。该位置通常是c:Users[UserName]\AppData\LocalIsolatedStorage[GuidIdentifer]形式的路径。位于用户隔离存储区中的数据被严格限制,其他非管理员用户不能访问。

  .NET参考文档中详细介绍了隔离存储区。然而,使用隔离存储区相当容易,因为它提供了和普通文件访问相同的基于流的模型。只需要使用 System.1O.IsolatedStorage 名称空间中的数据类型即可。通常,首先调用IsolatedStorageFile.GetUserStoreForApplication( )方法,获取用于当前用户和应用程序的隔离存储区的引用(每个应用程序都有一块单独的隔离存储区)。然后可使用 IsolatedStorageFileStream 类在隔离存储区中创建虚拟文件。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Create a permission that represents writing to a file.
string filePath = SYstem.IO.Path.Combine(appPath, "highscores.txt");
FileIOPermission permission = new FileIOPermission(FileIOPermissionAccess.Write,filePath);

// Check for this permission.
if(CheckPermission(permission))
{
// Write to local haed drive
try
{
using(FileStyream fs = File.Create(filePath))
{
WriteHighScores(fs);
}
}
catch{}
}
else
{
// Write to isolated storage.
try
{
IsolatedStorageFile store = IsolatedStorageFile.GetUserStoreForApplication();
using(IsolatedStorageFileStream fs = new IsolatedStorageFileStream("highscores.txt",FileMode.Create,store))
{
WriteHighScores(fs);
}
}
catch{}
}

  还可使用 IsolatedStorageFile.GetFileNames()方法以及 IsolatedStorageFile.GetDirectoryNames()等方法为当前用户和应用程序枚举隔离存储区中的内容。

  请记住,如果决定创建部署到 Web上的普通 XBAP 应用程序,就已经知道不拥有本地硬盘驱动器(或其他任何位置)上的 FileIOPermission 权限。如果正在设计此类应用程序,就不需要使用此处给出的条件代码,而应将代码直接跳转到隔离存储类。

使用Popup控件模拟对话框

  对于 XBAP 应用程序,另一个被限制的功能在于打开辅助窗口的能力方面。在许多情况下将使用导航和多个页面,而不是独立的窗口,并且不会失去此功能。然而,有时弹出窗口来显示某些消息或收集输入是很方便的。在独立的 Windows 应用程序中,可使用模态对话框完成这一任务。在XBAP应用程序中,有另一种可能——Popup控件。

  基本技术是很容易的。首先,在标记中定义Popup控件,确保将其StaysOpen 属性设置为true,从而该控件会保持打开状态,直到关闭它(使用PopupAnimation或AlowsTransparency 属性没有任何意义,因为在网页中没有任何效果)。还需要为Popup控件添加合适的按钮,如OK按钮和 Cancel 按钮,并将 Popup 控件的Placement属性设置为 Center,使 Popup 控件在浏览器窗口的中间位置显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Popup Name="dialogPopUp" StaysOpen="True" Placement="Center" MaxWidth="200">
<Border>
<Border.Background>
<LinearGradientBrush>
<GradientStop Color-"AliceBlue" offset="1"></Gradientstop>
<GradientStop Color="LightBlue" offset-"0"></Gradientstop>
</LinearGradientBrush>
</Border.Background>
<StackPanel Margin="5" Background="White">
<TextBlock Margin="10" TextWrapping="Wrap">Please enter your name.</TextBlock>
<TextBox Name="txtName" Marqin="10"></TextBox>
<StackPanel Orientation="Horizontal" Margin="10">
<Button Click="dialog cmdoK click" Padding="3" Margin="0,0,5,0">ОK</Button>
<Button Click="dialog cmdCancel Click" Padding="3">Cancel</Button>
</StackPanel>
</StackPanel>
</Border>
</Popup>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private void cmdStart_Click(object sender,RoutedEventArgs e)
{
DisableMainPage();
}

private DiabledMainPage()
{
mainPage.IsEnabled = false;
this.Background = Brushes.LightGray;
dialogPopup.IsOpen = true;
}

private void dialog_cmdOK_Click(object sender,RoutedEventArgs e)
{
// Copy name frtom the Popup into the main page.
lblName.Content = "You entered: " + txtName.Text;
EnableMainPage();
}

private void dialog_cmdCancel_Click(object sender,RoutedEventArgs e)
{
EnabledMainPage();
}

private void EnableMainPage()
{
mainPage.IsEnabled = true;
this.Background = null;
dialogPopup.IsOpen = false;
}

  在恰当的时机(例如单击按钮时),应禁用其他用户界面并显示Popup 控件。可将一些顶级容器的 IsEnabled 属性设置为 false 来禁用用户界面,例如 StackPanel面板或 Gird 控件(还可将页面的 Background 属性设置为灰色,从而将用户的注意力转移到 Popup 控件上)。为显示 Popup控件,只需要将它的IsVisible 属性设置为true。

  使用 Popup控件模拟对话框有一个重要限制。为确保 Popup控件不能被用于欺骗合法的系统对话框,Popup窗口被限制在浏览器窗口中。如果有大的 Popup 控件窗口,而浏览器窗口很小,就会裁剪掉一些内容。一种解决方法是使用ScrollViewer 控件封装 Popup 控件的全部内容,并将 ScrollViewer 控件的 VerticalScrollBarVisibility属性设置为 Auto,本章的示例代码演示了该方法。

  还有一种解决方法,该方法是在 WPF页面中显示对话框的更奇怪选择。该方法使用.NET2.0中的 Windows窗体库。可安全地创建并显示 System.Windows.Forms.Form 类(或其他任何继承自Form 类的自定义窗体)的一个实例,因为它不需要非托管代码权限。实际上,甚至可非模态地显示窗体,从而可以保持页面能够进行响应。该方法唯一的缺点是,会在窗体的上面自动显示一个安全气球,并且直到用户单击了警告消息该气球才会消失。此外,在窗体中显示的内容也受到限制。可使用 Windows 窗体控件,但不能使用 WPF内容。对于该技术的
一个示例,可参考本章的示例代码。

使用.NET2.0窗体对话框

在网页中嵌入XBAP应用程序

  通常会直接在浏览器中加载 XBAP 应用程序,所以它会填满整个可用空间。然而还有一种选择—可在 HTML 页面的部分空间中和其他 HTML, 内容一起显示 XBAP 应用程序。需要做的全部工作就是创建 HTML页面,并使用<iframe>标签指向.xbap 文件,如下所示:

1
2
3
4
5
6
7
8
9
<html>
<head>
<title>An HTML Page That Contains an XBAP</title></head>
<body>
<h1>Regular HTML Content</h1>
<iframe sre="BrowserApplication.xbap"></iframe>
<h1>More HTML Content</h1>
</body>
</html>

  使用<iframe>标签是较不常见的技术,但可以通过该技术使用一些新技巧。例如,在同一个浏览器窗口中显示多个 XBAP应用程序,也便于将应用程序添加到由诸如 WordPress 的内容管理系统支持的站点上。

WebBrowser控件

  WPF模糊了传统的桌面应用程序和Web应用程序之间的界限。使用页面,可创建具有Web风格导航的WPF应用程序。使用XABP,可在浏览器窗口中运行WPF,就像时运行网页。使用Frame控件,可执行相反的技巧,将HTML网页放入到WPF窗口中。

  然而,当使用Frame 控件显示HTML内容时,放弃了对 HTML内容的所有控制。无法检查 HTML内容,并且当用户通过单击链接以导航到新页面时无法跟随该过程。当然也无法调用位于 HTML 网页中的JavaScript 方法,更无法让它们调用您的 WPF 代码。这正是提供WebBrowser 控件的目的。

如果需要能够在 WPF 和 HTML 内容之间进行无缝切换的容器,Frame 控件是很好的选择。如果需要检查页面的对象模型、限制或监控页面导航,或创建一条在 JavaScript和 WPF 代码之间能够交互的路径,WebBrowser控件是更好的选择。

  当显示 HTML,内容时,WebBrowser 和 Frame 控件都显示标准的Internet Explorer 窗口。该窗口具有 Internet Explorer 的全部特征和技巧,包括JavaScript、Dyamic HTML、ActiveX 控件以及插件。然而,该窗口没有提供诸如工具栏、地址栏以及状态栏等细节(尽管可使用其他控件为窗体添加所有这些要素)。

  **WebBrowser 控件不是完全使用托管代码编写的。与 Frame 控件一样(当使用 Frame 控件显示 HTML内容时),WebBrowser 控件封装了shdocvw.dll COM 组件,该组件是Intermet Explorer的一部分,并且 Windows提供了该组件。副作用是,WebBrowser 和 Frame 控件有几个其他 WPF控件不能共享的图形限制。**例如,不能在这些控件中显示的HTML内容的上面放置其他内容,并且不能使用变换对象扭曲或旋转 HTML内容。

作为一个特性,WPF 显示 HTML,的能力(无论是通过 Frame 还是通过 WebBrowser)没有页面模型或 XBAP 那么强大。然而在特定情况下,比如具有一些已经开发好的 HTML内容,并且不希望替换该内容,可能会选择使用这种方法。例如,可能在应用程序内部使用 WebBrowser控件显示 HTML文档,或让用户在应用程序的功能和第三方网站的功能之间来回跳转

导航到页面

  一旦将 WebBrowser 控件放置到窗口上,就需要将其指向一个文档。最容易的方法是使用URI 设置 Source 属性,将该属性设置为远程 URL或完全限定的文件路径。URI可指向 Interet Explorer 能够打开的任何文件类型,尽管几乎总是使用 WebBrowser 控件显示 HTML页面。

1
<WebBrowser Source="https://www.baidu.com"/>
WebBrowser控件的导航方法
方法 说明
Navigate() 导航到指定的新URL。如果使用重载方法,可选择将文档加载到指定的框架中,个腿回送数据,并且发生附加的HTML题头
NavigateToString() 加载来自提供的字符串中的内容,字符串应当包含网页的全部HTML内容。这提供了些有趣的选择,比如从应用程序的资源中检索 HTML 文本并显示文本的能力
NavigateToStream() 加载来自包含HTML文档的流中的内容。该方法允许打开一个文件并将其直接提供给WebBrowser控件进行渲染,而不需要立即在内存中保存整个HTML内容
GoBack() GoForward() 移动到导航历史中的上一个或下一个文档。为了避免错误,在使用这些方法之前应当检査 CanGoBack和 CanGoForward 属性,因为如果试图移动到不存在的文档(例如,当正处于导航历史中的第一个文档时试图向前移动),就会导致异常
Refresh() 重新加载当前文档

  所有 WebBrowser 导航都是异步的,这意味着当下载页面时您的代码会继续运行WebBrowser控件还提供了少部分事件,包括以下事件:

  • Navigating:当设置新的URL或当用户单击链接时会引发该事件。可审查URL,并通过将e.Cancel设置为true你看取消导航。
  • Navigated:在导航之后并且在Web浏览器开始下载页面之前引发该事件
  • LoadCompleted:当页面已完全下载时引发该事件。这是嘎嘎人员处理页面的机会

构建DOM树

  使用 WebBrowser 控件,可创建 C#代码来遍历页面的 HTML元素树。甚至当遍历时可以修改、删除或插入元素,使用与在 Web 浏览器脚本语言(如JavaScript)中使用的 HTMLDOM 类似的编程模型。

  在为 WebBrowser控件使用DOM之前,需要添加对Microsoft HTML, Object Library(mshtml.tlb)的引用。这是一个 COM库,所以 Visual Studio需要生成托管的封装器。为此,选择 ProiectAdd Refrence 菜单项,选择COM选项卡,选择 Microsoft HTMLObiect Library,然后单击OK 按钮。

  探索网页内容的开始点是 WebBrowser.Document属性。该属性提供了一个 HTMLDocument对象,该对象作为 IHTMLElement 对象的层次集合来表示网页。针对网页中的每个标签,包括段落(<p>)、超链接(<a>)、图像(<img>)以及所有熟悉的 HTML,标记要素,将发现一个不同的IHTMLElement 元素对象。

  WebBrowser.Document 属性是只读的,这意味尽管可以修改链接的 HTMLDocument 对象,但是不能随意创建新的 HTMLDocument对象,而是需要设置 Source 属性或调用 Navigate()方法来加载新的页面。一旦引发 WebBrowser.LoadCompleted 事件,就可以访问 Document 属性了。

每个IHTMLElement对象具有以下几个重要属性

  • tagName:是实际的标签,没有尖括号
  • id:包含id特性的值。如果需要在自动生成工具或服务器端代码中操作元素的话,通常需要为元素提供唯一的id特性
  • chdilren:为每个包含的标签提供IHTMLElement对象集合
  • innerHTML:显示标签标签的完整内容,包括任何嵌套的标签和它们的内容
  • innerText:显示标签的完整内容,以及所有嵌套标签的内容,但剥离了所有HTML标签
  • outerHTML与outerText:扮演和innerHTML以及innerText类似的角色,当这两个属性包含当前标签(而不仅是其内容)

  为导航 HTML,页面的文档模型,只需要遍历每个 [HTMLElement]的 Children 集合。下面的代码执行该任务以响应按钮单击操作,并生成显示页面中元素的结构和内容的树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private void cmdBuildTree_Click(object sender, System.EventArgs e)
{
// Analyzing a page takes a nontrivial amount of time.
// Use the hourglass cursor to warn the user.
this .Cursor = Cursors.Wait;

// Get the DOM object from the WebBrowser control.
HTMLDocument dom = (HTMLDcoument)webBrowser.Document;

// Process all the HTML elements on the page, and display them in the TreeView named treeDOM
ProcessElement(dom.documentElement,treeDOM.Items);

this.Cursor = null;
}

private void ProcessElement(IHTMLElement parentElement,ItemCollection nodes)
{
// Scan through the collection of elements
foreach(IHTMLElement in parentElement.children)
{
// Create a new noide that shows the tag name.
TreeViewItem node = new TreeViewItem();
node.Header = "<" + element.tagName + ">";
nodes.Add(node);

if((element.children.length == 0) && (element.innerText != null))
{
// If this element doesn't contain any other elements, add anyleftover text content as a new node.
node.Items.Add(element.innerText);
}
else
{
// If thi element contains other elements, process them recursively.
PtocessElement(element,node.Items);
}
}
}

使用.NET代码为网页添加脚本

  您将看到的 WebBrowser 控件的最后一个技巧甚至更令人感到好奇:在 Windows 代码中响应网页事件的能力。
  WebBrowser 控件使得这种技术非常简单。第一步是创建一个用于接收来自JavasSript 代码的消息的类。为使该类能与脚本进行交互,必须为类的声明添加ComVisible特性(该特性位于System.Runtime.InteropServices 名称空间):

1
2
3
4
5
6
7
8
[ComVisible(true)]
public class HtmlBridge
{
public void WebCLick(strong source)
{
MessageBox.Show("Received: " + source);
}
}

  接下来,需要为 WebBrowser 控件注册该类的一个实例。通过设置 WebBrowser.ObjectForScripting属性来完成该工作:

1
2
3
4
5
6
7
public MainWindow()
{
InitializeComponent();

webBrowser.Navigate("file:///" + System.IO.Path.Combine(Path.GetDirectoryName(Application.ResourceAssembly.Location),"sample.html"));
webBrowser.ObjectForScripting = new HtmlBridge();
}

  在网页中,使用 JavaScript代码触发该事件。在此,技巧是使用windows.external对象,该对象代表链接的.NET对象。使用该对象,指定希望触发的方法;例如,如果希望调用.NET对象中名为 HelloWorld 的公用方法,使用 windows.external.HelloWorld( )。

  为将 JavaScript命令构建进网页,首先需要决定希望响应哪个网页事件。大部分 HTML 元素支持少数几个事件,下面是其中最常用的几个事件:

  • onFocus:当控件接受焦点时引发该事件
  • onBlur:当焦点离开控件时引发该事件
  • onClick:当用户单击控件时引发该事件
  • onChange:当用户改变特定控件的值时引发该事件
  • onMouseOver:当用户将鼠标指针移到控件上时引发该事件

可添加onClick特性,从而无论何时用户单击该图像都会触发链接的.NET类中的HelloWorld()方法

1
<img onClick="window.external.HelloWorld('customArgs')" border="0" id="img1" src="buttonC.jpg" height="20" width="100">

  WebClick( )方法此后取而代之。该方法可显示另一个网页、打开新窗口或修改网页的一部分。在这个示例中,该方法只是显示消息框以确认已经接收到事件。每幅图像向 WebClick()方法传递一个硬编码的字符串,该字符串标识了触发方法的按钮。