-NET-MAUI-项目第三版-全-

.NET MAUI 项目第三版(全)

原文:zh.annas-archive.org/md5/08911220be4971170be2327959a2c057

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET MAUI 项目 是一本实践性书籍,你将从头开始创建九个应用程序。你将获得设置环境所需的根本技能,我们将在过渡到 .NET MAUI 之前解释 .NET Mobile 是什么,以便真正利用真正的本地跨平台代码。

在阅读这本书之后,你将真正理解创建一个你可以构建并经得起时间考验的应用程序需要什么。

我们将涵盖升级到 Xamarin.Forms、动画、消费 REST 接口、使用 SignalR 进行实时通信以及使用设备的 GPS 进行位置跟踪等内容。还有空间用于机器学习、一点 .NET Blazor 以及必备的待办事项列表。

编程愉快!

本书面向的对象

这本书是为那些熟悉 C# 和 Visual Studio 的开发者所写。你不必是专业的程序员,但你应该具备使用 .NET 和 C# 进行面向对象编程的基本知识。典型的读者可能是那些想要探索如何使用 .NET Mobile,特别是 .NET MAUI 来创建应用程序的人。

在此之前不需要了解 .NET Mobile,但如果你已经使用过传统的 .NET Mobile 并希望迈向 .NET MAUI,这将大有裨益。

本书涵盖的内容

第一章.NET MAUI 简介,解释了 .NET Mobile 和 .NET MAUI 的基本概念。它帮助你理解如何创建真正的跨平台应用程序的构建块。这是本书唯一的理论章节,将帮助你开始并设置你的开发环境。

第二章构建我们的第一个 .NET MAUI 应用,指导你了解 模型-视图-视图模型MVVM)的概念,并解释如何使用控制反转模式简化视图和视图模型的创建。我们将创建一个支持导航、过滤和向列表添加待办事项的应用程序,并且还将渲染一个利用 .NET MAUI 强大的数据绑定机制的用户界面。

第三章将 Xamarin.Forms 应用转换为 .NET MAUI 应用,逐步介绍了将现有在 Mono 上运行的 Xamarin.Forms 应用转换为在 .NET 7 上运行的 .NET MAUI 应用的步骤。我们将讨论两种将你的 Xamarin.Forms 应用转换为 .NET MAUI 应用的不同方法。第一种方法将使用新的 .NET MAUI 项目,并将我们旧的 Xamarin.Forms 代码移动到新项目中,第二种方法将使用 .NET 升级助手 工具为我们完成一些升级。

第四章使用.NET MAUI Shell 构建新闻应用,探讨了.NET MAUI 中的默认导航模板Shell,这是定义.NET MAUI 应用结构的标准方式。在本章中,你将学习到使用.NET MAUI 应用中的Shell所需的所有知识。

第五章使用动画和内容布局构建具有丰富 UX 的匹配应用,让你深入了解如何使用动画和内容布局定义更丰富的用户界面。它还涵盖了自定义控件的概念,用于封装用户界面为自包含的组件。

第六章使用 CollectionView 和 CarouselView 构建照片库应用,详细介绍了.NET MAUI 的CollectionViewCarouselView控件。在本章中,我们将使用它们构建一个照片库应用,以学习如何掌握这些控件。

第七章使用 GPS 和地图构建位置跟踪应用,深入探讨了如何使用设备 GPS 中的地理位置数据,以及如何在地图上绘制这些数据。它还解释了如何使用后台服务在长时间内持续跟踪位置,以创建你花费时间的热图。

第八章为多种形态构建天气应用,全部关于消费第三方 REST 接口并以用户友好的方式显示数据。我们将连接到天气服务以获取当前位置的天气预报,并在列表中显示结果。

第九章使用 Azure 服务为游戏设置后端,是两部分的第一个部分,我们将设置一个游戏应用。本章解释了如何使用 Azure 服务创建一个后端,通过 SignalR 公开功能,以设置应用程序之间的实时通信通道。

第十章构建实时游戏,承接上一章内容,涵盖了应用程序的前端——在这种情况下,是一个连接到后端并传递用户之间消息的.NET MAUI 应用。本章重点介绍在客户端设置 SignalR,并解释如何创建一个通过消息和事件抽象这种通信的服务模型。

第十一章使用.NET MAUI Blazor 构建计算器,探讨了.NET Blazor 应用嵌入到.NET MAUI 应用中的情况。我们将使用 Blazor 编写计算器应用的一部分,并在.NET MAUI 中使用BlazorWebView托管它。我们还将实现 Blazor 和.NET MAUI 之间的通信。

第十二章使用机器学习判断热狗是否存在,涵盖了创建一个使用机器学习来识别图像中是否包含热狗的应用。

为了充分利用本书

我们建议您先阅读第一章,以确保您对 Xamarin 的基本概念有足够的了解。之后,您可以随意选择您想深入了解的章节。每个章节都是独立的,但章节按照复杂度排序;您在书中的位置越深,应用程序将越复杂。

应用程序适用于实际使用,但一些部分被省略了,例如适当的错误处理和分析,因为它们超出了本书的范围。然而,您应该能够很好地掌握创建应用程序的基本构建块。

话虽如此,如果您之前一直是 C#和.NET 开发者,这确实会有所帮助,因为许多概念并不是特定于应用程序的,而是通用的良好实践,例如 MVVM 和反转控制。

但是,最重要的是,这是一本您可以用来通过关注您最感兴趣的章节来启动您的.NET MAUI 开发学习曲线的书。

本书涵盖的软件/硬件 操作系统要求
Visual Studio Community Edition. 一台能够运行 Windows 10 或更高版本的计算机用于 UWP 和 Android。一台能够运行 macOS Mojave 10.14 的 Mac 用于 iOS 模拟器 Windows 10 或更高版本,macOS Sierra 10.12 或更高版本
Xcode. 一台能够运行 macOS Sierra 10.14 的 Mac macOS Mojave 10.14

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/MAUI-Projects-3rd-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“由于所有.NET MAUI 程序都以MauiProgram.cs文件开始,这似乎是一个不错的起点。”

代码块设置如下:

.keypad {
    width: 300px;
    margin: auto;
    margin-top: -1.1em;
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

    builder.Services.AddMauiBlazorWebView();
#if DEBUG
    builder.Services.AddBlazorWebViewDeveloperTools();
    builder.Logging.AddDebug();
#endif

任何命令行输入或输出都按照以下方式编写:

$ mkdir ViewModels
$ cd ViewModels

粗体:表示新术语、重要词汇或屏幕上出现的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“打开 Visual Studio 2022 并选择创建一个新项目。”

小贴士或重要注意事项

看起来像这样。

联系我们。

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 发送电子邮件,并在邮件主题中提及书名。

勘误:尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法。

一旦您阅读了.NET MAUI Projects,我们很乐意听到您的想法!请点击此处直接转到本书的 Amazon 评论页面并分享您的反馈。

您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本。

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何时间、任何设备上阅读。从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的精彩免费内容。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接。

二维码

packt.link/free-ebook/9781837634910

  1. 提交您的购买证明。

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分:简介

在这部分,您将了解.NET MAUI 的概述,并学习如何安装.NET MAUI 以创建您的第一个.NET MAUI 项目。在创建您的第一个项目时,您将了解模型-视图-视图模型设计模式以及如何在.NET MAUI 应用程序中使用它。Xamarin 用户还将学习如何手动或使用.NET 升级助手将他们的项目升级到.NET MAUI。

本部分包含以下章节:

  • 第一章.NET MAUI 简介

  • 第二章构建您的第一个.NET MAUI 应用程序

  • 第三章将 Xamarin.Forms 应用程序转换为.NET MAUI

第一章:.NET MAUI 简介

本章主要介绍如何了解.NET 多平台应用程序用户界面(.NET MAUI)以及可以期待它带来什么。.NET MAUI 允许您使用.NET 和 C#构建针对 Android、iOS、macOS 和 Windows 的原生跨平台移动和桌面应用。这是唯一一个纯粹理论性的章节;其他所有章节都涵盖实际项目。在这个阶段,您不需要编写任何代码,而是简单地阅读本章,以形成一个高级理解.NET MAUI 是什么,.NET MAUI 如何与.NET 相关联,以及如何设置开发机器。

我们将首先定义什么是原生应用,以及.NET 作为一项技术带来了什么。然后,我们将探讨.NET MAUI 如何融入更大的图景,并了解何时使用传统的.NET 移动和.NET MAUI 应用是合适的。我们经常使用“传统.NET 移动应用”这个术语来描述不使用.NET MAUI 的应用,即使.NET MAUI 应用是通过传统的.NET 移动应用启动的。

在本章中,我们将涵盖以下主题:

  • 定义原生应用

  • .NET 移动

  • 探索.NET MAUI 框架

  • 设置我们的开发机器

  • .NET 移动生产力工具

让我们开始吧!

定义原生应用

“原生应用”这个术语对不同的人有不同的含义。对一些人来说,它指的是使用平台创建者指定的工具开发的应用,例如使用 Objective-C 或 Swift 开发的 iOS 应用,使用 Java 或 Kotlin 开发的 Android 应用,或使用 C/C++或.NET Framework 开发的 Windows 应用。其他人使用“原生应用”一词来指代编译成平台架构的机器码的应用,例如 x86、x64 或 ARM。在这本书中,我们将原生应用定义为具有原生 UI、性能和 API 访问的应用。以下列表更详细地解释了这三个概念:

  • 原生 UI:使用.NET MAUI 构建的应用使用每个平台的标准控件。这意味着,例如,使用.NET MAUI 构建的 iOS 应用将看起来和表现得像一个 iOS 用户所期望的那样,而使用.NET MAUI 构建的 Android 应用将看起来和表现得像一个 Android 用户所期望的那样。

  • 原生性能:使用.NET MAUI 构建的应用针对原生性能进行编译,这意味着它们的执行速度几乎与为平台设计的工具构建的应用相同,即 Java 或 Swift,并且可以使用平台特定的硬件加速。

  • 原生 API 访问:原生 API 访问意味着使用.NET MAUI 构建的应用可以访问目标平台和设备为开发者提供的一切。例如,.NET MAUI 应用可以使用特定的硬件功能,如相机或地图。

.NET 移动

.NET 移动(以前称为 Xamarin)是一组用于开发 iOS(.NET for iOS/tvOS/Mac Catalyst)、Android(.NET for Android)和 macOS(.NET for macOS)原生应用程序的 .NET 扩展。.NET 是 .NET Framework 的演变,旨在进行跨平台开发。.NET 移动是在 .NET Core 5 中作为可选工作负载引入的。从技术上讲,它是在这些平台之上的绑定层。使用绑定到平台 API 可以使 .NET 开发者能够使用 C#(和 F#)利用每个平台的全部能力来开发原生应用程序。

当我们使用 .NET 移动开发应用程序时,我们使用的 C# API 与平台 API 匹配,但它们被修改以符合 .NET Core 中的约定。例如,API 通常被定制以遵循 .NET 命名约定,Android 的 setget 方法通常被属性替换。这使得使用 API 对 .NET 开发者来说更容易也更熟悉。

Mono (www.mono-project.com) 是微软 .NET Framework 的开源实现,它基于欧洲计算机制造商协会(ECMA)的 C# 标准和 通用语言运行时(CLR)。Mono 的创建是为了将 .NET Framework 带到除 Windows 之外的平台。它是 .NET 基金会(www.dotnetfoundation.org)的一部分,这是一个支持涉及 .NET 生态系统的开放开发和协作的独立组织。自 .NET 5 以来,Mono 现在是 .NET 上构建的应用程序的支持运行时。使用 Mono 与 .NET 无需单独的安装程序;它包含在 .NET 安装程序中。Mono 运行时用于 iOS、tvOS、Mac Catalyst 和 Android 应用程序,而 .NET Core CLR 用于所有其他支持的平台。

通过结合 .NET 移动平台、.NET 和 Mono,我们可以使用特定平台的 API 以及 .NET 的平台无关部分,包括 SystemSystem.LinqSystem.IOSystem.NetSystem.Threading.Tasks 等命名空间。

使用 .NET 移动进行移动应用程序开发有几个原因,我们将在以下章节中介绍。

代码共享

如果我们为多个移动平台(甚至服务器平台)使用一种通用的编程语言,那么我们可以在我们的目标平台之间共享大量代码,如下面的图示所示。所有与目标平台无关的代码都可以与其他 .NET 平台共享。以这种方式共享的代码通常包括业务逻辑、网络调用和数据模型:

图 1.1 – .NET MAUI 代码共享

图 1.1 – .NET MAUI 代码共享

在 .NET 平台周围也有一个庞大的社区,提供不同形式的代码共享。可以从 NuGet (nuget.org) 下载各种第三方库和组件,这些库和组件可以为您提供额外的功能或能力,这些功能或能力可以在所有支持的 .NET MAUI 平台上工作。例如,您可以找到提供数据库、图形或条形码读取的 NuGet 包,以便包含在您的应用程序中。

跨平台代码共享可以缩短开发时间。它还产生了更高品质的应用程序,例如,我们只需要编写一次业务逻辑的代码。降低错误的风险,并且也能保证无论用户使用什么平台,计算都能返回相同的结果。

利用现有知识

对于想要开始构建原生移动应用的 .NET 开发者来说,学习新平台的 API 比学习旧平台和新平台的编程语言和 API 更容易。

同样,想要构建原生移动应用的组织可以使用熟悉 .NET 的现有开发者来开发应用程序。由于 .NET 开发者比 Objective-C 和 Swift 开发者多,因此更容易为移动应用开发项目找到新的开发者。

.NET 移动平台

可用的不同 .NET 移动平台包括 .NET for iOS/tvOS/Mac Catalyst、.NET for Android 和 .NET for macOS。在本节中,我们将查看每个平台。

.NET for iOS/tvOS/Mac Catalyst

.NET for iOS/tvOS/Mac Catalyst 用于使用 .NET 分别构建 iOS、tvOS 或 Mac Catalyst 应用程序,并包含之前提到的 iOS API 的绑定。.NET for iOS/tvOS/Mac Catalyst 使用 System.LinqSystem.Net,由 Mono 运行时执行,而使用 iOS 特定命名空间的代码则由 Objective-C 运行时执行。Mono 运行时和 Objective-C 运行时都运行在 X is Not Unix (XNU) 类 Unix 内核之上 (github.com/apple/darwin-xnu),该内核由苹果公司开发。以下图展示了 iOS 架构的概述:

图 1.2 – .NET for iOS

图 1.2 – .NET for iOS

.NET for macOS

.NET for macOS 用于使用 .NET 构建 macOS 应用程序,并包含对 macOS API 的绑定。.NET for macOS 与 .NET for iOS 具有相同的架构——唯一的区别是,.NET for macOS 应用程序是即时编译的(JIT),而 .NET for iOS 应用程序是 AOT 编译的。这将在以下图中展示:

图 1.3 – .NET for macOS

图 1.3 – .NET for macOS

.NET for Android

.NET for Android 用于使用 .NET 构建安卓应用,并包含对安卓 API 的绑定。Mono 运行时和 Android 运行时(ART)在 Linux 内核之上并行运行。.NET for Android 应用可以是 JIT 编译或 AOT 编译,但为了进行 AOT 编译,我们需要使用 Visual Studio Enterprise。

Mono 运行时和 ART 之间的通信通过 Java 本地接口JNI)桥进行。有两种类型的 JNI 桥——管理可调用包装器MCW)和 安卓可调用包装器ACW)。当代码需要在 ART 中运行时使用 MCW,而当 ART 需要在 Mono 运行时中运行代码时使用 ACW,如下所示:

图 1.4 – .NET for Android

图 1.4 – .NET for Android

.NET for Tizen

.NET MAUI 从三星获得了对 Tizen 平台的支持。三星提供了绑定层和运行时,以允许 .NET MAUI 在 Tizen 平台上运行。要了解更多关于如何安装和为 Tizen 平台开发的信息,请访问您的浏览器中的 github.com/Samsung/Tizen.NET

现在我们已经了解了 .NET 移动是什么以及每个平台的工作方式,我们可以详细探索 .NET MAUI。

探索 .NET MAUI 框架

.NET MAUI 是一个基于 .NET 移动(用于 iOS 和 Android)和 Windows UIWinUI)库的跨平台框架。.NET MAUI 允许开发者使用 XAML 创建 iOS、Android 和 WinUI 的用户界面。.NET MAUI 通过将所有特定于平台的功能放在与跨平台功能相同的项目中,改进了 Xamarin.Forms,这使得查找和编辑代码变得更加容易。.NET MAUI 还包括了之前在 Xamarin.Essentials 中所有的内容,它提供了跨平台功能,如权限、位置、照片和相机、联系人以及地图,并通过一个共享代码库利用这些跨平台功能,如下面的图示所示:

图 1.5 – .NET MAUI 架构

图 1.5 – .NET MAUI 架构

如果我们使用 .NET MAUI 构建应用,我们可以使用 XAML、C# 或两者的组合来创建用户界面。

.NET MAUI 的架构

.NET MAUI 是在每个平台之上的一个抽象层。.NET MAUI 有一个共享层,该层被所有平台使用,以及一个特定于平台的层。特定于平台的层包含 处理程序。处理程序是一个将 .NET MAUI 控件映射到特定于平台的本地控件的类。每个 .NET MAUI 控件都有一个特定于平台的处理程序。

以下图表说明了.NET MAUI 中的输入控件如何映射到每个平台的正确原生控件。当在 iOS 应用程序中使用共享.NET MAUI 代码时,输入控件映射到UIKit命名空间中的UITextField控件。在 Android 上,输入控件映射到AndroidX.AppCompat.Widget命名空间中的EditText控件。最后,对于 Windows,.NET MAUI Entry处理程序映射到Microsoft.UI.Xaml.Controls命名空间中的TextBox

图 1.6 – .NET MAUI 控制架构

图 1.6 – .NET MAUI 控制架构

在牢固掌握.NET MAUI 架构和.NET 移动平台之后,是时候探索如何在.NET MAUI 中创建 UI 了。

使用 XAML 定义 UI

在.NET MAUI 中声明我们的 UI 最常见的方式是在 XAML 文档中定义它。由于 XAML 是用于实例化对象的标记语言,因此也可以在 C#中创建 GUI。从理论上讲,只要它有一个无参构造函数,我们就可以使用 XAML 创建任何类型的对象。XAML 文档是一个具有特定模式的可扩展标记语言XML)文档。

在接下来的几节中,我们将学习一些.NET MAUI 的控制,以帮助我们入门。然后,我们将比较使用.NET MAUI 构建 UI 的不同方法。

定义标签控件

作为简单的例子,让我们看看以下 XAML 文档片段:

<Label Text="Hello World!" />

当 XAML 解析器遇到此片段时,它将创建一个Label对象的实例,然后设置与 XAML 中的属性相对应的对象属性。这意味着如果我们设置 XAML 中的Text属性,它将设置创建的Label对象实例的Text属性。前面示例中的 XAML 具有以下相同的效果:

var obj = new Label()
{
    Text = "Hello World!"
};

XAML 的存在是为了使查看我们需要创建以制作 GUI 的对象层次结构变得更加容易。GUI 的对象模型也是按设计分层的,因此 XAML 支持添加子对象。我们可以简单地将其作为子节点添加,如下所示:

<StackLayout>
    <Label Text="Hello World" />
    <Entry Text="Ducks are us" />
</StackLayout>

StackLayout是一个容器控件,它在一个容器内垂直或水平地组织子控件。默认情况下是垂直组织,除非我们指定其他方式。还有其他容器,例如GridFlexLayout

这些将在后续章节的许多项目中使用。

在 XAML 中创建页面

一个控件如果没有容器来承载它就没有用处。让我们看看一个完整的页面会是什么样子。在 XAML 中定义的完全有效的ContentPage对象是一个 XML 文档。这意味着我们必须从一个 XML 声明开始。之后,我们必须有一个——并且只有一个——根节点,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage  
 x:Class="MyApp.
MainPage">
    <StackLayout>
        <Label Text="Hello world!" />
    </StackLayout>
</ContentPage>

在前面的示例中,我们定义了一个 ContentPage 对象,它在每个平台上转换为一个单独的视图。为了使其成为有效的 XAML,我们需要指定一个默认命名空间(http://schemas.microsoft.com/dotnet/2021/maui)并添加 x 命名空间(http://schemas.microsoft.com/winfx/2009/xaml)。

默认命名空间让我们可以创建对象而无需前缀,例如 StackLayout 对象。x 命名空间让我们可以访问诸如 x:Class 这样的属性,它告诉 XAML 解析器在创建 ContentPage 对象时实例化哪个类来控制页面。

ContentPage 对象只能有一个子元素。在这种情况下,它是一个 StackLayout 控件。除非我们指定其他方式,否则默认布局方向是垂直的。因此,StackLayout 对象可以有多个子元素。本书后面我们将讨论更高级的布局控件,如 GridFlexLayout 控件。

作为 StackLayout 的第一个子元素,我们将创建一个 Label 控件。

在 C# 中创建页面

为了清晰起见,以下代码显示了上一个示例在 C# 中的样子:

public class MainPage : ContentPage
{
}

MainPage 是一个继承自 .NET MAUI 的 ContentPage 类。如果我们创建一个 XAML 页面,这个类会自动为我们生成,但如果我们只使用代码,我们就需要自己定义它。

让我们使用以下代码创建与之前定义的 XAML 页面相同的控件层次结构:

var page = new MainPage();
var stacklayout = new StackLayout();
stacklayout.Children.Add(
    new Label()
    {
        Text = "Welcome to .NET MAUI"
    });
page.Content = stacklayout;

第一个语句创建了一个 page 对象。理论上,我们可以直接创建一个新的 ContentPage 页面,但这将阻止我们为其编写任何代码。因此,为每个我们计划创建的页面进行子类化是一个好的实践。

在此第一个语句之后的块创建了 StackLayout 控件,它包含添加到 Children 集合中的 Label 控件。

最后,我们需要将 StackLayout 分配给页面的 Content 属性。

由于 XAML 是一种主要为我们实例化对象的标记语言,我们可以看到在 C# 中复制它有多么容易。接下来,我们将看看一些使您在 C# 中开发 UI 更好的扩展。

使用 .NET MAUI Markup Community Toolkit

GitHub 上的 Community Toolkit 组织有一个项目,为 MAUI 添加 Fluent 扩展,以使用 C# 创建 UI。该项目 .NET MAUI Markup Community Toolkit (github.com/CommunityToolkit/Maui.Markup),或简称 MAUI.Markup,在网站上描述如下:

.NET MAUI Markup Community Toolkit 是一组 Fluent C# 扩展方法,允许开发者在无需 XAML 的情况下继续使用 MVVM、绑定、资源字典等来架构他们的应用程序。

使用 MAUI 标记来创建我们在前两个部分中创建的相同页面看起来可能如下所示:

public class MainPage: ContentPage
{
    public MainPage()
    {
        Build();
    }
    public void Build() {
        Content = new StackLayout()
        {
            Children =
            {
                new Label()
                {
                    Text = "Welcome to .NET MAUI"
                }
            }
        };
    }
}

有关如何在您的应用程序中使用 MAUI 标记的更多信息,请使用您喜欢的网络浏览器访问github.com/CommunityToolkit/Maui.Markup

那么,创建我们的 UI,XAML 还是 C#更好呢?

XAML 或 C#?

通常,使用 XAML 可以提供一个更好的概览,因为页面是一个对象的分层结构,而 XAML 是定义这种结构的一种非常好的方式。在代码中,结构是颠倒的,因为我们需要首先定义最内层的对象,这使得阅读我们页面的结构变得困难。这在本章的“在 XAML 中创建页面”部分中得到了演示。话虽如此,我们决定如何定义 GUI 通常是一个个人偏好的问题。这本书将在未来的项目中使用 XAML 而不是 C#。

现在我们已经探讨了如何使用.NET MAUI 创建我们的页面,是时候回顾.NET MAUI 和.NET 移动之间的比较了。

.NET MAUI 与传统.NET 移动的比较

虽然这本书是关于.NET MAUI 的,但我们也会突出使用传统.NET 移动和.NET MAUI 之间的差异。当开发使用 iOS 或 Android 软件开发套件SDK)且没有任何抽象手段的 UI 时,会使用传统.NET 移动。例如,我们可以创建一个 iOS 应用程序,该应用程序在故事板或直接在代码中定义其 UI。这段代码对于其他平台,如 Android,是不可重用的。使用这种方法构建的应用程序仍然可以通过简单地引用.NET 标准库来共享非平台特定的代码。这种关系在以下图中显示:

图 1.7 – 传统.NET UI

图 1.7 – 传统.NET UI

另一方面,.NET MAUI 是 GUI 的抽象,它允许我们以平台无关的方式定义 UI。它仍然基于.NET for iOS、.NET for Android 以及所有其他支持的平台。.NET MAUI 应用程序被创建为一个.NET 标准库,其中共享源文件和特定于平台的源文件都在我们当前构建的平台上的同一个项目中构建。这种关系在以下图中显示:

图 1.8 – 单个项目中的.NET MAUI UI

图 1.8 – 单个项目中的.NET MAUI UI

话虽如此,.NET MAUI 无法在没有传统.NET 移动的情况下存在,因为它通过每个平台的应用程序进行引导。这使我们能够使用自定义渲染器和特定于平台的代码在每个平台上扩展.NET MAUI,这些代码可以通过接口暴露给我们的共享代码库。我们将在本章后面更详细地探讨这些概念。

何时使用.NET MAUI

在大多数情况下和大多数类型的应用中,我们可以使用 .NET MAUI。如果我们需要使用 .NET MAUI 中不可用的控件,我们始终可以使用特定平台的 API。然而,有些情况下 .NET MAUI 并不适用。我们可能想要避免使用 .NET MAUI 的最常见情况是,如果我们构建的应用在不同目标平台上看起来应该非常不同。

现在理论部分已经足够了;让我们准备我们的开发机器,以便使用 .NET MAUI 进行开发。

设置我们的开发机器

为多个平台开发应用对我们的开发机器提出了更高的要求。其中一个原因是,我们通常希望在开发机器上运行一个或多个模拟器或仿真器。不同的平台对开始开发所需的内容也有不同的要求。无论我们使用 macOS 还是 Windows,Visual Studio 都将是我们的 集成开发环境IDE)。Visual Studio 有几个版本,包括免费的社区版。请访问 visualstudio.microsoft.com/ 比较可用的版本,并选择适合你的版本;.NET MAUI 包含在所有 Windows 和 macOS 的 Visual Studio 版本中。以下列表是针对每个平台开始开发所需内容的总结:

  • iOS:要为 iOS 开发应用,我们需要一个 MacintoshMac) 设备。这可以是我们在其上开发的那台机器,或者如果我们使用的是网络上的机器。我们需要连接到 Mac 的原因是我们需要 Xcode 来构建应用包。Xcode 还提供了各种模拟器来运行和调试你的应用。在没有连接 Mac 的情况下,你可以在 Windows 上进行一些 iOS 开发;你可以在本章的 Xamarin Hot Restart 部分中了解更多信息。

  • Android:Android 应用可以在 macOS 或 Windows 上开发。我们需要的所有东西,包括 SDK 和模拟器,都可以通过 Visual Studio 安装。

  • WinUI:WinUI 应用只能在 Windows 机器上的 Visual Studio 中开发。

我们首先设置 Mac,然后稍后介绍 Windows。如果你没有 Mac,你可以跳过这一部分,直接进入 设置 Windows 机器 部分。

设置 Mac

在 Mac 上使用 .NET 移动开发 iOS 和 Android 应用需要两个主要工具。这些是 Visual Studio for Mac(如果我们只开发 Android 应用,这就是我们需要的唯一工具)和 Xcode。在接下来的章节中,我们将探讨如何设置 Mac 以进行应用开发。

安装 Xcode

在我们安装 Visual Studio 之前,我们需要下载并安装 Xcode。Xcode 是苹果的官方开发 IDE,包含所有 iOS 开发工具,包括 iOS、macOS、Mac Catalyst 和 tvOS 的 SDK。

我们可以从 Apple 开发者门户(developer.apple.com)或 Apple App Store 下载 Xcode。我建议您从 App Store 下载,因为这可以保证您拥有最新的稳定版本。唯一需要从开发者门户下载 Xcode 的原因是为了使用 Xcode 的预发布版本来开发 iOS 的预发布版本。

当使用 macOS 的预发布版本及其相应的 Xcode 版本时,可能 .NET for iOS/tvOS/Mac Catalyst/macOS 还未更新以与最新的 Xcode 变更兼容。建议在安装最新 Xcode 之前检查兼容性,以确保工作环境正常。

在第一次安装之后,以及每次 Xcode 更新之后,打开 Xcode 都非常重要。Xcode 在安装或更新后通常需要安装额外的组件。我们还需要打开 Xcode 来接受与 Apple 的许可协议。

安装 Visual Studio

要安装 Visual Studio,我们首先需要从 visualstudio.microsoft.com 下载它。

Visual Studio for Mac 弃用

2023 年 8 月 31 日,微软根据其现代生命周期政策宣布了 Visual Studio for Mac 的弃用,这意味着支持将在 2024 年 8 月 31 日结束。Visual Studio for Mac 将一直支持到那个日期。如果您有 Visual Studio 订阅,您可以从 my.visualstudio.com 下载 Visual Studio for Mac 的最新版本。微软已发布了 C# Dev Kit 和 .NET MAUI Dev Kit 扩展程序,这些扩展程序在 Windows、macOS 和 Linux 上运行。您可以使用 Visual Studio Code 和这些扩展程序来完成本书中的项目,尽管与 Visual Studio for Mac 的 UI 相关的说明将不匹配 Visual Studio Code。要了解更多信息,请访问 learn.microsoft.com/en-us/visualstudio/mac/what-happened-to-vs-for-mac?view=vsmac-2022

当我们通过下载的文件启动 Visual Studio 安装程序时,它将开始检查我们机器上已经安装的内容。当检查完成后,我们可以选择我们想要安装的平台和工具。

一旦我们选择了想要安装的平台,Visual Studio 将下载并安装我们使用 .NET 移动进行应用程序开发所需的所有内容,如下所示:

图 1.9 – Visual Studio for Mac 安装程序

图 1.9 – Visual Studio for Mac 安装程序

配置 Android 模拟器

Visual Studio 使用 Google 提供的 Android 模拟器。它们通过 SDK 管理器进行安装和配置。

关于基于 Intel 的 Mac 设备的特别说明

如果我们希望我们的模拟器运行得快,那么我们需要确保它是硬件加速的。为了硬件加速 Android 模拟器,我们需要安装 Intel 硬件加速执行管理器HAXM),可以从software.intel.com/en-us/articles/intel-hardware-accelerated-execution-manager-intel-haxm下载。

下一步是创建 Android 模拟器。首先,我们需要确保已安装 Android 模拟器和 Android 操作系统图像。为此,请执行以下步骤:

  1. 前往工具选项卡安装 Android 模拟器:

图 1.10 – 在 Visual Studio for Mac 中安装 Android 模拟器

图 1.10 – 在 Visual Studio for Mac 中安装 Android 模拟器

  1. 我们还需要安装一个或多个图像以供模拟器使用。例如,如果我们想在不同的 Android 版本上运行我们的应用,我们可以安装多个图像。我们可以通过 Google Play(如下面的截图所示)选择模拟器,这样我们就可以在我们的应用中使用 Google Play 服务,即使我们在模拟器中运行它。例如,如果我们想在我们的应用中使用 Google Maps,这是必需的:

图 1.11 – 在 Visual Studio for Mac 中安装模拟器图像

图 1.11 – 在 Visual Studio for Mac 中安装模拟器图像

英特尔与苹果 M1 的比较

如果您有一台使用 M1 芯片组的 Apple Mac,那么您应该使用名称中包含ARM 64的模拟器图像;否则,如果您正在使用带有 Intel 芯片组的较旧 Mac 设备,则使用名称中包含Intel x86的图像。

  1. 然后,要创建和配置模拟器,请转到 Visual Studio 中的工具菜单下的设备管理器。从Android 设备管理器,如果我们已经创建了一个模拟器,我们可以启动它,或者我们可以创建新的模拟器,如下所示:

图 1.12 – Visual Studio for Mac 中的 Android 设备管理器

图 1.12 – Visual Studio for Mac 中的 Android 设备管理器

  1. 如果我们点击新建设备按钮,我们可以创建一个具有所需规格的新模拟器。在这里创建新模拟器最简单的方法是选择一个与我们需求匹配的基础设备。这些基础设备是预先配置的,这通常已经足够。然而,我们也可以编辑设备的属性,以便我们有一个符合我们特定需求的模拟器。

    处理器下拉菜单将预先选择与您的设备正确的架构。如果您更改此设置,例如,从 ARM 更改为 x86 或从 x86 更改为 ARM,那么模拟器将变慢;始终尝试使用与您的设备匹配的架构。

图 1.13 – 创建新的 Android 设备

图 1.13 – 创建新的 Android 设备

如果我们只有 Mac,那么我们已经完成,可以跳转到 .NET 移动生产力工具 部分。如果我们有 Windows 设备,那么下一部分,设置 Windows 机器,就是为您准备的。

设置 Windows 机器

我们可以使用虚拟或物理 Windows 机器来使用 .NET 移动进行开发。例如,我们可以在我们的 Mac 上运行虚拟 Windows 机器。我们只需要在我们的 Windows 机器上安装 Visual Studio 就可以进行应用开发。

为 Visual Studio 2022 或更高版本安装 .NET 移动

如果我们已安装 Visual Studio 2022 或更高版本,我们首先需要打开 Visual Studio 安装程序;否则,我们需要前往 visualstudio.microsoft.com 下载安装文件。在 认识 Visual Studio 家族 部分的横幅下,您可以找到下载 Visual Studio 2022 for Windows 或 Visual Studio 2022 for Mac 的链接。

在安装开始之前,我们需要选择我们想要安装的工作负载。

对于 .NET MAUI 开发,我们需要安装 .NET 多平台应用程序 UI 开发。选择 ASP.NET 和 Web 开发 工作负载,以便能够开发 MAUI/Blazor 混合应用。

图 1.14 – Visual Studio 2022 安装程序

图 1.14 – Visual Studio 2022 安装程序

在使用 .NET MAUI 时,Hyper-V 是默认的硬件加速方法。如果我们想使用 Intel HAXM,我们需要在 单个组件 选项卡中勾选 Intel HAXM,如下面的截图所示:

图 1.15 – 添加 Intel HAXM

图 1.15 – 添加 Intel HAXM

当我们第一次启动 Visual Studio 时,系统会询问我们是否想要登录。除非我们想要使用 Visual Studio Professional 或 Enterprise,在这种情况下我们需要登录以验证我们的许可证,否则我们不需要登录。

现在 Visual Studio 已经安装,我们可以完成运行和调试 iOS 和 Android 应用的配置。

将 Visual Studio 与 Mac 配对

如果我们想在 Windows 开发机器上运行、调试和编译我们的 iOS 应用,那么我们需要将其连接到 Mac。我们可以手动设置我们的 Mac,如本章前面所述,或者我们可以使用 Visual Studio 中的 自动 Mac 配置。这将安装 Mono 和 .NET for iOS 到我们连接的 Mac 上。它不会安装 Visual Studio IDE,但如果我们只是想将其用作构建机器,则这不是必需的。然而,我们确实需要手动安装 Xcode。

要从 Visual Studio 连接到 Mac,请使用工具栏中的 配对到 Mac 按钮(如下面的截图所示),或者在顶部菜单中转到 工具 | iOS | 配对 | 到 Mac

图 1.16 – 配对到 Mac 按钮

图 1.16 – 配对到 Mac 按钮

如果这是您第一次尝试配对到 Mac,Visual Studio 将打开一个向导,该向导将指导您在 Mac 上执行使 Visual Studio 能够连接所需的步骤。

图 1.17 – 配对到 Mac 向导

图 1.17 – 配对到 Mac 向导

要能够连接到 Mac——无论是手动还是使用自动 Mac 配置——我们需要能够通过网络访问 Mac,并且需要在 Mac 上启用远程登录

要完成此操作,请转到设置 | 共享并选中远程登录的复选框。在窗口的左侧,我们可以选择允许使用远程登录连接的用户,如图所示:

图 1.18 – 在 macOS 上启用远程登录

图 1.18 – 在 macOS 上启用远程登录

将会弹出一个对话框,显示网络上可以找到的所有 Mac。如果您的 Mac 不在可用的 Mac 列表中,您可以在窗口左下角的添加 Mac...按钮中输入 IP 地址,如图所示:

图 1.19 – 配对到 Mac

图 1.19 – 配对到 Mac

如果 Mac 上安装了所有必需的软件,Visual Studio 将连接,我们可以开始构建和调试我们的 iOS 应用。如果 Mac 上缺少 Mono,将出现警告。此警告还将提供安装它的选项,如图所示:

图 1.20 – 缺少 Mono 安装对话框

图 1.20 – 缺少 Mono 安装对话框

现在我们已经将 Mac 配对成功,我们可以配置 Android 模拟器。

配置 Android 模拟器和硬件加速

如果我们想要一个运行流畅的快速 Android 模拟器,我们需要启用硬件加速。这可以通过 Intel HAXM 或 Hyper-V 来完成。Intel HAXM 的缺点是它不能在带有高级微设备公司AMD)处理器的机器上使用;我们必须使用带有 Intel 处理器的机器。我们无法在 Hyper-V 并行使用 Intel HAXM。

由于这个原因,Hyper-V 是在 Windows 机器上硬件加速 Android 模拟器的首选方式。要使用 Hyper-V 与我们的 Android 模拟器,我们需要安装 Windows 11 或带有 2018 年 4 月更新(或更高版本)的 Windows 10,以及安装了 Visual Studio 2017 版本 15.8(或更高版本)。

查找您的 Visual Studio 版本

要确定您正在使用的 Visual Studio 版本,当 Visual Studio 打开时,使用帮助 | 关于 Visual Studio菜单,您应该会看到一个类似于以下对话框:

图 1.21 – 帮助 | 关于

要启用 Hyper-V,我们需要采取以下步骤:

  1. 打开搜索菜单,输入打开或关闭 Windows 功能。点击出现的选项以打开它,如图所示:

图 1.22 – 打开或关闭 Windows 功能

图 1.22 – 打开或关闭 Windows 功能

  1. 要启用 Hyper-V,选中 Hyper-V 复选框。同时,展开 Hyper-V 选项并选中 Hyper-V 平台 复选框。我们还需要选中 Windows 虚拟机平台 复选框,如下所示:

图 1.23 – 在 Windows 功能中启用 Hyper-V

图 1.23 – 在 Windows 功能中启用 Hyper-V

  1. 当 Windows 提示您时,请重新启动计算机。

  2. 由于我们在安装 Visual Studio 时没有安装 Android 模拟器,现在我们需要安装它。在 Visual Studio 的 工具 菜单中,然后点击 Android,再点击 Android SDK 管理器

  3. Android SDK 管理器工具 下,我们可以通过选择 Android 模拟器 来安装模拟器,如下截图所示。同时,我们应确保已安装最新版本的 Android SDK 构建工具

图 1.24 – 在 Android SDK 管理器中安装 Android 模拟器

图 1.24 – 在 Android SDK 管理器中安装 Android 模拟器

  1. Android SDK 允许同时安装多个模拟器镜像。如果我们想在不同的 Android 版本上运行我们的应用,我们可以安装多个镜像。选择带有 Google Play 的模拟器(如以下截图所示),这样我们就可以在我们的应用中使用 Google Play 服务,即使我们在模拟器中运行它。

    例如,如果我们想在我们的应用中使用 Google Maps,这是必需的:

图 1.25 – 在 Android SDK 管理器中安装 Android 模拟器镜像

图 1.25 – 在 Android SDK 管理器中安装 Android 模拟器镜像

  1. 在关闭窗口之前,务必点击 应用更改 按钮来安装您之前选择的任何组件。

  2. 下一步是创建一个虚拟设备来使用模拟器镜像。要创建和配置一个模拟器,请转到 Android 设备管理器,我们可以从 Visual Studio 的 工具 选项卡中打开它。从设备管理器中,我们可以启动一个模拟器(如果我们已经创建了一个)或者创建新的模拟器,如下所示:

图 1.26 – Android 设备管理器

图 1.26 – Android 设备管理器

如果我们点击 新建 按钮,我们可以创建一个具有所需规格的新模拟器。在这里创建新模拟器最简单的方法是选择一个与我们需求匹配的基础设备。这些基础设备是预先配置的,这通常就足够了。然而,我们可以编辑设备的属性,以便我们有一个符合我们特定需求的模拟器。

我们必须为模拟器选择正确的处理器,以匹配我们的 Windows 开发机器中的处理器。如果它们不匹配,那么模拟器将比所需的运行得更慢。如果您使用的是基于 Intel 或 AMD x86 的硬件,请选择 x86_64 处理器(如下截图所示),或者如果您有一个运行 Windows 的 ARM 设备,请选择 arm64-v*。

图 1.27 – 在 Android 设备管理器中创建新设备

图 1.27 – 在 Android 设备管理器中创建新设备

配置开发者模式

如果我们想要为 Windows 开发桌面应用程序,我们需要在我们的开发机器上激活开发者模式。为此,转到设置 | 隐私和安全 | 开发者选项。然后,选择开发者模式,如以下截图所示。这使得我们可以通过 Visual Studio 侧载和调试应用程序:

图 1.28 – 启用开发者模式

图 1.28 – 启用开发者模式

到目前为止,我们的 Windows 机器已准备好进行开发。在我们开始创建第一个项目之前,还有一些其他可选功能需要我们审查。这些功能将有助于您在构建应用程序时提高开发效率。

.NET 移动生产力工具

Xamarin 热重启热重载是两种提高.NET MAUI 开发者生产力的工具。为了从 Android 模拟器中获得更好的性能,您可以使用Windows 子系统AndroidWSA)。

Xamarin 热重启

热重启是 Visual Studio 的一个功能,旨在提高开发者的生产力。它还为我们提供了一种在 iPhone 上运行和调试 iOS 应用程序的方法,而无需使用连接到 Visual Studio 的 Mac。微软将热重启描述如下:

Xamarin 热重启允许您在开发过程中快速测试对应用程序的更改,包括多文件代码编辑、资源和引用。它将新更改推送到调试目标上的现有应用程序包,从而实现更快的构建和部署周期。

要使用热重启,您需要以下内容:

  • Visual Studio 2019 版本 16.5

  • iTunes(微软商店或 64 位版本)

  • 需要一个苹果开发者账户和付费的苹果开发者计划(developer.apple.com/programs/)注册

热重启目前只能与 iOS 应用程序的.NET 一起使用。

有关热重启当前状态的更多信息,请参阅docs.microsoft.com/en-us/xamarin/xamarin-forms/deploy-test/hot-restart

热重载

热重载是一种运行时技术,允许我们在 IDE 中做出更改时更新我们的运行中的应用程序。目前热重载有两种主要类型:XAML 热重载C#热重载

XAML 热重载允许我们在不重新部署应用程序的情况下更改我们的 XAML。当我们对 XAML 进行了更改后,我们只需保存文件,它就会更新模拟器/模拟器或设备上的页面。XAML 热重载目前支持所有.NET MAUI 平台。

C# 热重载允许我们在不重新部署我们的应用的情况下修改代码。C# 热重载类似于 编辑并继续;然而,您不需要处于中断模式才能将更改应用到应用中。一旦您修改了代码,您可以在 Visual Studio 工具栏中点击 热重载 按钮,热重载将更新运行中的应用。如果由于某些原因无法应用更改,热重载将显示一个对话框,要求您修复任何编译错误,或者在某些情况下,要求您重新启动应用。

要在 Windows 上为 Visual Studio 启用 XAML 热重载,请转到 工具 | 选项 | Xamarin | 热重载.

要在 Mac 上为 Visual Studio 启用 XAML 热重载,请转到 Visual Studio | 首选项 | Xamarin 工具 | XAML 热重载.

C# 热重载仅在 Windows Visual Studio 中可用;要启用它,请转到 工具 | 选项 | 调试器 | .NET / C++ 热重载.

Windows Subsystem for Android

如果您在支持的地区使用 Windows 11,您可以使用 WSA 作为调试目标,而不是使用 Android 模拟器。要了解更多关于 WSA 以及如何设置您的机器以使用它,请访问 learn.microsoft.com/en-us/windows/android/wsa/.

如果您想使用 WSA 调试您的 .NET MAUI 应用,如果您安装了 WSA Barista Visual Studio 扩展 (marketplace.visualstudio.com/items?itemName=Redth.WindowsSubsystemForAndroidVisualStudioExtension),这将有所帮助。这将添加 Windows Subsystem for Android 菜单项到 工具 下,这将提示您从 Windows Store 安装 WSA,然后自动配置 WSA 并设置 Visual Studio 以使用 WSA 作为设备。

摘要

您现在应该对 .NET 移动是什么以及 .NET MAUI 如何与 .NET 移动相关有更多的了解。

在本章中,我们定义了什么是原生应用,并看到了它如何具有原生 UI、性能和 API 访问。我们讨论了 .NET 移动如何使用 Mono,Mono 是 .NET Framework 的开源实现,并讨论了在核心上,.NET 移动是一组绑定到特定平台 API 的绑定。然后我们查看 .NET for iOS 和 .NET for Android 在底层是如何工作的。

之后,我们开始触及本书的核心主题,即 .NET MAUI。我们首先概述了平台无关控件如何渲染为特定平台的控件,以及如何使用 XAML 定义控件层次结构来组装页面。然后,我们花了一些时间来查看 .NET MAUI 应用与传统 .NET 移动应用之间的区别。

一个传统的 .NET 移动应用直接使用特定平台的 API,除了 .NET 作为平台添加的任何抽象之外,没有任何抽象。.NET MAUI 是一个建立在传统 .NET API 之上的 API,允许我们使用 XAML 或代码定义平台无关的 GUI,这些代码被渲染为特定平台的控件。.NET MAUI 的功能远不止于此,但这正是其核心所在。

在本章的最后部分,我们讨论了如何在 Windows 或 macOS 上设置开发机器。最后,我们查看了一些可选功能,这些功能可以帮助改善您的开发周期:热重启、热重载和 WSA。

现在,是时候将我们新获得的知识付诸实践了!在下一章中,我们将从头开始创建一个 待办事项 应用程序。我们将探讨诸如 模型-视图-视图模型 (MVVM) 等概念,以实现业务逻辑和 UI 之间的清晰分离,以及 SQLite.NET 将数据持久化到设备上的本地数据库。我们将同时为三个平台进行此操作——所以,继续阅读吧!

第二章:构建我们的第一个.NET MAUI 应用

在本章中,我们将创建一个待办事项列表应用,并在创建过程中探索构成应用的所有组成部分。我们将查看创建页面、向页面添加内容、在页面之间导航以及创建令人惊叹的布局。好吧,令人惊叹可能有点夸张,但我们将确保设计出的应用在完成之后你可以根据自己的需求进行调整!

本章将涵盖以下主题:

  • 设置项目

  • 使用仓库模式在设备上本地持久化数据

  • MVVM 是什么以及为什么它非常适合.NET MAUI

  • 使用.NET MAUI 页面(作为视图)以及使用.NET MAUI 控件在 XAML 中在它们之间导航

  • 使用数据绑定

  • 在.NET MAUI 中使用样式

技术要求

要完成此项目,你需要在你的MacintoshMac)或 PC 上安装 Visual Studio,以及.NET 移动组件。有关如何设置环境的更多详细信息,请参阅第一章.NET MAUI 简介。本章提供了 Windows 上 Visual Studio 的截图和说明。

本章将是一个经典的文件 | 新建 | 项目章节,逐步指导你创建你的第一个待办事项列表应用。除了几个 NuGet 包之外,无需任何下载。

你可以在github.com/Packt Publishing/MAUI-Projects-3rd-EditionChapter02文件夹中找到本章代码的完整源代码。

项目概述

每个人都需要一种跟踪事物的方法。为了启动我们的 .NET MAUI 开发学习曲线,我们决定制作一个待办事项列表应用是开始的最佳方式,并帮助你跟踪事物。这是一个简单、经典的双赢场景。

我们将首先创建一个项目并定义一个存储待办事项列表项的仓库。我们将以列表形式渲染这些项,并允许用户通过详细用户界面进行编辑。我们还将探讨如何通过SQLite.NET在设备上本地存储待办事项列表项,以便在退出应用时不会丢失。

本项目的构建时间大约为 2 小时。

设置项目

.NET MAUI 引入了一种新的代码共享范式,称为单一项目。在 Xamarin.Forms 中,你将为每个应用部署的平台创建一个单独的项目。在.NET MAUI 中,所有平台都在一个项目中,该项目针对所有支持的平台进行多目标配置。默认情况下,所有代码都被视为共享的,除非它位于平台特定的子文件夹之一。随着我们继续本章和未来的章节,我们将进一步探讨这一点。

让我们开始吧!

创建新项目

第一步是创建一个新的.NET MAUI 项目。打开 Visual Studio 2022 并选择创建一个 新项目

图 2.1 – Visual Studio 2022

图 2.1 – Visual Studio 2022

这将打开 maui 并从列表中选择 .NET MAUI App 项:

图 2.2 – 创建新项目

图 2.2 – 创建新项目

通过将项目命名为 DoToo 并点击 下一步 来完成向导的下一页:

图 2.3 – 配置你的新项目

图 2.3 – 配置你的新项目

下一步将提示你选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持LTS),.NET 7 可用为 标准期限支持。对于本书,我们假设你将使用 .NET 7:

图 2.4 – 其他信息

图 2.4 – 其他信息

通过点击 创建 并等待 Visual Studio 创建项目来最终完成设置。

恭喜!我们刚刚创建了我们第一个 .NET MAUI 应用。让我们看看模板向导为我们生成了什么。

检查文件

选择的模板已创建一个名为 DoToo 的单个项目,作为一个可以针对 iOS、Mac Catalyst(macOS)、Android 和 Windows 平台的目标 .NET 库。你可以使用 Visual Studio 中的主工具栏切换目标平台,如图 图 2**.5 所示:

图 2.5 – 调试目标下拉菜单

图 2.5 – 调试目标下拉菜单

默认情况下选择 Windows 平台,但你可以通过使用 调试目标 下拉菜单轻松切换到 iOS 或 Android。在 框架 子菜单下的下拉菜单中,你可以找到所有支持的目标平台。

当你选择目标设备时,目标框架也将相应地更改。如果你在 Android 模拟器 菜单项下选择模拟器,那么 Android 目标框架将成为当前框架,而如果你从 iOS 菜单项中选择 iOS 模拟器或设备,iOS 将成为当前框架。

项目现在应该如下所示:

图 2.6 – .NET MAUI 项目结构

图 2.6 – .NET MAUI 项目结构

我们将突出显示项目中的几个重要文件,以便我们对其有一个基本了解。首先,我们将查看共享代码,然后我们将查看每个平台特定的文件/代码(存储在不同的平台文件夹下)。

共享代码

Dependencies 下,我们将找到对任何外部依赖项的引用,例如每个引用的 .NET 移动框架。在每个框架下,你将在 packages 文件夹下找到 .NET MAUI 依赖项。我们将在 更新 .NET MAUI 包 部分更新 .NET MAUI 包版本,并在本章的后续内容中添加更多依赖项。

MauiProgram.cs 文件是应用程序的起点。初始模板将生成一个如下所示的 MauiProgram 类:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", 
"OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", 
"OpenSansSemibold");
            });
#if DEBUG
        builder.Logging.AddDebug();
#endif
        return builder.Build();
    }
}

静态的MauiProgram类包含一个CreateMauiApp方法,该方法返回MauiApp。这个实例是通过使用MauiAppBuilder创建的,它的工作方式与 ASP.NET 构建器非常相似;MauiAppBuilder使用一个Application实例。

什么是 Fluent API?

Fluent API 允许方法链式调用,其中 API 的每个方法都返回相同的上下文。Fluent API 通过使用特定于 API 主题的术语形成了一种独特的语言。这使得 API 更容易理解和使用。C#的语言集成查询LINQ)是 Fluent API 的一个很好的例子。

扩展方法用于向MauiApp实例添加功能和服务。UseMauiApp扩展方法标识要使用的Microsoft.Maui.Controls.Application的子类。默认情况下,这个类定义在App.xamlApp.xaml.cs文件中。另一个扩展方法ConfigureFonts被模板用于注册应用程序使用的自定义字体文件。另一个可以使用的扩展方法示例是ConfigureLifecycleEvents,它用于设置.NET MAUI 中可用的跨平台生命周期事件的处理器。我们将在第三章将 Xamarin.Forms 应用程序转换为.NET MAUI中更详细地讨论ConfigureLifecycleEvents

App.xaml文件是一个 XAML 文件,它表示应用程序。这是一个放置应用程序范围资源的好地方,我们稍后会这样做。我们还可以看到App.xaml.cs文件,它包含启动代码。

如果我们打开App.xaml.cs,我们可以看到我们的.NET MAUI 应用程序的起点:

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
        MainPage = new AppShell();
    }
}

MainPage属性被分配给一个页面,这尤其重要,因为它决定了哪个页面首先向用户显示。在这个模板中,这是DoToo.AppShell()类。

AppShell.xamlAppShell.xaml.cs文件声明了.NET MAUI 应用程序中第一个可见的 UI 组件。Shell 提供了页面之间的导航形式。当你打开AppShell.xaml时,它应该看起来像这样:

<Shell
    x:Class="DoToo.AppShell"
    xmlns=http://schemas.microsoft.com/dotnet/2021/maui
    xmlns:x=http://schemas.microsoft.com/winfx/2009/xaml

    Shell.FlyoutBehavior="Disabled">
    <ShellContent
        Title="Home"
        ContentTemplate="{DataTemplate local:MainPage}"
        Route="MainPage" />
</Shell>

ShellContent元素标识了在 shell 中显示的单独页面。ContentTemplate属性用于定位实现页面的类——在本例中是MainPage——而Route是页面的唯一标识符。

最后两个文件是MainPage.xaml文件,它包含应用程序的第一页,以及后置代码文件,称为MainPage.xaml.cs

接下来,我们将逐个处理每个平台的文件。每个平台在Platforms文件夹下都有一个独特的文件夹。Android 文件位于Android文件夹中,iOS 文件位于iOS文件夹中,Mac Catalyst 文件位于MacCatalyst文件夹中,Tizen 文件位于Tizen文件夹中,Windows 文件位于Windows文件夹中。

Android

Android 特定平台代码位于项目中的Platforms/Android文件夹下:

图 2.7 – Android 特定文件

图 2.7 – Android 特定文件

这里的重要文件是 MainActivity.csMainApplication.cs。这两个文件包含我们在 Android 设备上运行应用程序时的应用程序入口点。标准的 Android 应用会声明 Activity 类的 MainLauncher 属性。MauiAppCompatActivity 将搜索带有 ApplicationAttribute 装饰的类型并实例化它。

此属性可以在 MainApplication.cs 文件中的 MainApplication 类中找到。在初始化期间,MainApplication 将调用 CreateMauiApp 方法,该方法反过来调用我们在本章前面探索过的 MauiProgram.CreateMauiApp

您不需要详细了解这些文件;只需记住,它们对于初始化我们的应用程序很重要。

iOS 和 Mac Catalyst

iOS 和 Mac Catalyst 平台文件相同,但每个平台都有一个用于自定义平台的文件夹。每个平台的文件都包含在 Platform 文件夹下各自命名的文件夹中:

图 2.8 – iOS 平台特定文件

图 2.8 – iOS 平台特定文件

AppDelegate.cs 是 Android 平台中 MainApplication 类的等价物。它包含一个名为 CreateMauiApp 的单个方法,其实现与 Android 相同;它调用 MauiProgram.CreateMauiApp 方法。

Program.cs 文件是 iOS 应用的入口点。它包含 Main 方法,该方法调用 UIApplication.Main,这是 iOS 应用的启动点,并引用 AppDelegate 类型以实例化。

代码首先初始化 .NET MAUI,然后加载应用程序。之后,它将控制权返回给 iOS。它必须在 17 秒内完成此操作;否则,应用程序将被操作系统终止。

info.plist 文件是 iOS 特定的文件,其中包含有关应用程序的信息,例如包 ID 和其配置文件。Visual Studio 为 info.plist 文件提供了一个图形编辑器,但由于它是一个标准的 XML 文件,因此可以使用任何文本编辑器进行编辑。

与 Android 应用的启动代码一样,我们不需要详细了解这里发生的事情,只需知道这对于初始化我们的应用程序很重要。

Tizen

Tizen 是三星定制的 Android 发行版。Main.cs 文件是启动点,类似于 Android 平台,Program 类有一个 CreateMauiApp 方法。Tizen 默认未启用。要启用它,请遵循 DoToo.csproj 文件中的注释说明。要为 Tizen 开发应用程序,您需要安装三星分发的附加软件。

Windows

我们将要检查的最后一个平台是 WinUI 应用。文件结构如下:

图 2.9 – Windows 特定文件

图 2.9 – Windows 特定文件

它有一个 App.xaml 文件,类似于共享代码中的文件,但特定于 App.xaml.cs。此文件是 Windows 的 Android 的 MauiApplication 的等价物,它包含 CreateMauiApp 方法。

平台项目文件就到这里了。接下来,我们将探讨如何保持 .NET MAUI 的更新。

更新 .NET MAUI 包

注意 – Windows 用户

由于 .NET MAUI 是作为 Visual Studio 的一部分分发的,因此最好让 Visual Studio 在您更新 Visual Studio 时更新这些包。如果您遵循这些步骤,您可能将 .NET MAUI 更新到一个不可用的状态。

.NET MAUI 以一组可选的 dotnet workload 命令的形式分发。要查看当前安装的工作负载及其版本,您可以使用 dotnet workload list 命令。Visual Studio 2022 内置了开发者 PowerShell 来执行命令。要访问它,请在 macOS 和 Windows 上同时按 Ctrl + `

运行 dotnet workload list 应该会给出以下输出。请注意,您的版本号可能更高:

C:\Users\cummings.michael\Source\Repos\DoToo
> dotnet workload list
Installed Workload Ids      Manifest Version                              Installation Source
---------------------------------------------------------------------------------------------
maui-windows                6.0.486/6.0.400                          
     VS 17.3.32901.215
maui-maccatalyst            6.0.486/6.0.400                          
     VS 17.3.32901.215
maccatalyst                 15.4.446-ci.-release-6-0-4xx.446/6.0.400
      VS 17.3.32901.215
maui-ios                    6.0.486/6.0.400                         
      VS 17.3.32901.215
ios                         15.4.446-ci.-release-6-0-4xx.446/6.0.400
      VS 17.3.32901.215
maui-android                6.0.486/6.0.400                         
      VS 17.3.32901.215
android                     32.0.448/6.0.400                        
      VS 17.3.32901.215
Use `dotnet workload search` to find additional workloads to install.

要更新 MAUI 工作负载中的包,您可以使用 dotnet workload update。这是运行该命令的结果示例:

C:\Users\cummings.michael\Source\Repos\DoToo
> dotnet workload update
No workloads installed for this feature band. To update workloads 
installed with earlier SDK
versions, include the --from-previous-sdk option.
Updated advertising manifest microsoft.net.sdk.android.
Updated advertising manifest microsoft.net.sdk.tvos.
Updated advertising manifest microsoft.net.sdk.macos.
Updated advertising manifest microsoft.net.sdk.maui.
Updated advertising manifest microsoft.net.workload.emscripten.
Updated advertising manifest microsoft.net.sdk.ios.
Updated advertising manifest microsoft.net.sdk.maccatalyst.
Updated advertising manifest microsoft.net.workload.mono.toolchain.
Downloading microsoft.net.sdk.android.manifest-6.0.400.msi.x64 
(32.0.465)
Installing Microsoft.NET.Sdk.Android.Manifest-6.0.400.32.0.465-x64.msi 
......... Done
Downloading microsoft.net.sdk.ios.manifest-6.0.400.msi.x64 (15.4.454)
Downloading microsoft.net.sdk.maccatalyst.manifest-6.0.400.msi.x64 
(15.4.454)
Downloading microsoft.net.sdk.macos.manifest-6.0.400.msi.x64 
(12.3.454)
Installing Microsoft.NET.Sdk.macOS.Manifest-6.0.400.12.3.454-x64.msi ...... Done
Downloading microsoft.net.sdk.maui.manifest-6.0.400.msi.x64 (6.0.540)
Installing Microsoft.NET.Sdk.Maui.Manifest-6.0.400.6.0.540-x64.msi 
...... Done
Downloading microsoft.net.sdk.tvos.manifest-6.0.400.msi.x64 (15.4.454)
Downloading microsoft.net.workload.mono.toolchain.manifest-6.0.400.
msi.x64 (6.0.9)
Installing Microsoft.NET.Workload.Mono.ToolChain.Manifest-
6.0.400.6.0.9-x64.msi ....... Done
Downloading microsoft.net.workload.emscripten.manifest-6.0.400.msi.x64 
(6.0.9)
Installing Microsoft.NET.Workload.Emscripten.Manifest-
6.0.400.6.0.9-x64.msi ...... Done
No workloads installed for this feature band. To update workloads 
installed 
with earlier SDK versions, include the --from-previous-sdk option.
Successfully updated workload(s): .

要查看结果,只需再次运行 dotnet workload list 命令:

C:\Users\cummings.michael\Source\Repos\DoToo
> dotnet workload list
Installed Workload Ids      Manifest Version
Installation Source
---------------------------------------------------------------------------------------------
maui-windows                6.0.540/6.0.400                            
   VS 17.3.32901.215
maui-maccatalyst            6.0.540/6.0.400                            
   VS 17.3.32901.215
maccatalyst                 15.4.446-ci.-release-6-0-4xx.446/6.0.400   
   VS 17.3.32901.215
maui-ios                    6.0.540/6.0.400                            
   VS 17.3.32901.215
ios                         15.4.446-ci.-release-6-0-4xx.446/6.0.400   
   VS 17.3.32901.215
maui-android                6.0.540/6.0.400                            
   VS 17.3.32901.215
android                     32.0.465/6.0.400                           
   VS 17.3.32901.215
Use `dotnet workload search` to find additional workloads to install.

现在我们已经对 .NET MAUI 项目的结构有了基本的了解,我们可以开始构建我们的第一个应用程序了!

创建一个存储库和一个 TodoItem 模型

任何好的架构总是涉及抽象。在这个应用程序中,我们需要一个东西来存储和检索待办事项列表中的项目。稍后,这些项目将存储在 SQLite 数据库中,但将数据库直接添加到负责 GUI 的代码中通常是一个坏主意,因为它将您的数据存储实现与 UI 层紧密耦合,这使得独立于数据库测试您的 UI 代码变得更加困难。

因此,我们需要一个东西来抽象我们的数据库与 GUI。对于这个应用程序,我们选择使用简单的存储库模式。这个存储库只是一个位于 SQLite 数据库和即将到来的 ViewModel 类之间的类。这是处理与视图交互的类,反过来,它处理 GUI。

存储库将公开获取、添加和更新项目的方法,以及允许应用程序的其他部分对存储库中的更改做出反应的事件。它将隐藏在接口后面,这样我们就可以在不修改应用程序初始化中的任何代码的情况下替换整个实现。这是由 Microsoft.Extensions.DependencyInjection NuGet 包实现的。

定义待办事项列表项

我们将首先创建一个 TodoItem 类,它代表列表上的单个项目。这是一个简单的 Plain Old CLR Object (POCO) 类,其中 CLR 代表 Common Language Runtime。换句话说,这是一个没有依赖第三方组件的 .NET 类。要创建该类,请按照以下步骤操作:

  1. DoToo 项目中,创建一个名为 Models 的文件夹。

  2. 在那个文件夹中添加一个名为 TodoItem.cs 的类,并输入以下代码:

    namespace DoToo.Models;
    using System;
    public class TodoItem
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public bool Completed { get; set; }
        public DateTime Due { get; set; }
    }
    

此代码是自我解释的;它是一个简单的 POCO(Plain Old CLR Object)类,只包含属性而没有逻辑。我们有一个Title属性,用于描述我们想要执行的操作,一个名为Completed的标志,用于确定待办事项列表项是否已完成,一个Due日期,表示我们期望完成的时间,以及一个唯一的Id属性,我们稍后会需要它在数据库中使用。

创建仓库及其接口

现在我们有了TodoItem类,让我们定义一个接口,描述一个将存储我们的待办事项列表项的仓库:

  1. DoToo项目中创建一个名为Repositories的文件夹。

  2. Repositories文件夹中创建一个名为ITodoItemRepository.cs的接口,并编写以下代码:

    namespace DoToo.Repositories;
    using DoToo.Models;
    public interface ITodoItemRepository
    {
        event EventHandler<TodoItem> OnItemAdded;
        event EventHandler<TodoItem> OnItemUpdated;
        Task<List<TodoItem>> GetItemsAsync();
        Task AddItemAsync(TodoItem item);
        Task UpdateItemAsync(TodoItem item);
        Task AddOrUpdateAsync(TodoItem item);
    }
    

等等,什么?没有删除方法?

眼尖的你们可能已经注意到,我们没有在这个接口中定义Delete方法。这在现实世界的应用中应该是必须的。虽然我们在本章创建的应用不支持删除项目,但我们非常确信,如果您想的话,您可以自己添加这个功能!

此接口定义了我们应用所需的一切。它的存在是为了在您的仓库实现和仓库使用者之间创建逻辑隔离。如果应用的其他部分需要ITodoItemRepository的一个实例,我们可以传递一个实现了ITodoItemRepository的对象,无论其实现方式如何。

话虽如此,让我们来实现ITodoItemRepository

  1. Repositories文件夹中创建一个名为TodoItemRepository.cs的类。

  2. 输入以下代码:

    namespace DoToo.Repositories;
    using DoToo.Models;
    public class TodoItemRepository : ITodoItemRepository
    {
        public event EventHandler<TodoItem> OnItemAdded;
        public event EventHandler<TodoItem> OnItemUpdated;
        public async Task<List<TodoItem>> GetItemsAsync()
        {
            return null; // Just to make it build
        }
        public async Task AddItemAsync(TodoItem item)
        {
        }
        public async Task UpdateItemAsync(TodoItem item)
        {
        }
        public async Task AddOrUpdateAsync(TodoItem item)
        {
            if (item.Id == 0)
            {
                await AddItemAsync(item);
            }
            else
            {
                await UpdateItemAsync(item);
            }
        }
    }
    

此代码是接口的裸骨实现,除了AddOrUpdateAsync(...)方法。这个方法处理一小段逻辑,即如果一个项目的Id值为0,则它是一个新项目。任何Id值大于0的项目都存储在数据库中。这是因为当我们在表中创建行时,数据库会分配一个大于0的值。

前面的代码中还定义了两个事件。它们将被用来通知订阅者有关已更新或添加的项目列表。

将 SQLite 连接到持久化数据

现在我们已经有一个接口,以及实现该接口的框架。为了完成本节,我们需要在仓库的实现中连接 SQLite。

添加 SQLite NuGet 包

要在此项目中访问 SQLite,我们需要将名为sqlite-net-pcl的 NuGet 包添加到DoToo项目中。为此,右键单击解决方案中的DoToo项目节点,然后单击管理 NuGet 包...

图 2.10 – 管理 NuGet 包...

图 2.10 – 管理 NuGet 包...

可移植类库 (PCL)

您可能已经注意到,NuGet 包以-pcl结尾。这是命名规范出错时发生的一个例子。此包支持.NET Standard 1.0,尽管其名称表明它是 PCL(Portable Class Library),它是.NET Standard 的前身。

这将打开NuGet 包管理器窗口:

图 2.11 – NuGet 包管理器

图 2.11 – NuGet 包管理器

要安装 SQLite NuGet 包,请按照以下步骤操作:

  1. 在搜索框中点击sqlite-net-pcl

  2. 通过sQLite-net选择包并点击安装

  3. 将会显示一个对话框,显示所有将被下载到您系统中的包;接受更改以完成安装。

重要

sqlite-net-pcl包的 1.8.116 版本引用了与.NET 6+在所有平台上不完全兼容的原生库版本。为了解决这个问题,您需要手动添加以下包的额外引用,版本至少为 2.1:

  • SQLitePCLRaw.core

  • SQLitePCLRaw.provider.sqlite3

  • SQLitePCLRaw.bundle_green

  • SQLitePCLRaw.provider.dynamic_cdecl

有关更多详细信息和一个可能的解决方案,请参阅github.com/praeclarum/sqlite-net/issues/1102

安装完成后,我们可以在TodoItem类中添加一些代码,将 C#对象映射到表,并在仓库中创建数据库连接。

更新 TodoItem 类

由于 SQLite 是一个关系型数据库,它需要了解一些关于如何创建存储我们对象的表的基本信息。这通过使用属性来完成,这些属性在 SQLite 命名空间中定义:

  1. 打开Models/TodoItem.cs

  2. 在文件开头,紧接在namespace语句下方添加一个using SQLite语句,如下面的代码所示:

    namespace DoToo.Models;
    using SQLite;
    public class TodoItem
    
  3. Id属性之前添加PrimaryKeyAutoIncrement属性,如下面的代码所示:

    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }
    

    PrimaryKey属性指示 SQLite,Id属性是表的键。AutoIncrement属性确保每次向表中添加新的TodoItem类时,Id的值增加 1。

在正确配置数据对象类之后,现在是时候创建数据库连接了。

创建与 SQLite 数据库的连接

现在我们将添加所有与数据库通信所需的代码。首先,我们需要定义一个连接字段,它将保存对数据库的连接:

  1. 打开Repositories/TodoItemRepository.cs文件。

  2. 在现有using语句下方添加一个using SQLite语句,如下面的代码所示:

    namespace DoToo.Repositories;
    using DoToo.Models;
    using SQLite;
    public class TodoItemRepository : ITodoItemRepository
    
  3. 在类声明下方添加以下字段:

    private SQLiteAsyncConnection connection;
    

连接需要被初始化。一旦初始化,它就可以在整个仓库的生命周期中重复使用。由于该方法是非同步的,不能从构造函数中调用而不引入锁定策略。为了简化问题,我们将简单地从接口定义的每个方法中调用它。为此,将以下代码添加到TodoItemRepository类中:

private async Task CreateConnectionAsync() 
{ 
    if (connection != null) 
    { 
        return; 
    } 

    var documentPath = Environment.GetFolderPath(Environment.
SpecialFolder.MyDocuments); 
    var databasePath = Path.Combine(documentPath, "TodoItems.db"); 

    connection = new SQLiteAsyncConnection(databasePath);  
    await connection.CreateTableAsync<TodoItem>(); 

    if (await connection.Table<TodoItem>().CountAsync() == 0) 
    { 
        await connection.InsertAsync(new TodoItem() 
        { 
            Title = "Welcome to DoToo", 
            Due = DateTime.Now 
        }); 
    } 
}

该方法首先检查我们是否已经有一个连接。如果有,我们可以简单地 return。如果没有设置连接,我们定义一个磁盘上的路径来指示我们希望数据库文件所在的位置。在这种情况下,我们将选择 MyDocuments 文件夹。.NET MAUI 将在每个我们针对的平台找到最接近的匹配项。

然后,我们创建连接并将该连接的引用存储在 connection 字段中。我们需要确保 SQLite 已经创建了一个与 TodoItem 表模式相对应的表。为了使开发应用程序更容易,如果 TodoItem 表为空,我们将添加一个默认待办事项列表项。

接下来,我们将添加数据库操作的实现。

实现 GetItemsAsync(), AddItemsAsync(), 和 UpdateItemsAsync() 方法

在存储库中剩下的唯一事情是实现获取、添加和更新项目的相关方法:

  1. TodoItemRepository 类中定位 GetItemsAsync() 方法。

  2. 使用以下代码更新 GetItemsAsync() 方法:

    public async Task<List<TodoItem>> GetItemsAsync()
    {
        await CreateConnectionAsync();
        return await connection.Table<TodoItem>().ToListAsync();
    }
    

    为了确保数据库连接有效,我们调用上一节中创建的 CreateConnectionAsync() 方法。当此方法返回时,我们可以确保它已初始化,并且 TodoItem 表已创建。

    然后,我们使用连接来访问 TodoItem 表,并返回一个包含数据库中所有待办事项列表项的 List<TodoItem> 项目。

SQLite 和 LINQ

SQLite 支持使用 LINQ 查询数据。项目完成后,你可以尝试使用它来更好地理解如何在你的应用程序中与数据库一起工作。

添加项目的代码甚至更简单:

  1. TodoItemRepository 类中定位 AddItemAsync() 方法。

  2. 使用以下代码更新 AddItemAsync() 方法:

    public async Task AddItemAsync(TodoItem item)
    {
        await CreateConnectionAsync();
        await connection.InsertAsync(item);
        OnItemAdded?.Invoke(this, item);
    }
    

    CreateConnectionAsync() 的调用确保了我们有一个连接,就像我们对 GetItemsAsync() 方法所做的那样。之后,我们使用 connection 对象上的 InsertAsyncAsync(...) 方法将其插入数据库。一个项目被插入到表中后,我们调用 OnItemAdded 事件来通知任何订阅者。

更新项目的代码与 AddItemAsync() 方法相同,但还包括对 UpdateAsyncOnItemUpdated 的调用。让我们通过以下代码更新 UpdateItemAsync() 方法来完成:

  1. TodoItemRepository 类中定位 UpdateItemAsync() 方法。

  2. 使用以下代码更新 UpdateItemAsync() 方法:

    public async Task UpdateItemAsync(TodoItem item)
    {
        await CreateConnectionAsync();
        await connection.UpdateAsync(item);
        OnItemUpdated?.Invoke(this, item);
    }
    

在下一节中,我们将开始学习 MVVM。拿一杯咖啡,让我们开始吧!

使用 MVVM – 创建视图和视图模型

模型-视图-视图模型,简称 MVVM,其核心是 关注点分离。它是一种定义了三个部分,每个部分都有特定意义的架构模式:

  • ViewModel

  • 视图:这是视觉组件。在 .NET MAUI 中,这由一个页面表示。

  • ViewModel:这是一个作为模型和视图之间粘合剂的类。

我们在这里引入 MVVM,因为 MVVM 模式是专门针对基于 XAML 的 GUI 设计的。这个应用以及本书中的其他应用都将使用 XAML 来定义 GUI,我们将使用 MVVM 模式来将代码分为前面提到的三个部分。

在这个应用中,我们可以认为模型是仓库以及它返回的任务列表项。ViewModel 指的是这个仓库,并公开了视图可以绑定的属性。基本规则是任何逻辑都应该位于 ViewModel 中,而没有任何逻辑应该位于视图中。视图应该知道如何呈现数据,例如将布尔值转换为

MVVM 可以以多种方式实现,并且有相当多的框架可以帮助我们实现,例如 PrismMVVMCross,甚至是 TinyMvvm。在本章中,我们选择保持简单,首先以纯方式实现 MVVM,然后使用 CommunityToolkit.Mvvm 库的部分功能。CommunityTookit.Mvvm 是由 .NET 基金会生产的开源库。它是 MVVMLight 库的替代品。

使用 MVVM 作为架构模式的主要好处是关注点的明确分离、代码更清晰,以及 ViewModel 的良好可测试性。如果您想了解更多关于 MVVM 的信息,以及如何与 .NET MAUI 一起使用它,请访问 learn.microsoft.com/en-us/dotnet/architecture/maui/mvvm

好了,就说到这里吧——让我们来写一些代码吧!

定义 ViewModel 基类

ViewModel 是视图和模型之间的中介。通过为所有 ViewModel 类创建一个公共基类,我们可以从中受益匪浅。为此,请按照以下步骤操作:

  1. DoToo 项目中创建一个名为 ViewModels 的文件夹。

  2. ViewModels 文件夹中创建一个名为 ViewModel 的类。

  3. 添加以下代码:

    using System.ComponentModel;
    public abstract class ViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        public void RaisePropertyChanged(params string[] 
    propertyNames)
        {
            foreach (var propertyName in propertyNames)
            {
                PropertyChanged?.Invoke(this, new 
    PropertyChangedEventArgs(propertyName));
             }
        }
        public INavigation Navigation { get; set; }
    }
    

ViewModel 类是所有 ViewModel 对象的基类。它不打算单独实例化,所以我们将其标记为 abstract。它实现了在 .NET 基类库中定义的 System.ComponentModel 接口 INotifyPropertyChanged。该接口只定义了一件事——PropertyChanged 事件。我们的 ViewModel 类必须在想要让 GUI 了解任何属性更改时引发此事件。这可以通过在属性的设置器中手动添加代码来实现,就像我们在当前实现中所做的那样,或者通过使用 CommunityToolkit.Mvvm 库。我们将在下一节中详细介绍这一点。

我们还将在这里采取一个小捷径,通过向 ViewModel 添加一个 INavigation 属性。这将帮助我们进行后续的导航。这也是可以(并且应该)抽象化的东西,因为我们不希望 ViewModel 依赖于 .NET MAUI,以便能够在任何平台上重用 ViewModel 类。

介绍 CommunityToolkit.Mvvm 库的 ObservableObject 和 ObservableProperty

实现 ViewModel 类传统的做法是从基类(例如我们之前定义的ViewModel类)继承,然后添加可能看起来如下所示的代码:

public class MyTestViewModel : ViewModel
{
    private string name;
    public string Name
    {
        get { return name; }
        set
        {
            if (name != value)
            {
                name = value;
                RaisePropertyChanged(nameof(Name));
            }
        }
    }
}

我们想要添加到ViewModel类的每个属性都会产生 13 行代码。你可能认为这还不错。然而,考虑到一个ViewModel类可能包含 10 到 20 个属性,这会迅速变成大量的代码。我们可以做得更好。

只需几个简单的步骤,我们就可以使用 CommunityToolkit.Mvvm 库在构建过程中自动注入几乎所有代码:

  1. DoToo项目中,安装 CommunityToolkit.Mvvm NuGet 包。

  2. 更新ViewModel类,使其看起来像这样:

    using CommunityToolkit.Mvvm.ComponentModel;
    [ObservableObject]
    public abstract partial class ViewModel
    {
        public INavigation Navigation { get; set; }
    }
    

我们已经更改了ViewModel类的基类,使其具有ObservableObject属性,并添加了partial修饰符。此属性将在构建过程中自动添加之前在ViewModel基类中存在的INotifyPropertyChanged的基实现。

一旦我们的基类被修改,我们就可以使用ObservableProperty属性来自动生成属性实现。结果是,我们之前拥有的测试类每个属性都减少到一行代码。这使得代码库更易于阅读,因为所有操作都在幕后进行:

public partial class MyTestViewModel : ViewModel
{
    [ObservableProperty]
    string name;
}

关于前面的示例,有几个需要注意的事项。首先,类必须被标记为partial,这样ObservableProperty属性才能正常工作,就像ObservableObject属性一样。其次,在使用ObservableProperty属性时,你应该将其放置在私有字段上,而不是属性上。CommunityToolkit.Mvvm 库使用.NET 5 中添加的特性Source Generators来生成实际的属性实现。

使用 Source Generators 的一个好处是,你可以始终查看生成的源代码来了解工作原理。例如,要查看ViewModel类的生成源代码,请执行以下操作:

  1. 打开ViewModel.cs文件。

  2. 右键点击ViewModel类型名称。

  3. 选择转到实现

通常情况下,这不会做任何事情,因为你正在ViewModel的实现中。然而,由于有额外的生成代码,Visual Studio 会显示一个包含ViewModel实现位置列表,类似于这里所示:

图 2.12 – 查找 ViewModel 的所有实现

图 2.12 – 查找 ViewModel 的所有实现

列表中的第一项是我们添加到ViewModel.cs文件中的内容,列表中的第二项是生成的代码。通过双击该项,它将在新的代码窗口中打开生成的代码。在创建 TodoItemViewModel部分,你可以遵循相同的步骤来查看属性实现生成的代码。

现在我们已经看到了如何使用示例 ViewModel 实现属性,现在是时候创建具体的 ViewModel 类了。

创建 MainViewModel

到目前为止,我们主要准备编写构成应用程序本身的代码。MainViewModel 是第一个显示给用户的视图的 ViewModel 类。它负责为待办事项列表项提供数据和逻辑。我们将创建基本的 ViewModel 类,并在本章的进展中向它们添加代码:

  1. ViewModels 文件夹中创建一个名为 MainViewModel 的类。

  2. 添加以下模板代码并解决引用:

    public class MainViewModel : ViewModel
    {
        private readonly ITodoItemRepository repository;
        public MainViewModel(ITodoItemRepository repository)
        {
            this.repository = repository;
            Task.Run(async () => await LoadDataAsync());
        }
        private async Task LoadDataAsync()
        {
        }
    }
    

这个类的结构是我们将重用于所有即将到来的 ViewModel 类。

让我们总结一下我们希望 ViewModel 类拥有的重要功能:

  • 我们通过继承 ViewModel 类来访问共享逻辑,例如 INotifyPropertyChanged 接口和常见的导航代码。

  • 所有对其他类的依赖,例如通过 ViewModel 构造函数传递的仓库和服务,都由 Microsoft.Extensions.DependencyInjection 处理,这是我们所使用的依赖注入的实现。我们将在 连接依赖注入 部分添加对自动依赖注入的支持。

  • 我们使用异步调用 LoadDataAsync() 作为初始化 ViewModel 类的入口点。不同的 MVVM 库可能会以不同的方式做这件事,但基本功能是相同的。

创建 TodoItemViewModel

TodoItemViewModel 是代表 MainView 上待办事项列表中每个项目的 ViewModel 类。它没有自己的完整视图,尽管它可以有。相反,它通过 ListView 中的模板进行渲染。我们将在创建 MainView 控件时回到这一点。

这里重要的是,这个 ViewModel 对象代表一个单独的项目,无论我们选择在哪里渲染它。

让我们创建 TodoItemViewModel 类:

  1. ViewModels 文件夹中创建一个名为 TodoItemViewModel 的类。

  2. 更新类,使其与以下代码匹配:

    namespace DoToo.ViewModels;
    using CommunityToolkit.Mvvm.ComponentModel;
    using DoToo.Models;
    public partial class TodoItemViewModel : ViewModel
    {
        public TodoItemViewModel(TodoItem item) => Item = item;
        public event EventHandler ItemStatusChanged;
        [ObservableProperty]
        TodoItem item;
        public string StatusText => Item.Completed ? "Reactivate" : "Completed";
    }
    

与任何其他 ViewModel 类一样,我们从 ViewModel 继承 TodoItemViewModel 类。我们遵循将所有依赖项注入构造函数的模式。在这种情况下,我们向构造函数传递一个 TodoItem 类的实例,该实例将被 ViewModel 对象用于公开视图。

当我们想要向视图发出信号,表示 TodoItem 类的状态已更改时,将使用 ItemStatusChanged 事件处理程序。Item 属性允许我们访问传递的项目。

StatusText 属性用于在视图中使待办事项的状态对人类可读。

创建 ItemViewModel 类

ItemViewModel 代表一个视图中的待办事项列表项,可以用来创建新项和编辑现有项:

  1. ViewModels 文件夹中创建一个名为 ItemViewModel 的类。

  2. 添加以下代码:

    namespace DoToo.ViewModels;
    using DoToo.Repositories;
    public class ItemViewModel : ViewModel
    {
        private readonly ITodoItemRepository repository;
        public ItemViewModel(ITodoItemRepository repository)
        {
            this.repository = repository;
        }
    }
    

该模式与之前的两个 ViewModel 类相同:

  • 我们使用依赖注入将 TodoItemRepository 类传递给 ViewModel 对象。

  • 我们通过从 ViewModel 基类继承来添加由基类定义的常见功能

创建 MainView 视图

现在我们已经完成了 ViewModel 类,让我们创建视图所需的骨架代码和 XAML。模板创建了一个名为 MainPage.xml 的文件。在 MVVM 中,惯例是使用 -View 后缀。我们还将希望将所有视图一起放在一个子文件夹中,就像我们对 ViewModel 类所做的那样。让我们首先处理 MainPage.xml 文件,这是将被首先加载的视图:

  1. 从项目的根目录删除 MainPage.xml 文件。

  2. DoToo 项目中创建一个名为 Views 的文件夹。

  3. 右键单击 Views 文件夹,选择 添加,然后点击 新建项...

  4. 在左侧的 C# 项 节点下选择 .NET MAUI

  5. 选择 MainView

  6. 点击 添加 创建页面:

图 2.13 – 添加新的 XAML 文件

图 2.13 – 添加新的 XAML 文件

让我们在新创建的视图中添加一些内容:

  1. 打开 MainView.xaml

  2. ContentPage 根节点下方删除所有模板代码,并添加以下代码中突出显示的 XAML 代码:

    <?xml version="1.0" encoding="utf-8"?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/
    maui"
    
      x:Class="DoToo.Views.MainView"
      Title="Do Too!">
      <ContentPage.ToolbarItems>
        <ToolbarItem Text="Add" />
      </ContentPage.ToolbarItems>
      <Grid>
        <Grid.RowDefinitions>
          <RowDefinition Height="auto" />
          <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Button Text="Toggle filter" />
        <ListView Grid.Row="1">
        </ListView>
      </Grid>
    </ContentPage>
    

为了能够访问自定义转换器,我们需要添加对本地命名空间的引用。line 定义了这个命名空间。在这种情况下,我们不会直接使用它,但定义一个本地命名空间是一个好主意。如果我们创建自定义控件,我们可以通过编写类似 <local:MyControl /> 的方式来访问它们。**

ContentPage 页面上的 Title 属性为页面提供了标题。根据我们运行的平台,标题的显示方式不同。如果我们使用标准导航栏,它将在顶部显示,例如,在 iOS 和 Android 上。页面应该始终有一个标题

ContentPage.ToolbarItems 节点定义了一个用于添加新待办事项的工具栏项。它根据平台的不同也会以不同的方式渲染,但始终遵循平台特定的 UI 指南。

.NET MAUI 中的页面(以及在 XML 文档中通常情况下)只能有一个根节点。.NET MAUI 页面的根节点填充了页面本身的 Content 属性。由于我们希望 MainView 视图包含一个项目列表和顶部的一个按钮来切换过滤器(在所有项目和仅活动项目之间切换),我们需要添加一个 Layout 控件来在页面上定位它们。Grid 是一个允许您根据行和列分割可用空间的控件。

对于我们的 MainView 视图,我们想要添加两行。第一行是按钮高度计算出的空间(Height="auto"),第二行占据剩余的所有空间用于 ListViewHeight="*")。像 ListView 这样的元素使用 Grid.RowGrid.Column 属性在网格中定位。如果未指定这两个属性,它们默认为 0,就像按钮一样。

ListView 是一个以列表形式展示项的控件,这恰好是我们应用将要做的。值得注意的是,.NET MAUI 确实有一个名为 CollectionView 的控件,它比 ListView 更好地处理显示项集合。后续章节将使用此控件,但我们还想介绍这个古老的 ListView 控件。

小贴士

如果你对 Grid 的工作原理感兴趣,你可以在互联网上搜索有关 .NET MAUI 网格的更多信息,或者查看官方文档learn.microsoft.com/en-us/dotnet/maui/user-interface/layouts/grid

我们还需要将 ViewModel 与视图连接起来。这可以通过在视图的构造函数中传递 ViewModel 类来实现:

  1. 通过展开 MainView.xaml 文件在 MainView.xaml.cs 中打开 MainView 的代码隐藏文件。

  2. 在文件顶部添加 using DoToo.ViewModels 语句,紧邻现有的 using 语句。

  3. 修改类的构造函数,使其看起来如下,通过添加高亮代码:

    public MainView(MainViewModel viewModel)
    {
        InitializeComponent();
        viewModel.Navigation = Navigation;
        BindingContext = viewModel;
    }
    

我们通过构造函数传递任何依赖项,遵循与 ViewModel 类相同的模式。视图始终依赖于 ViewModel 类。为了简化项目,我们还直接将页面的 Navigation 属性分配给在 ViewModel 基类中定义的 Navigation 属性。在一个更大的项目中,我们可能想要将此属性抽象出来,以确保将 ViewModel 类与 .NET MAUI 分离。然而,对于这个应用来说,直接引用它是可以的。

最后,我们将 ViewModel 分配给页面的 BindingContext 类。这告诉 .NET MAUI 绑定引擎使用我们稍后创建的绑定来使用我们的 ViewModel 对象。

在这一点上,由于我们已移除 MainPage,项目将无法运行。我们将在后面的 使应用运行 部分修复这个问题。

创建 ItemView 视图

我们将要添加的第二个视图是 ItemView。我们将使用它来添加和编辑待办事项列表项:

  1. 创建一个新的内容页面(与创建 MainView 视图的方式相同),并将其命名为 ItemView

  2. 编辑 XAML 文件,使其看起来如下代码所示。更改已高亮显示:

    <?xml version="1.0" encoding="UTF-8"?>
    <ContentPage xmlns=http://schemas.microsoft.com/dotnet/2021/maui
                  xmlns:x=http://schemas.microsoft.com/winfx/2009/xaml
      x:Class="DoToo.Views.ItemView"
      Title="New todo item">
      <ContentPage.ToolbarItems>
        <ToolbarItem Text="Save" />
      </ContentPage.ToolbarItems>
      <StackLayout Padding="14">
        <Label Text="Title" />
        <Entry />
        <Label Text="Due" />
        <DatePicker />
        <StackLayout Orientation="Horizontal">
          <Switch />
          <Label Text="Completed" />
        </StackLayout>
      </StackLayout>
    </ContentPage>
    

MainView 一样,我们需要一个标题。现在我们将给它一个默认标题 New todo item,但稍后当我们重用此视图进行编辑时,我们将将其更改为 Edit todo item。用户必须能够保存新的或编辑的项目,因此我们添加了一个工具栏 Save 按钮。页面内容使用 StackLayout 来结构化控件。StackLayout 根据它计算出的元素所占用的空间垂直(默认选项)或水平地添加元素。这是一个 CPU 密集型过程,因此我们只应在布局的小部分上使用它。在 StackLayout 中,我们添加了一个 Label 控件,它是一个在下面的 Entry 控件上方的文本行。Entry 控件是一个文本输入控件,包含待办事项列表项的名称。然后,我们有一个用于 DatePicker 的部分,用户可以为此待办事项列表项选择一个截止日期。最后一个控件是一个 Switch 控件,它渲染一个切换按钮来控制项目是否完成,以及旁边的标题。由于我们希望这些控件水平显示,我们使用一个水平的 StackLayout 控件来完成这个操作。

视图的最后一步是将 ItemViewModel 模型连接到 ItemView

  1. 通过在 解决方案资源管理器 中展开 ItemView.xaml 文件来打开 ItemView 的代码隐藏文件。

  2. 在文件顶部添加一个 using DoToo.ViewModels 语句,紧邻现有的 using 语句。

  3. 修改类的构造函数,使其看起来如下。添加以下加粗的代码:

    public ItemView (ItemViewModel viewmodel)
    {
        InitializeComponent ();
        viewmodel.Navigation = Navigation;
        BindingContext = viewmodel;
    }
    

这段代码与我们为 MainView 添加的代码相同,只是 ViewModel 类的类型不同。

连接依赖注入

之前,我们讨论了依赖注入模式,该模式指出所有依赖项,如存储库和 ViewModel,都必须通过类的构造函数传递。这个要求有几个好处:

  • 它增加了代码的可读性,因为我们可以快速确定所有外部依赖项

  • 它使得依赖注入成为可能

  • 它通过模拟类使得单元测试成为可能

  • 我们可以通过指定对象应该是单例还是每次解析时的新实例来控制对象的生命周期

依赖注入是一种模式,它允许我们在运行时确定创建对象时应该传递给构造函数的对象的哪个实例。我们通过定义一个容器来实现这一点,我们在其中注册了类的所有类型。我们让使用的框架解决它们之间的任何依赖关系。假设我们要求容器提供一个 MainView 类。容器负责解决 MainViewModel 以及该类所拥有的任何依赖关系。

.NET MAUI 在内部使用 Microsoft.Extensions.DependencyInjection NuGet 库,并且它被暴露给我们用于我们的应用程序。第一步是注册我们想要参与依赖注入的类。

注册视图、ViewModel 和服务

为了使我们的类可以通过依赖注入使用,它们需要注册到依赖注入服务中。.NET MAUI 通过 MauiAppBuilder 类的 Services 属性公开依赖注入服务。Services 属性将返回一个 IServiceCollection 对象,也称为容器。IServiceCollection 有两个我们感兴趣的方法,AddSingletonAddTransient。方法名称中的“Transient”和“Singleton”指的是对象的生存期。Transient 对象每次从容器请求时都会创建。Singleton 对象只创建一次,并且每次从容器请求该类时都会返回那个实例。

当使用容器注册类时,建议使用扩展方法来分组类型。对于此应用程序,有三个组:ViewViewModelsServices。扩展方法将接受一个参数并返回一个值,即 MauiAppBuilder 实例。这就是实现 Builder 模式的方式,并允许我们在 CreateMauiApp 方法中定义的构建器上链式调用方法。

要实现这些方法,请按照以下步骤操作:

  1. 打开 MauiProgram.cs 文件。

  2. MauiProgram 类进行以下更改。更改内容已加粗:

    using DoToo.Repositories;
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", 
    "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", 
    "OpenSansSemibold");
                })
                .RegisterServices()
                .RegisterViewModels()
                .RegisterViews();
                return builder.Build();
        }
    public static MauiAppBuilder RegisterServices(this 
    MauiAppBuilder mauiAppBuilder)
        {
        mauiAppBuilder.Services.AddSingleton<ITodoItemRepository,TodoItemRepository>();
            return mauiAppBuilder;
        }
    public static MauiAppBuilder RegisterViewModels(this 
    MauiAppBuilder mauiAppBuilder)
        {
             mauiAppBuilder.Services.AddTransient<ViewModels.
    MainViewModel>();
            mauiAppBuilder.Services.AddTransient<ViewModels.
    ItemViewModel>();
            return mauiAppBuilder;
        }
    public static MauiAppBuilder RegisterViews(this 
    MauiAppBuilder mauiAppBuilder)
        {
            mauiAppBuilder.Services.AddTransient<Views.MainView>();
            mauiAppBuilder.Services.AddTransient<Views.ItemView>();
            return mauiAppBuilder;
        }
    }
    

通常,通过将类型名称用作注册方法的泛型参数来注册类型,例如 mauiAppBuilder.Services.AddTransient<Views.MainView>();。但如果需要将接口解析为实现,如 RegisterServices 方法中发生的情况,则这不起作用。在那里,注册方法不使用泛型参数;相反,它将注册的类型作为第一个参数传递,第二个参数是要返回的实例的类型。

信息

要了解更多关于 Microsoft.Extensions.DependencyInjection 的工作原理,请访问您最喜欢的浏览器中的 learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection

现在依赖注入已经连接好,我们可以再次运行项目。

使应用程序运行

我们还需要进行一些更改才能使应用程序运行:

  1. 通过展开 DoToo 项目中的 App.xaml 节点来打开 App.xaml.cs 文件。

  2. 以下行需要加粗:

    public App(Views.MainView view)
    {
        InitializeComponent();
        MainPage = new NavigationPage(view);
    }
    
  3. AppShell.XamlAppShell.xaml.cs 已不再使用,因此可以从项目中删除。

当.NET MAUI 通过构建器初始化App类时,它是通过使用依赖注入容器来完成的,所以你添加到App构造函数中的任何参数都会从容器中解析出来,以及它们的依赖项。在这种情况下,我们正在导入MainView类(及其所有依赖项,包括MainViewModelTodoItemRepository),并将其包装在NavigationPage中。NavigationPage是在.NET MAUI 中定义的一个页面,它添加了一个导航栏,并使用户能够导航到其他视图。

信息

.NET MAUI 包括Shell,在这本书中我们有一整章关于它的内容。然而,要成为一名优秀的.NET MAUI 开发者,你需要了解基础知识,而.NET MAUI 中导航的基础知识使用的是古老的NavigationPage控件。

那就这样!现在,你的项目应该开始了。根据你使用的平台,它可能看起来如下所示:

图 2.14 – DoToo 应用程序在 Windows 子系统中的 Android

图 2.14 – DoToo 应用程序在 Windows 子系统中的 Android

现在我们已经运行了一个具有基本 UI 的应用程序,让我们添加一些功能,从显示数据开始。

小贴士

如果你正在使用 Windows 目标框架调试应用程序,但它不起作用,并且你没有收到任何错误消息,尝试使用 Android 目标框架。有时,你可以从不同的平台获得更好的错误报告。

添加数据绑定

数据绑定是 MVVM 的核心和灵魂。这是视图和 ViewModel 之间相互通信的方式。在.NET MAUI 中,我们需要两样东西来使数据绑定发生:

  • 我们需要一个对象来实现INotifyPropertyChanged

  • 我们需要将页面的BindingContext类设置为该对象。我们已经在ItemViewMainView上做了这件事。

数据绑定的一个有用特性是它允许我们使用双向通信。例如,当将数据绑定到Entry控件上的文本时,数据绑定对象上的属性会直接更新。考虑以下 XAML:

<Entry Text="{Binding Title}" />

要使这起作用,我们需要在字符串对象上有一个名为Title的属性。我们必须查看文档,定义一个对象,并让IntelliSense提供提示,以找出我们的属性应该是什么类型。

执行动作的控件,如Button,通常公开一个名为Command的属性。这个属性是ICommand类型,我们可以返回Microsoft.Maui.Controls.Command或我们自己的实现。Command属性将在下一节中解释,我们将使用它来导航到ItemView

在接下来的几节中,我们将向我们的视图和 ViewModel 添加数据绑定和命令实现,从从MainView导航到ItemView开始。

信息

值得注意的是,.NET MAUI 除了双向数据绑定外,还支持单向绑定,这在你想在视图中显示数据但不允许它更新 ViewModel 时非常有用。从性能的角度来看,将那些绑定标记为单向绑定是一个好主意。

从 MainView 导航到 ItemView 添加新项目

MainView 中我们有一个 Add 工具栏按钮。当用户点击此按钮时,我们希望它将他们带到 ItemView。按照 MVVM 的方式来做这件事,就是定义一个命令,然后将该命令绑定到按钮上。

在 .NET MAUI 中,要导航到一个视图,你需要一个指向目标实例的引用。在这种情况下,那就是 ItemView。由于我们所有的视图都已经注册到依赖注入容器中,当我们准备导航时,我们需要一个指向容器的引用来请求视图的新实例。我们将使用构造函数注入来让容器提供其实例,如下所示:

  1. 打开 ViewModels/MainViewModel.cs

  2. DoToo.Views 添加一个 using 语句。

  3. 向类中添加以下字段:

    private readonly IServiceProvider services;
    
  4. 按照以下方式修改构造函数。变更部分已高亮:

    public MainViewModel(ITodoItemRepository repository, 
    IServiceProvider services)
    {
        this.repository = repository;
        this.services = services;
        Task.Run(async () => await LoadDataAsync());
    }
    

这将捕获由依赖注入容器创建的 ItemView 实例,并将其存储在类字段中。现在,让我们看看命令的实现。

所有命令都应该以泛型 ICommand 类型公开。这抽象了实际的命令实现,这是遵循良好通用实践的好方法。命令必须是一个属性;在我们的例子中,我们正在创建一个新的 Command 对象并将其分配给这个属性。这个属性是只读的,对于 Command 对象来说通常是可以接受的。命令的动作(当命令执行时我们想要运行的代码)被传递给 Command 对象的构造函数。

按照那些要求,你可能会写出以下内容:

public ICommand AddItem => new Command(async () =>
{
    await Navigation.PushAsync(itemView);
});

在这个实现中有很多样板代码,你不得不为每个命令重复这些代码。这些样板代码可能会妨碍命令的实际操作。就像我们能够通过属性消除样板代码一样,我们也可以用 ICommand 来做同样的事情,但在这里,我们可以使用 RelayCommand 属性。RelayCommand 属性使用源生成器将方法包装在一个新的 Command 实例中,并通过属性暴露出来。生成的属性名是方法名加上Command

现在,我们可以添加 Command 对象的实现:

  1. 打开 ViewModels/MainViewModel.cs

  2. 向类中添加以下方法:

    [RelayCommand]
    public async Task AddItemAsync() => await Navigation.
    PushAsync(services.GetRequiredService<ItemView>());
    
  3. using CommunityToolkit.Mvvm.Input; 添加到文件的 usings 部分。

  4. 更新类定义以允许源生成器执行其操作:

    public partial class MainViewModel : ViewModel
    

命令的动作仅仅是使用 Navigation 服务将 itemView 实例推入堆栈。

之后,我们只需要将 ViewModel 中的 AddItemAsync 命令连接到视图中的 Add 按钮上:

  1. 打开 Views/MainView.xaml

  2. 更新 ContentPage 元素:

    <ContentPage xmlns=http://schemas.microsoft.com/dotnet/2021/maui
                 xmlns:x=http://schemas.microsoft.com/winfx/2009/xaml
    
      x:Class="DoToo.Views.MainView"
      x:DataType="viewModels:MainViewModel"
      Title="Do Too!">
    
  3. ToolbarItem 添加 Command 属性:

    <ContentPage.ToolbarItems>
      <ToolbarItem Text="Add" Command="{Binding AddItemAsyncCommand}" />
    </ContentPage.ToolbarItems>
    

运行应用并点击 Add 按钮导航到新的 ItemView 视图。注意,后退按钮会自动出现。

向列表添加新项目

现在我们已经完成了向新项目添加导航的添加,让我们添加创建新项目并将其保存到数据库的代码:

  1. 打开 ViewModels/ItemViewModel.cs

  2. 添加以下粗体标记的代码:

    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    using DoToo.Models;
    using DoToo.Repositories;
    public partial class ItemViewModel : ViewModel
    {
        private readonly ITodoItemRepository repository;
        [ObservableProperty]
        TodoItem item;
        public ItemViewModel(ITodoItemRepository repository)
        {
            this.repository = repository;
            Item = new TodoItem() { Due = DateTime.Now.AddDays(1) };
        }
        [RelayCommand]
        public async Task SaveAsync()
        {
            await repository.AddOrUpdateAsync(Item);
            await Navigation.PopAsync();
        }
    }
    

Item 属性持有我们想要添加或编辑的当前项的引用。在构造函数中创建一个新项,当我们想要编辑一个项时,我们可以简单地将自己的项分配给这个属性。除非我们执行定义在末尾的 Save 命令,否则新项不会被添加到数据库中。一旦项被添加或更新,我们就从导航堆栈中移除视图并返回到 MainView

信息

由于导航将页面保存在一个堆栈中,框架声明了反映您可以在堆栈上执行的操作的方法。从堆栈中移除最顶层项的操作称为 弹出堆栈,因此我们使用 PopAsync() 而不是 RemoveAsync()。要将页面添加到导航堆栈中,我们将其推入,所以该方法称为 PushAsync()

现在我们已经通过必要的命令和属性扩展了 ItemViewModel,是时候在 XAML 中将它们数据绑定了:

  1. 打开 Views/ItemView.xaml

  2. 添加以下粗体标记的代码:

    <?xml version="1.0" encoding="UTF-8"?>
    <ContentPage
      xmlns=http://schemas.microsoft.com/dotnet/2021/maui
        xmlns:x=http://schemas.microsoft.com/winfx/2009/xaml
    
      x:Class="DoToo.Views.ItemView"
      x:DataType="viewModels:ItemViewModel" >
      <ContentPage.ToolbarItems>
        <ToolbarItem Text="Save" Command="{Binding 
    SaveAsyncCommand}" />
      </ContentPage.ToolbarItems>
      <StackLayout Padding="14">
        <Label Text="Title" />
        <Entry Text="{Binding Item.Title}" />
        <Label Text="Due" />
        <DatePicker Date="{Binding Item.Due}" />
        <StackLayout Orientation="Horizontal">
        <Switch IsToggled="{Binding Item.Completed}" />
        <Label Text="Completed" />
      </StackLayout>
    </ContentPage>
    

ToolbarItems 命令属性绑定触发 ItemViewModel 暴露的 SaveAsync 命令,当用户点击 Save 链接时。再次值得注意的是,任何名为 Command 的属性都表示将发生动作,并且我们必须将其绑定到实现 ICommand 接口的对象的实例。

代表标题的 Entry 控件绑定到 ItemViewModelItem.Title 属性,DatepickerSwitch 控件以类似方式绑定到它们各自的属性。

我们可以将 TitleDueComplete 直接作为 ItemViewModel 的属性暴露,但相反,我们选择重用已经存在的 TodoItem 对象作为引用。这是可以的,只要 TodoItem 对象的属性实现了 INotifyPropertyChange 接口。

MainView 中绑定 ListView

没有项目列表的待办事项列表没有什么用处。让我们通过项目列表扩展 MainViewModel

  1. 打开 ViewModels/MainViewModel.cs

  2. 在类的 using 部分添加 using System.Collections.ObjectModel

  3. 为待办事项列表添加一个属性:

    [ObservableProperty]
    ObservableCollection<TodoItemViewModel> items;
    

ObservableCollection 类似于一个普通集合,但它有一个有用的超能力:它可以在列表发生变化时通知监听器,例如当项目被添加或删除时。ListView 控件会监听列表的变化,并基于这些变化自动更新自己。然而,重要的是要注意,列表中项目的更改不会触发更新。更改项目的标题不会导致列表重新渲染。让我们继续实现 MainViewModel 的其余部分。

现在,我们需要一些数据:

  1. 打开 ViewModels/MainViewModel.cs

  2. 替换(或完成)LoadDataAsync 方法并创建 CreateTodo ItemViewModelItemStatusChanged 方法:

    private async Task LoadDataAsync()
    {
        var items = await repository.GetItemsAsync();
        var itemViewModels = items.Select(i => CreateTodoItemViewModel(i));
        Items = new ObservableCollection<TodoItemViewModel> 
    (itemViewModels);
    }
    private TodoItemViewModel CreateTodoItemViewModel(TodoItem item)
    {
        var itemViewModel = new TodoItemViewModel(item);
        itemViewModel.ItemStatusChanged += ItemStatusChanged;
        return itemViewModel;
    }
    private void ItemStatusChanged(object sender, EventArgs e)
    {
    }
    
  3. 通过添加以下 using 语句解决所有新的引用:

    using CommunityToolkit.Mvvm.ComponentModel;
    using DoToo.Models;
    

LoadData 方法调用存储库以获取所有项目。然后,我们将每个待办事项列表项包装在 TodoItemViewModel 中。这包含了一些特定于视图的信息,我们不想将其添加到 TodoItem 类中。将普通对象包装在 ViewModel 中是一种良好的实践;这使得向其中添加操作或额外属性变得更加简单。ItemStatusChanged 是一个存根,当我们将待办事项列表项的状态从 active 更改为 completed,反之亦然时被调用。

我们还需要连接存储库的一些事件,以便知道数据何时发生变化:

  1. 打开 ViewModels/MainViewModel.cs

  2. 在粗体中添加以下代码:

    public MainViewModel(TodoItemRepository repository, 
    IServiceProvider services)
    {
        repository.OnItemAdded += (sender, item) =>
            items.Add(CreateTodoItemViewModel(item));
        repository.OnItemUpdated += (sender, item) =>
            Task.Run(async () => await LoadDataAsync());
        this.repository = repository;
        this.services = services;
        Task.Run(async () => await LoadDataAsync());
    }
    

当项目被添加到存储库中时,无论谁添加了它,MainView 都会将它添加到 items 列表中。由于项目集合是一个可观察集合,列表会更新。如果项目被更新,我们只需重新加载列表。

让我们将项目数据绑定到 ListView

  1. 打开 MainView.xaml 并定位 ListView 元素。

  2. 修改它,使其反映以下代码:

    <ListView Grid.Row="1"
      RowHeight="70" ItemsSource="{Binding Items}">
      <ListView.ItemTemplate>
        <DataTemplate x:DataType="viewModels:TodoItemViewModel">
          <ViewCell>
            <Grid Padding="15,10">
              <Grid.RowDefinitions>
                  <RowDefinition />
                  <RowDefinition />
              </Grid.RowDefinitions>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="10" />
                <ColumnDefinition Width="*" />
              </Grid.ColumnDefinitions>
              <BoxView Grid.RowSpan="2" />
              <Label Grid.Column="1"
                Text="{Binding Item.Title}" FontSize="Medium" />
              <Label Grid.Column="1" Grid.Row="1"
                Text="{Binding Item.Due}" FontSize="Micro" />
              <Label Grid.Column="1" Grid.Row="1"
                HorizontalTextAlignment="End" Text="Completed"
                IsVisible="{Binding Item.Completed}"
                FontSize="Micro" />
            </Grid>
          </ViewCell>
        </DataTemplate>
      </ListView.ItemTemplate>
    </ListView>
    

ItemsSource 绑定告诉 ListView 去哪里找到要迭代的集合,并且是 ViewModel 本地化的。然而,ViewCell 节点中的任何绑定都是针对列表中迭代的每个项目本地化的。在这种情况下,我们正在绑定到 TodoItemViewModel,它包含一个名为 Item 的属性。这个属性反过来又包含诸如 TitleDueCompleted 等属性。在定义绑定时,我们可以毫无问题地导航到对象的层次结构。

DataTemplate 元素定义了每一行将看起来是什么样子。我们使用网格来划分空间,就像我们之前做的那样。

你可能已经注意到我们没有讨论 BoxView 的用途,并且它没有绑定到 ViewModel 的任何属性。接下来的两个部分将介绍我们如何使用 Completed 属性来使用 BoxView 为项目着色。

为项目状态创建一个 ValueConverter 对象

有时,我们想要绑定到表示原始值的对象。这可能是一段基于布尔值的文本。例如,我们可能想要写 YesNo,或者返回一个颜色。这就是 ValueConverter 发挥作用的地方。它可以用来将一个值转换为另一个值,或者从另一个值转换回来。我们将编写一个 ValueConverter 对象,将待办事项列表项的状态转换为颜色:

  1. DoToo 项目的根目录下创建一个名为 Converters 的文件夹。

  2. Converters 文件夹中创建一个名为 StatusColorConverter.cs 的类,并添加以下代码:

    using System;
    using System.Globalization;
    public class StatusColorConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object 
    parameter, CultureInfo culture)
        {
            return (Color)Application.Current.Resources[
            (bool)value ? "CompletedColor" : "ActiveColor"];
        }
        public object ConvertBack(object value, Type targetType, 
    object parameter, CultureInfo culture)
        {
            return null;
        }
    }
    

ValueConverter 对象是一个实现了 IValueConverter 接口的类。这反过来又只定义了两个方法。当视图从 ViewModel 读取数据时调用 Convert 方法,当 ViewModel 从视图中获取数据时使用 ConvertBack 方法。ConvertBack 方法仅用于返回纯文本数据的控件,例如 Entry 控件。

如果我们查看 Convert 方法的实现,我们会注意到传递给该方法的所有值都是 object 类型。这是因为我们不知道用户将什么类型绑定到我们添加 ValueConverter 类的属性上。我们可能还会注意到我们从资源文件中获取颜色。我们本来可以在代码中定义颜色,但这不是推荐的做法。因此,我们走得更远,将它们作为全局资源添加到 App.xaml 文件中。完成本章学习后,资源是一个值得再次审视的好东西:

  1. 打开 DoToo 项目的 App.xaml 文件。

  2. 添加以下 ResourceDictionary 元素:

    <Application ...>
        <Application.Resources>
            <ResourceDictionary>
                <ResourceDictionary.MergedDictionaries>
                    <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
                    <ResourceDictionary Source="Resources/Styles/
    Styles.xaml" />
            </ResourceDictionary.MergedDictionaries>
            <ResourceDictionary>
                <Color x:Key="CompletedColor"> #1C8859 </Color>
                <Color x:Key="ActiveColor"> #D3D3D3 </Color>
            </ResourceDictionary>
            </ResourceDictionary>
        </Application.Resources>
    </Application>
    

ResourceDictionary 可以定义广泛的不同对象。我们只需要访问 ValueConverter 的两个颜色。请注意,这些可以通过给定的键访问,并且可以从任何其他 XAML 文件使用静态资源绑定访问。

ValueConverter 本身作为一个静态资源被引用,但来自局部作用域。

使用 ValueConverter

我们想在 MainView 中使用我们全新的 StatusColorConverter 对象。不幸的是,我们必须跳过一些步骤才能实现这一点。我们需要做三件事:

  • 在 XAML 中定义一个命名空间

  • 定义一个表示转换器实例的本地资源

  • 声明我们想要在绑定中使用转换器

让我们从命名空间开始:

  1. 打开 Views/MainView.xaml

  2. 将以下命名空间添加到页面中:

    <ContentPage  xmlns="http://schemas.microsoft.com/dotnet/2021/
    maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/
    xaml"
    
      x:Class="DoToo.Views.MainView" Title="Do Too!>
    

MainView.xaml 文件中添加一个 Resource 节点:

  1. 打开 Views/MainView.xaml

  2. 在 XAML 文件的根元素下添加以下 ResourceDictionary 元素,如下所示(加粗):

    <ContentPage ...>
      <ContentPage.Resources>
        <ResourceDictionary>
    <converters:StatusColorConverter 
    x:Key="statusColorConverter"/>
        </ResourceDictionary>
      </ContentPage.Resources>
      <ContentPage.ToolBarItems>
        <ToolbarItem Text="Add" Command="{Binding AddItem}" />
      </ContentPage.ToolbarItems>
      <Grid ...>
      </Grid>
    </ContentPage>
    

这与全局资源字典具有相同的形式,但由于这个资源是在 MainView 中定义的,因此只能从那里访问。我们本来可以在全局资源字典中定义它,但通常将只在一个地方使用的对象尽可能靠近那个地方定义会更高效。

最后一步是添加转换器:

  1. 在 XAML 文件中找到 BoxView 节点。

  2. 添加标记为粗体的 BackgroundColor XAML:

    <BoxView Grid.RowSpan="2"
            BackgroundColor="{Binding Item.Completed,
            Converter={StaticResource statusColorConverter}}" />
    

我们在这里所做的是将一个 bool 值绑定到一个接受 Color 对象的属性。然而,在数据绑定发生之前,ValueConverterbool 值转换为颜色。这只是 ValueConverter 有用的许多情况之一。在定义 GUI 时请记住这一点。

使用命令导航到项

我们希望能够看到所选待办事项列表项的详细信息。当我们点击一行时,我们应该导航到该行的项。

要完成这个任务,我们需要添加以下代码:

  1. 打开 ViewModels/MainViewModel.cs

  2. SelectedItem 属性、OnSelectedItemChanging 事件处理程序和 NavigateToItemAsync 方法添加到类中:

    [ObservableProperty]
    TodoItemViewModel selectedItem;
    partial void OnSelectedItemChanging(TodoItemViewModel value)
    {
        if (value == null)
        {
            return;
        }
        MainThread.BeginInvokeOnMainThread(async () => {
            await NavigateToItemAsync(value);
        });
    }
    private async Task NavigateToItemAsync(TodoItemViewModel item)
    {
        var itemView = services.GetRequiredService<ItemView>();
        var vm = itemView.BindingContext as ItemViewModel;
        vm.Item = item.Item;
        itemView.Title = "Edit todo item";
        await Navigation.PushAsync(itemView);
    }
    

SelectedItem 属性是我们将要数据绑定到 ListView 的属性。当我们选择 ListView 中的行时,此属性设置为表示该行的 TodoItemViewModel 对象。在这里我们使用 ObservableProperty 属性来执行其 PropertyChanged 魔法。然而,由于设置器是通过 ObservableProperty 属性生成的,因此没有地方可以添加额外的代码到该属性中。幸运的是,ObservableProperty 源生成器还添加了两个可以实现的局部方法。我们使用 OnSelectedItemChanging 来向设置器添加额外的功能。另一个局部方法是 OnSelectedItemChangedOnSelectedItemChanging 在属性值改变之前被调用,而 OnSelectedItemChanged 在值改变后被调用。记住,你总是可以查看生成的源代码来了解这些属性是如何扩展你的代码的。

OnSelectedItemChanging 方法随后调用 NavigateToItem,它使用 .NET MAUI 依赖注入容器创建一个新的 ItemView 视图。在这个时候,我们将视图的 Title"Add todo item" 改为 "Edit todo item"。我们从新创建的 ItemView 视图中提取 ViewModel 并将 TodoItemViewModel 包含的当前 TodoItem 对象分配给它。困惑了吗?记住,TodoItemViewModel 包装了一个 TodoItem 对象,而我们想要传递给 ItemView 的就是那个对象。

我们还没有完成。现在,我们需要将新的 SelectedItem 属性数据绑定到视图中的正确位置:

  1. 打开 Views/MainView.xaml

  2. 定位到 ListView 并添加粗体字中的属性:

    <ListView x:Name="ItemsListView" Grid.Row="1" RowHeight="70"
        ItemsSource="{Binding Items}"
        SelectedItem="{Binding SelectedItem}">
    

SelectedItem 属性将 ListView 视图的 SelectedItem 属性绑定到 ViewModel 属性。当 ListView 中项的选择改变时,ViewModel 属性的 SelectedItem 属性被调用,并且我们导航到新的和令人兴奋的视图。

x:Name 属性用于命名 ListView,因为我们需要做一些小而丑陋的修改来使它工作。ListView 在导航完成后会保持选中状态。当我们返回导航时,它不能再次被选中,直到我们选择另一行。为了减轻这种情况,我们需要连接到 ListViewItemSelected 事件,并在 ListView 上直接重置选中项。这不被推荐,因为我们不应该在视图中有任何逻辑,但有时我们别无选择:

  1. 打开 Views/MainView.xaml.cs

  2. 请以粗体添加以下代码:

    public MainView(MainViewModel viewmodel)
    {
        InitializeComponent();
        viewmodel.Navigation = Navigation;
        BindingContext = viewmodel;
        ItemsListView.ItemSelected += (s, e) =>
            ItemsListView.SelectedItem = null;
    }
    

现在我们应该能够导航到列表中的某个项目。接下来,我们将标记它为完成。

使用命令标记项目为完成

我们需要添加一个功能,允许我们在 completeactive 之间切换项目。虽然可以导航到待办事项列表项的详细视图,但这对于用户来说工作量太大。因此,我们将在 ListView 中添加一个 ContextAction 项目。例如,在 iOS 中,这可以通过在行上向左滑动来访问:

  1. 打开 ViewModel/TodoItemViewModel.cs

  2. 添加 using 语句 CommunityToolkit.Mvvm.Input

  3. 添加一个用于切换项目状态的命令和一段描述状态的文本:

    [RelayCommand]
    void ToggleCompleted()
    {
        Item.Completed = !Item.Completed;
        ItemStatusChanged?.Invoke(this, new EventArgs());
    }
    

在这里,我们添加了一个用于切换项目状态的命令。当执行时,它会反转当前状态并引发 ItemStatusChanged 事件,以便订阅者得到通知。为了根据状态更改上下文操作按钮的文本,我们添加了一个 StatusText 属性。这不是推荐的做法,因为我们正在向 ViewModel 添加仅因特定 UI 情况而存在的代码。理想情况下,这应该由视图处理,可能通过使用 ValueConverter。然而,为了省去我们实现这些步骤的需要,我们将其保留为字符串属性:

  1. 打开 Views/MainView.xaml

  2. 定位到 ListView.ItemTemplate 节点,并添加以下 ViewCell.ContextActions 节点:

    <ListView.ItemTemplate>
      <DataTemplate>
        <ViewCell>
          <ViewCell.ContextActions>
    <MenuItem Text="{Binding StatusText}" Command="{Binding 
    ToggleCompletedCommand}" />
          </ViewCell.ContextActions>
          <Grid Padding="15,10">
          ...
          </Grid>
        </ViewCell>
      </DataTemplate>
    </ListView.ItemTemplate>
    

使用命令创建过滤切换功能

我们希望能够切换仅在查看活动项目或所有项目之间。我们将创建一个简单的机制来实现这一点。

按照以下方式将 MainViewModel 中的更改连接起来:

  1. 打开 ViewModels/MainViewModel.cs 并定位 ItemStatusChangeMethod

  2. 将实现添加到 ItemStatusChanged 方法,并添加一个名为 ShowAll 的属性来控制过滤:

    private void ItemStatusChanged(object sender, EventArgs e)
    {
        if (sender is TodoItemViewModel item)
        {
            if (!ShowAll && item.Item.Completed)
            {
                Items.Remove(item);
            }
            Task.Run(async () => await repository.UpdateItemAsync(item.Item));
        }
    }
    [ObservableProperty]
    bool showAll;
    

当我们使用上一节中的上下文操作时,会触发 ItemStatusChanged 事件处理程序。由于发送者始终是一个对象,我们尝试将其转换为 TodoItemViewModel。如果成功,我们检查是否可以将其从列表中删除,如果 ShowAll 不是 true。这是一个小的优化;我们本可以调用 LoadData 并重新加载整个列表,但由于 Items 列表被设置为 ObservableCollection,它会通知 ListView 列表中已删除一个项目。我们还调用存储库来更新项目以持久化状态更改。

ShowAll 属性控制我们的过滤器处于哪种状态。我们需要调整 LoadData 方法以反映这一点:

  1. MainViewModel 中定位 Load 方法。

  2. 添加以下加粗的代码行:

    private async Task LoadDataAsync()
    {
        var items = await repository.GetItemsAsync();
        if (!ShowAll)
        {
            items = items.Where(x => x.Completed == false).ToList();
        }
        var itemViewModels = items.Select(i => 
    CreateTodoItemViewModel(i));
        Items = new ObservableCollection<TodoItemViewModel> (itemViewModels);
    }
    

如果 ShowAllfalse,我们将限制列表的内容为未完成的项。我们可以通过有两个方法,GetAllItems()GetActiveItems(),或者使用可以传递给 GetItemsAsync()filter 参数来实现这一点。花一分钟时间思考我们如何实现这一点。

让我们添加切换过滤器的代码:

  1. 打开 ViewModels/MainViewModel.cs

  2. 添加 FilterTextToggleFilterAsync 属性:

    [RelayCommand]
    private async Task ToggleFilterAsync()
    {
        ShowAll = !ShowAll;
        await LoadDataAsync();
    }
    

ShowAll 属性是一个布尔值,它以人类可读的形式显示得不好。我们将使用另一个 ValueConverter 将状态转换为人类可读的形式:

  1. Converters 文件夹中创建一个名为 FilterTextConverter.cs 的新类。

  2. 添加以下代码:

    using System;
    using System.Globalization;
    internal class FilterTextConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object 
    parameter, CultureInfo culture)
        {
            return (bool)value ? "All" : "Active";
        }
        public object ConvertBack(object value, Type targetType, 
    object parameter, CultureInfo culture)
        {
            return null;
        }
    }
    

FilterTextConverter 与我们之前创建的转换器非常相似。区别在于在 Convert 方法中,我们将 bool 值转换为 "All""Active" 字符串。此转换器将在视图中使用,将 ShowAll 的值转换为更适合在用户界面中显示的值。

ToggleFilterAsync 命令的逻辑是状态的简单反转然后调用 LoadDataAsync。这反过来又导致列表被重新加载。

在我们可以过滤项目之前,我们需要将过滤按钮连接到 CommandConverter

  1. 打开 Views/MainView.xaml

  2. 将以下突出显示的条目添加到 ResourceDictionary

    <ResourceDictionary>
        <converters:StatusColorConverter  x:Key=
    "statusColorConverter"/>
        <converters:FilterTextConverter 
    x:Key="filterTextConverter"/>
    </ResourceDictionary>
    
  3. 定位控制过滤器的按钮(文件中的唯一按钮)。

  4. 调整您的代码以反映以下代码:

    <Button Text="{Binding ShowAll,Converter={StaticResource 
    filterTextConverter}, StringFormat='Filter: {0}'}"
        Command="{Binding ToggleFilterAsyncCommand}" />
    

我们现在已经完成了这个功能!然而,我们的应用并不吸引人;我们将在下一节中处理这个问题。

布局内容

这最后一部分是关于让应用看起来更美观。我们在这里只是触及了可能性的表面,但这应该会给你一些关于样式如何工作的想法。

设置应用程序范围的背景颜色

样式是应用样式的绝佳方式。如果添加了 x:Key 属性,它们可以应用于类型的所有元素或通过键引用的元素:

  1. 打开 App.xaml

  2. 将以下加粗的 XAML 添加到文件中:

    <ResourceDictionary>
      <Style TargetType="NavigationPage">
        <Setter Property="BarBackgroundColor" Value="#A25EBB" />
        <Setter Property="BarTextColor" Value="#FFFFFF" />
      </Style>
      <Style x:Key="FilterButton" TargetType="Button">
        <Setter Property="Margin" Value="15" />
        <Setter Property="BorderWidth" Value="1" />
        <Setter Property="BorderColor" Value="Silver" />
        <Setter Property="TextColor" Value="Black" />
      </Style>
      <Color x:Key="CompletedColor">#1C8859</Color>
      <Color x:Key="ActiveColor">#D3D3D3</Color>
    </ResourceDictionary>
    

我们将应用的第一种样式是导航栏的新背景颜色和文本颜色。第二种样式将应用于过滤按钮。我们可以通过设置 TargetType 来定义样式,这告诉 .NET MAUI 这种样式可以应用于哪种类型的对象。然后我们可以添加一个或多个我们想要设置的属性。结果将与我们在 XAML 代码中直接添加这些属性相同。

缺少 x:Key 属性的样式应用于 TargetType 中定义的类型的所有实例。具有键的样式必须在用户界面的 XAML 中显式分配。我们将在定义下一节中的筛选按钮时看到这个例子。

布局 MainView 和 ListView 项目

在本节中,我们将改进 MainViewListView 的外观。打开 Views/MainView.xaml 并在每个以下部分中应用 XAML 代码中的粗体更改。

筛选按钮

筛选按钮允许我们切换列表的状态,以仅显示活动待办事项或所有待办事项。让我们将其样式化,使其在布局中更加突出:

  1. 找到筛选按钮。

  2. 进行以下修改:

    <Button Style="{DynamicResource FilterButton}"
            Text="{Binding ShowAll,Converter={StaticResource 
    filterTextConverter}, StringFormat='Filter: {0}'}"
            BackgroundColor="{DynamicResource ActiveColor}"
            TextColor="Black"
            Command="{Binding ToggleFilterCommand}">
      <Button.Triggers>
    <DataTrigger TargetType="Button" Binding="{Binding ShowAll}" 
    Value="True">
    <Setter Property="BackgroundColor" Value="{DynamicResource 
    CompletedColor}" />
          <Setter Property="TextColor" Value="White" />
        </DataTrigger>
      </Button.Triggers>
    </Button>
    

样式是通过 DynamicResource 应用的。在资源字典中定义的任何内容,无论是 App.xaml 文件中还是本地 XAML 文件中,都可以通过它访问。然后,我们设置了 BackgroundColor,再次将 DynamicResource 设置为 ActiveColor 并将 TextColor 设置为 Black

Button.Triggers 节点是一个有用的功能。我们可以定义在满足某些条件时触发的几种类型的触发器。在这种情况下,我们使用了一个数据触发器,检查 ShowAll 的值是否变为 true。如果是,我们将 TextColor 设置为白色,将 BackgroundColor 设置为 CompletedColor。最酷的部分是当 ShowAll 再次变为 false 时,它会切换回之前的任何值。

优化 ListView

ListView 可以进行一些小的修改。第一个修改是将到期日期字符串格式化为更易读的格式,第二个修改是将 Completed 标签的颜色改为漂亮的绿色:

  1. 打开 Views/MainView.xaml

  2. ListView 中定位绑定 Item.DueItem.Completed 的标签:

    <Label Grid.Column="1" Grid.Row="1"
          Text="{Binding Item.Due, StringFormat='{0:MMMM d, yyyy}'}"
          FontSize="Micro" />
    <Label Grid.Column="1" Grid.Row="1"
          HorizontalTextAlignment="End"
          Text="Completed"
          IsVisible="{Binding Item.Completed}"
          FontSize="Micro"
          TextColor="{StaticResource CompletedColor}" />
    

在这里,我们向绑定中添加了一个格式化字符串,用于使用特定格式格式化日期。在这种情况下,我们使用了 0:MMMM d, yyyy 格式,这将日期显示为字符串,例如,5 月 5 日,2020 年。

我们还向 Completed 标签添加了文本颜色,仅在项目完成时可见。我们通过在 App.xaml 中引用我们的字典来实现这一点。

现在所有代码更改都已完成,运行我们的应用程序。以下是一组截图,应该与你的应用程序匹配:

图 2.15 – DoToo 在 Android 上

图 2.15 – DoToo 在 Android 上

摘要

现在,你应该已经很好地掌握了从头创建 .NET MAUI 应用所需的所有步骤。在本章中,我们了解了项目结构和新建项目中的重要文件。我们讨论了依赖注入,并通过创建所有视图和所需的 ViewModel 类学习了 MVVM 的基础知识。我们还介绍了在 SQLite 中进行数据存储,以快速且安全的方式在我们的设备上持久化数据。利用本章中获得的知识,你现在应该能够创建任何你想要的任何应用的骨架。

下一章将专注于将现有的 Xamarin.Forms 应用程序升级到.NET MAUI。**

第三章:将 Xamarin.Forms 应用转换为.NET MAUI

在我们深入.NET MAUI 之前,我们将回顾一个现有的 Xamarin.Forms 应用,并将其转换为.NET MAUI。本章将指导您通过将运行在 Mono 上的现有 Xamarin.Forms 应用转换为运行在.NET 7 上的.NET MAUI 应用的步骤。我们将讨论两种将您的 Xamarin.Forms 应用程序转换为.NET MAUI 的不同方法。第一种方法将使用新的.NET MAUI 项目,并将我们旧的 Xamarin.Forms 代码移动到新项目中。第二种方法将使用.NET 升级助手工具为我们完成一些升级工作。

如果您是.NET MAUI 的新手,并且不是从 Xamarin.Forms 应用开发过来的,您可以自由地跳过这一章,直接进入下一个项目。

本章将涵盖以下主题:

  • 将代码迁移到新的.NET MAUI 项目

  • 升级 Xamarin.Forms 应用的概述

  • 安装和运行.NET 升级助手

技术要求

要完成此项目,您需要在您的MacintoshMac)或 PC 上安装 Visual Studio,以及.NET 移动组件。有关如何设置环境的更多详细信息,请参阅第一章.NET MAUI 简介。本章还将安装额外的组件,因此您需要互联网连接来下载和安装.NET 升级助手。本章提供了 Windows 上 Visual Studio 的截图和说明。

本章将既是经典的文件 | 新建 | 项目章节,同时也会使用一个现有的应用,引导您一步步完成将应用从 Xamarin.Forms 迁移到.NET MAUI 的过程。对于第二个应用,您需要从本书的 GitHub 仓库下载源代码。

您可以在github.com/PacktPublishing/MAUI-Projects-3rd-EditionChapter03文件夹下找到本章代码的完整源代码。

项目概述

本章的目的不是详尽无遗地介绍在将您的 Xamarin.Forms 应用转换为.NET MAUI 时需要了解的所有事项。相反,它概述了在迁移应用时需要考虑的因素,以及两个实现该任务的示例。由于应用样式、版本、框架、自定义控件等存在太多变体,本章无法涵盖所有场景。这可能需要一整本书,而且很可能在出版时就已经过时了。因此,在本章中,我们将专注于一个简单的迁移方法,该方法利用.NET MAUI 的单项目特性和.NET 迁移助手,这将自动化许多手动操作,并且会不断更新。

本章的第一部分将使用从 Shell 模板创建的新 Xamarin.Forms 应用。本章的第二部分将使用.NET 升级助手升级 GitHub 上可用的开源应用 BuildChat,网址为github.com/mindofai/Build2019Chat

每本开发书籍都需要有一个聊天应用;这本书也不例外。对于.NET 迁移助手,我们将使用一个使用 Xamarin.Forms 构建的现有聊天应用。该应用可以在本地进行调试和测试,因此无需设置和配置任何云服务。

此项目的构建时间大约为一小时。

将应用迁移到空白.NET MAUI 模板

这种将现有应用代码移动到新.NET MAUI 应用的方法主要用于较小、较简单的应用,这些应用没有很多外部依赖,例如 NuGet 或本地库。这种方法的最大好处是迁移后的应用将是一个单一的项目,针对所有.NET MAUI 支持的平台。如果你的原始应用只针对 Android 和 iOS,使用这种方法可以免费获得 Mac Catalyst 和 Windows 目标。使用.NET 升级助手不会添加你尚未针对的平台。

为了说明这些步骤,我们将创建一个新的项目,就像我们在第二章中所做的那样。然而,这一次,我们首先需要创建一个 Xamarin.Forms 项目。

创建一个新的 Xamarin.Forms 应用

以下步骤将指导你创建一个新的 Xamarin.Forms 项目:

  1. 打开 Visual Studio 2022 并选择创建新项目

图 3.1 – Visual Studio 2022

图 3.1 – Visual Studio 2022

这将打开创建新项目向导。

  1. 在搜索框中输入Xamarin.Forms,并从列表中选择移动应用(Xamarin.Forms)项:

图 3.2 – 新的 Xamarin.Forms 项目

图 3.2 – 新的 Xamarin.Forms 项目

  1. 通过将你的项目命名为MauiMigration来完成向导的下一页,然后点击下一步

图 3.3 – 配置 Xamarin.Forms 项目

图 3.3 – 配置 Xamarin.Forms 项目

  1. 选择飞出模板,并确保所有三个 Xamarin.Forms 平台都被勾选:

图 3.4 – 选择 Xamarin.Forms 模板和平台

图 3.4 – 选择 Xamarin.Forms 模板和平台

你可能会收到一些关于过时组件的消息。如果你是第一次创建 Xamarin.Forms 应用,这是预期的,可以安全忽略。

  1. 通过点击创建并等待 Visual Studio 创建项目来完成设置。

    在我们开始迁移此应用之前,确保它能够正常工作是个好主意。

  2. 运行应用并测试所有按钮、弹出选项和菜单。

    关于页面中,有一个了解更多按钮,它将打开浏览器并导航你到 Xamarin.Forms 快速入门网页:

图 3.5 – 了解更多按钮

图 3.5 – 了解更多按钮

  1. 快捷菜单有三个选项:关于浏览注销。确保你点击每一个并探索它们的所有功能:

图 3.6 – 快捷菜单选项

图 3.6 – 快捷菜单选项

现在我们已经探索了这个 Xamarin.Forms 应用,让我们继续创建新的.NET MAUI 项目,这个项目将作为我们的新应用。

创建新的.NET MAUI 应用

我们将向当前解决方案添加一个新项目以简化操作。要创建一个新的.NET MAUI 项目,请按照以下步骤操作:

  1. 在 Visual Studio 中,右键单击解决方案资源管理器中的MauiMigration解决方案项,选择文件 | 添加 | 新项目

图 3.7 – 将新项目添加到解决方案

图 3.7 – 将新项目添加到解决方案

  1. 添加新项目对话框中,选择.NET MAUI 应用,然后点击下一步

图 3.8 – 添加新的.NET MAUI 项目

图 3.8 – 添加新的.NET MAUI 项目

  1. MyMauiApp中点击下一步

图 3.9 – 配置.NET MAUI 项目

图 3.9 – 配置.NET MAUI 项目

  1. 附加信息对话框中,确保选定的框架是.NET 7.0(标准支持期限),然后点击创建

现在我们有了.NET MAUI 应用的框架,我们可以开始将 Xamarin.Forms 应用的重要部分移动到.NET MAUI 项目中。

将 MauiMigration 应用迁移到 MyMauiApp

将 Xamarin.Forms 应用迁移到新的空白.NET MAUI 模板将涉及以下高级步骤:

  1. 将你的应用文件复制到新的模板中。

  2. 将 Xamarin.Forms 命名空间更改为其.NET MAUI 等效名称。

  3. 更新应用启动项,使其使用你的视图。

下几个部分将解释如何完成这些步骤。

将文件复制到新项目

首先,让我们将 XAML 和 C#文件和文件夹从 Xamarin.Forms 项目复制到.NET MAUI 项目。在MauiMigration项目中,选择ModelsServicesViewModelsViews文件夹。我们将复制这些文件而不是移动它们,这样我们就不破坏原始项目。右键单击所选文件夹中的任何一个,选择MauiApp项目并点击粘贴

还需要一些图片;我们将从MauiMigration.UWP项目复制它们。在MauiMigration.UWP项目下,你会找到三个名为icon_about.pngicon_feed.pngxamarin_logo.png的图片文件。选择所有三个文件,就像你之前对文件所做的那样。将这些文件粘贴到MyMauiApp/Resources/Images文件夹中。

当您复制/粘贴文件时,Visual Studio 可能会对您的项目文件进行一些更改,例如添加新的项目组,这些项目组会删除并添加相同的文件到项目中。您可以安全地删除这些更改,因为单一项目系统知道如何处理 XAML.png 文件。如果您遇到与缺失图像或 XAML 文件中的错误相关的编译错误,请检查 MyMAuiApp.csproj 文件中是否有任何额外的 ItemGroups 引用了 .png 文件或 XAML 文件,并删除它们。

以下截图显示了从 MauiMigration.UWP 复制图像后的 MyMauiApp.csproj 文件示例。图 3**.10 显示了 Visual Studio 添加的更改;这些可以删除:

图 3.10 – Visual Studio 添加了不必要的项目

图 3.10 – Visual Studio 添加了不必要的项目

接下来,我们需要更新 XAML 文件,以便它们引用 .NET MAUI 控件。

更新命名空间

目前,XAML 文件仍在使用 Xamarin.Forms 命名空间。要更新这些文件,我们需要更改以下内容:

我们必须按照以下方式修改:

您可以使用 Visual Studio 中的 查找和替换 功能来执行这些更改或手动编辑每个文件。要使用 查找和替换 对话框,请按照以下步骤操作:

  1. MyMauiApp 项目中。

  2. 从 Visual Studio 菜单中选择 编辑 | 查找和替换 | 在文件中替换(或 Ctrl + Shift + H)以打开 查找和替换 对话框:

图 3.11 – 查找和替换对话框

图 3.11 – 查找和替换对话框

  1. 查找位置 字段中,选择 当前项目

  2. 点击 全部替换 按钮;应该只有五个地方需要做出更改。

完成后,还需要进行一些其他更改。

MyMauiApp 项目的 Views 文件夹中的 NewItemPage.xaml 文件中,删除以下突出显示的文本:

Title="New Item"

XAML file namespace changes are complete, we can move on to the C# namespace changes.
Using the **Find and Replace** dialog again, we can remove all the Xamarin.Forms namespace references. This time, by using a regular expression, we can remove multiple lines. Use the following expression in the **Find** entry box:

^using Xamarin.[Forms,Essentials].*;


 Then, check the **Use regular expressions** checkbox and select **Current project** for **Look in**, as shown here:
![Figure 3.12 – Find and Replace – C# namespaces](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_12.jpg)

Figure 3.12 – Find and Replace – C# namespaces
Most of the changes have been made, and the app should compile at this point. However, it is not using any of the code we have copied and modified as .NET MAUI Shell does not reference the copied pages. The next step is to hook Shell into our code.
Modifying the app startup
The `MyMauiApp` project is still using the default `MainPage.xaml` file as its startup page. The next step in our migration is to make the `AppShell.xaml` file the same as it is for our Xamarin.Forms app.
What we need to copy is in the `MauiMigration` project, in the `AppShell.xaml` file, starting at line 76 until line 101, as shown in the following code snippet:


 Copy the preceding code and replace the following lines in the `MyMauiApp` project’s `AppShell.xaml` file:

<ShellContent

Title="Home"

ContentTemplate="{DataTemplate local:MainPage}"

Route="MainPage" />


 To enable the flyout, you will need to remove the following highlighted text in `AppShell.xaml`:

MauiProgram.cs 文件并对以下代码块中突出显示的更改进行修改:

using Microsoft.Extensions.Logging;
using MauiMigration.Services;
using MauiMigration.Models;
namespace MyMauiApp
{
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                });
            DependencyService.RegisterSingleton<IDataStore<Item>>(new MockDataStore());
#if DEBUG
        builder.Logging.AddDebug();
#endif
        return builder.Build();
        }
    }
}

到目前为止,您应该能够编译并运行转换后的项目。在运行之前,请确保将 MyMauiApp 设置为启动项目。玩转应用程序,确保一切正常后再进入下一节,即手动将应用程序迁移到 .NET MAUI。

手动迁移概述

在上一节中,我们使用单一项目系统将一个简单的 Xamarin.Forms 应用程序转换为 .NET MAUI。该项目没有使用任何高级的 Xamarin.Forms 功能,例如外部 NuGet 包、自定义控件或任何商业控件。这些是在将您的应用程序从 Xamarin.Forms 迁移到 .NET MAUI 时需要考虑的额外项目。

在本节中,我们将讨论您在迁移您的 Xamarin.Forms 应用程序到 .NET MAUI 时应该遵循的基本流程。这绝对不是一份详尽的列表;.NET MAUI 团队正在更新一个维基页面,其中详细说明了他们所有的知识。

官方迁移指南

由于 .NET MAUI 的发布,迁移您的 Xamarin.Forms 应用程序到 .NET MAUI 的指南一直在不断演变,基于使用情况和反馈。要查看最新指南,请访问以下 URL 并在您喜欢的浏览器中查看:learn.microsoft.com/en-us/dotnet/maui/get-started/migrate?view=net-maui-7.0

当您将应用程序从 Xamarin.Forms 迁移到 .NET MAUI 时,您需要遵循这里概述的整体步骤:

  1. 将 .NET Framework 的 Xamarin.Forms 项目转换为 .NET SDK 风格。

  2. 将代码从 Xamarin.Forms 更新到 .NET MAUI。

  3. 更新与 .NET 6+ 版本不兼容的依赖项。

  4. 解决任何破坏性的 API 变更。

  5. 运行转换后的应用程序并验证其功能。

.NET 升级助手是一个工具,它将尝试为您执行前四个步骤。然而,在我们深入使用 .NET 升级助手之前,我们将查看每个步骤包含的内容,以便我们能够牢固地理解如何在 .NET 升级助手无法在应用程序项目中操作时迁移我们的应用程序。

将 .NET Framework 的 Xamarin.Forms 项目转换为 .NET SDK 风格

一些 Xamarin.Forms 项目基于 .NET Framework 项目模板。这是一个冗长的项目格式,已经更新为适用于 .NET 项目。新的格式,通常称为 SDK 风格,是一个更简洁的格式,具有更好的默认设置。使用 Visual Studio 16.5 或更高版本创建的 Xamarin.Forms 项目使用较新的 SDK 格式。

要转换旧格式的项目文件,例如我们将在本章后面的安装和运行.NET 升级助手部分使用的项目文件,你需要将<Project />元素更改为新的 SDK 样式,如下所示:

  1. 在你的项目文件中找到如下类似的行:

    <Project ToolsVersion="4.0" DefaultTargets="Build" >
    
    1. 替换为以下内容:
    <Project Sdk="Microsoft.NET.Sdk">
    

记住,你将必须为所有你的 Xamarin.Forms 项目、平台特定项目和任何共享库项目进行此更改。

让我们看看你可能需要为针对 Android 和 iOS 的典型 Xamarin.Forms 项目做出的具体更改。

要将 Xamarin.Forms 应用的共享库项目转换为.NET MAUI 项目,我们需要将csproj文件的内容替换为以下内容:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net7.0-ios;net7.0-android; </TargetFrameworks>
    <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform
('windows'))">$(TargetFrameworks);net7.0-windows10.0.19041.0
</TargetFrameworks>
    <UseMaui>True</UseMaui>
    <OutputType>Library</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Required for C# Hot Reload -->
    <UseInterpreter Condition="'$(Configuration)' == 'Debug'">True</UseInterpreter>
    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$
(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$
(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
    <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$
(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOS
PlatformVersion>
    <TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$
(TargetFramework)')) == 'windows'">10.0.17763.0</Target
PlatformMinVersion>
  </PropertyGroup>
</Project>

重要的部分被突出显示。<UseMaui>True</UseMaui>将使项目系统自动为.NET MAUI 库添加正确的项目引用。<TargetFrameworks>元素被更新为.NET 6 或 7 的正确目标框架标识符TFMs)。

.NET MAUI 还需要一个额外的<SupportedOSPlatformVersion>属性,该属性根据TargetPlatformIdentifier有条件地设置。

与.NET Framework 的csproj文件相比,这是一个显著的数量减少。除了属性减少之外,你可以删除大多数<ItemGroup>…</ItemGroup>条目,因为现在所有源文件默认都包含在内。需要保留的组是包含<ProjectReferences />条目的组。

现在我们已经转换了共享项目,让我们回顾一下 Android 项目所需的更改。

与所有.NET Framework 项目一样,我们需要将<Project …>元素更改为以下内容:

<Project Sdk="Microsoft.NET.Sdk">

现在,删除所有<PropertyGroup>…</PropertyGroup>元素,因为它们是默认值,并用以下内容替换它们:

<PropertyGroup>
  <UseMaui>True</UseMaui>
  <TargetFramework>net7.0-android</TargetFramework>
  <OutputType>Exe</OutputType>
  <ImplicitUsings>enable</ImplicitUsings>
  <SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net7.0-android'">31.0</SupportedOSPlatformVersion>
</PropertyGroup>
<PropertyGroup>
  <UseInterpreter Condition="$(TargetFramework.Contains('-android'))">True</UseInterpreter>
</PropertyGroup>

在 Android 项目中,我们设置了<UseMaui><TargetFramework>属性。

要完成迁移此项目文件,删除所有<ItemGroup>元素及其内容,除了包含<AndroidResource>元素的组。最后,删除项目文件底部的<Import>元素;它不再需要。

接下来要查看的项目类型是 iOS 项目。这里的更改将与 Android 项目的更改非常相似,但带有 iOS 特色。Xamarin.Forms iOS 项目也是一个.NET Framework 风格的项目,因此我们需要将<Project …>元素更改为以下内容:

<Project Sdk="Microsoft.NET.Sdk">

然后,我们需要删除所有现有的<PropertyGroup>元素,并添加以下内容:

<PropertyGroup>
  <UseMaui>true</UseMaui>
  <TargetFramework>net7.0-ios</TargetFramework>
  <OutputType>Exe</OutputType>
  <ImplicitUsings>enable</ImplicitUsings>
  <SupportedOSPlatformVersion 
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$
(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
</PropertyGroup>

再次,你会注意到<UseMaui>属性被设置,并且<TargetFramwork>的值是net7.0-ios

要完成 iOS 项目的转换,删除除包含<ProjectReference>条目的<ItemGroup><Import>元素之外的所有元素;这些仍然需要。

将 Xamarin.Forms 代码更新到.NET MAUI

将代码从 Xamarin.Forms 更新到 .NET MAUI 包含几个步骤。首先,我们需要添加一些初始化 .NET MAUI 所必需的新代码。有关所需文件的更多详细信息,请参阅 第二章检查文件部分

您需要做的第一个更改是在共享项目中。添加一个名为 MauiProgram.cs 的新类文件,内容至少如下所示:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", 
"OpenSansRegular");
             });
        return builder.Build();
    }
}

您可能需要向此文件添加额外的代码,以便它可以处理您应用程序的额外需求,例如注册类型以进行依赖注入或注册额外的库功能,例如日志记录。

Platforms/Android 文件夹中,添加一个名为 MainApplication.cs 的新类文件,并更新类,使其看起来像这样:

[Application]
public class MainApplication : MauiApplication
{
    public MainApplication(IntPtr handle, JniHandleOwnership ownership)
    : base(handle, ownership)
    {
    }
    protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

我们还必须对 MainActivity 类进行一些调整。打开 MainActivity.cs 文件,并对以下代码中突出显示的部分进行更改:

[Activity(Label = "ManualMigration", Icon = "@mipmap/icon", 
Theme = "@style/Theme.MaterialComponents", MainLauncher = true, 
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.
Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | 
ConfigChanges.SmallestScreenSize )]
public class MainActivity : MauiAppCompatActivity
{
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
    }
targetSdkVersion in the AndroidManifest.xml file to version 33, like this:


 Android SDK versions
Since the Android SDK is updated yearly, it may be the case that the version of .NET for Android and .NET MAUI that you are using is also using a version of the Android SDK that is greater than `33`. The good news is that you will get an error in Visual Studio if `targetSdkVersion` is too low or is not installed. Just follow the instructions in the build error to set the SDK version correctly.
That is all the changes we need to make in the Android project for now. Moving on to the iOS project, the `AppDelegate.cs` file can be updated so that it matches the following:

public partial class AppDelegate : MauiUIApplicationDelegate

{

protected override MauiApp CreateMauiApp() => MauiProgram.

CreateMauiApp();

}


 The final change for the iOS project is to open the `Info.plist` file and change the `MinimumOSVersion` property to `15.2`.
With the base changes needed to start your app as a .NET MAUI app done, the next changes are much broader brush strokes:

1.  Remove all `Xamarin.*` namespaces from `.``cs` files.
2.  Change all `xaml` namespace declarations from the following:

    You will need to amend them like so:

You may notice that these are the same changes we made in the previous section when using a .NET MAUI Single Project.
About images
.NET MAUI has improved image handling for the various platforms that it targets. You can provide a single SVG image file, and it will resize the image correctly for all platforms for you. Since the SVG format is based on vectors, it will render much better than other formats such as JPG and PNG after resizing. It is recommended that you convert your images into SVG format, if possible, to take advantage of this feature in .NET MAUI.
Updating any incompatible NuGet packages
There are a lot of NuGet packages out there and there is no way we can cover them all. But, in general, for each of the NuGet packages that are in use in your app, be sure to look for a version that specifically supports .NET MAUI or the version of .NET that you are targeting. You can use the NuGet Gallery page to determine whether a package supports .NET MAUI. Using a popular package such as PCLCrypto version 2.0.147 ([`www.nuget.org/packages/PCLCrypto/2.0.147#supportedframeworks-body-tab`](https://www.nuget.org/packages/PCLCrypto/2.0.147#supportedframeworks-body-tab)) targets classic Xamarin projects but not .NET 6 or .NET 7\. You can find the compatible frameworks under the **Frameworks** tab:
![Figure 3.13 – NuGet Gallery page for PCLCrypto v2.0.147](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_13.jpg)

Figure 3.13 – NuGet Gallery page for PCLCrypto v2.0.147
However, version 2.1.40-alpha ([`www.nuget.org/packages/PCLCrypto/2.1.40-alpha#supportedframeworks-body-tab`](https://www.nuget.org/packages/PCLCrypto/2.1.40-alpha#supportedframeworks-body-tab)) lists .NET 6 and .NET 7 as compatible frameworks:
![Figure 3.14 – NuGet Gallery page for PCLCrypto v2.1.40-alpha](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_14.jpg)

Figure 3.14 – NuGet Gallery page for PCLCrypto v2.1.40-alpha
Currently, we know of the following NuGet changes:

*   Remove all Xamarin.Forms and Xamarin.Essentials NuGet references from your projects. These are now included in .NET MAUI directly. You will have to make some namespace adjustments as those have changed.
*   Replace Xamarin.Community Toolkit with the latest preview of .NET MAUI Community Toolkit. You will have to make some namespace adjustments as those have changed.
*   If you reference any of the following SkiaSharp NuGet packages directly, replace them with the latest previews:
    *   **SkiaSharp.Views.Maui.Controls**
    *   **SkiaSharp.Views.Maui.Core**
    *   **SkiaSharp.Views.Maui.Controls.Compatibility**

You can find the latest version of NuGet packages on the NuGet Gallery website at [`nuget.org/packages`](https://nuget.org/packages).
Addressing any breaking API changes
Unfortunately, there is no magic bullet for any of these types of changes. You will simply have to start from the top of your error list and work your way through them. You can review the release notes linked in the official migration guide, available at [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/), as helpful hints.
For example, a common type of error that’s seen is `error CS0104: 'ViewExtensions' is an ambiguous reference between 'Microsoft.Maui.Controls.ViewExtensions' and 'Microsoft.Maui.ViewExtensions'`. This can be fixed by explicitly using the full namespace when referencing the type or by using a type alias – for example, `using ViewExtensions =` `Microsoft.Maui.Controls.ViewExtensions`.
Custom renderers and effects
Your application may use custom renderers or effects to provide a unique user experience. Covering how to upgrade these components is beyond the scope of this chapter. To learn more about how to upgrade renderers and effects, visit the Microsoft Learn site for .NET MAUI migration at [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/).
Running the converted app and verifying its functionality
This is not the last step – I recommend that you attempt to do this after each change. Building your app as you make changes ensures that you are moving in the right direction. I recommend that you `obj` and `bin` folders beforehand. This will ensure that you are building with the latest changes and dependencies, by forcing a NuGet restore.
Now that we know the basics of how to convert a Xamarin.Forms app, let’s use .NET Upgrade Assistant to migrate a project for us.
Installing and running .NET Upgrade Assistant
As stated previously, .NET Upgrade Assistant will attempt to perform the first four steps of the migration of your Xamarin.Forms app to .NET MAUI outlined in the previous section. The tool is under active development and as the team discovers new improvements, they are added. This is mostly due to feedback they receive from developers like you.
At the time of writing, .NET Upgrade Assistant did not work on all projects and has the following limitations:

*   Xamarin.Forms must be version 5.0 and higher
*   Only Android and iOS projects are converted
*   .NET MAUI must be properly installed with the appropriate workloads

If you have followed the steps from *Chapter 1*, then the last should should already be satisfied.
If your Xamarin.Forms app meets these criteria, then we can get started by installing the tool.
Installing .NET Upgrade Assistant
.NET Upgrade Assistant is a Visual Studio extension on Windows and a command-line tool on Windows and macOS. You can use the integrated developer PowerShell in Visual Studio or any command-line prompt to install the tool. Follow these steps to install the tool using Visual Studio on Windows:

1.  In Visual Studio, select the **Extensions** menu, then the **Manage Extensions** item. This will open the **Manage** **Extensions** dialog.
2.  In the `upgrade`.
3.  Select **.NET Upgrade Assistant** and click **Download**:

![Figure 3.15 – Visual Studio – the Manage Extensions dialog](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_15.jpg)

Figure 3.15 – Visual Studio – the Manage Extensions dialog

1.  Once the extension has been downloaded, you will need to close and reopen Visual Studio to install the extension:

![Figure 3.16 – Installing the .NET Upgrade Assistant VSIX](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_16.jpg)

Figure 3.16 – Installing the .NET Upgrade Assistant VSIX

1.  Click **Modify** and then follow the instructions to complete the installation.
2.  Once the installation is complete, reopen Visual Studio.

Now that the tool has been installed, we can use it to convert a Xamarin.Forms project!
Preparing to run .NET Upgrade Assistant
For the remainder of this chapter, we will be using Visual Studio on Windows to migrate a Xamarin.Forms app to .NET MAUI.
To run .NET Upgrade Assistant, we will need a Xamarin.Forms project to upgrade. For this portion of the chapter, we will use the Xamarin.Forms app that was demoed at the Microsoft Build conference in 2019\. The source can be found at [`github.com/mindofai/Build2019Chat`](https://github.com/mindofai/Build2019Chat), though you can find it in this book’s GitHub repository at [`github.com/PacktPublishing/MAUI-Projects-3rd-Edition`](https://github.com/PacktPublishing/MAUI-Projects-3rd-Edition) under the `Chapter03/Build2019Chat` folder.
Once you have downloaded the source, open the `BuildChat.sln` file in Visual Studio. Once the project has finished loading, make sure your configuration is correct by running the app first. You should see a screen that looks like this on Android:
![Figure 3.17 – The original app on Android](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_17.jpg)

Figure 3.17 – The original app on Android
Now that we have confirmed that the original app runs, we can follow these steps to prepare for running .NET Upgrade Assistant:

1.  Right-click the `BuildChat` solution node in **Solution Explorer** and select **Manage NuGet Packages** **for Solution…**:

![Figure 3.18 – Solution context menu](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_18.jpg)

Figure 3.18 – Solution context menu

1.  In the **NuGet – Solution** window that opens, select the **Updates** tab:

![Figure 3.19 – The NuGet – Solution window](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_19.jpg)

Figure 3.19 – The NuGet – Solution window

1.  Click the **Select all packages** checkbox, then click **Update**.
2.  Visual Studio will prompt you with a preview of all the changes that will be made. Click **OK** once you have reviewed them.
3.  Visual Studio will then prompt you to accept the license terms for packages that have them. Once you have reviewed the license terms, click **I Accept**.
4.  After updating, you may still have a **gold bar** indicator in the Visual Studio window from running the application earlier. You can safely dismiss the message by clicking the **X** button on the right:![Figure 3.20 – Xamarin.Forms version gold bar](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_20.jpg)

Figure 3.20 – Xamarin.Forms version gold bar

1.  Once the packages have been updated, let’s make sure the app is still working by running it again. You should get a build error like the following:

![Figure 3.21 – Error after upgrading packages](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_21.jpg)

Figure 3.21 – Error after upgrading packages

1.  To resolve this error, in `BuildChat.Android` project, then press *Alt* and *Enter* at the same time to open the project properties page.
2.  Use the `Android 8.1 (Oreo)` to `Android 10.0`.

Google Play support
You may get a warning about Google Play requiring new apps and updates to support a specific version of Android. To remove that warning, just set **Target Framework** to the version indicated in the warning message.

1.  Visual Studio will prompt you to confirm the change as it has to close and re-open the project. Select **Yes**.
2.  Visual Studio may also prompt you to install the Android version if you haven’t installed it. Follow the prompts to install the Android version.
3.  Attempting to run the project again yields a new set of errors:

![Figure 3.22 – Missing packages error](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_22.jpg)

Figure 3.22 – Missing packages error

1.  To resolve this error, right-click the `BuildChat.Android` project and select **Unload Project**. The project file should open in Visual Studio automatically.
2.  Locate `<ItemGroup>` in the file with `<PackageReference>` items and make the changes highlighted in the following snippet:

    ```

    <ItemGroup>

    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client">

    <Version>7.0.9</Version>

    </PackageReference>

    <PackageReference Include="Xamarin.Forms" Version="5.0.0.2578" />

    <PackageReference Include="Xamarin.Android.Support.Design" Version="28.0.0.3" />

    <PackageReference Include="Xamarin.Android.Support.v7.AppCompat" Version="28.0.0.3" />

    <PackageReference Include="Xamarin.Android.Support.v4" Version="28.0.0.3" />

    <PackageReference Include="Xamarin.Android.Support.v7.CardView" Version="28.0.0.3" />

    <PackageReference Include="Xamarin.AndroidX.MediaRouter" Version="1.2.0" />

    <PackageReference Include="Xamarin.AndroidX.Palette" Version="1.0.0.5" />

    </ItemGroup>

    ```cs

    You could also use Visual Studio’s NuGet Package Manager to add these packages.

     3.  Save and reload the project before trying to run it again. Since the project was unloaded, you will need to set the `BuildChat.Android` project as the startup project again.

You should be able to run the application at this time since some warnings can be ignored. If not, review the previous steps to make sure you made all the changes correctly. At this point, we are ready to run the upgrade assistant to convert from Xamarin.Forms into .NET MAUI.
Treat warnings as errors
If you have the project option to treat warnings as errors set to anything other than none, then the warnings will prevent you from running the app. Set the option to none to allow the app to run. The option defaults to none.
Running .NET Upgrade Assistant
Running .NET Upgrade Assistant from within Visual Studio is a straightforward process. We will upgrade each project individually; there isn’t any method to upgrade all the projects in one go.
Upgrading the BuildChat project
Let’s start with the shared project, `BuildChat`, by following these steps:

1.  Select the `BuildChat` project in **Solution Explorer**.
2.  Use the context menu to select the **Upgrade** menu item:

![Figure 3.23 – Upgrading the BuildChat project](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_23.jpg)

Figure 3.23 – Upgrading the BuildChat project
This will open the **Upgrade** assistant in a document window:
![Figure 3.24 – Upgrading the BuildChat project](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_24.jpg)

Figure 3.24 – Upgrading the BuildChat project

1.  Select the **In-place project** **upgrade** option.
2.  Depending on the versions of .NET you have installed, you will be prompted to choose one. If you followed the setup instructions in *Chapter 1*, you should have the .NET 7.0 option available. Select **.NET 7.0** and select **Next**:

![Figure 3.25 – Choosing the preferred target framework](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_25.jpg)

Figure 3.25 – Choosing the preferred target framework

1.  At this point, you are allowed to review the changes that will be made by expanding each node in the list. You can also choose to not upgrade certain items by removing the check in the checkbox next to that item. When you have inspected all the changes, make sure all items are checked again, then click **Upgrade selection**:

![Figure 3.26 – Reviewing the upgrade](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_26.jpg)

Figure 3.26 – Reviewing the upgrade

1.  Visual Studio will start the upgrade process. You can monitor it as it completes each item:

![Figure 3.27 – Upgrade in progress](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_27.jpg)

Figure 3.27 – Upgrade in progress

1.  When it’s finished, you can inspect each item to see what the result of the upgrade was:

![Figure 3.28 – Upgrade complete](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_28.jpg)

Figure 3.28 – Upgrade complete
A white check in a green circle indicates some transformation was completed and successful, a green check with a white background means the step was skipped since nothing was needed, and a red cross (not shown) means the transformation failed. You can view the complete output from the tool by inspecting the **Upgrade Assistant** log in the output pane:
![Figure 3.29 – Upgrade Assistant log output](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_29.jpg)

Figure 3.29 – Upgrade Assistant log output
Do not be concerned with the errors in the error window at this point. There will be errors until we finish upgrading the remaining projects. Now that the `BuildChat` project has been upgraded, we can upgrade the `BuildChat.Android` project.
Upgrading the BuildChat.Android project
The steps for upgrading the remaining projects are largely the same – the only difference will be the steps involved in upgrading each project. The next two sections will skip the screenshots and just provide the steps. To complete the upgrade for the `BuildChat.Android` project, follow these steps:

1.  Select the **BuildChat.Android** project in **Solution Explorer**.
2.  Use the context menu to select the **Upgrade** menu item.
3.  This will open the **Upgrade** assistant in a document window.
4.  Select the **In-place project** **upgrade** option.
5.  Select the **.NET 7.0** option, then select **Next**.
6.  Review the changes that will be made by expanding each node in the list. Make sure all items are checked, then click **Upgrade Selection**.
7.  Visual Studio will complete the upgrade process.

Now that .NET Upgrade Assistant has completed the `BuildChat.Android` project, we can upgrade the `BuildChat.iOS` project.
Upgrading the BuildChat.iOS project
The steps for upgrading the iOS project are largely the same – the only difference will be the steps involved in upgrading each project. To complete the upgrade for the `BuildChat.iOS` project, follow these steps:

1.  Select the `BuildChat.iOS` project in **Solution Explorer**.
2.  Use the context menu to select the **Upgrade** menu item.
3.  This will open the **Upgrade** assistant in a document window:
4.  Select the **In-place project** **upgrade** option.
5.  Select the **.NET 7.0** option, then select **Next**.
6.  Review the changes that will be made by expanding each node in the list. Make sure all items are checked, then click **Upgrade Selection**.
7.  Visual Studio will complete the upgrade process.

Now that .NET Upgrade Assistant has completed the `BuildChat.iOS` project, we can see how well it worked.
Completing the upgrade to .NET MAUI
With .NET Upgrade Assistant having done all the work it can to upgrade the projects, we can now see what is left for us to complete the upgrade to .NET MAUI.
The first thing we want to do is make sure that the project is clean of all the previous build artifacts. This will ensure we are referencing all the right dependencies in our build output by forcing a restore and build. The best way to accomplish this is to remove the `bin` and `obj` folders from each project folder.
Use `bin` and `obj` folders from the `BuildChat`, `BuildChat.Android` and `BuildChat.iOS` folders, then build the solution.
We’ll end up with a few build errors for each project, as shown in the following figure:
![Figure 3.30 – Package issues](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_30.jpg)

Figure 3.30 – Package issues
To resolve these errors, either use Visual Studio’s NuGet Package Manager to add a reference to version 7.0.1 of the `Microsoft.Extensions.Logging.Abstractions` package to all the projects, or follow these steps to update the project files manually:

1.  Select the `BuildChat` project in **Solution Explorer**.

    The project file will open in a document window automatically.

2.  Locate the `ItemGroup` element that contains the `PackageReference` items.
3.  Make the changes highlighted in the following snippet:

    ```

    <ItemGroup>

    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />

    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />

    </ItemGroup>

    ```cs

     4.  Select the `BuildChat.Android` project in **Solution Explorer**.

    The project file will open in a document window automatically.

5.  Locate the `ItemGroup` element that contains the `PackageReference` items.
6.  Make the changes highlighted in the following snippet:

    ```

    <ItemGroup>

    <PackageReference Include="Xamarin.AndroidX.MediaRouter" Version="1.2.0" />

    <PackageReference Include="Xamarin.AndroidX.Palette" Version="1.0.0.5" />

    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />

    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />

    </ItemGroup>

    ```cs

     7.  Select the `BuildChat.iOS` project in **Solution Explorer**.

    The project file will open in a document window automatically.

8.  Locate the `ItemGroup` element that contains the `PackageReference` items.
9.  Make the changes highlighted in the following snippet:

    ```

    <ItemGroup>

    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />

    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />

    </ItemGroup>

    ```cs

Now that we have added the required package references, we can try building the app again. After this build, we’ll get two new errors:
![Figure 3.31 – Namespace does not exist errors](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_31.jpg)

Figure 3.31 – Namespace does not exist errors
These two errors show two areas that the upgrade assistant did not upgrade. Luckily, we covered how to upgrade these two files easier in this chapter. Let’s upgrade them again, starting with the `BuildChat.Android` project.
Open the `MainActivity.cs` file and make the changes highlighted in the following code:

使用 Microsoft.Maui;

命名空间 BuildChat.Droid

{

[Activity(Label = "BuildChat", Icon = "@mipmap/icon", Theme = "@style/Theme.MaterialComponents", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]

public class MainActivity : MauiAppCompatActivity

{

protected override void OnCreate(Bundle savedInstanceState)

{

base.OnCreate(savedInstanceState);

}

}

}


 That should complete the changes needed for the Android project. Now, to upgrade the iOS project, open the `AppDelegate.cs` file in `BuildChat.iOS` and update it so that it matches the following:

使用 Foundation;

使用 Microsoft.Maui;

using Microsoft.Maui.Hosting;

namespace BuildChat.iOS

{

// 应用的 UIApplicationDelegate。此类负责启动

// 应用的用户界面,以及监听(并可选地响应)

// 从 iOS 接收的应用事件。

[Register("AppDelegate")]

public partial class AppDelegate : MauiUIApplicationDelegate

{

protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();

}

}


 The final change for the iOS project is to open the `Info.plist` file and change the `MinimumOSVersion` property to `15.2`. To make this change, use `Info.plist` file and make this change, follow these steps:

1.  Select the `Info.plist` file in the `BuildChat.iOS` project.
2.  Use the context menu (right-click) and select **Open With…**.
3.  In the **Open With** dialog, select **Generic Plist Editor**, then select **OK**:

![Figure 3.32 – Opening the Info.plist file](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_32.jpg)

Figure 3.32 – Opening the Info.plist file

1.  Find the entry labeled `Minimum system version` and change the value from `8.0` to `15.1`:

![Figure 3.33 – Changing the minimum system version](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_33.jpg)

Figure 3.33 – Changing the minimum system version
Great – that should complete the changes needed to get the app running as a .NET MAUI application! The following are the before and after screenshots of the application; *before* is on the left and *after* is on the right:
![](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_03_34.jpg)

Figure 3.34 – Xamarin.Forms versus .NET MAUI
There are some visual changes between Xamarin.Forms and .NET MAUI, and you can tweak the .NET MAUI settings for the layouts and controls to get a very similar output.
Summary
In this chapter, we focused on upgrading a Xamarin.Forms app to .NET MAUI. We learned how to upgrade the project files from .NET Framework to SDK-style projects, application startup files, XAML views, and C# files needed for .NET MAUI. We started by doing this manually to learn about all the required steps and changes. We ended this chapter by using .NET Upgrade Assistant to make many of the changes for us. We also learned how to upgrade to the Single Project format, which is the default for .NET MAUI. Now, we can pick the best method for the project we are upgrading.
While we covered a lot in this chapter, it was not exhaustive. There is a lot of variation in different projects, from NuGet package dependencies and vendor-provided controls to customizations using renderers and effects. The app you are upgrading may use one or all of these, and you can find additional help on the Microsoft Learn site for upgrading Xamarin.Forms to .NET MAUI: [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/).
If you are interested in seeing the `BuildChat` app fully functional, try using .NET Upgrade Assistant on the service that was also built for the 2019 Microsoft Build conference. You can find the source on GitHub at [`github.com/mindofai/SignalRChat/tree/master`](https://github.com/mindofai/SignalRChat/tree/master). You could also use ChatGPT to help you build the service yourself using Azure Functions and SignalR.
In the next chapter, we will build an app that displays news articles using the new .NET MAUI Shell.


第二部分:基本项目

在这部分,你将学习.NET MAUI 的特性,例如 Shell、CollectionView、Image、Button、Label、CarouselView、Grid、自定义控件和手势。你将探索使用位置服务、调用自定义 Web API 以及为不同形态设计你的 XAML。

本部分包含以下章节:

  • 第四章使用.NET MAUI Shell 构建新闻应用

  • 第五章使用动画构建具有丰富 UX 的匹配应用

  • 第六章使用 CollectionView 和 CarouselView 构建照片库应用

  • 第七章使用 GPS 和地图构建位置跟踪应用

  • 第八章为多种形态构建天气应用

第四章:使用 .NET MAUI Shell 构建新闻应用

在本章中,我们将创建一个利用微软 .NET MAUI 团队提供的 Shell 导航功能构建的新闻应用。我们之前使用 ContentPageFlyoutPageTabbedPageNavigationPage 作为主页的方法仍然有效,就像我们在 第二章 中所做的那样,但我们确信您会喜欢定义应用程序结构的新方法。此外,您还可以混合使用新旧方法。

到本章结束时,您将学习如何使用 Shell 定义应用程序结构,从 REST API 消费数据,配置导航,以及使用查询样式路由在视图之间传递数据。

那么,Shell 是什么呢?在 Shell 中,您使用 可扩展应用程序标记语言XAML)来定义您应用程序的结构,而不是将其隐藏在应用程序中分散的代码片段中。您还可以使用路由进行导航,就像那些花哨的网页开发者所做的那样。

本章将涵盖以下主题:

  • 定义 Shell 导航页面

  • 创建飞出菜单

  • 创建导航栏

  • 使用路由进行导航并在查询字符串中传递数据

  • 从公共 表示状态传输REST应用程序编程接口API)消费数据

  • CollectionView 控件的形式添加内容

技术要求

要完成这个项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载组件。有关如何设置环境的更多详细信息,请参阅 第一章.NET MAUI 简介

您可以在 github.com/PacktPublishing/MAUI-Projects-3rd-Edition 找到本章的源代码。

项目概述

我们将使用 单项目 功能作为代码共享策略来创建一个 .NET MAUI 项目。它将包含以下两个部分:

  • 在第一部分,我们将创建视图并使用 Shell 使它们可导航。

  • 在第二部分,我们将通过消费新闻的 REST API 来添加一些内容

第二部分不是学习 Shell 所必需的,但它将使您在构建完整应用程序的道路上更进一步。

本项目的构建时间大约为 1.5 小时。

构建新闻应用

本章将从头开始构建新闻应用。它将指导您完成每个步骤,但不会深入每个细节。为此,我们建议阅读 第二章构建我们的第一个 .NET MAUI 应用程序,其中包含更多详细信息。

开心编码!

设置项目

与所有其他项目一样,本项目是一个 文件 | 新建 | 项目... 风格的项目。这意味着我们根本不会导入任何代码。因此,本节全部关于创建项目和设置基本项目结构。

创建新项目

第一步是创建一个新的 .NET MAUI 项目:

  1. 打开 Visual Studio 2022 并选择 创建一个 新项目

图 4.1 – Visual Studio 2022

图 4.1 – Visual Studio 2022

这将打开 创建一个新项目 向导。

  1. 在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项:

图 4.2 – 创建一个新项目

图 4.2 – 创建一个新项目

  1. 点击 下一步

  2. 如下截图所示,输入 News 作为应用程序的名称:

图 4.3 – 配置您的新的项目

图 4.3 – 配置您的新的项目

  1. 点击 下一步

  2. 最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用作为 长期支持LTS),而 .NET 7 可用作为 标准期限支持。对于本书,我们假设您将使用 .NET 7:

图 4.4 – 其他信息

图 4.4 – 其他信息

  1. 通过点击 创建 并等待 Visual Studio 创建项目来完成设置。

项目创建到此结束。

让我们继续设置应用程序的结构。

创建应用程序的结构

在本节中,我们将开始构建应用程序的 视图ViewModel第二章 中的 使用 MVVM – 创建视图和 ViewModel 部分包含有关 模型-视图-ViewModelMVVM)作为设计模式的更多详细信息。如果您不知道 MVVM 是什么,建议您先阅读。

创建 ViewModel 基类

ViewModelViewModel 之间的中介。让我们创建一个具有常见功能的基础类 ViewModels,我们可以重用它。在实践中,ViewModel 必须实现一个名为 INotifyPropertyChanged 的接口,以便 MVVM 能够运行。我们将在基类中这样做,并添加一个名为 CommunityToolkit.Mvvm 的小巧助手工具,这将为我们节省大量时间。如果您对 MVVM 感到不确定,请再次查看 第二章构建我们的第一个 .NET MAUI 应用

第一步是创建一个基类。按照以下步骤操作:

  1. News 项目中,创建一个名为 ViewModels 的文件夹。

  2. ViewModels 文件夹中,创建一个名为 ViewModel 的类。

  3. 将现有类更改为以下样子:

    namespace News.ViewModels;
    public abstract class ViewModel
    {
    }
    

太棒了!让我们在基 ViewModel 类中实现 INotifyPropertyChanged

CommunityToolkit.Mvvm 快速回顾

CommunityToolkit.Mvvm 是一个包含几个源生成器的 NuGet 包,这些生成器可以自动生成 INotifyPropertyChanged 所需的实现细节。更具体地说,它将注入一个调用,每当调用设置器时都会引发 PropertyChanged 事件。它还负责属性依赖关系;如果更改 FirstName 属性,只读属性 FullName 也会收到一个 PropertyChanged 事件。在 CommunityToolkit.Mvvm 之前,您将不得不手动编写此代码。

更详细的内容请参阅 第二章构建我们的第一个 .NET MAUI 应用程序。你读过它了吗?

添加对 CommunityToolkit.Mvvm 的引用

CommunityToolkit.Mvvm 及其依赖项使用 NuGet 安装。因此,让我们安装 NuGet 包:

  1. News 项目中,安装 CommunityToolkit.Mvvm NuGet 包,版本 8.0.0。

  2. 接受任何许可对话框。

这将安装相关的 NuGet 包。

实现 INotifyPropertyChanged

ViewModel 位于 ViewModel 之间。当 ViewModel 发生变化时,View 必须被通知。这种机制的实现是 INotifyPropertyChanged 接口,它定义了一个 View 控件订阅的事件。ObservableObject 属性是生成我们 INotifyPropertyChanged 实现的魔法。按照以下步骤进行:

  1. News 项目中,打开 ViewModels.cs

  2. 在粗体中添加以下代码:

    using CommunityToolkit.Mvvm.ComponentModel;
    [ObservableObject]
    public abstract partial class ViewModel
    {
    }
    

这指示 CommunityToolkit.Mvvm 实现了 INotifyPropertyChanged 接口。下一步是减少我们将要编写的代码行数。通常,您需要手动从您的代码中引发 PropertyChanged 事件,但多亏了在构建时编写代码的源生成器,我们只需创建常规属性,让 CommunityToolkit.Mvvm 做出魔法。

让我们继续前进,创建我们的第一个 ViewModel

创建 HeadlinesViewModel 类

现在,我们将开始创建一些 ViewViewModel 占位符,我们将在本章中对其进行扩展。我们不会直接实现所有图形功能;相反,我们将保持简单,并将所有这些页面视为未来内容的占位符。

第一个是 HeadlinesViewModel 类,它将作为 HeadlinesViewViewModel。按照以下步骤进行:

  1. News 项目中,在 ViewModels 文件夹下,创建一个名为 HeadlinesViewModel 的新类。

  2. 编辑类,使其从以下粗体代码片段中的 ViewModel 基类继承:

    namespace News.ViewModels;
    public class HeadlinesViewModel : ViewModel
    {
        public HeadlinesViewModel()
        {
        }
    }
    

好的 – 不坏。它现在还没有做什么,但我们先这样吧。让我们创建匹配的视图。

创建 HeadlinesView

这个视图最终将显示新闻列表,但到目前为止,它将保持简单。按照以下步骤创建页面:

  1. News 项目中,创建一个名为 Views 的文件夹。

  2. 右键点击 Views 文件夹,选择 添加,然后点击 新建项...

    如果您使用的是 Visual Studio 17.7 或更高版本,请点击弹出的对话框中的 显示所有模板 按钮。否则,继续下一步。

  3. 在左侧的 C# 项 节点下,选择 .NET MAUI

  4. 选择 HeadlinesView

  5. 点击 添加 创建页面。

    参考以下截图查看上述信息:

图 4.5 – 添加新项

图 4.5 – 添加新项

让我们在HeadlinesView中添加一些占位符代码,以便有东西可以导航到和从。我们将在本章稍后用更热的东西替换它,但为了保持简单,让我们添加一个标签。要这样做,请按照以下步骤进行:

  1. News项目中,在Views文件夹下,打开HeadlinesView.xaml

  2. 通过添加以下加粗代码来编辑 XAML 代码:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage 
    
        x:Class="News.Views.HeadlinesView"
        Title="Home">
        <VerticalStackLayout>
            <Label
                Text="HeadLinesView!"
                VerticalOptions="Center"
                HorizontalOptions="Center" />
        </VerticalStackLayout>
    </ContentPage>
    

这将设置页面的标题并在页面中间添加一个带有文本HeadlinesView的标签。让我们继续并创建一些额外的视图占位符。

创建 ArticleItem

应用程序最终将显示一系列文章,其中每篇文章都将使用一个可重用组件进行渲染。我们将把这个可重用组件称为ArticleItem。在.NET MAUI 中,一个可重用组件被称为ContentView。请不要将其与表示为.NET MAUI 中页面的 MVVM View 混淆。我们知道这很令人困惑,但规则是.NET MAUI 页面是一个 MVVM View,而.NET MAUI ContentView 基本上是一个可重用控件。

话虽如此,让我们创建ArticleItem类,如下所示:

  1. News项目中,右键单击Views文件夹,选择添加,然后点击新建项...

    如果你使用的是 Visual Studio 17.7 或更高版本,请点击弹出对话框中的显示所有模板按钮。否则,继续下一步。

  2. 在左侧的C# Items节点下,选择.NET MAUI

    重要:确保在下一步中选择ContentView模板,而不是ContentPage模板。

  3. 选择ArticleItem

  4. 点击添加来创建视图。

参考以下截图查看上述信息:

图 4.6 – 添加新项 – ArticleItem.xaml

图 4.6 – 添加新项 – ArticleItem.xaml

目前我们不需要修改生成的 XAML 代码,所以我们将其保持原样。

创建 ArticleView

在上一节中,我们创建了ArticleItem内容视图。这个视图(ArticleView)将包含WebView以显示每篇文章。但到目前为止,我们只需将ArticleView作为一个占位符添加。按照以下步骤进行操作:

  1. News项目中,右键单击Views文件夹,选择添加,然后点击新建项...

    如果你使用的是 Visual Studio 17.7 或更高版本,请点击弹出对话框中的显示所有模板按钮。否则,继续下一步。

  2. 在左侧的C# Items节点下选择.NET MAUI

  3. 选择ArticleView

  4. 点击添加来创建页面。

由于这个视图目前也是一个占位符视图,我们只需添加一个标签来指示页面的类型。按照以下步骤编辑内容:

  1. News项目中,打开ArticleView.xaml

  2. 通过添加以下加粗代码来编辑 XAML 代码:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage 
    
        x:Class="News.Views.ArticleView"
        Title="ArticleView">
        <VerticalStackLayout>
            <Label
                Text="ArticleView!"
                VerticalOptions="Center"
                HorizontalOptions="Center" />
        </VerticalStackLayout>
    </ContentPage>
    

好的 – 在我们开始连接东西之前,还有一个视图需要模拟。

创建 AboutView

最后一个视图将以与其他所有视图相同的方式进行创建。按照以下步骤进行:

  1. News项目中,右键单击Views文件夹,选择添加,然后点击新建项...

  2. 在左侧的C# 项目节点下,选择.****NET MAUI

  3. 选择AboutView

  4. 点击添加以创建页面。

这种视图是唯一会保留为占位符视图的视图。如果你选择在以后从这个项目中构建一些酷炫的东西,那么就需要你来处理它。因此,我们只会添加一个标签来声明这是一个AboutView

  1. News项目中,打开AboutView.xaml

  2. 通过添加以下加粗代码来编辑 XAML 代码:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage 
    
        x:Class="News.Views.AboutView"
        Title="AboutView">
        <VerticalStackLayout>
            <Label
                Text="AboutView!"
                VerticalOptions="Center"
                HorizontalOptions="Center" />
        </VerticalStackLayout>
    </ContentPage>
    

这样,我们就有了开始连接应用所需的所有视图。第一步是配置依赖注入。

连接依赖注入

通过使用依赖注入作为模式,我们可以使我们的代码更干净、更易于测试。这个应用将使用构造函数注入,这意味着一个类所拥有的所有依赖都必须通过其构造函数传递。容器随后为你构建对象,因此你不需要过多关注依赖链。由于.NET MAUI 已经包含了一个名为 Microsoft.Extensions.DependencyInjection 的依赖注入框架,因此不需要安装任何额外的东西。

对依赖注入感到困惑?

第二章构建我们的第一个.NET MAUI 应用中查看连接依赖注入部分,以获取有关依赖注入的更多详细信息。

使用依赖注入注册视图和 ViewModel

在使用容器注册类时,建议使用扩展方法来分组类型。扩展方法将接受一个参数并返回一个值,即MauiAppBuilder实例。这就是CreateMauiApp方法的工作方式。对于这个应用,我们现在需要注册ViewsViewModels。让我们创建这个方法:

  1. News项目中,打开MauiProgram.cs文件。

  2. MauiProgram类进行以下更改;更改已在代码中突出显示:

    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                })
            .RegisterAppTypes();
            return builder.Build();
        }
        public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
        {
            // ViewModels
            mauiAppBuilder.Services.AddTransient<ViewModels.
            HeadlinesViewModel>();
            // Views
            mauiAppBuilder.Services.AddTransient<Views.AboutView>();
            mauiAppBuilder.Services.AddTransient<Views.
    ArticleView>();
            mauiAppBuilder.Services.AddTransient<Views.
    HeadlinesView>();
            return mauiAppBuilder;
        }
    }
    

.NET MAUI 的MauiAppBuilder类公开了Services属性,它是依赖注入容器。我们只需要添加我们想要依赖注入了解的类型;容器会为我们完成剩下的工作。顺便说一句,将构建器想象成收集了大量需要完成的信息的东西,然后最终构建我们需要的对象。它本身就是一个非常有用的模式。

目前我们只使用构建器来做一件事。稍后,我们将使用它来注册任何从我们的抽象ViewModel类继承的类。容器现在已为我们准备好请求这些类型。

现在,我们需要对我们的应用做一些图形上的调整。我们将依靠Font Awesome来完成魔法。

下载和配置 Font Awesome

Font Awesome 是一个免费集合,将图像打包成字体。.NET MAUI 在工具栏、导航栏以及各个地方使用 Font Awesome 方面有出色的支持。虽然制作这个应用程序并不严格需要它,但我们认为额外的往返是值得的,因为你很可能在你的新杀手级应用程序中需要类似的东西。

第一步是下载字体。

下载 Font Awesome

下载字体很简单。请注意文件的重命名——这不是必需的,但如果文件名更简单,编辑配置文件等会更容易。按照以下步骤获取并复制字体到每个项目中:

  1. 浏览到fontawesome.com/download

  2. 点击Free for Desktop按钮下载 Font Awesome。

  3. 解压下载的文件,然后找到otfs文件夹。

  4. Font Awesome 5 Free-Solid-900.otf文件重命名为FontAwesome.otf(你可以保留原始名称,但如果重命名,输入会更少)。由于 Font Awesome 不断更新,你的文件名可能不同,但应该类似。

  5. FontAwesome.otf复制到News项目的Resources/Fonts文件夹中。

好的——现在,我们需要将 Font Awesome 注册到.NET MAUI 中。

配置.NET MAUI 以使用 Font Awesome

如果我们只需要将字体文件复制到项目文件夹中那就太好了。仅仅这一步就会发生很多事情。默认的.NET MAUI 模板在News.csproj文件中包含了Resources/Fonts文件夹中的所有字体,其项目定义如下:

<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />

这确保了字体文件会被处理并自动包含在应用程序包中。剩下的只是将字体注册到.NET MAUI 运行时,以便它可以在我们的 XAML 资源中使用。为此,将以下高亮行添加到MauiProgram.cs文件中:

.ConfigureFonts(fonts =>
{
    fonts.AddFont("FontAwesome.otf", "FontAwesome");
    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})

这行代码添加了一个别名,我们可以在下一节中使用它来创建静态资源。第一个参数是字体文件的文件名,而第二个是我们可以在FontFamily属性中使用字体别名。

剩下的只是需要在资源字典中定义一些图标。

在资源字典中定义一些图标

现在我们已经定义了字体,我们将使用它并定义五个要在我们的应用程序中使用的图标。我们首先添加 XAML;然后,我们将检查一个FontImage标签。

按照以下步骤操作:

  1. News项目中打开App.xaml

  2. 在现有的ResourceDictionary.MergedDictionaries标签下添加以下加粗代码:

    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
        <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
      </ResourceDictionary.MergedDictionaries>
      <FontImage x:Key="HomeIcon" FontFamily="FontAwesome" Glyph="&#xf015;" Size="22" Color="Black" />
      <FontImage x:Key="HeadlinesIcon" FontFamily="FontAwesome" Glyph="&#xf70e;" Size="22" />
      <FontImage x:Key="NewsIcon" FontFamily=" FontAwesome" Glyph="&#xf1ea;" Size="22" />
      <FontImage x:Key="SettingsIcon" FontFamily="FontAwesome" Glyph="&#xf013;" Size="22" Color="Black" />
      <FontImage x:Key="AboutIcon" FontFamily="FontAwesome" Glyph="&#xf05a;" Size="22" Color="Black" />
    </ResourceDictionary>
    

FontImage是一个可以在.NET MAUI 的任何地方使用的类,它期望一个ImageSource对象。它被设计用来将一个字符(或符号)渲染成图像。FontImage类需要一些属性才能工作,详细说明如下:

  • 这是一个键,它将被应用程序中的其他视图使用。

  • 一个使用别名引用回我们在上一节中定义的Font资源的FontFamily资源。

  • Glyph 对象,代表要显示的图像。要找出这些神秘值引用的是哪个图像,请访问 fontawesome.com,点击 图标,选择 免费和开源图标,并开始浏览。

  • SizeColor。这些不是严格必需的,但定义它们是好的。它们用于此应用中的一些图标,以便它们在浅色主题中正确渲染。

Font Awesome 现已安装并配置。我们已经做了很多工作,才到达本章的实际主题。现在是定义壳的时候了!

定义壳

如前所述,.NET MAUI Shell 是定义您应用结构的新方法。本书中的不同项目使用不同的方法来定义应用的整体结构,但根据我们的观点,.NET MAUI Shell 是定义 UI 结构的最佳方式。我们希望您觉得它和我们都一样令人兴奋!

定义基本结构

我们将首先为应用定义一个基本结构,而不真正添加我们定义的任何视图。之后,我们将逐个添加实际视图。但让我们先添加一些内容,并使用 XAML 直接创建 ContentPage 对象。按照以下两个步骤操作:

  1. News 项目中,打开 AppShell.xaml 文件。

  2. 修改文件,使其看起来像以下代码:

    <?xml version="1.0" encoding="UTF-8"?>
    <Shell 
    
        x:Class="News.AppShell">
        <FlyoutItem Title="Home" Icon="{StaticResource HomeIcon}">
          <ShellContent Title="Headlines" Icon="{StaticResource HeadlinesIcon}" >
          <ContentPage Title="Headlines" />
          </ShellContent>
          <Tab Title="News" Icon="{StaticResource NewsIcon}">
            <ContentPage Title="Local" />
            <ContentPage Title="Global" />
          </Tab>
        </FlyoutItem>
        <FlyoutItem Title="Settings" Icon="{StaticResource SettingsIcon}">
          <ContentPage Title="Settings" />
        </FlyoutItem>
        <ShellContent Title="About" Icon="{StaticResource AboutIcon}">
          <ContentPage Title="About"/>
        </ShellContent>
    </Shell>
    

让我们分解一下。首先,默认情况下,.NET MAUI Shell 模板禁用了飞出菜单。由于我们想在应用中使用它,您必须删除禁用它的那一行。Shell 本身的直接子对象是两个 FlyoutItem 对象和一个 ShellContent 对象。这三个对象都有定义的 TitleIcon 属性,如下面的截图所示。图标引用了我们之前创建的 Font Awesome 资源。这将渲染一个飞出菜单,如下面的截图所示:

图 4.7 – 应用飞出菜单

图 4.7 – 应用飞出菜单

通过从左侧滑动可以访问飞出菜单。Flyout 对象可以有多个子对象,而 ShellContent 元素只能有一个子对象。

ShellContent 包含一个标题为 Headlines 的页面和一个定义其自身两个子页面的标签页。第一级子页面将在应用的底部渲染标签栏,如下面的截图所示。在具有 News 标题的 Tab 元素下的第二级子页面将直接在顶部导航栏的标题下方渲染为一个标签栏:

图 4.8 – Shell 标签和页面

图 4.8 – Shell 标签和页面

设置关于 飞出菜单将简单地渲染它们定义的页面。

使应用运行

是时候尝试这个应用并看看它是否看起来像本章中展示的截图了。现在应用应该可以运行了。如果不行,保持冷静,只需再次检查代码即可。一旦你完成了对应用的导航,我们就可以创建一个新闻服务来获取新闻,并扩展我们创建的所有视图。

创建新闻服务

为了找到有趣的内容,我们将使用由newsapi.org提供的现有News API。为此,我们必须注册一个 API 密钥,我们可以用它来请求新闻。如果你不习惯这样做,你可以模拟新闻服务,而不是使用 API。

我们必须做的第一件事是获取一个 API 密钥。

获取 API 密钥

注册过程相当简单。然而,请注意,newsapi.org的 UI 在你阅读此内容时可能已经改变。

好的——让我们获取这个密钥:

  1. 浏览到newsapi.org/

  2. 点击获取 API 密钥

  3. 按照以下截图所示填写表格:

图 4.9 – 注册 API 密钥

图 4.9 – 注册 API 密钥

  1. 复制下一页上提供的 API 密钥,如图所示:

图 4.10 – 注册完成

图 4.10 – 注册完成

现在,我们需要一个地方来存储密钥以便于访问。我们将创建一个静态类来为我们保存密钥。按照以下步骤操作:

  1. News项目的根文件夹中创建一个名为Settings的新类。

  2. 添加以下代码片段中的代码,将前面的步骤中获得的 API 密钥替换占位文本:

    namespace News;
    internal static class Settings
    {
        public static string NewsApiKey => "<Your APIKEY Here>";
    }
    

这里重要的是将密钥复制并粘贴到文件中。现在,我们需要模型。

关于令牌和其他秘密的说明

这不是存储 API 密钥或其他应安全存储在应用中的令牌的推荐方式。为了安全地存储令牌和其他数据,你应该使用安全存储(见learn.microsoft.com/en-us/dotnet/maui/platform-integration/storage/secure-storage)并从安全服务器获取数据,最好是通过某种形式的用户身份验证。你也可以要求用户通过设置页面提供 API 密钥——提示,提示。

创建模型

从 API 返回的数据需要存储在某个地方,最方便的访问方式是将数据反序列化到Models中。让我们创建我们的模型:

  1. News项目中,创建一个名为Models的新文件夹。

  2. Models文件夹中,添加一个名为NewsApiModels的新类。

  3. 将以下代码添加到类中:

    namespace News.Models;
    using System;
    using System.Collections.Generic;
    using System.Text.Json.Serialization;
    public class Source
    {
        [JsonPropertyName("id")]
        public string Id { get; set; }
        [JsonPropertyName("name")]
        public string Name { get; set; }
    }
    public class Article
    {
        [JsonPropertyName("source")]
        public Source Source { get; set; }
        [JsonPropertyName("author")]
        public string Author { get; set; }
        [JsonPropertyName("title")]
        public string Title { get; set; }
        [JsonPropertyName("description")]
        public string Description { get; set; }
        [JsonPropertyName("url")]
        public string Url { get; set; }
        [JsonPropertyName("urlToImage")]
        public string UrlToImage { get; set; }
        [JsonPropertyName("publishedAt")]
        public DateTime PublishedAt { get; set; }
        [JsonPropertyName("content")]
        public string Content { get; set; }
    }
    public class NewsResult
    {
        [JsonPropertyName("status")]
        public string Status { get; set; }
        [JsonPropertyName("totalResults")]
        public int TotalResults { get; set; }
        [JsonPropertyName("articles")]
        public List<Article> Articles { get; set; }
    }
    

每个属性的 JsonPropertyName 特性允许 System.Text.Json 反序列化器将来自 Web API 的 JSON 接收到的名称映射到 C#对象中。当我们调用 API 时,API 将返回一个 NewsResult 对象,该对象将包含一系列文章。下一步是创建一个封装 API 并允许我们访问最新新闻的服务。

创建 POCO 类的技巧

如果你需要从一大堆 JavaScript Object Notation (JSON) 创建一个类模型,你可以使用 Windows Visual Studio 中的 Paste JSON as Classes 工具(编辑 | 粘贴特殊 | 粘贴 JSON 为类)。

创建服务类

服务类将封装 API,以便我们可以以类似.NET 的方式访问它。

但我们首先定义一个枚举,它将定义我们请求的新闻范围。

创建 NewsScope 枚举

NewsScope 枚举定义了我们服务支持的不同类型的新闻。让我们按照以下几个步骤添加它:

  1. News 项目中,创建一个新的文件夹,名为 Services

  2. Services 文件夹中,添加一个名为 NewsScope.cs 的新文件。

  3. 将以下代码添加到该文件中:

    namespace News.Services;
    public enum NewsScope
    {
        Headlines,
        Local,
        Global
    }
    

下一步是创建将封装对 News API 调用的 NewsService 类。

创建 NewsService 类

NewsService 类的目的是封装对新闻 REST API 的 HTTP 调用,并使其以常规.NET 方法调用的形式轻松访问我们的代码。为了更容易替换新闻的来源——例如,在测试中使用模拟——我们将使用一个接口。

要创建 INewsService 接口,按照以下步骤操作:

  1. Services 文件夹中,创建一个新的接口,名为 INewsService

  2. 编辑接口,使其看起来像这样:

    namespace News.Services;
    using News.Models;
    public interface INewsService
    {
        public Task<NewsResult> GetNews(NewsScope scope);
    }
    

创建 NewsService 类现在相当直接。按照以下步骤操作:

  1. Services 文件夹中,创建一个新的类,名为 NewsService

  2. 编辑类,使其看起来像这样:

    namespace News.Services;
    using News.Models;
    using System.Net.Http.Json;
    public class NewsService : INewsService, IDisposable
    {
        private bool disposedValue;
        const string UriBase = "https://newsapi.org/v2";
        readonly HttpClient httpClient = new() {
            BaseAddress = new(UriBase),
            DefaultRequestHeaders = { { "user-agent", "maui-projects-news/1.0" } }
        };
        public async Task<NewsResult> GetNews(NewsScope scope)
        {
            NewsResult result;
            string url = GetUrl(scope);
            try
            {
                result = await httpClient.GetFromJsonAsync<NewsResult>(url);
            }
            catch (Exception ex) {
                result = new() { Articles = new() { new() { Title = $"HTTP Get failed: {ex.Message}", PublishedAt = DateTime.Now} } };
            }
            return result;
        }
        private string GetUrl(NewsScope scope) => scope switch
        {
            NewsScope.Headlines => Headlines,
            NewsScope.Global => Global,
            NewsScope.Local => Local,
            _ => throw new Exception("Undefined scope")
        };
        private static string Headlines => $"{UriBase}/top-headlines?country=us&apiKey={Settings.NewsApiKey}";
        private static string Local => $"{UriBase}/everything?q=local&apiKey={Settings.NewsApiKey}";
        private static string Global => $"{UriBase}/everything?q=global&apiKey={Settings.NewsApiKey}";
        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    httpClient.Dispose();
                }
                disposedValue = true;
            }
        }
        public void Dispose()
        {
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
    

NewsService 类由五个方法组成;我知道技术上应该有八个,但我们会稍后讨论这一点。

第一个方法 GetNews 是我们将最终从我们的应用程序中调用的方法。它接受一个参数 scope,这是我们之前创建的枚举。根据此参数的值,我们将获得不同类型的新闻。此方法的第一件事是解析要调用的 URL,它是通过调用带有范围的 GetUrl 方法来完成的。

GetUrl 方法使用 switch 表达式解析 URL,并根据传递的 scope 参数的值返回三个 URL 之一。该 URL 指向 News API 的 REST API,其中包含一些预定义的查询参数和为我们注册的 API 密钥。

当我们解决了正确的 URL 后,我们就准备好发起 HTTP 请求并以 JSON 形式下载新闻。.NET 内置的HttpClient类为我们很好地获取了 JSON。在获取数据后,剩下的就是将其反序列化为我们之前定义的新闻模型。

现在让我们简单谈谈剩余的方法和HttpClient类。HttpClient现在是请求网络数据时推荐的类。它是一个比之前可用的实现更安全的实现。它随.NET 5+一起发货,并且可以作为单独的 NuGet 包用于旧版本。有了这个,使用HttpClient时有一些特殊之处。

首先,HttpClient会保留原生资源,因此必须正确地释放。为了正确地释放HttpClient,我们需要从IDisposable派生并实现它。这就是为什么在类中有额外的Dispose(bool)Dispose()方法的原因。它们所做的只是确保HttpClient的实例被正确地释放。

其次,HttpClient会池化这些原生资源,因此建议尽可能多地重用HttpClient的实例。这就是为什么在NewsService构造函数中创建HttpClient实例的原因。

最后的话 - 由于GetFromJsonAsync调用可能会抛出异常,并且它是在一个async方法中调用的,因此你必须处理这个异常;否则,它将在执行线程上丢失,而你唯一能意识到有问题的情况就是你没有项目。对于这个应用,我们只是创建一个包含一个有异常的ArticleNewsResult对象,以便显示一些内容。处理错误有更好的方法,但这对这个应用来说已经足够了。

下一步是连接NewsService类。

连接NewsService

我们现在准备好在我们的应用中连接NewsService类,并将其与真实的新闻源集成。我们将扩展所有现有的ViewModels,并定义 UI 元素以在Views中渲染新闻。

扩展HeadlinesViewModel

在 MVVM 中,ViewModel是处理应用逻辑的地方。模型是我们将从NewsService类中获取的新闻数据。我们现在将扩展HeadlinesViewModel类,使其使用NewsService来获取新闻:

  1. News项目中,展开ViewModels文件夹并打开HeadlinesViewModel.cs文件。

  2. 添加以下加粗的代码并解决引用:

    namespace News.ViewModels;
    using System.Threading.Tasks;
    using System.Web;
    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    using News.Models;
    using News.Services;
    public partial class HeadlinesViewModel : ViewModel
    {
        private readonly INewsService newsService;
        [ObservableProperty]
        private NewsResult currentNews;
        public HeadlinesViewModel(INewsService newsService)
        {
            this.newsService = newsService;
        }
        public async Task Initialize(string scope) =>
            await Initialize(scope.ToLower() switch
            {
                "local" => NewsScope.Local,
                "global" => NewsScope.Global,
                "headlines" => NewsScope.Headlines,
                _ => NewsScope.Headlines
            });
        public async Task Initialize(NewsScope scope)
        {
            CurrentNews = await newsService.GetNews(scope);
        }
        [RelayCommand]
        public void ItemSelected(object selectedItem)
        {
            var selectedArticle = selectedItem as Article;
            var url = HttpUtility.UrlEncode(selectedArticle.Url);
            // Placeholder for more code later on
        }
    }
    

由于我们使用(构造函数)依赖注入,我们需要将依赖注入到构造函数中。这个ViewModel唯一的依赖是NewsService,我们将其内部存储在类的字段中。

CurrentNews属性被定义为获取绑定 UI 的内容。

然后,我们有两个 Initialize 方法——一个接受 scope 作为枚举,另一个接受 scope 作为字符串。字符串重载的 Initialize 方法将在 XAML 中使用。它只是将字符串转换为 scope 的枚举表示形式,然后调用另一个 Initialize 方法,该方法反过来调用新闻服务上的 GetNews(...) 方法。

最后一个属性 ItemSelected 返回一个 .NET MAUI 命令,我们将将其连接到当应用程序用户选择一个项目时响应的事件。方法的一半从一开始就实现了。所选的项目将被传递到方法中。然后,我们编码文章的 URL,因为我们将在应用程序内导航时将其作为查询参数传递。我们稍后会回到导航部分。

如果你好奇关于 ObservablePropertyRelayCommand 属性,可以通过回顾 第二章构建我们的第一个 .NET MAUI 应用程序 来刷新你的记忆。

现在我们已经有了获取数据的代码,接下来我们将转向定义用于显示数据的用户界面。

扩展 HeadlinesView

HeadlinesView 是一个共享视图,将在应用程序的几个地方使用。这个视图的目的是显示文章列表,并允许用户从一个文章导航到显示整个文章的网页浏览器。

要扩展 HeadlinesView,我们必须做两件事——首先,我们必须编辑 XAML 并定义 UI;然后,我们需要添加一些代码来初始化它。按照以下步骤进行:

  1. News 项目中,展开 Views 文件夹并打开 HeadlinesView.xaml 文件。

  2. 编辑 XAML,如下面的代码块所示:

    <?xml version="1.0" encoding="UTF-8"?>
    <ContentPage 
    
        x:Name="headlinesview"
        x:Class="News.Views.HeadlinesView"
                  x:DataType="viewModels:HeadlinesViewModel"
        Title="Home" Padding="14">
      <CollectionView ItemsSource="{Binding CurrentNews.Articles}">
        <CollectionView.EmptyView>
          <Label Text="Loading" />
        </CollectionView.EmptyView>
        <CollectionView.ItemTemplate>
          <DataTemplate x:DataType="models:Article">
            <ContentView>
              <ContentView.GestureRecognizers>
                <TapGestureRecognizer Command="{Binding BindingContext.ItemSelectedCommand, Source={x:Reference headlinesview}}" CommandParameter="{Binding .}" />
              </ContentView.GestureRecognizers>
              <views:ArticleItem />
            </ContentView>
          </DataTemplate>
        </CollectionView.ItemTemplate>
      </CollectionView>
    </ContentPage>
    

HeadlinesView 使用 CollectionView 来显示文章列表。ItemsSource 属性设置为 ViewModelCurrentNews.Articles 属性,在加载新闻后,应该包含一个新闻列表。当列表为空或正在加载时,我们将在 CollectionView.EmptyView 元素内显示一个加载标签。当然,你可以在该标签内创建任何有效的 UI 来创建一个更酷的加载界面。

CurrentNews.Articles 列表中的每一篇文章都将使用 CollectionView.ItemTemplate 元素内部的内容进行渲染,而 ContentView 元素内部的内容将代表实际的项目。文章将通过一个 ArticleItem 视图进行渲染,这是一个我们之前定义的自定义控件。我们将在完成这个视图后定义这个视图。

要启用从视图的导航,我们需要检测用户何时点击特定的文章。我们可以通过添加 TapGestureRecognizer 并将其绑定到根 ViewModelItemSelectedCommand 属性来实现。Source={x:Reference headlinesview}} 这段代码是引用当前上下文回到页面的根,而不是我们正在迭代的列表中的当前文章。如果我们没有指定源,绑定引擎将尝试将 ItemSelectedCommand 属性绑定到在 CurrentNews.Articles 属性中定义的当前文章的属性。

GUI 部分就到这里。现在,我们需要修改代码背后的部分,以便根据我们从 XAML 本身传递的数据进行初始化。按照以下步骤进行操作以实现这一点:

  1. News 项目中,打开 HeadlinesView.xaml.cs 代码背后的文件。

  2. 将以下加粗的代码添加到文件中:

    namespace News.Views
    using System.Threading.Tasks;
    using News.Services;
    using News.ViewModels;
    public partial class HeadlinesView : ContentPage
    {
        readonly HeadlinesViewModel viewModel;
        public HeadlinesView(HeadlinesViewModel viewModel)
        {
            this.viewModel = viewModel;
            InitializeComponent();
            Task.Run(async () => await Initialize(GetScopeFromRoute()));
        }
        private async Task Initialize(string scope)
        {
            BindingContext = viewModel;
            await viewModel.Initialize(scope);
        }
        private string GetScopeFromRoute()
        {
            var route = Shell.Current.CurrentState.Location
            .OriginalString.Split("/").LastOrDefault();
            return route;
        }
    }
    

通常,我们不想直接在视图的代码背后添加代码,但我们需要做出例外,以便可以将参数从 XAML 传递到我们的 ViewModel

根据创建视图所使用的路由信息,我们将以不同的方式初始化 ViewModelGetScopeFromRoute 方法将解析 Shell 中的位置信息以确定用于查询新闻服务的范围。然后,我们可以调用一个私有方法为我们创建 HeadlinesViewModel 的实例,将其设置为视图的绑定上下文,并在 ViewModel 上调用 Initialize() 方法,这会向 News API 发起 REST 调用。我们将在编辑 shell 文件时定义路由。

但首先,我们需要扩展 ArticleItemContentView 以显示新闻列表中的单行项。

扩展 ArticleItem 的 ContentView

ArticleItemContentView 代表新闻列表中的一个条目,如图中所示:

图 4.11 – 一个示例新闻条目

图 4.11 – 一个示例新闻条目

要创建如图 图 4.11 所示的布局,我们将使用 Grid 控件。按照以下步骤创建布局:

  1. News 项目中,展开 Views 文件夹并打开 ArticleItem.xaml 文件。

  2. 编辑以下代码块中的 XAML 代码:

    <?xml version="1.0" encoding="UTF-8"?>
    <ContentView 
    
        x:Class="News.Views.ArticleItem"
        x:DataType="models:Article">
      <Grid Margin="0">
        <Grid.RowDefinitions>
          <RowDefinition Height="10" />
          <RowDefinition Height="40" />
          <RowDefinition Height="15" />
          <RowDefinition Height="10" />
          <RowDefinition Height="1" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="65" />
          <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Label Grid.Row="1" Grid.Column="1" Text="{Binding Title}" Padding="10,0" FontSize="Small" FontAttributes="Bold" />
        <Label Grid.Row="2" Grid.Column="1" Text="{Binding PublishedAt, StringFormat='{0:MMMM d, yyyy}'}" Padding="10,0,0,0" FontSize="Micro" />
        <Border Grid.Row="1" Grid.RowSpan="2" StrokeShape="RoundRectangle 15,15,15,15" Padding="0" Margin="0,0,0,0" BackgroundColor="#667788" >
          <Image Source="{Binding UrlToImage}" Aspect="AspectFill" HeightRequest="55" HorizontalOptions="Center" VerticalOptions="Center" />
        </Border>
        <BoxView Grid.Row="4" Grid.ColumnSpan="2" BackgroundColor="LightGray" />
      </Grid>
    </ContentView>
    

上述 XAML 代码定义了一个具有两列和五行的网格布局。Grid.RowGrid.Column 属性将子元素定位到网格中,而 Grid.ColumnSpan 属性允许控件跨越多个列。

使用具有 StrokeShape="RoundRectangle 15,15,15,15"Border 元素,并设置 ImageAspect 属性为 AspectFill,可以创建一个圆形图像。

标签中的字符串可以直接在绑定语句中格式化。查看 Text="{Binding PublishedAt, StringFormat='{0:MMMM d, yyyy}'}" 这行代码,它将日期格式化为特定的字符串格式。

最后,灰色分隔线是 XAML 代码末尾的 BoxView

现在我们已经创建了 NewsService 并修复了所有相关视图,是时候使用它们了。

添加到依赖注入

由于 HeadlinesViewModel 依赖于 INewsService,我们需要在我们的依赖注入容器中注册它(请参阅 第二章 中的 连接依赖注入 部分,了解更多关于 .NET MAUI 中依赖注入的细节)。按照以下步骤进行操作:

  1. News 项目中,打开 MauiProgram.cs 文件。

  2. 定位到 RegisterAppTypes() 方法并添加以下加粗的行:

    public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
    {
        // Services
         mauiAppBuilder.Services.AddSingleton<Services.INewsService>((serviceProvider) => new Services.NewsService());
        // ViewModels
        mauiAppBuilder.Services.AddTransient<ViewModels.HeadlinesViewModel>();
        //Views
        mauiAppBuilder.Services.AddTransient<Views.AboutView>();
        mauiAppBuilder.Services.AddTransient<Views.ArticleView>();
        mauiAppBuilder.Services.AddTransient<Views.HeadlinesView>();
        return mauiAppBuilder;
    }
    

这将允许进行 NewsService 的依赖注入。

添加 ContentTemplate 属性

到目前为止,我们的 AppShell 文件中只有占位符代码。让我们用实际内容替换它,如下所示:

  1. News 项目中,打开 AppShell.xaml

  2. 定位到标题设置为 HomeFlyoutItem 元素。

  3. 编辑 XAML,使 ShellContent 元素成为自闭合的,添加以下加粗的 ContentTemplate 属性,并替换 Tab 元素的全部内容:

    <Shell
        x:Class="News.AppShell"
    
        >
        <FlyoutItem Title="Home" Icon="{StaticResource HomeIcon}">
            <ShellContent Title="Headlines" Route="headlines" Icon="{StaticResource HeadlinesIcon}" ContentTemplate="{DataTemplate views:HeadlinesView}" />
            <Tab Title="News" Route="news" Icon="{StaticResource NewsIcon}">
                <ShellContent Title="Local" Route="local" ContentTemplate="{DataTemplate views:HeadlinesView}" />
                <ShellContent Title="Global" Route="global" ContentTemplate="{DataTemplate views:HeadlinesView}" />
            </Tab>
        </FlyoutItem>
        <FlyoutItem Title="Settings" Icon="{StaticResource SettingsIcon}">
            <ContentPage Title="Settings" />
        </FlyoutItem>
        <ShellContent Title="About" Icon="{StaticResource AboutIcon}">
            <ContentPage Title="About"/>
        </ShellContent>
    </Shell>
    

这里有两件事情在进行。第一件事是我们使用 ContentTemplate 属性指定 ShellContent 的内容。这意味着我们指向当壳可见时要创建的视图类型。通常,你希望在即将显示之前才创建视图,而 ContentTemplate 属性就提供了这个功能。注意,那个 FlyoutItemRoute 属性被设置为 headlines

第二件事是对于 LocalGlobal 新闻,我们在下面做同样的事情,但使用的是 localglobal 路由。

如果你现在运行这个应用,你应该得到以下截图所示的内容:

图 4.12 – 主要列表视图

图 4.12 – 主要列表视图

我们需要实现的最后一件事是在我们点击列表中的项目时如何查看文章。

处理导航

我们现在离这个应用完成只剩下最后一步了。我们唯一需要做的是实现导航到文章视图,该视图将在网页中显示整个文章。由于我们使用 Shell,我们将使用路由进行导航。路由可以直接在 Shell 标记中注册 – 例如,在 AppShell.xaml 文件中。我们可以通过在 ShellContent 元素上使用 Route 属性来实现,就像我们在上一节中所做的那样。

在下面的代码中,我们将以编程方式添加一个路由并注册一个视图来为我们处理它。我们还将创建一个导航服务来抽象化导航的概念。

所以,系好安全带,让我们完成这个应用!

创建导航服务

第一步是定义一个将包装 .NET MAUI 导航的接口。我们为什么要这样做呢?因为将接口与实现分离是一种良好的实践;这使得单元测试更容易,等等。

创建 INavigation 接口

INavigation 接口很简单,我们可能会稍微超出目标。我们只对 NavigateTo 方法感兴趣,但我们会添加 PushModal()PopModal() 方法,因为如果你继续扩展应用程序,你可能会用到它们。

添加导航接口很简单,以下步骤将说明:

  1. News 项目中,展开 ViewModels 文件夹,并添加一个名为 INavigate.cs 的新文件。

  2. 将以下代码添加到文件中:

    namespace News.ViewModels;
    public interface INavigate
    {
        Task NavigateTo(string route);
        Task PushModal(Page page);
        Task PopModal();
    }
    

NavigateTo() 方法的声明接受我们想要导航到的路由。这是我们将会调用的方法。PushModal() 方法在导航堆栈顶部添加一个新的页面作为模态页面,强制用户只能与这个特定的页面进行交互。PopModal() 方法将其从导航堆栈中移除。所以,如果你使用 PushModal() 方法,确保你给用户一个方法来将其从堆栈中移除。

否则,你将永远卡在查看模态页面。

接口部分就到这里。让我们使用 .NET MAUI Shell 创建一个实现。

使用 .NET MAUI Shell 实现 INavigate 接口

实现非常直接,因为每个方法都只是调用由 Shell API 提供的 .NET MAUI 静态方法。

按照以下步骤创建 Navigator 类:

  1. News 项目中,添加一个名为 Navigator 的新类。

  2. 将以下代码添加到类中:

    namespace News;
    using News.ViewModels;
    public class Navigator : INavigate
    {
        public async Task NavigateTo(string route) => await Shell.Current.GoToAsync(route);
        public async Task PushModal(Page page) => await Shell.Current.Navigation.PushModalAsync(page);
        public async Task PopModal() => await Shell.Current.Navigation.PopModalAsync();
    }
    

这只是简单的透传代码,调用已经存在的方法。现在,我们需要将类型注册到我们的依赖注入容器中,以便它可以被 ViewModel 类消费。

使用依赖注入注册 Navigator

为了让 ViewModel 类及其派生类能够访问 Navigator 实例,我们必须将其注册到容器中,就像我们之前对 NewService 所做的那样;只需按照以下步骤操作:

  1. News 项目中,打开 MauiProgram.cs 文件。

  2. 找到 RegisterAppTypes 方法,并添加以下突出显示的代码:

    public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
    {
        // Services
        mauiAppBuilder.Services.AddSingleton<Services.INewsService>((serviceProvider) => new Services.NewsService());
        mauiAppBuilder.Services.AddSingleton<ViewModels.INavigate>((serviceProvider) => new Navigator());
        // ViewModels
    …
    }
    

现在,我们可以将 INavigate 接口添加到 ViewModel 类及其派生类中。

将 INavigate 接口添加到 ViewModel 类

为了能够访问 Navigator,我们必须扩展 ViewModel 基类,使其对所有 ViewModels 可用。按照以下步骤操作:

  1. News 项目中,打开 ViewModels 文件夹,然后打开 ViewModel.cs 文件。

  2. 将以下突出显示的代码添加到类中:

    public abstract class ViewModel
    {
        public INavigate Navigation { get; init; }
        internal ViewModel(INavigate navigation) => Navigation = navigation;
    }
    
  3. 打开 HeadlinesViewModel.cs 文件,并对构造函数进行突出显示的更改:

    public HeadlinesViewModel(INewsService newsService, INavigate navigation) : base (navigation)
    

基础 ViewModel 现在通过 INavigate 接口公开了 Navigator 属性。到这一点,我们就准备好将导航连接到我们的 Article 视图了。

使用路由进行导航

路由是导航的一个非常方便的方法,因为它们抽象了页面创建的过程。我们只需要知道我们想要导航到的视图的路由 - .NET MAUI Shell 会为我们处理其余的事情。如果你熟悉网络导航的工作方式,你可能会认出我们在路由中传递参数的方式。它们作为查询参数传递。

完成 ItemSelected 命令

之前,我们在 HeadlinesViewModel 类中定义了 ItemSelected 方法。现在,是时候添加将执行导航到 ArticleView 的代码了:

  1. 新闻 项目中,展开 ViewModels 文件夹并打开 HeadlinesViewModel.cs

  2. 定位到 ItemSelected 方法并添加以下加粗的行:

    [RelayCommand]
    public async Task ItemSelected(object selectedItem)
    {
        var selectedArticle = selectedItem as Article;
        var url = HttpUtility.UrlEncode(selectedArticle.Url);
        await Navigation.NavigateTo($"articleview?url={url}");
    }
    

在这里,我们定义了一个名为 articleview 的路由,它接受一个名为 url 的查询行参数,该参数指向文章本身的 URL。它看起来可能像这样:articleview?url=www.mypage.com。只有传递给 url= 参数之后的数据必须使用 HttpUtility.UrlEncode() 方法进行编码,该方法由 System.Web 为我们定义。

前面的 NavigateTo() 方法调用使用查询参数中的此编码数据。在导航调用的接收端,我们需要处理传入的 url 参数。

扩展 ArticleView 以接收查询数据

ArticleView 负责为我们渲染文章。为了保持简单(并且说明你并不总是需要 ViewModel),我们不会为这个类定义 ViewModel;相反,我们将定义 BindingContextUrlWebViewSource 类的一个实例。

将以下代码添加到 ArticleView.xaml.cs 文件中:

  1. 新闻 项目中,展开 视图 文件夹并打开 ArticleView.xaml.cs 文件。

  2. 将以下加粗的代码添加到文件中:

    namespace News.Views;
    using System.Web;
    [QueryProperty("Url", "url")]
    public partial class ArticleView : ContentPage
    {
        public string Url
        {
            set
            {
                BindingContext = new UrlWebViewSource
                {
                    Url = HttpUtility.UrlDecode(value)
                };
            }
        }
        public ArticleView()
        {
            InitializeComponent();
        }
    }
    

ArticleView 依赖于一个已设置的 URL,我们通过定义一个只读属性 Url 来实现这一点。当此属性被设置时,它将创建一个新的 UrlWebViewSource 实例,并将属性值赋给它,然后将其分配给页面的 BindingContext。这个设置器是由 Shell 框架调用的,因为我们向类本身添加了一个名为 QueryProperty 的属性。它接受两个参数 - 第一个是设置哪个属性,第二个是 url 查询参数的名称。

由于数据是 URL 编码的,我们需要使用 HttpUtility.UrlDecode() 方法对其进行解码。

这样,我们就有一个指向我们想要显示的网页的绑定上下文。现在,我们只需要在 XAML 中定义 WebView

通过 WebView 扩展 ArticleView

这个页面只有一个目的,那就是显示我们传递给它的 URL 中的网页。让我们在页面上添加一个 WebView 控件,如下所示:

  1. 新闻 项目中,展开 视图 文件夹并打开 ArticleView.xaml

  2. 添加以下突出显示的 XAML:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage 
    
        x:Class="News.Views.ArticleView"
        Title="ArticleView">
        <WebView Source="{Binding .}" />
    </ContentPage>
    

WebView 控件将占据视图中的所有可用空间。源设置为 .,这意味着它与 ViewModelBindingContext 属性相同。在这种情况下,BindingContext 属性是一个 UrlWebViewSource 实例,这正是 WebView 需要导航和显示内容所需的。

我们只剩下一步了——我们的应用需要了解 ArticleView 路由以及如何处理它。

注册路由

如前所述,路由可以在 XAML 中声明性添加(Route="MyDucks")或通过代码添加,如下所示:

  1. 新闻 项目中,打开 AppShell.xaml.cs 文件。

  2. 添加以下加粗的代码行:

    namespace News;
    public partial class AppShell : Shell
    {
        public AppShell()
        {
            InitializeComponent();
            Routing.RegisterRoute("articleview", typeof(Views.ArticleView));
        }
    }
    

RegisterRoute() 方法接受两个参数——第一个是我们想要使用的路由,这是我们指定在 NavigateTo() 调用中的路由。第二个是我们想要创建的页面(视图)的类型——在我们的例子中,我们想要创建 ArticleView

太棒了!这就完成了。应用现在应该可以运行,你应该能够从你的 CollectionViews 中导航到文章。干得好!

摘要

在本章中,我们学习了如何使用 .NET MAUI Shell 定义导航结构,如何使用路由导航到视图,以及如何以查询字符串的形式在视图之间传递参数。Shell 还有很多内容,但这应该能让你开始并足够自信地探索 Shell API。此外,请记住,Shell API 正在不断发展,确保查看最新的功能。

我们还学习了如何为任意 REST API 创建 API 客户端,这在编写大多数应用时非常有用,因为大多数应用都需要在某个时候与服务器通信。有很大可能性,服务器将通过 REST API 公开其数据和功能。

如果你感兴趣,想进一步扩展应用,尝试设计自己的 News API 密钥,并通过设置进行设置。

下一个项目将关于创建一个匹配应用,以及如何仅使用 .NET MAUI 渲染和动画跨平台 UI 控件来创建一个带有滑动功能的是/否图片选择应用。

第五章:使用动画的丰富 UX 的匹配应用

在本章中,我们将创建匹配应用的基线功能。然而,由于隐私问题,我们不会对人员进行评分。相反,我们将从互联网上的随机来源下载图片。这个项目是为那些想要了解如何编写可重用控件的人准备的。我们还将探讨如何使用动画使我们的应用程序更易于使用。这个应用将不会是一个模型-视图-视图模型MVVM)应用程序,因为我们想将控件创建和使用与 MVVM 的轻微开销隔离开来。

本章将涵盖以下主题:

  • 创建自定义控件

  • 将应用程序样式设计成照片样式,并在其下方添加描述性文本

  • 使用 .NET MAUI 创建动画

  • 订阅自定义事件

  • 重复使用自定义控件

  • 处理平移手势

技术要求

为了能够完成本章的项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载。有关如何设置环境的更多详细信息,请参阅 第一章,* .NET MAUI 简介*。

您可以在github.com/PackPublishing/MAUI-Projects-3rd-Edition找到本章代码的完整源代码。

项目概述

许多人都曾面临过这样的困境:是滑动左键还是右键。突然间,你可能开始 wonder:这是怎么工作的?滑动魔法是如何发生的? 好吧,在这个项目中,我们将学习所有关于它的知识。我们将从定义一个MainPage文件开始,我们的应用程序图像将驻留在其中。之后,我们将实现图像控制,并逐渐添加图形用户界面GUI)和功能,直到我们打造出完美的滑动体验。

该项目的构建时间大约为 90 分钟。

创建匹配应用

在这个项目中,我们将学习更多关于创建可重用控件的知识,这些控件可以添加到可扩展应用程序标记语言XAML)页面中。为了保持简单,我们不会使用 MVVM,而是使用不带任何数据绑定的裸机 .NET MAUI。我们的目标是创建一个允许用户左右滑动图片的应用程序,就像大多数流行的匹配应用一样。

好吧,让我们从创建项目开始吧!

设置项目

这个项目,就像所有其他项目一样,是一个文件 | 新建 | 项目...风格的程序。这意味着我们不会导入任何代码。因此,这个第一部分完全是关于创建项目和设置基本项目结构。

让我们开始吧!

创建新项目

那么,让我们开始吧。

第一步是创建一个新的 .NET MAUI 项目:

  1. 打开 Visual Studio 2022 并选择创建一个 新项目

图 5.1 – Visual Studio 2022

图 5.1 – Visual Studio 2022

这将打开 创建新项目 向导。

  1. 在搜索框中,键入 maui 并从列表中选择 .NET MAUI 应用 项:

图 5.2 – 创建一个新项目

图 5.2 – 创建一个新项目

  1. 点击 下一步

  2. 通过命名您的项目来完成向导的下一步。在这种情况下,我们将我们的应用程序命名为 Swiper。通过点击 创建,如图所示,继续到下一个对话框:

图 5.3 – 配置您的全新项目

图 5.3 – 配置您的全新项目

  1. 点击 下一步

  2. 最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持LTS),.NET 7 可用为 标准期限支持。对于这本书,我们假设您将使用 .NET 7:

图 5.4 – 补充信息

图 5.4 – 补充信息

  1. 通过点击 创建 并等待 Visual Studio 创建项目来完成设置。

就这样,应用程序已经创建。让我们先设计 MainPage 文件。

设计 MainPage 文件

已创建一个名为 Swiper 的新 .NET MAUI Shell 应用程序,包含一个名为 MainPage.xaml 的单页。这位于项目的根目录中。我们需要将默认的 XAML 模板替换为包含我们的 Swiper 控件的新布局。

让我们通过替换默认内容来编辑已存在的 MainPage.xaml 文件:

  1. 打开 MainPage.xaml 文件。

  2. 将页面内容替换为以下突出显示的 XAML 代码:

    <?xml version="1.0" encoding="utf-8"?>
    <ContentPage
      xmlns=http://schemas.microsoft.com/dotnet/2021/maui
    
      x:Class="Swiper.MainPage">
      <Grid Padding="0,40" x:Name="MainGrid">
        <Grid.RowDefinitions>
          <RowDefinition Height="400" />
          <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Grid Grid.Row="1" Padding="30">
            <!-- Placeholder for later -->
        </Grid>
      </Grid>
    </ContentPage>
    

ContentPage 节点内的 XAML 代码定义了应用程序中的两个网格。网格简单地是一个其他控件的容器。它根据行和列定位这些控件。在这种情况下,外部网格定义了两个将覆盖整个屏幕可用区域的行。第一行高 400 个单位,第二行,使用 Height="*",使用剩余的可用空间。

定义在第一个网格内的内部网格,通过 Grid.Row="1" 属性分配给第二行。行和列索引是从零开始的,所以 "1" 实际上指的是第二行。我们将在本章的后面添加一些内容到这个网格中,但现在我们先让它保持为空。

两个网格都定义了它们的填充。您可以输入一个数字,这意味着所有边都将有相同的填充,或者 – 如此案例中 – 输入两个数字。我们输入了 0,40,这意味着左侧和右侧应该有 0 个单位的填充,顶部和底部应该有 40 个单位的填充。还有一个第三个选项,使用四个数字,它设置了 左侧顶部右侧底部 的填充,按照特定的顺序。

最后要注意的是,我们给外层网格起了一个名字,x:Name="MainGrid"。这将使得它可以直接从 MainPage.xaml.cs 文件中定义的后台代码中访问。由于在这个例子中我们没有使用 MVVM,我们需要一种方法来访问网格而不使用数据绑定。

创建 Swiper 控件

这个项目的核心部分是创建 Swiper 控件。在一般意义上,控件是一个自包含的 ContentView,与 ContentPage 相对,后者是 XAML 页面。它可以作为一个元素添加到任何 XAML 页面中,或者在代码的后台文件中。在这个项目中,我们将从代码中添加控件。

创建控件

创建 Swiper 控件是一个简单的过程。我们只需要确保我们选择了正确的项目模板,即 内容视图,通过以下操作:

  1. Swiper 项目中,创建一个名为 Controls 的文件夹。

  2. 右键单击 Controls 文件夹,选择 添加,然后点击 新建项...

  3. 添加新项 对话框的左侧面板中选择 C# 项,然后选择 .NET MAUI

  4. 选择 .NET MAUI 内容视图 (XAML) 项。确保您不要选择 .NET MAUI 内容视图 (C#) 选项;这只会创建一个 C# 文件,而不是 XAML 文件。

  5. 将控件命名为 SwiperControl.xaml

  6. 点击 添加

    参考以下截图查看上述信息:

图 5.5 – 添加新项

图 5.5 – 添加新项

这添加了一个用于 UI 的 XAML 文件和一个 C# 后台代码文件。它应该看起来如下:

图 5.6 – 解决方案布局

图 5.6 – 解决方案布局

定义主网格

让我们设置 Swiper 控件的基本结构:

  1. 打开 SwiperControl.xaml 文件。

  2. 将以下代码块中的内容替换为高亮的代码:

    <?xml version="1.0" encoding="UTF-8"?>
    <ContentView 
    
                 x:Class="Swiper.Controls.SwiperControl">
    <ContentView.Content>
      <Grid>
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="100" />
          <ColumnDefinition Width="*" />
          <ColumnDefinition Width="100" />
        </Grid.ColumnDefinitions>
        <!-- ContentView for photo here -->
        <!-- StackLayout for like here -->
        <!-- StackLayout for deny here -->
      </Grid>
    </ContentView.Content>
    </ContentView>
    

这定义了一个有三个列的网格。最左边的和最右边的列将占用 100 个单位的空间,中间将占用剩余的可供空间。两侧的空间将是添加标签以突出用户所做选择的地方。我们还添加了三个注释,作为即将到来的 XAML 代码的占位符。

我们将继续添加额外的 XAML 来创建照片布局。

添加照片内容视图

现在,我们将通过添加定义照片外观的定义来扩展 SwiperControl.xaml 文件。我们的最终结果将看起来像 图 5.7。由于我们将从互联网上拉取图片,我们将显示一个加载文本,以确保用户能够得到关于正在发生什么的反馈。为了使其看起来像即时打印的照片,我们在照片下方添加了一些手写的文本,如下面的图所示:

图 5.7 – 照片 UI 设计

图 5.7 – 照片 UI 设计

前面的图显示了我们希望照片看起来像什么。为了使这成为现实,我们需要通过以下方式向SwiperControl文件添加一些 XAML 代码:

  1. 打开SwiperControl.xaml

  2. <!-- ContentView for photo here -->注释之后添加高亮的 XAML 代码。确保不要替换页面的整个ContentView控件;只需在注释下添加此代码,如以下代码块所示。页面的其余部分应保持不变:

    <!-- ContentView for photo here -->
      <ContentView x:Name="photo" Padding="40" Grid.ColumnSpan="3" >
        <Grid x:Name="photoGrid" BackgroundColor="Black" Padding="1" >
          <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="40" />
          </Grid.RowDefinitions>
          <BoxView Grid.RowSpan="2" BackgroundColor="White" />
          <Image x:Name="image" Margin="10" BackgroundColor="#AAAAAA" Aspect="AspectFill" />
          <Label x:Name="loadingLabel" Text="Loading..." TextColor="White" FontSize="Large" FontAttributes="Bold" HorizontalOptions="Center" VerticalOptions="Center" />
          <Label Grid.Row="1" x:Name="descriptionLabel" Margin="10,0" Text="A picture of grandpa" FontFamily="Bradley Hand" />
        </Grid>
      </ContentView>
    

ContentView控件定义了一个新的区域,我们可以在这里添加其他控件。ContentView控件的一个非常重要的特性是它只接受一个子控件。大多数时候,我们会添加一个可用的布局控件。在这种情况下,我们将使用Grid控件来布局控件,如前述代码所示。

网格定义了两行:

  • 为照片本身添加一行,当其他行已经分配空间时,它将占据所有可用空间

  • 为评论添加一行,其高度正好为 40 个单位

Grid控件本身设置为使用黑色背景和 1 的填充。这与一个白色背景的BoxView控件结合使用,创建了我们在控件周围看到的框架。BoxView控件也被设置为跨越网格的两行(Grid.RowSpan="2"),占据网格的整个区域,减去填充。

接下来是Image控件。它设置了一个背景颜色为漂亮的灰色调(#AAAAAA)和 40 的边距,这将使其与周围的框架稍微隔开。它还有一个硬编码的名称(x:Name="image"),这将允许我们从代码后端与之交互。最后一个属性,称为Aspect,决定了如果图像控件与源图像的比例不同时我们应该做什么。在这种情况下,我们希望填充整个图像区域,但不显示任何空白区域。这实际上在高度或宽度上裁剪了图像。

我们通过添加两个标签来完成,这些标签也有硬编码的名称供以后参考。

现在 XAML 的部分就到这里了;让我们继续为这张照片创建一个描述。

创建DescriptionGenerator

在图片底部,我们可以看到一个描述。由于我们来自即将到来的图片源没有任何通用的图片描述,我们需要创建一个生成描述的生成器。这里有一个简单而有趣的方法来做这件事:

  1. Swiper项目中创建一个名为Utils的文件夹。

  2. 在那个文件夹中创建一个名为DescriptionGenerator的新类。

  3. 向这个类添加以下代码:

    internal class DescriptionGenerator
    {
      private string[] _adjectives = { "nice", "horrible", "great", "terribly old", "brand new" };
      private string[] _other = { "picture of grandpa", "car", "photo of a forest", "duck" };
      private static Random random = new();
      public string Generate()
      {
        var a = _adjectives[random.Next(_adjectives.Count())];
        var b = _other[random.Next(_other.Count())];
        return $"A {a} {b}";
      }
    }
    

这个类只有一个目的:它从_adjectives数组中随机取一个词,并将其与_other数组中的一个随机词组合。通过调用Generate()方法,我们得到一个全新的组合。您可以在数组中自由添加自己的词。请注意,Random实例是一个静态字段。这是因为如果我们创建时间上过于接近的新实例的Random类,它们会被相同的值初始化,并返回相同的随机数序列。

现在我们可以为照片创建一个有趣的描述,我们需要一种方法来捕获照片和描述。

创建一个Picture

为了抽象出我们想要显示的图片的所有信息,我们将创建一个封装这些信息的类。在我们的Picture类中信息不多,但这是一个好的编码实践。按照以下步骤进行:

  1. Utils文件夹中创建一个新的类,名为Picture

  2. 将以下代码添加到类中:

    public class Picture
    {
      public Uri Uri { get; init; }
      public string Description { get; init; }
      public Picture()
      {
        Uri = new Uri($"https://picsum.photos/400/400/?random&ts={DateTime.Now.Ticks}");
         var generator = new DescriptionGenerator();
         Description = generator.Generate();
      }
    }
    

Picture类有以下两个公共属性:

  • Uri属性,指向其在互联网上的位置

  • 该图片的描述,作为Description属性公开

在构造函数中,我们创建一个新的 URI,它指向一个公共测试照片源,我们可以使用。宽度和高度在 URI 的查询字符串部分指定。我们还附加了一个随机时间戳,以避免.NET MAUI 缓存图片。这为我们每次请求图片时生成一个唯一的 URI。

我们随后使用之前创建的DescriptionGenerator类为图片生成一个随机描述。

注意,属性并不定义一个set方法,而是使用init。由于我们创建对象后永远不需要更改URLDescription的值,这些属性可以是只读的。init只允许在构造函数完成之前设置值。如果您在构造函数运行之后尝试设置值,编译器将生成一个错误。

现在我们已经拥有了开始显示图片所需的所有组件,让我们开始把它们整合起来。

将图片绑定到控件上

让我们开始连接Swiper控件,以便它开始显示图片。我们需要设置图片的来源,然后根据图片的状态控制加载标签的可见性。由于我们使用的是从互联网上获取的图片,可能需要几秒钟的时间来下载。一个好的用户界面将提供适当的反馈,帮助用户避免对正在发生的事情产生困惑。

我们将首先设置图片的源。

设置源

Image控件(在代码中称为image)有一个source属性。这个属性是ImageSource抽象类型。您可以使用几种不同类型的图像源。我们感兴趣的是UriImageSource类型,它接受一个 URI,下载图片,并允许图像控件显示它。

让我们扩展 Swiper 控件,以便我们可以设置源和描述:

  1. 打开 Controls/Swiper.Xaml.cs 文件(Swiper 控件的代码隐藏文件)。

  2. Swiper.Utils 添加一个 using 语句(using Swiper.Utils;),因为我们将会使用该命名空间中的 Picture 类。

  3. 将以下突出显示的代码添加到构造函数中:

    public SwiperControl()
    {
      InitializeComponent();
      var picture = new Picture();
      descriptionLabel.Text = picture.Description;
      image.Source = new UriImageSource() { Uri = picture.Uri };
    }
    

在这里,我们创建了一个 Picture 类的新实例,并通过设置该控制器的文本属性将描述分配给 GUI 中的 descriptionLabel 控制器。然后,我们将图像的源设置为 UriImageSource 类的新实例,并将 picture 实例的 URI 分配给它。这将导致图像从互联网上下载,并在下载完成后立即显示。

接下来,我们将更改加载标签的可见性以提供积极的用户反馈。

控制加载标签

当图像正在下载时,我们想在图像上方显示一个居中的加载文本。这已经在之前创建的 XAML 文件中,所以我们需要做的是在图像下载后隐藏它。我们将通过控制 loadingLabel 控件的 IsVisibleProperty 属性(是的,属性实际上命名为 IsVisibleProperty)来实现这一点,通过将绑定设置到图像的 IsLoading 属性。每当图像上的 IsLoading 属性发生变化时,绑定就会更改标签上的 IsVisible 属性。这是一个很好的“点火并忘记”的方法。

你可能已经注意到,当我们说我们不会使用绑定时,我们使用了绑定。这被用作一个快捷方式,以避免我们不得不编写与这个绑定本质上相同功能的代码。而且公平地说,虽然我们说过不要使用 MVVM 和数据绑定,但我们是在绑定到自身,而不是在类之间绑定,所以所有代码都包含在 Swiper 控件内部。

让我们添加控制 loadingLabel 控件的代码,如下所示:

  1. 打开 Swiper.xaml.cs 代码隐藏文件。

  2. 将以下加粗的代码添加到构造函数中:

    public SwiperControl()
    {
      InitializeComponent();
      var picture = new Picture();
      descriptionLabel.Text = picture.Description;
      image.Source = new UriImageSource() { Uri = picture.Uri };
      loadingLabel.SetBinding(IsVisibleProperty, "IsLoading");
      loadingLabel.BindingContext = image;
    }
    

在前面的代码中,loadingLabel 控制器将一个绑定设置到 IsVisibleProperty 属性,该属性属于所有控件继承的 VisualElement 类。它告诉 loadingLabel 监听绑定上下文中任何对象的 IsLoading 属性的变化。在这种情况下,这是图像控件。

接下来,我们将允许用户“向右滑动”或“向左滑动”。

处理滑动手势

本应用的核心功能是滑动手势。滑动手势是指用户按下控件并在屏幕上移动它。我们还将向 Swiper 控件添加随机旋转,以便在添加多个图像时使其看起来像一堆照片。

我们将首先向 SwiperControl 类添加一些字段,如下所示:

  1. 打开 SwiperControl.xaml.cs 文件。

  2. 在代码中将以下字段添加到类中:

    private readonly double _initialRotation;
    private static readonly Random _random = new Random();
    

第一个字段_initialRotation存储图像的初始旋转。我们将在构造函数中设置这个值。第二个字段是一个包含Random对象的static字段。你可能记得,最好创建一个静态的随机对象,以确保不会创建具有相同种子的多个随机对象。种子基于时间,所以如果我们创建的对象在时间上太接近,它们将生成相同的随机序列,这根本不是随机的。

接下来,我们必须创建一个事件处理程序来处理PanUpdated事件,我们将在本节末尾将其绑定,如下所示:

  1. 打开SwiperControl.xaml.cs代码隐藏文件。

  2. OnPanUpdated方法添加到类中,如下所示:

    private void OnPanUpdated(object sender, PanUpdatedEventArgs e)
    {
      switch (e.StatusType)
      {
        case GestureStatus.Started: PanStarted();
        break;
        case GestureStatus.Running: PanRunning(e);
        break;
        case GestureStatus.Completed: PanCompleted();
        break;
      }
    }
    

这段代码很简单。我们处理一个以PanUpdatedEventArgs对象作为第二个参数的事件。这是处理事件的标准方法。然后我们有一个switch子句来检查事件引用的是哪种状态。

平移手势可以有以下三种状态:

  • GestureStatus.Started:当平移开始时,事件以这种状态触发一次

  • GestureStatus.Running:事件被多次触发,每次你移动手指时触发一次

  • GestureStatus.Completed:当你放手时,事件最后一次被触发

对于这些状态中的每一个,我们调用特定的方法来处理不同的状态。我们现在将继续添加这些方法:

  1. 打开SwiperControl.xaml.cs代码隐藏文件。

  2. 将以下三个方法添加到类中,如下所示:

    private void PanStarted()
    {
      photo.ScaleTo(1.1, 100);
    }
    private void PanRunning(PanUpdatedEventArgs e)
    {
      photo.TranslationX = e.TotalX;
      photo.TranslationY = e.TotalY;
      photo.Rotation = _initialRotation + (photo.TranslationX / 25);
    }
    private void PanCompleted()
    {
      photo.TranslateTo(0, 0, 250, Easing.SpringOut);
      photo.RotateTo(_initialRotation, 250, Easing.SpringOut);
      photo.ScaleTo(1, 250);
    }
    

让我们先看看PanStarted()。当用户开始拖动图像时,我们希望添加一点效果,使其稍微高出表面。这是通过将图像按 10%的比例缩放来实现的。.NET MAUI 有一套出色的函数来完成这个任务。在这种情况下,我们在图像控件(命名为Photo)上调用ScaleTo()方法,并告诉它缩放到1.1,这对应于其原始大小的 10%。我们还告诉它在100毫秒(ms)内完成这个动作。这个调用也是可等待的,这意味着我们可以在执行下一个调用之前等待控件完成动画。在这种情况下,我们将使用一种触发即忘掉的方法。

接下来是PanRunning(),它在平移操作期间多次被调用。这个方法从调用PanRunning()的事件处理程序中接收一个名为PanUpdatedEventArgs的参数。我们也可以只传递XY值作为参数来减少代码的耦合。这是一件你可以实验的事情。该方法从事件的TotalX/TotalY属性中提取XY分量,并将它们分配给图像控件的TranslationX/TranslationY属性。我们还根据图像移动的距离稍微调整了旋转。

我们最后需要做的是在图像释放时将其恢复到初始状态。这可以在 PanCompleted() 中完成。首先,我们将图像(或移动)在 250 毫秒内翻译(或移动)回其原始局部坐标(0,0)。我们还添加了一个缓动函数,使其略微超出目标,然后动画回弹。我们可以尝试不同的预定义缓动函数;这些对于创建漂亮的动画很有用。我们同样将图像移动回其初始旋转。最后,我们在 250 毫秒内将其缩放回原始大小。

现在,是时候在构造函数中添加代码,以连接平移手势并设置一些初始旋转值。按照以下步骤进行:

  1. 打开 SwiperControl.xaml.cs 代码隐藏文件。

  2. 将以下加粗代码添加到构造函数中。注意构造函数中还有更多代码,所以不要覆盖整个方法;只需添加以下代码块中显示的加粗文本:

    public SwiperControl()
    {
      InitializeComponent();
      var panGesture = new PanGestureRecognizer();
      panGesture.PanUpdated += OnPanUpdated;
      this.GestureRecognizers.Add(panGesture);
      _initialRotation = _random.Next(-10, 10);
      photo.RotateTo(_initialRotation, 100, Easing.SinOut);
      var picture = new Picture();
      descriptionLabel.Text = picture.Description;
      image.Source = new UriImageSource() { Uri = picture.Uri };
      loadingLabel.SetBinding(IsVisibleProperty, "IsLoading");
      loadingLabel.BindingContext = image;
    }
    

所有 .NET MAUI 控件都有一个名为 GestureRecognizers 的属性。有不同类型的手势识别器,例如 TapGestureRecognizerSwipeGestureRecognizer。在我们的情况下,我们感兴趣的是 PanGestureRecognizer 类型。我们创建一个新的 PanGestureRecognizer 实例,并通过将其连接到我们之前创建的 OnPanUpdated() 方法来订阅 PanUpdated 事件。然后,我们将它添加到 Swiper 控件的 GestureRecognizers 集合中。

然后,我们设置图像的初始旋转,并确保我们存储当前的旋转值,以便我们可以修改旋转,然后将其旋转回原始状态。

接下来,我们将临时连接控件,以便我们可以对其进行测试。

测试控件

我们现在已经编写了所有代码,可以对这个控件进行测试运行。按照以下步骤进行:

  1. 打开 MainPage.xaml.cs

  2. Swiper.Controls 添加一个 using 语句(using Swiper.Controls;)。

  3. 将以下加粗代码添加到构造函数中:

    public MainPage()
    {
      InitializeComponent();
      MainGrid.Children.Add(new SwiperControl());
    }
    

如果构建一切顺利,我们最终应该得到如图所示的照片:

图 5.8 – 测试应用

图 5.8 – 测试应用

我们还可以拖动照片(平移它)。注意当你开始拖动时会有轻微的抬起效果,以及根据平移量(即总移动量)的照片旋转。如果你放手,照片会动画回到原位。

现在我们有了显示照片并可以左右滑动它的控件,我们需要对那些滑动做出反应。

创建决策区域

一个配对应用如果没有屏幕两侧的特殊拖放区域,那就什么都不是。我们在这里想做一些事情:

  • 当用户将图像拖动到任一侧时,应显示 LIKEDENY(决策区域)的文本

  • 当用户将图片拖放到决策区域时,应用应从页面上移除该图片

我们将通过在SwiperControl.xaml文件中添加一些 XAML 代码来创建这些区域,然后添加必要的代码来实现这一功能。值得注意的是,这些区域不是放置图像的热点,而是用于在控制表面上方显示标签。实际的放置区域是根据你拖动图像的距离来计算和确定的。

第一步是添加左右滑动动作的 UI。

扩展网格

Swiper控制有三个列(左侧、右侧和中间)定义。我们希望在图像被拖动到页面任一侧时,向用户添加某种视觉反馈。我们将通过添加一个带有每侧Label控制的StackLayout控制来实现这一点。

我们将首先添加右侧。

添加用于喜欢照片的 StackLayout

我们需要做的第一件事是在控制的右侧添加用于喜欢照片的StackLayout控制:

  1. 打开Controls/SwiperControl.xaml

  2. <!-- StackLayout for like here -->注释下添加以下代码:

    <StackLayout Grid.Column="2" x:Name="likeStackLayout" Opacity="0" Padding="0, 100">
      <Label Text="LIKE" TextColor="Lime" FontSize="30" Rotation="30" FontAttributes="Bold" />
    </StackLayout>
    

StackLayout控制是我们想要显示的子元素的容器。它有一个名称,并分配为在第三列渲染(由于零索引,代码中显示为Grid.Column="2")。Opacity属性设置为0,使其完全不可见,并且调整了Padding属性,使其从顶部向下移动一点。

StackLayout控制内部,我们将添加Label控制。

现在我们有了右侧,让我们添加左侧。

添加用于拒绝照片的 StackLayout

下一步是添加在控制的左侧用于拒绝照片的StackLayout控制:

  1. 打开Controls/SwiperControl.xaml

  2. <!-- StackLayout for deny here -->注释下添加以下代码:

    <StackLayout x:Name="denyStackLayout" Opacity="0" Padding="0, 100" HorizontalOptions="Start">
      <Label Text="DENY" TextColor="Red" FontSize="30" Rotation="-20" FontAttributes="Bold" />
    </StackLayout>
    

左侧StackLayout的设置与右侧相同,只是它应该位于第一列,这是默认设置,因此不需要添加Grid.Column属性。我们还将HorizontalOptions="End"指定为HorizontalOptions,这意味着内容应该右对齐。

UI 设置完成后,我们现在可以着手实现逻辑,通过调整LIKEDENIED文本控制的透明度,在照片平移时为用户提供视觉反馈。

确定屏幕大小

为了能够计算用户拖动图像的距离百分比,我们需要知道控件的大小。这直到.NET MAUI 布局控件后才确定。

我们将重写OnSizeAllocated()方法并在类中添加一个_screenWidth字段来跟踪窗口的当前宽度:

  1. 打开SwiperControl.xaml.cs

  2. 将以下代码添加到文件中,将字段放在类的开头,并在构造函数下方添加OnSizeAllocated()方法:

    private double _screenWidth = -1;
    protected override void OnSizeAllocated(double width, double height)
    {
      base.OnSizeAllocated(width, height);
      if (Application.Current.MainPage == null)
      {
        return;
      }
      _screenWidth = Application.Current.MainPage.Width;
    }
    

_screenWidth 字段用于在解决后立即存储宽度。我们通过重写 .NET MAUI 调用的 OnSizeAllocated() 方法来实现这一点,当控制的大小被分配时调用。这会被多次调用。第一次调用实际上是在宽度和高度设置之前,以及当前应用程序的 MainPage 属性设置之前。此时,宽度和高度被设置为 -1Application.Current.MainPage 属性为 null。我们通过检查 Application.Current.MainPage 是否为 null 来寻找此状态,如果是 null,则返回。我们也可以检查宽度上的 -1 值。两种方法都可行。然而,如果它具有值,我们希望将其存储在我们的 _screenWidth 字段中供以后使用。

.NET MAUI 会在应用框架发生变化时调用 OnSizeAllocated() 方法。这对于 WinUI 应用程序尤其相关,因为它们位于用户可以轻松更改的窗口中。Android 和 iOS 应用程序不太可能再次收到此方法的调用,因为应用程序将占据整个屏幕的空间。

添加代码以计算状态

为了计算图像的状态,我们需要定义我们的区域,然后创建一个函数,该函数接受当前的移动量,并根据我们平移图像的距离更新 GUI 决策区域的不透明度。

定义计算状态的函数

让我们按照以下几个步骤添加 CalculatePanState() 方法来计算平移图像的距离,并确定是否应该开始影响 GUI:

  1. 打开 Controls/SwiperControl.xaml.cs

  2. 在类中任何位置添加顶部属性和 CalculatePanState() 方法,如下面的代码块所示:

    private const double DeadZone = 0.4d;
    private const double DecisionThreshold = 0.4d;
    private void CalculatePanState(double panX)
    {
      var halfScreenWidth = _screenWidth / 2;
      var deadZoneEnd = DeadZone * halfScreenWidth;
      if (Math.Abs(panX) < deadZoneEnd)
      {
        return;
      }
      var passedDeadzone = panX < 0 ? panX + deadZoneEnd : panX - deadZoneEnd;
      var decisionZoneEnd = DecisionThreshold * halfScreenWidth;
      var opacity = passedDeadzone / decisionZoneEnd;
      opacity = double.Clamp(opacity, -1, 1);
      likeStackLayout.Opacity = opacity;
      denyStackLayout.Opacity = -opacity;
    }
    

我们将以下两个值定义为常量:

  • DeadZone 定义了在平移图像时,中心点两侧的 40% (0.4) 可用空间为死区。如果我们在这个区域内释放图像,它将简单地返回到屏幕中心,不执行任何操作。

  • 下一个常量是 DecisionThreshold,它定义了另外 40% (0.4) 的可用空间。这用于在布局两侧插值 StackLayout 的不透明度。

然后,每当平移发生变化时,我们使用这些值来检查平移动作的状态。如果 X 的绝对平移值 (panX) 小于死区,则不执行任何操作并返回。如果不满足条件,我们计算超过死区的距离以及进入决策区的距离。我们根据这个插值计算不透明度值,并将值限制在 -11 之间。

最后,我们将不透明度设置为 likeStackLayoutdenyStackLayout 的此值。

连接平移状态检查

当图像正在平移时,我们想要更新状态,如下所示:

  1. 打开 Controls/SwiperControl.xaml.cs

  2. PanRunning() 方法中添加以下加粗代码:

    private void PanRunning(PanUpdatedEventArgs e)
    {
      photo.TranslationX = e.TotalX; photo.TranslationY = e.TotalY;
      photo.Rotation = _initialRotation + (photo.TranslationX / 25);
      CalculatePanState(e.TotalX);
    }
    

这个 PanRunning() 方法的添加将总移动量传递到 CalculatePanState() 方法,以确定是否需要调整控件右侧或左侧的 StackLayout 的不透明度。

添加退出逻辑

到目前为止,一切顺利,除了如果我们拖动图片到边缘并释放,文本会保留下来。我们需要确定用户何时停止拖动图片,以及,如果是这样,图片是否在决策区域。

让我们添加将照片动画回原始位置的代码。

检查图片是否应该退出

我们需要一个简单的函数来判断图片是否已经平移足够远,可以算作图片的退出。要创建这样的函数,请按照以下步骤操作:

  1. 打开 Controls/SwiperControl.xaml.cs 文件。

  2. CheckForExitCriteria() 方法添加到类中,如下代码片段所示:

    private bool CheckForExitCriteria()
    {
      var halfScreenWidth = _screenWidth / 2;
      var decisionBreakpoint = DeadZone * halfScreenWidth;
      return (Math.Abs(photo.TranslationX) > decisionBreakpoint);
    }
    

这个函数计算我们是否已经越过了死区并进入了决策区。我们需要使用 Math.Abs() 方法来获取总绝对值以进行比较。我们也可以使用 <> 操作符,但我们使用这种方法因为它更易读。这是一个关于代码风格和品味的问题——请随意按照您的方式来做。

移除图片

如果我们确定图片已经平移足够远,可以算作退出,我们希望将其动画移出屏幕,然后从页面上移除图片。为此,请按照以下步骤操作:

  1. 打开 Controls/SwiperControl.xaml.cs 文件。

  2. Exit() 方法添加到类中,如下代码块所示:

    private void Exit()
    {
      MainThread.BeginInvokeOnMainThread(async () =>
      {
        var direction = photo.TranslationX < 0 ? -1 : 1;
        await photo.TranslateTo(photo.TranslationX + (_screenWidth * direction), photo.TranslationY, 200, Easing.CubicIn);
        var parent = Parent as Layout;
        parent?.Children.Remove(this);
      });
    }
    

让我们分解前面的代码块,了解 Exit() 方法的作用:

  1. 我们首先确保这个调用是在 UI 线程上完成的,这也被称为 MainThread 线程。这是因为只有 UI 线程可以进行动画。

  2. 我们还需要异步运行这个线程,这样我们就可以一石二鸟。因为这个方法完全是关于将图片动画到屏幕的任一侧,我们需要确定动画的方向。我们通过确定图片的总平移量是正数还是负数来实现这一点。

  3. 然后,我们使用这个值通过 photo.TranslateTo() 调用等待平移。

  4. 我们使用 await 来等待这个调用,因为我们不希望代码执行继续直到它完成。一旦完成,我们就从父控件的子控件集合中移除该控件,使其永远消失。

更新 PanCompleted

关于图片是否应该消失或简单地返回到原始状态的决定是在 PanCompleted() 方法中触发的。在这里,我们将连接我们之前两个部分中创建的两个方法。请按照以下步骤操作:

  1. 打开 Controls/SwiperControl.xaml.cs 文件。

  2. 将以下代码以粗体形式添加到 PanCompleted() 方法中:

    private void PanCompleted()
    {
      if (CheckForExitCriteria())
      {
        Exit();
      }
      likeStackLayout.Opacity = 0;
      denyStackLayout.Opacity = 0;
      photo.TranslateTo(0, 0, 250, Easing.SpringOut);
      photo.RotateTo(_initialRotation, 250, Easing.SpringOut);
      photo.ScaleTo(1, 250);
    }
    

本节的最后一步是使用CheckForExitCriteria()方法,如果满足这些条件,则使用Exit()方法。如果未满足退出条件,我们需要重置状态和StackLayout的不透明度,使一切恢复正常。

现在我们可以左右滑动,让我们添加一些事件,当用户滑动时触发。

添加到控制器的事件

在控制器本身中,我们剩下要做的最后一件事是添加一些事件,以指示图片是否已被喜欢拒绝。我们将使用一个干净的界面,允许简单使用控件,同时隐藏所有实现细节。

声明两个事件

为了使控制器更容易从应用程序本身进行交互,我们需要添加LikeDeny事件,如下所示:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 在类开始处添加两个事件声明,如下代码片段所示:

    public event EventHandler OnLike;
    public event EventHandler OnDeny;
    

这两个是标准的事件声明,带有开箱即用的事件处理器。

触发事件

我们需要在Exit()方法中添加代码来触发我们之前创建的事件,如下所示:

  1. 打开Controls/SwiperControl.xaml.cs

  2. Exit()方法中添加以下加粗代码:

    private void Exit()
    {
      MainThread.BeginInvokeOnMainThread(async () =>
      {
        var direction = photo.TranslationX < 0 ? -1 : 1;
        if (direction > 0)
        {
          OnLike?.Invoke(this, new EventArgs());
        }
        if (direction < 0)
        {
          OnDeny?.Invoke(this, new EventArgs());
        }
        await photo.TranslateTo(photo.TranslationX + (_screenWidth * direction), photo.TranslationY, 200, Easing.CubicIn);
        var parent = Parent as Layout;
        parent?.Children.Remove(this);
      });
    }
    

在这里,我们注入代码以检查我们是在喜欢还是拒绝图片。然后,根据这些信息触发正确的事件。

我们现在准备好最终化这个应用;Swiper控制器已完成,因此现在我们需要添加正确的初始化代码来完成它。

连接 Swiper 控制器

我们现在已经到达了本章的最后一部分。在本节中,我们将连接图片,并使我们的应用成为一个闭环应用,可以永久使用。当应用启动时,我们将添加 10 张图片,这些图片将从互联网上下载。每次移除一张图片,我们就会简单地添加另一张。

添加图片

让我们先创建一些代码,这些代码将添加图片到MainView类。首先,我们将添加初始图片;然后,我们将为每次图片被喜欢或拒绝时在堆栈底部添加新图片创建一个逻辑模型。

添加初始图片

要使照片看起来像堆叠的,我们需要至少 10 张。按照以下步骤进行:

  1. 打开MainPage.xaml.cs

  2. AddInitalPhotos()方法和InsertPhotoMethod()添加到类中,如下代码块所示:

    private void AddInitialPhotos()
    {
      for (int i = 0; i < 10; i++)
      {
        InsertPhoto();
      }
    }
    private void InsertPhoto()
    {
      var photo = new SwiperControl();
      this.MainGrid.Children.Insert(0, photo);
    }
    

首先,我们创建一个名为AddInitialPhotos()的方法,该方法将在启动时被调用。此方法简单地调用InsertPhoto()方法 10 次,并在每次调用时向MainGrid添加一个新的SwiperControl。它将控件插入堆栈的第一个位置,由于控件集合是从开始到结束渲染的,因此这实际上将控件放置在堆栈底部。

在构造函数中发起调用

我们需要调用此方法以实现魔法效果,因此请按照以下步骤进行操作:

  1. 打开MainPage.xaml.cs

  2. 在构造函数中添加以下加粗代码:

    public MainPage()
    {
      InitializeComponent();
      AddInitialPhotos();
    }
    

这里没有太多可说的。一旦MainPage对象被初始化,我们调用方法添加 10 张从互联网下载的随机照片。

添加计数标签

我们还想在应用程序中添加一些值。我们可以通过在Swiper控件集合下方添加两个标签来实现。每次用户对图像进行评分时,我们将增加两个计数器之一,并显示结果。

因此,让我们添加显示标签所需的 XAML 代码:

  1. 打开MainPage.xaml

  2. <!-- Placeholder for later -->注释替换为以下加粗的代码:

    <Grid Grid.Row="1" Padding="30">
      <Grid.RowDefinitions>
        <RowDefinition Height="auto" />
        <RowDefinition Height="auto" />
        <RowDefinition Height="auto" />
        <RowDefinition Height="auto" />
      </Grid.RowDefinitions>
      <Label Text="LIKES" />
      <Label x:Name="likeLabel" Grid.Row="1" Text="0" FontSize="Large" FontAttributes="Bold" />
      <Label Grid.Row="2" Text="DENIED" />
      <Label x:Name="denyLabel" Grid.Row="3" Text="0" FontSize="Large" FontAttributes="Bold" />
    </Grid>
    

此代码添加了一个新的Grid控件,具有四个自动高度的行。这意味着我们计算每行内容的长度,并使用这个值进行布局。这与StackLayout相同,但我们想展示一种更好的实现方式。

我们在每一行添加一个Label控件,并将其中两个命名为likeLabeldenyLabel。这两个命名标签将包含有关有多少图像被点赞和有多少被拒绝的信息。

订阅事件

最后一步是连接OnLikeOnDeny事件,并将总计数显示给用户。

添加方法以更新 GUI 和响应用件

我们需要一些代码来更新 GUI 并跟踪计数。按照以下步骤进行:

  1. 打开MainPage.xaml.cs

  2. 将以下代码添加到类中:

    private int _likeCount;
    private int _denyCount;
    private void UpdateGui()
    {
      likeLabel.Text = _likeCount.ToString();
      denyLabel.Text = _denyCount.ToString();
    }
    private void Handle_OnLike(object sender, EventArgs e)
    {
      _likeCount++;
      InsertPhoto();
      UpdateGui();
    }
    private void Handle_OnDeny(object sender, EventArgs e)
    {
      _denyCount++;
      InsertPhoto();
      UpdateGui();
    }
    

上述代码块顶部的两个字段跟踪点赞和拒绝的数量。由于它们是值类型变量,它们的默认值是零。

为了使这些标签的变化显示在 UI 中,我们创建了一个名为UpdateGui()的方法。这个方法将上述两个字段的值分配给两个标签的Text属性。

下面的两个方法是处理OnLikeOnDeny事件的处理器。它们增加适当的字段,添加一张新照片,然后更新 GUI 以反映变化。

连接事件

每次创建一个新的SwiperControl实例时,我们需要连接事件,如下所示:

  1. 打开MainPage.xaml.cs

  2. InsertPhoto()方法中,以下代码需要加粗:

    private void InsertPhoto()
    {
      var photo = new SwiperControl();
      photo.OnDeny += Handle_OnDeny;
      photo.OnLike += Handle_OnLike;
      this.MainGrid.Children.Insert(0, photo);
    }
    

添加的代码连接了我们之前定义的事件处理程序。这些事件使得与我们的新控件交互变得容易。自己试一试,并玩一玩你创建的应用程序。

摘要

干得好!在本章中,我们学习了如何创建一个可重用、外观良好的控件,可以在任何.NET MAUI 应用程序中使用。为了增强应用程序的用户体验UX),我们使用了一些动画,为用户提供更多的视觉反馈。我们还巧妙地使用了 XAML 来定义一个看起来像照片的控件 GUI,并附有手写描述。

之后,我们使用事件将控制的行为暴露回MainPage页面,以限制您的应用和控制之间的接触面。最重要的是,我们触及了GestureRecognizers的主题,这在我们处理常见手势时可以使我们的生活变得更加轻松。

正在寻找如何使这个应用变得更好的想法?试试这个:保留点赞和踩不喜欢的记录,并添加一个视图来显示每个收藏。

在下一章中,我们将使用CollectionViewCarouselView控件创建一个照片库应用。该应用还将允许您通过使用存储来保留在应用运行之间的收藏列表,来收藏您喜欢的照片。

第六章:使用 CollectionView 和 CarouselView 构建 Photo Gallery 应用程序

在本章中,我们将构建一个应用程序,展示用户设备相册(照片库)中的照片。用户还可以选择照片作为收藏夹。然后我们将探讨不同的照片显示方式——在轮播图中和在多列网格控件中。通过使用 .NET MAUI CarouselView 控件显示一组图像,用户可以滑动查看每张图像。为了显示大量图像,我们将使用 .NET MAUI CollectionView 控件和垂直滚动,以便用户查看所有图像。通过学习如何使用这些控件,我们将在构建实际应用程序时能够将它们用于许多其他情况。

本章将涵盖以下主题:

  • 从用户请求访问数据的权限

  • 如何从 iOS 和 Mac Catalyst 照片库导入照片

  • 如何从 Android 照片库导入照片

  • 如何从 Windows 照片库导入照片

  • 如何在 .NET MAUI 中使用 CarouselView

  • 如何在 .NET MAUI 中使用 CollectionView

技术要求

要完成此项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载。有关如何设置环境的更多详细信息,请参阅 第一章.NET MAUI 简介

要使用 Visual Studio for PC 构建 iOS 应用程序,您必须连接一个 MacintoshMac)设备。如果您根本无法访问 Mac,您可以只遵循此项目的 Android 和 Windows 部分。

您可以在本章中找到代码的完整源代码,请访问 github.com/PacktPublishing/MAUI-Projects-3rd-Edition/

项目概述

几乎所有应用程序都会可视化数据集合,在本章中,我们将重点关注两个可以用于显示数据集合的 .NET MAUI 控件——CollectionViewCarouselView。我们的应用程序将展示用户设备上的照片;为此,我们需要为每个平台创建一个照片导入器——一个用于 iOS 和 Mac Catalyst,一个用于 Windows,一个用于 Android。

此项目的构建时间约为 60 分钟。

构建 Photo Gallery 应用程序

此项目,就像所有其他项目一样,是一个 文件 | 新建 | 项目... 风格的项目,这意味着我们根本不会导入任何代码。因此,本节全部关于创建项目和设置基本项目结构。

是时候开始使用以下步骤构建应用程序了。让我们开始吧!

创建新项目

第一步是创建一个新的 .NET MAUI 项目:

  1. 打开 Visual Studio 2022 并选择 创建新项目

图 6.1 – Visual Studio 2022

图 6.1 – Visual Studio 2022

这将打开 创建新项目 向导。

  1. 在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项:

图 6.2 – 创建新项目

图 6.2 – 创建新项目

  1. 点击 下一步

  2. 通过命名您的项目来完成向导的下一步。在本例中,我们将我们的应用程序命名为 GalleryApp。通过点击 下一步,继续到下一个对话框,如图所示:

图 6.3 – 配置您的项目

图 6.3 – 配置您的项目

  1. 点击 下一步

  2. 最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持LTS),而 .NET 7 可用为 标准期限支持。在本书中,我们假设您将使用 .NET 7。

图 6.4 – 补充信息

图 6.4 – 补充信息

  1. 通过点击 创建 并等待 Visual Studio 创建项目来最终完成设置。

就这样,应用程序就创建完成了。让我们先获取一些照片来显示。

导入照片

照片的导入是在所有平台上执行的操作,因此我们将创建一个照片导入接口。该接口将有两个 Get 方法—一个支持分页,另一个获取指定文件名的照片。这两种方法也将接受一个质量参数,但我们只会在 iOS 照片导入器中使用该参数。质量参数将是一个具有两个选项的 enum 类型—HighLow。然而,在我们创建接口之前,我们将创建一个模型类,该类将使用以下步骤表示导入的照片:

  1. GalleryApp 项目中创建一个名为 Models 的新文件夹。

  2. 在最近创建的文件夹中创建一个名为 Photo 的新类:

    namespace GalleryApp.Models;
    public class Photo
    {
      public string Filename { get; set; }
      public byte[] Bytes { get; set; }
    }
    

现在我们已经创建了模型类,我们可以继续创建接口:

  1. 在项目中创建一个名为 Services 的新文件夹。

  2. Services 文件夹中创建一个名为 IPhotoImporter 的新接口:

    namespace GalleryApp.Services;
    using System.Collections.ObjectModel;
    using GalleryApp.Models;
    public interface IPhotoImporter
    {
      Task<ObservableCollection<Photo>> Get(int start, int  count, 
    Quality quality = Quality.Low);
      Task<ObservableCollection<Photo>> Get(List<string> filenames, 
    Quality quality = Quality.Low);
    }
    
  3. Services 文件夹中,添加一个新文件并创建一个名为 Qualityenum 类型,包含两个成员—LowHigh

    namespace GalleryApp.Services;
    public enum Quality
    {
      Low,
      High
    }
    
  4. Services 文件夹中创建一个名为 PhotoImporter 的新类:

    namespace GalleryApp.Services;
    using GalleryApp.Models;
    using System.Collections.ObjectModel;
    internal partial class PhotoImporter : IPhotoImporter
    {
      private partial Task<string[]> Import();
      public partial Task<ObservableCollection<Photo>> Get(int 
    start, int count, Quality quality);
      public partial Task<ObservableCollection<Photo>> 
    Get(List<string> filenames, Quality quality);
    }
    

    此类为我们提供了特定平台实现的基础。通过将其标记为 partial,我们告诉编译器该类在其他文件中还有更多内容。我们将在稍后把实现放在特定平台的文件夹中。

现在我们有了接口,我们可以添加应用程序权限。

请求应用程序权限

如果您的应用程序不需要设备任何额外的功能,如位置、相机或互联网,那么您将需要使用权限来请求访问这些资源。虽然每个平台对权限的实现略有不同,但 .NET MAUI 将特定平台的权限映射到一组通用的权限,以简化操作。.NET MAUI 的权限系统也是可扩展的,这样您就可以创建最适合您应用程序的自定义权限。

让我们通过一个具体的例子来看看请求权限是如何工作的。GalleryApp 显示来自设备照片库的图片。在 iOS 和 Android 的案例中,应用必须在能够使用照片库之前声明并请求访问权限。虽然这些权限的配置和命名方式不同,但 .NET MAUI 定义了一个 Photo 权限,隐藏了这些实现细节。

按照以下步骤向 GalleryApp 添加权限检查:

  1. GalleryApp 项目中创建一个名为 AppPermissions 的新类。

  2. 修改类定义以添加 partial 修饰符,并移除默认构造函数:

    namespace GalleryApp;
    internal partial class AppPermissions
    {
    }
    
  3. 将以下类定义添加到 AppPermissions 类中:

    internal partial class AppPermissions
    {
      internal partial class AppPermission : Permissions.Photos
      {
      }
    }
    

    这创建了一个名为 AppPermission 的类型,它从默认的 .NET MAUI Photos 权限类继承。它也被标记为 partial,以便添加特定于平台的实现细节。剧透一下:我们将需要一些特定于平台的权限。

  4. 将以下方法添加到 AppPermissions 类中:

    public static async Task<PermissionStatus> 
    CheckRequiredPermission() => await Permissions.
    CheckStatusAsync<AppPermission>();
    

    CheckRequiredPermission 方法用于确保在我们尝试任何可能会因为权限不足而失败的操作之前,我们的应用拥有正确的权限。其实现是调用 .NET MAUI 的 CheckSyncStatus 方法,并使用我们的 AppPermission 类型。它返回一个 PermissionStatus,这是一个枚举类型。我们主要对 DeniedGranted 值感兴趣。

  5. CheckAndRequestRequiredPermission 方法添加到 AppPermissions 类中:

    public static async Task<PermissionStatus> 
    CheckAndRequestRequiredPermission()
    {
      PermissionStatus status = await Permissions.
    CheckStatusAsync<AppPermission>();
      if (status == PermissionStatus.Granted)
                return status;
      if (status == PermissionStatus.Denied && DeviceInfo.Platform 
    == DevicePlatform.iOS)
      {
        // Prompt the user to turn on in settings
        // On iOS once a permission has been denied it may not be 
    requested again from the application
        await App.Current.MainPage.DisplayAlert("Required App 
    Permissions", "Please enable all permissions in Settings for 
    this App, it is useless without them.", "Ok");
      }
      if
      (Permissions.ShouldShowRationale<AppPermission>())
      {
        // Prompt the user with additional information as to why the 
    permission is needed
        await App.Current.MainPage.DisplayAlert("Required App 
    Permissions", "This is a Photo gallery app, without these 
    permissions it is useless.", "Ok");
      }
      status = await MainThread.InvokeOnMainThreadAsync(Permissions.
    RequestAsync<AppPermission>);
      return status;
      }
    }
    

    CheckAndRequestRequiredPermission 方法处理从用户请求访问权限的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果您在 iOS 上且权限已被拒绝,则无法再次请求,因此您必须指导用户如何通过设置面板授予应用权限。在请求行为中,Android 包括如果用户拒绝访问时骚扰用户的能力。这种行为通过 .NET MAUI 的 ShouldShowRationale 方法公开。对于不支持此行为的任何平台,它将返回 false;在 Android 上,第一次用户拒绝访问时将返回 true,如果用户第二次拒绝,则返回 false。最后,我们请求用户对 AppPermission 进行访问。同样,.NET MAUI 正在隐藏所有平台实现细节,使得检查和请求访问某些资源变得非常直接。

看起来熟悉吗?

如果前面的代码看起来很熟悉,那可能是因为它。这正是 .NET MAUI 文档中描述的实现。您可以在 learn.microsoft.com/en-us/dotnet/maui/platform-integration/appmodel/permissions 找到它。

现在我们已经设置了共享的 AppPermissions,我们可以开始平台实现。

从 iOS 照片库导入照片

首先,我们将编写 iOS 代码。为了访问照片,我们需要用户的权限,并且我们需要解释为什么我们需要请求权限。为此,我们将解释为什么需要权限的文本添加到 info.plist 文件中。当请求用户权限时,将显示此文本。要打开 info.plist 文件,在 Platforms/iOS 文件夹中的文件上右键单击并点击 Info.plist 编辑器。将以下文本添加到 <``dict> 元素的末尾:

<key> NSPhotoLibraryUsageDescription </key>
<string> We want to show your photos in this app </string>

我们将要做的第一件事是实现 Import 方法,该方法读取可以加载哪些照片:

  1. Platforms/iOS 文件夹中的 GalleryApp 项目中,创建一个名为 PhotoImporter 的新类。

  2. 将命名空间声明从 GalleryApp.Platforms.iOS 更改为 GalleryApp.Services

  3. 即使部分类定义在不同的文件夹中,它们也必须在同一个命名空间中。

  4. 添加 partial 修饰符。

  5. 解析所有引用。

  6. 创建一个名为 assetsPHAsset 字典的 private 字段。这将用于存储照片信息:

    private Dictionary<string,PHAsset> assets;
    
  7. 创建一个名为 Import 的新 private partial 方法:

    private partial async Task<string[]> Import()
    {
    }
    
  8. Import 方法中,使用 AppPermissions.Check``AndRequestRequiredPermission 方法请求授权:

    var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
    
  9. 如果用户已经授予访问权限,则使用 PHAsset.FetchAssets 通过 PHAsset 获取所有图像资产:

    internal partial class PhotoImporter
    {
      private Dictionary<string,PHAsset> assets;
      private partial async Task<string[]> Import()
      {
        var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
        if (status == PermissionStatus.Granted)
        {
          assets = PHAsset.FetchAssets(PHAssetMediaType.Image, null)
          .Select(x => (PHAsset)x)
          .ToDictionary(asset => asset.
    ValueForKey((NSString)"filename").ToString(), asset => asset);
        }
        return assets?.Keys.ToList().ToArray();
    }
    

现在,我们已经获取了所有照片的 PHAssets,但要显示照片,我们需要获取实际的照片。在 iOS 上,为了做到这一点,我们需要请求资产的图像。这是一项异步执行的操作,因此我们将使用 ObservableCollection

private void AddImage(ObservableCollection<Photo> photos, string path, 
PHAsset asset, Quality quality)
  {
    var options = new PHImageRequestOptions()
    {
      NetworkAccessAllowed = true,
      DeliveryMode = quality == Quality.Low ?
      PHImageRequestOptionsDeliveryMode.FastFormat :
      PHImageRequestOptionsDeliveryMode.HighQualityFormat
    };
        PHImageManager.DefaultManager.RequestImageForAsset(asset, 
PHImageManager.MaximumSize, PHImageContentMode.AspectFill, options, 
(image, info) =>
    {
      using NSData imageData = image.AsPNG();
      var bytes = new byte[imageData.Length];
             System.Runtime.InteropServices.Marshal.Copy(imageData.
Bytes, bytes, 0, Convert.ToInt32(imageData.Length));
      photos.Add(new Photo()
      {
        Bytes = bytes,
        Filename = Path.GetFileName(path)
      });
    });
  }

现在,我们已经拥有了开始实现接口中的两个 Get 方法所需的一切。我们将从部分 Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法开始,该方法将用于从加载照片的 CollectionView 视图中获取照片:

public partial async Task<ObservableCollection<Photo>> Get(int start, 
int count, Quality quality)
  {
    var photos = new ObservableCollection<Photo>();
    var status = await AppPermissions.
CheckAndRequestRequiredPermission();
    if (status == PermissionStatus.Granted)
    {
      var result = await Import();
      if (result.Length == 0)
      {
        return photos;
      }
      Index startIndex = start;
      Index endIndex = start + count;
      if (endIndex.Value >= result.Length)
      {
        endIndex = result.Length;
      }
      if (startIndex.Value > endIndex.Value)
      {
        return photos;
      }
      foreach (var path in result[startIndex..endIndex])
      {
        AddImage(photos, path, assets[path], quality);
      }
    }
    return photos;
  }

来自 IPhotoImporter 接口的另一个方法 Task<ObservableCollection<Photo>> Get(List<string> filenames, Quality quality = Quality.Low)Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法非常相似。唯一的区别是没有处理索引的代码,并且遍历结果数组的 foreach 循环包含一个 if 语句,检查文件名是否与当前的 PHAsset 对象相同,如果是,则调用 AddImage 方法:

public partial async Task<ObservableCollection<Photo>> 
Get(List<string> filenames, Quality quality)
  {
    var photos = new ObservableCollection<Photo>();
    var result = await Import();
    if (result?.Length == 0)
    {
      return photos;
    }
    foreach (var path in result)
    {
      if (filenames.Contains(path))
      {
        AddImage(photos, path, assets[path], quality);
      }
    }
    return photos;
  }

在前面的代码中,我们设置了 NetworkAccessAllowed = true。我们这样做是为了使下载来自 iCloud 的照片成为可能。

现在,我们项目中的四个照片导入器之一已经完成。下一个我们将实现的是 Mac Catalyst 导入器。

从 Mac Catalyst 照片库导入照片

Mac Catalyst 导入器与我们刚刚为 iOS 所做的是完全相同的。然而,并没有一种方便的方式来表达,“我只需要这个类用于 iOS 和 Mac Catalyst,而不需要其他任何东西。”因此,我们将走最简单的路径,直接将类复制到 Mac Catalyst 平台文件夹中:

  1. 右键单击项目中的Platforms/iOS文件夹中的PhotoImporter.cs文件并选择复制

  2. 右键单击Platforms/MacCatalyst文件夹并选择粘贴

  3. 右键单击Platforms/MacCatalyst文件夹中的Info.plist文件并点击<dict>元素:

    <key> NSPhotoLibraryUsageDescription </key>
    <string> We want to show your photos in this app </string>
    

这就完成了PhotoImporter类的 Mac Catalyst 实现。接下来,我们将着手处理 Android 平台。

从 Android 照片库导入图片

现在我们已经为 iOS 创建了一个实现,我们将为 Android 做同样的处理。在我们直接进入导入器之前,我们需要解决 Android 上的权限问题。

在 Android API 版本 33 中,添加了三个新权限以启用对媒体文件的读取访问:ReadMediaImagesReadMediaVideosReadMediaAudio。在 API 版本 33 之前,所需的只是ReadExternalStorage权限。为了正确请求设备的 API 版本的正确权限,在Platform/Android文件夹中创建一个名为AppPermissions的新文件,并将其修改如下:

using Android.OS;
[assembly: Android.App.UsesPermission(Android.Manifest.Permission.
ReadMediaImages)]
[assembly: Android.App.UsesPermission(Android.Manifest.Permission.
ReadExternalStorage, MaxSdkVersion = 32)]
namespace GalleryApp;
internal partial class AppPermissions
{
  internal partial class AppPermission : Permissions.Photos
  {
    public override (string androidPermission, bool isRuntime)[] 
RequiredPermissions
    {
      get
      {
        List<(string androidPermission, bool isRuntime)> perms = new();
        if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
                    perms.Add((global::Android.Manifest.Permission.
ReadMediaImages, true));
        else
                    perms.Add((global::Android.Manifest.Permission.
ReadExternalStorage, true));
        return perms.ToArray();
      }
    }
  }
}

前两行将所需的权限添加到AndroidManifet.xml文件中,这与我们手动对 iOS 的info.plist文件所做的是类似的。然而,我们只需要ReadMediaImages权限用于 API 33 及以上版本,以及ReadExternalStorage权限用于低于 33 版本的 API 版本,因此我们为ReadExternalStorage属性设置了MaxSdkVersion。然后,我们通过实现RequirePermissions属性来扩展AppPermission类。在RequirePermissions中,如果 API 版本为 33 或更高,我们返回包含ReadMediaImages权限的数组;如果 API 版本低于 33,则返回ReadExternalStorage权限。perms数组中的布尔值表示权限是否需要在运行时请求用户的访问权限。现在,当应用启动时,它将根据设备的 API 级别请求正确的权限。

现在我们已经整理好了 Android 特定的权限,我们可以按照以下步骤导入图片:

  1. Platforms/Android文件夹中的项目中创建一个名为PhotoImporter的新类。

  2. 将命名空间声明从GalleryApp.Platforms.Android更改为GalleryApp.Services

  3. 即使部分类定义在不同的文件夹中,它们也必须在同一个命名空间中。

  4. 添加partial修饰符。

  5. 添加一个using语句以使用GalleryApp.Models中的Photo类。

  6. 与 iOS 实现类似,我们将首先实现Import方法。添加一个名为Import的新方法,如下所示:

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      return paths.ToArray();
    }
    
  7. 从用户那里请求权限以获取照片(以下代码块中突出显示):

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
      if (status == PermissionStatus.Granted)
      {
      }
      return paths.ToArray();
    }
    
  8. 现在,使用 ContentResolver 查询文件并将它们添加到结果中:

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
      if (status == PermissionStatus.Granted)
      {
        var imageUri = MediaStore.Images.Media.ExternalContentUri;
        var projection = new string[] { MediaStore.IMediaColumns.
    Data };
        var orderBy = MediaStore.Images.IImageColumns.DateTaken;
        var cursor = Platform.CurrentActivity.ContentResolver.
    Query(imageUri, projection, null, null, orderBy);
        while (cursor.MoveToNext())
        {
          string path = cursor.GetString(cursor.
    GetColumnIndex(MediaStore.IMediaColumns.Data));
          paths.Add(path);
        }
      }
      return paths.ToArray();
    }
    

然后,我们将开始编辑 Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法。如果导入成功,我们将继续编写处理在此图像加载中应导入哪些照片的代码。条件由 startcount 参数指定。使用以下代码列表来实现第一个 Get 方法:

public partial async Task<ObservableCollection<Photo>> Get(int start, 
int count, Quality quality)
{
  var photos = new ObservableCollection<Photo>();
  var result = await Import();
  if (result.Length == 0)
  {
    return photos;
  }
  Index startIndex = start;
  Index endIndex = start + count;
  if (endIndex.Value >= result.Length)
  {
    endIndex = result.Length;
  }
  if (startIndex.Value > endIndex.Value)
  {
    return photos;
  }
  foreach (var path in result[startIndex..endIndex])
  {
    photos.Add(new()
    {
      Bytes = File.ReadAllBytes(path),
      Filename = Path.GetFileName(path)
    });
  }
  return photos;
}

让我们回顾一下前面的代码。第一步是调用 Import 方法并验证是否有照片要导入。如果没有,我们简单地返回一个空列表。如果有照片要导入,那么我们需要知道 photos 数组中的 startIndexendIndex 以导入。代码默认 endIndexstartIndex 加上要导入的照片数量。如果要导入的照片数量大于 Import 方法返回的照片数量,则将 endindex 调整为 Import 方法返回的照片长度。如果 startIndex 大于 endIndex,则返回照片列表。最后,我们可以从照片数组中读取 startIndexendIndex 的图像,并返回每个条目的文件字节和文件名。

现在,我们将继续处理其他 Task<ObservableCollection<Photo>> Get (List filenames, Quality quality = Quality.Low) 方法。

创建一个 foreach 循环来遍历所有照片并检查每个照片是否在 filenames 参数中指定。如果照片在 filenames 参数中指定,则从路径读取照片,就像第一个 Get 方法一样:

public partial async Task<ObservableCollection<Photo>> 
Get(List<string> filenames, Quality quality)
{
  var photos = new ObservableCollection<Photo>();
  var result = await Import();
  if (result.Length == 0)
  {
    return photos;
  }
  foreach (var path in result)
  {
    var filename = Path.GetFileName(path);
    if (!filenames.Contains(filename))
    {
      continue;
    }
    photos.Add(new Photo()
    {
      Bytes = File.ReadAllBytes(path),
      Filename = filename
    });
  }
  return photos;
}

随着 Android 导入器的完成,我们可以转向 Windows 的最终导入器。

从 Windows 照片库导入照片

我们需要的最终导入器是为 Windows 平台。代码将遵循与其他平台相同的模式;然而,对于 Windows,我们将使用 Windows 搜索 服务来获取照片列表。让我们通过以下步骤查看此平台是如何实现的:

  1. 导入 tlbimp-Windows.Search.InteropSystem.Data.OleDB NuGet 包。这些包用于在文件系统中搜索图像。

  2. 通过在 解决方案资源管理器 中双击它来打开 GalleryApp 项目;编辑新的导入以添加一个条件:

    <PackageReference Include="System.Data.OleDb" Version="7.0.0" 
    Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(Target
    Framework)')) == 'windows'" />
    <PackageReference Include="tlbimp-Microsoft.Search.Interop" 
    Version="1.0.0" Condition="$([MSBuild]::GetTargetPlatform
    PackageReference so that it is only used when TargetPlatformIdentifier is 'windows'.
    
  3. Windows 平台文件夹中创建一个名为 PhotoImporter 的新类,并将其标记为 partial

  4. 将命名空间声明从 GalleryApp.Platforms.Windows 更改为 GalleryApp.Services

  5. partial 类定义必须在同一个命名空间中,即使它们在不同的文件夹中。

  6. 添加 using 指令,以便我们可以使用那些命名空间中的类:

    using GalleryApp.Models;
    using Microsoft.Search.Interop;
    using System.Data.OleDb;
    
  7. QueryHelper 引用添加一个 private 字段:

    ISearchQueryHelper queryHelper;
    
  8. 与之前的实现类似,我们将首先实现 Import 方法,因此添加一个名为 Import 的新方法,如下所示:

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      return paths.ToArray();
    }
    
  9. 从用户那里请求权限以获取照片(以下代码块中突出显示):

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
      if (status == PermissionStatus.Granted)
      {
      }
      return paths.ToArray();
    }
    
  10. 现在,使用QueryHelper获取所有图像路径:

    private partial async Task<string[]> Import()
    {
      var paths = new List<string>();
      var status = await AppPermissions.
    CheckAndRequestRequiredPermission();
      if (status == PermissionStatus.Granted)
      {
        string sqlQuery = queryHelper.GenerateSQLFromUserQuery(" ");
        using OleDbConnection conn = new(queryHelper.
    ConnectionString);
        conn.Open();
        using OleDbCommand command = new(sqlQuery, conn);
        using OleDbDataReader WDSResults = command.ExecuteReader();
        while (WDSResults.Read())
        {
          var itemUrl = WDSResults.GetString(0);
          paths.Add(itemUrl);
        }
      }
      return paths.ToArray();
    }
    

    在这里,使用QueryHelper创建一个 SQL 查询,并使用OleDbConnection查询搜索索引以获取所有匹配的文件。

我们现在可以开始编辑Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low)方法。将以下声明添加到PhotoImporter类中:

public partial async Task<ObservableCollection<Photo>> Get(int start, 
int count, Quality quality)
{
}

现在,我们将开始实现方法,设置文件模式和我们将要搜索的位置:

string[] patterns = { ".png", ".jpeg", ".jpg" };
string[] locations = {
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
      Environment.GetFolderPath(Environment.SpecialFolder.
CommonPictures),
     Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.
UserProfile),"OneDrive","Camera Roll")
};

这些数组定义了我们将会搜索的文件扩展名和文件夹,我们将从tlbimp-Windows.Search.Interop NuGet 包中创建QueryHelper,并使用这些数组来配置查询参数:

queryHelper = new CSearchManager().GetCatalog("SystemIndex").
GetQueryHelper();
queryHelper.QueryMaxResults = start + count;
queryHelper.QuerySelectColumns = "System.ItemPathDisplay";
queryHelper.QueryWhereRestrictions = "AND (";
foreach (var pattern in patterns)
     queryHelper.QueryWhereRestrictions += " Contains(System.
FileExtension, '" + pattern + "') OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";
queryHelper.QueryWhereRestrictions += " AND (";
foreach (var location in locations)
     queryHelper.QueryWhereRestrictions += " scope='" + location + "' 
OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";
queryHelper.QuerySorting = "System.DateModified DESC";

QueryMaxResults设置为只检索我们正在寻找的结果。然后,我们指定只返回数据列"System.ItemPathDisplay"。接下来,我们从我们的扩展名列表中设置QueryWhereRestrictions。注意在查询字符串中移除尾随的"OR"时使用了range运算符。我们使用相同的技巧将位置添加到QueryWhereRestrictions中。最后,我们设置排序顺序。

方法的其余部分将与之前平台的类似。如果导入成功,我们将继续处理这次加载图像中应该导入的照片。条件由startcount参数指定。使用以下代码列表来完成第一个Get方法的实现:

var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result?.Length == 0)
{
  return photos;
}
Index startIndex = start;
Index endIndex = start + count;
if (endIndex.Value >= result.Length)
{
  endIndex = result.Length;
}
if (startIndex.Value > endIndex.Value)
{
  return photos;
}
foreach (var uri in result[startIndex..endIndex])
{
  var path = new System.Uri(uri).AbsolutePath;
  photos.Add(new()
  {
    Bytes = File.ReadAllBytes(path),
    Filename = Path.GetFileName(path)
  });
}
return photos;

让我们快速回顾一下前面的代码。第一步是调用Import方法并验证是否有照片要导入。如果没有,我们简单地返回一个空列表。如果有照片要导入,那么我们需要知道photos数组中的startIndexendIndex以导入。startIndexendIndex被调整以确保它们对于要导入的照片是有效的。然后,我们可以从照片数组中读取从startIndexendIndex的图像,并返回每个条目的文件字节和文件名。

现在,我们将继续其他Task<ObservableCollection<Photo>> Get(List<string> filenames, Quality quality = Quality.Low)方法。将以下声明添加到PhotoImporter类中:

public partial async Task<ObservableCollection<Photo>> 
Get(List<string> filenames, Quality quality)
{
}

现在,我们将开始实现方法,设置搜索参数:

queryHelper = new CSearchManager().GetCatalog("SystemIndex").
GetQueryHelper();
queryHelper.QuerySelectColumns = "System.ItemPathDisplay";
queryHelper.QueryWhereRestrictions = "AND (";
foreach (var filename in filenames)
     queryHelper.QueryWhereRestrictions += " Contains(System.Filename, 
'" + filename + "') OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";

对于这个方法,我们只需要将所有文件名添加到QueryWhereRestrictions中。随后,调用Import方法,如果它返回结果,则使用foreach循环遍历所有照片,并检查每张照片是否在filenames参数中指定。如果照片在filenames参数中指定,则从路径读取照片,就像第一个Get方法中那样:

var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result?.Length == 0)
{
  return photos;
}
foreach (var uri in result)
{
  var path = new System.Uri(uri).AbsolutePath;
  var filename = Path.GetFileName(path);
  if (filenames.Contains(filename))
  {
    photos.Add(new()
    {
      Bytes = File.ReadAllBytes(path),
      Filename = filename
    });
  }
}
return photos;

照片导入器现在已经完成,我们准备编写应用程序的其余部分,这主要涉及添加在各个平台之间共享的代码。

编写应用初始化代码

我们现在已经编写了将用于获取数据的代码。让我们继续构建应用,从初始化应用的核心部分开始。

配置依赖注入

通过使用依赖注入作为模式,我们可以使我们的代码更干净、更易于测试。此应用将使用构造函数注入,这意味着一个类所拥有的所有依赖项都必须通过其构造函数传递。然后容器为您构建对象,因此您不必太关心依赖链。由于.NET MAUI 已经包含了依赖注入框架Microsoft.Extensions.DependencyInjection,因此无需安装任何额外的内容。

对依赖注入感到困惑?

第二章构建我们的第一个.NET MAUI 应用中查看配置依赖注入部分,以获取有关依赖注入的更多详细信息。

虽然建议使用扩展方法来分组类型,但在此应用中要注册的类型很少,所以我们将在下一节中使用不同的方法。

使用依赖注入注册 PhotoImporter

让我们添加必要的代码来注册我们迄今为止创建的类型,如下所示:

  1. GalleryApp项目中,打开MauiProgram.cs

  2. MauiProgram类进行以下更改(更改已突出显示):

    using GalleryApp.Services;
    using Microsoft.Extensions.Logging;
    public static class MauiProgram
    {
      public static MauiApp CreateMauiApp()
      {
        var builder = MauiApp.CreateBuilder();
        builder
          .UseMauiApp<App>()
          .ConfigureFonts(fonts =>
          {
            fonts.AddFont("OpenSans-Regular.ttf", 
    "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", 
    "OpenSansSemibold");
          });
    #if DEBUG
        builder.Logging.AddDebug();
    #endif
      builder.Services.AddSingleton<IPhotoImporter>(serviceProvider 
    => new PhotoImporter());
        return builder.Build();
      }
    }
    

.NET MAUI 的MauiAppBuilder类公开了Services属性,它是依赖注入容器。我们只需添加我们想要依赖注入了解的类型,容器就会为我们完成剩余的工作。顺便说一句,将构建器视为收集大量需要完成的信息的东西,然后构建我们需要的对象。它是一个非常有用的模式。

目前我们只使用构建器做一件事。稍后,我们将使用它来注册程序集中从我们的抽象ViewModel类和视图继承的任何类。容器现在已为我们准备好,以便我们可以请求这些类型。

创建外壳

此应用的主要导航将在屏幕底部显示标签。应用将有一个飞出菜单,包含两个选项——主页画廊

  1. 在项目中创建一个名为Views的新文件夹。

  2. Views文件夹中,使用MainView创建两个新文件,并命名为GalleryView

  3. 从项目的根目录中删除MainPage.XamlMainPage.Xaml.cs文件,因为我们不再需要那些文件。

  4. 打开项目根目录中的AppShell.xaml文件。

    使用ShellContentContentTemplate属性将两个视图添加到Shell对象中。使用DataTemplate标记扩展从依赖注入容器中加载视图:

    <?xml version="1.0" encoding="UTF-8" ?>
    <Shell
        x:Class="GalleryApp.AppShell"
    
        >
        <ShellContent Title="Home" ContentTemplate="{DataTemplate views:MainView}" />
        <ShellContent Title="Gallery" ContentTemplate="{DataTemplate views:GalleryView}" />
    </Shell>
    
  5. 由于视图是通过DataTemplates加载的,它们必须与依赖注入进行注册。在MauiProgram.cs文件中,在IPhotoInmporter行之后添加突出显示的代码:

            builder.Services.
    AddSingleton<IPhotoImporter>(serviceProvider => new 
    PhotoImporter());
            builder.Services.AddTransient<Views.MainView>();
            builder.Services.AddTransient<Views.GalleryView>();
    return builder.Build();
    

现在我们已经创建了一个外壳,在开始创建视图之前,让我们继续编写一些其他的基础代码。

创建基视图模型

在创建实际视图模型之前,我们将创建一个所有视图模型都可以继承的抽象基视图模型。这个基视图模型背后的想法是我们可以在其中编写通用代码。在这种情况下,我们将通过以下步骤实现 INotifyPropertyChanged 接口:

  1. GalleryApp 项目中,创建一个名为 ViewModels 的文件夹。

  2. CommunityToolkit.Mvvm 添加为 NuGet 引用;我们使用 CommunityToolkit.Mvvm 来实现 INotifyPropertyChanged 接口,就像在其他章节中做的那样。

  3. 创建一个新的抽象类名为 ViewModel

    namespace GalleryApp.ViewModels;
    using CommunityToolkit.Mvvm.ComponentModel;
    public abstract partial class ViewModel: ObservableObject
    {
        [ObservableProperty]
        [NotifyPropertyChangedFor(nameof(IsNotBusy))]
        private bool isBusy;
        public bool IsNotBusy => !IsBusy;
        abstract protected internal Task Initialize();
    }
    

在此应用的 ViewModel 类中,我们为 Initialize 添加了一个抽象方法。每个 ViewModel 实现都将覆盖此方法并异步加载图像以供显示。IsBusyNotIsBusy 属性用作标志,指示数据何时完成加载。

现在,我们有一个 ViewModel 基类,我们可以用于在此项目中稍后创建的所有 ViewModel 实例。

创建画廊视图

现在,我们将开始构建视图。我们将从画廊视图开始,该视图将作为网格显示照片。我们将从 GalleryViewModel 开始,然后创建 GalleryView。首先创建视图模型允许 Visual Studio 使用 GalleryViewModel 定义来检查 XAML 文件中的数据绑定语法。

创建 GalleryViewModel

GalleryViewModel 是负责获取数据和处理视图逻辑的类。由于照片将被异步添加到照片集合中,我们不想在调用 PhotoImporterGet 方法后立即将 IsBusy 设置为 false。相反,我们首先等待 3 秒钟。然而,我们也会向集合添加一个事件监听器,以便我们可以监听变化。如果集合发生变化并且其中包含项目,我们将 IsBusy 设置为 false。在 ViewModels 文件夹中创建一个名为 GalleryViewModel 的类,并添加以下代码以实现此功能:

namespace GalleryApp.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
using GalleryApp.Models;
using GalleryApp.Services;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
public partial class GalleryViewModel : ViewModel
{
    private readonly IPhotoImporter photoImporter;
    [ObservableProperty]
    public ObservableCollection<Photo> photos;
    public GalleryViewModel(IPhotoImporter photoImporter) : base()
    {
        this.photoImporter = photoImporter;
    }
    override protected internal async Task Initialize()
    {
        IsBusy = true;
        Photos = await photoImporter.Get(0, 20);
        Photos.CollectionChanged += Photos_CollectionChanged;
        await Task.Delay(3000);
        IsBusy = false;
    }
    private void Photos_CollectionChanged(object sender, System.
Collections.Specialized.NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null && e.NewItems.Count > 0)
        {
            IsBusy = false;
            Photos.CollectionChanged -= Photos_CollectionChanged;
        }
    }
}

最后,在 MauiProgram 中使用依赖注入注册 GalleryViewModel

        builder.Services.AddSingleton<IphotoImporter>(serviceProvider 
=> new PhotoImporter());
        builder.Services.AddTransient<ViewModels.GalleryViewModel>();
builder.Services.AddTransient<Views.MainView>();
builder.Services.AddTransient<Views.GalleryView>();
return builder.Build();

现在,GalleryViewModel 已经准备好了,所以我们可以开始创建 GalleryView

创建画廊视图

首先,我们将创建一个将 byte[] 转换为 Microsft.Maui.Controls.ImageSource 的转换器。在 GalleryApp 项目中,创建一个新的文件夹名为 Converters,并在文件夹内创建一个新的类名为 BytesToImageConverter

namespace GalleryApp.Converters;
using System.Globalization;
internal class BytesToImageConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object 
parameter, CultureInfo culture)
    {
        if (value != null)
        {
            var bytes = (byte[])value;
            var stream = new MemoryStream(bytes);
            return ImageSource.FromStream(() => stream);
        }
        return null;
    }
    public object ConvertBack(object value, Type targetType, object 
parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

要使用转换器,我们需要将其添加为资源。我们将通过将其添加到 GalleryViewResources 属性中的 Resource 字典 对象来完成此操作。

打开 GalleryView.xaml,并将以下突出显示的代码添加到视图中:

<ContentPage  

    x:Class="GalleryApp.Views.GalleryView"
    Title="GalleryView">
    <ContentPage.Resources>
      <ResourceDictionary>
        <converters:BytesToImageConverter x:Key="ToImage" />
      </ResourceDictionary>
    </ContentPage.Resources>
</ContentPage>

为了能够绑定到 ViewModel,我们将 BindingContext 设置为 GalleryViewModel。在 GalleryView.xaml.cs 中使用构造函数依赖注入创建 GalleryViewModel 的实例。

打开 GalleryView.xaml.cs,并将以下突出显示的代码添加到类中:

public GalleryView(GalleryViewModel viewModel)
{
    InitializeComponent();
    BindingContext = viewModel;
      MainThread.InvokeOnMainThreadAsync(viewModel.Initialize);
GalleryViewModel. That instance is set as BindingContext for the page. This object will be used in the XAML bindings of the view. Finally, we initialize the view model asynchronously.
What we will show in this view is a grid with three columns. To build this with .NET MAUI, we will use the `CollectionView` control. To specify the layout that `CollectionView` should have, add a `GridItemsLayout`element to the `ItemsLayout` property of `CollectionView`. Follow these steps to build this view:

1.  Navigate to `GalleryView.xaml`.

    Import the namespaces for `GalleryApp.ViewModels` and `GalleryApp.Models` as `viewModels` and `models`, respectively:

    ```

    `x:Class=" GalleryApp.Views.GalleryView"`

    ```cs

     2.  On `ContentPage`, set `x:DataType` to `viewModels:GalleryViewModel`. This makes the bindings compile, which will make our view faster to render:

    ```

    `<CollectionView x:Name="Photos" ItemsSource="{Binding Photos}">

    `<CollectionView.ItemsLayout>`

    `<GridItemsLayout Orientation="Vertical" Span="3"

    `HorizontalItemSpacing="0" />`

    `<CollectionView.ItemsLayout>`

    `<CollectionView.ItemTemplate>`

    `<DataTemplate x:DataType="models:Photo">

    `<Grid>`

    `<Image Aspect="AspectFill" Source="{Binding Bytes,

    `Converter={StaticResource ToImage}}" HeightRequest="120" />`

    </Grid>

    </DataTemplate>

    `<CollectionView.ItemTemplate>`

    `<CollectionView>`

    ```cs

Now, we can see the photos in the view. However, we will also need to create the content that will be shown when we don’t have any photos to show as they have not been loaded yet, or if there are no photos available. Add the following highlighted code to create a `DataTemplate` object to show when `CollectionView`doesn’t have any data:

`<CollectionView :Name="Photos" ItemsSource="{Binding Photos}"

`EmptyView="{Binding}">

<CollectionView.EmptyViewTemplate>

`

<Grid>

<ActivityIndicator IsVisible="{Binding IsBusy}" />

`<Label Text="No photos to import could be found"

IsVisible="{Binding IsNotBusy}" HorizontalOptions="Center"

VerticalOptions="Center" HorizontalTextAlignment="Center" />

<CollectionView.EmptyViewTemplate>

<CollectionView>


 Now, we can run the app. The next step is to load more photos when a user reaches the end of the view.
Loading photos incrementally
To load more than the first 20 items, we will load photos incrementally so that when users scroll to the end of `CollectionView`, it will start to load more items. `CollectionView` has built-in support for loading data incrementally. Because we get an `ObservableCollection`object back from the photo importer and data is added asynchronously to it, we need to create an event listener to handle when items are added to the photo importer so that we can add it to the `ObservableCollection`instance that we bound to `CollectionView`. Create the event listener by navigating to `GalleryViewModel.cs` and adding the following code at the end of the class:

private int itemsAdded;

`private void Collection_CollectionChanged(object sender, System.

Collections.Specialized.NotifyCollectionChangedEventArgs args)

{

foreach (Photo photo in args.NewItems)

{

itemsAdded++;

Photos.Add(photo);

}

if (itemsAdded == 20)

{

var collection = (ObservableCollection<Photo>)sender;

collection.CollectionChanged -= Collection_CollectionChanged;

}

}

private int currentStartIndex = 0;

[RelayCommand]

public async Task LoadMore()

{

currentStartIndex += 20;

itemsAdded = 0;

var collection = await photoImporter.Get(currentStartIndex, 20);

collection.CollectionChanged += Collection_CollectionChanged;

}


 The only thing we have left to do to get the incremental load to work is to bind `CollectionView` to the code we created in `ViewModel`. The following code will trigger the loading of more photos when the user has just five items left:

`<CollectionView x:Name="Photos" EmptyView="{Binding}"

ItemsSource="{Binding Photos}" RemainingItemsThreshold="5"

RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">


 Now that we have a view that shows photos and loads them incrementally, we can make it possible to add photos as favorites.
Saving favorites
In `GalleryView`, we want to be able to select favorites that we can show in `MainView`. To do that, we need to store the photos that we have selected so that it remembers our selection. Create a new interface in the `GalleryApp` project named `ILocalStorage` in the `Services` folder:

`public interface ILocalStorage

{

void  Store(string filename);

List<string> Get();

}


 The easiest way to store/persist data in .NET MAUI is to use the built-in property store. `Preferences` is a static class in the `Microsoft.Maui.Storage` namespace. Follow these steps to use it:

1.  Create a new class named `MauiLocalStorage` in the `Services` folder.
2.  Implement the `ILocalStorage` interface:

    ```

    `namespace GalleryApp.Services;`

    `using System.Text.Json;`

    `public class MauiLocalStorage : ILocalStorage`

    {

    `public const string FavoritePhotosKey = "FavoritePhotos";`

    `public List<string> Get()`

    {

    `if (Preferences.ContainsKey(FavoritePhotosKey))`

    {

    `var filenames = Preferences.Get(FavoritePhotosKey,string.Empty);`

    `return JsonSerializer.Deserialize<List<string>>(filenames);`

    }

    `return new List<string>();`

    }

    `public void Store(string filename)`

    {

    `var filenames = Get();`

    `filenames.Add(filename);`

    `var json = JsonSerializer.Serialize(filenames);`

    `Preferences.Set(FavoritePhotosKey, json);`

    }

    }

    ```cs

To be able to use `ILocalStorage` with constructor injection, we need to register it with the container. Navigate to the `MauiProgram` class and add the following highlighted code:

`builder.Services.AddSingleton(serviceProvider => new

PhotoImporter());

`builder.Services.AddTransient(ServiceProvider => new

MauiLocalStorage());

builder.Services.AddTransient<ViewModels.MainViewModel>();


 Now, we are ready to use the local storage.
Navigate to the `GalleryViewModel` class, add the `ILocalStorage`interface to the constructor, and assign it to a field:

private readonly IPhotoImporter photoImporter;

private readonly ILocalStorage localStorage;

public GalleryViewModel(IPhotoImporter photoImporter, ILocalStorage

`localStorage)

{

this.photoImporter = photoImporter;

this.localStorage = localStorage;

}


 The next step is to create a command that we can bind to from the view when we select photos. The command will monitor which photos we have selected and notify other views that we have added favorite photos. We will use `WeakReferenceManager` from `CommunityToolkit` to send messages from `GalleryViewModel` to `MainViewModel`.
Follow these steps to implement the `GalleryViewModel` side:

1.  Create a new class in the `Services` folder named `Messages`:

    ```

    `namespace GalleryApp.Services;`

    `internal static class Messages`

    {

    public const string FavoritesAddedMessage =

    nameof(FavoritesAddedMessage);

    }

    ```cs

    This is used to define the message type we are sending to `MainViewModel`.

     2.  Navigate to `GalleryViewModel`.
3.  Create a new method named `AddFavorites` that is attributed to the `RelayCommand` type.
4.  Add the following code:

    ```

    [RelayCommand]

    public void AddFavorites(List<Photo> photos)

    {

    foreach (var photo in photos)

    {

    localStorage.Store(photo.Filename);

    }

    WeakReferenceMessenger.Default.Send<string>(Messages.

    FavoritesAddedMessage);

    }

    ```cs

Now, we are ready to start working with the view. The first thing we will do is make it possible to select photos. Navigate to `GalleryView.xaml` and set the `SelectionMode` mode of `CollectionView` to `Multiple` to make it possible to select multiple items:

<CollectionView x:Name="Photos"

EmptyView="{Binding}" ItemsSource="{Binding Photos}"

SelectionMode="Multiple" RemainingItemsThreshold="5"

RemainingItemsThresholdReachedCommand="{Binding LoadMore}">


 When a user selects a photo, we want it to be clear which photos have been selected. To achieve this, we will use `VisualStateManager`. We will do this by creating a style for `Grid` and setting `Opacity` to `0.5`, as in the following code. Add the code to `Resources` of the page:

<ContentPage.Resources>

<converters:BytesToImageConverter x:Key="ToImage" />

</ContentPage.Resources>


 To save the selected photos, we will create a toolbar item that the user can tap:

1.  Add `ToolbarItem` with the `Text` property set to `Select`.
2.  Add an event handler named `SelectToolBarItem_Clicked`:

    ```

    <ContentPage.ToolbarItems>

    <ToolbarItem Text="Select" Clicked="SelectToolBarItem_

    Clicked" />

    </ContentPage.ToolbarItems>

    ```cs

     3.  Navigate to the code behind the `GalleryView.xaml.cs` file.
4.  Add the following `using` statements:

    ```

    using GalleryApp.Models;

    using GalleryApp.ViewModels;

    ```cs

     5.  Create an event handler named `SelectToolBarItem_Clicked`:

    ```

    private void SelectToolBarItem_Clicked(object sender, EventArgs e)

    {

    if (!Photos.SelectedItems.Any())

    {

    DisplayAlert("No photos", "No photos selected", "OK");

    return;

    }

    var viewModel = (GalleryViewModel)BindingContext;

    viewModel.AddFavoritesCommand.Execute(Photos.

    SelectedItems.Select(x =>(Photo)x).ToList());

    DisplayAlert("Added", "Selected photos have been added to

    favorites", "OK");

    }

    ```cs

Now that we are done with `GalleryView`, we will continue with the main view, which will show the latest photos and the favorite photos in two carousels.
Creating the carousels for MainView
The last view in this app is `MainView`, which is the view that is visible when users start the app. This view will show two carousel views—one with recent photos and one with favorite photos.
Creating the view model for MainView
We will start by creating `ViewModel` that we will use for the view. In the `ViewModel` folder, create a new class named `MainViewModel`:

namespace GalleryApp.ViewModels;

using CommunityToolkit.Mvvm.ComponentModel;

using CommunityToolkit.Mvvm.Messaging;

using GalleryApp.Models;

using GalleryApp.Services;

using System.Collections.ObjectModel;

public partial class MainViewModel : ViewModel

{

private readonly IPhotoImporter photoImporter;

private readonly ILocalStorage localStorage;

[ObservableProperty]

private ObservableCollection recent;

[ObservableProperty]

private ObservableCollection favorites;

public MainViewModel(IPhotoImporter photoImporter, ILocalStorage

localStorage)

{

this.photoImporter = photoImporter;

this.localStorage = localStorage;

}

override protected internal async Task Initialize()

{

var photos = await photoImporter.Get(0, 20, Quality.Low);

Recent = photos;

await LoadFavorites();

WeakReferenceMessenger.Default.Register(this, async

(sender, message) => {

if( message == Messages.FavoritesAddedMessage )

{

await MainThread.InvokeOnMainThreadAsync(LoadFavorites);

}

});

}

private async Task LoadFavorites()

{

var filenames = localStorage.Get();

var favorites = await photoImporter.Get(filenames, Quality.Low);

Favorites = favorites;

}

}


 In the preceding code, the `Initialize` method is used to register a callback with `Weak` **ReferenceManager**. This callback invokes the `LoadFavorites` method if the message sent was `Message.FavoritesAddedMessage`. Recall that `Messages.Favorites` **AddedMessage** is sent from `GalleryViewModel` after selecting new photos.
In the `LoadFavorites` method, the favorites are loaded from the storage provider instance in `localStorage`. Then, the photos from the favorites are imported using the `photoImporter` instance.
We need to add the view model to dependency injection so that we can use it in the view. Open `MauiProgram` and add the highlighted code:

builder.Services.AddTransient(ServiceProvider

=> new MauiLocalStorage());

builder.Services.AddTransient<ViewModels.MainViewModel>();

builder.Services.AddTransient<ViewModels.GalleryViewModel>();


 Now that we have created `MainViewModel`, we will continue with the latest photos.
Showing the latest photos
We are now ready to set up the carousel views. We have already created the view model, so we can use the view model to populate the view with content.
Let’s look at the steps to create the view:

1.  In the constructor of the code, behind the `MainView.xaml.cs` file, set `ViewModel` to `BindingContext`:

    ```

    public MainView(MainViewModel viewModel)

    {

    InitializeComponent();

    BindingContext = viewModel;

    MainThread.InvokeOnMainThreadAsync(viewModel.Initialize);

    }

    ```cs

     2.  Navigate to `MainView.xaml`.
3.  Add the following code:

    ```

    <ContentPage

    x:Class="GalleryApp.Views.MainView"

    x:DataType="viewModels:MainViewModel"

    Title="My Photos">

    <ContentPage.Resources>

    <ResourceDictionary>

    <converters:BytesToImageConverter x:Key="ToImage" />

    </ResourceDictionary>

    </ContentPage.Resources>

    <Grid>

    <Grid.RowDefinitions>

    <RowDefinition Height="*" />

    <RowDefinition Height="50" />

    <RowDefinition Height="*" />

    <RowDefinition Height="20" />

    </Grid.RowDefinitions>

    <CarouselView ItemsSource="{Binding Recent}"

    PeekAreaInsets="40,0,40,0" >

    <CarouselView.ItemsLayout>

    <LinearItemsLayout Orientation="Horizontal"  SnapPointsAlignment="Start"

    SnapPointsType="Mandatory" />

    </CarouselView.ItemsLayout>

    <CarouselView.ItemTemplate>

    <DataTemplate x:DataType="models:Photo">

    <Image Source="{Binding Bytes,

    Converter={StaticResource ToImage}}" Aspect="AspectFill" />

    </DataTemplate>

    </CarouselView.ItemTemplate>

    </CarouselView>

    </Grid>

    </ContentPage>

    ```cs

    The `CarouselView` control is used to present data to the user in a scrollable layout, where the user can swipe to move through the collection of items. It is very similar to `CollectionView`; however, the uses of the two controls are different. You would use `CollectionView` when you want to display a list of items with an indeterminate length, and `CarouselView` is used to highlight items from a list of items with a limited length. Since `CarouselView` shares implementations with the `CollectionView` control, it uses the familiar `ItemTemplate` property to customize how each item is displayed. It adds an `ItemsLayout` property to define how the collection of items is displayed. `CarouselView` can use either a `Horizontal` or `Vertical` layout direction, with `Horizontal` being the default.

    In `MainView`, `CarouselView` is used to display the `Recent` photos from `MainViewModel`. `ItemsLayout` is customized to set the scrolling behavior so that items will snap into view using the start, or left edge of the image. The `SnapPointType` property set to `Mandatory` makes sure that `CarouselView` snaps the image into place after scrolling, which would ensure a single image is always in view.

    `ItemsTemplate` is used to display an image that is data-bound to each photo and displays the image from the bytes in the `Photo` model. `BytesToImageConverter` converts the byte array from the `Photo` model into `ImageSource` that can be displayed by the `Image` control. The `Image` control has the `Aspect` property set to `AspectFill`, allowing the image control to resize the image, maintaining the aspect ratio of the source image to fill the available visible space.

Now that we have shown the latest photos in a carousel, the next (and the last) step is to show the favorite photos in another carousel.
Showing the favorite photos
The last thing we will do in this app is add a carousel to show favorite photos. Add the following highlighted code inside `Grid`, after the first `CarouselView`, as shown in the following code snippet:

<!—Code omitted for brevity -->

<!—Code omitted for brevity -->

<Label Grid.Row="1" Margin="10" Text="Favorites" FontSize="Subtitle"

FontAttributes="Bold" />

<CarouselView Grid.Row="2" ItemsSource="{Binding Favorites}"

PeekAreaInsets="0,0,40,0" IndicatorView="Indicator">

<CarouselView.ItemsLayout>

<LinearItemsLayout Orientation="Horizontal"

SnapPointsAlignment="Start" SnapPointsType="MandatorySingle" />

</CarouselView.ItemsLayout>

<CarouselView.EmptyViewTemplate>

</CarouselView.EmptyViewTemplate>

<CarouselView.ItemTemplate>

<Border Grid.RowSpan="2" StrokeShape="RoundRectangle

15,15,15,15" Padding="0" Margin="0,0,0,0" BackgroundColor="#667788" >

<Image Source="{Binding Bytes, Converter={StaticResource

ToImage}}" Aspect="AspectFill" />

</CarouselView.ItemTemplate>


 For the `Favorites` photos, again, `CarouselView` is used with a few changes from `CarouselView` displaying the `Recent` photos. The most visible change is that the `ItemsLayout` property is now using `MandatorySingle` for the value of `SnapPointsType`. This forces a behavior that only allows the user to swipe one image at a time, snapping each image into view.
The `ItemTemplate` property has also been changed to add a rounded border around each image, with a background color.
New to this `CarouselView` is the `EmptyViewTemplate` property. This is used to display the text `"No favorites selected"` when the `Favorites` property is empty.
Finally, `IndicatorView` was added to provide the user with a visual cue of how many items are in `CarouselView` and which item is currently displayed. `CarouselView` is connected to `IndicatorView` by the `IndicatorView` property of `CarouselView`. The `IndicatoryView` property is set to the `x:Name` property of `IndicatorView`. The `IndicatorView` displays on the page as a series of horizontal light gray dots, with the dot representing the current image in red.
That is all—now, we can run the app and see both the most recent photos and the photos that have been marked as favorites.
Summary
In this chapter, we focused on photos. We learned how to import photos from the platform-specific photo galleries and how we can display them as a grid using `CollectionView` and in carousels using `CarouselView`. This makes it possible for us to build other apps and provides multiple options for presenting data to users, as we can now pick the best method for the situation.
Additionally, we learned about permissions and how to check and request permission to use protected resources in our app.
If you are interested in extending the app even further, try creating a page to view the details of the photo, or to view the photo in full screen by tapping on the photo.
In the next chapter, we will build an app using location services and look at how to visualize location data on a map.

第七章:使用 GPS 和地图构建位置跟踪应用

在本章中,我们将创建一个位置跟踪应用,该应用将保存用户的地理位置并以热图的形式显示。我们将学习如何在 iOS、macOS 和 Android 设备上后台运行任务。我们将扩展 .NET MAUI 的 Map 控件,以便直接在地图中显示保存的地理位置。

本章将涵盖以下主题:

  • 在 iOS 设备和 macOS 设备上后台跟踪用户的地理位置

  • 在 Android 设备上后台跟踪用户的地理位置

  • 如何在 .NET MAUI 应用中显示地图

  • 如何扩展 .NET MAUI 地图的功能

让我们开始吧!

技术要求

要完成此项目,您需要安装 Visual Studio for Mac 或 Windows,以及 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章.NET MAUI 简介。如果您使用 Visual Studio for Windows 构建 iOS 应用,您必须连接一台 Mac。如果您根本无法访问 Mac,您只需完成此项目的 Android 部分。

您可以在本章中找到代码的完整源代码,链接为 github.com/PacktPublishing/MAUI-Projects-3rd-Edition

Windows 用户的重要信息

在撰写本文时,.NET MAUI 在 Windows 平台上没有 Map 控件。这是由于底层 WinUI 平台上缺少 Map 控件。有关 Windows 上 Map 支持的最新信息,请访问 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/mapMap 文档。

项目概述

通过添加地图和位置服务,许多应用可以变得更加丰富。在本项目中,我们将构建一个名为 MeTracker 的位置跟踪应用。此应用将跟踪用户的地理位置并将其保存到 SQLite 数据库中,以便我们可以以热图的形式可视化结果。为了构建此应用,我们将学习如何在 iOS、macOS 和 Android 上设置后台进程。幸运的是,iOS 和 macOS 的实现是相同的;然而,Android 的实现非常不同。对于地图,我们将使用 .NET MAUI 的 Maps 组件并扩展其功能以构建热图。

由于 Windows 平台上缺少 Map 支持,以及为了增加一些多样性,本章将使用 Visual Studio for Mac 的截图和参考。如果您没有 Mac,不要担心;您仍然可以在 Windows 开发机器上完成 Android 项目的开发。如果您需要帮助,可以查看一些早期章节中的等效步骤。

此项目的预计构建时间为 180 分钟。

构建 MeTracker 应用

是时候开始构建应用了。使用以下步骤从模板创建项目:

  1. 打开 Visual Studio for Mac 并点击 新建

图 7.1 – Visual Studio for Mac 启动屏幕

图 7.1 – Visual Studio for Mac 启动屏幕

  1. 选择新项目的模板 对话框中,使用位于 多平台 | 应用 下的 .NET MAUI App 模板;然后,点击 继续

图 7.2 – 新项目

图 7.2 – 新项目

  1. 配置你的新 .NET MAUI 应用 对话框中,确保已选择 .NET 7.0 目标框架,然后点击 继续

图 7.3 – 选择目标框架

图 7.3 – 选择目标框架

  1. MeTracker 中,然后点击 创建

图 7.4 – 命名新应用

图 7.4 – 命名新应用

如果你现在运行应用程序,你应该会看到以下类似的内容:

图 7.5 – MeTracker 应用在 macOS 上

图 7.5 – MeTracker 应用在 macOS 上

现在我们已经从一个模板中创建了一个项目,是时候开始编码了!

创建一个用于保存用户位置的仓库

我们首先要做的是创建一个仓库,我们可以用它来保存用户的地理位置。

为位置数据创建模型

在我们创建仓库之前,我们将创建一个表示用户位置的模型类。按照以下步骤进行操作:

  1. 创建一个用于所有模型的 Models 文件夹。

  2. Models 文件夹中创建一个 Location 类,并添加 IdLatitudeLongitude 属性。

  3. 创建两个构造函数 – 一个为空,另一个接受 latitudelongitude 作为参数。使用以下代码进行操作:

    Using'System;
    namespace MeTracker.Models;
    public class Location
    {
        public Location() {}
        public Location(double latitude, double longitude)
        {
            Latitude = latitude;
            Longitude = longitude;
        }
        public int Id { get; set; }
        public double Latitude { get; set; }
        public double Longitude { get; set; }
    }
    

现在我们已经创建了一个模型,我们可以开始创建仓库。

创建仓库

首先,我们将为仓库创建一个接口。按照以下步骤进行操作:

  1. 创建一个名为 Repositories 的新文件夹。

  2. 在我们的新文件夹中,创建一个名为 ILocationRepository 的接口。

  3. 在我们为接口创建的新文件中编写以下代码:

    using MeTracker.Models;
    using System;
    using System.Threading.Tasks;
    namespace MeTracker.Repositories;
    public interface ILocationRepository
    {
        Task SaveAsync(Models.Location location);
    }
    

现在我们有了接口,我们需要创建它的实现。按照以下步骤进行操作:

  1. Repositories 文件夹中创建一个新的 LocationRepository 类。

  2. 实现 ILocationRepository 接口,并在 SaveAsync 方法中添加 async 关键字,使用以下代码:

    using System;
    using System.Threading.Tasks;
    using MeTracker.Models;
    namespace MeTracker.Repositories;
    public class LocationRepository : ILocationRepository
    {
        public async Task SaveAsync(Models.Location location)
        {
        }
    }
    

关于 Async 后缀的说明

你会在本书的许多章节中看到方法后使用 Async 作为后缀。在所有异步方法上附加 Async 后缀是 .NET 的一个约定。我们如何知道接口中的方法是否是异步的,因为我们看不到 async 关键字?它很可能会返回一个 TaskValueTask 对象。在某些情况下,异步方法将返回 void;然而,这并不被看好,正如 Stephen Cleary 在他的文章 msdn.microsoft.com/en-us/magazine/jj991977.aspx 中解释的那样,所以你不会在本书中看到它的使用。

为了存储数据,我们将使用 SQLite 数据库和名为对象关系映射器ORM)的 SQLite-net,这样我们就可以针对领域模型编写代码,而不是使用 SQL 对数据库进行操作。这是一个由 Frank A. Krueger 创建的开源库。让我们通过以下步骤来设置它:

  1. 通过在解决方案资源管理器中的Dependencies节点上右键单击来添加对sqlite-net-pcl的引用:

图 7.6 – 添加 NuGet 包

图 7.6 – 添加 NuGet 包

  1. 从上下文菜单中选择管理 NuGet 包…以打开NuGet 窗口。

  2. 如下所示,在搜索框中检查sqlite-net-pcl

图 7.7 – 添加 sqlite-net-pcl 包

图 7.7 – 添加 sqlite-net-pcl 包

  1. 最后,勾选sqlite-net-pcl旁边的复选框,然后点击添加包

  2. 前往Location模型类,并将PrimaryKeyAttributeAuto IncrementAttribute属性添加到Id属性。当我们添加这些属性时,Id属性将成为数据库中的主键,并且将自动为其创建一个值。现在Location类应该看起来如下所示:

    using SQLite;
    namespace MeTracker.Models;
    public class Location
    {
        public Location() { }
        public Location(double latitude, double longitude)
        {
            Latitude = latitude;
            Longitude = longitude;
        }
        [PrimaryKey]
        [AutoIncrement]
        public int Id { get; set; }
        public double Latitude { get; set; }
        public double Longitude { get; set; }
    }
    
  3. LocationRepository类中编写以下代码以连接到 SQLite 数据库。使用if语句检查我们是否已经创建了连接。如果我们已经有了,我们不会创建一个新的;相反,我们将使用我们已创建的连接:

    private SQLiteAsyncConnection connection;
    private async Task CreateConnectionAsync()
    {
        if (connection != null)
        {
            return;
        }
        var databasePath = Path.Combine(Environment.GetFolderPath (Environment.SpecialFolder .MyDocuments), "Locations.db");
        connection = new SQLiteAsyncConnection(databasePath);
        await connection.CreateTableAsync<Location>();
    }
    

现在,是时候实现SaveAsync方法了,它将接受一个location对象作为参数并将其存储在数据库中。

我们将在SaveAsync方法中使用CreateConnectionAsync方法来确保在我们尝试将数据保存到数据库时创建一个连接。当我们知道我们有一个活动的连接时,我们就可以直接使用InsertAsync方法,并将SaveAsync方法的location参数作为参数传递。

编辑LocationRepository类中的SaveAsync方法,使其看起来像这样:

public async Task SaveAsync(Models.Location location)
{
    await CreateConnectionAsync();
    await connection.InsertAsync(location);
}

目前这个仓库就到这里,接下来让我们继续到位置跟踪服务。

创建位置跟踪服务

要跟踪用户的位置,我们需要根据平台编写代码。.NET MAUI 有获取用户位置的方法,但不能在后台使用。为了能够使用我们将为每个平台编写的代码,我们需要创建一个接口。对于ILocationRepository接口,只有一个实现将在两个平台(iOS 和 Android)上使用,而对于位置跟踪服务,我们将为每个平台提供一个实现。

按照以下步骤创建一个ILocationTrackingService接口:

  1. 创建一个名为Services的新文件夹。

  2. Services文件夹中创建一个新的ILocationTrackingService接口。

  3. 在接口中添加一个名为StartTracking的方法,如下面的代码片段所示:

    public interface ILocationTrackingService
    {
        void StartTracking();
    }
    

为了确保我们可以在为每个平台实现位置跟踪服务的同时运行和测试我们的应用程序,我们将使用部分类。类的主要部分将在项目的共享代码部分,而类的特定平台部分将在特定平台文件夹中。我们将在本章后面部分回到每个实现。

Services 文件夹中创建一个名为 LocationTrackingService 的类,如下所示:

public partial class LocationTrackingService : ILocationTrackingService
{
    public void StartTracking()
    {
        StartTrackingInternal();
    }
    partial void StartTrackingInternal();
}

我们使用接口来抽象我们的实现。我们还使用部分类来抽象每个特定的实现,但提供基础实现,这样我们就不必立即为每个平台实现。然而,这两种方法(部分类和基类继承)并不能与相同的方法一起使用。

实现 StartTracking 接口方法需要一个 public 关键字,它看起来像这样:

public void StartTracking() {}

然后,将其设置为部分类,如下所示:

public partial void StartTracking() {}

编译器抱怨没有部分方法的初始定义——也就是说,没有实现的方法。

删除空定义,如下所示:

public partial void StartTracking();

编译器现在抱怨因为它有一个可访问性修饰符,public

在这种情况下,根本无法让编译器满意。因此,为了避免这些问题,我们通过调用 StartTrackingInternal 部分方法来实现 StartTracking 接口方法。我们将在本章后面部分访问每个平台的 StartTrackingInternal 的实现;现在,即使我们没有实现 StartTrackingInternal,应用程序也应该可以编译和运行。

现在我们已经有了位置跟踪服务的接口和基础实现,我们可以将注意力转向应用程序逻辑和用户界面。

设置应用程序逻辑

现在我们已经创建了接口,我们需要跟踪用户的位置并将其保存在设备上本地。是时候编写一些代码,以便我们可以开始跟踪用户了。我们还没有任何跟踪用户位置的代码,但如果我们已经编写了启动跟踪过程的代码,这将更容易编写。

创建带有地图的视图

首先,我们将创建一个带有简单地图的视图,该地图以用户的位置为中心。让我们通过以下步骤来设置它:

  1. Views 文件夹中创建一个名为 Views 的新文件夹。

  2. Views 文件夹中,创建一个基于 XAML 的 ContentPage 模板,并将其命名为 MainView

图 7.8 – 添加 .NET MAUI XAML ContentPage 组件

图 7.8 – 添加 .NET MAUI XAML ContentPage 组件

  1. 通过在 解决方案资源管理器 中的 Dependencies 节点右键单击来添加对 Microsoft.Maui.Controls.Maps 的引用:

图 7.9 – 添加 NuGet 包

图 7.9 – 添加 NuGet 包

  1. 从上下文菜单中选择 管理 NuGet 包… 以打开 NuGet 包 管理器 窗口。

  2. 在搜索框中键入 Microsoft.Maui.Controls.Maps,如下所示:

图 7.10 – 添加 .NET MAUI Maps 包

图 7.10 – 添加 .NET MAUI Maps 包

  1. 最后,勾选 Microsoft.Maui.Controls.Maps 旁边的复选框,然后单击 添加包

  2. 通过打开 MauiProgram.cs 文件并做出高亮更改来添加 Map 初始化代码:

    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
        })
        .UseMauiMaps();
        return builder.Build();
    
  3. 使用以下高亮代码在 MainView 中添加 Microsoft.Maui.Controls.Maps 命名空间:

    <ContentPage 
                 _idTextAnchor605"/>om/winfx/2009/xaml"
        xmlns:maps="clr-namespace:Microsoft.Maui.Controls.Maps;assembly=Microsoft.Maui.Controls.Maps"
        x:Class="MeTracker.Views.MainView"
        Title="MainView">
    

    现在,我们可以在我们的视图中使用地图了。因为我们想让 Map 覆盖整个页面,所以我们可以将其添加到 ContentPage 的根目录。

  4. Map 添加到 ContentPage 并为其命名,以便我们可以从代码隐藏文件中访问它。命名为 Map,如下面的代码片段所示:

    <ContentPage 
    
        x:Class="MeTracker.Views.MainView"
        Title="MainView">
        <maps:Map x:Name="Map" />
    </ContentPage>
    

在我们能够启动应用程序并首次看到 Map 控件之前,我们需要将外壳设置为使用我们新的 MainView 模板而不是默认的 MainPage 模板。但首先,我们将删除我们在启动项目时创建的 MainPage.xamlMainPage.xaml.cs 文件,因为我们在这里不会使用它们:

  1. 由于我们将设置 MainView 模板为用户看到的第一个视图,因此请删除项目中的 MainPage.xamlMainPage.xaml.cs 文件。

  2. 编辑 AppShell.xaml 文件,如下所示的高亮代码:

    <Shell
        x:Class="MeTracker.AppShell"
    
        Shell.FlyoutBehavior="Disabled">
        <ShellContent
            Title="Home"
            ContentTemplate="{DataTemplate views:MainView}"
            Route="MainView" />
    </Shell>
    

我们能否使用现有的 MainPage 模板呢?当然可以——对于编译器来说,XAML 文件的名字或位置并没有任何区别,但为了保持一致性,并且按照 .NET MAUI 的 MVVM 习惯,我们将我们的 页面 放在 Views 文件夹中,并在页面名称后缀加上 Views

选择 Mac Catalyst 或 iOS 模拟器并运行应用程序将产生 图 7.11 中所示的结果。Android 不会工作,直到我们完成下一节:

图 7.11 – 添加 Map 控件后运行应用程序

图 7.11 – 添加 Map 控件后运行应用程序

现在我们有一个带有 Map 控件的页面,我们需要确保我们已从用户那里获得使用位置信息的权限。

声明特定平台的定位权限

要使用 Map 控件,我们需要声明我们需要位置信息的权限。如果需要,Map 控件将进行运行时请求。iOS/Mac Catalyst 和 Android 各自有声明所需权限的方式。我们将从 iOS/Mac Catalyst 开始,之后我们将进行 Android。

通过双击将其打开到 属性列表编辑器 中,在 Platforms/iOS 文件夹中的 info.plist 文件中。向文件中添加两个新条目,如下一个屏幕截图所示的高亮部分:

图 7.12 – 编辑 iOS 的 info.plist 文件

图 7.12 – 编辑 iOS 的 info.plist 文件

Platforms/MacCatalyst 文件夹中的 info.plist 文件中做出相同的更改,如下所示:

图 7.13 – 编辑 Mac Catalyst 的 info.plist 文件

图 7.13 – 编辑 Mac Catalyst 的 info.plist 文件

Windows 用户

要在 Windows 上编辑info.plist文件,您需要通过右键单击文件,选择打开方式…,然后选择XML 编辑器来在文本编辑器中打开文件。然后,添加下一代码片段中突出显示的条目。

使用属性列表编辑器编辑info.plist文件会导致以下高亮显示的更改:

    <key>XSAppIconAssets</key>
    <string>Assets.xcassets/appicon.appiconset</string>
    <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
    <string>Can we use your location at all times?</string>
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>Can we use your location when your app is being used?</string>
</dict>
</plist>

要在 Android 中后台跟踪用户的位置,我们需要声明五个权限,如下表所示:

ACCESS_COARSE_LOCATION 获取用户的大致位置
ACCESS_FINE_LOCATION 获取用户的确切位置
ACCESS_NETWORK_STATE 我们需要这个权限,因为 Android 中的位置服务使用网络信息来确定用户的位置
ACCESS_WIFI_STATE 我们需要这个权限,因为 Android 中的位置服务使用 Wi-Fi 网络的信息来确定用户的位置
RECEIVE_BOOT_COMPLETED 这样后台任务在设备重启后可以再次启动

以下步骤将声明我们应用所需的权限:

  1. Platforms/Android文件夹中打开MainActivity.cs文件。

  2. using声明块的末尾添加以下assembly属性,如下所示:

    using Android.App;
    using Android.Runtime;
    [assembly: UsesPermission(Android.Manifest.Permission.AccessCoarseLocation)]
    [assembly: UsesPermission(Android.Manifest.Permission.AccessFineLocation)]
    [assembly: UsesPermission(Android.Manifest.Permission.AccessWifiState)]
    [assembly: UsesPermission(Android.Manifest.Permission.ReceiveBootCompleted)]
    namespace MeTracker;
    

    注意,我们没有声明Android.Manifest.Permission.AccessNetworkState,因为它包含在.NET MAUI 模板中。

现在我们已经声明了我们所需的全部权限,我们可以在 Android 上启用地图服务。

Android 需要API 密钥才能使Google Maps与地图一起工作。有关如何获取 API 密钥的 Microsoft 文档可以在learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/map?view=net-maui-7.0#get-a-google-maps-api-key找到。按照这些说明获取您的 Google Maps 密钥,然后按照以下步骤在应用中使用您的密钥来配置 Google Maps API 密钥:

  1. 通过右键单击文件并选择打开方式…,然后选择XML (****文本) 编辑器来打开位于Platforms/Android文件夹中的AndroidManifest.xml文件。

  2. 在应用程序元素下插入一个元数据元素,如下所示的高亮代码中所示,将"{YourKeyHere}"替换为从 Google 获得的密钥:

    <application android:label="MeTracker.Android">
      <meta-data android:name="com.google.android.geo.API_KEY" android:value="{YourKeyHere}" />
    </application>
    

Android 和 iOS 的最近版本已经改变了权限的处理方式。在应用运行时,某些权限,如位置权限,如果没有用户的明确批准则不会授予。也有可能用户会拒绝权限。让我们在下一节中看看如何处理运行时权限请求。

在运行时请求位置权限

在我们可以使用用户的位置之前,我们需要从用户那里请求权限。.NET MAUI 有跨平台的权限 API,我们只需要一点代码来使处理请求更加优雅。要实现权限请求处理,请按照以下步骤操作:

  1. 在项目的根目录下创建一个名为AppPermissions的新类。

  2. 编辑新文件以看起来像以下内容:

    namespace MeTracker;
    internal partial class AppPermissions
    {
        internal partial class AppPermission : Permissions.LocationWhenInUse
        {
        }
        public static async Task<PermissionStatus> CheckRequiredPermissionAsync() => await Permissions.CheckStatusAsync<AppPermission>();
        public static async Task<PermissionStatus> CheckAndRequestRequiredPermissionAsync()
        {
            PermissionStatus status = await Permissions.CheckStatusAsync<AppPermission>();
            if (status == PermissionStatus.Granted)
                return status;
            if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
            {
                // Prompt the user to turn on in settings
                // On iOS once a permission has been denied it may not be requested again from the application
                await App.Current.MainPage.DisplayAlert("Required App Permissions", "Please enable all permissions in Settings for this App, it is useless without them.", "Ok");
            }
            if (Permissions.ShouldShowRationale<AppPermission>())
            {
                // Prompt the user with additional information as to why the permission is needed
                await App.Current.MainPage.DisplayAlert("Required App Permissions", "This is a location based app, without these permissions it is useless.", "Ok");
            }
            status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<AppPermission>);
            return status;
        }
    }
    

    这创建了一个名为AppPermission的类型,它从默认的.NET MAUI LocationWhenInUse权限类继承。

    CheckRequiredPermission 方法用于确保在我们尝试任何可能会失败的操作之前,我们的应用程序拥有正确的权限。其实现是调用.NET MAUI 的CheckSyncStatus方法并使用我们的AppPermission类型。它返回一个PermissionStatus类型,这是一个枚举。我们主要对DeniedGranted值感兴趣。

    CheckAndRequestRequiredPermission 方法处理从用户请求访问的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果我们处于 iOS 并且权限已被拒绝,则无法再次请求,因此您必须指导用户如何通过设置面板授予应用程序权限。Android 在请求行为中包括如果用户拒绝访问则骚扰用户的能力。此行为通过.NET MAUI 的ShouldShowRationale方法公开。对于不支持此行为的任何平台,它将返回false;在 Android 上,如果用户第一次拒绝访问,它将返回true,如果用户第二次拒绝,则返回false。最后,我们从用户那里请求对AppPermission类型的访问。同样,.NET MAUI 正在隐藏所有平台实现细节,使得检查和请求访问某些资源变得非常直接。

现在我们已经设置了AppPermissions类,我们可以使用它来请求用户的当前位置,并将地图中心定位在该位置。

在当前用户位置上居中地图

我们将在MainView.xaml.cs的构造函数中使地图以用户的位置为中心。因为我们想异步获取用户的位置,并且这需要在主线程上执行,所以我们将使用MainThread.BeginInvokeOnMainThread在主线程上运行一个匿名方法。一旦我们有了位置,我们就可以使用MapMoveToRegion方法。我们可以通过以下步骤来设置它:

  1. 打开MainView.xaml.cs

  2. 将以下代码片段中突出显示的代码添加到MainView.xaml.cs类的构造函数中:

    public MainView ()
    {
        InitializeComponent();
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync();
            if (status == PermissionStatus.Granted)
            {
                var location = await Geolocation.GetLastKnownLocationAsync();
                if (location == null)
                {
                    location = await Geolocation.GetLocationAsync();
                }
                Map.MoveToRegion(MapSpan.FromCenterAndRadius(
                    location,
                    Distance.FromKilometers(50)));
            }
        });
    }
    

如果现在运行应用程序,它应该看起来像以下内容:

图 7.14 – 以用户位置为中心的地图

图 7.14 – 以用户位置为中心的地图

现在我们已经有了显示我们当前位置的地图,让我们开始构建应用程序其余部分的逻辑,从我们的 ViewModel 类开始。

创建 ViewModel 类

在我们创建实际的 ViewModel 类之前,我们将创建一个所有视图模型都可以继承的抽象基视图模型。这个基视图模型背后的想法是我们可以在其中编写通用代码。在这种情况下,我们将通过使用 CommunityToolkit.Mvvm NuGet 包来实现 INotifyPropertyChanged 接口。要添加包,请按照以下步骤操作:

  1. 通过在 Solution Explorer 中的 Dependencies 节点右键单击添加对 CommunityToolkit.Mvvm 的引用:

图 7.15 – 添加 NuGet 包

图 7.15 – 添加 NuGet 包

  1. 从上下文菜单中选择 Manage NuGet Packages… 以打开 NuGet package manager 窗口。

  2. 在搜索框中输入 CommunityToolkit.Mvvm,如下所示:

图 7.16 – 添加 CommunityToolkit.Mvvm 包

图 7.16 – 添加 CommunityToolkit.Mvvm 包

  1. 最后,勾选 CommunityToolkit.Mvvm 旁边的复选框,然后单击 Add Package

现在,我们可以通过以下步骤创建一个 ViewModel 类:

  1. 在项目中创建一个名为 ViewModels 的文件夹。

  2. 创建一个名为 ViewModel 的新类。

  3. 修改模板代码以匹配以下内容:

    using CommunityToolkit.Mvvm.ComponentModel;
    namespace MeTracker.ViewModels;
    public partial class ViewModel : ObservableObject
    {
    }
    

下一步是创建一个实际使用 ViewModel 作为基类的视图模型。让我们通过以下步骤来设置它:

  1. ViewModels 文件夹中创建一个新的 MainViewModel 类。

  2. 使 MainViewModel 类继承 ViewModel

  3. 添加一个只读字段,类型为 ILocationTrackingService,命名为 locationTrackingService

  4. 添加一个只读字段,类型为 ILocationRepository,命名为 locationRepository

  5. 创建一个带有 ILocationTrackingServiceILocationRepository 作为参数的构造函数。

  6. 使用参数的值设置我们在 步骤 3步骤 4 中创建的字段的值,如下面的代码片段所示:

    public class MainViewModel : ViewModel
    {
        private readonly ILocationRepository locationRepository;
        private readonly ILocationTrackingService locationTrackingService;
        public MainViewModel(ILocationTrackingService locationTrackingService,
            ILocationRepository locationRepository)
        {
            this.locationTrackingService = locationTrackingService;
            this.locationRepository = locationRepository;
        }
    }
    

要使应用程序开始跟踪用户的地理位置,我们需要在主线程上运行启动跟踪过程的代码。按照以下步骤操作:

  1. 在新创建的 MainViewModel 类的构造函数中,使用 MainThread.BeginInvokeOnMainThread 向主线程添加调用。

  2. 在传递给 BeginInvokeOnMainThread 方法的操作中调用 locationService.StartTracking。这在上面的高亮代码中显示:

    public MainViewModel(ILocationTrackingService locationTrackingService, ILocationRepository locationRepository)
    {
        this.locationTrackingService = locationTrackingService;
        this.locationRepository = locationRepository;
        MainThread.BeginInvokeOnMainThread(() =>
        {
            locationTrackingService.StartTracking();
        });
    }
    

最后,我们需要将 MainViewModel 类注入到 MainView 的构造函数中,并将 MainViewModel 实例分配给视图的绑定上下文。这将允许我们完成的数据绑定被处理,并且 MainViewModel 的属性将被绑定到用户界面中的控件。按照以下步骤操作:

  1. 前往 Views/MainView.xaml.cs 文件的构造函数。

  2. MainViewModel 作为构造函数的参数并命名为 viewModel

  3. BindingContext 设置为 MainViewModel 的实例,如下代码片段所示:

    public MainView(MainViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            var location = await Geolocation.GetLastKnownLocationAsync();
            if(location == null)
            {
                location = await Geolocation.GetLocationAsync();
            }
            Map.MoveToRegion(MapSpan.FromCenterAndRadius(
                location, Distance.FromKilometers(5)));
        });
    }
    

为了让 .NET MAUI 定位到我们在此部分已实现的类,我们需要将它们添加到 依赖注入DI) 容器中。

将类添加到 DI 容器中

由于我们向视图的构造函数中添加了一个参数,.NET MAUI View 框架将无法自动构建视图。因此,我们需要将 MainViewMainViewModelLocationTrackingServiceLocationRepository 实例添加到 DI 容器中。为此,请按照以下步骤操作:

  1. 打开 MauiProgram.cs 文件。

  2. 将以下突出显示的行添加到 CreateMauiApp 方法中:

    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            })
            .UseMauiMaps(); 
    #if DEBUG
        builder.Logging.AddDebug();
    #endif
        builder.Services.AddSingleton<Services.ILocationTrackingService, Services.LocationTrackingService>();
            builder.Services.AddSingleton<Repositories.ILocationRepository, Repositories.LocationRepository>();
            builder.Services.AddTransient(typeof(ViewModels.MainViewModel));
            builder.Services.AddTransient(typeof(Views.MainView));
        return builder.Build();
    }
    

现在,我们再次能够运行应用程序。我们没有更改任何接口,所以它应该看起来和表现与之前相同。如果它没有,请仔细回顾前面的部分,确保所有代码都是正确的。

让我们添加一些代码,以便我们可以使用后台位置跟踪跟踪用户的位置随时间的变化。

iOS 和 Mac Catalyst 的后台位置跟踪

位置跟踪的代码是我们需要为每个平台编写的。对于 iOS 和 Mac Catalyst,我们将使用 CoreLocation 命名空间中的 CLLocationManager

启用后台位置更新

当我们想在 iOS 或 Mac Catalyst 应用程序中执行后台任务时,我们需要在 info.plist 文件中声明我们想要执行的操作。以下步骤展示了我们如何进行这一操作:

  1. 打开 info.plist;您需要为 Platforms/iOS/info.plistPlatforms/MacCatalyst/info.plist 都这样做。

  2. 使用 属性列表编辑器 通过从下拉菜单中选择 Required background modes 并选择 App registers for location updates,按照以下截图所示添加以下突出显示的条目:

图 7.17 – 添加位置更新

图 7.17 – 添加位置更新

我们也可以使用 XML 编辑器直接在 info.plist 文件中启用后台模式。在这种情况下,我们将添加以下 XML:

<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>

订阅位置更新

现在我们已经为位置跟踪准备好了 info.plist 文件,是时候编写实际跟踪用户位置的代码了。如果我们没有将 CLLocationManager 设置为不暂停位置更新,iOS 或 Mac Catalyst 在位置数据不太可能改变时可以自动暂停位置更新。在这个应用程序中,我们不希望发生这种情况,因为我们想多次保存位置,以便我们可以确定用户是否频繁访问特定位置。

如果您还记得,我们之前已经将服务定义为部分类和部分方法;现在,我们将通过实现服务的平台特定部分来完成服务。让我们设置如下:

  1. Platforms/iOS 文件夹中创建一个名为 Services 的新文件夹。

  2. Services 文件夹中创建一个名为 LocationTrackingService 的新类。

  3. 修改类以匹配以下内容:

    namespace MeTracker.Services;
    public partial class LocationTrackingService : ILocationTrackingService
    {
        partial void StartTrackingInternal()
        {
        }
    }
    
  4. CLLocationManager 添加一个私有字段。

  5. StartTrackingInternal 方法中创建一个 CLLocationManager 的实例。

  6. PausesLocationUpdatesAutomatically 设置为 false

    在我们可以开始跟踪用户的位置之前,我们需要设置我们从 CLLocationManager 收到的数据的精度。我们还将添加一个事件处理程序来处理位置更新。

  7. DesiredAccuracy 设置为 CLLocation.AccuracyBestForNavigation。在应用程序在后台运行时,DesiredAccuracy 需要设置为 AccuracyBestAccuracyBestForNavigation 之一。

  8. AllowBackgroundLocationUpdates 设置为 true(如以下代码片段所示),这样即使应用程序在后台运行,位置更新也会继续。

    您的更改应如下所示:

    CLLocation locationManager;
    partial void StartTrackingInternal()
    {
        locationManager = new CLLocationManager
        {
            PausesLocationUpdatesAutomatically = false,
            DesiredAccuracy = CLLocation.AccuracyBestForNavigation,
            AllowsBackgroundLocationUpdates = true
        };
        // Add code here
    }
    

下一步是请求用户允许跟踪他们的位置。我们将请求始终跟踪用户的位置,但用户可以选择只在他们使用应用程序时才给我们权限跟踪他们的位置。因为用户也有权拒绝我们跟踪他们的位置,所以在开始之前我们需要检查这一点。让我们设置它:

  1. // Add code here 注释之后添加 LocationsUpdated 的事件处理程序。它应类似于以下片段中高亮的代码:

    partial void StartTrackingInternal()
    {
        locationManager = new CLLocationManager
        {
            PausesLocationUpdatesAutomatically = false,
            DesiredAccuracy = CLLocation.AccuracyBestForNavigation,
            AllowsBackgroundLocationUpdates = true
        };
        // Add code here
        locationManager.LocationsUpdated +=
    async (object sender, CLLocationsUpdatedEventArgs e) =>
        {
            // Final block of code goes here
        };
    };
    
  2. 在事件处理程序之后,调用我们最近在 CLLocationManager 中创建的实例的 RequestAlwaysAuthorization 方法:

    partial void StartTrackingInternal()
    {
        locationManager = new CLLocationManager
        {
            PausesLocationUpdatesAutomatically = false,
            DesiredAccuracy = CLLocation.AccurracyBestForNavigation,
            AllowsBackgroundLocationUpdates = true
        };
        // Add code here
        locationManager.LocationsUpdated +=
    async (object sender, CLLocationsUpdatedEventArgs e) =>
        {
            // Final block of code goes here
        };
        locationManager.RequestAlwaysAuthorization();
    };
    
  3. 然后,调用 locationManagerStartUpdatingLocation 方法:

    partial void StartTrackingInternal()
    {
        locationManager = new CLLocationManager
        {
            PausesLocationUpdatesAutomatically = false,
            DesiredAccuracy = CLLocation.AccurracyBestForNavigation,
            AllowsBackgroundLocationUpdates = true
        };
        // Add code here
        locationManager.LocationsUpdated +=
    async (object sender, CLLocationsUpdatedEventArgs e) =>
        {
            // Final block of code goes here
        };
        locationManager.RequestAlwaysAuthorization();
        locationManager.StartUpdatingLocation();
    };
    

小贴士

精度越高,电池消耗就越高。如果我们只想跟踪用户去过哪里,而不关心一个地方有多受欢迎,我们也可以设置 AllowDeferredLocationUpdatesUntil。这样,我们可以指定用户必须移动一定距离后才会更新位置。我们还可以使用 timeout 参数指定我们希望位置更新的频率。要跟踪用户在一个地方停留了多久,最节能的解决方案是使用 CLLocationManagerStartMonitoringVisits 方法。

现在,是时候处理 LocationsUpdated 事件了。让我们按以下步骤进行:

  1. 添加一个名为 locationRepository 的私有字段,其类型为 ILocationRepository

  2. 添加一个具有 ILocationRepository 作为参数的构造函数。将参数的值设置为 locationRepository 字段。您的类应类似于以下代码片段:

    using CoreLocation;
    using MeTracker.Repositories;
    namespace MeTracker.Services;
    public partial class LocationTrackingService : ILocationTrackingService
    {
        CLLocationManager locationManager;
        ILocationRepository locationRepository;
        public LocationTrackingService(ILocationRepository locationRepository)
        {
            this.locationRepository = locationRepository;
        }
        partial void StartTrackingInternal()
        {
        // Remainder of code omitted for brevity
        }
    
  3. 读取 CLLocationsUpdatedEventArgsLocations 属性的最新位置。

  4. 创建一个 MeTracker.Models.Location 的实例,并将最新位置的纬度和经度传递给它。

  5. 使用 ILocationRepositorySaveAsync 方法保存位置。

  6. 代码应放置在 // Final block of code goes here 注释之后。它应类似于以下片段中加粗的代码:

    locationManager.LocationsUpdated +=
    async (object sender, CLLocationsUpdatedEventArgs e) =>
    {
        // Final block of code goes here
        var lastLocation = e.Locations.Last();
        var newLocation = new Models.Location(lastLocation.Coordinate.Latitude, lastLocation.Coordinate.Longitude);
         await locationRepository.SaveAsync(newLocation);
    };
    

这样,我们就完成了 iOS 应用程序的跟踪部分。对于 Mac Catalyst 的实现是相同的;你可以重复本节中的步骤为 Mac Catalyst(但将文件创建为 Platforms/MacCatalyst/Services 而不是 Platforms/iOS/Services),或者将 Platforms/iOS/Services/LocationTrackingService.cs 文件复制到 Platforms/MacCatalyst/Services 文件夹中。

现在,我们将实现 Android 的后台跟踪,之后我们将可视化位置跟踪数据。

Android 后台位置跟踪

Android 实现后台更新的方式与我们用 iOS 实现的方式非常不同。在 Android 中,我们需要创建一个 JobService 类并对其进行调度。

创建后台作业

要在后台跟踪用户的位置,我们需要创建一个后台作业。后台作业由操作系统使用,允许开发者在应用不在前台或屏幕上可见时执行代码。按照以下步骤创建一个后台作业以捕获用户的位置:

  1. Platforms/Android 文件夹中创建一个名为 Services 的新文件夹。

  2. Platforms/Android 文件夹中创建一个名为 LocationJobService 的新类。

  3. 将类继承自 Android.App.Job.JobService 作为基类。

  4. 在文件顶部添加 using Android.App.Jobusing Android.App.Job 声明。

  5. 实现 OnStartJobOnStopJob 抽象方法,如下面的代码片段所示:

    using Android.App;
    using Android.App.Job;
    namespace MeTracker.Platforms.Android.Services;
    internal class LocationJobService : JobService
    {
        public override bool OnStartJob(JobParameters @params)
        {
            return true;
        }
        public override bool OnStopJob(JobParameters @params)
        {
            return true;
        }
    }
    

应用程序中的所有 Android 服务都需要添加到 AndroidManifest.xml 文件中。我们不必手动进行此操作;相反,我们可以在 LocationJobService 类中添加一个属性,然后它将在 AndroidManifest.xml 文件中生成。我们将使用 NamePermission 属性来设置所需的信息,如下面的代码片段所示:

[Service(Name = "MeTracker.Platforms.Android.Services.LocationJobService", Permission = "android.permission.BIND_JOB_SERVICE")]
internal class LocationJobService : JobService

调度后台作业

当我们创建了一个作业后,我们需要对其进行调度。我们将从 Platforms/Android/LocationTrackingService 文件夹中进行操作。为了配置作业,我们将使用 JobInfo.Builder 类。

我们将使用 SetPersisted 方法来确保在重启后作业会再次启动。这就是为什么我们之前添加了 RECEIVE_BOOT_COMPLETED 权限。

要调度一个作业,至少需要一个约束。在这种情况下,我们将使用 SetOverrideDeadline。这将指定作业需要在指定时间(以毫秒为单位)过去之前运行。

SetRequiresDeviceIdle 方法可以用来确保作业仅在设备未被用户使用时运行。如果我们想确保在用户使用设备时不会减慢设备速度,我们可以将 true 传递给该方法。

SetRequiresBatteryNotLow 方法可以用来指定当电池电量低时不应运行作业。如果你没有在电池电量低时运行作业的合理理由,我们建议始终将其设置为 true。这是因为我们不希望我们的应用程序耗尽用户的电池电量。

因此,让我们实现 LocationTrackingService。按照以下步骤进行:

  1. Platforms/Android/Services 文件夹中创建一个名为 LocationTrackingService 的新类。

  2. 修改类,使其看起来如下:

    namespace MeTracker.Services;
    public partial class LocationTrackingService : ILocationTrackingService
    {
        partial void StartTrackingInternal()
        {
        }
    }
    
  3. StartTrackingInternal 方法中,基于我们将指定的 ID(这里我们将使用 1)和组件名称(我们将从应用程序上下文和 Java 类中创建)创建一个 JobInfo.Builder 类。组件名称用于指定在作业期间将运行哪些代码。

  4. 使用 SetOverrideDeadline 方法并将 1000 传递给它,以确保作业在作业创建后 1 秒内运行。

  5. 使用 SetPersisted 方法并传递 true,使作业即使在设备重新启动后也能持续。

  6. 使用 SetRequiresDeviceIdle 方法并传递 false,这样即使用户正在使用设备,作业也会运行。

  7. 使用 SetRequiresBatteryLow 方法并传递 true,以确保我们不会耗尽用户的电池。此方法是在 Android API 级别 26 中添加的。

    LocationTrackingService 的代码现在应该看起来像这样:

    using Android.App.Job;
    using Android.Content;
    using MeTracker.Platforms.Android.Services;
    namespace MeTracker.Services;
    public partial class LocationTrackingService : ILocationTrackingService
    {
        partial void StartTrackingInternal()
        {
            var javaClass = Java.Lang.Class.FromType(typeof(LocationJobService));
            var componentName = new ComponentName(global::Android.App.Application.Context, javaClass);
            var jobBuilder = new JobInfo.Builder(1, componentName);
            jobBuilder.SetOverrideDeadline(1000);
            jobBuilder.SetPersisted(true);
            jobBuilder.SetRequiresDeviceIdle(false);
            jobBuilder.SetRequiresBatteryNotLow(true);
            var jobInfo = jobBuilder.Build();
        }
    }
    

StartTrackingInternal 方法中的最后一步是使用 JobScheduler 系统安排作业。JobScheduler 服务是一个 Android 系统服务。为了获取系统服务的实例,我们将使用应用程序上下文。按照以下步骤进行:

  1. 使用 GetSystemService 方法在 Application.Context 上获取 JobScheduler 服务。

  2. 将结果转换为 JobScheduler

  3. JobScheduler 类上使用 Schedule 方法并传递 JobInfo 对象来安排作业,如下面的代码片段所示:

    var jobScheduler = (JobScheduler)global::Android.App.Application.Context.GetSystemService(Context.JobSchedulerService);
    jobScheduler.Schedule(jobInfo);
    

现在作业已经安排好了,我们可以开始接收位置更新;让我们继续这个工作。

订阅位置更新

一旦我们安排了作业,我们可以编写代码来指定作业应该做什么——即跟踪用户的地理位置。为此,我们将使用 LocationManager,这是一个 SystemService 类。使用 LocationManager,我们可以请求单个位置更新或订阅位置更新。在这种情况下,我们想要订阅位置更新。

我们将首先创建 ILocationRepository 接口的一个实例。我们将使用它将位置保存到 SQLite 数据库中。让我们设置一下:

  1. LocationJobService 创建一个构造函数。

  2. ILocationRepository 接口创建一个名为 locationRepositoryprivate 只读字段。

  3. 在构造函数中使用 Services.GetService<T> 创建 ILocationRepository 的实例,如下面的代码片段所示:

    private ILocationRepository locationRepository;
    public LocationJobService()
    {
        locationRepository = MauiApplication.Current.Services.GetService<ILocationRepository>();
    }
    

在我们订阅位置更新之前,我们将添加一个监听器。为此,我们将使用 Android.Locations.ILocationListener 接口。

按照以下步骤进行:

  1. Android.Locations.ILocationListener 接口添加到 LocationJobService

  2. 将以下命名空间声明添加到文件顶部:

    using Android.Content;
    using Android.Locations;
    using Android.OS;
    using Android.Runtime;
    using MeTracker.Repositories;
    
  3. 实现接口并移除所有 throw new NotImplemented Exception(); 实例。这是在您让 Visual Studio 生成接口实现时添加到方法中的。

    方法实现应类似于以下代码片段:

        public override bool OnStartJob(JobParameters @params)
        {
            return true;
        }
        public void OnLocationChanged(global::Android.Locations.Location location) { }
        public override bool OnStopJob(JobParameters @params) => true;
        public void OnStatusChanged(string provider, [GeneratedEnum] Availability status, Bundle extras) { }
        public void OnProviderDisabled(string provider) { }
        public void OliknProviderEnabled(string provider) { }
    
  4. OnLocationChanged 方法中,将 Android.Locations.Location 对象映射到 Model.Location 对象。

  5. 使用 LocationRepository 类上的 SaveAsync 方法,如下代码片段所示:

    public void OnLocationChanged(Android.Locations.Location location)
    {
    var newLocation = new Models.Location(location.Latitude, location.Longitude);
    locationRepository.SaveAsync(newLocation);
    }
    

现在我们已经创建了一个监听器,我们可以订阅位置更新。按照以下步骤进行:

  1. 创建一个名为 locationManagerLocationManager 类型的 static 字段。确保它具有与应用程序相同的生命周期。

  2. 在 Android 中,JobService 可能会在 MainView 显示之前启动,并且我们请求位置权限。为了避免因权限缺失而导致的任何错误,我们将首先检查权限。

    public override bool OnStartJob(JobParameters @params)
    {
        PermissionStatus status = PermissionStatus.Unknown;
        Task.Run(async ()=> status = await AppPermissions.CheckRequiredPermissionAsync()).Wait();
        if (status == PermissionStatus.Granted)
        {
        }
    }
    

    我们在 Task.Run 实例中运行 CheckRequiredPermissionsAsync,因为它是一个 async 调用,我们不能将 async 添加到方法中,因为返回类型不兼容。对 Wait 的调用将 async 调用转换为同步调用。如果结果是 Granted,则我们可以继续。

  3. 前往 LocationJobService 中的 StartJob 方法。通过在 ApplicationContext 上调用 GetSystemService 获取 LocationManager

  4. 要订阅位置更新,使用如下代码片段所示的 RequestLocationUpdates 方法:

    public override bool OnStartJob(JobParameters @params)
    {
        PermissionStatus status = PermissionStatus.Unknown;
        Task.Run(async ()=> status = await AppPermissions.CheckRequiredPermissionAsync()).Wait();
        if (status == PermissionStatus.Granted)
        {
            locationManager = (LocationManager)ApplicationContext.GetSystemService  (Context.LocationService);
            locationManager.RequestLocationUpdates (LocationManager.GpsProvider, 1000L, 0.1f, this);
            return true;
        }
        return false;
    }
    

我们传递给 RequestLocationUpdates 方法的第一个参数确保我们从 GPS 获取位置。第二个参数确保位置更新之间至少有 1000 毫秒的间隔。第三个参数确保用户至少移动 0.1 米以获取位置更新。最后一个参数指定我们应使用哪个监听器。因为当前类实现了 Android.Locations.ILocationListener 接口,所以我们将传递 this

现在我们已经从用户那里收集了位置数据并将其存储在我们的 SQLite 数据库中,我们现在可以在地图上显示这些数据。

创建热图

为了可视化我们收集的数据,我们将创建一个热图。我们将在地图上添加很多点,并根据用户在特定地点花费的时间长短使它们呈现不同的颜色。最受欢迎的地方将呈现暖色调,而最不受欢迎的地方将呈现冷色调。

在我们将点添加到地图之前,我们需要从存储库中获取所有位置。

向 LocationRepository 添加 GetAllAsync 方法

为了可视化数据,我们需要编写一些代码,以便可以从数据库中读取位置数据。让我们设置如下:

  1. 打开 ILocationRepository.cs 文件。

  2. 添加一个返回 Location 对象列表的 GetAllAsync 方法,如下代码所示:

    Task<List<Models.Location>> GetAllAsync();
    
  3. 打开实现 ILocationRepositoryLocationRepository.cs 文件。

  4. 实现新的 GetAllAsync 方法,并返回数据库中所有保存的位置,如下代码片段所示:

    public async Task<List<Location>> GetAllAsync()
    {
        await CreateConnectionAsync();
        var locations = await connection.Table<Location> ().ToListAsync();
        return locations;
    }
    

准备可视化数据

在我们可以在地图上可视化数据之前,我们需要准备数据。我们首先要做的是创建一个新的模型,我们可以用它来准备数据。让我们设置一下:

  1. Models 文件夹中,创建一个名为 Point 的新类。

  2. 添加 LocationCountHeat 属性,如下代码片段所示:

    namespace MeTracker.Models{
    public class Point
    {
        public Location Location { get; set; }
        public int Count { get; set; } = 1;
        public Color Heat { get; set; }
    }
    }
    

    MainViewModel 将存储我们稍后找到的位置。让我们为存储点添加一个属性。

  3. 打开 MainViewModel 类。

  4. 添加一个名为 pointsprivate 字段,其类型为 List<Point>

  5. ObservableProperty 属性添加到 points 字段,如下代码片段所示:

    [ObservableProperty]
    private List<Models.Point> points;
    

现在我们已经有了点的存储,我们必须添加一些代码,以便我们可以添加位置。我们将通过实现 MainViewModel 类的 LoadDataAsync 方法来完成此操作,并确保它在位置跟踪开始后立即在主线程上调用。

我们首先要做的是将保存的位置分组,以便所有在 200 米范围内的位置都将作为一个点处理。我们将跟踪在该点内记录位置的次数,以便我们可以决定点在地图上的颜色。让我们设置一下:

  1. 添加一个名为 LoadDataAsyncasync 方法。此方法返回一个 Task 对象给 MainViewModel

  2. 在调用 ILocationTrackingService 上的 StartTracking 方法之后,从构造函数中调用 LoadDataAsync 方法,如下代码片段所示:

    public MainViewModel(ILocationTrackingService locationTrackingService, ILocationRepository locationRepository)
    {
        this.locationTrackingService = locationTrackingService;
        this.locationRepository = locationRepository;
        MainThread.BeginInvokeOnMainThread(async() =>
        {
            locationTrackingService.StartTracking();
            await LoadDataAsync();
        });
    }
    

LoadDataAsync 方法的第一步是从 SQLite 数据库中读取所有跟踪的位置。当我们拥有所有位置后,我们将遍历它们并创建点。

要计算位置和点之间的距离,我们将使用 CalculateDistance 方法,如下代码片段所示:

private async Task LoadDataAsync()
{
    var locations = await locationRepository.GetAll();
    var pointList = new List<Models.Point>();
    foreach (var location in locations)
    {
        //If no points exist, create a new one and continue to the next location in the list
        if (!pointList.Any())
        {
            pointList.Add(new Models.Point() { Location = location });
            continue;
        }
        var pointFound = false;
        //try to find a point for the current location
        foreach (var point in pointList)
        {
            var distance = Location.CalculateDistance(
                new Location(point.Location.Latitude, point.Location.Longitude),
                new Location(location.Latitude, location.Longitude),
                DistanceUnits.Kilometers);
            if (distance < 0.2)
            {
                pointFound = true;
                point.Count++;
                break;
            }
        }
        //if no point is found, add a new Point to the list of points
        if (!pointFound)
        {
            pointList.Add(new Models.Point() { Location = location });
        }
        // Next section of code goes here
    }
}

当我们有一个点的列表时,我们可以计算每个点的热色。我们将使用颜色的 色调饱和度亮度HSL)表示,如下所述:

  • 色调:色调是色轮上的一个度数,从 0 到 360,0 为红色,240 为蓝色。因为我们希望最热门的地点是红色(热),最不热门的地点是蓝色(冷),我们将根据用户访问该地点的次数计算一个介于 0 和 240 之间的值,这意味着我们只会使用三分之二的刻度。

  • (代码中的 1)。

  • (代码中的 0.5)。

我们首先需要做的是找出用户访问最热门和最不热门地点的次数。让我们看一下:

  1. 首先,检查点的列表不为空。

  2. 获取点列表中 Count 属性的 MinMax 值。

  3. 计算最小值和最大值之间的差异。

  4. 代码应该在LoadDataAsync方法底部的// Next section of code goes here注释之后添加,如下面的代码片段所示:

    private async Task LoadDataAsync()
    {
        // The rest of the method has been omitted for brevity
        // Next section of code goes here
        if (pointList == null || !pointList.Any())
        {
            return;
        }
        var pointMax = pointList.Select(x => x.Count).Max();
        var pointMin = pointList.Select(x => x.Count).Min();
        var diff = (float)(pointMax - pointMin);
        // Last section of code goes here
    }
    

现在,我们可以计算每个点的热值,如下所示:

  1. 遍历所有点。

  2. 应该在LoadDataAsync()方法底部的// Last section of code goes here注释之后添加以下代码(以下代码片段已突出显示):

    private async Task LoadDataAsync()
    {
        // The rest of the method has been omitted for brevity
        // Next section of code goes here
        if (pointList == null || !pointList.Any())
        {
            return;
        }
        var pointMax = pointList.Select(x => x.Count).Max();
        var pointMin = pointList.Select(x => x.Count).Min();
        var diff = (float)(pointMax - pointMin);
        // Last section of code goes here
        foreach (var point in pointList)
        {
            var heat = (2f / 3f) - ((float)point.Count / diff);
            point.Heat = Color.FromHsla(heat, 1, 0.5);
        }
        Points = pointList;
    }
    

这就是我们为MeTracker项目设置位置跟踪所需做的全部工作。现在,让我们将注意力转向可视化我们接收到的数据。

添加数据可视化

在.NET MAUI 中,Map控件可以在地图上渲染额外的信息。这包括图钉和自定义形状,被称为MapElements。我们可以简单地添加存储在存储库中的每个位置作为图钉;然而,为了得到热图,我们想在地图上的每个位置添加一个彩色圆点,所以我们将为每个位置使用MapElements

如果MapElements属性是BindableProperty,我们就可以使用转换器将MainViewModelPoints属性映射到地图的MapElements属性进行绑定。但是MapElements不是一个可绑定的属性,所以这不会那么简单。

让我们先创建一个自定义地图控件。

创建地图的自定义控件

为了在我们的地图上显示热图,我们将创建一个新的控件。由于Map是一个密封类,我们无法直接继承它;相反,我们将使用BindablePropertyMap控件封装在ContentView中,以便从ViewModel访问Points数据。

按照以下步骤创建自定义控件:

  1. 创建一个名为Controls的新文件夹。

  2. 创建一个名为CustomMap的新类。

  3. ContentView作为基类添加到新类中,如下面的代码片段所示:

    namespace MeTracker.Controls;
    public class CustomMap : ContentView
    {
        public CustomMap()
        {
        }
    }
    

现在,我们需要将Map控件添加到自定义控件中。按照以下步骤添加Map控件:

  1. 从.NET MAUI 的Map控件派生出CustomMap控件,如下面的代码片段所示:

    using Microsoft.Maui.Controls.Maps;
    using Microsoft.Maui.Maps;
    using Map = Microsoft.Maui.Controls.Maps.Map;
    namespace MeTracker.Controls;
    public class CustomMap : Map
    {
        public CustomMap()
        {
        }
    }
    
  2. 在构造函数中初始化地图,如下所示(新更改已突出显示):

    public CustomMap()
    {
        IsScrollEnabled = true;
        IsShowingUser = true;
    }
    

如果我们想要将数据绑定到属性,我们需要创建一个BindableProperty类。这应该是类中的一个公共静态字段。我们还需要创建一个常规属性来保存值。属性的命名非常重要。BindableProperty的名称需要是{NameOfTheProperty}Property;例如,我们将在以下步骤中创建的BindableProperty的名称将是PointsProperty,因为属性的名称是PointsBindableProperty是通过BindableProperty类的静态Create方法创建的。这至少需要四个参数,如下所示:

  • propertyName:这是属性名称的字符串。

  • returnType:这是属性将返回的类型。

  • declaringType:这是BindableProperty声明的类的类型。

  • defaultValue:这是如果没有设置值将返回的默认值。这是一个可选参数。如果没有设置,.NET MAUI 将使用 null 作为默认值。

该属性的 setget 方法将调用基类中的方法来设置或从 BindableProperty 获取值:

  1. 创建一个名为 PointsPropertyBindableProperty,如下面的代码片段所示:

    public static BindableProperty PointsProperty = BindableProperty.Create(nameof(Points), typeof(List<Models.Point>), typeof(CustomMap), new List<Models.Point>());
    
  2. 创建一个名为 PointsList<Models.Point> 类型的属性。记住将 GetValue 的结果转换为与属性相同的类型。我们需要这样做,因为 GetValue 将返回一个 type 对象作为值:

    public List<Models.Point> Points
    {
    get => GetValue(PointsProperty) as List<Models.Point>;
    set => SetValue(PointsProperty, value);
    }
    

为了显示 Points,我们需要将它们转换为 MapElements。这是通过一个名为 PropertyChangedBindingProperty 事件来完成的。每当 BindingProperty 发生变化时,都会触发 PropertyChanged。要添加事件并将 Points 转换为 MapElements,请将以下突出显示的代码添加到类中:

public readonly static BindableProperty PointsProperty = BindableProperty.Create(nameof(Points), typeof(List<Models.Point>), typeof(MapView), new List<Models.Point>(), propertyChanged: OnPointsChanged);
private static void OnPointsChanged(BindableObject bindable, object oldValue, object newValue)
{
    var map = bindable as Map;
    if (newValue == null) return;
    if (map == null) return;
    foreach (var point in newValue as List<Models.Point>)
    {
        // Instantiate a Circle
        Circle circle = new()
        {
            Center = new Location(point.Location.Latitude, point.Location.Longitude),
            Radius = new Distance(200),
            StrokeColor = Color.FromArgb("#88FF0000"),
            StrokeWidth = 0,
            FillColor = point.Heat
        };
        // Add the Circle to the map's MapElements collection
        map.MapElements.Add(circle);
    }
}
public List<Models.Point> Points
{
    get => GetValue(PointsProperty) as List<Models.Point>;
    set => SetValue(PointsProperty, value);
}

现在我们已经创建了一个自定义地图控件,我们将使用它来替换 MainView 中的 Map 控件。请按照以下步骤操作:

  1. MainView.xaml 文件中,声明自定义控件的命名空间。

  2. 用我们创建的新控件替换 Map 控件。

  3. MainViewModel 中将 Points 属性绑定,如下面的代码片段所示:

    <ContentPage  
    
    x:Class="MeTracker.Views.MainView">
    <map:CustomMap x:Name="Map" Points="{Binding Points}" />
    </ContentPage>
    

这就完成了关于如何扩展 Maps 控件的这一部分。我们应用程序的最终步骤是在应用程序恢复时刷新地图。

在应用程序恢复时刷新地图

我们将要做的最后一件事是确保在应用程序恢复时地图与最新的点保持最新。最简单的方法是在 App.xaml.cs 文件中将 MainPage 属性设置为 AppShell 的新实例,就像构造函数一样,如下面的代码片段所示:

protected override void OnResume()
{
    base.OnResume();
    MainPage = new AppShell();
}

现在 MeTracker 应用程序已经完成 – 尝试使用它。一个示例截图显示在 图 7.18

图 7.18 – iOS 和 Android 上的 MeTracker

摘要

在本章中,我们为 iOS、Mac Catalyst 和 Android 开发了一个应用程序,该应用程序跟踪用户的地理位置。在构建应用程序时,我们学习了如何在 .NET MAUI 中使用地图以及如何在后台运行时进行位置跟踪。我们还学习了如何使用自定义控件扩展 .NET MAUI。有了这些知识,我们可以创建执行其他后台任务的程序。我们还学习了如何扩展 .NET MAUI 中的大多数控件。

这里有一些方法可以进一步扩展这个应用程序:

  • 目前,应用程序在应用程序恢复时更新地图位置。当位置发生变化时,您如何更新地图?

  • 添加一个视图,列出数据库中的所有位置。允许用户从列表中删除位置。

下一个项目将是一个天气应用程序。在下一章中,我们将使用现有的天气服务 API 获取天气数据,然后在应用程序中显示这些数据。

第八章:为多种形态构建天气应用

.NET MAUI 不仅用于创建手机应用;它还可以用于创建平板电脑和桌面电脑的应用。在本章中,我们将构建一个适用于所有这些平台的应用,并为每个形态优化用户界面。除了使用三种不同的形态,我们还将针对四个不同的操作系统进行工作:iOS、macOS、Android 和 Windows。

本章将涵盖以下主题:

  • 在 .NET MAUI 中使用 FlexLayout

  • 使用 VisualStateManager

  • 使用不同视图针对不同形态

  • 使用行为

让我们开始吧!

技术要求

要处理此项目,我们需要安装 Visual Studio for Mac 或 PC,以及必要的 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章.NET MAUI 简介。如果你使用 Visual Studio for PC 构建 iOS 应用,你需要连接一台 Mac。如果你根本无法访问 Mac,你可以选择只处理此项目的 Windows 和 Android 部分。同样,如果你只有 Mac,你可以选择只处理此项目的 iOS 和 Android 部分。

你可以在本章中找到代码的完整源代码,链接为 github.com/PacktPubliching/MAUI-Projects-3rd-Edition

项目概述

iOS 和 Android 应用可以在手机和平板电脑上运行。通常,应用只是针对手机进行优化。在本章中,我们将构建一个适用于不同形态的应用,但不会仅限于手机和平板电脑——我们还将针对桌面电脑。桌面版本将使用 Window UI LibraryWinUI)和 macOS 通过 Mac Catalyst。

我们将要构建的应用是一个天气应用,它根据用户的地理位置显示天气预报。对于本章,我们将使用 Visual Studio for Mac 的说明。如果你使用 Visual Studio for Windows,你应该能够跟上。如果你需要帮助,可以使用其他章节进行参考。

构建天气应用

是时候开始构建应用了。按照以下步骤在 Visual Studio for Mac 中创建一个新的空白 .NET MAUI 应用:

  1. 打开 Visual Studio for Mac 并点击 新建

图 8.1 – Visual Studio 2022 for Mac 启动屏幕

图 8.1 – Visual Studio 2022 for Mac 启动屏幕

  1. 选择 你的新项目模板 对话框中,使用位于 多平台 | 应用 下的 .NET MAUI 应用 模板,然后点击 继续

图 8.2 – 新建项目

图 8.2 – 新建项目

  1. 配置 你的新 .NET MAUI 应用 对话框中,确保已选择 .NET 7.0 目标框架,然后点击 继续

图 8.3 – 选择目标框架

图 8.3 – 选择目标框架

  1. Weather 中,点击 创建

图 8.4 – 命名新应用

图 8.4 – 命名新应用

如果你现在运行该应用,你应该看到以下类似的内容:

图 8.5 – macOS 上的天气应用

图 8.5 – macOS 上的天气应用

现在我们已经从模板创建了项目,是时候开始编码了!

为天气数据创建模型

在我们编写从外部天气服务获取数据的代码之前,我们将创建用于反序列化服务结果的模型。我们将这样做,以便我们有一个通用的模型,我们可以用它来从服务返回数据。

作为此应用的数据源,我们将使用外部天气 API。本项目将使用 OpenWeatherMap,这是一个提供几个免费 API 的服务。你可以在 openweathermap.org/api 找到这个服务。在本项目中,我们将使用 5 天 / 3 小时预报服务,该服务以 3 小时为间隔提供 5 天的预报。为了使用 OpenWeatherMap API,我们必须创建一个账户以获取 API 密钥。如果你不想创建 API 密钥,你可以模拟数据。

按照以下说明在 home.openweathermap.org/users/sign_up 创建你的账户并获取你的 API 密钥,你需要用它来调用 API。

生成用于反序列化服务结果的模型的最简单方法是,在浏览器或使用工具(如浏览器中的 https://api.openweathermap.org/data/2.5/forecast?lat=44.34&lon=10.99&appid={API key})中对服务进行调用,将 {API KEY} 替换为你的 API 密钥。

注意

如果你遇到了 401 错误,请等待几个小时后再使用你的 API,如 openweathermap.org/faq#error401 中所述。

我们可以手动创建类或使用一个可以从 JSON 生成 C# 类的工具。一个可以使用的工具是 quicktype,可以在 quicktype.io/ 找到。只需将 API 调用的输出粘贴到 quicktype 中,即可生成你的 C# 模型。

如果你使用工具生成它们,请确保将命名空间设置为 Weather.Models

如前所述,你也可以手动创建这些模型。我们将在下一节中描述如何进行此操作。

手动添加天气 API 模型

如果你希望手动添加模型,请按照以下说明进行。我们将添加一个名为 WeatherData.cs 的单个代码文件,其中将包含多个类:

  1. 创建一个名为 Models 的文件夹。

  2. 在新创建的文件夹中添加一个名为 WeatherData.cs 的文件。

  3. 将以下代码添加到 WeatherData.cs 文件中:

    using System.Collections.Generic;
    namespace Weather.Models
    {
        public class Main
        {
            public double temp { get; set; }
            public double temp_min { get; set; }
            public double temp_max { get; set; }
            public double pressure { get; set; }
            public double sea_level { get; set; }
            public double grnd_level { get; set; }
            public int humidity { get; set; }
            public double temp_kf { get; set; }
        }
        public class Weather
        {
            public int id { get; set; }
            public string main { get; set; }
            public string description { get; set; }
            public string icon { get; set; }
        }
        public class Clouds
        {
            public int all { get; set; }
        }
        public class Wind
        {
            public double speed { get; set; }
            public double deg { get; set; }
        }
        public class Rain
        {
        }
        public class Sys
        {
            public string pod { get; set; }
        }
        public class List
        {
            public long dt { get; set; }
            public Main main { get; set; }
            public List<Weather> weather { get; set; }
            public Clouds clouds { get; set; }
            public Wind wind { get; set; }
            public Rain rain { get; set; }
            public Sys sys { get; set; }
            public string dt_txt { get; set; }
        }
        public class Coord
        {
            public double lat { get; set; }
            public double lon { get; set; }
        }
        public class City
        {
            public int id { get; set; }
            public string name { get; set; }
            public Coord coord { get; set; }
            public string country { get; set; }
        }
        public class WeatherData
        {
            public string cod { get; set; }
            public double message { get; set; }
            public int cnt { get; set; }
            public List<List> list { get; set; }
            public City city { get; set; }
        }
    }
    

如您所见,有很多类。这些类直接映射到我们从服务中获得的响应。在大多数情况下,您只想在与服务通信时使用这些类。为了在您的应用中表示数据,您将需要使用另一组仅公开您在应用中需要的信息的类。

添加应用特定的模型

在本节中,我们将创建我们的应用将翻译天气 API 模型的模型。让我们先添加 WeatherData 类(除非你在前面的部分手动创建了它):

  1. Weather 项目中创建一个名为 Models 的新文件夹。

  2. 添加一个名为 WeatherData.cs 的新文件。

  3. 将 quicktype 生成的代码粘贴过来,或者根据 JSON 写出类的代码。如果生成了除属性以外的代码,忽略它,只使用属性。

  4. 重命名 MainClass(这是 WeatherData 的内容)。

现在,我们将创建基于我们感兴趣的数据的模型。这将使其余的代码与数据源耦合得更松散。

添加 ForecastItem 模型

我们将要添加的第一个模型是 ForecastItem,它代表特定时间点的特定预测。我们可以这样做:

  1. Weather 项目和 Models 文件夹中,创建一个名为 ForecastItem 的新类。

  2. 添加以下代码:

    using System;
    using System.Collections.Generic;
    namespace Weather.Models
    {
        public class ForecastItem
        {
            public DateTime DateTime { get; set; }
            public string TimeAsString => DateTime.ToShortTimeString();
            public double Temperature { get; set; }
            public double WindSpeed { get; set; }
            public string Description { get; set; }
            public string Icon { get; set; }
        }
    }
    

现在我们已经有了每个预测的模型,我们需要一个容器模型来按 CityForecastItems 进行分组。

添加 Forecast 模型

在本节中,我们将创建一个名为 Forecast 的模型,该模型将跟踪一个城市的单个预测。Forecast 模型保留多个 ForeCastItem 对象的列表,每个对象代表特定时间点的预测。让我们设置它:

  1. Models 文件夹中创建一个名为 Forecast 的新类。

  2. 添加以下代码:

    using System;
    using System.Collections.Generic;
    namespace Weather.Models;
    public class Forecast
    {
        public string City { get; set; }
        public List<ForecastItem> Items { get; set; }
    }
    

现在我们已经有了天气 API 和应用的两个模型,我们需要从天气 API 获取数据。

创建一个获取天气数据的服务

为了更容易更改外部天气服务并使代码更易于测试,我们将为服务创建一个接口。以下是我们可以如何进行:

  1. 创建一个名为 Services 的新文件夹。

  2. 创建一个名为 IWeatherService 的新 public interface

  3. 添加一个基于用户位置获取数据的方法,如下所示。将方法命名为 GetForecastAsync

    using System.Threading.Tasks;
    using Weather.Models;
    namespace Weather.Services;
    public interface IWeatherService
    {
        Task<Forecast> GetForecastAsync(double latitude, double longitude);
    }
    

现在我们有一个接口,我们可以创建一个实现,如下所示:

  1. Services 文件夹中,创建一个名为 OpenWeatherMapWeatherService 的新类。

  2. 实现接口,并将 async 关键字添加到 GetForecastAsync 方法中:

    using System;
    using System.Globalization;
    using Weather.Models;
    using System.Text.Json;
    namespace Weather.Services;
    public class OpenWeatherMapWeatherService : IWeatherService
    {
        public async Task<Forecast> GetForecastAsync(double latitude, double longitude)
        {
        }
    }
    

在我们调用 OpenWeatherMap API 之前,我们需要为对天气 API 的调用构建一个 URI。这将是一个 GET 调用,位置的位置纬度和经度将作为查询参数添加。我们还将添加 API 密钥和我们希望响应使用的语言。让我们设置它:

  1. 打开 OpenWeatherMapWeatherService 类。

  2. 将以下代码片段中高亮显示的代码添加到 OpenWeatherMapWeatherService 类中:

    public async Task<Forecast> GetForecastAsync(double latitude, double longitude)
    {
     var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
     var apiKey = “{AddYourApiKeyHere}”;
     var uri = $”https://api.openweathermap.org/data/2.5/forecast?lat={latitude}&lon={longitude}&units=metric&lang={language}&appid={apiKey}”;
    }
    

{AddYourApiKeyHere} 替换为从 Creating models for the weather data 部分获得的密钥

为了反序列化我们从外部服务获取的 JSON,我们将使用 System.Text.JSON

要调用 Weather 服务,我们将使用 HttpClient 类和 GetStringAsync 方法,如下所示:

  1. 创建 HttpClient 类的新实例。

  2. 调用 GetStringAsync 并将 URL 作为参数传递。

  3. 使用 System.Text.Json 中的 JsonSerializer 类和 DeserializeObject 方法将 JSON 字符串转换为对象。

  4. WeatherData 对象映射到 Forecast 对象。

  5. 此代码应类似于以下代码片段中高亮显示的代码:

    public async Task<Forecast> GetForecastAsync(double latitude, double longitude)
    {
        var language = CultureInfo.CurrentUICulture.
    TwoLetterISOLanguageName;
        var apiKey = “{AddYourApiKeyHere}”;
        var uri = $”https://api.openweathermap.org/data/2.5/forecast?lat={latitude}&lon={longitude}&units=metric&lang={language}&appid={apiKey}”;
     var httpClient = new HttpClient();
     var result = await httpClient.GetStringAsync(uri);
     var data = JsonSerializer.Deserialize<WeatherData>(result);
     var forecast = new Forecast()
     {
     City = data.city.name,
     Items = data.list.Select(x => new ForecastItem()
     {
     DateTime = ToDateTime(x.dt),
     Temperature = x.main.temp,
     WindSpeed = x.wind.speed,
     Description = x.weather.First().description,
     Icon = $”http://openweathermap.org/img/w/{x.weather.First().icon}.png”
     }).ToList()
     };
     return forecast;
    }
    

性能提示

为了优化应用程序的性能,我们可以将 HttpClient 作为单例使用,并在应用程序的所有网络调用中重用它。以下信息来自 Microsoft 的文档:“HttpClient 旨在一次性实例化并在整个应用程序生命周期中重用。为每个请求实例化 HttpClient 类将在高负载下耗尽可用的套接字数量。这将导致 SocketException 错误。” 这可以在 learn.microsoft.com/en-gb/dotnet/api/system.net.http.httpclient?view=netstandard-2.0 找到。

在前面的代码中,我们有一个调用 ToDateTime 方法的调用,这是一个我们需要创建的方法。该方法将日期从 Unix 时间戳转换为 DateTime 对象,如下面的代码所示:

private DateTime ToDateTime(double unixTimeStamp)
{
    DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
    dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime();
    return dateTime;
}

性能提示

默认情况下,HttpClient 使用 HttpClient 的 Mono 实现(iOS 和 Android)。为了提高性能,我们可以使用特定平台的实现。对于 iOS,使用 NSUrlSession。这可以在 iOS 项目的“iOS 构建选项卡”下的项目设置中设置。对于 Android,使用 Android。这可以在 Android 项目的“Android 选项” | 高级”下设置。

配置应用程序平台以使用位置服务

要使用位置服务,我们需要在每个平台上进行一些配置。

配置 iOS 平台以使用位置服务

要在 iOS 应用程序中使用位置服务,我们需要在 info.plist 文件中添加一个描述,说明我们为什么想要使用位置。在这个应用程序中,我们只需要在我们使用应用程序时获取位置,所以我们只需要为此添加一个描述。让我们设置它:

  1. 使用 XML (****Text) Editor 打开 Platforms/iOS 中的 info.plist

  2. 使用以下代码添加 NSLocationWhenInUseUsageDescription 键:

    <key>NSLocationWhenInUseUsageDescription</key>
    <string>We are using your location to find a forecast for you</string>
    

配置 Android 平台以使用位置服务

对于 Android,我们需要设置应用程序,使其需要以下两个权限:

  • ACCESS_COARSE_LOCATION

  • ACCESS_FINE_LOCATION

我们可以在 AndroidManifest.xml 文件中设置此内容,该文件位于 Platforms\Android\ 文件夹中。然而,我们也可以在项目属性中的 Android Manifest 选项卡中设置此内容,如下面的截图所示:

图 8.6 – 选择位置权限

图 8.6 – 选择位置权限

配置 WinUI 平台以使用位置服务

由于我们将在 WinUI 平台中使用位置服务,我们需要在项目的 Platforms/Windows 文件夹中的 Package.appxmanifest 文件下添加 Location 功能,如下面的截图所示:

图 8.7 – 向 WinUI 应用添加位置

图 8.7 – 向 WinUI 应用添加位置

创建 ViewModel 类

既然我们已经有一个负责从外部天气源获取天气数据的服务,那么是时候创建一个 ViewModel 了。然而,首先我们将创建一个基视图模型,我们可以在这里放置所有应用中 ViewModels 之间可以共享的代码。让我们来设置它:

  1. 创建一个名为 ViewModels 的新文件夹。

  2. 创建一个名为 ViewModel 的新类。

  3. 将新类设置为 publicabstract

  4. 添加对 CommunityToolkit.MVVM 的包引用:

    public abstract partial class ViewModel : ObservableObject
    {
    }
    

现在我们有一个基视图模型。我们可以使用它来创建我们即将创建的视图模型。

现在,是时候创建 MainViewModel 了,它将是应用中 MainView 的 ViewModel。执行以下步骤来完成此操作:

  1. ViewModels 文件夹中,创建一个名为 MainViewModel 的新类。

  2. 将抽象的 ViewModel 类添加为基类。

  3. 由于我们将使用构造函数注入,我们将添加一个带有 IWeatherService 接口参数的构造函数。

    创建一个 read-only private 字段。我们将使用它来存储 IweatherService 实例:

    public class MainViewModel : ViewModel
    {
        private readonly IWeatherService weatherService;
        public MainViewModel(IWeatherService weatherService)
        {
            this.weatherService = weatherService;
        }
    }
    

MainViewModel 接受任何实现 IWeatherService 的对象,并将对该服务的引用存储在一个字段中。我们将在下一节添加将获取天气数据的功能。

获取天气数据

现在,我们将创建一个新的加载数据的方法。这将是一个三步过程。首先,我们将获取用户的位置。一旦我们有了这个,我们就可以获取与该位置相关的数据。最后一步是为视图准备数据,以便创建用户界面。

要获取用户的位置,我们将使用 Geolocation 类,该类公开了可以获取用户位置的方法。执行以下步骤:

  1. 创建一个名为 LoadDataAsync 的新方法。使其成为一个返回 Task 的异步方法。

  2. 使用 Geolocation 类中的 GetLocationAsync 方法来获取用户的位置。

  3. GetLocationAsync 调用的结果中传递纬度和经度,并使用以下代码将其传递给实现 IWeatherService 的对象上的 GetForecast 方法:

    public async Task LoadDataAsync()
    {
        var location = await Geolocation.GetLocationAsync();
        var forecast = await weatherService.GetForecastAsync(location.Latitude, location.Longitude);
    }
    

现在我们可以从服务中获取数据,我们需要通过分组单个数据项来为我们的用户界面结构化它。

对天气数据进行分组

当我们展示天气数据时,我们将按天对其进行分组,以便所有针对一天的预测都将位于同一标题下。为此,我们将创建一个新的模型,称为 ForecastGroup。为了使该模型能够与 .NET MAUI 的 CollectionView 一起使用,它必须有一个 IEnumerable 类型作为基类。让我们设置它:

  1. Models 文件夹中创建一个新的类 ForecastGroup

  2. List<ForecastItem> 作为新模型的基类。

  3. 添加一个空的构造函数和一个带有 ForecastItem 实例列表参数的构造函数。

  4. 添加一个 Date 属性。

  5. 添加一个名为 DateAsString 的属性,它返回 Date 属性作为短日期字符串。

  6. 添加一个名为 Items 的属性,它返回 ForecastItem 实例的列表,如下所示:

    using System;
    namespace Weather.Models;
    public class ForecastGroup : List<ForecastItem>
    {
        public ForecastGroup() { }
        public ForecastGroup(IEnumerable<ForecastItem> items)
        {
            AddRange(items);
        }
        public DateTime Date { get; set; }
        public string DateAsString => Date.ToShortDateString();
        public List<ForecastItem> Items => this;
    }
    

当我们完成这个操作后,我们可以通过以下方式更新 MainViewModel 的两个新属性:

  1. 创建一个名为 city 的私有字段,并使用 ObservableProperty 属性来获取我们正在获取天气数据的城市的名称。

  2. 创建一个名为 days 的私有字段,并使用 ObservableProperty 属性,它将包含分组后的天气数据。

  3. MainViewModel 类应该看起来像以下代码片段中高亮显示的代码:

    public partial class MainViewModel : ViewModel
    {
        [ObservableProperty]
     private string city;
    [ObservableProperty]
     private ObservableCollection<ForecastGroup> days;
        // Rest of the class is omitted for brevity
    }
    

现在,我们准备好对数据进行分组了。我们将在 LoadDataAsync 方法中这样做。我们将遍历服务中的数据,并将项目添加到不同的组中,如下所示:

  1. 创建一个 itemGroups 变量,其类型为 List<ForecastGroup>

  2. 创建一个 foreach 循环,遍历 forecast 变量中的所有项目。

  3. 添加一个 if 语句来检查 itemGroups 属性是否为空。如果是空的,则向变量中添加一个新的 ForecastGroup 并继续到项目列表中的下一个项目。

  4. itemGroups 变量上使用 SingleOrDefault 方法(这是来自 System.Linq 的扩展方法)来根据当前 ForecastItem 的日期获取一个组。将结果添加到一个新变量 group 中。

  5. 如果 group 属性为 null,则当前天在组列表中没有组。如果是这种情况,应在 itemGroups 变量中添加一个新的 ForecastGroup。代码将继续执行,直到它到达 forecast.Items 列表中的下一个 forecast 项目。如果找到组,则应将其添加到 itemGroups 变量中的列表。

  6. foreach 循环之后,使用新的 ObservableCollection 设置 Days 属性,并将 itemGroups 变量作为构造函数的参数。

  7. City 属性设置为 forecast 变量的 City 属性。

  8. LoadDataAsync 方法现在应该如下所示:

    public async Task LoadDataAsync()
    {
        var location = await Geolocation.GetLocationAsync();
        var forecast = await weatherService.GetForecastAsync(location.Latitude, location.Longitude);
        var itemGroups = new List<ForecastGroup>();
        foreach (var item in forecast.Items)
        {
            if (!itemGroups.Any())
            {
                itemGroups.Add(new ForecastGroup(new List<ForecastItem>() { item })
                {
                    Date = item.DateTime.Date
                });
                continue;
            }
            var group = itemGroups.SingleOrDefault(x => x.Date == item.DateTime.Date);
            if (group == null)
            {
                itemGroups.Add(new ForecastGroup(new List<ForecastItem>() { item })
                {
                    Date = item.DateTime.Date
                });
                continue;
            }
            group.Items.Add(item);
        }
        Days = new ObservableCollection<ForecastGroup>(itemGroups);
        City = forecast.City;
    }
    

小贴士

当你想添加超过几个项目时,不要在 ObservableCollection 上使用 Add 方法。最好创建一个新的 ObservableCollection 实例并将集合传递给构造函数。这样做的原因是,每次你使用 Add 方法时,你都会从视图中绑定它,这将导致视图被渲染。如果我们避免使用 Add 方法,我们将获得更好的性能。

为平板电脑和桌面电脑创建视图

下一步是创建当应用在平板电脑或桌面电脑上运行时我们将使用的视图。让我们设置它:

  1. Weather 项目中创建一个名为 Views 的新文件夹。

  2. Views 文件夹中创建一个名为 Desktop 的新文件夹。

  3. Views\Desktop 文件夹中创建一个名为 MainView 的新 .NET MAUI ContentPage (XAML) 文件:

图 8.8 – 添加 .NET MAUI XAML ContentPage

图 8.8 – 添加 .NET MAUI XAML ContentPage

  1. 在视图的构造函数中传递 MainViewModel 的实例以设置 BindingContext,如下面的代码所示:

    public MainView (MainViewModel mainViewModel)
    {
        InitializeComponent ();
        BindingContext = mainViewModel;
    }
    

    在后面的 添加服务和 ViewModels 到依赖注入 部分中,我们将配置依赖注入以为我们提供实例。

要在主线程上触发 MainViewModel 中的 LoadDataAsync 方法,通过覆盖主线程上的 OnNavigatedTo 方法来调用 LoadDataAsync 方法。我们需要确保调用是在 UI 线程上执行的,因为它将交互用户界面。

要执行此操作,请按照以下步骤操作:

  1. Views\Desktop 文件夹中打开 MainView.xaml.cs 文件。

  2. 创建 OnNavigatedTo 方法的覆盖版本。

  3. 将以下代码片段中突出显示的代码添加到 OnNavigateTo 方法中:

        protected override void OnNavigatedTo(NavigatedToEventArgs args)
        {
            base.OnNavigatedTo(args);
     if (BindingContext is MainViewModel viewModel)
     {
     MainThread.BeginInvokeOnMainThread(async () =>
     {
     await viewModel.LoadDataAsync();
     });
     }
        }
    

MainView XAML 文件中,将 ContentPageTitle 属性绑定到 ViewModel 中的 City 属性,如下所示:

  1. Views\Desktop 文件夹中打开 MainView.xaml 文件。

  2. Title 绑定添加到以下代码片段中突出显示的 ContentPage 元素中:

    <ContentPage
        xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
        xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
        x:Class=”Weather.Views.Desktop.MainView”
        Title=”{Binding City}”>
    

在接下来的部分中,我们将使用 FlexLayout 将 ViewModel 中的数据渲染到屏幕上。

使用 FlexLayout

在.NET MAUI 中,如果我们想显示一组数据,可以使用CollectionViewListView。在大多数情况下,使用CollectionViewListView都很好,我们将在本章后面使用CollectionView,但ListView只能垂直显示数据。在这个应用中,我们希望两个方向都能显示数据。在垂直方向上,我们将有天数(我们根据天数分组预测),而在水平方向上,我们将有特定天内的预测。我们还希望如果在一行中不足以显示所有预测时,预测内容可以换行。CollectionView可以在水平方向显示数据,但它不会自动换行。使用FlexLayout,我们可以添加两个方向的项目,并且我们可以使用BindableLayout将其绑定。当我们使用BindableLayout时,我们将使用ItemSourceItemsTemplate作为附加属性。

执行以下步骤来构建视图:

  1. Grid作为页面的根视图添加。

  2. ScrollView添加到Grid中。我们需要这样做,以便如果内容高于页面高度,则可以滚动。

  3. FlexLayout添加到ScrollView中,并将方向设置为Column,以便内容将垂直排列。

  4. 使用BindableLayout.ItemsSourceMainViewModel中的Days属性添加绑定。

  5. DataTemplate设置为ItemsTemplate的内容,如下面的代码所示:

    <Grid>
      <ScrollView BackgroundColor=”Transparent”>
        <FlexLayout BindableLayout.ItemsSource=”{Binding Days}” Direction=”Column”>
          <BindableLayout.ItemTemplate>
            <DataTemplate>
              <!--Content will be added here -->
            </DataTemplate>
          </BindableLayout.ItemTemplate>
        </FlexLayout>
      </ScrollView>
    </Grid>
    

每个项目的内联内容将是一个包含日期的标题以及一个水平FlexLayout,其中包含该天的预测。让我们设置如下:

  1. 打开MainView.xaml文件。

  2. 添加StackLayout,以便我们将添加到其中的子项垂直排列。

  3. ContentView添加到StackLayout中,并将Padding设置为10BackgroundColor设置为#9F5010。这将作为标题。我们需要ContentView的原因是我们希望文本周围有填充。

  4. Label添加到ContentView中,并将TextColor设置为WhiteFontAttributes设置为Bold

  5. LabelText属性添加对DateAsString的绑定。

  6. 代码应放置在<!-- Content will be added here -->注释中,并应如下所示:

    <StackLayout>
      <ContentView Padding=”10” BackgroundColor=”#9F5010”>
        <Label Text=”{Binding DateAsString}” TextColor=”White” FontAttributes=”Bold” />
      </ContentView>
    </StackLayout>
    

现在我们已经在用户界面中有了日期,我们需要添加一个FlexLayout属性,该属性将在MainViewModel中的任何项目中重复。执行以下步骤来完成此操作:

  1. </ContentView>标签之后但在</StackLayout>标签之前添加FlexLayout

  2. JustifyContent设置为Start,以便将项目从左侧添加,而不在可用空间中分配它们。

  3. AlignItems设置为Start,以便将内容设置为FlexLayout中每个项目的左侧,如下面的代码所示:

    <FlexLayout BindableLayout.ItemsSource=”{Binding Items}” Wrap=”Wrap” JustifyContent=”Start” AlignItems=”Start”>
    </FlexLayout>
    

在定义了FlexLayout之后,我们需要提供一个ItemsTemplate属性,该属性定义了列表中每个项目应该如何渲染。继续在您刚刚添加的<FlexLayout>标签下直接添加 XAML,如下所示:

  1. ItemsTemplate属性设置为DataTemplate

  2. 使用以下代码将元素添加到FillDataTemplate中:

提示

如果我们想在绑定中添加格式,可以使用StringFormat。在这种情况下,我们想在温度后面添加度符号。我们可以通过使用{Binding Temperature, StringFormat=’{0}° C’}短语来实现。通过绑定的StringFormat属性,我们可以使用与在 C#中执行此操作时相同的参数格式化数据。这相当于 C#中的string.Format(“{0}° C”, Temperature)。我们还可以用它来格式化日期;例如,{Binding Date, StringFormat=’yyyy’}。在 C#中,这看起来像Date.ToString(“yyyy”)

<BindableLayout.ItemTemplate>
  <DataTemplate>
    <StackLayout Margin=”10” Padding=”20” WidthRequest=”150” BackgroundColor=”#99FFFFFF”>
      <Label FontSize=”16” FontAttributes=”Bold” Text=”{Binding TimeAsString}” HorizontalOptions=”Center” />
      <Image WidthRequest=”100” HeightRequest=”100” Aspect=”AspectFit” HorizontalOptions=”Center” Source=”{Binding Icon}” />
      <Label FontSize=”14” FontAttributes=”Bold” Text=”{Binding Temperature, StringFormat=’{0}° C’}” HorizontalOptions=”Center” />
      <Label FontSize=”14” FontAttributes=”Bold” Text=”{Binding Description}” HorizontalOptions=”Center” />
    </StackLayout>
  </DataTemplate>
</BindableLayout.ItemTemplate>

提示

AspectFill短语作为ImageAspect属性的值,意味着整个图像始终可见,并且不会更改其比例。AspectFit短语也将保持图像的比例,但图像可以放大和缩小,并裁剪以填充整个Image元素。Aspect可以设置的最后一个值Fill意味着图像可以拉伸或压缩以匹配Image视图,从而确保保持宽高比。

添加工具栏项以刷新天气数据

为了能够在不重新启动应用程序的情况下刷新数据,我们将向工具栏添加一个Refresh按钮。MainViewModel负责处理我们想要执行的任何逻辑,并且我们必须将任何操作公开为可绑定的ICommand,以便我们可以将其绑定到。

让我们从在MainViewModel上创建Refresh命令方法开始:

  1. 打开MainViewModel类。

  2. CommunityToolkit.Mvvm.Input添加using声明:

    using System.Collections.ObjectModel;
    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    using Weather.Models;
    using Weather.Services;
    
  3. 添加一个名为RefreshAsync的方法,该方法调用LoadDataAsync方法,如下所示:

        public async Task RefreshAsync()
        {
            await LoadDataAsync();
        }
    
  4. 由于这些方法是异步的,Refresh将返回Task,我们可以使用asyncawait来调用LoadDataAsync而不会阻塞 UI 线程。

  5. RefreshAsync方法添加RelayCommand属性以自动生成方法的可绑定ICommand属性:

    [RelayCommand]
        public async Task RefreshAsync()
        {
            await LoadDataAsync();
        }
    

现在我们已经定义了Refresh命令,我们需要将其绑定到用户界面,以便当用户点击工具栏按钮时,将执行该操作。

要完成此操作,请执行以下步骤:

  1. 打开MainView.xaml文件。

  2. raw.githubusercontent.com/PacktPublishing/MAUI-Projects-3rd-Edition/main/Chapter08/Weather/Resources/Images/refresh.png下载refresh.png文件,并将其保存到项目的Resources/Images文件夹中。

  3. ContentPageToolbarItems属性添加一个新的ToolbarItem,将Text属性设置为Refresh,并将IconImageSource属性设置为refresh.png(或者,您可以将IconImageSource属性设置为图片的 URL,.NET MAUI 将下载该图片)。

  4. Command 属性绑定到 MainViewModel 中的 Refresh 属性,如下所示:

    <ContentPage.ToolbarItems>
      <ToolbarItem IconImageSource=”refresh.png” Text=”Refresh” Command=”{Binding RefreshCommand}” />
    </ContentPage.ToolbarItems>
    

数据刷新的所有内容都已完成。现在,我们需要某种指示数据正在加载的指示器。

添加加载指示器

当我们刷新数据时,我们希望显示一个加载指示器,以便用户知道正在发生某些事情。为此,我们将添加 ActivityIndicator,这是 .NET MAUI 中对该控件的称呼。让我们设置如下:

  1. 打开 MainViewModel 类。

  2. MainViewModel 中添加一个名为 isRefreshingBoolean 字段。

  3. isRefreshingField 上添加 ObservableProperty 属性以生成 IPropertyChanged 实现。

  4. LoadDataAsync 方法的开始处将 IsRefreshing 属性设置为 true

  5. LoadDataAsync 方法的末尾,将 IsRefreshing 属性设置为 false,如下所示:

        [ObservableProperty]
        private bool isRefreshing;
    ....// The rest of the code is omitted for brevity
    public async Task LoadData()
    {
        IsRefreshing = true;
    ....// The rest of the code is omitted for brevity
        IsRefreshing = false;
    }
    

现在我们已经在 MainViewModel 中添加了一些代码,我们需要将 IsRefreshing 属性绑定到当 IsRefreshing 属性为 true 时将显示的用户界面元素,如下所示:

  1. MainView.xaml 中,将 Frame 添加到 ScrollView 之后,作为 Grid 中的最后一个元素。

  2. IsVisible 属性绑定到我们在 MainViewModel 中创建的 IsRefreshing 方法。

  3. HeightRequestWidthRequest 设置为 100

  4. VerticalOptionsHorizontalOptions 设置为 Center,以便 Frame 将位于视图的中间。

  5. BackgroundColor 设置为 #99000000 以将背景设置为带有一定透明度的白色。

  6. Frame 中添加 ActivityIndicator,将 Color 设置为 Black,将 IsRunning 设置为 True,如下所示:

    <Frame IsVisible=”{Binding IsRefreshing}” BackgroundColor=”#99FFFFFF” WidthRequest=”100” HeightRequest=”100” VerticalOptions=”Center” HorizontalOptions=”Center”>
      <ActivityIndicator Color=”Black” IsRunning=”True” />
    </Frame>
    

这将创建一个在数据加载时可见的旋转器,这在创建任何用户界面时都是一个非常好的实践。现在,我们将添加一个背景图像,使应用看起来更美观。

设置背景图像

对于这个视图,我们目前要做的最后一件事是添加一个背景图像。在这个例子中,我们将使用的是通过 Google 搜索免费使用图像的结果。让我们设置如下:

  1. 打开 MainView.xaml 文件。

  2. ScrollViewBackground 属性设置为 Transparent

  3. Grid 中添加一个 Image 元素,将 UriImageSource 设置为 Source 属性的值。

  4. CachingEnabled 属性设置为 true,将 CacheValidity 设置为 5。这意味着图像将被缓存 5 天。

注意

如果您使用了 URL 作为 Refresh IconImageSource 属性的值,也可以设置这些属性以避免在每次运行应用时下载图像。

  1. XAML 现在应该看起来如下所示:

    <ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
        x:Class=”Weather.Views.Desktop.MainView”
        Title=”{Binding City}”>
      <ContentPage.ToolbarItems>
        <ToolbarItem Icon=”refresh.png” Text=”Refresh” Command=”{Binding RefreshCommand}” />
      </ContentPage.ToolbarItems>
     <Grid>
     <Image Aspect=”AspectFill”>
     <Image.Source>
     <UriImageSource Uri=”https://upload.wikimedia.org/wikipedia/commons/7/79/Solnedg%C3%A5ng_%C3%B6ver_Laholmsbukten_augusti_2011.jpg” CachingEnabled=”true” CacheValidity=”5” />
     </Image.Source>
     </Image>
        <ScrollView BackgroundColor=”Transparent”>
    <!-- The rest of the code is omitted for brevity -->
    

我们也可以通过使用 <Image Source=”https://ourgreatimage.url” /> 直接在 Source 属性中设置 URL。然而,如果我们这样做,我们无法指定对图像的缓存。

桌面视图完成后,我们需要考虑当我们在手机或平板上运行应用时,这个页面将如何显示。

创建手机视图

在平板电脑和台式计算机上结构化内容在很多方面非常相似。然而,在手机上,我们能够做的事情却非常有限。因此,在本节中,我们将为在手机上使用此应用时创建一个特定的视图。为此,请按照以下步骤操作:

  1. 创建一个新的基于 XAML 的 Views 文件夹。

  2. Views 文件夹中,创建一个名为 Mobile 的新文件夹。

  3. Views\Mobile 文件夹中创建一个名为 MainView 的新 .NET MAUI ContentPage (XAML) 文件:

图 8.9 – 添加 .NET MAUI XAML ContentPage

图 8.9 – 添加 .NET MAUI XAML ContentPage

  1. 在视图的构造函数中传递 MainViewModel 的实例以设置 BindingContext,如下所示:

    public MainView (MainViewModel mainViewModel)
    {
        InitializeComponent();
        BindingContext = mainViewModel;
    }
    

    添加服务和 ViewModels 到依赖注入 部分中,我们将配置依赖注入以为我们提供实例。

要触发 MainViewModel 中的 LoadDataAsync 方法,通过在主线程上重写 OnNavigatedTo 方法来调用 LoadDataAsync 方法。我们需要确保调用在 UI 线程上执行,因为它将交互用户界面。

要执行此操作,请按照以下步骤进行:

  1. Views\Mobile 文件夹中打开 MainView.xaml.cs 文件。

  2. 重写 OnNavigatedTo 方法。

  3. 将以下片段中突出显示的代码添加到 OnNavigateTo 方法中:

        protected override void OnNavigatedTo(NavigatedToEventArgs args)
        {
            base.OnNavigatedTo(args);
     if (BindingContext is MainViewModel viewModel)
     {
     MainThread.BeginInvokeOnMainThread(async () =>
     {
     await viewModel.LoadDataAsync();
     });
     }
        }
    

MainView XAML 文件中,将 ContentPageTitle 属性绑定到 ViewModel 中的 City 属性,如下所示:

  1. Views\Mobile 文件夹中打开 MainView.xaml 文件。

  2. Title 绑定添加到以下代码片段中突出显示的 ContentPage 元素:

    <ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
        x:Class=”Weather.Views.Desktop.MainView”
        Title=”{Binding City}”>
    

在下一节中,我们将使用 CollectionView 来显示天气数据,而不是像桌面视图那样使用 FlexView

使用分组 CollectionView

我们可以使用 FlexLayout 来实现手机的视图,但由于我们希望用户体验尽可能好,我们将使用 CollectionView。为了获取每天的标题,我们将对 CollectionView 使用分组。对于 FlexLayout,我们有 ScrollView,但对于 CollectionView,我们不需要这个,因为 CollectionView 默认可以处理滚动。

让我们继续为手机的视图创建用户界面:

  1. Views\Mobile 文件夹中打开 MainView.xaml 文件。

  2. CollectionView 添加到页面的根处。

  3. MainViewModel 中为 ItemSource 属性设置对 Days 属性的绑定。

  4. IsGrouped 设置为 True 以在 CollectionView 中启用分组。

  5. BackgroundColor 设置为 Transparent,如下所示:

    <CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
    </CollectionView>
    

为了格式化每个标题的外观,我们将创建一个 DataTemplate 属性,如下所示:

  1. DataTemplate 属性添加到 CollectionViewGroupHeaderTemplate 属性中。

  2. 将行内容添加到 DataTemplate 中,如下所示:

    <CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
     <CollectionView.GroupHeaderTemplate>
     <DataTemplate>
     <ContentView Padding=”15,5” BackgroundColor=”#9F5010”>
     <Label FontAttributes=”Bold” TextColor=”White” Text=”{Binding DateAsString}” VerticalOptions=”Center”/>
     </ContentView>
     </DataTemplate>
     </CollectionView.GroupHeaderTemplate>
    </CollectionView>
    

为了格式化每个预报的外观,我们将创建一个 DataTemplate 属性,就像我们对组标题所做的那样。让我们设置这个:

  1. DataTemplate 属性添加到 CollectionViewItemTemplate 属性中。

  2. DataTemplate 中添加一个包含四个列的 Grid 属性。使用 ColumnDefinition 属性指定列的宽度。第二列应该是 50;其他三列将共享剩余的空间。我们将通过将 Width 设置为 * 来实现这一点。

  3. 将以下内容添加到 Grid

    <CollectionView.ItemTemplate>
      <DataTemplate>
        <Grid Padding=”15,10” ColumnSpacing=”10” BackgroundColor=”#99FFFFFF”>
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width=”*” />
            <ColumnDefinition Width=”50” />
            <ColumnDefinition Width=”*” />
            <ColumnDefinition Width=”*” />
          </Grid.ColumnDefinitions>
          <Label FontAttributes=”Bold” Text=”{Binding TimeAsString}” VerticalOptions=”Center” />
          <Image Grid.Column=”1” HeightRequest=”50” WidthRequest=”50” Source=”{Binding Icon}” Aspect=”AspectFit” VerticalOptions=”Center” />
          <Label Grid.Column=”2” Text=”{Binding Temperature, StringFormat=’{0}° C’}”
    VerticalOptions=”Center” />
          <Label Grid.Column=”3” Text=”{Binding Description}” VerticalOptions=”Center” />
        </Grid>
      </DataTemplate>
    </CollectionView.ItemTemplate>
    

添加下拉刷新功能

对于视图的平板和桌面版本,我们在工具栏中添加了一个按钮来刷新天气预报。然而,在手机版本的视图中,我们将添加下拉刷新功能,这是在数据列表中刷新内容的一种常见方式。.NET MAUI 中的 CollectionView 没有内置下拉刷新的支持,就像 ListView 一样。

相反,我们可以使用 RefreshViewRefreshView 可以用于向任何控件添加下拉刷新行为。让我们设置这个:

  1. 前往 Views\Mobile\MainView.xaml

  2. CollectionView 包裹在 RefreshView 内。

  3. MainViewModel 中的 RefreshCommand 属性绑定到 RefreshViewCommand 属性,以便在用户执行下拉刷新手势时触发刷新。

  4. 要在刷新进行时显示加载图标,将 MainViewModel 中的 IsRefreshing 属性绑定到 RefreshViewIsRefreshing 属性。当我们设置这个时,我们也会在初始加载运行时获得一个加载指示器,如下面的代码所示:

    <RefreshView Command=”{Binding Refresh}” IsRefreshing=”{Binding IsRefreshing}”>
      <CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
    ....
      </CollectionView>
    </RefreshView>
    

这就完成了当前视图的创建。现在,让我们将它们连接到依赖注入,以便我们可以看到我们的工作。

添加服务和 ViewModels 到依赖注入

为了让我们的视图获取 MainViewModel 的实例,以及让 MainViewModel 获取 OpenWeatherMapWeatherService 的实例,我们需要将它们添加到依赖注入中。让我们设置这个:

  1. 打开 MauiProgram.cs

  2. 添加以下突出显示的代码:

    #if DEBUG
        builder.Logging.AddDebug();
    #endif
     builder.Services.AddSingleton<IWeatherService, OpenWeatherMapWeatherService>();
     builder.Services.AddTransient<MainViewModel, MainViewModel>();
        return builder.Build();
    

在下一节中,我们将根据设备的形态添加视图的导航。

根据设备形态导航到不同的视图

现在我们有两个不同的视图,它们应该在应用程序的同一位置加载。如果应用程序在平板或桌面上运行,则应加载 Weather.Views.Desktop.MainView;如果应用程序在手机上运行,则应加载 Weather.Views.Mobile.MainView

.NET MAUI 中的 Device 类有一个静态的 Idiom 属性,我们可以使用它来检查应用程序正在哪个形态上运行。Idiom 的值可以是 PhoneTabletDesktopWatchTV。因为我们在这个应用程序中只有一个视图,所以我们可以在 App.xaml.cs 中设置 MainPage 时使用 if 语句来检查 Idiom 的值。

由于我们只需要一个视图,我们只需在依赖注入中注册我们需要的视图即可——我们只需要一个公共类型来注册视图。让我们创建一个新的接口,我们的视图将实现它:

  1. Views 文件夹中创建一个名为 IMainView 的新接口。

    我们不会向接口添加任何额外的属性或方法,我们只是将其用作标记。

  2. 打开 Views\Desktop\MainView.xaml.cs 并将 IMainView 接口添加到类中:

    public partial class MainView : ContentPageViews\Mobile\MainView.xaml.cs and add the IMainView interface to the class:
    
    

    public partial class MainView : ContentPage, IMainView

    
    

现在我们有一个公共接口,我们可以通过依赖注入注册视图:

  1. 打开 MauiProgram.cs 文件。

  2. 添加以下代码:

    #if DEBUG
        builder.Logging.AddDebug();
    #endif
        builder.Services.AddSingleton<IWeatherService, OpenWeatherMapWeatherService>();
        builder.Services.AddTransient<MainViewModel, MainViewModel>();
     if (DeviceInfo.Idiom == DeviceIdiom.Phone)
     {
     builder.Services.AddTransient<IMainView, Views.Mobile.MainView>();
     }
     else
     {
     builder.Services.AddTransient<IMainView, Views.Desktop.MainView>();
     }
        return builder.Build();
    

通过这些更改,我们现在可以测试我们的应用程序。如果你运行你的应用,你应该看到以下内容:

图 8.10 – 在 macOS 和 iOS 上运行的应用

图 8.10 – 在 macOS 和 iOS 上运行的应用

接下来,让我们通过使用 VisualStateManager 来更新桌面视图,以便正确处理调整大小。

使用 VisualStateManager 处理状态

VisualStateManager 是一种从代码中更改 UI 的方法。我们可以定义状态并为选定的属性设置值,以应用于特定状态。VisualStateManager 在我们想要为具有不同屏幕分辨率的设备使用相同视图的情况下非常有用。对于我们这些 .NET MAUI 开发者来说,VisualStateManager 非常有趣,尤其是在 iOS 和 Android 都可以在手机和平板上运行的情况下。

在此项目中,我们将使用它来在平板电脑或桌面上的横幅模式下运行应用时使 forecast 项更大。我们还将使天气图标更大。让我们设置它:

  1. 打开 Views\Desktop\MainView.xaml 文件。

  2. 在第一个 FlexLayoutDataTemplate 中,将一个 VisualStateManager.VisualStateGroups 元素插入到第一个 StackLayout

    <StackLayout Margin=”10” Padding=”20” WidthRequest=”150” BackgroundColor=”#99FFFFFF”>
     <VisualStateManager.VisualStateGroups>
     <VisualStateGroup>
     </VisualStateGroup>
     </VisualStateManager.VisualStateGroups>
    ......
    </StackLayout>
    

关于 VisualStateGroup,我们应该添加两个状态,如下所示:

  1. VisualStateGroup 中添加一个名为 Portrait 的新 VisualState

  2. VisualState 中创建一个设置器,并将 WidthRequest 设置为 150

  3. 将另一个名为 LandscapeVisualState 添加到 VisualStateGroup 中。

  4. VisualState 中创建一个设置器,并将 WidthRequest 设置为 200,如下面的代码所示:

    <VisualStateGroup>
     <VisualState Name=”Portrait”>
     <VisualState.Setters>
     <Setter Property=”WidthRequest” Value=”150” />
     </VisualState.Setters>
     </VisualState>
     <VisualState Name=”Landscape”>
     <VisualState.Setters>
     <Setter Property=”WidthRequest” Value=”200” />
     </VisualState.Setters>
     </VisualState>
    </VisualStateGroup>
    

我们还希望当预测项本身更大时,预测项中的图标也更大。为此,我们将再次使用 VisualStateManager。让我们设置它:

  1. 在第二个 FlexLayoutDataTemplate 中的 Image 元素中插入一个 VisualStateManager.VisualStateGroups 元素。

  2. PortraitLandscape 添加 VisualState

  3. 向状态添加设置器以设置 WidthRequestHeightRequest。在 Portrait 状态中,值应为 100,在 Landscape 状态中,值应为 150,如下面的代码所示:

    <Image WidthRequest=”100” HeightRequest=”100” Aspect=”AspectFit” HorizontalOptions=”Center” Source=”{Binding Icon}”>
     <VisualStateManager.VisualStateGroups>
     <VisualStateGroup>
     <VisualState Name=”Portrait”>
     <VisualState.Setters>
     <Setter Property=”WidthRequest” Value=”100” />
     <Setter Property=”HeightRequest” Value=”100” />
     </VisualState.Setters>
     </VisualState>
     <VisualState Name=”Landscape”>
     <VisualState.Setters>
     <Setter Property=”WidthRequest” Value=”150” />
     <Setter Property=”HeightRequest” Value=”150” />
     </VisualState.Setters>
     </VisualState>
     </VisualStateGroup>
     </VisualStateManager.VisualStateGroups>
    </Image>
    

创建一个用于设置状态更改的行为

使用 Behavior,我们可以在不必须对控件进行子类化的情况下向控件添加功能。使用行为,我们还可以创建比子类化控件时更多的可重用代码。我们创建的 Behavior 越具体,其可重用性就越高。例如,从 Behavior<View> 继承的 Behavior 可以用于所有控件,但仅从 Button 继承的 Behavior 可以用于按钮。正因为如此,我们总是希望使用更不具体的基类来创建行为。

当我们创建 Behavior 时,需要重写两个方法:OnAttachedOnDetachingFrom。如果我们在 OnAttached 方法中添加了事件监听器,那么在 OnDeattached 方法中移除它们是非常重要的。这将使应用程序使用更少的内存。同样重要的是将值设置回 OnAppearing 方法运行之前的状态;否则,我们可能会看到一些奇怪的行为,尤其是在行为位于重用单元格的 CollectionViewListView 视图中。

在此应用程序中,我们将为 FlexLayout 创建一个行为。这是因为我们不能从代码后端设置 FlexLayout 中项的状态。我们可以在 FlexLayout 中添加一些代码来检查应用程序是否以纵向或横向运行,但如果我们使用 Behavior,则可以将该代码从 FlexLayout 中分离出来,使其更具可重用性。执行以下步骤来完成此操作:

  1. 创建一个名为 Behaviors 的新文件夹。

  2. 创建一个名为 FlexLayoutBehavior 的新类。

  3. Behavior<FlexLayoutView> 作为基类添加。

  4. 创建一个名为 viewFlexLayout 类型的 private 字段。

  5. 代码应如下所示:

    using System;
    namespace Weather.Behaviors;
    public class FlexLayoutBehavior : Behavior<FlexLayout>
    {
        private FlexLayout view;
    }
    

FlexLayout 是一个继承自 Behavior<FlexLayout> 基类的类。这将使我们能够覆盖一些在将行为附加到或从 FlexLayout 类中移除时将被调用的虚拟方法。

但首先,我们需要创建一个处理状态变化的方法。执行以下步骤来完成此操作:

  1. 打开 FlexlayoutBehavior.cs 文件。

  2. 创建一个名为 SetStateprivate 方法。此方法将有一个 VisualElement 值和一个 string 参数。

  3. 调用 VisualStateManager.GoToState 并传递参数给它。

  4. 如果视图是 Layout 类型,可能还有需要获取新状态的子元素。为此,我们将遍历布局的所有子元素。我们不会直接将状态设置到子元素,而是调用 SetState 方法,这是我们已经在其中的方法。这样做的原因是,一些子元素可能有它们自己的子元素:

    private void SetState(VisualElement view, string state)
    {
        VisualStateManager.GoToState(view, state);
        if (view is Layout layout)
        {
            foreach (VisualElement child in layout.Children)
            {
                SetState(child, state);
            }
        }
    }
    

现在我们已经创建了 SetState 方法,我们需要编写一个使用它并确定要设置什么状态的方法。执行以下步骤来完成此操作:

  1. 创建一个名为 UpdateStateprivate 方法。

  2. MainThread 上运行代码以检查应用程序是否以纵向或横向模式运行。

  3. 创建一个名为 page 的变量,并将其值设置为 Application.Current.MainPage

  4. 检查Width是否大于Height。如果是true,将view变量的VisualState属性设置为Landscape。如果是false,将view变量的VisualState属性设置为Portrait,如下面的代码所示:

    private void UpdateState()
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            var page = Application.Current.MainPage;
            if (page.Width > page.Height)
            {
                SetState(view, “Landscape”);
                return;
            }
            SetState(view, “Portrait”);
        });
    }
    

这样,UpdateState方法已经添加。现在,我们需要重写OnAttachedTo方法,该方法将在行为添加到FlexLayout时被调用。当它被添加时,我们想要通过调用此方法并将其连接到MainPageSizeChanged事件来更新状态,以便当大小改变时,我们将再次更新状态。

让我们设置如下:

  1. 打开FlexLayoutBehavior文件。

  2. 从基类中重写OnAttachedTo方法。

  3. view属性设置为OnAttachedTo方法中的参数。

  4. Application.Current.MainPage.SizeChanged添加事件监听器。在事件监听器中添加对UpdateState方法的调用,如下面的代码所示:

    protected override void OnAttachedTo(FlexLayout view)
    {
        this.view = view;
        base.OnAttachedTo(view);
        UpdateState();
        Application.Current.MainPage.SizeChanged += MainPage_SizeChanged;
    }
    void MainPage_SizeChanged(object sender, EventArgs e)
    {
        UpdateState();
    }
    

当我们从控件中移除行为时,非常重要的一点是也要从其中移除任何事件处理器,以避免内存泄漏,在最坏的情况下,防止应用崩溃。让我们这样做:

  1. 打开FlexLayoutBehavior.cs文件。

  2. 从基类中重写OnDetachingFrom方法。

  3. Application.Current.MainPage.SizeChanged中移除事件监听器。

  4. view字段设置为null,如下面的代码所示:

    protected override void OnDetachingFrom(FlexLayout view)
    {
        base.OnDetachingFrom(view);
        Application.Current.MainPage.SizeChanged -= MainPage_SizeChanged;
        this.view = null;
    }
    

执行以下步骤以将behavior添加到视图中:

  1. 打开Views/Desktop文件夹中的MainView.xaml文件。

  2. 按照以下代码导入Weather.Behaviors命名空间:

    <ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
     xmlns:behaviors=”clr-namespace:Weather.Behaviors” 
        x:Class=”Weather.Views.Desktop.MainView”
        Title=”{Binding City}”>
    

我们最后要做的就是将FlexLayoutBehavior添加到第二个FlexLayout中,如下面的代码所示:

<FlexLayout ItemsSource=”{Binding Items}” Wrap=”Wrap” JustifyContent=”Start” AlignItems=”Start”>
 <FlexLayout.Behaviors>
 <behaviors:FlexLayoutBehavior />
 </FlexLayout.Behaviors>
<FlexLayout.ItemsTemplate>

恭喜——天气应用已经完成!

图 8.11——平板电脑、手机和桌面上的完成应用

图 8.11——平板电脑、手机和桌面上的完成应用

摘要

在本章中,我们成功地为四个不同的操作系统——iOS、macOS、Android 和 Windows——以及三种不同的形态——手机、平板电脑和桌面电脑——创建了一个应用。为了在所有平台和形态上提供良好的用户体验,我们使用了FlexLayoutVisualStateManager。我们还学习了如何处理不同形态的不同视图,以及如何使用Behaviors

我们接下来要构建的应用将是一个具有实时通信的游戏。在下一章中,我们将探讨如何使用Azure中的SignalR服务作为后端游戏服务。

第三部分:高级项目

在本部分,你将处理更高级的主题和复杂的项目。你将学习如何在 Azure 中创建和部署服务。此外,你将使用 Azure 存储和 SignalR 服务。你将学习如何从.NET MAUI 应用程序中调用你的服务,正确处理错误条件,并将摄像头集成到你的应用程序中。你将探索一个嵌入到.NET MAUI 应用程序中的 Blazor 项目,并学习如何将人工智能服务集成到.NET MAUI 应用程序中。

本部分包含以下章节:

  • 第九章, 使用 Azure 服务为游戏设置后端

  • 第十章, 构建实时游戏

  • 第十一章, 使用.NET MAUI Blazor 构建计算器

  • 第十二章, 使用机器学习判断热狗是否热

第九章:使用 Azure 服务设置游戏的后端

在本章中,我们将设置一个具有实时通信功能的游戏应用的后端。我们不仅将创建一个可以扩展以处理大量用户的后端,当用户数量减少时还可以缩小规模。为了构建这个后端,我们将使用基于Microsoft Azure服务的无服务器架构。

本章将涵盖以下主题:

  • 理解不同的 Azure 无服务器服务

  • 在 Microsoft Azure 中创建SignalR服务

  • 使用 Azure Functions 作为应用程序编程 接口API

技术要求

要能够完成此项目,您需要安装 Visual Studio for Mac 或 PC,以及必要的.NET MAUI 组件。有关如何设置您的环境的更多详细信息,请参阅第一章.NET MAUI 简介

您还需要一个 Azure 账户。如果您有 Visual Studio 订阅,则每月包含一定数量的 Azure 信用额度。要激活您的 Azure 福利,请访问my.visualstudio.com

您还可以创建一个免费账户,在 12 个月内您可以免费使用所选服务。您将获得价值 200 美元的信用额度,用于探索任何 Azure 服务 30 天,您还可以随时使用免费服务。更多信息请参阅azure.microsoft.com/en-us/free/

如果您没有并且不想注册免费 Azure 账户,您可以使用本地开发工具在无需 Azure 的情况下运行服务。

您可以在本章中找到代码的完整源代码,请参阅github.com/PacktPublishing/MAUI-Projects-3rd-Edition

项目概述

本项目的主要目标将是设置游戏的后端。项目的大部分工作将是我们将在 Azure 门户中进行的配置。我们还将为 Azure 函数编写一些代码,以处理 SignalR 连接以及一些游戏逻辑和状态。SignalR 是一个使应用程序中的实时通信更简单的库。Azure SignalR 是一个使通过 SignalR 库连接多个客户端发送消息更简单的服务。SignalR 将在后面详细描述。将会有函数来返回有关 SignalR 连接的信息,管理匹配玩家进行对战,并将每位玩家的回合结果发布到 SignalR 服务。

下图显示了此应用程序架构的概述:

图 9.1 – 应用程序架构

完成此项目部分所需的时间估计约为 2 小时。

游戏概述

Sticks & Stones 是一款基于两个童年游戏概念结合而成的回合制社交游戏,即点线格和井字棋。游戏板布局为一个 9x9 的网格。每位玩家将轮流在盒子的旁边放置一根棍子,获得一分。如果一根棍子完成了一个盒子,那么玩家将获得该盒子的所有权,获得五分。当玩家在水平、垂直或对角线上拥有三个连续的盒子时,游戏结束。如果没有任何玩家能拥有三个连续的盒子,则游戏胜利者由得分最高的玩家决定。

为了保持应用和服务端相对简单,我们将消除大量的状态管理。当玩家打开应用时,他们需要连接到游戏服务。他们必须提供游戏标签或用户名和电子邮件地址。可选地,他们可以上传自己的照片作为头像。

一旦连接,玩家将看到连接到同一游戏服务的所有其他玩家的列表;这被称为大厅。玩家的状态,无论是“准备游戏”还是“正在比赛中”,将和玩家的游戏标签和头像一起显示。如果玩家不在比赛中,则还会有一个按钮挑战玩家进行比赛。

挑战玩家进行比赛将导致应用提示对手回应挑战,要么接受要么拒绝。如果对手接受挑战,那么两位玩家将被导航到一个新的游戏板,接受挑战的玩家将先走一步。所有其他玩家的大厅中,两位玩家的状态都将更新为“正在比赛中”。玩家将轮流选择放置一根棍子的位置。每次玩家放置一根棍子时,游戏板和分数将在两位玩家的设备上更新。当放置的棍子完成一个或多个方块时,玩家就“拥有”了那个方块,并在方块的中央放置一堆石头。当所有棍子都放置完毕,或者玩家拥有三个连续的石头时,游戏结束,玩家返回大厅,状态更新为“准备游戏”。

如果玩家在游戏中离开应用,那么他们将放弃比赛,剩余的对手将获得胜利,并返回大厅。

以下截图应能给您一个概念,了解应用完成后的样子,如第 10 章所述:

图 9.2 – 主游戏界面

图 9.2 – 主游戏界面

理解不同的 Azure 无服务器服务

在我们开始构建无服务器架构的后端之前,我们需要定义无服务器的含义。在无服务器架构中,代码将在服务器上运行,但我们无需担心这一点;我们唯一需要关注的是构建我们的软件。我们让其他人处理所有与服务器相关的事情。我们不需要考虑服务器需要多少内存或中央处理器CPUs),甚至不需要考虑我们需要多少服务器。当我们使用 Azure 中的服务时,Microsoft 会为我们处理这些事情。

Azure SignalR 服务

Azure SignalR 服务Microsoft Azure中的一项服务,用于服务器和客户端之间的实时通信。该服务将内容推送到客户端,而无需客户端轮询服务器以获取内容更新。SignalR 可用于多种类型的应用程序,包括移动应用程序、Web 应用程序和桌面应用程序。

SignalR 将使用WebSockets,如果该选项可用。如果不可用,SignalR 将使用其他通信技术,例如服务器发送事件SSEs)或长轮询。SignalR 将检测哪种传输技术可用,并使用它,而无需开发者进行任何思考。

SignalR 可以在以下示例中使用:

  • 聊天应用程序:当应用程序需要在新消息可用时从服务器获取更新时

  • 协作应用程序:例如,会议应用程序或当多个设备上的用户共同编辑同一文档时

  • 多人游戏:所有用户都需要实时了解其他用户的更新

  • 仪表板应用程序:用户需要实时更新

Azure Functions

Azure Functions 是 Microsoft Azure 的一项服务,允许我们以无服务器的方式运行代码。我们将部署称为函数的小块代码。函数以组的形式部署,称为函数应用。当我们创建函数应用时,我们需要选择是否希望它在消费计划或应用服务计划上运行。如果我们希望应用程序完全无服务器,则选择消费计划;而使用应用服务计划,我们必须指定服务的要求。使用消费计划,我们只需为函数的执行时间和使用的内存付费。应用服务计划的一个好处是,您可以将其配置为始终开启,这样就不会有任何冷启动,只要您不需要扩展到更多实例。消费计划的一个主要好处是,它将始终根据当时所需的资源进行扩展。

函数可以通过多种方式触发运行。两个例子是 HttpTriggerTimeTriggerHttpTrigger 将在 HTTP 请求调用函数时触发函数运行。使用 TimeTrigger,函数将按照我们指定的间隔运行。还有其他 Azure 服务的触发器。例如,我们可以配置一个函数在文件上传到 Azure Blob 存储时运行,当新消息发布到事件中心或服务总线时运行,或者当 Azure Cosmos DB 服务中的数据发生变化时运行。

现在我们已经了解了 Azure SignalR 服务和 Functions 提供的功能,让我们使用它们来构建我们的游戏后端。

构建无服务器后端

在本节中,我们将根据上一节中描述的服务设置后端。

创建 SignalR 服务

我们将首先设置 SignalR 服务。要创建此类服务,请按照以下步骤操作:

  1. 访问 Azure 门户 portal.azure.com

  2. 创建一个新的资源。SignalR 服务 资源位于 Web & 移动 类别。

  3. 在表单中为资源提供名称。

  4. 选择您想用于此项目的订阅。

    我们建议您创建一个新的 资源组 并将其用于我们将为该项目创建的所有资源。我们想要一个资源组的原因是跟踪与该项目相关的资源更容易,并且也更容易一起删除所有资源。

  5. 选择一个靠近您用户的位置。

  6. 选择一个定价层。对于此项目,我们将使用 免费 层。我们始终可以使用 免费 层进行开发,然后扩展到可以处理更多连接的层。

  7. 服务模式 设置为 无服务器

  8. 点击 审查 + 创建 在创建 SignalR 服务之前审查设置。

  9. 点击 创建 创建存储账户。

参考以下截图查看上述信息:

图 9.3 – 创建 SignalR 服务

图 9.3 – 创建 SignalR 服务

这就是我们设置 SignalR 服务所需做的所有事情。我们将在 Azure 门户中稍后返回以获取其连接字符串。

下一步是在存储账户中设置一个账户,我们可以将用户上传的图片存储在其中。

在创建计算机视觉服务后,我们现在可以创建 Azure Functions 服务,该服务将运行我们的游戏逻辑并使用 SignalR、Blob 存储和认知服务,这些服务我们刚刚创建。

使用 Azure Functions 作为 API

我们将为后端编写的所有代码都将使用 Azure Functions。我们将使用 Visual Studio 项目来编写、调试和部署我们的函数。在创建项目之前,我们必须设置和配置 Azure Functions 服务。然后,我们将实现连接玩家到游戏并提供客户端当前玩家列表的函数。接下来,我们将编写允许一个玩家向另一个玩家挑战游戏的函数。最后,我们将通过编写允许玩家轮流在棋盘上放置木棒的函数来结束。

让我们从创建 Azure Functions 服务开始。

创建 Azure Functions 服务

在我们编写任何代码之前,我们将创建函数应用。这将包含 Azure 门户中的函数。按照以下步骤操作:

  1. 创建一个新的 Function App 资源。Function App 可在 计算 类别下找到。

  2. 选择函数应用的订阅。

  3. 选择函数应用的资源组。这应该与我们在本章中创建的其他资源相同。

  4. 给函数应用起一个名字。这个名字也将是函数 URL 的起始部分。

  5. 选择 代码 作为部署机制。

  6. .NET 作为函数的运行时堆栈。

  7. 选择 .NET 6.0 (长期支持) 作为版本。

  8. 选择离您的用户最近的位置。

  9. 选择 Windows 作为 操作系统

  10. 我们将使用 消费 计划作为我们的 托管 计划,因此我们只为使用的内容付费。Function app 将根据我们的需求进行扩展和缩减 – 而无需我们考虑 – 如果我们选择 消费 计划。

    参考以下截图查看上述信息:

图 9.4 – 创建 Function App – 基础

图 9.4 – 创建 Function App – 基础

  1. 点击 审查 + 创建 在创建函数应用之前审查设置。

  2. 点击 创建 以创建函数应用。

创建项目

如果您愿意,您可以在 Azure 门户中创建函数。我更喜欢使用 Visual Studio,因为代码编辑体验更好,并且可以使用源代码控制。对于此项目,我们需要在我们的解决方案中创建和配置单独的项目 – 一个 Azure Functions 项目和一个用于函数和将在第十章中构建的 .NET MAUI 应用之间共享代码的类库。要创建和配置项目,请按照以下步骤操作:

  1. 在 Visual Studio 中创建一个新项目。

  2. 在搜索字段中输入 function 以找到 Azure Functions 的模板。

  3. 点击以下截图所示的 Azure Functions 模板以继续:

图 9.5 – 创建新项目

图 9.5 – 创建新项目

  1. 将项目命名为 SticksAndStones.Functions

  2. 将解决方案命名为 SticksAndStones.Functions,如以下截图所示,然后点击 下一步

图 9.6 – 配置您的项目

图 9.6 – 配置您的新的项目

下一步是创建我们的第一个函数,如下所示:

  1. 在对话框顶部选择 .Net 6.0 (长期支持) 作为 函数工作器

  2. Http 触发器 作为我们第一个函数的触发器。

  3. 点击 创建 以继续;我们的函数项目将被创建。

参考以下截图查看上述信息:

图 9.7 – 创建新的 Azure Functions 应用程序 – 其他信息

图 9.7 – 创建新的 Azure Functions 应用程序 – 其他信息

我们的第一个函数将返回 SignalR 服务的连接信息。为此,我们需要通过向 SignalR 服务添加连接字符串来连接函数,如下所示:

  1. 前往 Azure 门户中的 SignalR 服务 资源。

  2. 切换到左侧的 选项卡并复制连接字符串。

  3. AzureSignalRConnectionString 作为设置的名称。

  4. 将连接字符串添加到 Visual Studio 项目中的 local.settings.json 文件中,以便能够在开发机上本地运行函数,如下面的代码块所示:

    {  "IsEncrypted": false,  "Values": {    "AzureWebJobsStorage": "UseDevelopmentStorage=true",    "FUNCTIONS_WORKER_RUNTIME": "dotnet",    "AzureSignalRConnectionString": "SticksAndStones.Functions project, and add the code listed previously with your connection string.
    

接下来,在 SticksAndStones.Functions 项目中,我们需要引用 Microsoft.Azure.WebJobs.Extensions.SignalRService NuGet 包。此包包含我们与 SignalR 服务通信所需的类。如果在安装此包时发生错误并且您无法安装包,请确保项目中所有其他包都是最新版本,然后重试。

我们需要做的最后一个更改是调整自动命名空间生成。默认情况下,默认命名空间是项目的名称,这意味着本项目中所有类型都将有一个根命名空间 SticksAndStones.Functions。我们不需要 Functions 这部分,所以让我们将其删除:

  1. 解决方案资源管理器 中右键单击 SticksAndStones.Functions 项目并选择 属性

  2. 默认命名空间

  3. 修改 $(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))

    这将根据点号 . 分割项目名称,仅使用第一部分并将任何空格替换为下划线。

现在,当我们创建一个新的类时,命名空间将仅以 SticksAndStones 开头。是时候创建一个共享项目,以便我们可以在 .NET MAUI 客户端和 Azure Functions 服务中重用代码了。

共享代码将放入一个类库项目中。要创建项目并将其从 SticksAndStones.Functions 项目中引用,请按照以下步骤操作:

  1. 解决方案资源管理器 中右键单击 SticksAndStones 解决方案节点并选择 添加,然后 新建项目

  2. 在如图所示的 添加新项目 对话框中搜索 类库

图 9.8 – 添加新项目

图 9.8 – 添加新项目

  1. 选择 类库 模板,然后点击 下一步

  2. StickAndStones.Shared命名空间中,如图下所示,然后点击下一步

图 9.9 – 配置您的新的项目

图 9.9 – 配置您的新的项目

  1. 附加信息对话框中,选择.NET 6.0 (长期支持)框架项目,然后点击创建

  2. 删除由项目模板创建的Class1.cs文件。

  3. SticksAndStones.Functions项目中添加对SticksAndStones.Shared的引用。

正如我们对SticksAndStones.Functions项目所做的那样,我们将通过以下步骤更改默认命名空间:

  1. 解决方案资源管理器中右键单击SticksAndStones.Functions项目并选择属性

  2. 默认命名空间

  3. 修改$(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))

    这将根据.分割项目名称,只使用第一部分,并将任何空格替换为下划线。

现在,我们可以编写返回连接信息的函数的代码。

将玩家连接到游戏

游戏的第一步是建立连接。建立连接会将您添加到可用玩家的列表中,这样您或其他玩家就可以加入游戏。正如我们在本书中的其他项目中做的那样,首先,我们将创建存储或在不同服务之间传输数据的所需模型。然后,我们将实现Connect函数本身。

创建模型

我们需要在应用的生命周期中调用几个函数,这些函数位于第十章。第一个是建立与游戏服务的连接,称为Connect。本质上,这告诉服务有一个新的或现有的玩家正在活跃并准备游戏。Connect函数将注册玩家详细信息并返回连接字符串到 SignalR 中心,以便应用可以接收消息。在完成函数之前,我们需要一些模型。需要一个Player模型,一个Game模型,以及帮助在 Azure 函数和 SignalR 服务之间传递数据的模型。

在我们深入创建库之前,我们应该讨论本章中使用的命名约定。有一个命名约定将使确定类的使用方式变得更容易。当应用调用任何 Azure 函数时,如果需要发送任何数据,它将使用后缀为-Request的类来发送,任何返回数据的 Azure 函数将使用以-Response结尾的类来发送。对于通过 SignalR 中心发送的任何数据,我们将使用带有EventArgs后缀的类。这些类将包含对我们实际模型的引用,仅作为数据的容器。拥有这些类意味着您可以修改发送或接收的数据,而不会影响模型本身。

由于这是一个双人对战游戏,我们需要跟踪一些状态信息,以便我们知道谁在线以及正在进行的比赛。对于这个项目,我们将保持状态简单,不涉及实际的数据库,但我们将仍然使用 Entity Framework 来为我们完成大部分工作。

现在我们已经创建并引用了新的项目,并且我们已经建立了命名约定,我们可以开始创建所需的类。我们将从两个模型 PlayerMatch 开始。Player 代表每个人,而 Game 是两个 Player 实例之间的比赛以及游戏状态。要创建这两个模型,请按照以下步骤操作:

  1. SticksAndStones.Shared 项目中创建一个新的文件夹名为 Models

  2. Models 文件夹中创建一个新的类名为 Player

  3. Player 类中创建一个名为 Idpublic 属性,类型为 Guid,并将其初始化为 Guid.Empty

  4. 创建另一个名为 GamerTagpublic 属性,类型为 string,并将其初始化为 string.Empty

  5. 创建一个名为 GameIdpublic 属性,类型为 Guid,并将其初始化为 Guid.Empty

  6. 您的 Player 类现在应该类似于以下代码块:

    namespace SticksAndStones.Models;public class Player {    public Guid Id { get; set; } = Guid.Empty;    public string GamerTag { get; set; } = string.Empty;    public string EmailAddress { get; set; } = string.Empty;    public Guid MatchId { get; set; } = Guid.Empty;}
    

我们的模型类将使用 Id 字段作为唯一标识符,以便我们可以单独定位每个对象。它将用于定位特定玩家进行消息传递以及将 Match 实例与 Player 实例相关联。GamerTag 将是玩家的显示名称,而 EmailAddress 是我们关联离开应用程序并再次登录的玩家的方式。最后,MatchId 属性将跟踪玩家是否正在积极进行游戏。

现在我们已经定义了 Player 类,是时候定义 Match 类了:

  1. Models 文件夹中创建一个新的类名为 Match

  2. Game 类中创建一个名为 Idpublic 属性,类型为 Guid,并将其初始化为 Guid.Empty

  3. 创建一个名为 PlayerOneIdpublic 属性,类型为 Guid

  4. 添加一个名为 PlayerOneScorepublic 属性,类型为 int

  5. 创建另一个名为 PlayerTwoIdpublic 属性,类型为 Guid

  6. 添加一个名为 PlayerTwoScorepublic 属性,类型为 int

  7. 创建一个名为 NextPlayerIdpublic 属性,类型为 Guid

  8. 创建一个名为 Stickspublic 属性,类型为 List<int>,并将其初始化为 new List<int>(24)

  9. 创建一个名为 Stonespublic 属性,类型为 List<int>,并将其初始化为 new List<int>(9)

  10. 创建一个名为 Scorespublic 属性,类型为 List<int>,并将其初始化为 new List<int>(2)

  11. 创建一个名为 Completedpublic 属性,类型为 bool,并将其初始化为 false

  12. 创建一个名为 WinnerIdpublic 属性,类型为 Guid,并将其初始化为 Guid.Empty

  13. 添加一个名为 Newpublic static 方法,该方法接受两个参数,参数类型均为 Guid,分别命名为 challengerIdopponentId。该方法返回一个 Game 类型的对象。该方法应返回一个新的 Game 实例,并将 Id 属性设置为 Guid.NewGuid()PlayerOneIdNextPlayerId 设置为 opponentIdPlayerTwoId 设置为 challengerId

  14. Player 类现在应类似于以下代码块:

    using System;using System.Collections.Generic;namespace SticksAndStones.Models;public class Match {    public Guid Id { get; set; } = Guid.Empty;    public Guid PlayerOneId { get; set; }    public int PlayerOneScore { get; set; }    public Guid PlayerTwoId { get; set; }    public int PlayerTwoScore { get; set; }    public Guid NextPlayerId { get; set; }    public List<int> Sticks {get; set; } = new(new int[24]);    public List<int> Stones {get; set;} = new(new int[9]);    public List<int> Score = new(new int[2]);    public bool Completed { get; set; } = false;    public Guid WinnerId { get; set; } = Guid.Empty;    public static Game New(Guid challengerId, Guid opponentId)    {        return new()        {            Id = Guid.NewGuid(),            PlayerOne = opponent,            PlayerTwo = challenger,            NextPlayer = opponent         };    }}
    

PlayerMatch 类将用于客户端和服务器之间的数据存储和数据传输。在我们创建模型之前,让我们使用 Entity Framework 添加数据库。执行以下步骤以添加对 Entity Framework 的引用并创建数据库上下文,以便 PlayerMatch 类可以存储在 InMemory 数据库中:

  1. Microsoft.EntityFrameworkCore.InMemory 包引用添加到 SticksAndStones.Functions 项目中。

  2. SticksAndStones.Functions 项目中创建一个名为 Repository 的新文件夹。

  3. Repository 文件夹中创建一个名为 GameDbContext 的类。

  4. 修改类的构造函数以设置数据库选项:

    public GameDbContext(DbContextOptions<GameDbContext> options) : base(options) { }
    
  5. 添加一个公共 Players 属性以存储 Player 对象:

    public DbSet<Player> Players { get; set; }
    
  6. 添加一个公共 Matches 属性以存储 Match 对象:

    public DbSet<Match Matches { get; set; }
    
  7. OnModelCreating 方法中添加一个重写:

    protected override void OnModelCreating(ModelBuilder modelBuilder){}
    

    此方法是我们指定 Entity Framework 如何在关系数据库中将我们的类关联在一起的地方。

  8. 首先在 OnModelCreating 方法中声明每个类的标识符,如下所示:

    modelBuilder.Entity<Player>()    .HasKey<Player>(p => p.Id);modelBuilder.Entity<Match>()    .HasKey<Match>(g => g.Id);base.OnModelCreating(modelBuilder);
    
  9. Entity Framework 无法正确处理我们的 List<int> 属性。它假设由于它是一个列表,它们是相关实例。为了在 Entity Framework 中更改默认行为,我们可以使用以下突出显示的代码:

    modelBuilder.Entity<Game>()    .HasKey(g => g.Id);modelBuilder.Entity<Match>()    .Property(p => p.Sticks)    .HasConversion(        toDb => string.Join(",", toDb),        fromDb => fromDb.Split(',', StringSplitOptions.None).Select(int.Parse).ToList() ?? new(new int[24]));
    modelBuilder.Entity<Match>()    .Property(p => p.Stones)    .HasConversion(        toDb => string.Join(",", toDb),        fromDb => fromDb.Split(',', StringSplitOptions.None).Select(int.Parse).ToList() ?? new(new int[9]));
    base.OnModelCreating(modelBuilder);
    

    每个块所做的是定义属性的转换。转换有两个 Lambda 表达式 - 一个是从 C# 对象到数据库的转换,另一个是从数据库到 C# 对象的转换。对于我们的 List<int> 属性,我们希望将 C# List<int> 转换为逗号分隔的整数字符串,然后将逗号分隔的整数字符串转换为 List<int>

    toDBList<int> 的一个实例,因此要将它转换为逗号分隔的数字列表,我们可以使用 String.Join 函数将列表中的每个元素与它们之间的 , 连接起来。

    fromDb 是一个包含逗号分隔的数字的 string 值。要将该值转换为 List<int>,我们可以使用 String.Split 方法来隔离每个数字,然后将每个数字传递给 Int.Parse 方法以将数字转换为 int 值。Select 将生成 IEnumberable<int>;我们可以使用 ToList 将其转换为 List<int>。如果它没有创建列表,我们可以提供一个默认值列表,就像我们在 Match 类本身中所做的那样。

要初始化 Entity Framework 以使用内存数据库,我们需要创建一个 Startup 方法。要创建该方法并初始化数据库,请按照以下步骤操作:

  1. SticksAndStones.Functions 项目的根目录中创建一个名为 Startup 的新类。

  2. 修改以下突出显示的代码的类文件:

    using Microsoft.Azure.Functions.Extensions.DependencyInjection;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.DependencyInjection;using SticksAndStones.Repository;[assembly: FunctionsStartup(typeof(SticksAndStones.Startup))]
    namespace SticksAndStones;public class Startup : FunctionsStartup {    public override void Configure(IFunctionsHostBuilder builder)    {        string SqlConnection = Environment.GetEnvironmentVariable("SqlConnectionString");        builder.Services.AddDbContextFactory<GameDbContext>(            options =>            {                options.UseInMemoryDatabase("SticksAndStones");            });    }}
    

SticksAndStones.Functions 项目在运行时加载时,将调用 Startup 方法。然后,它将为之前创建的 GameDbContext 类创建一个工厂以创建其实例,并使用内存数据库对其进行初始化。

这就完成了我们对 Entity Framework 和基本模型PlayerGame的设置。我们还需要一个最终模型,将 SignalR 连接信息发送到客户端。要创建此模型,请按照以下步骤操作:

  1. Models文件夹中创建一个名为ConnectionInfo的新类。

  2. 添加一个名为Url的公共属性,它是一个string值。

  3. 添加另一个名为AccessToken的公共属性,它也是一个string值。

  4. ConnectionInfo类应该看起来像这样:

    namespace SticksAndStones.Models;public class ConnectionInfo {    public string Url { get; set; }    public string AccessToken { get; set; }}
    

现在模型已经创建,我们可以开始创建Connect函数。

创建 Connect 函数

我们将从一个连接我们的玩家到游戏的函数开始,这个函数恰当地命名为Connect。这个函数将期望在请求体中发送一个部分填充的Player对象。该函数将返回一个完全填充的Player对象、当前连接的玩家列表以及客户端连接到 SignalR 中心所需连接信息。为了使输入和输出更清晰,我们将它们包装起来。

要创建输入和输出类,请按照以下步骤操作:

  1. Messages文件夹中创建一个名为ConnectMessages的新类。

  2. 修改ConnectMessages.cs使其看起来像这样:

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct ConnectRequest(Player Player);public record struct ConnectResponse(Player Player, List<Player> Players, ConnectionInfo ConnectionInfo);
    

对于所有将在客户端和 Azure 函数或 SignalR 服务之间传输数据的类,我们将使用record语法。由于这些类没有任何实际功能,它们唯一的目的就是包含我们的模型。通过使用record结构体,我们还提高了我们函数的内存使用效率,因为新实例将在本地内存中创建,而不是全局内存中,这需要额外的处理。record语法将构造函数和属性声明合并为单行代码,消除了大量无实际益处的样板代码。

你会注意到我们正在使用我们在创建模型部分讨论的约定。后缀为RequestResponse的类被用作任何 Azure 函数的输入和输出。对于通过 SignalR 服务发送的任何数据,该类将使用后缀为EventArgs

当新客户端连接时,将通过 SignalR 服务向其他用户发送消息,以表明他们已连接。此消息还将用于通知玩家开始或结束游戏。要创建此类消息,请按照以下步骤操作:

  1. SticksAndStones.Shared项目的Messages文件夹中创建一个名为PlayerUpdatedEventArgs的新类。

  2. 修改该类,使其成为一个具有单个Player参数的record结构体,如下面的代码片段所示:

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct PlayerUpdatedEventArgs(Player Player);
    

现在我们已经创建了Connect函数所需的架构,我们可以开始编写函数本身:

  1. 创建一个名为Hubs的新文件夹。我们将把我们的服务类放入这个文件夹。

  2. Function1.cs文件移动到Hubs文件夹。

  3. 对于移动文件和调整命名空间的下两个提示,回答

  4. 将默认的 Function1.cs 文件重命名为 GameHub.cs,并在重命名提示中点击

  5. 打开 GameHub.cs 文件,将类名 GameHub 重命名为 GameHub,将 internal static 访问修饰符替换为 public,并从 ServerlessHub 基类派生,如以下突出显示所示:

    public class GameHub : ServerlessHub
    {    [FunctionName("Function1")]    public static async Task<IActionResult> Run(    [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,    ILogger log)    {        log.LogInformation("C# HTTP trigger function processed a request.");        string name = req.Query["name"];        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();        dynamic data = JsonConvert.DeserializeObject(requestBody);        name = name ?? data?.name;        string responseMessage = string.IsNullOrEmpty(name)        ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."                : $"Hello, {name}. This HTTP triggered function executed successfully.";        return new OkObjectResult(responseMessage);    }}
    
  6. 将默认的 Function1 函数重命名为 Connect,同时移除 static 修饰符。方法签名应如下所示的高亮代码片段:

    [FunctionName("HttpTrigger attribute indicates that this function is called by using the HTTP protocol and not by some other means, such as a SignalR message or a timer. The function is only called using the HTTP POST method, not GET.
    
  7. 要向连接到 SignalR 中心的所有客户端发送消息,我们需要另一个 SignalR 绑定。这次是一个 HubName,类型为 IAsyncCollector<SignalRMessage>,如下所示:

    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req,[SignalRConnectionInfo(HubName = "GameHub")] SignalRConnectionInfo connectionInfo,[SignalR(HubName = "GameHub")] IAsyncCollector<SignalRMessage> signalRMessages,ILogger log)
    

删除 Connect 方法的所有内容,并按照以下步骤实现功能:

  1. 在 Azure Functions 中,日志记录非常重要,因为它有助于在生产环境中调试。因此,让我们添加一个 log 消息:

    log.LogInformation("A new client is requesting connection");
    
  2. 客户端,一个 .NET MAUI 应用,将在 HTTP 请求体中发送 ConnectRequest 作为 JSON。要从请求体中获取 ConnectRequest 的实例,请使用以下代码行:

    var result = await JsonSerializer.DeserializeAsync<ConnectRequest>(req.Body, jsonOptions);var newPlayer = result.Player;
    

    您将需要在命名空间声明中添加 using System.Text.Json。这使用 System.Text.Json.JsonSerializer 类来读取请求体的内容,并从中创建一个 ConnectRequest 对象。它使用 jsonOptions 正确反序列化对象。

  3. 现在,我们需要定义 jsonOptions 字段。在 Connect 方法上方添加以下代码行:

    internal class GameHub : ServerlessHub {    private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
        [FunctionName("Connect")]    public async Task<IActionResult> Connect(
    

    JsonSerializerDefaults.Web 确保了 JSON 格式正确,以便 Azure Functions 和 SignalR 服务可以正确地序列化和反序列化对象。主要将强制执行以下操作:

    • 属性名称不区分大小写

    • 所有属性和对象名称都将格式化为 camelCase

    • 数字可以加引号

  4. 如果我们收到不良玩家数据,向客户端返回 ArgumentException,如下所示:

    if (newPlayer is null){    var error = new ArgumentException("No player data.", "Player");    log.LogError(error, "Failure to deserialize arguments");    return new BadRequestObjectResult(error);}if (string.IsNullOrEmpty(newPlayer.GamerTag)){    var error = new ArgumentException("A GamerTag is required for all players.", "GamerTag");    log.LogError(error, "Invalid value for GamerTag");    return new BadRequestObjectResult(error);}if (string.IsNullOrEmpty(newPlayer.EmailAddress)){    var error = new ArgumentException("An Email Address is required for all players.", "EmailAddress");    log.LogError(error, "Invalid value for EmailAddress");    return new BadRequestObjectResult(error);}
    

    由于函数的返回类型是 IActionResult,我们无法简单地返回自定义对象。相反,我们需要创建一个派生或实现 IActionResult 的对象,并传入我们的结果。在错误的情况下,我们将使用 BadRequestObjectResult,它将在构造函数中接受 Exception 作为参数。BadRequestObjectResult 将 HTTP 状态码设置为 400,表示错误。然后,客户端可以检查此状态码,以确定在解析响应体之前请求是否成功。

  5. 下几个步骤将需要我们查询数据库,因此我们需要将数据库上下文工厂添加到类中。添加 Microsoft.EntityFrameworkCore 命名空间声明:

    using Microsoft.Azure.WebJobs.Extensions.SignalRService;private field to store the context factory and a constructor with an argument that will be fulfilled by dependency injection, as follows:
    
    

    private readonly JsonSerializerOptions jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); private readonly IDbContextFactory dbContextFactory;public GameHub(IDbContextFactory dbcontext){    contextFactory = dbContextFactory;}

    [FunctionName("Connect")]public async Task Connect(

    
    
  6. 添加 System.Linq 的命名空间声明以允许使用 Linq 查询:

    using System;GamerTag in the database to ensure it isn’t in use already by another player, as follows:
    
    

    使用 var context = contextFactory.CreateDbContext();log.LogInformation("Checking for GameTag usage");var gamerTagInUse = (from p in context.Players where string.Equals(p.GamerTag, newPlayer.GamerTag, StringComparison.InvariantCultureIgnoreCase) && !string.Equals(p.EmailAddress, newPlayer.EmailAddress, StringComparison.OrdinalIgnoreCase) select p).Any();if (gamerTagInUse){ var error = new ArgumentException($"The GamerTag {newPlayer.GamerTag} is in use, please choose another.", "GamerTag"); log.LogError(error, "GamerTag in use."); return new BadRequestObjectResult(error);}

    
    The first step is to get a new database context from the factory. The `Players` list from the database context to compare `GamerTag` value to other GamerTagsvalues. However, we want to exclude a result if it matches `EmailAddress` since that would indicate the records are identical, and this user is just signing back in again.
    
  7. 现在,查询 Players 数据集以找到匹配的电子邮件的玩家:

        log.LogInformation("Locating Player record.");    var thisPlayer = (from p in context.Players where string.Equals(p.EmailAddress, newPlayer.EmailAddress, StringComparison.OrdinalIgnoreCase) select p).FirstOrDefault();
    
  8. 如果在 Players 中没有匹配的 Player,则将 Player 添加到数据集中:

    if (thisPlayer is null){    log.LogInformation("Player not found, creating.");    thisPlayer = newPlayer;    thisPlayer.Id = Guid.NewGuid();    context.Add(thisPlayer);    await context.SaveChangesAsync();}
    

    我们为 Player 对象分配一个新的 Guid,以便每个 Player 都有一个唯一的标识符。这也可以通过 Entity Framework 来完成;然而,我们将在这里处理它。然后使用上下文添加 Player 实例,以便跟踪任何更改。之后,SaveChangesAsync 将提交所有更改到数据库。

  9. Connect 函数的下一步中,我们需要向所有已连接的玩家发送一条消息,告知他们有新玩家加入。我们可以使用 SendAsync 方法来完成此操作。SendAsync 方法接受两个参数 - 一个作为 string 值的方法名称,该消息旨在为其发送,以及一个作为 object 值的消息。为了确保我们发送和接收正确的方法,我们将创建一个常量值。在 SticksAndStones.Shared 项目的根目录下创建一个名为 Constants 的新类,然后更新它,使其看起来像这样:

    namespace SticksAndStones;public static class Constants {    public static class Events     {        public static readonly string PlayerUpdated = nameof(PlayerUpdated);    }}
    
  10. 现在,我们可以通知其他已连接的玩家有新玩家连接。打开 GameHub 类,并在 Connect 方法的末尾添加以下代码:

        log.LogInformation("Notifying connected players of new player.");    await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(thisPlayer));
    

    此代码使用 ServerlessHub 基类中 Clients.All 集合的 SendAsync 方法向所有已连接的客户端发送消息。我们传递 Constants.Events.PlayerUpdated,即 "PlayerUpdated" 字符串,作为方法名称。作为参数,我们发送包裹在 PlayerUpdatedEventArgs 中的 Player 实例。我们将在 第十章 中处理此消息。

  11. 现在,从数据库中获取可用的玩家集合以发送回客户端:

    // Get the set of available players log.LogInformation("Getting the set of available players.");    var players = (from player in context.Players         where player.Id != thisPlayer.Id         select player).ToList();
    

    使用 Linq,我们可以轻松查询 Players 集合并排除当前玩家。

  12. 在这个阶段,我们需要从 SignalR 服务中获取 SignalR 连接信息。这可以通过调用 ServerlessHub 基类中的 NegotiateAsync 方法来实现。此外,为了能够向单个用户发送定向消息,我们将连接的 UserId 值设置为玩家 ID 值。添加以下代码行以配置和检索 SignalR 连接信息:

    var connectionInfo = await NegotiateAsync(new NegotiationOptions() { UserId = thisPlayer.Id.ToString() });
    
  13. 现在我们已经拥有了返回给客户端所需的所有信息,我们可以构建ConnectResponse对象。我们将使用ConnectionInfo类并将SignalRConnection属性映射到它,这样我们就可以避免在共享库中引用 SignalR 服务:

    log.LogInformation("Creating response.");var connectResponse = new ConnectResponse(){    Player = thisPlayer,    Players = players,    ConnectionInfo = new Models.ConnectionInfo { Url = connectionInfo.Url, AccessToken = connectionInfo.AccessToken }};
    
  14. 一旦ConnectResponse被初始化,我们可以通过使用OkObjectResult来返回它,这将使用 HTTP 响应代码200 OK

    log.LogInformation("Sending response.");return new OkObjectResult(connectResponse);
    

要测试我们刚刚编写的函数,你可以在 Visual Studio 中按下F5后,使用 PowerShell 命令提示符和以下命令:

Invoke-WebRequest -Headers @{ ContentType = "application/json" } -Uri http://localhost:7024/api/Connect -Method Post -Body ''

Uri参数中使用的端口号可能因项目而异。你可以通过打开SticksAndStones.Functions项目的Properties文件夹中的launchSettings.json文件来获取正确的端口号。端口号设置在commandLineArgs属性中,如下所示:

{  "profiles": {    "SticksAndStones.Functions": {      "commandName": "Project",      "commandLineArgs": "--port Uri parameter after localhost:.
In the `Body` parameter, you can add the JSON that the command is expecting. For the `Connect` function, this would be `ConnectRequest` and would look like this:

'{     "player": {        "gamerTag": "NewPlayer2",        "emailAddress": "newplayer2@gmail.com",    }}'


 The full command will look like this:

Invoke-WebRequest -Headers @{ ContentType = "application/json" } -Uri http://localhost:7024/api/Connect -Method Post -Body '{     "player": {        "gamerTag": "NewPlayer2",        "emailAddress": "newplayer2@gmail.com",    }}'


 Go ahead and try out various versions of the command to see how the function reacts.
Now that we can connect players to the game server, let’s look at what is needed for the lobby.
Refreshing the lobby
In the Sticks and Stones App, which you will create in Chapter 10, once a player has connected, they will move to the lobby page. Initially, the lobby will be populated from the list of players sent in the response from the `Connect` function. Additionally, as each player connects, the lobby will be updated through a SignalR event.
But we all get impatient and want a way to refresh the list immediately. So, the lobby page has a way to refresh the list; to do so, it will call the `GetAllPlayers` function.
Let’s start by creating the messages needed for `GetAllPlayers`.
Creating the messages
`GetAllPlayers` takes no parameters, so we only need to create the `GetAllPlayersResponse` type. Follow these steps to add `GetAllPlayersResponse`:

1.  In the `SticksAndStones.Shared` project, create a new file named `GetAllPlayers` **Messages.cs** in the `Messages` folder.
2.  Modify the contents of the file so that it looks as follows:

    ```

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct GetAllPlayersResponse(List<Player> Players);

    ```cs

With the messages created, we can move on to the `GetAllPlayers` function.
Getting all the players
`GetAllPlayers` is called using the `Http` `GET` method and has an optional parameter that is passed through `QueryString` using the `id` key. The optional parameter is used to exclude a specific `id` from the returned list. This makes it so that the app can send the current player’s `id` and not have it returned in the list. To create the `GetAllPlayers` function, follow these steps:

1.  In the `GameHub` class, after the `Connect` method, add the following method declaration:

    ```

    [FunctionName("GetAllPlayers")]public IActionResult GetAllPlayers([HttpTrigger(AuthorizationLevel.Function, "get", Route = "Players/GetAll")] HttpRequest req,ILogger log){}

    ```cs

    Not much is new here other than using `"get"` instead of `"post"` for the `Http` method, and `Route` is set to `"Players/GetAll"`, which would make the URL for the function `http://localhost:7024/api/Players/GetAll`.

     2.  In the method, we will process the `id` option parameter. To do so, add the following code:

    ```

    // 排除提供的 playerId Guid playerId = Guid.Empty;if (req.Query.ContainsKey("id")){    string id = req.Query["id"];    if (!string.IsNullOrEmpty(id))    {        playerId = new Guid(id);    }}

    ```cs

    In this code, we check for the existence of a key named `id`. If it exists, then its value is retrieved and converted into a `Guid` value and assigned to the `playerId` variable.

     3.  Next, we can query the database for all players, and exclude `player.Id` using the following code:

    ```

    using var context = contextFactory.CreateDbContext();// 获取可用玩家的集合 log.LogInformation("获取可用玩家的集合.");var players = (from player in context.Players                where player.Id != playerId                select player).ToList();

    ```cs

     4.  Finally, return `OkObjectResult` with a new `GetAllPlayersResponse` object initialized with the list of `players`:

    ```

    return new OkObjectResult(new GetAllPlayersResponse(players));

    ```cs

Now that we can refresh the lobby with a list of all the players, it’s time to match them up for a game.
Challenging another player to a game
To test your skills at this game, you’ll need an opponent – someone who would also like to test their skills against yours. This section will build the functionality needed in the `SticksAndStones.Function` project to have one player – the challenger – challenge another player – the opponent – to a game. The opponent has the option to accept the challenge or deny it. We will also handle the case where the opponent does not respond since they might have put their phone down; this is an edge case.
The interactions in this use case can get tricky, so let’s review the following diagram to get a better understanding of what we are building:
![Figure 9.10 – Challenge diagram](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_11.jpg)

Figure 9.10 – Challenge diagram
The process starts with a user interaction that results in the client making an HTTP request to the `GameHub` instance via the `IssueChallenge` function. The client will pass the challenger and opponent details when making the HTTP call. `IssueChallenge` will create an `Id` value to track this process. `IssueChallenge` will then send a direct message to the opponent via the SignalR hub using the `SendAsync` method. The message will include the `Id` value that was created earlier, the challenger, and the opponent details as an instance of `ChallengeEventArgs`. The opponent’s client will receive the message via an `On<ChallengeEventArgs>` event handler. The opponent will then have the choice of accepting or declining the challenge. The response is sent back to the `GameHub` instance using the `AcknowledgeChallenge` function. The `Id` value of the challenge is sent along with `ChallengeResponse`, either `Accept` or `Decline`. A third possibility is `Timeout`. If the opponent never responds, then after a certain amount of time has passed, `Challenge` will `Timeout`. In either event, the result is then returned to the challenger using the response of the `IssueChallenge` call.
Let’s get started by defining the messages.
Creating the messages and models
We will start with `IssueChallengeRequest` since that is the first message that is being sent. Follow these steps to create the class:

1.  In the `SticksAndStones.Shared` project, create a new file named `ChallengeMessages.cs` under the `Message` folder.
2.  Modify the file so that it looks as follows:

    ```

    using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct IssueChallengeRequest(Player Challenger, Player Opponent);

    ```cs

As we have in the `Connect` function, we use a `record` struct to eliminate a lot of the boilerplate code needed to define a `struct` value. Our message only needs two `Player` objects – `Challenger` and `Opponent`. The client will have both available when it makes the call to this function. `Challenger` will be the client making the call to `IssueChallenge` and `Opponent` will be the opposing side.
The `IssueChallenge` function will return `IssueChallengeResponse`. This `Issue` **ChallengeResponse** will have just one field, `Response`, which will be an `enum` value called `ChallengeResponse`. Follow these steps to create `ChallengeResponse`:

1.  In the `SticksAndStones.Shared` project, in the `Models` folder, create a new `enum` named `ChallengeResponse`.
2.  Add the following values to the `enum` value:
    *   `None`
    *   `Accepted`
    *   `Declined`
    *   `Timeout`

    Your code should look like this:

    ```

    namespace SticksAndStones.Models;public enum ChallengeResponse {    None,    Accepted,    Declined,    TimeOut }

    ```cs

To create the remaining messages for the `IssueChallenge` and `AcknowledgeChallenge` functions, follow these steps:

1.  Open the `ChallengeMessages.cs` file and add the following declaration at the end of the file:

    ```

    public record struct IssueChallengeResponse(ChallengeResponse Response);

    ```cs

     2.  When the opponent responds to a challenge, they will call the `AcknowledgeChallenge` function and pass an `AcknowledgeChallengeRequest` object as an argument. In the `ChallengeMessages.cs` file, add the following declaration to create `AcknowledgeChallengeRequest`:

    ```

    public record struct AcknowledgeChallengeRequest(Guid Id, ChallengeResponse Response);

    ```cs

That completes the messages that are sent or received from the `GameHub` functions for a challenge. That just leaves `ChallengeEventArgs`, which is sent from `GameHub` to the opponent. To create the `ChallengeEventArgs` class, follow these steps:

1.  In the `Messages` folder of the `SticksAndStones.Shared` project, create a new file named `ChallengeEventArgs.cs`.
2.  Replace the contents of the `ChallengeEventArgs.cs` file with the following:

    ```

    using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct ChallengeEventArgs(Guid Id, Player Challenger, Player Opponent);

    ```cs

     3.  To add the method name constant for the `SendAsync` method, open the `Constants.cs` file in the `SticksAndStones.Shared` project and add the following highlighted field to the `Events` class:

    ```

    public static class Events {    public static readonly string PlayerUpdated = nameof(PlayerUpdated);    public static readonly string Challenge = nameof(Challenge);

    ```cs

As with the previous message definitions, `ChallengeEventArgs` is also defined as `public record struct`. The parameters are an `Id` value of the `Guid` type and two `Player` objects – one for `Challenger` or the initiator, and one for `Opponent` or the receiver. `Id` will be created in the `IssueChallenge` function and is used to correlate the challenge with the response. This is needed because we are tracking the challenge and if a certain amount of time has passed, we expire the challenge. `Id` is used to track that state and check whether the challenge is still valid if the client responds.
What was not included in the diagram in *Figure 9**.10* is a structure that is used to track the challenge in the `GameHub` class. It is only needed while a challenge is active and hasn’t timed out. To create the `Challenge` class, follow these steps:

1.  Create a file named `Challenge.cs` in the `Models` folder of the `SticksAndStones.Shared` project.
2.  Replace the contents of the `Challenge.cs` file with the following code:

    ```

    using System;namespace SticksAndStones.Models;public record struct Challenge(Guid Id, Player Challenger, Player Opponent, ChallengeResponse Response);

    ```cs

As with previous models, we use a `record` struct. The `Challenge` class has various properties – `Id`, `Challenger` as a `Player` type, `Opponent` as a `Player` type, and `ChallengeResponse`, which is called `Response`.
If a player accepts the challenge, then the two players will start a match with each other. Each player will be notified that the match has begun by receiving a `MatchStarted` SignalR event. To create the event and its arguments, follow these steps:

1.  Open the `Constants.cs` file in the `SticksAndStones.Shared` project and add the following highlighted field to the `Events` class:

    ```

    public static class Events {    public static readonly string PlayerUpdated = nameof(PlayerUpdated);    public static readonly string Challenge = nameof(Challenge); public static readonly string GameStarted = nameof(MatchStarted);}

    ```cs

     2.  In the `Messages` folder of the `SticksAndStones.Shared` project, create a new file named `MatchStartedEventArgs.cs`.
3.  Replace the contents of the `MatchStartedEventArgs.cs` file with the following:

    ```

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct MatchStartedEventArgs(Match Match);

    ```cs

That concludes the new messages and models that are needed to allow one player to challenge another to a game of `SticksAndStones`. Next, we will create the first of two functions that will handle the process in the `GameHub` class.
Creating the IssueChallenge function
We’ll start with the `IssueChallenge` function. This function is called from the `Challenger` client to start the challenge process. The client will pass their `Player` object, `Challenger`, and the player they are challenging – that is, `Opponent`. These two models are contained in an `IssueChallengeRequest` object. The `IssueChallenge` function will need to perform the following actions:

*   Validate input
*   Create a challenge object
*   Send a challenge to the opponent
*   Wait for a response from the opponent
*   Send the response to the challenger

To create the function and implement these actions, follow these steps:

1.  Open the `GameHub.cs` file in the `SticksAndStones.Functions` project.
2.  Add the `IssueChallenge` function declaration, as follows:

    ```

    [FunctionName("IssueChallenge")]public async Task<IssueChallengeResponse> IssueChallenge([HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Challenge/Issue")] HttpRequest req, ILogger log){}

    ```cs

    The `FunctionName` attribute tells Azure Functions that this is an available function. The `req` parameter is an `HttpRequest` object, and the Azure Functions runtime will provide its instance when the function is called. It is attributed by the `HttpTrigger` attribute, which makes the function available via an `Http` API call. `HttpRequest` must use the `POST` method and not `GET` when making the call and the function’s `Route` or `Url` will end with `Challenge/Issue`. The function returns `IssueChallengeResponse` to the caller.

     3.  The first action the function performs is to validate the inputs. The `IssueChallengeRequest` object is sent as part of the body of the `Http` POST request. To retrieve the instance, use the following code in the `IssueChallenge` function:

    ```

    {    var result = await JsonSerializer.DeserializeAsync<IssueChallengeRequest>(req.Body, jsonOptions);}

    ```cs

    This is the same way we retrieved the arguments that were passed in the `Connect` function. The main difference is that the type of object being returned by the `DeserializeAsync` method is `IssueChallengeRequest`, not `ConnectRequest`. The `jsonOptions` field is already defined in the `GameHub` class.

     4.  Now, we need to check whether the challenger and opponent are valid. Valid means that they exist in our database, and neither are currently in a match. We will use `IssueChallenge` function to verify that the players exist:

    ```

    using var context = contextFactory.CreateDbContext();Guid challengerId = result.Challenger.Id;var challenger = (from p in context.Players where p.Id == challengerId select p).FirstOrDefault();

    Guid opponentId = result.Opponent.Id;var opponent = (from p in context.Players where p.Id == opponentId select p).FirstOrDefault();if (challenger is null)throw new ArgumentException(paramName: nameof(challenger), message: $"{challenger.GamerTag} is not a valid player.");if (opponent is null)throw new ArgumentException(paramName: nameof(opponent), message: $"{opponent.GamerTag} is not a valid player.");

    ```cs

    First, we capture the `Id` value of the player. We use this `Id` to query the `Players` `DbSet` in the database context for a matching `Id` and return the `Player` object if it exists; otherwise, we return `null`. If the object is `null`, then we exit the function by throwing `ArgumentException` and passing the name of the object as the `paramName` argument and a message detailing the issue. This can be used on the client to display an error message.

     5.  The following code will check whether the players are currently engaged in a match with another player. Add the following code to the end of the `IssueChallenge` function:

    ```

    var challengerInMatch = (from g in context.Matches

    where g.PlayerOneId == challengerId || g.PlayerTwoId == challengerId

    select g).Any();

    var opponentInMatch = (from g in context.Matches

    where g.PlayerOneId == opponentId || g.PlayerTwoId == opponentId

    select g).Any();

    if (challengerInMatch)

    throw new ArgumentException(paramName: nameof(challenger), message: $"{challenger.GamerTag} is already in a match!");

    if (opponentInMatch)

    throw new ArgumentException(paramName: nameof(opponent), message: $"{opponent.GamerTag} is already in a match!");

    ```cs

    Again, we use `Matches` `DbSet`. We are not looking for the `Match` instance, just the fact that one does exist, where either `PlayerOneId` or `PlayerTwoId` is the player’s `Id` value. We use the `Any` function to return `true` if there are any results and `false` if there are no results. Again, we throw `ArgumentException` if either player is in a match with an appropriate message.

     6.  At this point, we have validated that both players exist and can join a new game. We will need to capture the game `Id` value if the challenge is accepted, so let’s create the variable and log some details before moving on:

    ```

    Guid matchId = Guid.Empty;log.LogInformation($"{challenger.GamerTag} has challenged {opponent.GamerTag} to a match!");

    ```cs

The next step in the `IssueChallenge` function is to create a `Challenge` object. But because we want to track how long `Challenge` is waiting so that we can time it out, we need a helper class to abstract that detail away from the function.
Don’t reinvent the wheel
The implementation of `ChallengeHandler` is heavily based on `AckHandler` from the **Azure SignalR AckableChatRoom** sample. The source for the sample is available at [`github.com/aspnet/AzureSignalR-samples/tree/main/samples/AckableChatRoom`](https://github.com/aspnet/AzureSignalR-samples/tree/main/samples/AckableChatRoom).
Let’s create the `ChallengeHandler` class by following these steps:

1.  Create a new folder in the `SticksAndStones.Functions` folder named `Handlers`.
2.  Create a new class named `ChallengeHandler` in the `Handlers` folder, and change the access modifier from `internal` to `public`.
3.  Add a constructor for the class that has three parameters – `completeAcksOnTimeout` as `bool`, `ackThreshold` as `TimeSpan`, and `ackInterval` as `TimeSpan`. The constructor will create a timer to periodically clear out old challenges and store the `ackThreshold` value in a class field. The class’s contents should look like this:

    ```

    private readonly TimeSpan ackThreshold;private readonly Timer timer;

    public ChallengeHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval){    if (completeAcksOnTimeout)    {        timer = new Timer(_ => CheckAcks(), state: null, dueTime: ackInterval, period: ackInterval);    }    this.ackThreshold = ackThreshold;}

    ```cs

    You will need to add a `using` declaration for the `System.Threading` namespace to use the `Timer` type. The `CheckAcks` method will be created later in this section.

     4.  To provide some reasonable defaults for the constructor, we will create a parameterless constructor and provide the defaults, as shown in the following snippet:

    ```

    private readonly Timer timer; public ChallengeHandler() : this(completeAcksOnTimeout: true, ackThreshold: TimeSpan.FromSeconds(30), ackInterval: TimeSpan.FromSeconds(1))    {    }

    public ChallengeHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval)

    ```cs

    This will provide the default values to the main constructor.

     5.  Now, to create a new `Challenge`, the `IssueChallenge` function will call a method named `CreateChallenge`, as shown here:

    ```

    public (Guid id, Task<Challenge> responseTask) CreateChallenge(Player challenger, Player opponent){    var id = Guid.NewGuid();    var tcs = new TaskCompletionSource<Challenge>(TaskCreationOptions.RunContinuationsAsynchronously);    handlers.TryAdd(id, new(id, tcs, DateTime.UtcNow, new(id, challenger, opponent, ChallengeResponse.None)));    return (id, tcs.Task);}

    private record struct ChallengeRecord(Guid Id, TaskCompletionSource<Challenge> ResponseTask, DateTime Created, Challenge Challenge);

    ```cs

    Add this declaration to the top of the `ChallengeHandler` class. This `record` has an `Id` value – for uniqueness, `TaskCompletionSource` – a `DateTime` value to track the creation time, and the `Challenge` object itself. We keep a list of `ChallengeRecord` instances in another field called `handlers`. The `handlers` field, which is declared right after the `ChallengeRecord` class, is as follows:

    ```

    private readonly ConcurrentDictionary<Guid, ChallengeRecord> handlers = new();

    ```cs

    We use `ConcurrentDictionary` since we may be accessing the field from several different threads at the same time. `ConncurrentDictionary` is designed to prevent data corruption in multithreaded situations, like this one.

    Once `TaskCompletionSource`, `Challenge`, and `ChallengeRecord` have been created, the `Challenge` `Id` value and the `Task` value associated with `TaskCompletionSource` are returned as a `Tuple` value to the `IssueChallenge` function. We will see what happens to that data later in this section when we complete the `IssueChallege` function.

    Finally, to resolve the missing namespaces, add the following highlighted namespace declarations to your `ChallengeHandler` class file:

    ```

    using SticksAndStones.Models;

    using System;

    using System.Collections.Concurrent;

    使用 System.Threading;

    AcknowledgeChallenge 函数将调用一个名为 Respond 的方法。Respond 方法会从字典中移除 ChallengeRecord(如果存在),并返回关联的 Challenge。如果没有 ChallengeRecord,则返回一个新的空 Challenge 记录,如下代码所示:

    ```cs
    public Challenge Respond(Guid id, ChallengeResponse response){    if (handlers.TryRemove(id, out var res))    {        var challenge = res.Challenge;        challenge.Response = response;        res.ResponseTask.TrySetResult(challenge);        return challenge;    }    return new Challenge();}
    ```

    ```cs

     6.  The `CheckAcks` method, which is called periodically to check for `Challenge` objects that have expired and have not been responded to, looks like this:

    ```

    private void CheckAcks(){    foreach (var pair in handlers)    {        var elapsed = DateTime.UtcNow - pair.Value.Created;        if (elapsed > ackThreshold)        {            pair.Value.ResponseTask.TrySetException(new TimeoutException("Response time out"));        }    }}

    ```cs

    This method will iterate over all the pairs in the `handlers` dictionary. For each one, it will determine whether the elapsed time is greater than the threshold provided in the constructor. If it is, then the task fails with a `TimeOutException` error.

     7.  To wrap up this class, we need to make sure that we clean up any remaining tasks when the service shuts down. We will handle canceling tasks in a `Dispose` method, which is implemented via the `IDisposable` interface. Add the `IDisposable` interface to the `ChallengeHandler` class and add the following `Dispose` method to the end of the class:

    ```

    public void Dispose(){    timer?.Dispose();    foreach (var pair in handlers)    {        pair.Value.ResponseTask.TrySetCanceled();    }}

    ```cs

    The `Dispose` method will dispose of the timer since we don’t want that firing any longer. Then, it iterates over the handlers and cancels each of the tasks.

That should complete the `ChallengeHandler` class. We can now resume the implementation of the `IssueChallenge` function:

1.  Open the `GameHub.cs` file and locate the constructor, modifying it as highlighted in the following code:

    ```

    private readonly GameDbContext context;private readonly ChallengeHandler challengeHandler;

    public GameHub(GameDbContext dbcontext, ChallengeHandler handler){    context = dbcontext;    challengeHandler = handler;

    }

    ```cs

    Since we will need the `ChallengeHandler` class, and it needs to maintain state, we will use dependency injection and have the Azure Functions runtime supply us with the instance.

     2.  Open the `Startup.cs` file in the `SticksAndStones.Function` project and add the following line of code at the end of the `Configure` method:

    ```

    builder.Services.AddSingleton<ChallengeHandler>();

    ```cs

    This will register `ChallengeHandler` with dependency injection to allow the Azure Functions runtime to manage the instance creation and lifetime.

     3.  Open the `GameHub.cs` file and navigate to the bottom of the `IssueChallenge` function.
4.  Add the following lines of code:

    ```

    var challengeInfo = challengeHandler.CreateChallenge(challenger, opponent);log.LogInformation($"Challenge [{challengeInfo.id}] has been created.");

    log.LogInformation($"Waiting on response from {opponent.GamerTag} for challenge[{challengeInfo.id}].");await Clients.User(opponent.Id.ToString()).SendAsync(Constants.Events.Challenge, new ChallengeEventArgs(challengeInfo.id, challenger, opponent));

    ```cs

    This code will first call `CreateChallenge` using the `ChallengeHandler` instance that we are getting in the constructor. `challengeInfo` is a `Tuple` value of the `Challenge` `Id` type and `task`.

    Next, the opponent is sent a SignalR `Challenge` message with `ChallengeEventArgs`. This message is sent slightly differently since this message will only be sent to the client that matches the opponent’s `Id`.

     5.  Now, we need to wait for the opponent’s response, or a timeout from `ChallengeHandler`, by using the following code:

    ```

    ChallengeResponse response;try {    var challenge = await challengeInfo.responseTask.ConfigureAwait(false);    log.LogInformation($"Got response from {opponent.GamerTag} for challenge[{challengeInfo.id}].");    response = challenge.Response;}catch {    log.LogInformation($"Never received a response from {opponent.GamerTag} for challenge[{challengeInfo.id}], it timed out.");    response = ChallengeResponse.TimeOut;}return new(response);

    ```cs

    The real trick in this code is the `challengeInfo.responseTask` await. `responseTask` is the task that is created as part of `TaskCompletionSource` in `ChallengeHandler`. By awaiting it, we do not continue until either the `Respond` method is called and the task is completed, or the task is failed by setting a `TimeoutException` error in the `CheckAcks` method of `ChallengeHandler`.

    Once one of those conditions is `true`, the method completes and we can get the response from the returned `Challenge`, or in the case of a timeout, handle the exception and return the response to the client in a new instance of `IssueChallengeResponse`.

The `IssueChallenge` function is now complete. The client can call the function and it will send a message to the opponent’s client and wait for the response. If the opponent client does not respond in a defined amount of time, which is 30 seconds by default, then the challenge will time out. Now, let’s work on accepting or declining a challenge. As with the `Connect` function, you can try it out using the command line. You just need to connect two players, and then have one challenge the other!
Creating the AcknowledgeChallenge function
The `AcknowledgeChallenge` function is used by the client to respond to an open challenge from another player. Let’s create the function by following these steps:

1.  Add a new function to the `GameHub` class, as follows:

    ```

    [FunctionName("AcknowledgeChallenge")]public async Task AcknowledgeChallenge(    [HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Challenge/Ack")] HttpRequest req,    ILogger log)

    {}

    ```cs

     2.  In the body of the function, deserialize the arguments using the following line of code:

    ```

    var result = await JsonSerializer.DeserializeAsync<AcknowledgeChallengeRequest>(req.Body, jsonOptions);

    ```cs

     3.  Use `challengeHandler` to `Respond` to the challenge:

    ```

    var challenge = challengeHandler.Respond(result.Id, result.Response);if (challenge.Id == Guid.Empty){    return;}

    ```cs

     4.  If the response is `Declined`, then just log a message:

    ```

    var challenger = challenge.Challenger;var opponent = challenge.Opponent;if (result.Response == ChallengeResponse.Declined){    log.LogInformation($"{opponent.GamerTag} has declined the challenge from {challenger.GamerTag}!");}

    ```cs

     5.  If the response is `Accepted`, then create a match and notify the players:

    ```

    if (result.Response == ChallengeResponse.Accepted)

    {

        log.LogInformation($"{opponent.GamerTag} has accepted the challenge from {challenger.GamerTag}!");

        using var context = contextFactory.CreateDbContext();

        var game = Match.New(challenger.Id, opponent.Id);

        context.Matches.Add(game);

        opponent.MatchId = challenger.MatchId = match.Id;

        context.Players.Update(opponent);

        context.Players.Update(challenger);

        context.SaveChanges();

        log.LogInformation($"创建了玩家 {opponent.GamerTag} 和 {challenger.GamerTag} 之间的比赛 {match.Id}!");

        // 为游戏创建组

        await UserGroups.AddToGroupAsync(opponent.Id.ToString(), $"Match[{match.Id}]");

        await UserGroups.AddToGroupAsync(challenger.Id.ToString(), $"Match[{match.Id}]");

        await Clients.Group($"Match[{match.Id}]").SendAsync(Constants.Events.MatchStarted, new MatchStartedEventArgs(match));

        await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(opponent));

        await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(challenger));

    }

    ```cs

    So, ignoring all the logging, since that is non-functional, the preceding code starts by creating a new `Match` instance and assigning `PlayerOneId`, `PlayerTwoId`, and `NextPlayerId`. The `Match` object’s `Id` property is then assigned to both of the players, and all the changes are saved to the database.

    Next, is the SignalR messages. First, we create a SignalR group with just the two players in it and use the `Match` object’s `Id` property in the name. This way we can send messages to the group and both players will receive them.

    The first message we will send will indicate the start of a new game and it will send the `match` instance wrapped in `MatchStartedEventArgs`.

    Finally, we send a message for each player to all players, indicating a change in their status.

That completes the functionality for one player to challenge another player to a match of Sticks and Stones! It’s time to move on to playing a match. But first, we will need a function to return the game to the player.
Getting the match
You may be wondering why we need this functionality since, in the previous function, we sent the `Match` object to both players through a SignalR message. The answer is rather simple – if the user accidentally closes the Sticks and Stones app while in the middle of a game, then when they return to the Sticks and Stones app and log back in, the app will detect that they are still in a match and navigate to the `Match` page. It will use this function to retrieve the `Match` object in this case since it wasn’t sent during `Connect`, just `Id`.
So, let’s create a function to return a game by its `Id`, starting with the messages.
Creating the messages
This function will only need a response message object. Unlike the previous functions, the `GetMatch` function will use the `Http` GET method, and we will pass the match `Id` value as part of the URL. The response from the `GetGame` function will be the `Match` instance. To create the `GetGameResponse` message, follow these steps:

1.  In the `SticksAndStones.Shared` project, create a new file named `GetGameMessages.cs` in the `Messages` folder.
2.  Modify the contents of the file so that it’s as follows:

    ```

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct GetMatchResponse(Match Match);

    ```cs

With the response message class in place, we can create the `GetMatch` function.
Getting a match by its ID
The `GetMatch` function will accept a single integer named `id` as a parameter. The parameter is bound to a part of the URL that’s used to call the function. Let’s look at an example. If we wanted to get `Match` identified by a `Guid` type of `c39c7490-f4bc-425a-84ab-0a4ad916ea48`, then the URL would be `http://localhost:7024/api/Game/c39c7490-f4bc-425a-84ab-0a4ad916ea48`.
Follow these steps to implement the `GetMatch` function:

1.  Open the `GameHub.cs` file in the `Hubs` folder of the `SticksAndStones.Functions` project.
2.  Add the following method declaration after the `AcknowledgeChallenge` method:

    ```

    [FunctionName("GetMatch")]

    public IActionResult GetMatch(

    [HttpTrigger(AuthorizationLevel.Function, "get", Route =

    "Match/{id}")] HttpRequest req,

    Guid id,    ILogger log){}

    ```cs

    There are a few differences from the other functions. First, the `Http` method that’s used is `"get"`, not `"post"`. Second, `Route` is set to `"Game/{id}"`; `{id}` in `Route` tells the Azure Functions runtime that this function has a parameter named `id` and that the value in that position of the URL should be passed in as an argument. You can see that the third change is that there is an `id` parameter of the `Guid` type. This means that whatever is on the URL in the `{id}` position must be able to be converted into the `Guid` type; otherwise, the Azure Functions runtime will return an HTTP `500 Internal` `Server` error.

     3.  To query our database for the `Match` object that matches the `id` value, use the following lines of code:

    ```

    using var context = contextFactory.CreateDbContext();Match match = (from m in context.Matches where m.Id == id select m).FirstOrDefault();

    ```cs

     4.  If the method gets this far, then it has been completed successfully, so we can return `OkObjectResult`. The object that’s returned will be a `GetMatchResponse` instance with the `Match` instance that was found, or `null`:

    ```

    return new OkObjectResult(new GetMatchResponse(match));

    ```cs

Since this function uses the HTTP GET method, you can test it out in your favorite browser:

1.  Press *F5* to start the project in debug mode.
2.  Wait for txhe service to start, then copy the URL for the `GetMatch` function from the output window – for example, http://localhost:####/api/Game/{id}, where `###` is your port number.
3.  Open your browser and paste the URL in the address bar.
4.  Change `{id}` to anything.
5.  Press *Enter*.

    You should get an error page in your browser. Try a valid `Guid` value such as `c39c7490-f4bc-425a-84ab-0a4ad916ea48`. You should get a response similar to the following:

    ```

    {    "match": null }

    ```cs

    Since there are no active games, you won’t be able to retrieve an actual `Match` instance.

Now that we can retrieve the `Match` object, we will tackle how players make and receive moves and how to determine the score and winner of the game.
Playing the game
Sticks and Stones is an interactive, fast-paced, turn-based game. 
Let's review the following diagram to get a better understanding of what we are building:
![Figure 9.11 – Processing turns](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_12.jpg)

Figure 9.11 – Processing turns
Once a match has started, players will take turns choosing a location to place one of their sticks. The client application will then send a message to the GameHub’s `ProcessTurn` function. The `ProcessTurn` function will then validate the move, recalculate the score, check for a winner, and finally, send an update to the players.
Creating the ProcessTurn messages and models
The `ProcessTurn` function has three parameters – the `Match Id`, the player making the move, and the position of the move. The function will return an updated `Match` instance. Follow these steps to add the `ProcessTurn` messages:

1.  Add a new file named `ProcessTurnMessages.cs` to the `Messages` folder in the `SticksAndStones.Shared` project.
2.  Modify the contents of the file so that it looks as follows:

    ```

    using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct ProcessTurnRequest(Guid MatchId, Player Player, int Position);

    public record struct ProcessTurnResponse(Match Match);

    ```cs

As part of the turn, `ProcessTurn` will send the updated `Match` instance to the players. This will require a new SignalR event. Perform the following steps to add it:

1.  `SaveMatchAndSendUpdates` sends a new event via SignalR to the clients, so we need to add that to our constants. Add the highlighted code in the following snippet to the `Constants.cs` file in the `SticksAndStones.Shared` project:

    ```

    public static class Events     {        public static readonly string PlayerUpdated = nameof(PlayerUpdated);        public static readonly string Challenge = nameof(Challenge);        public static readonly string MatchStarted = nameof(MatchStarted);

    public static readonly string MatchUpdated = nameof(MatchUpdated);

    }

    ```cs

     2.  Add the `MatchUpdatedEventArgs` class that we used to send the updated `Match` to the players when the `MatchUpdated` event is set by adding a new file named `MatchUpdatedEventArgs.cs` to the `Messages` folder in the `SticksAndStones.Shared` project.
3.  Modify the contents of the file so that it’s as follows:

    ```

    using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct MatchUpdatedEventArgs(Match Match);

    ```cs

That concludes the additional models, events, and messages that are used by the `ProcessTurn` function. Next, we can start working on the ProcessTurn (P-Code) function.
Processing turns
The `ProcessTurn` function has a few responsibilities. It will need to do the following:

*   Validate the turn
*   Make the necessary changes to the `Match` object
*   Recalculate the score
*   Determine whether there is a winner
*   Notify the players

To start the implementation of the `ProcessTurn` function, we will create stubs for each of the methods that we will call when processing a turn. Follow these steps to create the method stubs:

1.  Add a new method declaration to the `GameHub` class for the `ProcessTurn` function:

    ```

    [FunctionName("ProcessTurn")]public async Task<IActionResult> ProcessTurn(

    [HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Game/Move")] HttpRequest req,

    ILogger log)

    {}

    ```cs

     2.  Add a new method declaration for `ValidateProcessTurnRequest`:

    ```

    private Exception ValidateProcessTurnRequest(ProcessTurnRequest args)

    {    return null;}

    ```cs

    The method accepts `ProcessTurnRequest` as the only argument. If there are no errors in the arguments, then it will default to returning `null`. If there is an error, then it will return an `Exception` error.

     3.  We will use another method to verify the match state before processing the move. Create a new method in the `GameHub` class named `VerifyMatchState`, as follows:

    ```

    private Exception VerifyMatchState(Match match, ProcessTurnRequest args)

    {    return null;}

    ```cs

    Just like with `ValidateProcessTurnRequest`, we will return `null` for the error if everything is okay; otherwise, we will return an error.

Now that we have the helper method signatures in place, let’s implement them, starting with `ValidateProcessTurnRequest`. Follow these steps to add the implementation to `ValidateProcessTurnRequest`:

1.  In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid position:

    ```

    if (args.Position <= 0 || args.Position > 23)

    {

    return new IndexOutOfRangeException("位置超出范围,必须在 1 到 24 之间");}

    ```cs

     2.  In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid player:

    ```

    if (args.Player is null){    return new ArgumentException("无效的玩家");}

    ```cs

     3.  In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid game `Id` value:

    ```

    if (args.MatchId == Guid.Empty){

    return new ArgumentException("无效的比赛 ID");}

    ```cs

That completes the `ValidateProcessTurnRequest` method. Now, we can add the code to `VerifyMatchState`, as follows:

1.  In `VerifyMatchState`, add the following at the top of the method to check that the position hasn’t already been played:

    ```

    if (match.Sticks[args.Position] != 0){    return new ArgumentException($"位置 [{args.Position}] 已经被玩过");}

    ```cs

     2.  In `VerifyMatchState`, add the following at the top of the method to check that the correct player is taking their turn:

    ```

    if (args.Player.Id != game.NextPlayerId){    return new ArgumentException($"现在不是 {args.Player.GamerTag}'s 轮次");}

    ```cs

     3.  In `VerifyMatchState`, add the following at the top of the method to check that the game isn’t over already:

    ```

    if (match.WinnerId != Guid.Empty){    return new ArgumentException("比赛已完成");}

    ```cs

     4.  In `VerifyMatchState`, add the following at the top of the method to check that the game object exists:

    ```

    if (match is null)

    {

    return new ArgumentException("无效的 MatchId");

    }

    ```cs

Now that we have created these helper methods, we can implement the `ProcessTurn` method by following these steps:

1.  Deserialize the arguments that are passed to the `ProcessTurn` function using `JsonSerializer`, as follows:

    ```

    var args = await JsonSerializer.DeserializeAsync<ProcessTurnRequest>(req.Body, jsonOptions);

    ```cs

     2.  In the `ProcessTurn` method, we can call `ValidateProcessTurnRequest`. If there is an error, we can handle it, as follows:

    ```

    var error = ValidateProcessTurnRequest(args);

    if (error is not null)

    {

    log.LogError(error, "验证回合请求时出错");

    return new BadRequestObjectResult(error);

    }

    ```cs

     3.  With the arguments verified, we can query the database for the game, and fail if it doesn’t exist:

    ```

    using var context = contextFactory.CreateDbContext();

    var game = (from g in context.Matches where m.Id == args.MatchId select m).FirstOrDefault() ?? throw new ArgumentException("无效的 MatchId.");

    ```cs

     4.  Now, we can call `VerifyGameState`. If there is an error, we can handle it, as follows:

    ```

    error = VerifyGameState(game, args);

    if (error is not null)

    {

    log.LogError(error, "验证游戏状态时出错");

    return new BadRequestObjectResult(error);

    }

    ```cs

     5.  We must do one final check before making the move and updating the scores – we need to check to see whether the player made their selection before their turn expired using the following code:

    ```

    if (turnHandler.EndTurn(args.GameId) == TurnHandler.TurnStatus.Forfeit)

    {

    error = new ArgumentException($"The turn has expired.");

    log.LogError(error, $"玩家未在规定时间内做出回应。");

    return new BadRequestObjectResult(error);

    }

    ```cs

With the validation of the input and game out of the way, we can now focus on applying the player’s move to the current state of the game, updating the score, and determining a winner. To make the code for updating the score simpler, we will need a complex data structure. Let’s explore this further.
When a player chooses a location to place one of their sticks, it may complete a square. If it does, then the player who placed the stick gets the square and an additional five points. The trick is how to determine that a stick has completed a square. We are storing the state of all sticks and stones as an array of integers. What we need is a map from a stone index (0–8) to the sticks that make up its sides. But we can simplify the logic a bit more once we know what sticks make up a square since we are only interested in a single stick, and a single stick can complete, at most, two squares. So, we can now have a structure that maps each stick position (0–23) to an array of tuples. Each tuple has an integer that is the index for the stone and another integer array that is the other three stick indexes that make up the square.
Let’s use an example to illustrate this. Pretend that we have a game that’s in the following state:
![Figure 9.12 – Sample view of the board game](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_13.jpg)

Figure 9.12 – Sample view of the board game
Now, pretend that a player has chosen to place their stick at location 9, highlighted in aqua:
![Figure 9.13 – Board game with stick placement highlighted](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_14.jpg)

Figure 9.13 – Board game with stick placement highlighted
We will only need to check the two squares highlighted in brown. This means that we need to check whether there are sticks in positions 2, 5, and 6 for the stone in the upper brown box, and 12, 13, and 16 for the stone in the lower brown box.
This means we need two tuples – one for stone 2 and a second for stone 5 – each with an array of integers of the sides – f example, { (2, {2, 5, 6} ), (5, {12, 13, 16) }. Using that data, we can check the two possible squares that could be completed by placing a stick at position 9.
Using old-fashioned sticky notes with a pencil and eraser, we can determine that the complete mapping will look like this:

(int stone, int[] sticks)[][] stickToStoneMap = new (int, int[])[][] {/* 1 / new (int, int[])[] { (1, new int[] { 4, 5, 8}), (0, new int[] { 0, 0, 0})},/ 2 / new (int, int[])[] { (2, new int[] { 5, 6, 9}), (0, new int[] { 0, 0, 0})},/ 3 / new (int, int[])[] { (3, new int[] { 6, 7,10}), (0, new int[] { 0, 0, 0})},/ 4 / new (int, int[])[] { (1, new int[] { 1, 5, 8}), (0, new int[] { 0, 0, 0})},/ 5 / new (int, int[])[] { (1, new int[] { 1, 4, 8}), (2, new int[] { 2, 6, 9})},/ 6 / new (int, int[])[] { (2, new int[] { 2, 5, 9}), (3, new int[] { 3, 7,10})},/ 7 / new (int, int[])[] { (3, new int[] { 3, 6,10}), (0, new int[] { 0, 0, 0})},/ 8 / new (int, int[])[] { (1, new int[] { 1, 4, 5}), (4, new int[] {11,12,15})},/ 9 / new (int, int[])[] { (2, new int[] { 2, 5, 6}), (5, new int[] {12,13,16})},/10 / new (int, int[])[] { (3, new int[] { 3, 6, 7}), (6, new int[] {13,14,17})},/11 / new (int, int[])[] { (4, new int[] { 8,12,15}), (0, new int[] { 0, 0, 0})},/12 / new (int, int[])[] { (4, new int[] { 8,11,15}), (5, new int[] { 9,13,16})},/13 / new (int, int[])[] { (5, new int[] { 9,12,16}), (6, new int[] {10,14,17})},/14 / new (int, int[])[] { (6, new int[] {10,13,17}), (0, new int[] { 0, 0, 0})},/15 / new (int, int[])[] { (4, new int[] { 8,11,12}), (7, new int[] {18,19,22})},/16 / new (int, int[])[] { (5, new int[] { 9,12,13}), (8, new int[] {19,20,23})},/17 / new (int, int[])[] { (6, new int[] {13,14,17}), (9, new int[] {20,21,24})},/18 / new (int, int[])[] { (7, new int[] {15,19,22}), (0, new int[] { 0, 0, 0})},/19 / new (int, int[])[] { (7, new int[] {15,18,22}), (8, new int[] {16,20,23})},/20 / new (int, int[])[] { (8, new int[] {16,19,23}), (9, new int[] {17,21,24})},/21 / new (int, int[])[] { (9, new int[] {17,20,24}), (0, new int[] { 0, 0, 0})},/22 / new (int, int[])[] { (7, new int[] {15,18,19}), (0, new int[] { 0, 0, 0})}, /23 / new (int, int[])[] { (8, new int[] {16,19,20}), (0, new int[] { 0, 0, 0})},/24 */ new (int, int[])[] { (9, new int[] {17,20,21}), (0, new int[] { 0, 0, 0})},};


 Add the preceding code to the `GameHub` class after the `turnHandler` field declaration. Now that we have declared the data structure for finding completed boxes, let’s continue processing the turn:

1.  Return the `ProcessTurn` method, and add the following code at the end:

    ```

    match.Sticks[args.Position] = args.Player.Id == match.PlayerOneId ? 1 : -1;

    ```cs

    This will assign the position `-1` or `1`, depending on who the active player is. We will use a value of `-1` and `1` later in determining a winner, in the case of three stones in a row.

     2.  Now that we have placed the stick, we need to adjust the player’s score, as follows:

    ```

    if (args.Player.Id == game.PlayerOneId){    match.PlayerOneScore += 1;}else {    match.PlayerTwoScore += 1;}

    ```cs

     3.  The following code will use the data structure from earlier to determine whether placing the stick completed any squares:

    ```

    // Determine if this play creates a square

    foreach (var tuple in stickToStoneMap[args.Position])

    {

        if (tuple.stone == 0) continue;

        var stickCompletesABox =

        (

            Math.Abs(match.Sticks[tuple.sticks[0] - 1]) +

            Math.Abs(match.Sticks[tuple.sticks[1] - 1]) +

            Math.Abs(match.Sticks[tuple.sticks[2] - 1])

        ) == 3;

        if (stickCompletesABox)

        {

            // If so, place stone, and adjust score

            var player = args.Player.Id == match.PlayerOneId ? 1 : -1;

            match.Stones[tuple.stone - 1] = player;

            if (player > 0)

            {

                match.PlayerOneScore += 5;

            }

            else

            {

                match.PlayerTwoScore += 5;

            }

        }

    }

    ```cs

    This code will iterate over the tuples declared at the array position of the newly placed stick, adjusting for C# arrays being 0-based. It will then use the array of stick positions from the tuple to index into the array of sticks in the match The value at that location will be either `1` for player one, `-1` for player two, or `0` for unclaimed. We can add the absolute value of all three locations and if it is 3, then the newly placed stick completes a box. If so, then assign the stone location from the tuple, adjusting for 0-based arrays again, to the player, and give them five points.

     4.  To help determine whether the match is over, we are going to use a couple of helper functions to make the code cleaner and easier to read. The first of those returns a `boolean` value if all the sticks have been played in the match. Add the following code to the end of the `GameHub` class:

    ```

    private static bool AllSticksHaveBeenPlayed(Match match)

    {

        return !(from s in match.Sticks where s == 0 select s).Any();

    }

    ```cs

    This function uses a straightforward LINQ query to search the `Sticks` array for any element that has a value of `0`, meaning unclaimed. If there are, the function returns.

     5.  The next function is a little more complex as it is used to determine whether a player has three stones in a row, either horizontally, vertically, or diagonally, and returns the `Id` value of the player that does. Add the following code to the end of the `GameHub` class:

    ```

    private static int HasThreeInARow(List<int> stones){    for (var rc = 0; rc < 3; rc++)    {        var rowStart = rc * 3;        var rowValue = stones[rowStart] + stones[rowStart + 1] + stones[rowStart + 2];        if (Math.Abs(rowValue) == 3) // we Have a winner!        {            return rowValue;        }        var colStart = rc;        var colValue = stones[colStart] + stones[colStart + 3] + stones[colStart + 6];        if (Math.Abs(colValue) == 3) // We have a winner!        {            return colValue ;        }    }    var tlbrValue = stones[0] + stones[4] + stones[8];    var trblValue = stones[2] + stones[4] + stones[6];    if (Math.Abs(tlbrValue) == 3) { return tlbrValue; }    if (Math.Abs(trblValue) == 3) { return trblValue; }    return 0;}

    ```cs

    This method starts by checking all the rows and columns for 3 stones in a row, for the same player. Since there are nine stones arranged in a 3x3 grid, we only need to check three columns and three rows. Using a single iterator, each row or column is checked by adding the values stored at each position in the row or column and if the absolute value of the sum is 3, then a single player has a winning row. If the sum is positive, player one has won; otherwise, player two has won. Since there are only two possible diagonals, those checks use the same logic but are done individually, rather than looping.

     6.  Now, we can use those two functions to determine whether there is a winner. To do so, we can use the following code at the end of the `ProcessTurn` function:

    ```

    // Does one player have 3 stones in a row?

    var winner = Guid.Empty;

    var threeInARow = HasThreeInARow(match.Stones);

    if (threeInARow != 0)

        winner = threeInARow > 0 ? match.PlayerOneId : match.PlayerTwoId;

    if (winner == Guid.Empty) // No Winner yet

    {

        // Have all sticks been played, if yes, use top score.

        if (HaveAllSticksBeenPlayed(match))

        {

            winner = match.PlayerOneScore > match.PlayerTwoScore ? match.PlayerOneId : match.PlayerTwoId;

        }

    }

    ```cs

    Here, we use the two methods we just created to do the main checks and assign the winner. We capture the winner as the `Guid` type from the `Id` property of the `Player` class, so some translation is needed.

     7.  Next, we can set the next player’s turn, or if there is a winner, complete the match, as follows:

    ```

    if (winner == Guid.Empty)

    {

        match.NextPlayerId = args.Player.Id == match.PlayerOneId ? match.PlayerTwoId : match.PlayerOneId;

    }

    else

    {

        match.NextPlayerId = Guid.Empty;

        match.WinnerId = winner;

        match.Completed = true;

    }

    ```cs

     8.  The final steps are to save any changes we have made and send updates to the players. We will use a helper method called `SaveGameAndSendUpdates` to handle that as we will need the same code when a turn expires. Add the following code to the end of the `GameHub` class:

    ```

    private async Task SaveMatchAndSendUpdates(GameDbContext context, Match match)

    {

        context.Matches.Update(match);

        await context.SaveChangesAsync();

        await Clients.Group($"Match[{match.Id}]").SendAsync(Constants.Events.MatchUpdated, new MatchUpdatedEventArgs(match));

        if (match.Completed)

        {

            await UserGroups.RemoveFromGroupAsync(match.PlayerOneId.ToString(), $"Match[{match.Id}]");

            await UserGroups.RemoveFromGroupAsync(game.PlayerTwoId.ToString(), $"Match[{match.Id}]");

        }

    }

    ```cs

This function will save the current match state to the database, then sends a message to the SignalR group for the match indicating that there have been updates to the match. If the match is over, then we remove the players from the group.

1.  The following final three lines of code complete the `ProcessTurn` method:

    ```

    await SaveMatchAndSendUpdates(context, match);

    return new OkObjectResult(new ProcessTurnResponse(match));

    ```cs

    After saving the match changes and notifying the players of the match updates, if the match is not over yet, we notify the next player that it is their turn to play. To wrap things up we return the updated match object back to the player that just made their move.

     2.  We also need to call `SaveGameAndSendUpdates` when there is an error after calling `VerifyGameState`. Modify that section of code in `ProcessTurn` using the following snippet:

    ```

    error = VerifyMatchState(game, args);

    if (error is not null)

    {

    await SaveMatchAndSendUpdates(game);

    log.LogError(error, "Error validating match state.");

    return new BadRequestObjectResult(error);

    }

    ```cs

We have now completed all the required functions to connect a player to the service, challenge another player to a match, and then process each player’s turn and determine the winner.
Let’s take a short look back at what we have accomplished so far in this chapter. We started by creating the Azure services that our game server backend would need, a SignalR service for real-time communication, and finally, the Functions service to host our backend functions. We then implemented the Azure functions that would provide the functionality for our game:

*   `Connect`: To register players to the game service
*   `IssueChallenge`: To allow one player to request a game with another player
*   `AcknowledgeChallenge`: To accept or decline a request
*   `ProcessTurn`: To manage the gameplay between two players and determine the winner

Our backend is now complete, and we are ready to publish it to Azure so that we can consume the services from the game app in *Chapter 10*.
Deploying the functions to Azure
The final step in this chapter is to deploy the functions to Azure. You can do that as a part of a **continuous integration/continuous deployment** (**CI/CD**) pipeline – for example, with Azure DevOps. But the easiest way to deploy the functions, in this case, is to do it directly from Visual Studio. Perform the following steps to deploy the functions:

1.  Right-click on the `SticksAndStones.Functions` project and select **Publish**.
2.  Select **Azure** as the destination for publishing and click **Next**:

![Figure 9.14 – Target selection when publishing](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_15.jpg)

Figure 9.14 – Target selection when publishing

1.  Choose **Azure Function App (Windows)** in the **Specific target** tab, then click **Next**:

![Figure 9.15 – Container selection when publishing](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_16.jpg)

Figure 9.15 – Container selection when publishing

1.  Sign in to the same Microsoft account that we used in the Azure portal when we were creating the **Function** **App** resource.
2.  Select the subscription that contains the function app. All function apps we have in the subscription will now be loaded.
3.  Select the function app and click **Finish**. If your app isn’t showing up, click **Back** and choose the **Azure Function App (Linux)** option as you may not have changed the default when creating the service in the *Creating the Azure service for* *functions* section.
4.  When the profile is created, click the **Publish** button.

The following screenshot shows the last step. After that, the publishing profile will be created:
![Figure 9.16 – Publishing Azure functions](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_09_17.jpg)

Figure 9.16 – Publishing Azure functions
Summary
In this chapter, we started by learning about a few Azure services, including SignalR, and Functions. Then, we created the services in Azure that our game server backend would need – a SignalR service for real-time communication, and finally, the Functions service to host our backend functions. After this, we implemented the Azure functions that would provide the functionality for our game.
We wrapped up this chapter by publishing our function code to the Azure Functions instance in Azure.
In the next chapter, we will build a game app that will use the backend we have built in this project.

第十章:构建实时游戏

在本章中,我们将构建一个支持多人、面对面实时通讯的游戏应用。在应用中,您将能够连接到游戏服务器并查看其他已连接玩家的列表。然后,您可以选择一个玩家请求与他们玩游戏,如果他们接受,就可以玩一场棍棒与石头游戏。我们将探讨如何使用 SignalR 实现与服务器的实时连接。

本章将涵盖以下主题:

  • 如何在.NET MAUI 应用中使用 SignalR

  • 如何使用控件模板

  • 如何使用 XAML 触发器更新界面

  • 如何在.NET MAUI 应用中使用 XAML 样式

让我们开始吧。

技术要求

在开始构建此项目的应用之前,您需要构建我们在第九章中详细说明的后端,即使用 Azure 服务设置游戏后端。您还需要安装 Visual Studio for Mac 或 PC,以及.NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅第一章.NET MAUI 简介。本章的源代码可在本书的 GitHub 仓库中找到:github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/chapters/ten/main

项目概述

在构建一个面对面游戏应用时,拥有实时通讯功能非常重要,因为用户期望其他玩家的动作能够尽可能快地到达。为了实现这一点,我们将使用 SignalR,这是一个用于实时通讯的库。如果 WebSocket 可用,SignalR 将使用 WebSocket,如果不可用,它将提供几个备选方案。在应用中,我们将使用 SignalR 通过我们在第九章中构建的 Azure Functions 发送玩家和游戏状态更新。

此项目的构建时间大约为 180 分钟。

开始使用

我们可以使用 PC 上的 Visual Studio 或 Mac 来完成这个项目。要使用 PC 上的 Visual Studio 构建 iOS 应用,您必须连接一台 Mac。如果您根本无法访问 Mac,您可以选择只构建应用的 Android 部分。

让我们从第九章回顾一下游戏的主要内容。

游戏概述

棍与石是一款基于两个童年游戏概念结合而成的回合制社交游戏,即点与框(en.wikipedia.org/wiki/Dots_and_boxes)和井字棋(en.wikipedia.org/wiki/Tic-tac-toe)的概念。游戏板以三乘三的网格布局。每位玩家将轮流在方框的旁边、两个点之间放置一根棍子,以获得一分。如果一根棍子完成了一个方框,那么该玩家将获得该方框的所有权,获得五分。当玩家在一条水平、垂直或对角线上拥有三个方框时,游戏获胜。如果没有任何玩家能够连续拥有三个方框,则游戏获胜者由得分最高的玩家决定。

为了保持应用和服务端相对简单,我们将消除大量的状态管理。当玩家打开应用时,他们需要连接到游戏服务。他们需要提供一个游戏标签、用户名或电子邮件地址。可选地,他们可以上传自己的照片作为头像。

一旦连接,玩家将看到所有连接到同一游戏服务的其他玩家的列表;这被称为大厅。玩家的状态(准备游戏正在比赛中)将与玩家的游戏标签和头像一起显示。如果玩家不在比赛中,将有一个按钮可供挑战其他玩家进行比赛。

向玩家发起比赛邀请会导致应用提示对手回应挑战,无论是接受还是拒绝。如果对手接受挑战,那么两位玩家将被导航到一个新的游戏板,接受挑战的玩家将先走一步。所有其他玩家的大厅中,两位玩家的状态都将更新为正在比赛中

玩家们将轮流选择放置一根棍子的位置。每次玩家放置一根棍子,游戏板和分数将在两位玩家的设备上更新。当放置的棍子完成一个或多个方格时,该玩家赢得该方格,并在方格中心放置一堆石头。当所有棍子都放置完毕,或者某个玩家在一条直线上拥有三颗石头时,游戏结束,玩家们将被导航回大厅,他们的状态更新为“准备游戏”。

如果玩家在游戏中离开应用,那么他们将放弃比赛,剩余的对手将被计入胜利,并导航回大厅。

既然我们已经了解了我们想要构建的内容,那么让我们深入细节。

我们建议您使用我们在 第九章使用 Azure 服务设置游戏后端 中使用的相同解决方案,因为这会使代码共享更容易。如果您不想阅读 第九章 的全部内容,您可以从 第九章 中获取完成的源代码,github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/chapters/nine/main

我们将分四个部分来构建这个应用程序:

  • 服务 – 所需的所有类,用于连接并与在 第九章使用 Azure 服务设置游戏后端 中构建的 Azure 函数后端进行交互。

  • 连接页面 – 这将包括允许用户作为玩家连接到游戏服务器的视图和视图模型。

  • 大厅页面 – 大厅是玩家可以与其他玩家发送和接收挑战的地方。在本节中,我们将构建大厅的视图和视图模型。

  • 游戏页面 – 这是玩家可以轮流玩 棍子和石头 游戏的地方。在本节中,我们将构建实现这一功能的视图和视图模型。

让我们先创建 .NET MAUI 应用程序的项目。

构建游戏应用程序

是时候开始构建应用程序了。从上一章打开 SticksAndStones 解决方案,按照以下步骤创建项目:

  1. 通过选择 Visual Studio 菜单中的 文件添加,然后 新建项目… 来打开 创建新项目 向导:

图 10.1 – 文件 | 添加 | 新项目…

图 10.1 – 文件 | 添加 | 新项目…

  1. 在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项,或者如果列出,从 最近的项目模板 中选择它:

图 10.2 – 创建新项目

图 10.2 – 创建新项目

  1. 点击 下一步

  2. 将应用程序名称输入为 SticksAndStones.App,并在 解决方案 下选择 添加到解决方案,如图下所示:

图 10.3 – 配置您的新的项目

图 10.3 – 配置您的新的项目

  1. 点击 下一步

  2. 最后一步将提示您选择支持 .NET Core 的版本。在撰写本文时,.NET 6 可用为 长期支持LTS),.NET 7 可用为 标准期限支持。为了本书的目的,我们假设您将使用 .NET 7:

图 10.4 – 补充信息

图 10.4 – 补充信息

  1. 通过点击 创建 完成设置,并等待 Visual Studio 创建项目。

现在我们已经为我们的游戏屏幕创建了 .NET MAUI 项目,让我们配置它,以便它可以添加服务和视图。我们需要将 SticksAndStones.Shared 项目添加为项目引用,以及一些 NuGet 包。按照以下步骤完成 SticksAndStones.App 项目的设置:

  1. 右键单击 Solution Explorer 中的 SticksAndStones.App 项目,并选择 Properties

  2. Default namespace 中。

  3. 修改 $(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))

    这将根据 "." 分割项目名称,仅使用第一部分,并将任何空格替换为下划线。

  4. 将 NuGet 包引用添加到 CommunityToolkit.Mvvm,因为在其他章节中,我们将使用此包来简化数据绑定到属性和命令的实现。

  5. 将 NuGet 包引用添加到 CommunityToolkit.Maui。我们将使用此包中的 GravatarImageSource 类来渲染用户的头像。对于 .NET 7,您需要使用 NuGet 包的 6.1.0 版本。7.0+ 版本以 .NET 8 为依赖项。

  6. 打开 MauiProgram.cs 文件,并添加此处显示的突出显示行:

    using CommunityToolkit.Maui;
    using Microsoft.Extensions.Logging;
    namespace SticksAndStones.App
    {
        public static class MauiProgram
        {
            public static MauiApp CreateMauiApp()
            {
                var builder = MauiApp.CreateBuilder();
                builder
                    .UseMauiApp<App>()
                    .ConfigureFonts(fonts =>
                    {
                        fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                        fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                    })
                    .UseMauiCommunityToolkit();
    #if DEBUG
                builder.Logging.AddDebug();
    #endif
                return builder.Build();
            }
        }
    }
    

    这将为应用程序内的 CommunityToolkit 配置。

  7. 将 NuGet 包引用添加到 Microsoft.Extensions.Logging.Abstractions。此包用于记录 Azure Functions 函数的消息以进行调试。

  8. 将 NuGet 包引用添加到 Microsoft.Extensions.Logging.Debugging。此包用于记录 Azure Functions 函数的消息以进行调试。

  9. 将 NuGet 包引用添加到 Microsoft.AspNetCore.SignalR.Client。此包对于应用程序连接到我们在第九章中创建的 SignalR Hub 并接收消息是必需的。

  10. 将项目引用添加到 SticksAndStones.Shared 项目。这将使我们能够访问在第九章中创建的消息和对象。

项目创建到此结束。接下来,我们将开始创建与我们的服务直接交互的类。

创建游戏服务

我们首先要做的是创建一个服务,该服务将用于与在第九章中创建的 Azure Functions 函数服务进行通信,即使用 Azure 服务设置游戏后端。该服务将分为三个主要类:

  • GameService – 用于调用 Azure Functions 和接收 SignalR 消息的方法和属性。

  • ServiceConnection – 包含对 HttpClient 和 SignalR Hub 实例的引用。同时提供用于安全调用 HttpClient 的方法。

  • Settings – 存储和检索 HttpClient 所使用的服务器 URL。它还存储用户提供的连接详细信息。

我们将从 Settings 类开始,因为 GameServiceServiceConnection 都将依赖于 Settings

创建 Settings 服务

Settings 服务用于存储应用程序运行之间所需的价值。它将使用 .NET MAUI 的 Preferences 类以跨平台方式存储这些值。使用以下步骤实现 Settings 类:

  1. SticksAndStones.App 项目中,创建一个名为 Services 的新文件夹。

  2. 在新创建的 Services 文件夹中,创建一个名为 Settings 的新类。

  3. 将类设置为公共。

  4. 创建一个名为 LastPlayerKeyconst string 字段,并按如下方式初始化:

    private const string LastPlayerKey = nameof(LastPlayerKey);
    
  5. 创建一个名为 ServerUrlKeyconst string 字段,并按如下方式初始化:

    private const string ServerUrlKey = nameof(ServerUrlKey);
    

    这两个字段被 .NET MAUI 的 Preferences 类用来存储服务器 URL 和用户上次登录的登录详情。

  6. 添加一个名为 ServerUrlDefaultprivate const string 字段,如下所示:

    #if DEBUG && ANDROID
        private const string ServerUrlDefault = "http://10.0.2.2:7071/api";
    #else
        private const string ServerUrlDefault = "http://localhost:7071/api";
    #endif
    ServerlUrlDefault value for Android devices. The 10.0.2.2 IP address is a special value used by the Android emulators to be able to access the host computer’s localhost address. This is very useful when testing the app using the Azurite development environment for Azure Functions.
    

    你可能需要调整前面列表中突出显示的端口号,以适应你特定的开发环境。Azure Functions 将在从 Visual Studio 启动时显示服务器 URL,如下面的屏幕截图所示:

图 10.5 – Azure Functions 控制台输出

图 10.5 – Azure Functions 控制台输出

使用托管在 Azure 中的 Azure Functions

如果你遵循了 第九章 部分中名为 将函数部署到 Azure 的步骤,那么你可以在 创建函数的 Azure 服务 部分中使用 第九章 中创建的 Azure Function App 的 URL。该 URL 显示在 Azure Functions App 的 概览 选项卡上。

  1. 现在,添加一个名为 ServerUrlpublic string 属性,其实现如下:

    public string ServerUrl
    {
        get => Preferences.ContainsKey(ServerUrlKey) ?
                    Preferences.Get(ServerUrlKey, ServerUrlDefault) :
                    ServerUrlDefault;
        set => Preferences.Set(ServerUrlKey, value);
    }
    

    如果存在,此代码将从 Preferences 存储中获取服务器 URL;如果不存在,它将使用 serverUrlDefault 值。该属性将在 Preferences 存储中存储新值。

  2. Settings.cs 文件的顶部添加以下 using 声明:

    using SticksAndStones.Models;
    using System.Text.Json;
    

    这将使我们能够使用我们的模型和 JsonSerializer 类。

  3. 创建一个名为 LastPlayer 的新属性,其类型为 Player,如下所示:

    public Player LastPlayer
    {
        get
        {
            if (Preferences.ContainsKey(LastPlayerKey))
            {
                var playerJson = Preferences.Get(LastPlayerKey, string.Empty);
                return JsonSerializer.Deserialize<Player>(playerJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new();
            }
            return new();
        }
        set => Preferences.Set(LastPlayerKey, JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
    }
    

    在这里,属性的 set 方法将在将 Player 对象存储在 Preferences 之前将其转换为 Json 字符串,在获取属性时,如果它在 Preferences 存储中存在,则将存储的 Json 转换为 Player 对象。如果没有在 Preferences 存储中找到值,则 get 方法将返回一个空的 Player 对象。

  4. Settings 类的最终步骤是将它注册到依赖注入容器中。打开 SticksAndStones.App 项目的 MauiProgram.cs 文件,然后在 CreateMauiApp 方法中添加以下突出显示的代码:

    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });
    #if DEBUG
            builder.Logging.AddDebug();
    #endif
        builder.Services.AddSingleton<Services.Settings>();
            return builder.Build();
        }
    

随着 Settings 类的完成,我们现在可以专注于 ServiceConnection 类。

创建 ServiceConnection 类

ServiceConnection 类封装了与 Azure Functions 服务通信所需的功能。它具有调用函数方法并返回结果的方法,并具有适当的错误处理。它还负责初始化用于实时通信的 SignalR Hub 实例。ServiceConnection 类有几个我们需要的依赖项,所以让我们先放在一起。

首先要添加的是日志。在调试期间有日志可以帮助找出问题,尤其是在处理异步过程时。与 Azure Functions 通信将涉及大量的异步操作。为了在调试时启用日志记录,将高亮代码添加到SticksAndStones.App项目中的MauiProgram类的CreateMauiApp方法:

#if DEBUG
        builder.Logging.AddDebug();       
        builder.Services.AddLogging(configure =>
        {
            configure.AddDebug();
        });
#endif
        builder.Services.AddSingleton<Services.Settings>();
        return builder.Build();

这将在服务容器中添加一个ILoggingProvider实例。ILoggerProvider实例将提供ILogger<T>实例。这将使ILogger<T>能够在ServiceConnection类构造函数中作为依赖项使用。

更多关于日志提供者的信息

learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers了解更多关于日志提供者如何工作以及日志的一般信息。

现在,当使用 HTTP 向 API 发出请求时,使用异步调用是一种常见且良好的做法,这样就不会阻塞主线程或 UI 线程。所有 UI 更新,如动画、按钮点击、屏幕上的轻触或文本更改,都发生在 UI 线程上。HTTP 调用可能需要相当长的时间才能完成,这可能导致应用程序对用户无响应。

异步编程中的错误处理可能很困难。为了帮助在调用 API 时处理错误,我们将使用几个类来封装异常;这些类是AsyncErrorAsyncExceptionError。我们需要AsyncErrorAsyncExceptionError,因为将任何从System.Exception派生的类实例序列化和反序列化都是一种不好的做法。并非所有从System.Exception派生的类都是可序列化的,即使它们是可序列化的,也可能由于缺少类型而无法反序列化——例如,类型在服务器上可用但在客户端不可用。在SticksAndStones.App项目中创建一个名为AsyncError.cs的新文件,并用以下代码替换其内容:

using System.Text.Json.Serialization;
namespace SticksAndStones;
public record AsyncError
{
    [JsonPropertyName("message")]
    public string Message { get; set; }
}
public record AsyncExceptionError : AsyncError
{
    [JsonPropertyName("innerException")]
    public string InnerException { get; set; }
}

AsyncError类有一个单独的属性MessageMessage属性被JsonPropertyName属性装饰,以便在需要时可以序列化,使用属性名的小写版本。AsyncExceptionErrorAsyncError继承并添加了一个额外的属性InnerExceptionInnerException属性也被JsonPropertyName属性装饰。

我们还需要最后一个类 AsyncLazy<T>。你可能已经在你编写的一些其他应用程序中使用了 Lazy<T>。当你想要延迟创建一个类直到你真正需要它时,它非常方便。如果你永远不需要它,它就不会被创建。但是 Lazy<T> 与异步编程不太兼容,所以如果你想要异步创建一个类,这会变得很繁琐。幸运的是,Stephen Toub,他在微软的 .NET 团队工作,创建了 AsyncLazy<T>。要将它添加到 SticksAndStones.App 项目中,创建一个名为 AsyncLazy~1.cs 的新文件,并用以下内容替换其内容:

using System.Runtime.CompilerServices;
namespace SticksAndStones;
// AsyncLazy<T>, Microsoft, Stephen Toub, .NET Parallel Programming Blog, https://devblogs.microsoft.com/pfxteam/asynclazyt/
public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<T> valueFactory) :
        base(() => Task.Factory.StartNew(valueFactory))
    { }
    public AsyncLazy(Func<Task<T>> taskFactory) :
        base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap())
    { }
    public TaskAwaiter<T> GetAwaiter() { return Value.GetAwaiter(); }
}

了解更多关于 AsyncLazy

访问 .NET 博客了解更多关于 Stephen Toub 创建 AsyncLazy<T> 类的信息:devblogs.microsoft.com/pfxteam/asynclazyt/

这就完成了开始实现 ServiceConnection 类所需的所有更改。要创建该类,请按照以下步骤操作:

  1. SticksAndStones.App 项目的 Services 文件夹中创建一个名为 ServiceConnection 的新类。

    将类改为 public sealed 并继承自 IDisposable:

    public sealed class ServiceConnection : IDisposable
    
  2. 将文件顶部的命名空间声明修改为以下内容:

    using Microsoft.AspNetCore.Http.Connections.Client;
    using Microsoft.AspNetCore.SignalR.Client;
    using Microsoft.Extensions.Logging;
    using SticksAndStones.Models;
    using System.Net;
    using System.Net.Http.Json;
    using System.Text.Json;
    

    这些是在以下步骤中需要的,以引用所需的类型。

  3. 向类中添加以下 private 字段:

    private readonly ILogger log;
    private readonly HttpClient httpClient;
    private readonly JsonSerializerOptions serializerOptions;
    

    serializerOptions 用于确保从 Azure Functions 函数发送和接收的 JSON 可以正确地序列化和反序列化。

  4. 现在,添加一个名为 Hubpublic 属性。Hub 的类型为 AsyncLazy<HubConnection>HubConnection 是来自 SignalR 客户端库的类型,用于从 SignalR 服务接收消息。该属性应如下所示:

    public AsyncLazy<HubConnection> Hub { get; private set; }
    

    HubConnectionConnectHub 方法中初始化。但首先,让我们添加构造函数。

  5. ServiceConnection 类的构造函数有两个参数:ILogger<ServiceConnection> 和一个 Settings 参数。在构造函数的主体中,初始化在 步骤 3 中创建的 private 字段如下:

    public ServiceConnection(ILogger<ServiceConnection> logger, Settings settings)
    {
        httpClient = new()
        {
            BaseAddress = new Uri(settings.ServerUrl)
        };
        httpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
        serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
        log = logger;
    }
    

    loggersettings 参数由 .NET MAUI 依赖注入服务提供。httpClient 字段被初始化,并且它的 BaseAddress 被分配为设置 ServerUrl 属性作为 URI。然后,DefaultHeaders 被修改以指示服务器期望结果以 JSON 格式返回。serializerOptions 实例被初始化为 Web 的默认值,这与 Azure Functions 使用的格式一致。最后,log 字段被初始化为 logger 参数的值。

  6. 现在,让我们实现Dispose方法。它将清理任何可能持有任何原生资源的值,例如网络、文件句柄等。这个类中需要释放引用的两个值是httpClientHub。请注意,我们不需要自己调用Dispose,因为.NET MAUI 依赖注入系统会为我们完成这个操作。将以下代码添加到ServiceConnection类中:

    public void Dispose()
    {
        httpClient?.Dispose();
        Hub?.Value?.Dispose();
        GC.SuppressFinalize(this);
    }
    
  7. 现在,通过将以下高亮显示的代码行添加到MauiProgram.cs文件中,将类添加到依赖注入中:

    builder.Services.AddSingleton<Services.Settings>();
    builder.Services.AddSingleton<Services.ServiceConnection>();
    return builder.Build();
    
  8. Hub属性的初始化将在ConnectHub方法中发生。SignalR Hub 连接的配置作为Connect函数的结果返回给应用程序。由于我们在这个类构造之前没有也不会调用那个方法,所以我们不能在构造函数中创建Hub。在初始化Hub实例之前需要配置。ConnectHub方法有一个名为ConnectionInfo的单个参数。使用以下代码片段添加该方法:

    public void ConnectHub(ConnectionInfo config)
    {
        Hub = new(async () =>
        {
            var connectionBuilder = new HubConnectionBuilder();
            connectionBuilder.WithUrl(config.Url, (HttpConnectionOptions obj) =>
            {
                obj.AccessTokenProvider = async () => await Task.FromResult(config.AccessToken);
            });
            connectionBuilder.WithAutomaticReconnect();
            var hub = connectionBuilder.Build();
            await hub.StartAsync();
            return hub;
        });
    }
    

    此方法将Hub属性初始化为一个新的AsyncLazy<HubConnection>实例。AsyncLazy<T>的构造函数接受Func<T>,它通过匿名方法语法提供。匿名方法也被标记为async方法,这意味着它将包含一个等待的方法调用。匿名方法不接受任何参数,在方法体中,首先创建一个新的HubConnectionBuilder。然后,在HubConnectionBuilder上调用WithUrl扩展方法来设置 SignalR 服务的 URL 并提供建立连接所需的AccessToken值。AccessTokenProviderTask<string>,因此config.AccessToken通过另一个async匿名函数提供。WithAutomaticReconnect方法将HubConnection实例设置为在连接丢失时自动尝试重新连接 SignalR 服务。如果没有调用WithAutomaticReconnect,则当连接丢失时,应用程序负责重新连接。通过调用HubConnectionBuilder.Build创建HubConnection实例。然后,通过StartAsync启动Hub实例,这是等待的,然后返回Hub。这里要记住的是,当调用ConnectHub时,匿名函数不会执行。该方法只有在第一次访问Hub属性的一个属性或方法时才会被调用。

ServiceConnection 类包含两个辅助函数,这些函数从 GameService 类中使用,以向 Azure Functions 服务发送 HTTP 请求。第一个是 GetAsync<T>,它接受两个参数:一个 URL 和一个字典,该字典包含要随 URL 一起传递的查询参数。它返回一个 T 实例和 AsyncError 作为 TupleGetAsync 方法在发起 HTTP 请求时将使用 GET HTTP 方法。另一个辅助方法是 PostAsync<T>,它使用 POST HTTP 方法,并接受两个参数:一个 URL 和一个对象,该对象作为请求体中的 JSON 格式发送。它将从响应中返回 T 的一个实例。

GetAsync<T>PostAsync<T> 使用几个辅助方法;使用以下代码片段将它们添加到 ServiceConnection 类中:

UriBuilder GetUriBuilder(Uri uri, Dictionary<string, string> parameters)
=> new(uri)
{
    Query = string.Join("&",
    parameters.Select(kvp =>
            $"{kvp.Key}={kvp.Value}"))
};
async ValueTask<AsyncError?> GetError(HttpResponseMessage responseMessage, Stream content)
{
    AsyncError? error;
    if (responseMessage.StatusCode == HttpStatusCode.Unauthorized)
    {
        log.LogError("Unauthorized request {@Uri}", responseMessage.RequestMessage?.RequestUri);
        return new()
        {
            Message = "Unauthorized request."
        };
    }
    try
    {
        error = await JsonSerializer.DeserializeAsync<AsyncError>(content, serializerOptions);
    }
    catch (Exception e)
    {
        error = new AsyncExceptionError()
        {
            Message = e.Message,
            InnerException = e.InnerException?.Message,
        };
    }
    log.LogError("{@Error} {@Message} for {@Uri}", responseMessage.StatusCode, error?.Message, responseMessage?.RequestMessage?.RequestUri);
    return error;
}

GetUriBuilder 方法将返回一个新的 UriBuilder,该 UriBuilder 从提供的 URL 和键值对 Dictionary 中获取,用于查询字符串。GetError 方法将根据状态码或 HTTP 方法调用响应的内容返回 AsyncError 对象或 AsyncExceptionError 对象。

现在,我们可以使用以下代码将 GetAsync<T> 方法添加到 ServiceConnection 类中:

public async Task<(T Result, AsyncError Exception)> GetAsync<T>(Uri uri, Dictionary<string, string> parameters)
{
    var builder = GetUriBuilder(uri, parameters);
    var fullUri = builder.ToString();
    log.LogDebug("{@ObjectType} Get REST call @{RestUrl}", typeof(T).Name, fullUri);
    try
    {
        var responseMessage = await httpClient.GetAsync(fullUri);
        log.LogDebug("Response {@ResponseCode} for {@RestUrl}", responseMessage.StatusCode, fullUri);
        if (responseMessage.IsSuccessStatusCode)
        {
            try
            {
                var content = await responseMessage.Content.ReadFromJsonAsync<T>();
                log.LogDebug("Object of type {@ObjectType} parsed for {@RestUrl}", typeof(T).Name, fullUri);
                return (content, null);
            }
            catch (Exception e)
            {
                log.LogError("Error {@ErrorMessage} for when parsing ${ObjectType} for {@RestUrl}", e.Message, typeof(T).Name, fullUri);
                return (default, new AsyncExceptionError()
                {
                    InnerException = e.InnerException?.Message,
                    Message = e.Message
                });
            }
        }
        log.LogDebug("Returning error for @{RestUrl}", fullUri);
        return (default, await GetError(responseMessage, await responseMessage.Content.ReadAsStreamAsync()));
    }
    catch (Exception e)
    {
        log.LogError("Error {@ErrorMessage} for REST call ${ResUrl}", e.Message, fullUri);
        // The service might not be happy with us, we might have connection issues etc..
        return (default, new AsyncExceptionError()
        {
            InnerException = e.InnerException?.Message,
            Message = e.Message
        });
    }
}

虽然这个方法有点长,但它所做的事情并不复杂。首先,它使用 GetUriBuilder 方法创建 UriBuilder 实例,并从该实例构建 fullUri 字符串值。然后,它使用 HttpClient 实例向 URL 发起 HTTP GET 请求。如果发生任何失败,异常处理程序将捕获它并返回 AsynExceptionError。如果没有错误发生,并且响应代码指示成功,则处理并返回结果。否则,将读取结果内容以查找错误,如果找到,则返回。当 GetAsync<T> 方法返回时,它将始终返回两个项目:T 类型的响应和 AsyncError。如果其中任何一个不存在,则返回它们的默认值或 null

审查并添加以下代码片段到 ServiceConnection 类以实现 PostAsync<T> 方法:

public async Task<(T Result, AsyncError Exception)> PostAsync<T>(Uri uri, object parameter)
{
    log.LogDebug("{@ObjectType} Post REST call @{RestUrl}", typeof(T).Name, uri);
    try
    {
        var responseMessage = await httpClient.PostAsJsonAsync(uri, parameter, serializerOptions);
        log.LogDebug("Response {@ResponseCode} for {@RestUrl}", responseMessage.StatusCode, uri);
        await using var content = await responseMessage.Content.ReadAsStreamAsync();
        if (responseMessage.IsSuccessStatusCode)
        {                
            if(string.IsNullOrEmpty(await.responseMessage.Content.ReadAsStringAsync()))
                return (default, null);
            try
            {
                log.LogDebug("Parse {@ObjectType} SUCCESS for {@RestUrl}", typeof(T).Name, uri);
                var result = await responseMessage.Content.ReadFromJsonAsync<T>();
                log.LogDebug("Object of type {@ObjectType} parsed for {@RestUrl}", typeof(T).Name, uri);
                return (result, null);
            }
            catch (Exception e)
            {
                log.LogError("Error {@ErrorMessage} for when parsing ${ObjectType} for {@RestUrl}", e.Message, typeof(T).Name, uri);
                return (default, new AsyncExceptionError()
                {
                    InnerException = e.InnerException?.Message,
                    Message = e.Message
                });
            }
        }
        log.LogDebug("Returning error for @{RestUrl}", uri);
        return (default, await GetError(responseMessage, content));
    }
    catch (Exception e)
    {
        log.LogError("Error {@ErrorMessage} for REST call ${ResUrl}", e.Message, uri);
        // The service might not be happy with us, we might have connection issues etc..
        return (default, new AsyncExceptionError()
        {
            InnerException = e.InnerException?.Message,
            Message = e.Message
        });
    }
}

此方法基本上与 GetAsync<T> 相同,但有一些小的变化。首先,它不需要调用 GetUriBuilder 将参数添加到 UriQueryString 中,因为参数作为请求体的一部分发送。其次,它使用 HTTP POST 方法而不是 GET。有了这些变化,方法的大部分是错误处理,以确保我们返回正确的数据。

这样就完成了 ServiceConnection 类。ServiceConnectionSettings 服务类将在下一节中使用,其中我们将创建 GameService 类。

创建 GameService

GameService 类是 UI 和网络之间的一个层。它使用 ServiceConnection 类来处理特定的网络调用,以创建我们需要与 Azure Functions 交互的逻辑。对于我们在 第九章 中创建的每个函数,GameService 类都有一个对应的方法来调用函数并返回结果(如果有的话)。

按照以下步骤创建和初始化类:

  1. SticksAndStones.App 项目的 Services 文件夹下创建一个名为 GameService 的新类。

  2. 将类定义更改为 public sealed 并从 IDisposable 接口继承:

    public sealed class GameService : IDisposable
    
  3. 将以下命名空间声明添加到文件顶部:

    using System.Collections.ObjectModel;
    using CommunityToolkit.Mvvm.Messaging;
    using Microsoft.AspNetCore.SignalR.Client;
    using SticksAndStones.Messages;
    using SticksAndStones.Models;
    
  4. GameService 类将依赖于 Settings 服务和 ServiceConnection 服务,因此我们需要将它们添加到构造函数中,并将引用存储在类字段中,如下所示:

    private readonly ServiceConnection service;
    private readonly Settings settings;
    public GameService(Settings settings, ServiceConnection service)
    {
        this.service = service;
        this.settings = settings;
    }
    GameService class. .NET MAUI will provide the Settings and ServiceConnection instances through dependency injection.
    
  5. 通过向 GameService 类添加以下方法来实现 IDisposable 接口:

    public void Dispose()
    {
        service.Dispose();
        GC.SuppressFinalize(this);
    }
    
  6. 现在,通过将以下高亮显示的代码行添加到 MauiProgram.cs 文件中,将类添加到依赖注入中:

    #if DEBUG
                builder.Logging.AddDebug();
    #endif
                builder.Services.AddSingleton<Services.Settings>();
               builder.Services.AddSingleton<Services.ServiceConnection>();
                builder.Services.AddSingleton<Services.GameService>();
                return builder.Build();
    

我们将从 Connect 方法开始。Connect 将接受一个要连接的 Player 对象,并返回一个更新的 Player 对象。此外,如果连接成功,它将配置 SignalR Hub。要创建 Connect 函数,请按照以下步骤操作:

  1. 创建一个名为 semaphoreSlimprivate 字段,并使用一个具有初始和最大计数为 1 的新实例初始化该字段:

    public sealed class GameService : IDisposable
    {
        private readonly SemaphoreSlim semaphoreSlim = new(1, 1);
        private readonly ServiceConnection service;
    

    SemaphoreSlim 类是限制一次执行操作线程数量的好方法。在我们的情况下,我们只想让一个线程在每次进行网络调用。它将在所有从 GameService 类进行网络调用的方法中使用。

  2. GameService 将在名为 CurrentPlayerpublic 属性中跟踪当前玩家;使用以下代码将属性添加到类中:

    private readonly Settings settings;
    public Player CurrentPlayer { get; private set; } = new Player() { Id = Guid.Empty, GameId = Guid.Empty };
    

    属性被初始化为一个空的 Player 对象。

  3. 一旦用户以玩家身份连接,我们还需要一个地方来存储在线玩家的列表。为此,将以下属性添加到 GameService 类中:

    public ObservableCollection<Player> Players { get; } = new();
    
  4. GameService 类在名为 IsConnected 的属性中跟踪当前连接状态;使用以下代码片段将属性添加到 GameService 类中:

    public bool IsConnected { get; private set; }
    
  5. 将一个名为 Connectpublic async 方法添加到 GameService 类中。它应该返回 Task<Player> 并接受一个 Player 参数,如下所示:

    public async Task<Player> Connect(Player player)
    {
    }
    
  6. Connect 方法中,第一步是确保一次只有一个线程在该方法中操作:

    await semaphoreSlim.WaitAsync();
    

    这使用 C# 中的 async/await 结构创建一个锁,只有当 SemaphoreSlim 中有足够的开放槽位时才会释放。由于 SemaphoreSlim 实例仅初始化了一个槽位,因此一次只能有一个线程处理 Connect 方法。

  7. 为了确保 the SemaphoreSlim 实例释放槽位,我们需要在方法的其他部分添加异常处理,并在最后调用 Release。将以下代码片段添加到 Connect 方法中:

    try
    {
    }
    finally
    {
        semaphoreSlim.Release();
    }
    return CurrentPlayer;
    

    try/finally 块确保我们将在方法结束时始终调用 Release,这将防止 the SemaphoreSlim 实例被饿死,防止任何其他线程进入该方法。最后,我们返回 CurrentPlayer 的值,我们将在 try 块中设置它。

处理 SemaphoreSlim 的另一种方法是

使用 try/catch/finally 块是可行的,但如果您正确处理所有异常或没有异常,则略显笨拙。Tom Dupont 在他的博客上发布了一个辅助类,允许您使用 using 语句来管理 the SemaphoreSlim 实例的生命周期。您可以在他的帖子 www.tomdupont.net/2016/03/how-to-release-semaphore-with-using.html 中阅读他的帖子。以下是他扩展的示例:

using var handle = semaphoreSlim.UseWaitAsync();

  1. try 块中,添加以下代码行:

    CurrentPlayer = player;
    var (response, error) = await service.PostAsync<ConnectResponse>(new($"{settings.ServerUrl}/Connect"), new ConnectRequest(player));
    if (error is null)
    {
        service.ConnectHub(response.ConnectionInfo);
        response.Players.ForEach(Players.Add);
        CurrentPlayer = response.Player;
        IsConnected = true;
    }
    else
    {
        WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
    }
    

    此代码块处理 Azure Functions 服务中 Connect 函数的调用。我们首先将传入的玩家详细信息设置为 CurrentPlayer 属性。然后,将 player 实例打包成一个 ConnectRequest 对象,并将其传递给 ServiceConnection 实例上的 PostAsync<T> 调用。URL 是从存储在 Settings 服务中的 ServerUrl 属性拼接 /Connect 创建的。预期的响应类型为 ConnectResponse,并将其存储在 response 中。如果我们没有收到任何错误,那么我们可以在 ServiceConnection 实例上调用 ConnectHub,填充我们的 Players 集合,并将 the CurrentPlayer 属性设置为返回的 Player 实例,该实例将包含来自服务器的额外详细信息。如果发生任何问题,则错误对象将被填充,我们将向 UI 发送包含该错误的消息。

    ServiceError 是我们需要从 GameService 类发送到 ViewModel 实例的第一个消息。它用于将 ServiceConnection 实例的错误发送到 ViewModel 实例。我们将在下一步添加 ServiceError 类。

  2. SticksAndStones.App 项目中,创建一个名为 Messages 的新文件夹。

  3. SticksAndStones.App 项目的 Messages 文件夹中创建一个名为 ServiceError 的新类。

  4. ServiceError 消息是对 AyncError 对象的简单包装,可用于向视图模型发送消息。将 ServiceError.cs 文件的内容替换为以下内容:

    using CommunityToolkit.Mvvm.Messaging.Messages;
    namespace SticksAndStones.Messages;
    internal class ServiceError : ValueChangedMessage<AsyncError>
    {
        public ServiceError(AsyncError error) : base(error)
        {
        }
    }
    
  5. 最后,由于我们正在使用 SemaphoreSlim 并且它可以保留原生资源,我们应该确保这些资源被正确释放。将以下高亮代码添加到 Dispose 方法中以清理 semaphoreSlim 字段:

    public void Dispose()
    {
        semaphoreSlim.Release();
        semaphoreSlim.Dispose();
        service.Dispose();
        GC.SuppressFinalize(this);
    }
    

这就完成了当前的 Connect 方法。

接下来的三个方法是从 Lobby 页面调用的。第一个方法用于刷新玩家列表。当用户下拉列表以刷新或如果 SignalR Hub 重新连接时,将调用此方法。要实现 the RefreshPlayerList 方法,请按照以下步骤操作:

  1. the RefreshPlayerList 方法不接受任何参数并返回 Task;将此方法添加到 GameService 类中,如下所示:

    public async Task RefreshPlayerList()
    {
        await semaphoreSlim.WaitAsync();
        try
        {
            var getAllPlayers = service.GetAsync<GetAllPlayersResponse>(new($"{settings.ServerUrl}/Players/GetAll"), new Dictionary<string, string> { { "id", $"{CurrentPlayer.Id}" } });
            var (response, error) = await getAllPlayers;
            if (error is null)
            {
                Players.Clear();
                response.Players.ForEach(Players.Add);
            }
            else
            {      WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
            }
        }
        finally
        {
            semaphoreSlim.Release();
        }
    }
    
  2. 当 SignalR Hub 重新连接时,刷新玩家列表,请将以下高亮代码添加到 Connect 方法中:

    if (error is null)
    {
        service.ConnectHub(response.ConnectionInfo);
        response.Players.ForEach(Players.Add);
        CurrentPlayer = response.Player;
        (await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
    }
    

    这行代码很有趣。首先,我们等待 service.Hub,然后设置 Reconnected 事件为一个匿名函数,该函数调用 RefreshPlayerList。如果你还记得,ServiceConnection 类中的 Hub 属性是 AsyncLazy<T>。第一次引用 Hub 属性时,它将异步初始化自身,因此有 await 调用。

下一个从 Lobby 页面使用的方法是 IssueChallenge。当玩家希望与其他玩家进行比赛时,Lobby 页面会调用 IssueChallenge 方法。由于实际的挑战响应将通过 SignalR Hub 返回,因此 IssueChallenge 方法不返回任何值。该方法将向服务器发送请求并处理任何错误,如下所示:

public async Task IssueChallenge(Player opponent)
{
    await semaphoreSlim.WaitAsync();
    try
    {
        var (response, error) = await service.PostAsync<IssueChallengeResponse>(new($"{settings.ServerUrl}/Challenge/Issue"), new IssueChallengeRequest(CurrentPlayer, opponent));
        if (error is not null)
        {         WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
        }
    }
    finally
    {
        semaphoreSlim.Release();
    }
}

将前面的代码添加到 GameService 类中。当对手响应挑战时调用的 SendChallengeResponse 方法与 IssueChallenge 方法非常相似,如下所示:

public async Task SendChallengeResponse(Guid challengeId, Models.ChallengeResponse challengeResponse)
{
    await semaphoreSlim.WaitAsync();
    try
    {
        var (response, error) = await service.PostAsync<string>(new($"{settings.ServerUrl}/Challenge/Ack"), new AcknowledgeChallengeRequest(challengeId, challengeResponse));
        if (error is not null)
        {         WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
        }
    }
    finally
    {
        semaphoreSlim.Release();
    }
}

SendChallengeResponse 方法添加到 GameService 类中。这样就完成了支持 Lobby 页面所需的方法。我们应用中的最后一页是 Game 页面。还需要三个由 Game 页面需要的方法。按照以下步骤添加它们:

  1. 添加 EndTurn 方法,该方法将玩家的移动发送到 Game 服务器,使用以下代码片段:

    public async Task<(Game?, string?)> EndTurn(Guid gameId, int position)
    {
        await semaphoreSlim.WaitAsync();
        try
        {
            var (response, error) = await service.PostAsync<ProcessTurnResponse>(new($"{settings.ServerUrl}/Game/Move"), new ProcessTurnRequest(gameId, CurrentPlayer, position));
            if (error is not null)
            {
                return (null, error.Message);
            }
            else return (response.Game, null);
        }
        finally
        {
            semaphoreSlim.Release();
        }
    }
    

    EndTurn 方法与 IssueChallengeSendChallengeResponse 方法非常相似,只有一个小的例外:它返回更新后的 Game 对象和错误消息(如果有的话)。

  2. the GetPlayerId 方法是一个小的辅助函数,用于搜索 Players 列表并返回与传入的 ID 匹配的 Player 实例。使用以下代码片段添加 GetPlayerById 方法:

    public Player? GetPlayerById(Guid playerId)
    {
        if (playerId == CurrentPlayer.Id)
            return CurrentPlayer;
        return (from p in Players where p.Id == playerId select p).FirstOrDefault();
    }
    
  3. the GetMatchById 方法是最后一个将调用后端的方法。在这种情况下,它将根据 ID 获取一个 Match 对象。使用以下代码片段将 GetMatchById 添加到 GameService 类中:

    public async Task<Match> GetMatchById(Guid matchId)
    {
        await semaphoreSlim.WaitAsync();
        try
        {
            var (response, error) = await service.GetAsync<GetMatchResponse>(new($"{settings.ServerUrl}/Match/{matchId}"), new());
            if (error != null) { }
            if (response.Match != null)
                return response.Match;
            return new Match();
        }
        finally
        {
            semaphoreSlim.Release();
        }
    }
    

GameService 类的最后一部分是处理通过 SignalR Hub 接收的事件。为了从 第九章 中刷新我们的记忆,后端函数将通过 SignalR 将以下事件发送到客户端:

  • PlayerUpdatedEventArgs

  • ChallengeEventArgs

  • GameStartedEventArgs

  • GameUpdatedEventArgs

我们将在 GameService 方法中处理这些事件中的每一个。要实现这些事件的处理器,请按照以下步骤操作:

  1. Hub 接收到 PlayerUpdatedEventArgs 时,我们需要使用新值更新 Players 集合中的 Player。我们将创建一个辅助函数来处理这项工作,如下所示:

    private void PlayerStatusChangedHandler(PlayerUpdatedEventArgs args)
    {
        var changedPlayer = (from player in Players
                             where player.Id == args.Player.Id
                             select player).FirstOrDefault();
        if (changedPlayer is not null)
        {
            changedPlayer.MatchId = args.Player.MatchId;
        }
        else if (args.Player.Id != CurrentPlayer.Id)
        {
            Players.Add(args.Player);
        }
    }
    

    PlayerStatusChangedHandler 方法将在 Players 集合中定位已更改的玩家并更新实例的相关字段或如果不存在则添加它。

  2. 当接收到 PlayerUpdated 事件时调用 PlayerStatusUpdateHandler 类,请将以下突出显示的代码添加到 Connect 方法:

    if (error is null)
    {
        service.ConnectHub(response.ConnectionInfo);
        response.Players.ForEach(Players.Add);
        CurrentPlayer = response.Player;
        IsConnected = true;
        (await service.Hub).On<PlayerUpdatedEventArgs>(Constants.Events.PlayerUpdated, PlayerStatusChangedHandler);
        (await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
    }
    

其他三个事件将通过 WeakReferenceManagerViewModel 实例发送消息。首先,我们需要添加消息类型,按照以下步骤操作:

  1. SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 ChallengeReceived 的新类。

  2. 用以下内容替换 ChallengeReceived.cs 文件的内容:

    using CommunityToolkit.Mvvm.Messaging.Messages;
    using SticksAndStones.Models;
    namespace SticksAndStones.Messages;
    public class ChallengeRecieved : ValueChangedMessage<Player>
    {
        public Guid Id { get; init; }
        public ChallengeRecieved(Guid id, Player challenger) : base(challenger)
        {
            Id = id;
        }
    }
    
  3. SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 MatchStarted 的新类。

  4. 用以下代码替换 MatchStarted.cs 文件的内容:

    using CommunityToolkit.Mvvm.Messaging.Messages;
    using SticksAndStones.Models;
    namespace SticksAndStones.Messages;
    public class MatchStarted : ValueChangedMessage<Match>
    {
        public MatchStarted(Match match) : base(match)
        {
        }
    }
    
  5. SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 MatchUpdated 的新类。

  6. 用以下代码替换 MatchUpdated.cs 文件的内容:

    using CommunityToolkit.Mvvm.Messaging.Messages;
    using SticksAndStones.Models;
    namespace SticksAndStones.Messages;
    class MatchUpdated : ValueChangedMessage<Match>
    {
        public MatchUpdated(Match match) : base(match)
        {
        }
    }
    
  7. 当接收到事件时发送消息,请将以下突出显示的代码添加到 GameService 类中的 Connect 方法:

    service.ConnectHub(response.ConnectionInfo);
    response.Players.ForEach(Players.Add);
    CurrentPlayer = response.Player;
    IsConnected = true;
    (await service.Hub).On<PlayerUpdatedEventArgs>(Constants.Events.PlayerUpdated, PlayerStatusChangedHandler);
    (await service.Hub).On<ChallengeEventArgs>(Constants.Events.Challenge, (args) => WeakReferenceMessenger.Default.Send(new ChallengeRecieved(args.Id, args.Challenger)));
    (await service.Hub).On<MatchStartedEventArgs>(Constants.Events.MatchStarted, (args) => WeakReferenceMessenger.Default.Send(new MatchStarted(args.Game)));
    (await service.Hub).On<MatchUpdatedEventArgs>(Constants.Events.MatchUpdated, (args) => WeakReferenceMessenger.Default.Send(new MatchUpdated(args.Game)));
    (await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
    

这就完成了 GameService 类。我们拥有了与后端功能交互所需的所有方法,并且我们正在处理发送到客户端的事件。本章的下一部分将添加用于向用户展示屏幕所需的页面,从 连接 页面开始。

创建连接页面

图 10.6 所示的 连接 页面,是应用加载后用户首先看到的屏幕。该页面包含四个主要元素:玩家游戏标签的输入框、玩家电子邮件地址的输入框、玩家头像的图像控件以及 连接 按钮。

图 10.6 – 连接页面

图 10.6 – 连接页面

连接 页面将包含几个部分:

  • 一个名为 ConnectViewModel.csViewModel 文件

  • 一个名为 ConnectView.xaml 的 XAML 文件,其中包含布局

  • 一个名为 ConnectView.xaml.cs 的代码隐藏文件,将执行数据绑定过程

  • 包含自定义按钮控件布局的 XAML 文件,称为 ActivityButton.xaml

  • ActivityButton.xaml.csActivityButton 的代码隐藏

我们将首先实现 ConnectViewModel

添加 ConnectViewModel

ConnectViewModelLobbyViewModelGameView 模型一起将继承自一个名为 ViewModelBase 的单一基类。ViewModelBase 类提供了实现页面刷新所需的功能。并非所有页面都会使用此功能,但它将是可用的。要添加 ViewModelBase,请按照以下步骤操作:

  1. SticksAndStones.App 项目中创建一个名为 ViewModels 的新文件夹。

  2. ViewModels 文件夹中添加一个名为 ViewModelBase 的新类。

  3. ViewModelBase.cs 文件顶部添加以下命名空间声明:

    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    
  4. 将类声明更改为 public abstract partial 并从 ObservableRecipient 派生类:

    ObservableRecipient comes from CommunityToolkit. If you have worked through the other chapters in this book, you will have seen view models that derive from ObservableObject, which implements INotifyPropertyChanged. ObservableRecipient extends ObservableObject and adds built-in support for working with implementations of the .NET MAUI IMessage interface. To learn more about ObservableRecipient, visit https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/observablerecipient.
    
  5. 添加一个名为 canRefreshprivate bool 字段,并使用 ObservableProperty 属性:

    [ObservableProperty]
    private bool canRefresh;
    
  6. 添加一个名为 isRefreshingprivate bool 字段,并使用 ObservableProperty 属性:

    [ObservableProperty]
    private bool isRefreshing;
    
  7. 添加一个无参数返回 bool 类型的 private 方法名为 CanExecuteRefresh。方法签名和实现如下代码片段所示:

    private bool CanExecuteRefresh() => CanRefresh && !IsRefreshing;
    
  8. 添加一个名为 RefreshInternal 的新 protected virtual 方法,它返回一个 Task,其实现返回 Task.CompletedTask,如下所示:

    protected virtual Task RefreshInternal() => Task.CompletedTask;
    
  9. 添加如下所示的 Refresh 方法:

    [RelayCommand(CanExecute = nameof(CanExecuteRefresh))]
    public async Task Refresh()
    {
        IsRefreshing = true;
        await RefreshInternal();
        IsRefreshing = false;
        return;
    }
    

    Refresh 方法是一个 Command,这意味着它可以绑定到 XAML 元素作为 RefreshCommandCanExecuteRefresh 方法用于确定命令的启用/禁用状态。命令本身翻转 IsRefreshing 布尔值并调用 RefreshInternal 方法,在从 ViewModelBase 派生的类中放置具体的实现。

现在 ViewModelBase 已经实现,我们可以实现 ConnectViewModelConnectViewModel 类具有可绑定的玩家游戏标签和电子邮件地址属性以及各种命令状态。最后,有一个用于建立与游戏服务的连接的命令。让我们按照以下步骤实现 ConnectViewModel 类:

  1. ViewModels 文件夹中创建一个名为 ConnectViewModel 的新类。

  2. 将类定义更改为 public partial 并从 ViewModelBase 派生,如下所示:

    public partial class ConnectViewModel : ViewModelBase
    {
    }
    
  3. ConnectViewModel 依赖于 GameServiceSettings 服务,因此让我们添加一个构造函数来通过依赖注入获取它们,并添加 private 字段来存储它们的值,如下所示:

    public partial class ConnectViewModel : ViewModelBase
    {
        private readonly GameService gameService;
        private readonly Settings settings;
        public ConnectViewModel(GameService gameService, Settings settings)
        {
            this.gameService = gameService;
            this.settings = settings;
        }
    }
    
  4. 要使用 GameServiceSettings 类,您需要在文件顶部添加一个命名空间声明:

    using SticksAndStones.Services;
    
  5. 在代码片段中添加一个名为 gamerTagprivate string 字段,并使用 ObservableProperty 属性来使其可绑定,如下所示:

    [ObservableProperty]
    private string gamerTag;
    
  6. 在代码片段中添加一个名为 emailAddressprivate string 字段,并使用 ObservableProperty 属性来使其可绑定,如下所示:

    [ObservableProperty]
    private string emailAddress;
    
  7. ConnectViewModel 的构造函数中,从用户上次连接时初始化可绑定属性,如下所示:

    {
        this.gameService = gameService;
        this.settings = settings;
        // Load Player settings
        var player = settings.LastPlayer;
        Username = player.GamerTag;
        EmailAddress = player.EmailAddress;
    }
    
  8. Connect 页面不需要刷新视图,因此通过在构造函数开头添加以下行代码来禁用该功能:

    CanRefresh = false;
    
  9. 要实现 Connect 命令,我们需要四样东西:一个表示命令状态的 string,一个表示命令当前状态的 bool,一个返回命令是否启用的方法,以及命令本身的方法。要将状态作为字符串添加,请在 ConnectViewModel 类的构造函数上方添加以下代码:

    [ObservableProperty]
    private string connectStatus;
    

    我们使用 ObservableProperty 标记此字段,以便它可以绑定到视图中。

  10. 要将 isConnecting 字段添加以跟踪命令的状态,请在 connectStatus 字段下添加以下代码:

    [ObservableProperty]
    private bool isConnecting;
    
  11. CanExecuteConnect 方法将在命令启用时返回 true,否则返回 false。请在 isConnecting 字段下方使用以下代码片段添加方法:

    private bool CanExecuteConnect() => !string.IsNullOrEmpty(GamerTag) && !string.IsNullOrEmpty(EmailAddress) && !IsConnecting;
    
  12. Connect 命令将调用 Connect 方法与游戏服务器建立连接。这主要是为了保持方法小且易于管理。请将以下私有 Connect 方法添加到 ConnectViewModel 类中:

    private async Task<Player> Connect(Player player)
    {
        // Get SignalR Connection
        var playerUpdate = await gameService.Connect(player);
        if (gameService.IsConnected)
        {
            // If the player has an in progress match, take them to it.
            if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
            {
                await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
            }
            else
            {
                await Shell.Current.GoToAsync($"///Lobby");
            }
        }
        return playerUpdate;
    }
    

    此方法将在 GameService 类上调用 Connect 方法,传入玩家详细信息。如果连接成功,则用户将被导航到大厅页面,除非他们当前正在玩游戏,在这种情况下,他们将被导航到游戏页面。

.NET MAUI Shell 中的导航

在 .NET MAUI 中,导航是通过从 Shell 对象调用 GotoAsync 来实现的。Shell 对象可以通过将 App.Current.MainPage 强制转换为 Shell 对象,或者使用 Shell.Current 属性来获取。传递给 GotoAsync 的路由可以是相对于当前位置的,也可以是绝对路径。相对和绝对路由的有效形式如下:

• route – 路由将从当前位置向上搜索,如果找到,将被推送到导航堆栈

• /route – 路由将从当前位置向下搜索,如果找到,将被推送到导航堆栈

• //route – 路由将从当前位置向上搜索,如果找到,将替换导航堆栈

• ///route – 路由将从当前位置向下搜索,如果找到,将替换导航堆栈

要了解更多关于路由和导航的信息,请访问 learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation

  1. 使用以下代码片段将实现 Connect 命令的方法添加到 Connect ViewModel 类的底部:

        [RelayCommand(CanExecute = nameof(CanExecuteConnect))]
        public async Task Connect()
        {
            IsConnecting = true;
            ConnectStatus = "Connecting...";
            var player = settings.LastPlayer;
            player.GamerTag = GamerTag;
            player.EmailAddress = EmailAddress;
            player.Id = (await Connect(player)).Id;
            settings.LastPlayer = player;
            ConnectStatus = "Connect";
            IsConnecting = false;
        }
    

    命令非常直接。它设置 IsConnectingConnectStatus 属性,然后从视图更新 Player 值。然后,它调用 Connect,传入当前的 Player 实例。返回的玩家 ID 被捕获并设置回 Settings 中的 LastPlayer。最后,将 ConnectStatusIsConnecting 属性设置回默认值。

  2. 为了总结,我们需要添加一些属性以确保值更改时属性得到适当的更新。例如,当 IsConnecting 属性更改时,我们需要确保再次评估 CanExecuteConnect 方法。为此,我们在 IsConnecting 字段上添加 NotifyCanExecuteChangeFor 属性,如下所示:

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
    private bool isConnecting;
    

    由于 gamerTag 字段和 emailAddress 字段也在 CanExecuteConnect 方法中被引用,因此我们应将这些字段也添加属性,如下所示:

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
    private string gamerTag;
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
    private string emailAddress;
    

我们几乎完成了 ConnectViewModel 的实现。要实现的功能是处理可能从 GameService 收到的消息。CommunityToolkitObservableObject 实现提供了一个使订阅和取消订阅这些消息变得简洁的功能。要实现消息处理程序,请按照以下步骤操作:

  1. ConnectViewModel 类中添加一个名为 OnServiceError 的新 private 方法,使用以下代码片段:

    private void OnServiceError(AsyncError error)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            await Shell.Current.CurrentPage.DisplayAlert("There is a problem...", error.Message, "Ok");
        });
    }
    
  2. 我们将从 ObservableObject 类的 OnActivated 事件方法订阅 ServiceError 消息。将以下方法添加到 ConnectViewModel 类中,以订阅 ServiceError 消息:

    protected override void OnActivated() => Messenger.Register<ServiceError>(this, (r, m) => OnServiceError(m.Value));
    
  3. 要取消订阅 ServiceError 消息,请将以下方法添加到 ConnectViewModel 类中:

    protected override void OnDeactivated() => Messenger.Unregister<ServiceError>(this);
    
  4. ConnectViewModel 的构造函数中,我们需要启用由 ObservableObject 触发的 OnActivatedOnDeactivated 事件。这些事件是推荐订阅和取消订阅消息的地方。将以下代码行添加到构造函数的末尾以启用事件:

    IsActive = true;
    

    IsActive 设置为 true 将触发 OnActivated 事件。将其设置为 false 将触发 OnDeactivated 事件。

  5. 要触发 OnDeactivated 事件,我们需要将 IsActive 设置为 false。在 Connect 方法中添加以下突出显示的代码行:

    private async Task<Player> Connect(Player player)
    {
        // Get SignalR Connection
        var playerUpdate = await gameService.Connect(player);
        if (gameService.IsConnected)
        {
            IsActive = false;
            // If the player has an in progress match, take them to it.
            if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
            {
                await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
            }
            else
            {
                await Shell.Current.GoToAsync($"///Lobby");
            }
        }
        return playerUpdate;
    }
    
  6. SticksAndStones.App 项目的 MauiProgram.cs 文件中打开,并将以下突出显示的行添加到注册 ConnectViewModel 以进行依赖注入:

    builder.Services.AddSingleton<Services.GameService>();
    builder.Services.AddTransient<ViewModels.ConnectViewModel>();
    return builder.Build();
    

这完成了 ConnectViewModel 的实现。ConnectViewModel 类控制用户游戏标签和电子邮件的输入。它使用玩家的详细信息将用户连接到游戏服务器。

添加连接视图

Connect 视图看起来相当简单,但其中有很多内容。我们将创建视图的创建分为三个部分:

  • 创建 ActivityButton 控件:

    ActivityButton控件是用于启动与后端服务连接的按钮。虽然一个简单的按钮可能可以完成任务,但如果有一个动画指示connect操作正在进行,并且按钮的文本也更新了会怎样?这正是ActivityButton将要做的,在一个可重用的控件中。

  • 创建图片:

    在这个页面上使用了一些图片。所有这些图片都是使用 AI 生成的。我们将探讨这是如何完成的。

  • 构建视图:

    这是我们将ActivityButton与我们的自定义图片和.NET MAUI 的内置控件结合起来,使ConnectView看起来像图中的样子。

让我们从构建ActivityButton控件开始。

创建 ActivityButton 控件

那么,这个ActivityButton控件是什么?它基本上是一个带有ActivityIndicator的按钮,只有在按钮背后的任务正在工作时才会显示出来。这个控件复杂性的来源在于我们正在制作一个通用控件而不是一个专用控件。因此,从所有目的和用途来看,它需要表现得像一个正常的ButtonActivityIndicator。我们只为这个应用程序实现所需的特性,即便如此,它仍然是一个可重用的控件。

Button,我们希望有以下 XAML 属性:

  • TextFontFamilyFontSize

  • CommandCommandParameter

ActivityIndicator,我们将有IsRunning

这些 XAML 元素将像它们的原始属性一样可绑定。以下是一个示例,说明如何声明这个控件作为一个元素:

<controls:ActivityButton IsRunning="{Binding IsConnecting}" 
                         Text="{Binding ConnectStatus}" 
                         BackgroundColor="#e8bc65" 
                         Command="{Binding ConnectCommand}" 
                         HorizontalOptions="Center"
                         WidthRequest="200"
                         HeightRequest="48"/>

此列表来自本节稍后我们将创建的ConnectView.xaml的实际 XAML。

从两个底层控件复制的属性需要能够绑定到视图模型。这要求它们被实现为绑定属性。要创建一个绑定属性,你需要两样东西:一个属性和一个引用该属性的BindablePropertyBindableProperty提供了保持实现INotifyPropertyChanged的视图模型属性与控件属性的功能。以下是一个创建Command绑定属性的示例:

  1. SticksAndStones.App项目中创建一个名为Controls的新文件夹。

  2. 添加一个新的.NET MAUI ContentView(XAML)名为ActivityButton

  3. 打开ActivityButton.xaml.cs文件。

  4. 创建一个名为Commandpublic ICommand属性,如下所示:

    public ICommand Command
    {
        get => (ICommand)GetValue(CommandProperty);
        set { SetValue(CommandProperty, value); }
    }
    

    BindableProperty属性与它们所绑定到的属性之间存在循环引用,因此直到我们完成下一步,你将得到红色的波浪线。这看起来几乎与我们所创建的每个属性都一样,除了getset方法只是分别委托给GetValueSetValue方法。GetValueSetValueBindableObject类提供,ContentView最终从这个类继承。GetValueSetValue是视图模型中INotifyPropertyChanged的等价物。调用它们不仅存储值,还会发送通知,表明值已更改。

  5. 现在,为Command属性添加BindableProperty属性,使用以下代码片段:

    public static readonly BindableProperty CommandProperty = BindableProperty.Create(
        propertyName: nameof(Command),
        returnType: typeof(ICommand),
        declaringType: typeof(ActivityButton),
        defaultBindingMode: BindingMode.TwoWay);
    

    CommandPropertyBindableProperty类型,并使用BindableProperty类的Create工厂方法创建。我们传入我们正在绑定的属性的名称(Command),该属性返回的类型(Icommand),声明类型(在这种情况下是ActivityButton),然后是我们想要绑定的模式。BindingMode有四个选项:

    • OneWay —— 默认选项,将更改从源(视图模型)传播到目标(控件)

    • OneWayToSource —— 这是OneWay的反向,将更改从目标(控件)传播到源(视图模型)

    • TwoWay —— 这在两个方向上传播更改

    • OneTime —— 仅当BindingContext更改时传播更改,并忽略所有INotifyPropertyChanged事件

    这两个组件——你将在大多数 C#类中使用的一般属性,以及BindableProperty——提供了我们创建自定义控件所需的所有功能。

现在我们已经了解了如何在 XAML 控件上实现BindableProperty,我们可以完成ActivityButton的实现。

让我们先更新 XAML,然后我们将继续剩余的BindableProperty实现。以下步骤将指导你创建控件:

  1. 我们选择的用于创建 XAML 和.cs文件的模板并不完全符合我们为ActivityButton的需求。我们需要将底层根控件从ContentView更改为Frame。我们使用Frame来用边框包裹我们的布局。打开ActivityButton.cs文件,并更新类定义以从ContentView继承到Frame,如下所示:

    public partial class ActivityButton : ActivityButton.xaml file and modify it to look like the following:
    
    

    <Frame

    x:Class="SticksAndStones.Controls.ActivityButton">

    </ContentView to Frame,同时删除 Frame 的内容,因为我们不会重新使用它。

    
    
  2. 让我们给我们的控件命名,以便以后更容易引用它。通常,在 C#中,如果你想引用类的实例,你会使用this关键字。在 XAML 中默认不存在this,所以添加x:Name属性并使用this的值来模拟 C#。

  3. 更新 Frame 元素并添加 BackgroundColor 属性,其值为 {x:StaticResource Primary}PrimaryResources/Styles/Colors.xaml 文件中定义,我们可以使用 StaticResource 扩展方法来引用它。

  4. 更新 Frame 元素并添加 CornerRadius 属性,其值为 5。这将给我们的按钮带来圆角。

  5. Padding 属性的值设置为 12 添加到 Frame 元素。这将确保在控件周围有足够的空白。Frame 元素现在应该看起来像以下:

    <?xml version="1.0" encoding="utf-8" ?>
    <Frame 
    
                 x:Class="SticksAndStones.Controls.ActivityButton"
            x:Name="this"
            BackgroundColor="{x:StaticResource Primary}"
            CornerRadius="5"
            Padding="12">
    </Frame>
    
  6. 要在 Frame 中使 ActivityIndicatorLabel 侧边对齐,我们将使用包含在 VerticalStackLayout 中的 HorizontalStackLayoutStackLayout 控件忽略控制方向的对齐选项,例如,VerticalStackLayout 忽略其子控件的 VerticalOptions 属性,而 HorizontalStackLayout 忽略其子控件的 HorizontalOptions 属性。这是因为,根据其本质,HorizontalStackLayout 控制在水平平面上布局其子控件,同样,VerticalStackLayout 也是如此,只是在垂直平面上。将以下突出显示的代码添加到 XAML 中:

    <Frame 
    
                 x:Class="SticksAndStones.Controls.ActivityButton"
            BackgroundColor="{x:StaticResource Primary}"
            CornerRadius="5"
            Padding="12">
        <VerticalStackLayout>
            <HorizontalStackLayout HorizontalOptions="CenterAndExpand" Spacing="10">
            </HorizontalStackLayout>
        </VerticalStackLayout>
    </Frame>
    
  7. HorizontalStackLayout 元素内,添加以下 XAML:

    <ActivityIndicator HeightRequest="15" WidthRequest="15"
                       Color="{x:StaticResource White}" 
                       IsRunning="{Binding Source={x:Reference this},Path=IsRunning}"
                       IsVisible="{Binding Source={x:Reference this},Path=IsRunning}"
                       VerticalOptions="CenterAndExpand"/>
    

    ActivityIndicator 将具有 HeightWidth 值为 15Color 值为 WhiteIsRunningIsVisible 属性绑定到控件的 IsRunning 属性。我们尚未创建 IsRunning 属性,因此这不会工作,直到我们这样做。x:Reference 标记扩展允许我们将属性绑定到父控件,我们在 步骤 3 中将其命名为 this

  8. 现在,我们可以使用以下 XAML 在 HorizontalStackLayout 内添加 Label

    <Label x:Name="buttonLabel" TextColor="{x:StaticResource White}" 
           Text="{Binding Source={x:Reference this},Path=Text}" 
           FontSize="15"
           VerticalOptions="CenterAndExpand"
           VerticalTextAlignment="Center" 
           HorizontalTextAlignment="Start" />
    
  9. 当用户在 Frame 的任何地方点击或轻触时,应运行 Command。为此,我们将使用 GestureRecognizerGestureRecognizer 是 XAML 提供事件处理程序的方式。有几种不同类型的 GestureRecognizer

    • DragGestureRecognizerDropGestureRecognizer

    • PanGestureRecognizer

    • PinchGestureRecognizer

    • PointerGestureRecognizer

    • SwipeGestureRecognizer

    • TapGestureRecognizer

    对于 ActivityButton,我们关注的是 TapGestureRecognizer。由于在此控件在视图中使用之前,要执行的操作尚未定义,因此当 Frame 被点击时,TapGestureRecognizer 将调用一个命令。将以下 XAML 添加到 Frame 元素以创建 TapGestureRecognizer

    <Frame.GestureRecognizers>
        <TapGestureRecognizer Command="{Binding Source={x:Reference this},Path=Command}" CommandParameter="{Binding Source={x:Reference this},Path=CommandParameter}" />
    </Frame.GestureRecognizers>
    

    TapGestureRecognizerCommand 属性和 CommandParameter 属性设置为绑定到父控件的 CommandCommandParameter 属性。

    如果 IsRunning 属性为 true,则 Frame 应该被禁用,反之亦然。DataTrigger 是一种 XAML 方法,用于根据另一个控件属性的变化设置一个控件的属性。要为 Frame 添加触发器,请将突出显示的 XAML 添加到控件:

    <Frame.Triggers>
        <DataTrigger TargetType="Frame" Binding="{Binding Source={x:Reference this},Path=IsBusy}" Value="True">
            <Setter Property="IsEnabled" Value="False" />
        </DataTrigger>
        <DataTrigger TargetType="Frame" Binding="{Binding Source={x:Reference this},Path=IsBusy}" Value="False">
            <Setter Property="IsEnabled" Value="True" />
        </DataTrigger>
    </Frame.Triggers>
    
  10. 这就完成了控件的 XAML 部分。打开ActivityButton.xaml.cs文件,我们可以添加缺失的属性,从CommandParameter开始:

    public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(
        propertyName: nameof(CommandParameter),
        returnType: typeof(object),
        declaringType: typeof(ActivityButton),
        defaultBindingMode: BindingMode.TwoWay);
    public object CommandParameter
    {
        get => GetValue(CommandParameterProperty);
        set { SetValue(CommandParameterProperty, value); }
    }
    

    将前面的代码列表添加到ActivityButton类中。除了名称外,这个属性与Command属性没有显著的不同。CommandParameter允许你通过 XAML 指定传递给Command的参数。

  11. Label控件是从Text属性填充的。要添加Text属性,请将以下代码添加到ActivityButton类中:

    public static readonly BindableProperty TextProperty = BindableProperty.Create(
        propertyName: nameof(Text),
        returnType: typeof(string),
        declaringType: typeof(ActivityButton),
        defaultValue: string.Empty,
        defaultBindingMode: BindingMode.TwoWay);
    public string Text
    {
        get => (string)GetValue(TextProperty);
        set { SetValue(TextProperty, value); }
    }
    

    Text属性的情况下,returnType已更改为string,但除此之外,它与CommandCommandParameter类似。

  12. 我们接下来需要实现的是IsRunning属性,如下所示:

    public static readonly BindableProperty IsRunningProperty = BindableProperty.Create(
        propertyName: nameof(IsRunning),
        returnType: typeof(bool),
        declaringType: typeof(ActivityButton),
        defaultValue: false);
    public bool IsRunning
    {
        get => (bool)GetValue(IsRunningProperty);
        set { SetValue(IsRunningProperty, value); }
    }
    
  13. 为了允许更改文本的大小和字体,我们实现了FontSizeFontFamily属性:

    public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create(
        propertyName: nameof(FontFamily),
        returnType: typeof(string),
        declaringType: typeof(ActivityButton),
        defaultValue: string.Empty,
        defaultBindingMode: BindingMode.TwoWay);
    public string FontFamily
    {
        get => (string)GetValue(Label.FontFamilyProperty);
        set { SetValue(Label.FontFamilyProperty, value); }
    }
    public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(
        nameof(FontSize),
        typeof(double),
        typeof(ActivityButton),
        Device.GetNamedSize(NamedSize.Small, typeof(Label)),
        BindingMode.TwoWay);
    public double FontSize
    {
        set { SetValue(FontSizeProperty, value); }
        get { return (double)GetValue(FontSizeProperty); }
    }
    

完成了ActivityButton。在我们创建游戏所需的图像之后,我们将立即在创建连接视图部分使用ActivityButton

使用 Bing 图片创建器创建图像

游戏中使用了几个图像。它们如下所示:

  • 一根水平的棍子

  • 一根垂直的棍子

  • 一堆石头

创建这些图像可能相当耗时,并且根据你的艺术能力,可能并不完全符合你的预期。对于你的应用程序,你可能选择雇佣图形设计师或艺术家为你创建数字资产。最近,出现了一个新的选项,那就是使用 AI 生成图像。在本节中,我们将探讨如何使用Bing Image Creator来创建游戏所需的图像。

Bing 图片创建器使用你想要看到的场景的英文描述,并尝试创建它。你可以使用一些关键词来指导图片创建器以创建图像的艺术风格,例如游戏艺术数字艺术逼真

让我们通过创建棍子图像开始:

  1. 在 Microsoft Edge 或你喜欢的网页浏览器中打开bing.com/create

  2. 如果需要,请使用你的 Microsoft 账户登录。这可以是你用于在第九章中登录 Azure 门户的相同账户。

  3. 在提示框中,输入以下提示,然后按创建

    A single wood stick, positioned horizontally, with five stubs where branches would be and no leaves, no background, game art
    

    Image Creator 将根据你的描述生成四张不同的图像。如果你对结果不满意,可以稍微调整描述并再次尝试。描述越详细,结果越好。尽量创建一个几乎垂直或水平的棍子,因为它将更容易旋转和裁剪图像。如果它是在明亮的白色背景上,看起来也会更好。

图 10.7 – Image Creator 的图像样本集

图 10.7 – Image Creator 的图像样本集

  1. 一旦你有一个满意的图片,点击图片以打开它。

  2. 点击下载按钮将图片保存到你的本地计算机。

  3. 现在,在您喜欢的图像编辑器中打开下载的文件。Image Creator 创建的图像大约是 1024 x 1024,理想情况下,图像应该是 3:9 的比例,或者大约 300 x 900 像素。使用您的图像编辑器工具,裁剪图像,使其大约为 300 x 900 像素。

  4. 将图像保存为 hstick.jpeg,如果棍子是水平放置,或者保存为 vstick.jpeg,如果棍子是垂直放置,在 SticksAndStones.App 项目的 Resources/Images 文件夹中。

  5. 使用相同的图像编辑工具,将图像旋转 90 度,使其方向相反,并将图像保存到 Resources/``I``mages 文件夹中,如果棍子现在是水平放置,则保存为 hstick.jpeg,如果是垂直放置,则保存为 vstick.jpeg

我们几乎完成了所需创建的一半图像。让我们接下来创建石头:

  1. 在 Microsoft Edge 或您喜欢的网页浏览器中打开 bing.com/create

  2. 如果需要,使用您的 Microsoft 账户登录。这可以与您在 第九章 中用于登录 Azure 门户的同一账户相同。

  3. 在提示框中,输入以下提示,然后按 创建

    3 grey stones, arranged closely together, no background, game art
    
  4. 通过处理提示,得到三块石头整齐堆叠在一起,最好是在白色背景上,如图所示:

图 10.8 – 白色背景上的三块石头

图 10.8 – 白色背景上的三块石头

  1. 当您对生成的图像满意时,点击图像以打开它。

  2. 点击 下载 按钮将图像保存到您的本地计算机。

  3. 现在,在您喜欢的图像编辑器中打开下载的文件。由于石头应该是方形图像,我们只需将文件保存到 Resources/Images 文件夹中,文件名为 stones.jpeg

没有图像编辑器?

没有您喜欢的图像编辑器?如果您使用的是 Windows,Paint 可以很好地完成这项工作,或者您可以使用 Visual Studio 编辑图像。在 macOS 上,您可以使用预览。

太好了,我们现在有了玩游戏所需的棍子和石头,这也标志着 Image Creator 生成游戏图像的使用结束。您总是可以返回网站并查看以前的结果,这是一个很好的功能。现在,我们可以继续创建 Connect 视图。

创建连接视图

Connect 视图是用户在应用程序中除了启动画面外将看到的第一个 UI。图 10**.6 提供了最终视图的表示。如果您决定生成自己的图像,图像可能会有所不同。我们将把这个部分分为三个部分。首先,我们将创建包含静态内容的视图顶部部分,然后继续创建包含输入控制的视图中间部分,最后,创建 连接 按钮。让我们通过以下步骤开始创建视图的顶部部分:

  1. SticksAndStones.App 项目中创建一个名为 Views 的文件夹。

  2. 右键单击 Views 文件夹,选择 添加,然后点击 新建项...

    如果您正在使用 Visual Studio 17.7 或更高版本,请点击弹出对话框中的 显示所有模板 按钮;否则,转到下一步。

  3. 在左侧的 C# Items 节点下,选择 .NET MAUI

  4. 选择 ConnectView.xaml

  5. 点击 添加 创建页面。

    参考以下截图查看上述信息:

图 10.9 – 添加新的 .NET MAUI ContentPage (XAML)

图 10.9 – 添加新的 .NET MAUI ContentPage (XAML)

  1. 将视图的标题更改为 Sticks & Stones。由于 XAML 是 XML 的方言,字符串中的 & 必须转义为 &amp;

    将以下突出显示的命名空间添加到 ContentView 元素中。它们将为我们提供访问 ViewModelsControlsToolkit 命名空间中的类的能力:

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
                  xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
                 x:Class=“SticksAndStones.Views.ConnectView”
                 Title=“Sticks and Stones”>
    
  2. 为了让 IntelliSense 对我们将要添加的绑定感到满意,通过在 ContentView 元素中添加 x:DataType 属性来定义视图所使用的视图模型,如下所示:

    <ContentPage  xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
                 xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
                 x:Class=“SticksAndStones.Views.ConnectView”
            x:DataType=“viewModels:ConnectViewModel”
            Title=“Sticks and Stones”>
    
  3. 我们不希望用户使用任何导航,例如 Shell 提供的 Back 按钮,除了本页提供的之外,因此请使用以下列表中突出显示的代码禁用它:

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
                 xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
                 x:Class=“SticksAndStones.Views.ConnectView”
            x:DataType=“viewModels:ConnectViewModel”
            Title=“Sticks and Stones”
            BackgroundColor of the entire view to White, which will make the images blend better, by adding the following highlighted code:
    
    

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”

    xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”

    xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”

    xmlns:controls=“clr-namespace:SticksAndStones.Controls”

    xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”

    x:Class=“SticksAndStones.Views.ConnectView”

    x:DataType=“viewModels:ConnectViewModel”

    Title=“Sticks & Stones”

    NavigationPage.HasNavigationBar=“False”

    定义了四行的 Grid 控制器;在 ContentPage 元素内添加以下代码:

    <Grid Margin=“40”>
        <Grid.RowDefinitions>
            <RowDefinition Height=“8*”/>
            <RowDefinition Height=“2*”/>
            <RowDefinition Height=“8*”/>
            <RowDefinition Height=“1*”/>
        </Grid.RowDefinitions>
    </Grid>
    

    Grid 使用 40Margin 值来为图像和控制提供足够的空白。第 8 单位的第 1 行将包含应用的标志。第 2 行将包含文本 Sticks & Stones。第 3 行将包含头像图像、电子邮件和游戏标签输入控件。最后一行将包含 Connect 按钮。记住,Height 单位是相对的,所以第 0 行,即第一行,将是第 1 行的四倍高,是第 3 行,即最后一行的八倍高。Height 值中的 * 符号表示如果需要,该行可以扩展。

    
    
  4. 为了将生成的图像排列成类似盒子的布局,我们使用另一个 Grid 控制器。在 </Grid.RowDefinitions></Grid> 标签之间添加以下列表:

    <Grid Grid.Row=“0” WidthRequest=“150” HeightRequest=“150”>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width=“1*” />
            <ColumnDefinition Width=“5*” />
            <ColumnDefinition Width=“1*” />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height=“1*” />
            <RowDefinition Height=“4*” />
            <RowDefinition Height=“1*” />
        </Grid.RowDefinitions>
        <Image Grid.Row=“0” Grid.Column=“1” Source=“hstick.jpeg” Aspect=“Fill”/>
        <Image Grid.Row=“1” Grid.Column=“0” Source=“vstick.jpeg” Aspect=“Fill”/>
        <Image Grid.Row=“1” Grid.Column=“1” Source=“stones.jpeg” Aspect=“AspectFit”/>
        <Image Grid.Row=“1” Grid.Column=“2” Source=“vstick.jpeg” Aspect=“Fill”/>
        <Image Grid.Row=“2” Grid.Column=“1” Source=“hstick.jpeg” Aspect=“Fill”/>
    </Grid>
    

    这个 Grid 控件定义了三行和三列。Grid 的内容完全由 Image 控件组成。Grid 位于其父 Grid 的行 0。网格的子控件,即 Image 控件,通过在 Image 控件上设置 Grid.RowGrid.Column 属性来定位。棍子图像使用设置为 FillAspect 属性。Fill 允许图像缩放以完全填充内容区域;为此,它可能不会在 xy 轴上均匀缩放。石头使用 Aspect 值为 AspectFit。这将均匀缩放图像,直到至少一边适合,这可能会导致信封式显示。Aspect 属性还有两个其他选项:Center,它不进行缩放,和 AspectFill,它将缩放直到两个轴都填满视图,这可能会导致裁剪。

  5. 在外部的 Grid 控件中添加一个包含文本 Connect to Sticks & StonesLabel 元素,并将其放置在行 1,即 Grid 的第二行。在 步骤 11 中添加的内部 Grid 控件之后,将以下代码添加到外部的 Grid 控件中:

    <Label Grid.Row="1" Text="Connect to Sticks &amp; Stones" FontSize="20" TextColor="Black" FontAttributes="Bold" Margin="0,0,0,20" HorizontalOptions="Center"/>
    
  6. 页面的下一部分包含头像图片、游戏标签输入和电子邮件输入控件。使用 HorizontalStackLayoutVerticalStackLayout 控件来排列控件。在 步骤 12 中添加的 Label 控件之后,将以下代码片段添加到外部的 Grid 控件中:

    <HorizontalStackLayout Grid.Row="2" HorizontalOptions="Center">
        <VerticalStackLayout Spacing="10" > 
            <Image HeightRequest="96" WidthRequest="96" BackgroundColor="LightGrey">
                <Image.Source>
                    <toolkit:GravatarImageSource
                        Email="{Binding EmailAddress}"
                        Image="MysteryPerson" />
                </Image.Source>
            </Image>
        </VerticalStackLayout>
        <VerticalStackLayout Spacing="10" >
            <Entry Placeholder="username" Keyboard="Email" Text="{Binding Username}" HorizontalTextAlignment="Start" HorizontalOptions="FillAndExpand"/>
            <Entry Placeholder="user@someaddress.com" Keyboard="Email" Text="{Binding EmailAddress}" HorizontalTextAlignment="Start" HorizontalOptions="FillAndExpand"/>
        </VerticalStackLayout>
    </HorizontalStackLayout>
    

    HorizontalStackLayout 控件被分配到 Grid 的第二行,即第三行。它在该行内水平居中。第一个 VerticalStackLayout 安排了组成头像的控件。它包含一个 Image 元素,其源设置为 GravatarImageSource 的实例。GravatarImageSource 使用 ConnectViewModelEmailAddress 属性并将其绑定到 GravatarImageSourceEmail 属性。当 EmailAddress 发生变化时,图片将自动更新。Image 属性使用 MysteryPerson 值在电子邮件地址没有可用的 Gravatar 时提供一个简单的个人资料。第二个 VerticalStackLayout 包含两个 Entry 控件:第一个,用于游戏标签,绑定到 ConnectViewModelUsername 属性,第二个绑定到 ConnectViewModelEmailAddress 属性。Keyboard 属性确定当焦点在控件上时显示哪个虚拟键盘。有关自定义键盘的更多信息,请参阅 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/entry#customize-the-keyboard

  7. 要添加到 ConnectView 的最后一个控件是 Connect 按钮。使用以下代码片段将按钮添加到视图中:

    <controls:ActivityButton Grid.Row="3" 
                             IsRunning="{Binding IsConnecting}" 
                             Text="{Binding ConnectStatus}" 
                             BackgroundColor="#e8bc65" 
                             Command="{Binding ConnectCommand}" 
                             HorizontalOptions="Center" 
                             WidthRequest="200" 
                             HeightRequest="48"/>
    

    “连接”按钮使用在 创建 ActivityButton 控件 中创建的 ActivityButton 控件。控件位于第 3 行,第四行,IsRunning 属性绑定到 ConnectViewModel.IsConnecting 方法。按钮的 Text 属性绑定到 ConnectViewModel.ConnectStatus 属性,最后,Command 绑定到 ConnectViewModel.Connect 方法。

  8. SticksAndStones.App项目中打开 MauiProgram.cs 文件,并将以下高亮行添加以使用依赖注入注册 ConnectView

    builder.Services.AddSingleton<Services.GameService>();
    builder.Services.AddTransient<ViewModels.ConnectViewModel>();
    builder.Services.AddTransient<Views.ConnectView>();
    return builder.Build();
    
  9. 现在,我们需要通过依赖注入消耗 ConnectViewModel 实例并将其设置为绑定对象。打开 ConnectView.Xaml.cs 文件并按以下方式修改:

    using SticksAndStones.ViewModels;
    namespace SticksAndStones.Views;
    public partial class ConnectView : ContentPage
    {
        public ConnectView(ConnectViewModel viewModel)
        {
            this.BindingContext = viewModel;
            InitializeComponent();
        }
    }
    
  10. 最后,我们需要将 ConnectView 设置为第一个显示的视图。在 SticksAndStones.App 项目中打开 AppShell.xaml 文件,并更新 Shell 元素的内容,如所示:

    <Shell
        x:Class="SticksAndStones.App.AppShell"
    
        Shell.FlyoutBehavior="Disabled">
        <ShellItem Route="Connect">
            <ShellContent ContentTemplate="{DataTemplate views:ConnectView}" />
        </ShellItem>
    </Shell>
    

应用程序中的三个视图中的第一个已经完成。要测试它,请按照以下步骤操作:

  1. 在 Visual Studio 中,在 解决方案资源管理器 中右键单击 SticksAndStones.Functions 项目,然后选择 调试 | 不调试启动

  2. SticksAndStones.App 项目中,选择 设置为 启动项目

  3. F5 启动 SticksAndStones.App 项目,使用调试器。

“大厅”页面,它将允许我们与其他玩家开始游戏。

创建大厅页面

“大厅”页面显示已连接玩家的列表,并允许玩家向其他玩家发起比赛挑战。随着更多玩家连接到服务器,他们将被添加到可用玩家列表中。图 10.10 显示了页面的两个视图,一个包含已连接玩家,另一个在没有其他玩家连接时为空视图。

图 10.10 – 大厅视图

图 10.10 – 大厅视图

每个玩家都显示在一个卡片上,其中包含他们的头像图像、游戏标签、状态以及一个按钮,允许玩家向其他玩家发起比赛挑战。

此页面由两个 ViewModel 类组成,而不是一个。正如你可能预期的,有一个 LobbyViewModel 类,该类有一个 PlayerViewModel 实例的集合。除了 ViewModel 类之外,还有一个 LobbyView 类。让我们从创建 PlayerViewModel 类开始。

添加 PlayerViewModel

PlayerViewModel 与我们所有的其他 ViewModel 类非常相似,但有一点细微的区别:它没有直接绑定到视图。否则,它具有相同的目的:抽象模型,在这种情况下是 Player,从显示它的 UI 中分离出来。PlayerViewModel 提供了显示每个单个玩家卡片在 LobbyView 中所需的所有绑定属性。要添加 PlayerViewModel,请按照以下步骤操作:

  1. SticksAndStones.App 项目中,在 ViewModels 文件夹下创建一个名为 PlayerViewModel 的新类。

  2. 将以下命名空间添加到文件顶部:

    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    using SticksAndStones.Models;
    using SticksAndStones.Services;
    
  3. 将以下代码添加到类中:

    private readonly Player playerModel;
    private readonly GameService gameService;
    public PlayerViewModel(Player player, GameService gameService)
    {
        playerModel = player;
        this.gameService = gameService;
    }
    

    这将为构造函数传入的参数值添加两个 private 字段。与 ConnectViewModel 类似,构造函数的参数由依赖注入提供。

  4. 玩家卡片显示玩家的游戏标签、头像和状态。将以下代码添加到 PlayerViewModel 类中,以添加 IdGamerTag 属性:

    public Guid Id => playermodel.Id;
    public string GamerTag => playerModel.GamerTag;
    

    对于 PlayerViewModel,我们绑定的一些属性并没有使用 ObservablePropertyAttribute 实现。这是因为我们直接从 Player 模型提供它们的值。因此,属性的 get 方法只是返回模型对象的相应属性。没有定义的 set 方法,所以这个属性本质上是一个单向数据绑定。

  5. Status 属性有一点不同,因为它不在我们的 Player 模型上存在。Status 属性是玩家是否在比赛中的文本指示。Player 模型确实有一个 MatchId 属性,所以如果 Player 模型有一个有效的 MatchId(即,不是 Guid.Empty),则状态将是 "In a match";否则,该状态将是 "Waiting for opponent"。将以下代码添加到 PlayerViewModel 以实现 Status 属性:

    public bool IsInMatch => !(playerModel.MatchId == Guid.Empty);
    public string Status => IsInMatch switch
    {
        true => "In a match",
        false => "Waiting for opponent"
    };
    

    IsInMatch 属性用于简化 Status 属性的实现。它也将在类中稍后使用。Status 属性是一个简单的基于 IsInMatch 的开关,并返回适当的 string 值。

  6. 要添加一个处理 Challenge 按钮的命令,将以下代码添加到 PlayerViewModel 类中:

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ChallengeStatus))]
    private bool isChallenging = false;
    public string ChallengeStatus => IsChallenging switch
    {
        true => "Challenging...",
        false => "Challenge"
    };
    public bool CanChallenge => !IsInMatch && !IsChallenging;
    [RelayCommand(CanExecute = nameof(CanChallenge))]
    public void Challenge(PlayerViewModel opponent)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            IsChallenging = true;
            bool answer = await Shell.Current.CurrentPage.DisplayAlert("Issue Challenge!", $" You are about to challenge {GamerTag} to a match!\nAre you sure?", "Yes", "No");
            if (answer)
            {
                await gameService.IssueChallenge(opponent.Player);
            }
            IsChallenging = false;
        });
        return;
    }
    

    当命令正在等待挑战响应时,它会阻止执行,这是有道理的——没有必要催促其他玩家。在挑战期间,IsChallenging 属性设置为 true,完成时设置为 falseCanChallenge 属性是 IsInMatchIsChallenging 的组合,这意味着你不能在有现有挑战进行时挑战同一玩家,也不能挑战已经与其他玩家进行比赛的玩家。用作按钮文本的 ChallengeStatus 绑定到 IsChallenging 值,并在该属性更新时更新。你可能已经注意到我们的命令只接受一个参数。这是用来操作正确玩家的。

这完成了 PlayerViewModel 的实现。接下来,使用 LobbyViewModel 来封装 PlayerViewModel 对象集合。

添加 LobbyViewModel

LobbyViewModel 的实现相当直接。它有一个暴露给 UI 的 PlayerViewModel 对象集合,允许用户下拉刷新视图,并处理 ChallengeReceivedMatchStartedServiceError 消息。按照以下步骤实现 LobbyViewModel

  1. SticksAndStones.App 项目中,在 ViewModels 文件夹内,创建一个名为 LobbyViewModel 的新类。

  2. 将以下命名空间添加到文件顶部:

    using System.Collections.ObjectModel;
    using System.Collections.Specialized;
    using CommunityToolkit.Mvvm.Messaging;
    using SticksAndStones.Messages;
    using SticksAndStones.Models;
    using SticksAndStones.Services;
    
  3. 将类声明修改为从 ViewModelBase 继承的 public partial 类,如下所示:

    LobbyViewModel class:
    
    

    private readonly GameService gameService;

    public ObservableCollection Players { get; init; }

    public LobbyViewModel(GameService gameService)

    {

    this.gameService = gameService;

    Players = new(from p in gameService.Players

    where p.Id != gameService.CurrentPlayer.Id

    select new PlayerViewModel(p, gameService));

    CanRefresh = true;

    IsActive = true;

    }

    
    `LobbyViewModel` receives an instance of `GameService` via dependency injection. The `gameService` instance is used to initialize the `Players` list. The `Players` property from the `GameService` class is a collection of the `Player` model, whereas `Players` in `LobbyViewModel` is an `ObservableCollection` `instance` of `PlayerViewModel`. We use `ObservableCollection` because it provides support for `INotifyPropertyChanged` and `INotifyCollectionChanged` when it is bound automatically. A LINQ query is used to get all the current players and add them to the `Players` `ObservableCollection`. `CanRefresh` from `ViewModelBase` is set to `true`, which enables `RefreshCommand`. Finally, `IsActive` is set to `true`, which enables the `OnActivated` and `OnDeactivated` events.
    
  4. 随着 GameService.Players 列表中的玩家连接到服务器,该列表将更新。然而,这些更改不会自动传播到 LobbyViewModel.Players 集合。通过实现 GameService.Players 属性的 CollectionChanged 事件的处理程序,我们可以相应地更新 LobbyViewModel.Players 集合。向 LobbyViewModel 类添加以下方法:

    private void OnPlayersCollectionChanged(object? sender, 
    NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            foreach (var player in e.NewItems.Cast<Player>())
            {
                Players.Add(new PlayerViewModel(player, gameService));
            }
        }
        else if (e.Action == NotifyCollectionChangedAction.Remove)
        {
            foreach (var player in e.OldItems.Cast<Player>())
            {
                var toRemove = Players.FirstOrDefault(p => p.Id == player.Id);
                Players.Remove(toRemove);
            }
        }
        else if (e.Action == NotifyCollectionChangedAction.Replace)
        {
        }
        else if (e.Action == NotifyCollectionChangedAction.Reset)
        {
            Players.Clear();
        }
    }
    

    OnPlayersCollectionChanged 方法是 Notify CollectionChangedEventHandler 的实现。它由 Observable Collection.CollectionChanged 事件调用。每当集合中的项目被添加、删除或更新时,都会调用此事件。当整个集合被清除时,也会调用此事件。此方法处理 NotifyCollectionChangedActionAddRemoveReset 值。

  5. OnActivated 事件处理程序中,将 Players.CollectionChanged 事件分配给 OnPlayers CollectionChanged 方法。使用以下代码添加 OnActivatedOnDeactivated 方法:

    protected override void OnActivated()
    {
        gameService.Players.CollectionChanged += OnPlayersCollectionChanged;
        // If the player has an in progress match, take them to it.
        if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
        {
            MainThread.InvokeOnMainThreadAsync(async () =>
            {
                IsActive = false;
                await Shell.Current.GoToAsync(Constants.ArgumentNames.MatchId, new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
            });
        }
    }
    protected override void OnDeactivated()
    {
        gameService.Players.CollectionChanged -= OnPlayersCollectionChanged;
    }
    

    OnActivated 方法中,将 CollectionChanged 事件分配给 OnPlayersCollectionChanged 方法,并在 OnDeactivated 方法中取消分配。在 OnActivated 中,还有一个检查以查看玩家是否已经在比赛中。如果是,则应用程序立即导航到 Match 视图。在导航到 Match 视图时,我们发送一个 Match 参数。这将要么是 MatchId,要么是 Match 模型。

  6. 打开 SticksAndStones.Shared 项目的 Constants.cs 文件,将以下代码片段添加到 Constants 类中:

    public class ArgumentNames
    {
        public static readonly string Match = nameof(Match);
        public static readonly string MatchId = nameof(MatchId);
    }
    
  7. 在大厅中,有三个消息需要处理:ChallengeReceivedMatchStartedServerError。将以下代码添加到每个消息的处理程序中:

    private void OnChallengeReceived(Guid challengeId, Player opponent)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            bool answer = await Shell.Current.CurrentPage.DisplayAlert("You have been challenged!", $"{opponent.GamerTag} has challenged you to a match of Sticks & Stones, do you accept?", "Yes", "No");
            await gameService.SendChallengeResponse(challengeId, answer ? Models.ChallengeResponse.Accepted : Models.ChallengeResponse.Declined);
        });
    }
    private void OnMatchStarted(Match match)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            IsActive = false;
            await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { Constants.ArgumentNames.Match, match } });
        });
    }
    private void OnServiceError(AsyncError error)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            IsActive = false;
            await Shell.Current.CurrentPage.DisplayAlert("There is a problem...",error.Message, "Ok");
        });
    }
    

    OnChallengeReceived 中,用户被提示接受或拒绝挑战。然后,他们的响应通过 GameService 类的 SendChallengeResponse 方法发送给挑战者。OnMatchStarted 将用户导航到 Match 视图。最后,OnServiceError 将错误显示给用户。

  8. OnActivated 方法的顶部添加以下代码片段以注册接收消息:

    Messenger.Register<ChallengeRecieved>(this, (r, m) => OnChallengeReceived(m.Id, m.Value));
    Messenger.Register<MatchStarted>(this, (r, m) => OnMatchStarted(m.Value));
    Messenger.Register<ServiceError>(this, (r, m) => OnServiceError(m.Value));
    
  9. OnDeactivated 方法的末尾添加以下代码片段以停止接收消息:

    Messenger.Unregister<ChallengeRecieved>(this);
    Messenger.Unregister<MatchStarted>(this);
    Messenger.Unregister<ServiceError>(this);
    
  10. 当用户在 UI 中下拉列表时刷新 Players 列表,请向 LobbyViewModel 类添加以下方法:

    protected override async Task RefreshInternal()
    {
        await gameService.RefreshPlayerList();
        return;
    }
    
  11. LobbyViewModel 需要使用依赖注入进行注册,因此打开 MauiProgram.cs 文件并添加以下高亮显示的代码行:

    builder.Services.AddTransient<ViewModels.ConnectViewModel>();
    builder.Services.AddTransient<ViewModels.LobbyViewModel>();
    builder.Services.AddTransient<Views.ConnectView>();
    

LobbyViewModel 现在已经完成,现在是时候创建视图了!

添加大厅视图

The Lobby 视图简单地显示连接玩家的列表,包括他们的头像、游戏标签和当前状态。要构建 LobbyView,请按照以下步骤操作:

  1. 右键单击 SticksAndStone.App 项目的 Views 文件夹,选择 添加,然后点击 新建项...

    如果您正在使用 Visual Studio 17.7 或更高版本,请点击弹出对话框中的 显示所有模板 按钮;否则,转到下一步。

  2. 在左侧的 C# 项 节点下,选择 .****NET MAUI

  3. 选择 LobbyView.xaml

  4. 点击 添加 创建页面。

    参考以下截图查看前面的信息:

图 10.11 – 添加新的 .NET MAUI 内容页 (XAML)

  1. 打开 LobbyView.xaml.cs 文件并添加以下 using 声明:

    using SticksAndStones.ViewModels;
    
  2. 对构造函数进行以下高亮显示的更改:

    public LobbyView(LobbyViewModel viewModel)
    {
        this.BindingContext = viewModel;
        InitializeComponent();
    }
    

    这些更改允许依赖注入提供 LobbyViewModel 实例给视图,然后将其分配给 BindingContext

  3. 打开 AppShell.xaml 文件并将以下代码片段添加到 ContentPage 元素中:

    <ShellItem Route="Lobby">
        <ShellContent ContentTemplate="{DataTemplate views:LobbyView}" />
    </ShellItem>
    

    这将注册 "Lobby" 路由并将其指向 LobbyView

  4. 打开 MauiProgram.cs 文件并添加以下高亮显示的代码行:

    builder.Services.AddTransient<Views.ConnectView>();
    builder.Services.AddTransient<Views.LobbyView>();
    return builder.Build();
    

    这将使用依赖注入注册 LobbyView,以便 DataTemplate 可以定位它。

  5. 打开 LobbyView.xaml 文件并将 ContentPage 元素的 Title 属性更改为 "Lobby"

  6. 将以下高亮显示的命名空间添加到 LobbyView 元素中;它们将为我们提供访问 ViewModelsControlsToolkit 命名空间中的类:

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
            xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
                 x:Class=“SticksAndStones.Views.LobbyView”>
    
  7. 为了让 IntelliSense 对我们将要添加的绑定感到满意,通过在 LobbyView 元素中添加 x:DataType 属性来定义视图所使用的视图模型,如下所示:

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
            xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
            x:DataType=“viewModels:LobbyViewModel”
                 x:Class=“SticksAndStones.Views.LobbyView”>
    
  8. 我们不希望用户使用任何导航,例如此页面上提供的 Shell 提供的 Back 按钮,因此使用以下列表中的高亮代码禁用它:

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
                 xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
            xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
            xmlns:controls=“clr-namespace:SticksAndStones.Controls”
            xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
            x:Class=“SticksAndStones.Views.LobbyView”
            x:DataType=“viewModels:LobbyViewModel”
            BackgroundColor value of the entire view to White, which will make the images blend better, by adding the following highlighted code:
    
    

    <ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”

    xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”

    xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”

    xmlns:controls=“clr-namespace:SticksAndStones.Controls”

    xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”

    x:Class=“SticksAndStones.Views.ConnectView”

    x:DataType=“viewModels:ConnectViewModel”

    Title=“ConnectView”

    NavigationPage.HasNavigationBar=“False”

    包含以下代码片段的内容页:

    <RefreshView IsRefreshing=“{Binding IsRefreshing}” Command=“{Binding RefreshCommand}”>
        <ScrollView Padding=“5”>
            <CollectionView ItemsSource=“{Binding Players}” Margin=”5,5,5,0 SelectionMode=“None”>
            </CollectionView>
        </ScrollView>
    </RefreshView>
    

    对于 LobbyView,有一个垂直滚动的玩家列表。根元素是 RefreshView。它的 IsRefreshing 属性绑定到 LobbyViewModelIsRefreshing 属性。RefreshViewCommand 属性绑定到 RefreshCommand,这将最终执行 LobbyViewModelRefreshInternal 方法。IsRefreshingRefreshCommandBaseViewModel 类中实现。在 RefreshView 内部是 ScrollView,它提供了滚动能力以显示长列表。在 ScrollView 内部是 CollectionView,它将显示每个 Player 实例作为一个单独的项目,因此 ItemsSource 绑定到 LobbyViewModelPlayers 属性。由于没有真正需要选择单个 Player 项目,SelectionMode 设置为 none

    
    
  9. 当列表为空时,向用户显示一些内容是很好的,这样他们就不会感到困惑。CollectionView 有一个 EmptyView 属性,用于配置在没有任何项目时显示的内容。在 ContentPage 开始打开标签后立即添加以下代码片段:

    <ContentPage.Resources>
        <ContentView x:Key="BasicEmptyView">
            <StackLayout>
                <Label Text="No players available"
                       Margin="10,25,10,10"
                       FontAttributes="Bold"
                       FontSize="18"
                       HorizontalOptions="Fill"
                       HorizontalTextAlignment="Center" />
            </StackLayout>
        </ContentView>
    </ContentPage.Resources>
    

    这定义了一个包含 ContentView 的页面资源,其 Key 值为 "BasicEmptyView"。视图包含 StackLayout,其中有一个 Label 子元素,其文本为 "No players available"。应用适当的样式以确保它足够大,并且有足够的周围空白。

  10. CollectionView 元素添加以下属性:

    EmptyView="{StaticResource BasicEmptyView}"
    

    这将 BasicEmptyView 绑定到 CollectionViewEmptyView 属性。图 10**.12 展示了运行应用并登录后的结果:

图 10.12 – 没有玩家的大厅

图 10.12 – 没有玩家的大厅

  1. 玩家卡片也将使用静态资源,这仅仅使文件更容易阅读,并且减少了缩进。在 ContentView.Resources 元素下,在 BasicEmptyView 元素下添加以下代码片段:

    <DataTemplate x:Key="PlayerCardViewTemplate" x:DataType="viewModels:PlayerViewModel">
        <ContentView>
            <Border StrokeShape="RoundRectangle 10,10,10,10" BackgroundColor="AntiqueWhite" Padding="3,3,3,3" Margin="5,5,5,5">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="50" />
                        <ColumnDefinition Width="4*" />
                        <ColumnDefinition Width="2*" />
                    </Grid.ColumnDefinitions>
                    <toolkit:AvatarView Grid.Column="0" Margin="0" BackgroundColor="LightGrey" HeightRequest="48" WidthRequest="48" CornerRadius="25" VerticalOptions="Center" HorizontalOptions="Center">
                        <toolkit:AvatarView.ImageSource>
                            <toolkit:GravatarImageSource
                                Email="{Binding EmailAddress}"
                                Image="MysteryPerson" />
                        </toolkit:AvatarView.ImageSource>
                    </toolkit:AvatarView>
                    <VerticalStackLayout Grid.Column="1" Margin="10,0,0,0">
                        <Label Text="{Binding GamerTag}" HorizontalTextAlignment="Start" FontSize="Large" BackgroundColor="AntiqueWhite" />
                        <Label Text="{Binding Status}" HorizontalTextAlignment="Start" FontSize="Caption" BackgroundColor="AntiqueWhite"/>
                    </VerticalStackLayout>
                    <controls:ActivityButton Grid.Column="2" IsRunning="{Binding IsChallenging}" Text="{Binding ChallengeStatus}" BackgroundColor="#e8bc65" Command="{Binding ChallengeCommand}" CommandParameter="{Binding .}" IsVisible="{Binding CanChallenge}" Margin="5"/>
                </Grid>
            </Border>
        </ContentView>
    </DataTemplate>
    

    DataTemplate 将显示玩家的头像。为此,它将使用 Image 控件和 GravatarImageSource,就像在 创建连接视图 部分中所做的那样。需要一个 DataTemplate 元素,因为这是用于 ItemTemplate 的,然后是必选的 ContentView。然后定义 Border。它使用特殊的 Stroke 形状来使矩形的边缘圆润,而不是有直角,并将 AntiqueWhite 作为 BackgroundColor 值应用。可用于 Border 的其他形状包括 EllipseLinePathPolygonPolylineRectangle。有关更多详细信息,请参阅 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/border。在 Border 内部有一个 Grid 控件,它定义了三列。第一列包含头像图像,宽度为 50,下一列包含玩家的游戏标签和状态,垂直堆叠,第三列包含 Challenge 按钮。

    对于头像,使用来自 CommunityToolkitAvatarView 控件。它提供图像的圆形版本。

    Challenge 按钮使用 ActivityButton 控件,并将其绑定到 PlayerViewModelIsChallengingCanChallengeChallengeStatusChallengeCommand 属性。

  2. 要将 PlayerCardViewTemplate 作为 CollectionViewItemTemplate,请向 CollectionView 元素添加以下属性:

    ItemTemplate="{StaticResource PlayerCardViewTemplate}"
    

这样就完成了 Lobby 页面。到这一点,你应该能够启动 SticksAndStone.Functions 并连接到 SticksAndStones.App 项目,以查看 Lobby 视图提供的不同布局。还需要创建一个页面来完成游戏,那就是 Match 页面。

创建比赛页面

Match 页面显示带有玩家和分数的游戏板。它还管理游戏玩法,允许每个玩家轮流放置棍子。每个玩家轮流时,板会更新以显示比赛的当前状态。让我们开始创建 ViewModel 类。

创建 ViewModel 类

Match 页面中使用了两个不同的 ViewModel 类,就像在 Lobby 页面中一样,一个用于游戏,另一个用于玩家详细信息。

添加 MatchPlayerViewModel

MatchPlayerViewModelPlayer 模型和 MatchView 之间的抽象。MatchPlayerViewModel 需要向 MatchView 暴露 Player 模型中的 IdGamerTagEmailAddress 值。此外,由于每个玩家都有一个分数,因此 Match 模型中玩家的分数也暴露给 MatchView。还需要一些额外的属性:

  • IsPlayersTurn:

    这用于确定 MatchPlayerViewModel 是否是当前玩家。

  • PlayerToken:

    这用于将每个玩家映射到一个令牌,以跟踪哪个玩家放置了哪个木棍。使用 -11 的令牌,因为这样比使用 Id 属性(它是 Guid 类型)更容易确定赢家。回顾第九章的 处理回合部分,以刷新如何确定赢家。

要创建 MatchPlayerViewModel,请按照以下步骤操作:

  1. SticksAndStones.App 项目的 ViewModels 文件夹中创建一个名为 MatchPlayerViewModel 的新类。

  2. using 声明修改为文件顶部的以下内容:

    using CommunityToolkit.Mvvm.ComponentModel;
    using SticksAndStones.Models;
    
  3. publicpartial 修饰符添加到类中,并使其继承自 ObservableObject,如下所示:

    public partial class MatchPlayerViewModel: ObservableObject
    {
    }
    
  4. MatchPlayerViewModelPlayerMatch 模型的抽象,将通过构造函数传入。创建字段和构造函数,如下所示列表所示:

    private readonly Player playerModel;
    private readonly Match matchModel;
    public MatchPlayerViewModel(Player player, Match match)
    {
        this.playerModel = player;
        this.matchModel = match;
    }
    
  5. 如果 Player 模型在 Match 模型中是 PlayerOne,则 PlayerToken 属性为 1;否则,为 -1。使用以下方式添加 PlayerToken 属性:

    public int PlayerToken => playerModel.Id == matchModel.PlayerOneId ? 1 : -1;
    
  6. 如果 Player 模型是 Match 模型的 NextPlayer,则 IsPlayersTurn 属性将返回 true,如下所示:

    public bool IsPlayersTurn => playerModel.Id == matchModel.NextPlayerId;
    
  7. IdGamerTagEmailAddress 属性都直接映射到 Player 模型中的相应属性。这与在 PlayerViewModel 中用于 Lobby 页面的相同实现。使用以下列表将属性添加到 MatchPlayerViewModel 中:

    public Guid Id => playerModel.Id;
    public string GamerTag => playerModel.GamerTag;
    public string EmailAddress => playerModel.EmailAddress;
    
  8. MatchPlayerViewModel 需要的最后一个属性是 Score 属性。Score 属性映射到 Match 模型中的 PlayerOneScorePlayerTwoScore 属性,具体取决于 Player 模型是哪个玩家。使用以下列表将 Score 属性添加到 MatchPlayerViewModel 中:

    public int Score => playerModel.Id == matchModel.PlayerOneId ? matchModel.PlayerOneScore : matchModel.PlayerTwoScore;
    

这就是 MatchPlayerViewModel 的全部内容。下一节将指导您创建 MatchViewModel

添加 MatchViewModel

MatchViewModel 需要提供所有游戏功能。它提供两个用于在页面标题中显示的 MatchPlayerViewModel 对象,以及显示已放置木棍和已捕获石头的棋盘。它还提供了玩家进行回合和选择弃权的所需功能。要实现 MatchViewModel,请按照以下步骤操作:

  1. SticksAndStones.App 项目的 ViewModels 文件夹中创建一个名为 MatchViewModel 的新类。

  2. 修改页面顶部的 using 声明部分,以匹配以下列表:

    using CommunityToolkit.Mvvm.ComponentModel;
    using CommunityToolkit.Mvvm.Input;
    using CommunityToolkit.Mvvm.Messaging;
    using SticksAndStones.Models;
    using SticksAndStones.Services;
    
  3. publicpartial 类修饰符添加到类中,继承自 ViewModelBase 并实现 IQueryAttributable,如下所示:

    public partial class MatchViewModel : ViewModelBase, IQueryAttributable
    

    回想一下,在 ConnectViewModelLobbyViewModel 中,当它们导航到 Match 时,会传递一个参数——要么是 MatchId,要么是 Match 实例本身。IQueryAttributable 是如何将这个参数传递给 MatchViewModel 的。IQueryAttributable 的实现将在后续步骤中提供。

  4. MatchViewModel 只有一个依赖项,即 GameService,因此添加一个字段来存储实例,并添加一个接受实例作为参数的构造函数,如下所示:

    private readonly GameService gameService;
    public MatchViewModel(GameService gameService)
    {
        this.gameService = gameService;
    }
    
  5. MatchViewModel 被加载时,它需要处理参数,无论是 Match 实例还是 MatchId 值。无论哪种参数,最终都会得到一个用于在视图中显示棋盘的 Match 实例,并基于此创建两个 MatchPlayerViewModel 实例,分别用于玩家一和玩家二。将 matchplayerOneplayerTwo 字段添加到 MatchViewModel 类中,以保存这些实例,如下所示:

    [ObservableProperty]
    private Match match;
    [ObservableProperty]
    private MatchPlayerViewModel playerOne;
    [ObservableProperty]
    private MatchPlayerViewModel playerTwo;
    
  6. IQueryAttributable 用于处理传递给视图模型的参数。嗯,这是其中一种方法。IQueryAttributable 接口定义了一个方法,即 ApplyQueryAttributes。.NET MAUI 路由系统将自动调用 ApplyQueryAttributes 方法,如果视图模型实现了 IQueryAttributable 接口。要添加 IQueryAttributable 的实现,请使用以下代码列表:

    public async Task ApplyQueryAttributes(IDictionary<string, object> query)
    {
        Match match = null;
        if (query.ContainsKey(Constants.ArgumentNames.Match))
        {
            match = query[Constants.ArgumentNames.Match] as Match;
        }
        if (query.ContainsKey(Constants.ArgumentNames.MatchId))
        {
            var matchId = new Guid($"{query[Constants.ArgumentNames.MatchId]}");
            if (matchId != Guid.Empty)
            {
                match = await gameService.GetMatchById(matchId);
            }
        }
            LoadMatch(match);
        });
    }
    private void LoadMatch(Match match)
    { 
        if (match is null) return;
        PlayerOne = new MatchPlayerViewModel(gameService.GetPlayerById(match.PlayerOneId), match);
        PlayerTwo = new MatchPlayerViewModel(gameService.GetPlayerById(match.PlayerTwoId), match);
        this.Match = match;
    }
    

    ApplyQueryAttributes 有一个名为 query 的单个参数,它是一个键值对字典,键是一个字符串,值是一个对象。键 ID 是参数的名称,如 "Match""MatchId"。该方法将检查 "Match" 键是否存在,如果存在,则将其值作为 Match 获取。如果存在 "MatchId" 键,则使用 GameServiceId 获取 Match 模型。如果没有 match 的值,则该方法返回;否则,初始化两个 GamePlayerViewModel 实例并将它们和 Match 存储在 ViewModel 属性中。LoadMatch 方法是从 ApplyQueryAttributes 调用的,因为我们将在接收到 UpdateMatch 事件时需要相同的功能。

  7. 在允许玩家选择放置棍子的位置之前,必须是他们的回合。使用以下代码创建一个名为 IsCurrentPlayersTurn 的属性:

    public bool IsCurrentPlayersTurn => gameService.CurrentPlayer.Id == (Match?.NextPlayerId ?? Guid.Empty);
    
  8. 任何时间 Match 对象被更新时,IsCurrentPlayersTurn 也需要更新,因为它依赖于 Match 属性中的值。为了自动执行此操作,使用来自 CommunityToolkitNotifyPropertyChangedFor 属性。在以下代码列表中添加高亮行:

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsCurrentPlayersTurn))]
    private Match match;
    

    现在,每当 Match 属性发生变化时,NotfiyPropertyChanged 方法也会被调用以更新 IsCurrentPlayersTurn。参见 第二章 中的 定义 ViewModel 基类 部分,以复习实现 INotifyPropertyChanged 接口。

  9. 游戏允许玩家在提交之前尝试不同的棒的位置。如果这是当前玩家的回合,即连接并使用应用的玩家,那么 SelectStick 方法将在用户选择的位置放置一根棒。选择不会发送到服务器,直到用户点击 lastSelectedStick 字段。添加以下代码以实现 SelectStick 方法:

    int lastSelectedStick = -1;
    [RelayCommand(CanExecute = nameof(IsCurrentPlayersTurn))]
    private void SelectStick(string arg)
    {
        if (gameService.CurrentPlayer is null) return;
        if (Match is null) return;
    
        if (int.TryParse(arg, out var pos))
        {
            pos--; // adjust for 0 based indexes
            if (lastSelectedStick != -1 && lastSelectedStick != pos)
                Match.Sticks[lastSelectedStick] = 0;
            if (Match.Sticks[pos] != 0)
                return;
            Match.Sticks[pos] = gameService.CurrentPlayer.Id == PlayerOne.Id ? PlayerOne.PlayerToken : PlayerTwo.PlayerToken;
            lastSelectedStick = pos;
            OnPropertyChanged(nameof(Match));
        }
    }
    

    lastSelectedStick-1 值用于表示没有棒。SelectStick 方法通过 RelayCommandAttribute 暴露为一个 Command 实例。使用 Is CurrentPlayersTurn 属性来确定命令是否可以执行。回想一下 第九章Sticks 元素将具有三个值之一:玩家一的 -1,空位的 0,以及玩家二的 1。在确定棒的位置是否有效后,该方法会引发 Match 属性的 OnPropertyChanged 事件,这会导致绑定更新。

  10. 在考虑将下一根棒放在哪个位置后,玩家有三个选择:将他们的移动发送到服务器并结束他们的回合,犹豫不决并撤销他们的移动,或者放弃并退出比赛。使用以下代码片段将 Play 方法添加到 MatchViewModel

    [RelayCommand]
    private async Task Play()
    {
        if (lastSelectedStick == -1)
        {
            await Shell.Current.CurrentPage.DisplayAlert("Make a move", "You must make a move before you play.", "Ok");
            return;
        }
        if (await Shell.Current.CurrentPage.DisplayAlert("Make a move", "Are you sure this is the move you want, this can't be undone.", "Yes", "No"))
        {
            var (newMatch, error) = await gameService.EndTurn(Match.Id, lastSelectedStick);
            if (error is not null)
            {
                await Shell.Current.CurrentPage.DisplayAlert("Error in move", error, "Ok");
                return;
            }
            lastSelectedStick = -1;
        }
    }
    

    Play 方法被暴露为一个 Command,以便它可以被 UI 元素绑定。

  11. 当玩家轻触 lastSelectedStick 位置和 lastSelectedStick 的值时,会调用 Undo 方法。添加 Undo 方法,如下代码所示:

    [RelayCommand]
    private async Task Undo()
    {
        if (lastSelectedStick != -1)
        {
            if (await Shell.Current.CurrentPage.DisplayAlert("Undo your move", "Are you sure you don't want to play this move?", "Yes", "No"))
            {
                OnPropertyChanging(nameof(Match));
                Match.Sticks[lastSelectedStick] = 0;
                OnPropertyChanged(nameof(Match));
                lastSelectedStick = -1;
                return;
            }
        }
    }
    

    再次,将 RelayCommand 属性应用于方法,以便它可以被 UI 元素绑定。

    当玩家使用 Forfeit 方法调用 MatchViewModel 类时,会调用 Forfeit 方法:

    [RelayCommand]
    private async Task Forfeit()
    {
        var returnToLobby = true;
        if (!Match.Completed)
        {
            returnToLobby = await Shell.Current.CurrentPage.DisplayAlert("W A I T", "Returning to the Lobby will forfeit your match, are you sure you want to do that?", "Yes", "No"))
        if (returnToLobby)
        {
            await Shell.Current.GoToAsync("///Lobby");
        }
    }
    
  12. 当对手玩家将他们的移动发送到服务器时,它作为来自 SignalR 服务的 MatchUpdated 事件在应用中接收。使用以下代码添加 MatchUpdated 事件的处理器:

    void OnMatchUpdated(object r, Messages.MatchUpdated m)
    {
        LoadMatch(m.Value);
        if (Match.WinnerId != Guid.Empty && Match.Completed == true)
        {
            MainThread.InvokeOnMainThreadAsync(async () =>
            {
                if (Match.WinnerId == gameService.CurrentPlayer.Id)
                {
                    await Shell.Current.CurrentPage.DisplayAlert("Congratulations!", $"You are victorious!\nPress the back button to return to the lobby.", "Ok");
                }
                else
                {
                    await Shell.Current.CurrentPage.DisplayAlert("Bummer!", $"You were defeated, better luck next time!\nPress the back button to return to the lobby.", "Ok");
                }
            });
            return;
        }
    }
    
  13. 要注册 MatchUpdated 事件处理器,从 OnActivated 中调用 Register 方法,从 OnDeactivated 中调用 UnRegister,如下所示:

    protected override void OnActivated()
    {
        Messenger.Register(this, (MessageHandler<object, Messages.MatchUpdated>)OnMatchUpdated);
    }
    protected override void OnDeactivated()
    {
        Messenger.Unregister<Messages.MatchUpdated>(this);
    }
    
  14. 通过在 MauiProgram.cs 文件中的 CreateMauiApp 方法中添加以下高亮代码行,使用依赖注入注册 MatchViewModel

    builder.Services.AddTransient<ViewModels.ConnectViewModel>();
    builder.Services.AddTransient<ViewModels.LobbyViewModel>();
    builder.Services.AddTransient<ViewModels.MatchViewModel>();
    builder.Services.AddTransient<Views.ConnectView>();
    builder.Services.AddTransient<Views.LobbyView>();
    

为什么叫 IQueryAttributable?这感觉有点尴尬

接口名称背后的原因是命名事物很难。传递参数到视图模型的系统可以是声明性的或不声明性的。声明性方式使用 QueryPropertyAttribute 将查询参数映射到视图模型上的属性。如果你选择不使用属性,而是手动处理映射,你可以声明你的类为 IQueryAttributable,例如,我本可以使用 QueryPropertyAttribute 但我选择不这样做。更多信息,请访问 learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation#pass-data

添加 Match 视图

这个页面很复杂,所以我们将将其分解成更小、更易于管理的块。首先,定义基本页面布局,包括玩家可用的命令:PlayUndoForfeit。接下来,定义计分板区域,包括玩家的游戏标签、Gravatar 和分数。最后,定义并布局游戏板,形成一个三行三列的网格。让我们开始创建视图和布局。

创建视图

Match 视图与其他创建的视图没有太大区别,除了它比预览视图有更多元素。让我们按照以下步骤开始创建视图和一些基本元素:

  1. 右键单击 SticksAndStone.App 项目的 Views 文件夹,选择 Add,然后点击 New Item...

    如果你正在使用 Visual Studio 17.7 或更高版本,请点击弹出的对话框中的 Show all Templates 按钮;否则,请跳到下一步。

  2. 在左侧的 C# Items 节点下,选择 .NET MAUI

  3. 选择 MatchView

  4. 点击 Add 以创建页面。

    参考以下截图查看上述信息:

图 10.13 – 添加新的 .NET MAUI ContentPage (XAML)

图 10.13 – 添加新的 .NET MAUI ContentPage (XAML)

  1. 打开 MatchView.xaml.cs 文件,并添加以下 using 声明:

    using SticksAndStones.ViewModels;
    
  2. 对构造函数进行以下突出显示的更改:

    public MatchView(MatchViewModel viewModel)
    {
        this.BindingContext = viewModel;
        InitializeComponent();
    }
    

    这些更改允许依赖注入提供 MatchViewModel 实例给视图,然后将其分配给 BindingContext

  3. 打开 AppShell.xaml 文件,并将以下代码片段添加到 ContentPage 元素中:

    <ShellItem Route="Match">
        <ShellContent ContentTemplate="{DataTemplate views:MatchView}" />
    </ShellItem>
    

    这将注册 "Match" 路由并将其指向 MatchView

  4. 打开 MauiProgram.cs 文件,并添加以下突出显示的代码行:

    builder.Services.AddTransient<Views.ConnectView>();
    builder.Services.AddTransient<Views.LobbyView>();
    builder.Services.AddTransient<Views.MatchView>();
    return builder.Build();
    

    这将注册 MatchView 以进行依赖注入,以便 DataTemplate 可以找到它。

  5. 打开 MatchView.xaml 文件,并移除 ContentPage 元素的 Title 属性。

  6. 将以下突出显示的命名空间添加到 MatchView 元素中。它们将为我们提供访问 ViewModelsConvertersControls 命名空间中的类:

    <ContentPage 
    
            x:Class="SticksAndStones.Views.GameView">
    
  7. 为了让 IntelliSense 对我们将要添加的绑定感到满意,定义视图所使用的视图模型,通过在 MatchView 元素中添加 x:DataType 属性来实现,如下所示:

    <ContentPage 
    
            x:DataType="viewModels:GameViewModel"
            x:Class="SticksAndStones.Views.GameView">
    

MatchView 使用了 Font Awesome 字体库中的几个图标,因此我们需要下载并安装这个库,以便在应用中可用。

下载和配置 Font Awesome

Font Awesome 是一个包含在字体中的图像集合。.NET MAUI 对在工具栏、导航栏等地方使用 Font Awesome 提供了出色的支持。虽然制作这个应用不是必需的,但我们认为这额外的往返是值得的,因为你很可能在你的新杀手级应用中需要类似的东西。

下载字体很简单。请注意文件的重命名——这实际上不是必需的,但如果文件名更简单,编辑配置文件等会更容易。按照以下步骤获取并复制字体到每个项目中:

  1. 浏览到 fontawesome.com/download

  2. 点击 Free for Desktop 按钮下载 Font Awesome。

  3. 解压下载的文件,然后找到 otfs 文件夹。

  4. Font Awesome 5 Free-Solid-900.otf 文件重命名为 FontAwesome.otf(你可以保留原始名称,但如果重命名会少输入一些)。由于 Font Awesome 持续更新,你的文件名可能不同,但应该相似。

  5. FontAwesome.otf 复制到 SticksAndStones.App 项目的 Resources/Fonts 文件夹中。

如果只需要将字体文件复制到项目文件夹中就足够了,那就太好了。默认的 .NET MAUI 模板在 News.csproj 文件中包含了所有字体,并在 Resources/Fonts 文件夹中定义了以下项目:

<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />

这确保了字体文件被处理并自动包含在应用包中。剩下要做的就是将字体注册到 .NET MAUI 运行时中,使其对 XAML 资源可用。为此,将以下高亮行添加到 MauiProgram.cs 文件中:

.ConfigureFonts(fonts =>
{
    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
    fonts.AddFont("FontAwesome.otf", "FontAwesome");
})

这一行添加了一个别名,我们可以在下一节中使用它来创建静态资源。第一个参数是字体文件的文件名,第二个参数是你可以用于 FontFamily 属性的字体别名。

定义布局

现在 Font Awesome 已安装并配置在 .NET MAUI 中,TitleView 可以使用它。按照以下步骤添加自定义标题区域和主要布局:

  1. 首先,覆盖 Shell 元素的 TitleView 并提供一个新容器来存放按钮:

    <Shell.TitleView>
        <Grid>
            <HorizontalStackLayout HorizontalOptions="Start">
            </HorizontalStackLayout>
            <HorizontalStackLayout HorizontalOptions="End">
            </HorizontalStackLayout>
        </Grid>
    </Shell.TitleView>
    

    按钮分为两个部分,一个对齐到窗口的左侧或起始位置,另一个对齐到右侧或结束位置。

  2. 玩家可以在任何时候决定他们不再想继续游戏。要退出比赛,玩家可以使用 TitleViewStart 部分,并在 MatchViewModel 中绑定 ForfeitCommand,添加以下片段中的高亮代码:

    <HorizontalStackLayout HorizontalOptions="Start">
    <ImageButton Command="{Binding ForfeitCommand}" ToolTipProperties.Text="Return to the lobby.">
            <ImageButton.Source>
                <FontImageSource Glyph="&#xf0a8;" FontFamily="FontAwesome" Color="White" Size="28" />
            </ImageButton.Source>
        </ImageButton>
    </HorizontalStackLayout>
    
  3. 当轮到玩家时,他们有两个可用的按钮,PlayUndoPlayUndo 按钮放置在 .NET MAUI 页面的 TitleView 区域。将以下高亮代码添加到 TitleView 以添加 PlayUndo 按钮:

    <HorizontalStackLayout HorizontalOptions="End">
        <ImageButton Command="{Binding UndoCommand}" IsVisible="{Binding IsCurrentPlayersTurn}" ToolTipProperties.Text="Undo the last stick placement.">
            <ImageButton.Source>
                <FontImageSource Glyph="&#xf0e2;" FontFamily="FontAwesome" Color="White" Size="28" />
            </ImageButton.Source>
        </ImageButton>
        <ImageButton Command="{Binding PlayCommand}" IsVisible="{Binding IsCurrentPlayersTurn}" ToolTipProperties.Text="Send the stick placement, and end my turn.">
            <ImageButton.Source>
                <FontImageSource Glyph="&#xf1d8;" FontFamily="FontAwesome" Color="White" Size="28" />
            </ImageButton.Source>
        </ImageButton>
    </HorizontalStackLayout>
    
  4. ContentView 中删除默认的 VerticalStackLayout 元素,并添加以下代码:

    <ContentView>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="4*" />
                <RowDefinition Height="2*" />
                <RowDefinition Height="6*" />
                <RowDefinition Height="2*" />
            </Grid.RowDefinitions>
        </Grid>
    </ContentView>
    

    这添加了一个具有四行的 Grid 控件。第一行和第三行将包含计分板和游戏板,而第二行和第四行是填充。

主布局已准备就绪。接下来,将计分板添加到布局的第一行。

创建计分板

计分板包含每个玩家的头像、游戏标签和分数。这些元素绑定到玩家的 MatchPlayerViewModel 对应字段。Match 对象有两个属性,PlayerOnePlayerTwo,每个属性都是一个 MatchPlayerViewModel。要添加计分板,请按照以下步骤操作:

  1. 每个玩家都有一个不同的颜色来识别。要将每种颜色添加为资源,打开 SticksAndStones.App 项目的 Resources/Styles 文件夹中的 Colors.xaml 文件,并将以下行添加到 ResourceDictionary 元素中:

    <Color x:Key="PlayerOne">#6495ED</Color>
    <Color x:Key="PlayerTwo">#CD5C5C</Color>
    
  2. 计分板使用 HorizontalStackLayout 作为外部容器。将以下代码添加到 Grid 元素中:

    <HorizontalStackLayout Grid.Row="0" HorizontalOptions="CenterAndExpand" Margin="10" BindableLayout.ItemsSource="{Binding Players}">
    </HorizontalStackLayout>
    

    HorizontalStackLayout 被分配到 Grid 的行 0,其内容通过 BindableLayout.ItemsSource 绑定到视图模型的 Players 属性。BindableLayout 是支持所有布局控件(如 AbsoluteLayoutFlexLayout)的底层接口。

  3. 每个玩家在 HorizontalStackLayout 中都有自己的卡片。由于控件绑定到 Players 属性,该属性是一个 MatchPlayerViewModels 数组,因此 BindableLayout.ItemTemplate 属性提供了在 Players 中显示每个项目的视图。卡片使用 Border 元素和嵌套的 VerticalStackLayout 元素进行布局。将以下高亮代码添加到 HorizontalStackLayout

    <HorizontalStackLayout Grid.Row="0" HorizontalOptions="CenterAndExpand" Margin="10">
        <BindableLayout.ItemTemplate>
            <DataTemplate>
                <Border x:DataType="viewModels:MatchPlayerViewModel" Padding="0" Margin="2" StrokeShape="RoundRectangle 10,10,10,10" HeightRequest="175">
                    <VerticalStackLayout Padding="2" HorizontalOptions="Center">
                    </VerticalStackLayout>
                </Border>
            </DataTemplate>
        </BindableLayout.ItemTemplate>
    </HorizontalStackLayout>
    
  4. Border 元素是玩家卡片的最高级容器。要根据 PlayerToken 设置 Border 元素的边框颜色和背景颜色,使用触发器(learn.microsoft.com/en-us/dotnet/maui/fundamentals/triggers)——具体来说,使用 DataTrigger 来根据其他值设置属性值。将以下代码添加到 Border 元素:

    <Border.Triggers>
        <DataTrigger TargetType="Border" Binding="{Binding PlayerToken}" Value="1" >
            <Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
            <Setter Property="BackgroundColor" Value="{StaticResource PlayerOne}" />
        </DataTrigger>
        <DataTrigger TargetType="Border" Binding="{Binding PlayerToken}" Value="-1" >
            <Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
            <Setter Property="BackgroundColor" Value="{StaticResource PlayerTwo}" />
        </DataTrigger>
    </Border.Triggers>
    

    DataTrigger 绑定属性与 Value 属性进行比较。如果它们相等,则执行 DataTriggerSetter 元素。在这种情况下,如果 PlayerToken 属性为 -1,则将 BorderStrokeBackgroundColor 属性设置为在 步骤 1 中定义的 PlayerOne 颜色。否则,如果 PlayerToken 属性等于 -1,则将 StrokeBackgroundColor 属性设置为 PlayerTwo 颜色。

  5. VerticalStackLayout 包含另一个 VerticalStackLayoutBorder 元素,如下所示突出显示的代码:

    <VerticalStackLayout BackgroundColor="{Binding PlayerToken, Converter={StaticResource PlayerToColor}}" Padding="2" HorizontalOptions="Center">
        <VerticalStackLayout>
        </VerticalStackLayout>
        <Border Padding="0" WidthRequest="96" StrokeShape="RoundRectangle 10,10,10,10" StrokeThickness="0">
            <Image IsVisible="{Binding IsPlayersTurn}" Source="hstick.jpeg" Aspect="AspectFit" MaximumHeightRequest="36"/>
        </Border>
    VerticalStackLayout will be used to hold GamerTag, AvatarImage, and the player’s score, which is added in the next step. Border contains a horizontal stick image whose IsVisible attribute is bound to the IsPlayersTurn property. The stick is used as a visual indicator of which player’s turn it is. If it is not the player’s turn, the image is not displayed.
    
  6. 在第二个 VerticalStackLayout 中有一个 Label 和一个 FlexLayout。添加以下突出显示的代码:

    <VerticalStackLayout>
        <Label Text="{Binding GamerTag}" HorizontalOptions="FillAndExpand" HorizontalTextAlignment="Center" FontSize="18" FontFamily="OpenSansSemibold"/>
        <FlexLayout Margin="3">
        </FlexLayout>
    FlexLayout contains the visual elements to display AvatarImage and Score. Add the following highlighted code to FlexLayout:
    
    

    <toolkit:AvatarView FlexLayout.Order="0" Margin="0" BackgroundColor="LightGrey" HeightRequest="85" WidthRequest="85" CornerRadius="50" VerticalOptions="Center" HorizontalOptions="Center">

    toolkit:AvatarView.ImageSource

    <toolkit:GravatarImageSource

    Email="{Binding EmailAddress}"

    Image="MysteryPerson" />

    </toolkit:AvatarView.ImageSource>

    toolkit:AvatarView.Triggers

    </toolkit:AvatarView.Triggers>

    </toolkit:AvatarView>

    FlexLayout 控件,FlexLayout 子项显示的顺序由 FlexLayout.Order 属性控制。类似于 Grid 控件,其子项的 Grid.Row 和 Grid.Column 属性,Order 属性设置在子项上。FlexLayout 中子项的顺序通过使用触发器来改变。在 AvatarView 上,如果 PlayerToken 属性等于 -1,即 PlayerTwo,DataTrigger 将 FlexLayout.Order 属性设置为 "1"。在 Label 上,DataTrigger 将 FlexLayout.Order 属性设置为 "0",从而有效地交换了两个元素。

    
    

这样就完成了记分板。MatchView 的最后一部分是最大的:棋盘。继续阅读以了解如何创建棋盘视觉效果。

创建游戏棋盘

游戏棋盘由三个不同的元素组成。这些元素是每个方格角落的点、横竖棒(水平和垂直)和石头。这些元素按照以下所示布局:

图 10.14 – 游戏棋盘

图 10.14 – 游戏棋盘

棋盘使用 Grid 控件来提供基本布局。使用 7 列和 7 行将为每个元素提供单元格:16 个点、9 个石头和 24 根棒。将以下代码添加到顶级 Grid 元素以提供游戏棋盘的基本布局:

<Grid Grid.Row="2" BackgroundColor="White" Margin="10,40,10,0" MaximumHeightRequest="410" MaximumWidthRequest="400" >
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="5*" />
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="5*" />
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="5*" />
        <ColumnDefinition Width="1*" />
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="1*" />
        <RowDefinition Height="4*" />
        <RowDefinition Height="1*" />
        <RowDefinition Height="4*" />
        <RowDefinition Height="1*" />
        <RowDefinition Height="4*" />
        <RowDefinition Height="1*" />
    </Grid.RowDefinitions> 
</Grid>

让我们从添加网格的角落开始,因为它们是最简单的。要定义一个角落,使用带有文本 "&#x26AB;"Label,这是点的十六进制字符代码。为了水平垂直居中点,将 HorizontalOptionsVerticalOptions 设置为 "Center"。您的基本元素看起来如下所示:

<Label Text="&#x26AB" HorizontalOptions="Center" VerticalOptions="Center" />

没有使用Grid.RowGrid.Column属性,Label将被放置在行0和列0。网格中有 16 个角落,它们占据了所有偶数单元格,所以(0,0)(0,2)(0,4)(0,6)(2,0)(2,2)等等。第一行的完全定义的标签如下所示:

            <Label Grid.Row="0" Grid.Column="0" Text="&#x26AB" HorizontalOptions="Center" VerticalOptions="Center" />
            <Label Grid.Row="0" Grid.Column="2" Text="&#x26AB" HorizontalOptions="Center" VerticalOptions="Center" />
            <Label Grid.Row="0" Grid.Column="4" Text="&#x26AB" HorizontalOptions="Center" VerticalOptions="Center" />
            <Label Grid.Row="0" Grid.Column="6" Text="&#x26AB" HorizontalOptions="Center" VerticalOptions="Center" />

当你为所有 16 行都这样做时,就会有很多TextHorizontalOptionsVerticalOptions属性的重复。通过使用Style元素,可以消除这种重复。Style元素包含Setter元素,如DataTrigger元素。当Style应用于元素时,Setter元素用于更新目标元素的属性。使用以下步骤通过Style将角落元素添加到Grid控件中:

  1. 将以下Style元素添加到ContentPage.Resources元素中:

    <Style x:Key="dotLabel"
            TargetType="Label">
        <Setter Property="Text" Value="&#x26AB;" />
        <Setter Property="HorizontalOptions" Value="Center" />
        <Setter Property="VerticalOptions" Value="Center" />
    </Style>
    

    这个Style元素通过x:Key属性进行标识。

  2. 在本节开头创建的Grid控件中添加一个Label

  3. LabelGrid.Row属性设置为0

  4. LabelGrid.Column属性设置为0

  5. Style属性设置为{StaticResource dotLabel}值。Style属性用于指定应用于元素的样式。由于StyleContentView.Resources元素中定义,它是一个StaticResource

  6. 完成的Label应如下所示:

    <Label Grid.Row="0" Grid.Column="0" Style="{StaticResource dotLabel}" />
    
  7. 现在,复制刚刚创建的Label,并将Grid.Column值增加两个,重复此步骤,直到你有四个具有相同Grid.Row值的Label元素。

  8. 复制在第 7 步中创建的最后一个Label,并将Grid.Row值增加两个,并将Grid.Column的值重置为0。现在,使用更新的Grid.Row值重复第 7 步,直到有四个具有Grid.Row值为6的标签。

  9. 标签应如下所示:

    <Label Grid.Row="0" Grid.Column="0" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="0" Grid.Column="2" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="0" Grid.Column="4" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="0" Grid.Column="6" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="2" Grid.Column="0" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="2" Grid.Column="2" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="2" Grid.Column="4" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="2" Grid.Column="6" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="4" Grid.Column="0" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="4" Grid.Column="2" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="4" Grid.Column="4" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="4" Grid.Column="6" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="6" Grid.Column="0" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="6" Grid.Column="2" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="6" Grid.Column="4" Style="{StaticResource dotLabel}" />
    <Label Grid.Row="6" Grid.Column="6" Style="{StaticResource dotLabel}" />
    

现在角落处理完毕,我们可以开始制作游戏棋子:木棍和石头。由于木棍和石头有一些相似之处,我们可以创建一个通用的控件来显示它们。然而,它们的可视化方式完全不同。所需的是一个通用的接口来定义BindableProperty属性,并在不同的布局中使用它。.NET MAUI使用ControlTemplate资源来允许对构成控件的视觉元素进行自定义,甚至完全替换。在.NET MAUI 中,许多控件可以通过ControlTemplate进行自定义,如果它们从ContentViewContentPage派生。让我们从添加自定义控件开始,然后按照以下步骤添加木棍和石头的ControlTemplate资源:

  1. SticksAndStones.App项目的Controls文件夹中创建一个新的类,命名为GamePieceView

  2. 更新类定义以匹配以下列表:

    namespace SticksAndStones.Controls;
    public partial class GamePieceView : ContentView
    {
    }
    
  3. 添加一个名为 GamePiecePositionstring 属性和一个名为 GamePiecePositionPropertyBindableProperty,如下所示:

    public static readonly BindableProperty GamePiecePositionProperty = BindableProperty.Create(nameof(GamePiecePosition), typeof(string), typeof(GamePieceView), string.Empty);
    public string GamePiecePosition
    {
        get => (string)GetValue(GamePiecePositionProperty);
        set => SetValue(GamePiecePositionProperty, value);
    }
    

    GamePiecePosition 用于确定 GameViewModel 上的 SticksStones 属性中的数组索引。

  4. 添加一个名为 GamePieceStateint 属性和一个名为 GamePieceStatePropertyBindableProperty,如下所示:

    public static readonly BindableProperty GamePieceStateProperty = BindableProperty.Create(nameof(GamePieceState), typeof(int), typeof(GamePieceView), 0, BindingMode.TwoWay);
    public int GamePieceState
    {
        get => (int)GetValue(GamePieceStateProperty);
        set => SetValue(GamePieceStateProperty, value);
    }
    

    GamePieceState 是棋子的所有者:1 表示 PlayerOne0 表示无人,-1 表示 PlayerTwo

  5. 添加一个名为 GamePieceDirectionstring 属性和一个名为 GamePieceDirectionPropertyBindableProperty,如下所示:

    public static readonly BindableProperty GamePieceDirectionProperty = BindableProperty.Create(nameof(GamePieceDirection), typeof(string), typeof(GamePieceView), null);
    public string GamePieceDirection
    {
        get => (string)GetValue(GamePieceDirectionProperty);
        set => SetValue(GamePieceDirectionProperty, value);
    }
    

    GamePieceDirection 仅适用于 Sticks,可以是 HorizontalVertical

  6. 再次打开 MatchView.Xaml 文件,并为所有棍子添加一个控件模板。将以下代码片段添加到 ContentView.Resources 元素中:

    <ControlTemplate x:Key="StickViewControlTemplate">
    </ControlTemplate>
    

    这定义了一个具有 StickViewControlTemplate 键的 ControlTemplate 元素。键用于将 ControlTemplate 元素应用于控件。

  7. 每个棍子视觉元素有两个部分:标签上显示的数字和棍子图像,图像使用 Image 控件在边框内显示,并由放置棍子的玩家着色。另一个有趣的方面是 LabelBorder 控件需要叠加在一起。为了实现这一点,使用了一个 Grid 控件,并将两个元素放置在同一单元格中。要添加 GridLabelBorderImage 控件,请使用以下列表,并将它们添加到 ControlTemplate 元素中:

    <Grid Margin="0" Padding="0">
        <Label Text="{TemplateBinding GamePiecePosition}" IsVisible="False" HorizontalTextAlignment="Center" VerticalTextAlignment="Center" TextColor="Red" FontAttributes="Bold" >
        </Label>
        <Border Padding="3" BackgroundColor="Transparent" StrokeShape="RoundRectangle 5" Stroke="Transparent">
            <Image Aspect="Fill">
            </Image>
        </Border>
    </Grid>
    

    GridMarginPadding 值为 0,这样它就不会占用任何屏幕空间。Label 控件的 Text 属性使用 TemplateBinding 绑定到 GamePiecePosition 属性。TemplateBindingBinding 有所不同,因为 TemplateBinding 使用应用于此 ControlTemplate 的控件作为 DataContext。由于此 ControlTemplate 将应用于 GamePieceView 的实例,因此它将绑定到这些控件的 Bindable 属性。

    检查 第 7 步 中的 Image 控件,你会发现它没有指定显示哪个图像。对于 Sticks,会显示两个图像中的一个:水平棍子显示 hstick.jpeg,垂直棍子显示 vstick.jpeg,如果该位置没有棍子,则控件不应可见。以下列表使用 DataTrigger 通过 TemplateBindingIsVisibleSource 的值设置为 Image 控件的 GamePieceStateGamePieceDirection 属性。将此代码添加到 ControlTemplateImage 控件中:

    <Image.Triggers>
        <DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceState}" Value="0">
            <Setter Property="IsVisible" Value="False" />
        </DataTrigger>
        <DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceDirection}" Value="Horizontal">
             <Setter Property="Source" Value="hstick.jpeg" />
        </DataTrigger>
        <DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceDirection}" Value="Vertical">
             <Setter Property="Source" Value="vstick.jpeg" />
        </DataTrigger>
    </Image.Triggers>
    
  8. Border 控件也使用 DataTrigger 以放置棍子的玩家的颜色来勾勒出棍子。在 Image 之后添加以下代码到 Border 元素中:

    <Border.Triggers>
        <DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="1" >
            <Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
        </DataTrigger>
        <DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="-1" >
            <Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
        </DataTrigger>
    </Border.Triggers>
    

    需要两个触发器来在PlayerOne1)和PlayerTwo-1)之间切换。Border控制的Stroke属性被设置为玩家的颜色资源。如果两个触发器都不活跃,则使用Border元素的默认StrokeTransparent。这样,如果没有使用棒子,GamePieceState0,边界将是透明的。如果GamePieceState1,则Stroke将具有由名为PlayerOne的资源定义的颜色,如果GamePieceState-1,则Stroke值将是名为PlayerTwo的资源。

  9. 当用户在他们的回合中移动时,他们将通过点击标签来放置他们的棒子在那个位置。为了在发生这种情况时调用SelectStickCommandBorder控制将TapGestureRecognizer绑定到GameViewModel.SelectStickCommand属性,并将GamePiecePosition作为参数传递。将以下列表添加到Border元素之后,在Border.Triggers元素之后:

    <Border.GestureRecognizers>
        <TapGestureRecognizer Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:GameViewModel}}, Path=SelectStickCommand}" CommandParameter="{TemplateBinding GamePiecePosition}" />
    </Border.GestureRecognizers>
    
  10. 最后,仔细看看Label元素;你会看到IsVisible属性被设置为False。如果没有在这个位置放置棒子,我们需要显示位置的标签。这可以通过使用DataTrigger来实现;如果GamePieceState0,即还没有放置棒子,则可以将标签的IsVisible属性设置为True,使标签可见。将以下列表添加到Label元素中:

    <Label.Triggers>
        <DataTrigger TargetType="Label" Binding="{TemplateBinding Path=GamePieceState}" Value="0">
            <Setter Property="IsVisible" Value="True" />
        </DataTrigger>
    </Label.Triggers>
    

这样就完成了棒子的控制模板。接下来,按照以下步骤创建Stones的控制模板:

  1. 在为棒子创建的ControlTemplate下方添加以下代码:

    <ControlTemplate x:Key="StoneViewControlTemplate">
    </ControlTemplate>
    

    就像棒子的控制模板一样,ControlTemplate使用一个键来定位正确的模板。

  2. Stones模板比Sticks模板简单一些。在这里,我们只有一个作为子控件的Border控制和Image控制。再次使用DataTrigger来选择正确的边界颜色,如果石头不存在,则边界不可见。使用以下代码示例并将其添加到在步骤 1中创建的ControlTemplate中:

    <Border Margin="3" Padding="5" HorizontalOptions="Center" VerticalOptions="Center" StrokeShape="RoundRectangle 5" StrokeThickness="3">
        <Border.Triggers>
            <DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="0">
                <Setter Property="IsVisible" Value="False" />
            </DataTrigger>
            <DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="1" >
                <Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
            </DataTrigger>
            <DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="-1" >
                <Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
            </DataTrigger>
        </Border.Triggers>
        <Image Source="stones.jpeg" Aspect="Fill" />
    </Border>
    

    你可能已经注意到这个列表中的触发器与Sticks控制模板中的触发器有所不同。在Sticks中,IsVisible属性是在Image上设置的,而不是在Border上,你可能想知道为什么是这样。解释很简单;如果边界不可见,它将不会收到TapGuesture事件。Grid元素无法注册GestureRecognizer,因此事件也无法在那里被捕获。

需要用于棍子和石头图像的ControlTemplates已经就位;现在,它们需要与GamePieceView控件元素关联。Style可以设置GamePieceView元素的ControlTemplate属性,但它如何确定这个元素是棍子还是石头?Style元素有一个Class属性,可以用来进一步细化应用于控件的风格。如果控件在其StyleClass属性中列出了匹配的类名,则应用该Style元素。让我们以棍子为例,按照以下步骤进行:

  1. 将新的Style元素添加到ContentView.Resources元素中,如下所示列表:

    <Style TargetType="controls:GamePieceView"
            Class="Stick">
        <Setter Property="ControlTemplate"
                Value="{StaticResource StickViewControlTemplate}" />
    </Style>
    

    此样式仅应用于类型为GamePiece且在StyleClass属性中列出Stick类的元素。匹配的元素可能如下所示:

    <controls:GamePieceView Grid.Row="0" Grid.Column="1" StyleClass="Stick" 
                            GamePiecePosition="01" GamePieceState="{Binding Game.Sticks[0]}" GamePieceDirection="Horizontal" />
    

    突出的部分显示了用于匹配Style元素的控件部分。StyleClass可以列出多个名称;只需使用逗号分隔名称。

  2. 添加一个新的Style元素。这次,它将应用于StoneViewControlTemplate,如下所示列表:

    <Style TargetType="controls:GamePieceView"
            Class="Stone">
        <Setter Property="ControlTemplate"
                Value="{StaticResource StoneViewControlTemplate}" />
    </Style>
    

棒子和石头元素添加到游戏板网格中所需的所有内容都已具备。要添加剩余的元素,请按照以下步骤进行:

  1. 有七行棍子:四行三列和三行四列。它们几乎相同,但又不完全相同。定位定义游戏板的Grid;它已经添加了角落的点。在 16 个点元素之后,添加以下列表以添加第一行棍子:

    <controls:GamePieceView Grid.Row="0" Grid.Column="1" StyleClass="Stick"
                            GamePiecePosition="01" GamePieceState="{Binding Game.Sticks[0]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="0" Grid.Column="3" StyleClass="Stick" 
                            GamePiecePosition="02" GamePieceState="{Binding Game.Sticks[1]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="0" Grid.Column="5" StyleClass="Stick" 
                            GamePiecePosition="03" GamePieceState="{Binding Game.Sticks[2]}" GamePieceDirection="Horizontal" />
    

    第一行中的每根棍子都是水平显示的。每根棍子都分配了其在GamePiecePosition属性中的位置,并且GamePieceState绑定到该棍子的Game.Sticks对象。Sticks数组是从零开始的,所以数组的索引比GamePiecePosition少一个。

  2. 使用以下列表添加第二行棍子的代码:

    <controls:GamePieceView Grid.Row="1" Grid.Column="0" StyleClass="Stick"
                            GamePiecePosition="04" GamePieceState="{Binding Game.Sticks[3]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="1" Grid.Column="2" StyleClass="Stick" 
                            GamePiecePosition="05" GamePieceState="{Binding Game.Sticks[4]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="1" Grid.Column="4" StyleClass="Stick" 
                            GamePiecePosition="06" GamePieceState="{Binding Game.Sticks[5]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="1" Grid.Column="6" StyleClass="Stick"
                            GamePiecePosition="07" GamePieceState="{Binding Game.Sticks[6]}" GamePieceDirection="Vertical" />
    

    这些元素都是Vertical而不是Horizontal;否则,它们遵循与上一步相同的模式。继续添加剩余的行。

  3. 使用以下列表添加第三行棍子:

    <controls:GamePieceView Grid.Row="2" Grid.Column="1" StyleClass="Stick"
                            GamePiecePosition="08" GamePieceState="{Binding Game.Sticks[7]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="2" Grid.Column="3" StyleClass="Stick"
                            GamePiecePosition="09" GamePieceState="{Binding Game.Sticks[8]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="2" Grid.Column="5" StyleClass="Stick"
                            GamePiecePosition="10" GamePieceState="{Binding Game.Sticks[9]}" GamePieceDirection="Horizontal" />
    
  4. 使用以下列表添加第四行棍子:

    <controls:GamePieceView Grid.Row="3" Grid.Column="0" StyleClass="Stick"
                            GamePiecePosition="11" GamePieceState="{Binding Game.Sticks[10]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="2" StyleClass="Stick"
                            GamePiecePosition="12" GamePieceState="{Binding Game.Sticks[11]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="4" StyleClass="Stick"
                            GamePiecePosition="13" GamePieceState="{Binding Game.Sticks[12]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="6" StyleClass="Stick"
                            GamePiecePosition="14" GamePieceState="{Binding Game.Sticks[13]}" GamePieceDirection="Vertical" />
    
  5. 使用以下列表添加第五行棍子:

    <controls:GamePieceView Grid.Row="4" Grid.Column="1" StyleClass="Stick"
                            GamePiecePosition="15" GamePieceState="{Binding Game.Sticks[14]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="4" Grid.Column="3" StyleClass="Stick"
                            GamePiecePosition="16" GamePieceState="{Binding Game.Sticks[15]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="4" Grid.Column="5" StyleClass="Stick" 
                            GamePiecePosition="17" GamePieceState="{Binding Game.Sticks[16]}" GamePieceDirection="Horizontal" />
    
  6. 使用以下列表添加第六行棍子:

    <controls:GamePieceView Grid.Row="5" Grid.Column="0" StyleClass="Stick"
                            GamePiecePosition="18" GamePieceState="{Binding Game.Sticks[17]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="2" StyleClass="Stick"
                            GamePiecePosition="19" GamePieceState="{Binding Game.Sticks[18]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="4" StyleClass="Stick"
                            GamePiecePosition="20" GamePieceState="{Binding Game.Sticks[19]}" GamePieceDirection="Vertical" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="6" StyleClass="Stick" 
                            GamePiecePosition="21" GamePieceState="{Binding Game.Sticks[20]}" amePieceDirection="Vertical" />
    
  7. 使用以下列表添加第七行棍子:

    <controls:GamePieceView Grid.Row="6" Grid.Column="1" StyleClass="Stick"
                            GamePiecePosition="22" GamePieceState="{Binding Game.Sticks[21]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="6" Grid.Column="3" StyleClass="Stick"
                            GamePiecePosition="23" GamePieceState="{Binding Game.Sticks[22]}" GamePieceDirection="Horizontal" />
    <controls:GamePieceView Grid.Row="6" Grid.Column="5" StyleClass="Stick" 
                            GamePiecePosition="24" GamePieceState="{Binding Game.Sticks[23]}" GamePieceDirection="Horizontal" />
    
  8. 棍子都已添加,现在我们需要添加石头。使用以下列表将九个Stone元素添加到游戏板Grid控件中,遵循棍子的顺序:

    <controls:GamePieceView Grid.Row="1" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[0]}" />
    <controls:GamePieceView Grid.Row="1" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[1]}" />
    <controls:GamePieceView Grid.Row="1" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[2]}" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[3]}" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[4]}" />
    <controls:GamePieceView Grid.Row="3" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[5]}" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[6]}" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[7]}" />
    <controls:GamePieceView Grid.Row="5" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[8]}" />
    

这是对游戏应用的总结。你现在可以在下一节测试项目。

测试完成的项目

这个项目跨越了两个章节,包括第九章,使用 Azure 服务设置游戏后端,以及本章,构建实时游戏。由于这是一个两人回合制游戏,正确配置所有组件可能是一个挑战。按照以下步骤在 Windows 上本地测试你的游戏:

  1. 第一步是让服务在后台运行。在 Visual Studio 中,右键单击SticksAndStones.Functions项目,然后选择调试 | 不调试启动或按Ctrl + F5

图 10.15 – 启动 Azure Functions 服务

图 10.15 – 启动 Azure Functions 服务

这应该会启动一个包含正在运行的 Azure Functions 服务的终端窗口。

  1. 现在,需要两个客户端来玩游戏。在 Windows 上,这意味着 Windows 客户端和 Android 客户端。首先启动 Windows 客户端,并使用与函数相同的方法。确保在调试选项中选择了 Windows 目标:

图 10.16 – 选择 Windows 作为调试目标

图 10.16 – 选择 Windows 作为调试目标

  1. 右键单击SticksAndStones.App项目,然后选择调试 | 不调试启动或按Ctrl + F5。应该会打开一个新窗口,显示登录页面。

  2. 现在,将调试目标切换到 Android:

图 10.17 – 选择 Android 作为调试目标

图 10.17 – 选择 Android 作为调试目标

  1. 现在,要么使用F5在 Android 模拟器中调试应用,要么使用Ctrl + F5仅运行应用。

  2. 使用不同的电子邮件和游戏标签登录到每个应用。

图 10.18 – 登录到游戏

图 10.18 – 登录到游戏

  1. 向其他玩家发起比赛!

图 10.19 – 发出挑战

图 10.19 – 发出挑战

  1. 在一场棍子 和石头游戏中挑战自己吧!

图 10.20 – 比赛已经开始

图 10.20 – 比赛已经开始

Android:不允许到 10.0.2.2 的明文 http 流量

如果你尝试使用 Android 客户端测试游戏,当你尝试向服务器发送移动时,你可能会遇到这个错误。幸运的是,解决方案很简单。在Platforms/Android文件夹中打开MainApplication.cs文件,并将MainApplication类上的Application属性修改为以下内容:

[Application(UsesCleartextTraffic = true)]

如果你遇到任何错误或某些事情没有按预期工作,请返回所有步骤并确保你没有错过任何东西。否则,恭喜你完成了这个项目。

摘要

就这样!做得好!这一章内容丰富,很难将其总结得简短。在这一章中,我们创建了一个连接到我们后端的游戏应用。我们创建了一个服务,用于管理对后端服务的调用并处理错误,所有操作都是异步进行的。我们学习了如何响应 SignalR 的消息,以及如何在应用中使用 IMessenger 接口发送和接收消息。我们创建了自定义控件,并在多个页面中使用它们。我们学习了如何使用 XAML 风格来设计应用,如何使用控件模板,以及如何通过样式来选择它们。我们探讨了路由及其在多页面 .NET MAUI 应用中的工作方式。我们检查了触发器,并了解了如何在不使用 C# 代码和转换器的情况下使用它们来更新界面。

现在,奖励自己,挑战一位朋友在你的新游戏中进行一场比赛。

在下一章中,我们将一起深入研究 Blazor 和 .NET MAUI。

第十一章:使用 .NET MAUI Blazor 构建 Calculator

在本章中,我们将探讨 .NET BlazorWebView。我们还将实现 Blazor 和 .NET MAUI 之间的通信。

在本章中,我们将涵盖以下主题:

  • 什么是 Blazor?

  • 探索 .NET MAUI 项目和 .NET MAUI Blazor 项目的区别

  • 使用 HTML 和 CSS 定义 UI

  • 在 WebView 中使用 XAML 控件与 HTML

  • 编写将与 XAML 控件和 HTML 控件集成的 C# 代码

  • 使用主 .NET MAUI 窗口来调整其大小以适应内容

技术要求

你需要安装 Visual Studio for Mac 或 PC,以及 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章.NET MAUI 简介。本章的源代码可在本书的 GitHub 仓库中找到:github.com/PacktPublishing/MAUI-Projects-3rd-Edition。

项目概述

在本章中,你将了解 .NET Blazor 以及如何使用它与 .NET MAUI 开发应用程序的 UI。我们将探讨在 .NET MAUI 应用程序内托管 Blazor 应用程序的不同选项。两个应用程序之间的通信是互操作性的关键部分,本章的项目将向你展示如何从 .NET MAUI 向 Blazor 发送数据,反之亦然。

什么是 Blazor?

.NET Blazor 是一个基于 .NET 的 Web 框架。Blazor 应用程序通过使用 WebAssemblyWASM)在浏览器中运行,或者通过 SignalR 在服务器上运行。Blazor 是整个 ASP.NET 生态系统的一部分,并且它利用 Razor 页面来开发 UI。Blazor 使用 HTML 和 CSS 来渲染丰富的 UI。Blazor 使用基于组件的 UI,其中每个组件都是一个 Razor 标记页。在一个 Razor 页面中,你可以混合使用 HTML、CSS 和 C# 代码。Blazor 应用程序有三种部署模型:

  • Blazor Server:在 Blazor Server 部署中,应用程序代码在 ASP.NET Core 应用程序的服务器上运行,并通过 SignalR 与在浏览器中运行的 UI 进行通信。

  • Blazor WebAssembly:对于 Blazor WebAssembly,整个应用程序通过 WASM 在浏览器中运行。它是一个开放的网络标准,使得在浏览器中安全运行 .NET 代码成为可能。WASM 提供了与 JavaScript 的互操作性。

  • Blazor Hybrid:Blazor Hybrid 是原生 .NET 和 Web 技术的混合体。Blazor Hybrid 应用程序可以托管在 .NET MAUI、WPFWindows Forms 应用程序中。由于所有宿主都是 .NET,Blazor 运行时在同一个 .NET 进程中本地运行,并将 Razor 页面 Web UI 渲染到 WebView 控件中。

现在我们对 Blazor 有了一些基本的了解,让我们看看本章我们将要构建的应用程序吧!

创建计算器应用程序

在本章中,我们将构建一个计算器应用程序。计算器的 UI 使用 Blazor 中的 Razor 页面构建,但计算器的实际机制位于 .NET MAUI 应用程序中。

设置项目

这个项目,就像所有其他项目一样,是一个 文件 | 新建 | 项目... 风格的项目。这意味着我们根本不会导入任何代码。因此,这个第一部分完全是关于创建项目和设置基本项目结构。

创建新项目

第一步是创建一个新的 .NET MAUI 项目。按照以下步骤操作:

  1. 打开 Visual Studio 2022 并选择 创建一个 新项目

图 11.1 – Visual Studio 2022

图 11.1 – Visual Studio 2022

这将打开 创建一个新 项目 向导。

  1. 在搜索框中输入 blazor 并从列表中选择 .NET MAUI Blazor App 项:

图 11.2 – 创建一个新项目

图 11.2 – 创建一个新项目

  1. 点击 下一步

  2. 如下截图所示,将应用名称输入为 Calculator

图 11.3 – 配置您的全新项目

图 11.3 – 配置您的全新项目

  1. 点击 下一步

    最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持LTS),而 .NET 7 可用为 标准期限支持。对于本书,我们假设您将使用 .NET 7:

图 11.4 – 其他信息

图 11.4 – 其他信息

  1. 通过点击 创建 并等待 Visual Studio 创建项目来最终完成设置。

项目创建到此结束。

让我们通过回顾应用的结构来继续。

探索 .NET MAUI Blazor 混合项目

如果您运行项目,您将看到一个应用,如图 11.5 所示。它并不像 .NET MAUI 应用模板,而是具有独特的网络感觉。稍微探索一下应用,看看所有视觉元素是如何协同工作的。然后,关闭应用程序,返回 Visual Studio,并继续探索项目:

图 11.5 – 运行 .NET MAUI Blazor 模板项目

图 11.5 – 运行 .NET MAUI Blazor 模板项目

.NET MAUI Blazor 应用的结构是 .NET MAUI 应用和 Blazor 应用的混合体。如果您查看通常存在于 .NET MAUI 模板中的 PlatformsResources 文件夹以及 App.xamlMainPage.xamlMauiProgram.cs 文件。wwwrootDataPagesShared 文件夹都支持 Blazor 应用。此外,您将在项目的根目录下找到 _Imports.razorMain.razor

图 11.6 – .NET MAUI Blazor 项目的解决方案资源管理器视图

图 11.6 – .NET MAUI Blazor 项目的解决方案资源管理器视图

如果您需要复习 .NET MAUI 应用的结构和功能,请参阅 第一章。暂时忽略 Blazor 应用的功能,让我们看看 Blazor 应用是如何由 .NET MAUI 托管的。

由于所有 .NET MAUI 程序都从 MauiProgram.cs 文件开始,这似乎是一个好的起点。打开 MauiProgram.cs 文件并检查其内容。以下代码片段突出了 .NET MAUI Blazor 应用的差异:

builder.Services.AddMauiBlazorWebView();
#if DEBUG
    builder.Services.AddBlazorWebViewDeveloperTools();
    builder.Logging.AddDebug();
#endif

第一行高亮显示的行启用了 Blazor 应用的托管服务,特别是 WebView 控制器。第二行高亮显示的行启用了 WebView 控制器内的开发者工具(F12),但仅限于调试配置。

App.xamlApp.xaml.cs 与 .NET MAUI 模板项目中的基本相同,但 MainPage.xaml 则不同。打开 MainPage.xaml 文件来检查其内容,如下所示:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage

    x:Class="Calculator.MainPage"
    BackgroundColor="{DynamicResource PageBackgroundColor}">
    <BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
      <BlazorWebView.RootComponents>
        <RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
      </BlazorWebView.RootComponents>
    </BlazorWebView>
</ContentPage>

MainPage 有一个单独的控制项,BlazorWebView。这是一个包装原生控件以在应用程序中托管网页的包装器。HostPage 属性指向起始页面 - 在这种情况下,wwwroot/index.htmlBlazorWebView.RootComponents 元素标识了 Blazor 应用程序的起始点以及它们在页面上的托管位置。在这种情况下,RootComponent Main 在具有 ID app 的元素中根。

要查看 app 元素的位置,打开 wwwroot 文件夹中的 index.html 文件并检查其内容,如下所示:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
  <title>Calculator</title>
  <base href="/" />
  <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
  <link href="css/app.css" rel="stylesheet" />
  <link href="Calculator.styles.css" rel="stylesheet" />
</head>
<body>
  <div class="status-bar-safe-area"></div>
  <div id="app">Loading...</div>
  <div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
  </div>
  <script src="img/blazor.webview.js" autostart="false"></script>
</body>
</html>

代码中突出显示的文件的关键部分。首先,id 设置为 appdiv 元素是 Main 组件的根或加载位置。在页面的页眉中,识别了样式表。第一个样式表 app.css 位于项目的 wwwroot/css 文件夹中。第二个样式表 Calculator.Styles.css 在构建过程中从隔离的 CSS 文件创建。导入 _framework/blazor.webview.js 文件,该文件负责在页面上正确位置渲染你的 Blazor 组件的所有繁重工作。

在我们继续创建应用程序的其他部分之前,我们需要审查的最后部分是 Blazor 组件。Main.razor 是一个路由文件,它将 Blazor 运行时指向起始组件 MainLayout.razor,如下面的代码所示:

<Router AppAssembly="@typeof(Main).Assembly">
  <Found Context="routeData">
    <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    <FocusOnNavigate RouteData="@routeData" Selector="h1" />
  </Found>
  <NotFound>
    <LayoutView Layout="@typeof(MainLayout)">
      <p role="alert">Sorry, there's nothing at this address.</p>
    </LayoutView>
  </NotFound>
</Router>

MainLayout.razor 文件定义了页面的基本布局,左侧有一个导航栏,主体内容占据了页面的剩余部分,如下面的代码所示:

@inherits LayoutComponentBase
<div class="page">
  <div class="sidebar">
    <NavMenu />
  </div>
  <main>
    <div class="top-row px-4">
      <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
    </div>
    <article class="content px-4">
      @Body
    </article>
  </main>
</div>

@Body 内容由满足路由的页面提供。对于第一个页面,那将是 /。如果你查看 Pages 文件夹中的文件,Index.razor 文件有一个 @page 指令,其参数为 /。因此,默认情况下,那将是显示的页面。@page 指令是一个 Razor 构造,允许满足路由而无需使用控制器。Shared/NavMenu.razor 文件中的 NavLink 条目使用 href 属性引用路由。该值在 @page 指令列表中查找匹配项。如果没有找到匹配项,则在 Main.razor 文件中的 <NotFound> 元素中渲染内容。

打开 Pages/Counter.razor 页面,看看 Razor 页面是如何工作的:

@page "/counter"
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
    private int currentCount = 0;
    private void IncrementCount()
    {
      currentCount++;
    }
}

@page 指令之后,页面由 HTML 和一些 Razor 指令混合组成。然后是 @code 指令,其中包含页面的 C# 代码。HTML 的 button 元素的点击事件通过 @onclick 指令映射到 C# 的 IncrementCount 方法。

了解更多

要了解更多关于 Razor 页面的信息,请查看官方文档:learn.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-7.0

现在,让我们开始创建项目。

准备项目

.NET MAUI 和 Blazor 无缝集成 – 集成得如此之好,以至于有时很难区分在哪里执行。这使得在 XAML 和 HTML 中渲染数据变得非常容易。

让我们从为计算器准备项目开始。我们将首先删除大部分我们不会使用的模板代码。按照以下步骤准备模板:

  1. 在 Visual Studio 中,使用 Data 文件夹。

  2. Pages 文件夹中,删除 Index.razorCounter.RazorFetchData.razor

  3. Shared 文件夹中,删除 NavMenu.razorNavMenu.razor.cssSurveyPrompt.razor

  4. 右键点击 Pages 文件夹,然后选择 添加 | Razor 组件…,如图所示:

图 11.7 – 添加新的 Razor 组件

图 11.7 – 添加新的 Razor 组件

  1. Keypad.razor 中点击 添加

图 11.8 – Razor 组件

图 11.8 – Razor 组件

  1. 在新的 Keypad.razor 文件中,添加以下突出显示的行:

    @page "/"
    <h3>Keypad</h3>
    <div>Keypad goes here</div>
    @code {
    }
    
  2. 打开 Shared 文件夹中的 MainLayout.razor 文件,并删除以下突出显示的部分:

    @inherits LayoutComponentBase
    <div class="page">
        <div class="sidebar">
          <NavMenu />
        </div>
        <main>
          <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
          </div>
          <article class="content px-4">
            @Body
          </article>
        </main>
    </div>
    
  3. 打开 MauiProgram.cs 并删除突出显示的代码行:

    using Calculator.Data;
    using Microsoft.Extensions.Logging;
    namespace Calculator
    {
        public static class MauiProgram
        {
          public static MauiApp CreateMauiApp()
          {
            var builder = MauiApp.CreateBuilder();
            builder
              .UseMauiApp<App>()
              .ConfigureFonts(fonts =>
              {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
              });
            builder.Services.AddMauiBlazorWebView();
    #if DEBUG
          builder.Services.AddBlazorWebViewDeveloperTools();
          builder.Logging.AddDebug();
    #endif
          builder.Services.AddSingleton<WeatherForecastService>();
          return builder.Build();
          }
        }
    }
    
  4. 现在,运行项目以查看更改的效果,如图 图 11.9 所示:

图 11.9 – 在 Windows 上运行空白计算器应用

图 11.9 – 在 Windows 上运行空白计算器应用

现在,项目已经准备好成为一个应用,让我们从 Keypad 视图开始。

创建 Keypad 视图

Keypad 视图是计算器的基本功能。它有 0 到 9 的每个数字按钮,小数分隔符 .*,清除按钮 C,清除所有内容 CE,以及最终,加号 +,减号 -,乘号 x,除号 /,等于 *= 来获取结果,和左箭头 < 来删除最后一个字符。视图有三个基本组件 – HTML 和 CSS 用于样式,以及 C# 代码。以下每个部分将指导您为视图添加每个这些组件,从 HTML 开始。

添加 HTML

Razor 文件和 .NET MAUI XAML 文件没有为你提供可视化的设计器。你必须运行你的应用来查看你所做的更改,这通常涉及到构建、部署,然后导航到应用中的视图。.NET 有一个节省时间的功能叫做热重载。它通过将你对 Razor、XAML、CSS 和 C# 文件所做的更改应用到正在运行的应用程序中,而无需你停止并重新启动应用来实现。在完成下一步操作时尝试使用热重载。要应用更改,请使用调试工具栏中的热重载按钮。它很容易找到——它是一个火焰图标:

图 11.10 – 热重载工具栏按钮

图 11.10 – 热重载工具栏按钮

如果在任何时候你收到类似于以下“粗鲁编辑”对话框,这仅仅意味着更改不能通过热重载应用,因此你需要停止、重新构建并重新启动调试:

图 11.11 – 热重载对话框

图 11.11 – 热重载对话框

热重载一直在不断改进,所以将来这些情况会越来越少。

要添加将为你提供与密钥盘交互的 UI 的 HTML,请按照以下步骤操作:

  1. 通过使用调试器或按F5键启动应用。

  2. 如果你想在 Visual Studio 中查看你的应用,你可以打开XAML 实时预览窗格。要打开XAML 实时预览窗格,请使用 Visual Studio 菜单并选择调试|窗口|XAML 实时预览。我通常将其固定打开,以便始终可用。

  3. Pages文件夹中打开Keypad.razor文件。

  4. 删除以下代码块中显示的突出内容:

    @page "/"
    <h3>Keypad</h3>
    <div>Keypad goes here</div>
    @code {
    }
    
  5. @page之后但在@代码指令之前添加以下 HTML:

    <div class="keypad">
      <div class="keypad-body">
        <div class="keypad-screen">
          <div class="keypad-typed"></div>
        </div>
        <div class="keypad-row">
          <div class="keypad-button wide command">C</div>
          <div class="keypad-button command">CE</div>
          <div class="keypad-button operator">/</div>
        </div>
        <div class="keypad-row">
          <div class="keypad-button">7</div>
          <div class="keypad-button">8</div>
          <div class="keypad-button">9</div>
          <div class="keypad-button operator">X</div>
        </div>
        <div class="keypad-row">
          <div class="keypad-button">4</div>
          <div class="keypad-button">5</div>
          <div class="keypad-button">6</div>
          <div class="keypad-button operator">−</div>
        </div>
        <div class="keypad-row">
          <div class="keypad-button">1</div>
          <div class="keypad-button">2</div>
          <div class="keypad-button">3</div>
          <div class="keypad-button operator">+</div>
        </div>
        <div class="keypad-row">
          <div class="keypad-button">.</div>
          <div class="keypad-button">0</div>
          <div class="keypad-button">&lt;</div>
          <div class="keypad-button operator">=</div>
        </div>
      </div>
    </div>
    

    不要忘记按div而不是table,因为比table元素更容易为div元素添加样式。密钥盘按四按钮一行排列,除了前两行。第一行是显示表达式或结果的显示屏。第二行只有三个按钮。类名已经添加到 HTML 元素中,但由于它们还不存在,它们不会改变外观。

密钥盘看起来还不完全像密钥盘,但一旦我们给它添加一些样式,它就会变得如此。

为 HTML 添加样式

CSS 已经存在很长时间了,是使你的 HTML 看起来最好的最佳方式。按照以下步骤添加我们在上一节中使用的样式:

  1. 确保你的应用仍在运行,并在Pages文件夹中使用Keypad.razor.css。如果 Visual Studio 默认不显示它们,请点击显示所有模板按钮:

图 11.12 – 添加新的 CSS 文件

图 11.12 – 添加新的 CSS 文件

如果你查看Pages文件夹,你会注意到你的新文件现在位于Keypad.razor文件下:

图 11.13 – 带有独立 CSS 文件的 Razor 页面

图 11.13 – 带有独立 CSS 文件的 Razor 页面

Visual Studio 自动识别您想要添加一个Keypad.razor文件。

  1. keypad样式添加到Keypad.razor.css文件中:

    .keypad {
        width: 300px;
        margin: auto;
        margin-top: -1.1em;
    }
    

    这个样式将元素的宽度设置为300px,除了顶部,其值为-1.1em-1.1em将键盘的顶部边缘直接移动到网页视图控制的顶部。

  2. 现在,使用以下代码添加keypad-body样式:

    .keypad-body {
        border: solid 1px #3A4655;
    }
    

    这个样式只是给整个元素添加了一个一像素宽的深灰色边框。

  3. 我们将最后保存keypad-screenkeypad-typed样式,所以添加以下代码中显示的keypad-row样式:

    .keypad-row {
        width: 100%;
        background: #3C4857;
    }
    

    这个样式将元素宽度设置为父元素的 100%,即keypad-body,并将背景设置为令人愉悦的深灰色。

  4. 接下来要添加的样式是keypad-button。使用以下代码添加样式:

    .keypad-button {
        width: 25%;
        background: #425062;
        color: #fff;
        padding: 20px;
        display: inline-block;
        font-size: 25px;
        text-align: center;
        vertical-align: middle;
        margin-right: -4px;
        border-right: solid 2px #3C4857;
        border-bottom: solid 2px #3C4857;
        transition: all 0.2s ease-in-out;
    }
    

    这个样式是所有按键按钮的基础,因此它具有最多的属性。应用了这个样式的元素在其右侧和底部有 2 像素宽的边框,并且使用与行背景相同的颜色。按钮的background属性比边框颜色略暗,这提供了一点深度。文本在垂直和水平方向上居中对齐,并使用 25 像素的字体大小。宽度设置为 25%,因为每行通常有四个按钮。transition属性使用ease-in-out进行 200 毫秒的过渡,这从开始加速到中间,然后从中间减速到结束。transition应用于所有属性,所以每当这个样式的属性发生变化时,它都会从起始值缓慢变化到结束值。

  5. 如果按钮是动作按钮,例如运算符,则应用一个额外的样式,称为operator。这个样式的定义与迄今为止创建的其他样式略有不同。这个样式不仅被命名为operator,而是命名为keypad-button.operator。在 CSS 中,.是一个选择器;它用于定位要应用哪些属性。在这种情况下,我们想要所有同时应用了keypad-button类和operator类的元素。要添加keypad-button.operator类,请使用以下代码:

    .keypad-button.operator {
        color: #AEB3BA;
        background: #404D5E;
    }
    

    这些按钮将以略暗的背景和略少的白色文字显示。

  6. 清除(C)和清除所有(CE)按钮也有它们自己的类,如下所示:

    .keypad-button.command {
        color: #D95D4E;
        background: #404D5E;
    }
    

    这些按钮将以略暗的背景和红色文字显示。

  7. 现在,对于桌面,我们可以通过使用:hover伪选择器来添加悬停高亮。使用以下代码添加悬停样式:

    .keypad-button:hover {
        background: #E0B612;
    }
    .keypad-button.command:hover,
    .keypad-button.operator:hover {
        background: #E0B612;
        color: #fff;
    }
    

    背景被改为橙色。由于keypad-button样式中存在transition属性,所以变化不会立即发生,它将在两十分之一秒内从深灰色过渡到橙色。

  8. 最后一个与按钮相关的样式是宽的,或者 keypad-button.wide。这种样式使按钮的宽度是普通按钮的两倍。要添加此样式,请使用以下代码:

    .keypad-button.wide {
        width: 50%;
    }
    
  9. 最后两个样式,keypad-screenkeypad-typed,用于显示表达式和结果。使用以下代码添加剩余的两个样式:

    .keypad-screen {
        background: #3A4655;
        width: 100%;
        height: 75px;
        padding: 20px;
    }
    .keypad-typed {
        font-size: 45px;
        text-align: right;
        color: #fff;
    }
    

现在,键盘看起来像真正的计算器键盘;请参阅 图 11**.14 以获取示例。你是否能够在不重新启动应用程序的情况下继续添加样式并看到更改?请记住点击 热重载 按钮,或在 热重载 按钮菜单下设置 在文件保存时热重载 选项;Visual Studio 将在您保存文件时尝试应用更改。接下来,我们将添加使按钮能够工作的代码:

图 11.14 – 带样式的 HTML 键盘

图 11.14 – 带样式的 HTML 键盘

连接控件

在大多数网页中,您会使用 Keypad.razor 文件,按照以下步骤操作:

  1. @code 指令块内,添加以下内容:

    string inputDisplay = string.Empty;
    bool clearInputBeforeAppend = false;
    

    这声明了一个名为 inputDisplaystring 字段,并将其初始化为空字符串。它还声明了一个 bool 字段,并将其初始化为 falseclearInputBeforeAppend 是一个标志,用于保持 inputDisplay 的清洁。在显示结果后,当用户轻触按钮时,应在将字符添加到屏幕之前清除 inputDisplay

  2. 更新具有 keypad-typed 类的元素,如下所示:

    <div class="keypad-typed">@inputDisplay</div>
    

    这将渲染 inputDisplay 变量的内容到 div 元素中。注意使用 @ 来引用 C# 字段。

  3. 为了帮助验证输入,请添加以下内容:

    readonly char[] symbols = { '/', 'X', '+', '-', '.' };
    
  4. 当按下任何数字(0 到 9)或操作按钮时,inputDisplay 将通过向显示中添加一个字符来更新。使用以下代码添加 AppendInput 方法:

    void AppendInput(string inputValue)
    {
        double numValue;
        if (clearInputBeforeAppend)
        {
            inputDisplay = string.Empty;
        }
        if (string.IsNullOrEmpty(inputDisplay) && inputValue.IndexOfAny(symbols) != -1)
        {
            return;
        }
        if (!double.TryParse(inputValue, out numValue) && !string.IsNullOrEmpty(inputDisplay) && $"{inputDisplay[¹]}".IndexOfAny(symbols) != -1)
        {
            return;
        }
        if (inputDisplay.Trim() == "0" && inputValue == "0")
        {
            return;
        }
        clearInputBeforeAppend = false;
        inputDisplay += inputValue;
    }
    

    让我们回顾一下代码。首先检查 inputDisplay 是否需要清除;如果是,则清除。然后进行操作符的检查。下一个检查更复杂,因为它不允许一行中有多个操作符。此检查使用 ¹Range 语法来指示最后一个字符。使用字符串插值将最后一个字符转换回字符串,以便 IndexOfAny 可以在符号数组中找到该字符。检查并拒绝多个前导 0。如果所有检查都通过,则将输入追加到 inputDisplay,并将 clearInputBeforeAppend 标志重置为 false

  5. 当用户使用以下代码按下 Undo 方法时:

    void Undo()
    {
        if (!clearInputBeforeAppend && inputDisplay.Length > 0)
        {
            inputDisplay = inputDisplay[0..¹];
            return;
        }
    }
    

    此方法再次使用 Range 语法,以及字符串能够像数组一样索引的能力。它使用数组语法从索引 0 到下一个最后一个索引获取元素,并返回它。

  6. 当用户使用以下代码按下 ClearInput 方法时:

    void ClearInput()
    {
        inputDisplay = string.Empty;
    }
    
  7. 当用户按下 ClearAll 方法时:

    void ClearAll()
    {
        ClearInput();
    }
    
  8. 最后,EvaluateExpression 方法:

    void EvaluateExpression()
    {
        var expression = inputDisplay;
        clearInputBeforeAppend = true;
    }
    

    此方法尚未评估输入的表达式;这将在 创建计算 服务 部分中发生。

Keypad.razor 文件中的下一步是连接刚刚定义的方法,以便在用户点击或触摸该元素时调用它们。就像在标准 HTML 中一样,事件通过引用代码的元素属性连接起来,无论是内联还是方法。Razor 页面中的属性使用页面指令与事件名称相关联。例如,处理触摸或点击的 DOM 事件是 click,因此 Razor 事件名称将是 @onclick。该属性随后被分配给一个代表,它可以是任何方法。完整的属性可能看起来像 @onclick="DoSomething",其中 DoSomething 是在页面的 @code 指令中定义的 C# 方法。

AppendInput 方法接受一个字符串参数,因此代表不能只是 AppendInput – 它必须被包裹在一个表达式中,以便可以将参数传递下去。Razor 页面中的表达式包含在 @(…) 指令中。所有从事件指令对 AppendInput 的调用都将类似于 @onclick="@(AppendInput("0"))"

使用以下代码中高亮显示的行来更新 Keypad.razor 文件中的 HTML:

<div class="keypad">
    <div class="keypad-body">
        <div class="keypad-screen">
            <div class="keypad-typed">@inputDisplay</div>
        </div>
        <div class="keypad-row">
            <div class="keypad-button wide command" @onclick="ClearInput">C</div>
            <div class="keypad-button command" @onclick="ClearAll">CE</div>
            <div class="keypad-button operator" @onclick="@(()=>AppendInput("/"))">/</div>
        </div>
        <div class="keypad-row">
            <div class="keypad-button" @onclick="@(()=>AppendInput("7"))">7</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("8"))">8</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("9"))">9</div>
            <div class="keypad-button operator" @onclick="@(()=>AppendInput("X"))">X</div>
        </div>
        <div class="keypad-row">
            <div class="keypad-button" @onclick="@(()=>AppendInput("4"))">4</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("5"))">5</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("6"))">6</div>
            <div class="keypad-button operator" @onclick="@(()=>AppendInput("-"))">−</div>
        </div>
        <div class="keypad-row">
            <div class="keypad-button" @onclick="@(()=>AppendInput("1"))">1</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("2"))">2</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("3"))">3</div>
            <div class="keypad-button operator" @onclick="@(()=>AppendInput("+"))">+</div>
        </div>
        <div class="keypad-row">
            <div class="keypad-button" @onclick="@(()=>AppendInput("."))">.</div>
            <div class="keypad-button" @onclick="@(()=>AppendInput("0"))">0</div>
            <div class="keypad-button" @onclick="Undo">&lt;</div>
            <div class="keypad-button operator" @onclick="EvaluateExpression">=</div>
        </div>
    </div>
</div>

更多关于 Razor 事件处理的信息

要了解更多关于 Razor 事件处理的信息,请访问 https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-7.0。

键盘几乎完成了。在此阶段,你应该能够输入一个完整的表达式进行评估,然后清除显示屏。在下一节中,你将创建一个用于评估表达式的服务。

创建计算服务

Compute 服务评估表达式并返回结果。为了说明 .NET MAUI 和 Blazor 应用程序如何相互交互,此服务将从 .NET MAUI 依赖注入容器注入到 Blazor 页面中。要实现 Compute 服务,请按照以下步骤操作:

  1. 在项目的根目录下创建一个名为 Services 的新文件夹。

  2. Services 文件夹中,添加一个名为 Compute 的新 C# 类文件。

  3. 修改 Compute.cs 文件,使其与以下代码匹配:

    namespace Calculator.Services;
    internal class Compute
    {
        public string Evaluate(string expression)
        {
            System.Data.DataTable dataTable = new System.Data.DataTable();
            var finalResult = dataTable.Compute(expression, string.Empty);
            return finalResult.ToString();
        }
    }
    

    这段代码可能比你预期的要短。而不是编写大量代码来解析表达式并构建一个用于评估的表达式树,已经存在一种内置的方式来评估简单的表达式:DataTableDataTable.Compute 方法可以评估从计算器构建的所有表达式。

  4. 打开 MauiProgram.cs 文件,并添加以下高亮显示的代码行以使用依赖注入注册类:

    #if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Logging.AddDebug();
    #endif
        builder.Services.AddSingleton<Compute>();
        return builder.Build();
    
  5. 为了允许 Keypad.razor 页面使用 Compute 类型,它需要使用 Razor 声明。打开 _Imports.razor 文件,并在文件末尾添加以下高亮显示的代码行:

    @using Calculator.Services
    

    没有此行,你仍然可以使用该类型,但必须完全限定它为Calculator.Services.Compute。这是 Razor 中 C#文件中global using指令的等效项。

  6. 现在,打开Keypad.razor文件,在@page指令之后添加以下代码行:

    @inject Compute compute
    

    @inject指令将使用.NET MAUI 依赖注入容器解析第一个参数提供的类型,并将其分配给第二个参数定义的变量。

  7. EvaluateExpression方法中,可以使用compute变量来评估inputDisplay中包含的表达式,如下所示突出显示的代码:

    void EvaluateExpression()
    {
        var expression = inputDisplay;
        var result = compute.Evaluate(inputDisplay.Replace('X', '*'));
        inputDisplay = result;
        clearInputBeforeAppend = true;
    }
    

    在这里,使用inputDisplay作为参数调用了Evaluate方法。首先将inputDisplay修改为将字符串中的所有X值替换为*,因为这是DataTable期望用于乘法的。然后将结果分配给inputDisplay

到目前为止,计算器应用程序可以接受数字和运算符的组合输入,并评估结果,将其显示给用户。用户还可以清除显示。在下一节中,我们将通过给计算器添加内存来探索更多的互操作性。

添加内存功能

大多数计算器可以存储以前的计算。在本节中,你将向计算器应用程序添加一个以前计算列表,并将以前的计算召回到键盘的inputDisplay参数。

代码将使用.NET MAUI 控件在WebViewControl旁边渲染。将使用一个名为Calculations的新类来管理列表。

要将内存功能添加到计算器应用程序中,请按照以下步骤操作:

  1. 创建一个名为ViewModels的新文件夹。

  2. ViewModels文件夹中添加一个名为Calculations的新类,并修改文件以匹配以下代码:

    using System.Collections.ObjectModel;
    namespace Calculator.ViewModels;
    public class Calculations : ObservableCollection<Calculation>
    {
    }
    public class Calculation : Tuple<string, string>
    {
        public Calculation(string expression, string result) : base(expression, result) { }
        public string Expression => this.Item1;
        public string Result => this.Item2;
    }
    

    此代码添加了两个类 - Calculations,它是Observable Collection<Calculation>的简称,以及Calculation,它是Tuple<string, string>,并定义了两个属性来引用Item1作为ExpressionItem2作为Result

  3. 添加对CommunityToolkit.Mvvm NuGet 包的引用。

  4. ViewModels文件夹中添加一个名为MainPageViewModel的新类,并修改文件,如下所示代码:

    using CommunityToolkit.Mvvm.Input;
    using CommunityToolkit.Mvvm.Messaging;
    namespace Calculator.ViewModels;
    public partial class MainPageViewModel
    {
        IMessenger messenger;
        public MainPageViewModel(Calculations results, IMessenger messenger)
        {
            Results = results;
            this.messenger = messenger;
        }
        public Calculations Results { get; init; }
        [RelayCommand]
        public void Recall(Calculation sender)
        {
            messenger.Send(sender);
        }
    }
    

    MainViewModel类使用CommunityToolkit的两个功能:RelayCommandIMessenger。与其他章节一样,RelayCommand用于将方法绑定到 XAML 操作。IMessenger是一个用于在应用程序的不同部分之间发送消息的接口。当你不希望两个类之间有硬依赖时,它非常有用,尤其是如果它创建了一个循环引用。CommunityToolkit提供了一个名为WeakReferenceMessengerIMessenger的默认实现。

  5. 打开MauiProgram.cs文件,在文件顶部添加以下using声明:

    using Calculator.ViewModels;
    using CommunityToolkit.Mvvm.Messaging;
    
  6. CreateMauiApp方法中,进行以下突出显示的更改:

    builder.Services.AddSingleton<Compute>();
    builder.Services.AddSingleton<Calculations>();
    builder.Services.AddSingleton<MainPage>();
    builder.Services.AddSingleton<MainPageViewModel>();
    builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
    return builder.Build();
    

    这会将 MainPageMainPageViewModelWeakReferenceMessenger 的默认实例添加到依赖注入容器中。接下来的几个步骤将使 MainPage 能够通过依赖注入进行初始化。

  7. 打开 MainView.xaml.cs 文件,并进行以下高亮显示的更改:

    using Calculator.ViewModels;
    namespace Calculator;
    public partial class MainPage : ContentPage
    {
        public MainPage(MainPageViewModel vm)
        {
            InitializeComponent();
            BindingContext = vm;
        }
    }
    

    如同其他章节,视图的构造函数被更新以接受视图模型作为参数,并将其分配为 BindingContext

  8. 打开 App.xaml.cs 文件,修改构造函数,并添加 OnHandlerChanging 事件处理程序,如下所示:

    public App()
    {
        InitializeComponent();
    }
    protected override void OnHandlerChanging(HandlerChangingEventArgs args)
    {
        base.OnHandlerChanging(args);
        MainPage = args.NewHandler.MauiContext.Services.GetService<MainPage>();
    }
    

    由于 .NET MAUI Blazor 应用程序默认不使用 Shell,因此视图不能像使用 Shell 那样通过依赖注入进行初始化。相反,在设置 Handler 之后,将获取 MainPage 的实例。使用 OnHandlerChanging 事件来获取新 Handler 的引用,然后它为依赖注入容器提供 MauiContext

  9. 打开 _Imports.razor 文件,并将以下行添加到文件末尾:

    @using Calculator.ViewModels
    @using CommunityToolkit.Mvvm.Messaging
    
  10. 打开 Keypad.razor 文件,并添加以下高亮显示的行:

    @inject Compute compute
    @inject Calculations calculations
    @inject IMessenger messenger
    <div class="keypad">
    

    这将从 .NET MAUI 依赖注入容器中注入 Calculations 实例作为 calculationsWeakReference 信使 作为 messenger

  11. 修改 ClearAllEvaluateExpression 方法,并添加一个 OnAfter RenderAsync 方法,如下所示:

    void ClearAll()
    {
        ClearInput();
        calculations.Clear();
    }
    void EvaluateExpression()
    {
        var expression = inputDisplay;
        var result = compute.Evaluate(inputDisplay.Replace('X', '*'));
        calculations.Add(new(expression, result));
        inputDisplay = result;
        clearInputBeforeAppend = true;
    }
    protected override Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            messenger.Register<Calculation>(this, (sender, er) =>
            {
                inputDisplay = er.Expression;
                clearInputBeforeAppend = true;
                StateHasChanged();
            });
        }
        return base.OnAfterRenderAsync(firstRender);
    ClearAll will just clear the collection, and EvaluateExpression will add the new Calulation to the collection. OnAfterRenderAsync is used to register this class to receive messages for any Calculation objects. When a message is received, inputDisplay is set to the Expression value of Calculation, and StateHasChanged is called to force the UI to refresh with the updated value.
    
  12. 打开 Shared/MainLayout.razor.css 文件,并将以下行添加到 page 类中:

    background-color: black;
    

    这只是为了美观,使得计算器周围区域变为黑色。

  13. 打开 MainPage.xaml 文件,并修改它以匹配以下代码:

    <ContentPage
    
        x:Class="Calculator.MainPage"
        x:DataType="viewModels:MainPageViewModel">
        <Grid BackgroundColor="Black">
          <Grid.RowDefinitions>
            <RowDefinition Height="1*" />
            <RowDefinition Height="1*" />
          </Grid.RowDefinitions>
          <ScrollView Grid.Row="0" BackgroundColor="Bisque" WidthRequest="400" VerticalScrollBarVisibility="Always">
            <CollectionView ItemsSource="{Binding Results}" ItemsUpdatingScrollMode="KeepLastItemInView">
              <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="viewModels:ExpressionResult">
                  <SwipeView>
                    <SwipeView.LeftItems>
                      <SwipeItems Mode="Execute">
                        <SwipeItem
    Text="Recall"                             BackgroundColor="LightPink"                                       Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:MainPageViewModel}}, Path=RecallCommand}"                                      CommandParameter="{Binding}"/>
                        </SwipeItems>
                      </SwipeView.LeftItems>
                      <VerticalStackLayout>
                        <HorizontalStackLayout Padding="10" HorizontalOptions="EndAndExpand">
                          <Label Text="{Binding Expression}" FontSize="Large" TextColor="Black" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
                          <Label Text="=" TextColor="Blue" FontSize="Large" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
                          <Label Text="{Binding Result}" FontSize="Large" TextColor="Black" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
                        </HorizontalStackLayout>
                        <Line Stroke="LightSlateGray" X2="400" />
                        <Line Stroke="Black" X2="400" />
                      </VerticalStackLayout>
                    </SwipeView>
                  </DataTemplate>
                </CollectionView.ItemTemplate>
              </CollectionView>
          </ScrollView>
          <BlazorWebView Grid.Row="1" x:Name="blazorWebView" HostPage="wwwroot/index.html" HeightRequest="540">
            <BlazorWebView.RootComponents>
              <RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
            </BlazorWebView.RootComponents>
          </BlazorWebView>
        </Grid>
    </ContentPage>
    

    使用定义了两行的 Grid 元素来包含新的显示区域,用于之前的计算和原始的 BlazorWebView 控件。计算使用 ScrollView 渲染,其中包含 CollectionViewCollectionView.ItemTemplate 属性包含每个 CalculationDataTemplateSwipeView 控件允许用户向上、向下、向左或向右滑动以显示额外的命令。每个方向都有一个元素来定义这些操作。对于 Calculation 项目,当用户向右滑动时,它会显示一个 Recall 项目,该项目绑定到 MainPageViewModelRecall 命令。Calculation 的显示使用水平和垂直 StackLayout 控件的组合来堆叠 ExpressionResult 之上,并用 = 对齐,所有内容都向左对齐。

这就完成了我们计算器应用的主要功能。下一节将处理在桌面操作系统(如 Windows 或 macOS)上运行时的主窗口的一些美学问题。

调整主窗口大小

计算器应用被定义为固定大小。本书中的大多数项目都允许控件随着窗口大小的变化而增长或缩小。对于我们的计算器应用,主窗口应该是固定的,以便以最佳方式显示。要在应用启动时固定窗口大小,打开 App.xaml.cs 文件,并将以下方法添加到 App 类中:

protected override Window CreateWindow(IActivationState activationState)
{
    var window = base.CreateWindow(activationState);
    if (OperatingSystem.IsWindows() || OperatingSystem.IsMacCatalyst())
    {
        window.Created += Window_Created;
    }
    return window;
}
private async void Window_Created(object sender, EventArgs e)
{
    const int defaultWidth = 450;
    const int defaultHeight = 800;
    var window = (Window)sender;
    window.Width = defaultWidth;
    window.Height = defaultHeight;
    window.X = -defaultWidth;
    window.Y = -defaultHeight;
    await window.Dispatcher.DispatchAsync(() => { });
    var displayInfo = DeviceDisplay.Current.MainDisplayInfo;
    window.X = (displayInfo.Width / displayInfo.Density - window.Width) / 2;
    window.Y = (displayInfo.Height / displayInfo.Density - window.Height) / 2;
    window.Created -= Window_Created;
}

CreateWindow 方法被重写,以便当应用在 Windows 或 macOS 上运行时,可以给 Window.Created 事件附加一个自定义处理程序。调整窗口大小的操作在 Window_Created 方法中完成。它使用 defaultHeightdefaultWidth 常量来设置新窗口的高度、宽度和屏幕上的位置。然后,该方法等待所有线程完成后再再次更改窗口的 XY 属性,但这次要考虑到屏幕像素密度。最后,它断开事件处理程序

图 11.15 – 完成的计算器项目

图 11.15 – 完成的计算器项目

摘要

优秀的工作!在本章中,你完成了一个使用 .NET MAUI Blazor 模板的项目。你使用 HTML 创建了一个 UI,并用 C# 代码更新它,然后实现了一个由 .NET MAUI 管理并注入到 Razor 页面中的服务。然后,你使用 CollectionView 显示之前计算列表。在 CollectionViewItemTemplate 中,使用了 SwipeView 控件来将之前的计算召回键盘进行额外的编辑和重新评估。

要进一步扩展此项目,请考虑以下:

  • 为集合添加一个额外的滑动操作以删除一个计算

  • 为科学计算添加一个额外的键盘布局

在下一章——也是最后一章中,你将随着构建一个物体识别应用而发现人工智能的世界。

第十二章:热狗还是非热狗:使用机器学习

在本章中,我们将学习如何使用机器学习创建一个模型,我们可以用它来进行图像分类。我们将导出为Onnx模型,这样我们就可以在所有平台上使用它——也就是说,Android、iOS、macOS 和 Windows。为了训练和导出模型,我们将使用 Azure 认知服务和自定义视觉服务。

一旦我们导出了模型,我们将学习如何在.NET MAUI 应用程序中使用它们。

本章将涵盖以下主题:

  • 使用 Azure 认知服务和自定义视觉服务训练模型

  • 使用 ML.NET 和 Onnx 模型进行图像分类

  • 在.NET MAUI 中使用自定义路由进行导航

技术要求

为了能够完成这个项目,你需要安装 Visual Studio for Mac 或 PC,以及.NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅第一章.NET MAUI 简介。你还需要一个 Azure 账户。如果你有 Visual Studio 订阅,每个月包含一定数量的 Azure 积分。要激活你的 Azure 福利,请访问my.visualstudio.com

你也可以创建一个免费账户,在那里你可以免费使用选定的服务长达 12 个月。你将获得价值 200 美元的积分,用于探索任何 Azure 服务 30 天,你还可以随时使用免费服务。更多信息请参阅azure.microsoft.com/en-us/free/

如果你没有并且不想注册免费的 Azure 账户,本章源代码中提供了训练好的模型。你可以下载并使用预训练模型。

本章的源代码可在本书的 GitHub 仓库github.com/PacktPublishing/MAUI-Projects-3rd-Edition中找到。

机器学习

术语机器学习由 1959 年的美国人工智能先驱 Arthur Samuel 提出。美国计算机科学家 Tom M. Mitchell 后来提供了以下更正式的机器学习定义:

“如果一个计算机程序在任务 T 和性能度量 P 方面从经验 E 中学习,那么它的性能随着经验 E 的提高而提高。”

用更简单的话说,这句话描述了一个可以不经过明确编程就能学习的计算机程序。在机器学习中,算法用于构建样本数据或训练数据的数学模型。这些模型用于计算机程序,以便在没有为特定任务明确编程的情况下做出预测和决策。

在本节中,我们将了解一些不同的机器学习服务和 API,这些服务和 API 在开发.NET MAUI 应用程序时可用。一些 API 仅适用于特定平台,如 Core ML,而其他则是跨平台的。

Azure 认知服务 – 定制视觉

定制视觉是一个工具或服务,可以用来训练图像分类模型和检测图像中的对象。使用定制视觉,我们可以上传自己的图像并对其进行标记,以便进行图像分类训练。如果我们为对象检测训练一个模型,我们还可以标记图像的特定区域。因为模型已经为基本图像识别进行了预训练,所以我们不需要大量的数据就能得到很好的结果。建议每个标签至少有 30 张图像。

当我们训练了一个模型后,我们可以使用 API 来使用它,这是定制视觉服务的一部分。我们还可以导出模型用于 Core MLiOS)、TensorFlowAndroid)、Open Neural Network ExchangeONNX)以及 DockerfileAzure IoT EdgeAzure FunctionsAzure ML)。这些模型可以在不连接到定制视觉服务的情况下执行分类或对象检测。

使用它需要 Azure 订阅 - 请访问 azure.com/free 创建一个免费订阅,这应该足以完成此项目。

Core ML

Core ML 是在 iOS 11 中引入的一个框架。Core ML 使得将机器学习模型集成到 iOS 应用中成为可能。在 Core ML 的基础上,我们还有以下高级 API:

  • 用于图像分析的视觉 API

  • 用于自然语言处理的自然语言 API

  • 将音频转换为文本的语音识别

  • 声音分析以识别音频中的声音

  • 使用 GameplayKit 评估学习到的决策树和策略

更多信息

更多关于 Core ML 的信息可以在苹果官方文档中找到,请访问 developer.apple.com/documentation/coreml

TensorFlow

TensorFlow 是一个开源的机器学习框架。然而,TensorFlow 不仅可以用在移动设备上运行模型,还可以用来训练模型。要在移动设备上运行它,我们使用 TensorFlow Lite。从 Azure 认知服务导出的模型是为 TensorFlow Lite 设计的。还有 TensorFlow Lite 的 C# 绑定,它作为一个 NuGet 包提供。

更多信息

更多关于 TensorFlow 的信息可以在官方文档中找到,请访问 www.tensorflow.org/

ML.Net

ML.Net 是一个开源且跨平台的机器学习框架,支持 iOS、macOS、Android 和 Windows,所有这些都可以在熟悉的环境中完成 - C#。ML.Net 提供了 AutoML,一套生产力工具,使构建、训练和部署自定义模型变得简单。ML.Net 可以用于以下场景和更多:

  • 情感分析和产品推荐

  • 目标检测和图像分类

  • 价格预测、销售峰值检测和预测

  • 欺诈检测

  • 客户细分

现在我们对正在使用的技术的概览已经比较广泛了,我们将专注于使用 ML.NET,因为它是一个跨平台框架,专为 C# 构建。让我们看看我们接下来要构建的项目。

项目概览

如果你看过电视剧 硅谷,你可能已经听说过 Not Hotdog 应用程序。在本章中,我们将学习如何构建这个应用程序。本章的第一部分将涉及收集我们将用于创建一个能够检测照片中是否包含热狗的机器学习模型的数据。

在本章的第二部分,我们将使用 .NET MAUI 和 ML.NET 构建一个应用程序,用户可以拍摄一张新照片或从照片库中选择一张照片,分析它以查看是否包含热狗。完成此项目的估计时间为 120 分钟。

开始

我们可以使用安装在 PC 上的 Visual Studio 2022 或 Visual Studio for Mac 来完成这个项目。如果你想在 PC 上使用 Visual Studio 构建 iOS 应用程序,你必须连接一台 Mac。如果你根本无法访问 Mac,你可以选择只完成这个项目的 Android 和 Windows 部分。

同样,如果你只有 Mac,你也可以选择只完成这个项目的 iOS 和 macOS 或 Android 部分。

使用机器学习构建热狗或非热狗应用程序

让我们开始吧!我们将首先训练一个用于图像分类的模型,我们可以在本章的后面使用它来判断照片中是否包含热狗。

注意

如果你不想费心训练模型,你可以从以下网址下载一个预训练的模型:github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/main/Chapter12/HotdogOrNot/Resources/Raw

训练模型

要训练一个用于图像分类的模型,我们需要收集热狗的照片以及不是热狗的照片。由于世界上大多数物品都不是热狗,我们需要更多不包含热狗的照片。如果热狗的照片涵盖了多种不同的热狗场景——比如有面包、番茄酱或芥末,那就更好了。这样,模型就能在不同的情境中识别出热狗。当我们收集不是热狗的照片时,我们也需要有一大批照片,这些照片既包含类似热狗的物品,也完全不同于热狗。

GitHub 上的解决方案中的模型是用 240 张照片训练的,其中 60 张是热狗的照片,180 张不是。

一旦我们收集了所有照片,我们就可以开始按照以下步骤训练模型:

  1. 前往 customvision.ai.

  2. 登录并创建一个新的项目。

  3. 给项目起一个名字——在我们的例子中,HotDogOrNot

  4. 通过点击 创建新资源 选择一个资源或创建一个新的资源。填写对话框,并在 类型 下拉菜单中选择 CustomVision.Training

    项目类型应该是分类,分类类型应该是多类(每张图片一个标签)。

  5. 将域选择为通用(紧凑)。如果我们想导出模型并在移动设备上运行,我们使用紧凑域。

  6. 点击创建项目继续,如下截图所示:

图 12.1 – 创建新的 AI 项目

图 12.1 – 创建新的 AI 项目

创建项目后,我们可以开始上传图片并标记它们。

标记图片

获取图片最简单的方法是去谷歌搜索。我们将通过以下步骤添加热狗的照片:

  1. 点击添加图片

  2. 选择应该上传的热狗照片。

  3. 标记照片为hotdog,如下截图所示:

图 12.2 – 上传热狗的图片

图 12.2 – 上传热狗的图片

上传所有热狗照片后,就是时候按照以下步骤上传非热狗的照片了。为了获得最佳结果,我们还应该包括看起来像热狗但实际上不是的照片:

  1. 点击上传图片画廊上方的添加图片按钮。

  2. 选择那些不是热狗的照片。

  3. Negative标签标记照片。

    使用Negative标签标记不包含我们创建的其他标签的任何对象的图片。在这种情况下,我们上传的图片中没有任何热狗,如下截图所示:

图 12.3 – 上传非热狗的图片

图 12.3 – 上传非热狗的图片

上传照片后,就是时候训练一个模型了。

训练模型

我们上传的并非所有照片都会用于训练;其中一些将用于验证,以给我们一个关于模型好坏的评分。如果我们分批上传照片并在每批之后训练模型,我们将能够看到我们的评分在提高。要训练一个模型,请点击页面顶部的绿色训练按钮,如下截图所示:

图 12.4 – 训练模型

图 12.4 – 训练模型

下面的截图显示了训练迭代的结果,其中模型的精确度为91.7%

图 12.5 – 模型验证结果

图 12.5 – 模型验证结果

训练好模型后,我们将导出它,以便在设备上使用。

导出模型

如果需要,我们可以使用 API,但为了快速分类并能够离线操作,我们将模型添加到应用包中。点击导出按钮,然后选择ONNX下载模型,如下截图所示:

图 12.6 – 导出模型

图 12.6 – 导出模型

下载了 ONNX 模型之后,就是时候构建应用了。

构建应用

我们的应用程序将使用训练好的模型来分类照片,根据它们是否是热狗的照片。我们将使用相同的 ONNX 模型在 .NET MAUI 应用程序的所有平台上。

创建新项目

让我们开始,如下所示。

第一步是创建一个新的 .NET MAUI 项目:

  1. 打开 Visual Studio 2022,并选择 创建新项目

图 12.7 – Visual Studio 2022

图 12.7 – Visual Studio 2022

这将打开 创建新项目 向导。

  1. 在搜索框中输入 maui,并从列表中选择 .NET MAUI 应用 项:

图 12.8 – 创建新项目

图 12.8 – 创建新项目

  1. 点击 下一步

  2. 通过命名项目来完成向导的下一步。在这种情况下,我们将应用程序命名为 HotdogOrNot。通过点击 下一步,如图所示继续到下一个对话框:

图 12.9 – 配置您的项目

图 12.9 – 配置您的项目

  1. 最后一步将提示您选择支持 .NET Core 的版本。在撰写本文时,.NET 6 可用作为 长期支持LTS),而 .NET 7 可用作为 标准期限支持。在本书中,我们假设您正在使用 .NET 7。

图 12.10 – 其他信息

图 12.10 – 其他信息

  1. 通过点击 创建 完成设置,并等待 Visual Studio 创建项目。

如果现在运行应用程序,您应该会看到以下类似的内容:

图 12.11 – HotdogOrNot 应用程序

图 12.11 – HotdogOrNot 应用程序

就这样,应用程序就创建完成了。接下来,让我们开始创建图像分类器。

使用机器学习进行图像分类

我们将首先通过以下步骤将 ONNX ML 模型添加到项目中:

  1. 从 Custom Vision 服务中获取的 .zip 文件进行解压。

  2. 找到 .onnx 文件,并将其重命名为 hotdog-or-not.onnx

  3. 将其添加到项目中的 Resources/Raw 文件夹。

一旦我们将文件添加到项目中,我们就可以开始创建图像分类器的实现。我们将用于图像分类的代码将在 .NET MAUI 支持的平台上共享。我们可以通过以下步骤创建分类器的接口:

  1. 创建一个名为 ImageClassifier 的新文件夹。

  2. ImageClassifier 文件夹中创建一个名为 ClassifierOutput 的新类。

  3. 修改 ClassifierOutput 类,使其看起来如下:

    namespace HotdogOrNot.ImageClassifier;
    internal sealed class ClassifierOutput
    {
        ClassifierOutput() { }
    }
    
  4. ImageClassifier 文件夹中创建一个名为 IClassifier 的新接口。

  5. 添加一个名为 Classify 的方法,该方法返回 ClassifierOutput 并接受 byte[] 作为参数。

  6. 您的界面应该看起来像以下代码块:

    namespace HotdogOrNot.ImageClassifier;
    public interface IClassifier
    {
        ClassifierOutput Classify(byte[] bytes);
    }
    

现在我们有了分类器的接口,我们可以继续到实现部分。

使用 ML.NET 进行图像分类

我们现在可以创建IClassifier接口的实现。在我们直接进入实现之前,让我们看看需要发生的高级步骤,以便我们更好地理解流程。

我们的训练模型hotdog-or-not.onnx具有特定的输入和输出参数,在将其提交给 ML.NET 框架之前,我们需要将我们想要分类的图像转换为输入格式。此外,我们还需要确保在提交之前图像的形状是正确的。图像的形状由大小、宽度、高度和颜色格式定义。如果图像与输入格式不匹配,那么在提交之前需要对其进行调整和转换,否则您将面临图像被错误分类的风险。对于由 Custom Vision 服务生成的图像分类模型,例如hotdog-or-not模型,其输入和输出如下所示:

图 12.12 – Netron 中的模型输入和输出

图 12.12 – Netron 中的模型输入和输出

模型的输入被格式化为一个名为data的多维数组。该数组由四个维度组成:

  • 图像:该格式允许您一次性提交多个图像;然而,对于此应用程序,我们将一次只提交一个图像

  • 0是蓝色,1是绿色,2是红色

  • 高度:每个索引代表图像的y轴或垂直轴上的一个位置,范围在 0 到 223 之间

  • 宽度:每个索引代表图像的x轴或水平轴上的一个位置,范围在 0 到 223 之间

该值是该特定图像、颜色以及xy位置的颜色值。例如,data[0,2,64,64]将是第一张图像中从左侧 64 像素和从底部 64 像素位置上的绿色通道的值。

为了减少错误分类的数量,我们需要将所有提交的图像缩放到 224 x 224 像素,并正确排序颜色通道。

我们可以通过以下步骤来完成:

  1. 在项目的ImageClassifier文件夹中创建一个名为MLNetClassifier的新类。

  2. 添加IClassifier接口。

  3. 实现接口中的Classify方法,如下面的代码块所示:

    namespace HotdogOrNot.ImageClassifier;
    internal class MLNetClassifier : Iclassifier
    {
        public MLNetClassifier(byte[] model)
        {
            // Initialize Model here
        }
        public ClassifierOutput Classify(byte[] imageBytes)
        {
            // Code will be added here
        }
    }
    

到目前为止,我们还没有从 ML.NET 引用任何类。要使用 ML.NET API,我们需要按照以下步骤添加对 NuGet 包的引用:

  1. 在项目中安装Microsoft.ML.OnnxRuntime NuGet 包。

  2. 接受任何许可对话框。

这将安装相关的 NuGet 包。

现在我们正在引用 ML.NET 包,我们可以按照以下步骤编译 ONNX ML 模型。

  1. MLNetClassifier文件顶部添加using Microsoft.ML.Onnx.Runtime;声明。

  2. MLNetClassifier类中添加以下字段:

        readonly InferenceSession session;
        readonly bool isBgr;
        readonly bool isRange255;
        readonly string inputName;
        readonly int inputSize;
    
  3. MLNetClassifier 构造函数中,添加以下代码行以初始化 OnnxRuntime 会话,替换 // Initialize Model 这里 注释:

            Session = new InferenceSession(model);
            isBgr = session.ModelMetadata.CustomMetadataMap["Image.BitmapPixelFormat"] == "Bgr8";
            isRange255 = session.ModelMetadata.CustomMetadataMap["Image.NominalPixelRange"] == "NominalRange_0_255";
            inputName = session.InputMetadata.Keys.First();
            inputSize = session.InputMetadata[inputName].Dimensions[2];
    

    在继续之前,让我们讨论一下前面的代码。MLNetClassifier 类的构造函数接受 byte[] 作为参数。这代表 ML 模型文件。byte[] 然后被传递到一个新的 InferenceSession 实例中,这是 ML.NET API 的主要入口点。一旦模型被加载到会话中,我们就可以检查模型的一些属性,例如图像格式(isBGR)、颜色值范围(isRange255)、输入名称和输入大小。我们将这些值缓存在类字段中,以便在分类期间使用。现在,你的 MLNetClassifier 类应该看起来像以下这样:

    using Microsoft.ML.OnnxRuntime;
    namespace HotdogOrNot.ImageClassifier;
    internal class MLNetClassifier : Iclassifier
    {
        readonly InferenceSession session;
        readonly bool isBgr;
        readonly bool isRange255;
        readonly string inputName;
        readonly int inputSize;
        public MLNetClassifier(byte[] model)
        {
            session = new InferenceSession(model);
            isBgr = session.ModelMetadata.CustomMetadataMap["Image.BitmapPixelFormat"] == "Bgr8";
            isRange255 = session.ModelMetadata.CustomMetadataMap["Image.NominalPixelRange"] == "NominalRange_0_255";
            inputName = session.InputMetadata.Keys.First();
            inputSize = session.InputMetadata[inputName].Dimensions[2];
        }
        public ClassifierOutput Classify(byte[] imageBytes)
        {
            // Code will be added here
        }
    }
    

我们现在可以继续实现 MLNetClassifier 类的 Classify 方法。

运行分类的第一步是将输入转换为正确的格式。对于图像分类,这意味着将图像调整到正确的尺寸,并将颜色值组织成预期的格式。然后,图像数据被加载到 Tensor 中,这是我们向 ML.NET 模型传递数据的方式。以下步骤将创建一个名为 LoadInputTensor 的方法来完成这项工作:

  1. MLNetClassifier 类中的 Classify 方法之后添加一个名为 LoadInputTensor 的新方法。此方法将接受四个参数,byte[]int 和两个布尔值,并返回一个 Tensor<float>byte[] 的元组。你的方法应该看起来像以下这样:

    static (Tensor<float>, byte[] resizedImage) LoadInputTensor(byte[] imageBytes, int imageSize, bool isBgr, bool isRange255)
        {
        }
    
  2. LoadInputTensor 内部,我们将创建 return 对象并添加以下突出显示的代码行:

        {
            var input = new DenseTensor<float>(new[] { 1, 3, imageSize, imageSize });
            byte[] pixelBytes;
            // Add code here
            return (input, pixelBytes);
        }
    

    下一步是调整图像大小;我们将使用 ImageSharp NuGet 库使这一过程变得非常简单。

  3. 将 ImageSharp NuGet 包添加到项目中。

  4. 添加以下代码行以调整图像大小,替换 \\ Add code 这里 注释:

    using (var image = Image.Load<Rgb24>(imageBytes))
        {
            image.Mutate(x => x.Resize(imageSize, imageSize));
            pixelBytes = new byte[image.Width * image.Height * Unsafe.SizeOf<Rgba32>()];
            image.ProcessPixelRows(source =>
            {
                // Add Code here
            });
        }
    

    此代码使用 ImageSharp 库从 byte[] 加载图像。然后,图像被调整到模型所需的大小。我们使用 imageSize 字段,其值从构造函数中捕获模型要求。最后,我们设置对 ProcessPixelRows 方法的调用,这将允许我们操作图像中的单个像素。

  5. 由于 .NET MAUI 和 ImageSharp 之间的命名冲突,我们必须在文件顶部添加一个声明,告诉编译器我们真正想要使用哪个类:

    using Image = SixLabors.ImageSharp.Image;
    
  6. 下一段代码也将需要以下突出显示的声明:

    using Microsoft.ML.OnnxRuntime;
    using SixLabors.ImageSharp.Formats.Png
    using Microsoft.ML.OnnxRuntime.Tensors;
    using Image = SixLabors.ImageSharp.Image;
    
  7. 为了将输入图像转换为模型所需的正确颜色格式,我们使用 ImageSharp 库中的 ProcessPixelRows 方法。此方法为我们提供了一个可写缓冲区,我们可以对其进行操作。使用以下突出显示的代码,替换 // Add Code here 注释,来遍历调整大小的图像数据,将颜色值放入正确的顺序,并在需要时将值夹在 0 和 255 之间:

    image.ProcessPixelRows(source =>
    {
        for (int y = 0; y < image.Height; y++)
        {
            Span<Rgb24> pixelSpan = source.GetRowSpan(y);
            for (int x = 0; x < image.Width; x++)
            {
                if (isBgr)
                {
                    input[0, 0, y, x] = pixelSpan[x].B;
                    input[0, 1, y, x] = pixelSpan[x].G;
                    input[0, 2, y, x] = pixelSpan[x].R;
                }
                else
                {
                    input[0, 0, y, x] = pixelSpan[x].R;
                    input[0, 1, y, x] = pixelSpan[x].G;
                    input[0, 2, y, x] = pixelSpan[x].B;
                }
                if (!isRange255)
                {
                    input[0, 0, y, x] = input[0, 0, y, x] / 255;
                    input[0, 1, y, x] = input[0, 1, y, x] / 255;
                    input[0, 2, y, x] = input[0, 2, y, x] / 255;
                 }
             }
         }
    });
    

    这段代码所做的是简单的——使用提供的源变量,它遍历图像中的每一行,以及行中的每个像素。如果模型期望颜色以蓝色、绿色和红色的顺序出现,isBGRtrue,那么提取的颜色值将按照该顺序放置在输入张量中;否则,它们将以红色、绿色和蓝色的顺序添加到输入张量中。这里棘手的部分是访问每个像素的正确元素。张量组织成四个维度,如前所述。对于这个模型,第一个元素始终为零,因为我们一次只处理一张图像。第二个维度是颜色通道,所以你会看到红色、绿色和蓝色颜色值的变化。

    最后,如果模型期望颜色值在 0 到 255 的范围内,isRange255,则每个颜色通道都会被限制在该范围内。

  8. 我们将要做的最后一件事是将调整大小后的图像内容复制到pixelBytes数组中,这样我们就可以向用户显示图像。添加以下高亮代码来完成此操作;注意,为了简洁,之前的代码已被省略:

                });
                var outStream = new MemoryStream();
                image.Save(outStream, new PngEncoder());
                pixelBytes = outStream.ToArray();
            }
            return (input, pixelBytes);
    

现在我们已经编写了处理图像并填充输入张量的代码,我们可以通过以下步骤完成Classify方法:

  1. // Code will be added here注释替换为对LoadInputTensor方法的调用:

        public ClassifierOutput Classify(byte[] imageBytes)
        {
            (Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
        }
    
  2. 接下来,我们可以运行会话,传入新创建的输入张量并捕获结果:

        public ClassifierOutput Classify(byte[] imageBytes)
        {
            (Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
            var resultsCollection = session.Run(new List<NamedOnnxValue>
            {
                        NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
             });
        }
    
  3. 我们从输出结果中获取标签,这将用来确定这张图像是否包含热狗:

        public ClassifierOutput Classify(byte[] imageBytes)
        {
            (Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
            var resultsCollection = session.Run(new List<NamedOnnxValue>
                    {
                        NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
                    });
            var topLabel = resultsCollection
                ?.FirstOrDefault(i => i.Name == "classLabel")
                ?.AsTensor<string>()
                ?.First();
        }
    
  4. 然后,我们可以获取结果的置信度水平,这告诉我们模型对分类有多确定。这将在我们显示结果时使用:

        public ClassifierOutput Classify(byte[] imageBytes)
        {
            (Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
            var resultsCollection = session.Run(new List<NamedOnnxValue>
                    {
                        NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
                    });
            var topLabel = resultsCollection
                ?.FirstOrDefault(i => i.Name == "classLabel")
                ?.AsTensor<string>()
                ?.First();
            var labelScores = resultsCollection
                ?.FirstOrDefault(i => i.Name == "loss")
                ?.AsEnumerable<NamedOnnxValue>()
                ?.First()
                ?.AsDictionary<string, float>();
        }
    
  5. 最后,我们可以使用ClassifierOutput类返回分类的结果:

        public ClassifierOutput Classify(byte[] imageBytes)
        {
            (Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
            var resultsCollection = session.Run(new List<NamedOnnxValue>
                    {
                        NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
                    });
            var topLabel = resultsCollection
                ?.FirstOrDefault(i => i.Name == "classLabel")
                ?.AsTensor<string>()
                ?.First();
            var labelScores = resultsCollection
                ?.FirstOrDefault(i => i.Name == "loss")
                ?.AsEnumerable<NamedOnnxValue>()
                ?.First()
                ?.AsDictionary<string, float>();
            return ClassifierOutput.Create(topLabel, labelScores, resizedImage);
        }
    
  6. 最后一步是完成MLNetClassifier的实现,通过实现ClassifierOutput类。通过添加以下高亮代码来更新你的ClassifierOutput类:

    internal sealed class ClassifierOutput
    {
        public string TopResultLabel { get; private set; }
        public float TopResultScore { get; private set; }
        public IDictionary<string, float> LabelScores { get; private set; }
        public byte[] Image { get; private set; }
        ClassifierOutput() { }
        public static ClassifierOutput Create(string topLabel, IDictionary<string, float> labelScores, byte[] image)
        {
            var topLabelValue = topLabel ?? throw new ArgumentException(nameof(topLabel));
            var labelScoresValue = labelScores ?? throw new ArgumentException(nameof(labelScores));
            return new ClassifierOutput
            {
                TopResultLabel = topLabelValue,
                TopResultScore = labelScoresValue.First(i => i.Key == topLabelValue).Value,
                LabelScores = labelScoresValue,
                Image = image,
            };
        }
    }
    

ClassifierOutput类用于封装将在 UI 中使用的四个值,并将它们作为公共属性公开。Create静态方法用于创建类的实例。Create方法验证提供的参数,并适当地设置公共属性以供 UI 使用。

我们现在已经编写了识别图像中热狗的代码。

现在,我们可以构建应用程序的用户界面并调用MLNetClasssifier来对图像进行分类。

请求应用权限

在我们深入构建应用的其他功能之前,我们需要处理权限问题。这个应用将有两个按钮供用户使用,一个用于拍照,另一个用于从设备中选择照片。这与我们在 第六章 中看到的 使用 CollectionView 和 CarouselView 构建照片库应用 的功能类似,在那里我们需要在访问相机或设备存储之前请求用户的权限。然而,我们将以与该章节不同的方式实现权限。由于访问相机和访问用户设备上的照片需要不同的权限,我们将从每个按钮处理程序中请求它们。

按照以下步骤添加一个类来帮助我们进行权限检查:

  1. 在项目中创建一个名为 AppPermissions 的新类。

  2. 修改类定义以添加 partial 修饰符,并移除默认构造函数:

    namespace HotdogOrNot;
    internal partial class AppPermissions
    {
    }
    
  3. AppPermissions 类添加以下方法:

        public static async Task<PermissionStatus> CheckRequiredPermission<TPermission>() where TPermission : Permissions.BasePermission, new() => await Permissions.CheckStatusAsync<TPermission>();
    

    CheckRequiredPermission 方法用于确保在我们尝试任何可能会因为权限不足而失败的操作之前,我们的应用已经拥有了正确的权限。它的实现是通过调用 .NET MAUI 的 CheckSyncStatus 方法,并使用在 TPermission 中提供的权限类型。它返回 PermissionStatus,这是一个枚举类型。我们主要关注的是 DeniedGranted 这两个值。

  4. AppPermissions 类添加 CheckAndRequestRequiredPermission 方法:

        public static async Task<PermissionStatus> CheckAndRequestRequiredPermission() <TPermission>() where TPermission : Permissions.BasePermission, new()
        {
            PermissionStatus status = await Permissions.CheckStatusAsync< TPermission >();
            if (status == PermissionStatus.Granted)
                return status;
            if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
            {
                // Prompt the user to turn on in settings
                // On iOS once a permission has been denied it may not be requested again from the application
                await App.Current.MainPage.DisplayAlert("Required App Permissions", "Please enable all permissions in Settings for this App, it is useless without them.", "Ok");
            }
            if (Permissions.ShouldShowRationale< TPermission >())
            {
                // Prompt the user with additional information as to why the permission is needed
                await App.Current.MainPage.DisplayAlert("Required App Permissions", "This app uses photos, without these permissions it is useless.", "Ok");
            }
            status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<TPermission>);
            return status;
        }
    }
    

    CheckAndRequestRequiredPermission 方法处理请求用户访问的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果我们是在 iOS 上,并且权限已经被拒绝,那么它不能再次请求,因此你必须指导用户如何通过设置面板授予应用权限。Android 在请求行为中包括了一个如果用户拒绝了访问,可以不断提醒用户的功能。这个行为通过 .NET MAUI 的 ShouldShowRationale 方法暴露出来。对于不支持此行为的任何平台,它将返回 false,在 Android 上,如果用户第一次拒绝访问,它将返回 true,如果用户第二次拒绝,它将返回 false。最后,我们请求用户访问权限。同样,.NET MAUI 隐藏了所有平台实现细节,使得检查和请求访问某些资源变得非常直接。

看起来熟悉吗?

如果前面的代码看起来熟悉,那么你是对的。它是基于在 .NET MAUI 文档中描述的实现。你可以在 learn.microsoft.com/en-us/dotnet/maui/platform-integration/appmodel/permissions 找到它。

现在,我们已经设置了共享的 AppPermissions,我们可以开始进行平台配置。然而,在我们可以使用媒体选择器之前,我们需要为每个平台进行一些配置。我们将从 Android 开始。

在 Android API 版本 33 中,增加了三个新权限以启用对媒体文件的读取访问 – ReadMediaImagesReadMediaVideosReadMediaAudio。在 API 版本 33 之前,只需要 ReadExternalStorage 权限。要访问相机,我们需要 CameraWriteExternalStorage 权限。为了正确请求设备的 API 版本的正确权限,请打开 Platform/Android 文件夹中的 MauiApplication.cs 并将其修改如下:

using Android.App;
using Android.Runtime;
// Needed for Picking photo/video
[assembly: UsesPermission(Android.Manifest.Permission.ReadExternalStorage, MaxSdkVersion = 32)]
[assembly: UsesPermission(Android.Manifest.Permission.ReadMediaImages)]
// Needed for Taking photo/video
[assembly: UsesPermission(Android.Manifest.Permission.Camera)]
IMAGE_CAPTURE intent as follows in the AndroidManifest.xml file:


 For iOS and Mac Catalyst, the only thing we need to do is add the following four usage descriptions to the `info.plist` file in the `platform/ios` and `platform/maccatalyst` folders:

NSCameraUsageDescription

此应用需要访问相机以拍照。

NSPhotoLibraryUsageDescription

此应用需要访问照片。

NSMicrophoneUsageDescription

此应用需要访问麦克风。

NSPhotoLibraryAddUsageDescription

此应用需要访问照片库。


 For Windows, we need to add the following highlighted code to the `Capabilities` section of the `package.appxmanifest` file in the `platforms/windows` folder:

<rescap:Capability Name="runFullTrust" />


 Now that we have declared the permissions we need for each platform, we can implement the remaining functionality to take a photo or pick an existing image.
Building the first view
The first view in this app will be a simple view with two buttons. One button will be to start the camera so that users can take a photo of something to determine whether it is a hot dog. The other button will be to pick a photo from the photo library of the device. We will continue to use the MVVM pattern in this chapter, so we will split the view into two classes, `MainView` for the UI visible to the user and `MainViewModel` for the actual implementation.
Building the ViewModel class
We will start by creating the `MainViewModel` class, which will handle what will happen when a user taps one of the buttons. Let’s set this up by going through the following steps:

1.  Create a new folder called `ViewModels`.
2.  Add a NuGet reference to `CommunityToolkit.Mvvm`; we use `CommunityToolkit.Mvvm` to implement the `INotifyPropertyChanged` interface and commands, as we did in other chapters.
3.  Create a new partial class called `MainViewModel` in the `ViewModels` folder, using `ObservableObject` from the `CommunityToolkit.Mvvm.ComponentModel` namespace as a base class.
4.  Create a private field of the `IClassifier` type and call it `classifier`, as shown in the following code block:

    ```

    using CommunityToolkit.Mvvm.ComponentModel;

    using HotdogOrNot.ImageClassifier;

    命名空间 HotdogOrNot.ViewModels;

    public partial class MainViewModel : ObservableObject

    {

    private IClassifier classifier;

    public MainViewModel()

    {

    }

    }

    ```cs

Initializing the ONNX model requires the use of asynchronous methods, so we need to handle them carefully, since we will be calling them from the constructor and the button handlers. The following steps will create the model initializer:

1.  Create an `InitTask` property that is of the `Task` type.
2.  Use a property initializer to set it to a new `Task`, using `Task.Run`.
3.  Initialize the model from the raw resources of the .NET MAUI app. The method should look like the following code:

    ```

    Task InitTask() => Task.Run(async () =>

    {

    using var modelStream = await FileSystem.OpenAppPackageFileAsync("hotdog-or-not.onnx");

    using var modelMemoryStream = new MemoryStream();

    modelStream.CopyTo(modelMemoryStream);

    var model = modelMemoryStream.ToArray();

    _classifier = new MLNetClassifier(model);

    });

    ```cs

    The `InitTask` property holds a reference to `Task` that does the following:

    *   Loads the `hotdog-or-not.onnx` file into `Stream`
    *   Copies the bytes from the original stream to an array of bytes so that the original stream can be closed and any native resources, such as file handles, can be released.
    *   Creates and returns a new instance of the `MLNetClassifier` class using the loaded model. 4.  To ensure that `InitTask` will only run successfully once, add the following highlighted code:

    ```

    public partial class MainViewModel : ObservableObject

    {

    IClassifier _classifier;

    Task initTask;

    public MainViewModel()

    {

    _ = InitAsync();

    }

    public Task InitAsync()

    {

    if (initTask == null || initTask.IsFaulted)

    initTask = InitTask();

    return initTask;

    }

    // 省略代码以节省篇幅

    }

    ```cs

    In `InitAsync`, the initialization task is captured by a field only if the field is `null` or its value has faulted. This ensures that we only run the initialization successfully once. The value of the field is then returned to the caller, which, in this case, is the constructor. Unwinding this, the constructor calls `InitAsync` and throws away the return value. `InitAsync`, meanwhile, captures the value returned by the `InitTask` property, which is `Task` that has already been queued for execution. Since `InitAsync` and `InitTask` and their closure are all asynchronous, they complete sometime after the constructor completes.

Now that we have initialized the `hotdog-or-not` ONNX model, we can now implement the two buttons, one that takes a photo and another that allows the user to pick a photo from their device storage. Let’s start by implementing a couple of helper methods to use in both use cases.
The first helper method is used to convert `FileResult` to `byte[]`. To implement `ConvertPhotoToBytes`, follow these steps:

1.  Open the `MainViewModel.cs` file.
2.  Add a new method named `ConvertPhotoToBytes`, which takes `FileResult` as a parameter and returns `byte []`. Since the method is `async`, you’ll need to return `Task` and use the `async` modifier.
3.  In the method, check whether `FileResult` is `null` and that it returns an empty array.
4.  Next, open a stream from `FileResult` using the `OpenStreamAsync` method.
5.  Create a new variable of the `MemoryStream` type and initialize it using the default constructor.
6.  Use the `Copy` method to copy `stream` to `MemoryStream`.
7.  Finally, return `MemoryStream` as `byte[]`; your method should look like the following:

    ```

    私有异步任务 ConvertPhotoToBytes(FileResult photo)

    {

    if (photo == null) return Array.Empty<byte>();

    using var stream = await photo.OpenReadAsync();

    using MemoryStream memoryStream = new();

    stream.CopyTo(memoryStream);

    return memoryStream.ToArray();

    }

    ```cs

The other helper method we will need is to use our classification model to get the results of a photo and return the results. We will need a new type to return the results. Follow these steps to implement the new class:

1.  Create a new folder named `Models` in the project.
2.  In the `Models` folder, create a new class, `Result`, in a file named `Result.cs`.
3.  Add a public property, `IsHotdog`, as `bool`.
4.  Add a public property, `Confidence`, as `float`.
5.  Add a public property, `PhotoBytes`, as `byte[]`; the class should now look like the following:

    ```

    命名空间 HotdogOrNot.Models;

    public class Result

    {

    public bool IsHotdog { get; set; }

    public float Confidence { get; set; }

    public byte[] PhotoBytes { get; set; }

    }

    ```cs

    The `IsHotdog` property is used to capture whether the label returned from the model is “hotdog.” `Confidence` is a score of how sure the model is that this is a hotdog or not. Finally, since we transform the image prior to processing, we store the transformed image in the `PhotoBytes` property.

Now, we can implement the method that will run and process the classification result, by following these steps:

1.  Open the `MainViewModel.cs` file.
2.  In the `MainViewModel` class, add a new field, `isClassifying`, with a `bool` type.
3.  Add the `ObservableAttribute` attribute to the field; it should look like the following:

    ```

    [ObservableProperty]

    private bool isClassifying;

    ```cs

     4.  Add a new method to the `MainViewModel` class, named `RunClassificationAsync`. The method will accept a `byte[]` parameter and return `Result`, wrapped in `Task`, since it is `async`:

    ```

    async Task<Result> RunClassificationAsync(byte[] imageToClassify)

    {

    }

    ```cs

     5.  In the method, the first thing we do is set the `IsClassifying` property to `true`; this will be used to disable the buttons later in the chapter.
6.  Add a `try..catch..finally` statement.
7.  Inside the `try` statement, ensure the model is initialized by calling `InitAsync`.
8.  Then, call `Classify` on the `classifier` field passing `byte[]`, representing the image as a parameter and storing the result.
9.  The last statement in the `try` statement block is to return a new `Result`, setting `IsHotdog` to `true` only if the classification result’s `TopResultLabel` property is “hotdog,” `Confidence` is set to the classification result’s `TopResultScore` property, and `PhotoBytes` is set to the classification result’s `Image` property. The `try` portion should look like the following:

    ```

    try

    {

    await InitAsync().ConfigureAwait(false);

    var result = _classifier.Classify(imageToClassify);

    return new Result()

    {

    IsHotdog = result.TopResultLabel == "hotdog",

    Confidence = result.TopResultScore,

    PhotoBytes = result.Image

    };

    }

    catch

    ```cs

     10.  Now, in the `catch` statement block, return a new `Result`, setting the `IsHotdog` property to `false`, `Confidence` to `0.0f`, and the `PhotoBytes` property to the bytes passed into the method. The `catch` block should look like the following:

    ```

    catch

    {

    return new Result

    {

    IsHotdog = false,

    Confidence = 0.0f,

    PhotoBytes = imageToClassify

    };

    }

    finally

    ```cs

     11.  Lastly, for the `finally` block, we want to set the `IsClassiying` property back to `false`; however, we will need to do this on the main UI thread using the `MainThread.BeginInvokeOnMainThread` method from .NET MAUI, as shown in the following code:

    ```

    finally

    {

    MainThread.BeginInvokeOnMainThread(() => IsClassifying = false);

    }

    ```cs

Now that we have written the helper methods, we can create two methods, one to handle capturing an image from the camera and another to pick a photo from user storage. We will start with the camera capture method.
Let’s set this up by following these steps:

1.  Open the `MainViewModel.cs` file.
2.  Create a public async void method called `TakePhoto`.
3.  Add the `RelayCommand` attribute to make the method bindable.
4.  Add an `if` statement to check whether the `MediaPicker.Default.IsCaptureSupported` parameter is `true`.
5.  In the `true` statement block of `if`, get the status of the `Camera` permission using the `CheckAndRequestPermission` method.
6.  If the status is `Granted`, then use `CheckAndRequestMethod` again to check the `WriteExternalStorage` permission.
7.  If the status is `Granted`, use `MediaPicker` to capture a photo using the `Capture``PhotoAsync` method.
8.  Call a method named `ConvertPhotoToBytes`, passing in the file returned from `MediaPicker`.
9.  Pass the photo bytes to the `RunClassificationAsync` method.
10.  Finally, we will dynamically navigate to the `Result` view, which we will create in the next section, passing the result from `RunClassificationAsync` as a parameter. We do this by using `Shell.Current.GotoAsync` and ensuring that the app uses the main thread to do so, as shown in the following code block:

    ```

    [RelayCommand()]

    public async void TakePhoto()

    {

    if (MediaPicker.Default.IsCaptureSupported)

    {

    var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.Camera>();

    if (状态 == PermissionStatus.Granted) {

    状态 = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.StorageWrite>();

    }

    if (状态 == PermissionStatus.Granted)

    {

    FileResult photo = await MediaPicker.Default.CapturePhotoAsync(new MediaPickerOptions() { Title = "热狗或不是热狗?" });

    var imageToClassify = await ConvertPhotoToBytes(photo);

    var result = await RunClassificationAsync(imageToClassify);

    await MainThread.InvokeOnMainThreadAsync(async () => await

    Shell.Current.GoToAsync("Result", new Dictionary<string, object>() { { "result", result } })

    );

    }

    }

    }

    ```cs

`Shell.Current.GotoAsync` takes two parameters – the first is the route that `Shell` is to navigate to, and the second is a dictionary of key-value pairs to send to the destination view. Later in this chapter, we will see how to configure a route to a view without using XAML and, when we create the `Result` view, how to access the parameters passed to it.
We will now create the `PickPhoto` method to allow a user to use an image from their device. Use the following steps to create the method:

1.  Create a public async void method called `PickPhoto`.
2.  Add the `RelayCommand` attribute to make the method bindable.
3.  Grant the status of the `Photos` permission using the `CheckAndRequestPermission` method.
4.  If the status is `Granted`, use `MediaPicker` to capture a photo using the `Pick``PhotoAsync` method.
5.  Call a method named `ConvertPhotoToBytes`, passing in the file returned from `MediaPicker`.
6.  Pass the photo bytes to the `RunClassificationAsync` method.
7.  Finally, we will dynamically navigate to the `Result` view, which we will create in the next section, passing the result from `RunClassificationAsync` as a parameter. We will do this by using `Shell.Current.GotoAsync` and ensuring that the app uses the main thread to do so, as shown in the following code block:

    ```

    [RelayCommand()]

    public async void PickPhoto()

    {

    var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.Photos>();

    if (状态 == PermissionStatus.Granted)

    {

    FileResult photo = await MediaPicker.Default.PickPhotoAsync();

    var imageToClassify = await ConvertPhotoToBytes(photo);

    var result = await RunClassificationAsync(imageToClassify);

    await MainThread.InvokeOnMainThreadAsync(async () => await

    Shell.Current.GoToAsync("Result", new Dictionary<string, object>() { { "result", result } })

    );

    }

    }

    ```cs

When a user clicks on a button, the classification could take a noticeable amount of time. To prevent the user from clicking the button again because they think it’s not working, we will disable the buttons until the operation completes. The `IsClassifying` property is already set; we just need to use that value to restrict `RelayCommands`, by following these steps:

1.  Add a new method that returns a Boolean named `CanExecuteClassification`, and return the inverse of the `IsClassifying` property, as shown in the following code:

    ```

    private bool CanExecuteClassification() => !IsClassifying;

    ```cs

     2.  Update the `RelayCommand` attribute for the `TakePhoto` method, as highlighted here:

    ```

    [RelayCommand(CanExecute = nameof(CanExecuteClassification))]

    public async void TakePhoto()

    ```cs

     3.  Update the `RelayCommand` attribute for the `PickPhoto` method, as highlighted here:

    ```

    [RelayCommand(CanExecute = nameof(CanExecuteClassification))]

    public async void PickPhoto()

    ```cs

Now that ViewModel for the main page is complete, we can build View for the main page.
Building the view
Now, once we have created the `MainViewModel` class, it is time to create the code for the `MainView` view:

1.  Create a new folder called `Views`.
2.  Add a new `MainView`.
3.  Set the `Title` property of `ContentPage` as `Hotdog or` `Not hotdog`.
4.  Add `HorizontalStackLayout` to the page, and set its `VerticalOptions` property to `Center` and its `HorizontalOptions` property to `CenterAndExpand`.
5.  Add `Button` to the `HorizontalStackLayout`, with the text `Take Photo`. For the `Command` property, add a binding to the `TakePhoto` property in the `MainViewModel` class.
6.  Add `Button` to `HorizontalStackLayout`, with the text `Pick Photo`. For the `Command` property, add a binding to the `PickPhoto` property in the `MainViewModel` class, as shown in the following code block:

    ```

    <ContentPage

    x:Class="HotdogOrNot.Views.MainView"

    x:DataType="viewModels:MainViewModel"

    Title="热狗或不是热狗">

    <HorizontalStackLayout VerticalOptions="Center" HorizontalOptions="CenterAndExpand">

    <Button Text="拍摄照片" Command="{Binding TakePhotoCommand}" WidthRequest="150" HeightRequest="150" Margin="20" FontSize="Large"/>

    <Button Text="选择照片" Command="{Binding PickPhotoCommand}" WidthRequest="150" HeightRequest="150" Margin="20" FontSize="Large"/>

    </HorizontalStackLayout>

    </ContentPage>

    ```cs

In the code-behind `MainView.xaml.cs` file, we will set the binding context of the view by following these steps:

1.  Add `MainViewModel` as a parameter of the constructor.
2.  After the `InitialComponent` method call, set the `BindingContext` property of the view to the `MainViewModel` parameter.
3.  Use the `SetBackButtonTitle` static method on the `NavigationPage` class so that an arrow to navigate back to this view will be shown in the navigation bar on the result view, as shown in the following code block:

    ```

    public MainView(MainViewModel viewModel)

    {

    InitializeComponent();

    BindingContext = viewModel; NavigationPage.SetBackButtonTitle(this, string.Empty);

    }

    ```cs

Building the result view
The last thing we need to do in this project is to create the result view. This view will show the input photo and the classification of a hot dog or not.
Building the ResultViewModel class
Before we create the view, we will create a `ResultViewModel` class that will handle all the logic for the view, by following these steps:

1.  Create a `partial` class called `ResultViewModel` in `ViewModels`.
2.  Add `ObservableObject` as a base class to the `ResultViewModel` class.
3.  Create a `private` field of the `string` type, called `title`. Add the `ObservableProperty` attribute to the field to make it a bindable property.
4.  Create a `private` field of the `string` type, called `description`. Add the `ObservableProperty` attribute to the field to make it a bindable property.
5.  Create a `private` field of the `string` type, called `Title`. Add the `ObservableProperty` attribute to the field to make it a bindable property, as shown in the following code block:

    ```

    使用 CommunityToolkit.Mvvm.ComponentModel;

    using HotdogOrNot.Models;

    namespace HotdogOrNot.ViewModels;

    public partial class ResultViewModel : ObservableObject

    {

    [ObservableProperty]

    private string title;

    [ObservableProperty]

    private string description;

    [ObservableProperty]

    byte[] photoBytes;

    public ResultViewModel()

    {

    }

    }

    ```cs

The next thing we will do in `ResultViewModel` is to create an `Initialize` method that will have the result as a parameter. Let’s set this up by following these steps:

1.  Add a `private` method named `Initialize` to the `ResultViewModel` class that accepts a parameter of the `Result` type, named `result`, and returns `void`.
2.  In the `Initialize` method, set the `PhotoBytes` property to the value of the `PhotoBytes` property of the `result` parameter.
3.  Add an `if` statement that checks whether the `IsHotDog` property of the `result` parameter is `true` and whether `Confidence` is higher than `90%`. If this is the case, set `Title` to `"Hot dog"` and `Description` to `"This is for sure` `a hotdog"`.
4.  Add an `else if` statement to check whether the `IsHotdog` property of the `result` parameter is `true`. If this is the case, set `Title` to `"Maybe"` and `Description` to `"This is maybe` `a hotdog"`.
5.  Add an `else` statement that sets `Title` to `"Not a hot dog"` and `Description` to `"This is not a hot dog"`, as shown in the following code block:

    ```

    public void Initialize(Result result)

    {

    PhotoBytes = result.PhotoBytes;

    if (result.IsHotdog && result.Confidence > 0.9)

    {

    Title = "热狗";

    Description = "这肯定是一条热狗";

    }

    else if (result.IsHotdog)

    {

    Title = "可能";

    Description = "这可能是一条热狗";

    }

    else

    {

    Title = "不是热狗";

    Description = "This is not a hot dog";

    }

    }

    ```cs

The final thing we need to do is call the `Initialize` method with the result. If you recall from the previous section on building the main view, we navigated to the `Result` view and passed the `Result` object as a parameter. To access the parameter and call the `Initialize` method properly, follow these steps:

1.  Add the `IQueryAttributable` interface to the list of inherited interfaces:

    ```

    public partial class ResultViewModel : ObservableObjectvoid method, ApplyQueryAttributes, that accepts a parameter named query of the IDictionary<string, object> type:

    ```cs
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
    }
    ```

    ```cs

     2.  Now, in the method, call the `Initialize` method, passing the `“result”` object from the query dictionary and casting it to a `Result` type, as shown in the following code:

    ```

    public void ApplyQueryAttributes(IDictionary<string, object> query)

    {

    Initialize(query["result"] as Result);

    }/

    ```cs

`ViewModel` is now complete, and we are ready to create `View`.
Building the view
Because we want to show the input photo in the result view, we need to convert it from `byte[]` to `Microsft.Maui.Controls.ImageSource`. We will do this in a value converter that we can use together with the binding in the **XAML**, by following these steps:

1.  Create a new folder called `Converters`.
2.  Create a new class called `BytesToImageConverter` in the `Converters` folder.
3.  Add and implement the `IValueConverter` interface, as shown in the following code block:

    ```

    using System.Globalization;

    namespace HotdogOrNot.Converters;

    public class BytesToImageConverter : IvalueConverter

    {

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)

    {

    throw new NotImplementedException();

    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)

    {

    throw new NotImplementedException();

    }

    }

    ```cs

The `Convert` method will be used when `ViewModel` updates a view. The `ConvertBack` method will be used in two-way bindings when `View` updates `ViewModel`. In this case, we only need to write code for the `Convert` method, by following these steps:

1.  First, check whether the `value` parameter is `null`. If so, we should return `null`.
2.  If the value is not `null`, cast it as `byte[]`.
3.  Create a `MemoryStream` object from the `byte` array.
4.  Return the result of the `ImageSource.FromStream` method to which we will pass the stream, as shown in the following code block:

    ```

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)

    {

    if(value == null)

    {

    return null;

    }

    var bytes = (byte[])value;

    var stream = new MemoryStream(bytes);

    return ImageSource.FromStream(() => stream);

    }

    ```cs

The view will contain the photo, which will take up two-thirds of the screen. Under the photo, we will add a description of the result. Let’s set this up by going through the following steps:

1.  In the `Views` folder, create a new file using the .NET MAUI ContentPage (XAML) file template, and name it `ResultView`.
2.  Import the namespace for the converter.
3.  Add `BytesToImageConverter` to `Resources` for the page and give it `“``ToImage”` key.
4.  Bind the `Title` property of `ContentPage` as the `Title` property of `ViewModel`.
5.  Add `Grid` to the page with two rows. The `Height` value for the first `RowDefinition` should be `2*`. The height of the second row should be `*`. These are relative values that mean that the first row will take up two-thirds of `Grid`, while the second row will take up one-third of `Grid`.
6.  Add `Image` to `Grid`, and bind the `Source` property to the `PhotoBytes` property in `ViewModel`. Use the converter to convert the bytes to an `ImageSource` object and set the `Source` property.
7.  Add `Label`, and bind the `Text` property to the `Description` property of `ViewModel`, as shown in the following code block:

    ```

    <ContentPage     xmlns:converters="clr-

    namespace:HotdogOrNot.Converters"

    x:Class="HotdogOrNot.Views.ResultView" Title="{Binding Title}">

    <ContentPage.Resources>

    <converters:BytesToImageConverter x:Key="ToImage" />

    </ContentPage.Resources>

    <Grid>

    <Grid.RowDefinitions>

    <RowDefinition Height="2*" />

    <RowDefinition Height="*" />

    </Grid.RowDefinitions>

    <Image Source="{Binding PhotoBytes, Converter=

    {StaticResource ToImage}}" Aspect="AspectFill" />

    <Label Grid.Row="1" HorizontalOptions="Center" FontAttributes="Bold" Margin="10" Text="{Binding Description}" />

    </Grid>

    </ContentPage>

    ```cs

We also need to set `BindingContext` of the view. We will do this in the same way as we did in `MainView` – in the code-behind file (`ResultView.xaml.cs`), as shown in the following code snippet:

public ResultView (ResultViewModel viewModel)

{

InitializeComponent();

BindingContext = viewModel;

}


 We are now ready to write the initialization code for the app.
Initializing the app
We will set up `Shell`.
Open `App.xaml.cs`, and set `MainPage` to `MainView` by following these steps:

1.  Delete the `MainPage.xaml` and `MainPage.xaml.cs` files from the root of the project, since we won’t be needing those.
2.  Open the `AppShell.xaml` file in the root of the project, and modify it to look like the following code:

    ```

    <?xml version="1.0" encoding="UTF-8" ?>

    <Shell

    x:Class="HotdogOrNot.AppShell"

    Shell.FlyoutBehavior="Disabled">

    <ShellContent

    Title="Home"

    ContentTemplate="{DataTemplate views:MainView}"

    Route="MainView" />

    </Shell>

    ```cs

Now, configure the `View` and `ViewModel` classes in the IoC container by following these steps:

1.  Open the `MauiProgram.cs` file.
2.  In the `CreateMauiApp` method before the `return` statement, add the following highlighted lines of code:

    ```

    #if DEBUG

    builder.Logging.AddDebug();

    #endif

    builder.Services.AddTransient<Views.MainView>();

    builder.Services.AddTransient<Views.ResultView>();

    builder.Services.AddTransient<ViewModels.MainViewModel>();

    builder.Services.AddTransient<ViewModels.ResultViewModel>();

    return builder.Build();

    ```cs

The very last thing we need to do is add the route to `ResultView` to enable navigation from `MainView`. We will do this by adding the following highlighted code to the constructor of `AppShell` in `AppShell.xaml.cs`:

public AppShell()

{

Routing.RegisterRoute("Result", typeof(HotdogOrNot.Views.ResultView));

InitializeComponent();

}


 Now, we are ready to run the app. If we use the simulator/emulator, we can just drag and drop photos to it if we need photos to test with. When the app has started, we can now pick a photo and run it against the model. The following screenshot shows how the app will look if we upload a photo of a hot dog:
![Figure 12.13 – HotdogOrNot running in an Android emulator](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-proj-3e/img/B19214_12_13.jpg)

Figure 12.13 – HotdogOrNot running in an Android emulator
Note
The prediction result for Android may not be as accurate as the web portal at [`github.com/Azure-Samples/cognitive-services-android-customvision-sample/issues/12`](https://github.com/Azure-Samples/cognitive-services-android-customvision-sample/issues/12). If you desire better, more consistent results, you can use the REST APIs.
Summary
In this chapter, we built an app that can recognize whether a photo contains a hot dog or not. We accomplished this by training a machine learning model for image classification, using Azure Cognitive Services and the Custom Vision service.
We exported models for ML.NET, and we learned how to use it in an MAUI app that targets iOS, Mac Catalyst, Windows, and Android. In the app, a user can take a photo or pick one from their photo library. This photo will be sent to the model to be classified, and we will get a result that tells us whether the photo is of a hot dog.
Now, we can continue to build other apps and use what we have learned in this chapter regarding machine learning, both on-device and in the cloud using Azure Cognitive Services. Even if we are building other apps, the concept will be the same.
Now, we have completed all the chapters in this book. We have learned the following:

*   What .NET MAUI is and how we can get started building apps
*   How to use the basic layouts and controls of .NET MAUI
*   How to work with navigation
*   How to make the user experience better with animations
*   How to use sensors such as the **Global Positioning System** (**GPS**) in the background
*   How to build apps for multiple form factors
*   How to build real-time apps powered by Azure
*   How to make apps smarter with machine learning

The next step is to start to build your own apps. To stay up to date and learn more about .NET MAUI, our recommendation is to read the official Microsoft dev blogs and watch live streams on Twitch and YouTube videos from the .NET MAUI team.
Thank you for reading the book!

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(110)  评论(0)    收藏  举报