-NET-MAUI-跨平台应用开发-全-
.NET MAUI 跨平台应用开发(全)
原文:
zh.annas-archive.org/md5/f68f693541453541646c4b8b58abcd0a
译者:飞龙
前言
.NET MAUI 是由微软开发的一种跨平台技术。本书的首版是在 .NET 6 上编写的。然而,第二版与 .NET 8 的发布同步,带来了各种改进。最新的 .NET MAUI 版本主要寻求提升代码质量。同时,对支持 iOS 和 Android 的开发环境也进行了更新,分别升级到 Xcode 15 和 Android API 34。
目标改进集中在一系列关键方面,如纠正内存泄漏、改进 UI 控件、实施特定平台的修复和优化性能。这些改进的目标是加强内存管理、提高应用程序的稳定性,并确保在不同平台间用户体验的一致性,从而提升整体应用程序的性能和响应速度。专注于这些组件,我努力为您提供对 .NET MAUI 技术最新进展的全面理解。
尽管如今有众多跨平台编程选项可供选择,包括 Flutter 和 React Native,但 .NET MAUI 因其独特的特性而脱颖而出,这些特性在选择跨平台解决方案时应予以考虑。
.NET MAUI 的一个显著优势是其单一项目结构,这比 Xamarin.Forms 有明显改进。这种简化的结构增强了多个领域,例如:
-
提升调试和测试:通过单一的项目结构,可以在同一项目中选择和调试多个目标,从而消除了在不同项目之间切换以针对不同目标的需求。
-
资源共享:在 Xamarin 中,传统上必须为每个平台单独管理资源。然而,.NET MAUI 通过允许跨平台共享大多数资源(包括字体、图像、图标等)改进了这一方面。
-
简化配置:通过使用单个应用程序清单来处理大多数任务,不再需要单独管理平台配置文件,如
AndroidManifest.xml
、Info.plist
或Package.appxmanifest
。
相比之下,在 Flutter 或 React Native 中访问原生设备功能,你必须依赖 Flutter 插件或 React Native 模块,而这些插件或模块又依赖于开发者社区或需要个人开发。此外,这些接口是由开发者设计的,因此缺乏标准化。幸运的是,微软在 .NET MAUI 的发布中为大多数常用原生设备功能标准化了 API。
.NET MAUI 通过使用基于 XAML 的传统 UI 或 Blazor 混合应用中的 Blazor UI,促进了应用程序开发中的高级代码重用。这种优势对于包含 Web 和移动应用程序的项目尤其有价值,因为它允许共享用户界面设计和源代码。
由于.NET MAUI 现在是.NET 平台发布的一部分,我们可以通过每次.NET 发布始终访问最新的.NET 平台和 C#语言功能。这种包含使得可以使用高级功能,如.NET 泛型托管、依赖注入和 MVVM 工具包等。
在这本书中,我将引导你通过我使用我设计的开源应用程序在.NET MAUI 开发中的旅程。在这本书的整个版本中,我们将彻底探索.NET MAUI 和.NET 平台的功能。
这本书面向的对象
这本书主要面向对跨平台编程技术感兴趣的面向前端开发者或本地应用程序开发者。它假设读者具备 C#编程知识或任何类似于 C#的对象导向编程语言知识。
这本书涵盖的内容
第一部分,探索.NET MAUI
第一章,.NET MAUI 入门,提供了一个关于跨平台技术的简介概述。作为介绍的一部分,.NET MAUI 与其他跨平台技术进行了比较,以突出其独特功能。此外,本章还指导你通过设置.NET MAUI 开发环境的过程。通过阅读本章,你将获得对跨平台技术的广泛理解,这将帮助你选择最适合你项目的选项。
第二章,构建我们的第一个.NET MAUI 应用程序,指导你设置新项目的过程,该项目将作为本书中展示的开发工作的基础。此外,本章还详细阐述了.NET MAUI 项目结构,并全面讨论了应用程序生命周期。到本章结束时,你将掌握如何创建新项目,并掌握与.NET MAUI 应用程序相关的基本调试技能。
第三章,使用 XAML 进行用户界面设计,介绍了使用 XAML 进行用户界面设计的概念。本章探讨了 XAML 的基本理解以及.NET MAUI 的 UI 元素。完成本章后,你将获得创建自己的用户界面设计所需的必要技能。
第四章,探索 MVVM 和数据绑定,介绍了.NET MAUI 应用程序开发中的关键主题,包括 MVVM 模式和数据绑定。我们将从理论开始,然后将所学应用到密码管理应用程序的开发工作中。你将学习如何使用数据绑定并将其应用于 MVVM 模式。
第五章,使用.NET MAUI Shell 和 NavigationPage 进行导航,探讨了.NET MAUI 应用开发中导航的基本方面。这包括利用.NET MAUI Shell 和NavigationPage
进行高效导航等主题。本章从理论概述开始,然后过渡到实际用例,特别是关注密码管理应用的开发。到本章结束时,您将深入理解如何在您的.NET MAUI 应用中有效地实现导航。
第六章,使用依赖注入进行软件设计,深入探讨了设计原则,特别是提供了 SOLID 设计原则的概述。随后,本章阐述了在.NET MAUI 中使用依赖注入,并将此技术融入我们的应用开发过程中。到本章结束时,您不仅将广泛了解 SOLID 设计原则,还将深入理解依赖注入。
第七章,使用平台特定功能,涵盖了与.NET MAUI 开发中利用平台特定功能相关的复杂主题。本章将指导您了解实现平台特定代码所涉及的基本步骤。随着您深入开发应用中的平台特定功能,这些知识将得到进一步巩固。
第二部分,实现.NET MAUI Blazor
第八章,介绍 Blazor 混合应用开发,介绍了使用.NET MAUI Blazor 开发应用的概念。本章指导您创建新的 Blazor 混合应用,并提供将.NET MAUI XAML 应用转换为.NET MAUI Blazor 混合应用的说明。
您将获得的知识包括理解基本环境设置和 Razor 语法,这对于.NET MAUI Blazor 应用开发至关重要。
第九章,理解 Blazor 路由和布局,专注于 Blazor 混合应用的路由和布局方面。本章提供了路由设置过程和布局组件使用的见解。到本章结束时,您将了解如何设计布局并为您的应用设置路由。
第十章,实现 Razor 组件,深入探讨了 Razor 组件的概念以及它们内部的数据绑定使用。本章将教会您如何创建 Razor 类库,并指导您改进现有的 Razor 代码以构建可重用的 Razor 组件。到本章结束时,您将实际理解如何有效地实现 Razor 组件。
第三部分,测试和部署
第十一章,开发单元测试,介绍了.NET MAUI 可用的单元测试框架。本章将教你如何利用 xUnit 和 bUnit 开发有效的单元测试用例。此外,你还将学习如何为.NET 类构造单元测试用例,以及如何使用 bUnit 为 Razor 组件创建特定的单元测试用例。
第十二章,在应用商店中部署和发布,讨论了为应用商店准备应用包以及使用 GitHub Actions 设置 CI/CD 工作流程的流程。本章提供了创建适合 Google Play、Apple Store 和 Microsoft Store 的包的见解。此外,你还将学习如何使用 GitHub Actions 自动化包创建过程,从而简化你的发布工作。
为了充分利用本书
完成第一章后,你可以选择继续阅读第一部分或跳转到第二部分。本书的第一部分深入探讨了利用 XAML UI 开发经典.NET MAUI 应用的开发过程。相比之下,第二部分介绍了.NET MAUI 中的新概念 Blazor 混合应用开发。本书的最后一部分专注于单元测试和部署策略。
请注意,构建本书中讨论的项目需要 Windows 和 macOS 计算机。我们将使用 Visual Studio 2022 和.NET 8 SDK 进行整个过程的操作。要在 Windows 上构建 iOS 和 macOS 目标,需要连接到一个可网络访问的 Mac,具体操作请参考 Microsoft 提供的文档:learn.microsoft.com/en-us/dotnet/maui/ios/pair-to-mac
。
由于 Visual Studio for Mac 计划于 2024 年 8 月 31 日退役,你可以在 Mac 上安装.NET SDK 和 Visual Studio Code 来替代它。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Visual Studio 2022 | Windows |
带有.NET SDK 的 Visual Studio Code | macOS |
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition
。如果代码有更新,它将在 GitHub 仓库中更新。
我的作业仓库是github.com/shugaoye/PassXYZ.Vault2
。
我将首先在我的作业仓库中更新源代码,然后将提交推送到 Packt 仓库。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们!
下载彩色图片
我们还提供了一份包含本书中使用的截图和图表彩色图片的 PDF 文件。你可以从这里下载:packt.link/gbp/9781835080597
。
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“CreateMauiApp
的返回值是一个MauiApp
实例,这是我们应用程序的入口点。”
代码块应如下设置:
private async Task<bool> UpdateItemAsync(string key,
string value)
{
if (listGroupItem == null) return false;
if (string.IsNullOrEmpty(key) ||
string.IsNullOrEmpty(value))
return false;
listGroupItem.Name = key;
listGroupItem.Notes = value;
if (_isNewItem) {...}
else {...}
StateHasChanged();
return true;
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
Image image = new Image {
BackgroundColor = Color.FromHex("#D1D1D1")
};
image.Source = new **FontImageSource** {
Glyph = "\uf007",
FontFamily = "FontAwesomeRegular",
Size = 32
};
任何命令行输入或输出都应如下编写:
git clone -b chapter09
https://github.com/PacktPublishing/Modern-Cross-Platform-Application-Development-with-.NET-MAUI PassXYZ.Vault2
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“启动 Visual Studio 2022 并在启动屏幕上选择创建新项目。”
技巧
看起来是这样的。
重要提示
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过feedback@packtpub.com
发送电子邮件,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com
与我们联系。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com
与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com
。
分享您的想法
一旦您阅读了.NET MAUI 跨平台应用开发,第 2 版,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢随时随地阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的邮箱访问权限
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接
packt.link/free-ebook/9781835080597
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件
第一部分
探索 .NET MAUI
在本书的第一部分,我们将深入探讨 .NET MAUI 编程。我们将从介绍 .NET MAUI 及其前身 Xamarin.Forms 开始。随后,我们将使用 Visual Studio 模板创建一个代码库,用于我们的应用程序。在整个书中,我们将逐步开发一个名为 PassXYZ.Vault 的密码管理器应用程序。在应用程序的开发过程中,我们将涵盖使用 XAML 进行用户界面设计、MVVM 模式、数据绑定、Shell 和依赖注入等主题。
到 第一部分 的结尾,我们将完成一个功能齐全的密码管理器应用程序。在本部分中,我们将探索构建此应用程序的一些重要基础,例如在 MVVM 和数据绑定的上下文中使用 .NET Community Toolkit。这将使您能够构建和实现涉及有效处理大量数据的应用程序。
第一部分 包含以下章节:
-
第一章,开始使用 .NET MAUI
-
第二章,构建我们的第一个 .NET MAUI 应用程序
-
第三章,使用 XAML 进行用户界面设计
-
第四章,探索 MVVM 和数据绑定
-
第五章,使用 .NET MAUI Shell 和 NavigationPage 进行导航
-
第六章,使用依赖注入进行软件设计
-
第七章,使用平台特定功能
第一章:开始使用 .NET MAUI
自从 .NET 5 发布以来,微软一直在努力将不同的 .NET 实现统一到一个 .NET 版本中。.NET 多平台应用程序用户界面(或 .NET MAUI)是提供统一跨平台 UI 框架的努力。本书将教会我们如何使用 .NET MAUI 开发跨平台应用程序。
本章节将涵盖以下内容:
-
跨平台技术的概述
-
跨平台技术(.NET、Java 和 JavaScript)的比较
-
.NET 景观和 Xamarin 的历史
-
.NET MAUI 特性
-
.NET MAUI Blazor 应用程序
-
.NET 8 为 .NET MAUI 带来了哪些新特性?
-
开发环境设置
如果你刚接触 .NET 开发,本章将帮助你了解 .NET 的整体环境。对于 Xamarin 开发者来说,本书中的许多主题可能听起来很熟悉;本章将为你概述本书将要讨论的内容。
跨平台和全栈技术的概述
.NET 多平台应用程序用户界面,或称 .NET MAUI,是微软推出的一款跨平台开发框架,用于构建针对 Android、iOS、macOS、Windows 和 Tizen 等移动和桌面设备的应用程序。它是市场上众多跨平台框架之一。
在讨论跨平台技术之前,我们先回顾一下应用开发的范围。这次回顾将帮助我们更好地理解各种跨平台框架。
通常,软件开发可以分为两大类——系统编程和应用编程。应用编程旨在直接为用户提供软件服务,而系统编程旨在产生为其他软件提供服务的软件和软件平台。在 .NET 领域,.NET 平台本身的发展属于系统编程,而基于 .NET 平台的应用开发则属于应用编程。
与系统编程相比,大多数软件开发需求都来自应用编程。
许多商业解决方案都属于应用开发的范畴。为了理解为什么我们为解决方案选择特定的技术栈,了解整个解决方案中使用的所有技术概述是至关重要的。一旦我们清楚地理解了跨平台技术在整个解决方案中的作用,我们就能更好地理解我们希望选择的技术。
商业解决方案的大部分开发工作包括前端和后端组件。前端开发者负责用户直接看到、触摸和交互的应用程序的可视和交互部分。通常,前端团队专注于开发网页和原生应用程序。另一方面,后端开发涉及服务器端处理、数据管理和应用程序中业务逻辑的实现。
前端和后端开发涉及不同的编程语言和框架。有时,由于各种原因,同一个团队可能需要同时进行前端和后端开发。在这种情况下,我们需要一个能够进行全栈开发的团队。
为了根据编程语言和框架对应用程序开发进行分类,我们有以下三个类别。
本地应用程序
通过本地应用程序开发,我们通常指的是为特定操作系统进行的应用程序开发。对于桌面应用程序,可能包括 Windows 应用程序、macOS 应用程序或 Linux 应用程序。对于移动应用程序,可能包括 Android 或 iOS。
当我们开发本地应用程序时,我们可能需要支持多个平台(Windows、Linux、Android、macOS/iOS 等)。为了支持多个平台,我们需要使用不同的编程语言、工具和库来分别开发每一个平台。
Web 应用程序
过去几十年中,Web 应用程序开发已经经历了几个时代的演变,从 Netscape 浏览器中的静态网页到今天使用 JavaScript 框架(如 React 和 Angular)的单页应用程序(SPA)。在 Web 应用程序开发中,JavaScript 和各种基于 JavaScript 的框架占据了市场的主导地位。在.NET 生态系统中,Blazor 正在努力追赶这个领域的步伐。我们将在第八章“介绍 Blazor 混合应用程序开发”中了解更多关于 Blazor 的内容。
后端服务
本地应用程序和 Web 应用程序通常需要后端服务来访问业务逻辑或数据库。对于后端开发,有各种语言和框架可供选择,包括 Java/Spring、.NET、Node.js、Ruby on Rails 和 Python/Django。在许多情况下,本地应用程序和 Web 应用程序可以共享同一个后端服务。Java/Spring、ASP.NET 和 Node.js 是后端服务开发中最受欢迎的选择之一。
每个类别的技术栈选择可以显著影响解决方案的复杂性。在下一节中,我们将回顾和分析解决方案的复杂性。
管理开发复杂性
构建一个完整的解决方案通常需要一个 Web 应用程序、一个本地应用程序和一个后端服务。由于 Web、本地和后端开发使用不同的编程语言和框架,我们必须组建多个团队来实现一个解决方案。为了管理开发过程的复杂性,我们需要管理开发团队的配置。团队配置可以根据两种极端情况/方法进行管理。最简单的一种是建立一个涵盖所有层的团队。更复杂的一种是针对每个层有独立的团队。要成功使用一个团队开发所有层,该团队必须是一个跨平台的全栈开发团队。是否存在跨平台的全栈团队?让我们在下一两节中回顾各种场景。
全栈开发
许多人怀疑组建全栈团队是否好,甚至是否可行,但实际上,最早的 Web 开发框架都是全栈框架。如果你有机会使用 Java/Thymeleaf 或 ASP.NET MVC,你会知道它们是全栈框架。这些框架使用服务器端渲染,因此 UI 和业务逻辑的实现都在服务器端。
前端和后端的分离伴随着单页面应用(SPAs)的出现。为了将 UI 移动到客户端,SPA 框架,如 React、Angular 和 Vue.js,被用来实现客户端逻辑。后端服务使用 Java/Spring 和 ASP.NET Core 等框架来实现。
单页面应用(SPA)框架使用客户端渲染(CSR),而 Java/Thymeleaf 和 ASP.NET MVC 使用服务器端渲染(SSR)。CSR 和 SSR 各有优缺点。在现代应用开发中,框架如 Next.js 和 Nuxt.js 都同时使用了 CSR 和 SSR。在.NET 8 中,微软引入了服务器端 Blazor 组件渲染,或称 Blazor United。有了这个特性,ASP.NET 中前端和后端的界限再次变得模糊。
总结来说,我们应该根据业务需求选择技术栈,因此并没有一个单一的、通用的答案来决定我们是否应该采用全栈开发。
对于最终用户来说,SPAs 与原生应用非常相似,一些 SPA 框架甚至已经发展成为跨平台框架,如 React Native。使用 React 和 React Native,一个团队可以同时进行前端开发和原生应用开发。此外,如果选择了基于 JavaScript 的后端框架,就有可能组建一个跨平台全栈团队来实现整个解决方案。
为了更好地理解跨平台框架,让我们在下一节分析目前市场上可用的跨平台框架。
跨平台技术
跨平台框架是原生应用程序开发的替代解决方案。原生应用程序开发是指使用操作系统提供的编程语言和软件开发工具包(SDK)。在原生应用程序开发中,我们使用原生开发工具,例如 iOS 的 Swift/UIKit、Android 的 Java/Kotlin 和 Windows 的 C#/WinUI。理想情况下,我们应该使用操作系统供应商提供的工具来为特定操作系统开发应用程序。使用原生应用程序开发,我们的应用程序不会出现性能或兼容性问题。使用跨平台开发框架时,总会存在某些无法仅使用跨平台 API 解决的边缘情况。无论你是 .NET MAUI、React Native 还是 Flutter 开发者,获取一定程度的原生编程知识来解决这些特定情况是必要的。例如,我现在正在等待 ZXing.Net.Maui 项目准备就绪,以便在我的应用程序中支持二维码功能。
然而,我们通常需要为多个操作系统开发我们的应用程序。原生应用程序开发比跨平台框架的成本要高得多。在项目中,我们必须在预算、时间和质量之间取得平衡。再次强调,我们需要根据业务需求选择解决方案。这可能会意味着我们需要选择一个跨平台框架。
市场上最受欢迎的跨平台框架包括 Flutter、React Native、.NET MAUI/Xamarin、Ionic 和 Apache Cordova。
理解各种跨平台技术可以极大地帮助 .NET MAUI 开发者在选择技术栈时做出明智的决定。需要注意的是,目前还没有一种单一的跨平台技术能够满足所有需求。因此,我们仍然会遇到在商业解决方案中使用 Ionic 的新项目。
在这些框架中,除了 Flutter 和 .NET MAUI,React Native、Ionic 和 Apache Cordova 都是源自 Web 开发的基于 JavaScript 的框架。
Apache Cordova
Apache Cordova 是一个混合框架,可以使用 HTML、CSS 和 JavaScript 等 Web 技术构建移动应用程序。Cordova 在 WebView 中运行,提供了一个运行时环境以访问设备功能。Cordova 使用与 Web 应用相同的技術,因此 Cordova 可以重用 Web 应用的源代码。使用混合框架,前端团队可以同时处理 Web 和移动应用程序。
混合框架的问题在于,由于用户界面是使用 Web 技术创建的,因此移动端的用户界面与原生应用相比更类似于 Web 应用。另一个担忧是,混合应用程序的性能依赖于特定平台上的 WebView 的性能。用户在 iOS 和 Android 平台上可能会有不同的体验。
Cordova 是一个使用 JavaScript、HTML 和 CSS 的混合框架,但它不提供使用现代 JavaScript 框架(如 React、Vue.js 和 Angular)的方法。
Ionic
Ionic 是一个最初建立在 Apache Cordova 之上的框架。另一个名为 Capacitor 的低级框架也可以在较新版本中使用。Ionic 可以与流行的 JavaScript 框架如 React、Vue.js 和 Angular 集成,这意味着生产力可以显著提高。Ionic 还包括 UI 控件以支持交互、手势和动画。
由于 Ionic 是使用混合框架(如 Cordova 和 Capacitor)构建的,其性能与底层框架相似。然而,Ionic 包括针对动画和过渡的优化,以提供类似原生的性能,用于内置库。
React Native
React Native 是另一个起源于 Web 开发框架的框架。由于 React Native 使用 React 和 JavaScript 开发移动应用,React 开发者可以轻松地掌握 React Native。跨平台团队可以使用 React 和 React Native 在 Web 和移动应用上同时工作。
与混合框架不同,React Native 使用桥接技术将 JavaScript UI 转换为原生 UI。React Native 应用与原生应用具有相同的视觉和触觉体验。由于 React Native 在 iOS 和 Android 上使用原生组件来渲染 UI,而不是 WebView,因此它比混合应用能实现更好的性能。React Native 使用原生组件的缺点是我们不能在 React 和 React Native 之间共享 UI 设计和 UI 代码。其范式是“一次学习,到处编写”,而不是“一次编写,到处运行”。
由于 React Native 在其架构中使用了桥接技术,其性能可能比 Flutter 或 .NET MAUI 慢。在新版本的 React Native 中,这可能会通过新的架构得到改善。
Flutter
Flutter 是由 Google 开发的一个开源 UI 工具包,使用 Dart 编程语言。使用 Dart,Flutter 支持 即时编译(JIT)和 提前编译(AOT),并且拥有强大的性能优化工具。Flutter 应用可以实现接近原生应用的性能。
Flutter 与混合应用和 React Native 相比,采用了独特的渲染技术。它使用一个名为 Skia(或预览中的 Impeller)的内部图形引擎将组件层次结构转换为屏幕上的实际像素。Flutter 应用在不同设备上具有相同的视觉和触觉体验,无需特定定制。
为了模拟不同平台的外观和感觉,Flutter 为 iOS 和 Android 提供了两个不同的 UI 库。Cupertino 小部件用于 iOS,Material 小部件用于 Android。
.NET MAUI
.NET MAUI 是 Xamarin.Forms 的继任者,Xamarin.Forms 是更大 Xamarin 平台的一部分。Xamarin 本身提供原生 UI 控件,作为 iOS、macOS 和 Android 上 .NET 运行时环境的一部分。您可以使用 C# 和 Xamarin 开发原生应用程序。在 Windows 上,由于 .NET 是原始框架,因此默认支持 .NET。
从 .NET 6 或更高版本开始,您在这四个平台上都有完整的 .NET 运行时。.NET MAUI 只是使用 Xamarin 的 .NET 跨平台框架之一。还有其他使用 Xamarin 的 .NET 跨平台框架,其中一些可以支持比 .NET MAUI 更多的操作系统,例如 Uno Platform、Avalonia UI 和 Blazor Hybrid。
Flutter、React Native 和 Cordova/Ionic 代表三种不同的跨平台框架实现类型。在 .NET 世界中,我们可以找到可以匹配所有这三个类别的 .NET 跨平台实现。
Cordova 或 Ionic 是一个混合框架,它使用 Web 技术 JavaScript、HTML 和 CSS 来开发在 WebView 内运行的移动应用程序。在 .NET 中,Blazor 是一种 Web 技术,它使用 C#、HTML 和 CSS 来开发 Web 应用程序。.NET 的混合解决方案是 Blazor Hybrid。BlazorWebView 用于在不同的平台上托管 Blazor Hybrid 应用程序。目前,BlazorWebView 可用于 .NET MAUI、WFP 和 Windows Forms。我们可以使用 BlazorWebView 在这三个框架上开发混合应用程序。它就像 Cordova 和 Ionic 一样,Blazor Hybrid 应用程序的外观和感觉与 Web 应用程序相同。Blazor Hybrid 和 Blazor 应用程序之间可以重用代码。
Flutter 使用基于 Skia 2D 引擎或 Impeller 的渲染技术。在 .NET 中,Avalonia UI 使用类似的方法,通过 Skia 2D 引擎构建 UI 控件。
.NET MAUI 使用原生组件作为 UI 控件,例如 React Native,因此 .NET MAUI 的 UI 看起来与原生应用程序相同。
要总结和比较不同的框架,请参阅 表 1.1。
跨平台框架(语言) | UI 功能 |
---|---|
.NET MAUI(XAML/C#) | React Native(HTML/CSS/JavaScript) |
Blazor Hybrid(HTML/CSS/C#) | Ionic/Cordova(HTML/CSS/JavaScript) |
Avalonia UI(XAML/C#) | Flutter(Dart) |
表 1.1:跨平台框架比较
除了 Flutter 之外,我们还可以看到跨平台框架使用 JavaScript 或 .NET。可以设置一个跨平台全栈团队,使用 JavaScript 或 .NET 技术来处理整个解决方案。让我们在下一节通过比较不同的组合来回顾跨平台全栈解决方案的复杂程度。
跨平台全栈解决方案分析
现在我们已经分析了跨平台和全栈框架,我们可以看到我们可以只使用一种编程语言,即 JavaScript 或 C#,来构建解决方案的整个技术栈。
表 1.2 是使用 JavaScript 或 .NET 的跨平台全栈框架的总结。
层 | 框架 |
---|---|
JavaScript | C#/.NET |
![]() |
React、Angular 或 Vue.js 等 |
![]() |
Cordova 或 Ionic |
React Native | .NET MAUI |
![]() |
Node.js/Nest.js/Koa/Express.js |
表 1.2:JavaScript 和 .NET 跨平台全栈技术栈比较
要使用 JavaScript 构建解决方案,我们可以使用 JavaScript 框架(如 React、Angular 和 Vue.js)开发 Web 客户端。如果成本和上市时间是主要考虑因素,我们希望尽可能地在 Web 和移动之间重用代码。在这种情况下,我们可以选择混合框架,如 Ionic 和 Cordova。如果性能和用户体验对业务更重要,React Native 可以是移动开发的良好选择。对于后端服务,有大量的基于 JavaScript 的后端框架,如 Nest.js、Koa 和 Express.js。我们可以选择在解决方案的所有层中使用纯 JavaScript 框架。
在 .NET 中,我们有与 JavaScript 中非常相似的选择。如果选择纯 .NET 技术栈的解决方案,可以使用 Blazor 进行 Web 客户端开发。我们还有选择混合和原生框架的选项。如果考虑成本和上市时间,我们可以选择 Blazor Hybrid,这样可以将 Web 和移动开发视为一个开发任务。为了获得原生用户体验和更好的性能,我们可以选择 .NET MAUI 进行移动开发。在后端开发中,ASP.NET Core 已经拥有很大的市场份额,并且是一个流行的框架。
使用单一编程语言的框架是最经济的选择,正如我们在 表 1.1 和 表 1.2 中所看到的。然而,在实际项目中,我们可能需要考虑许多因素。我们的实际解决方案可以是不同语言和框架的组合。让我们回顾 表 1.3 中不同组合的复杂度级别。
上市时间成本 | ![]() |
性能用户体验 | 复杂度级别 | 解决方案中的技术栈 |
---|---|---|---|---|
移动 | Web | 后端 | ||
1 | Blazor Hybrid | Blazor | ASP.NET | |
离子/Cordova | JavaScript | Node.js | ||
2 | .NET MAUI | Blazor | ASP.NET | |
React Native | ReactJS | Node.js | ||
3 | React Native | ReactJS | Java/ASP.NET/… | |
4 | Flutter | JavaScript | Java/ASP.NET/… | |
5 | Android/iOS/macOS/Windows/… | JavaScript | Java/ASP.NET/… |
表 1.3:跨平台全栈解决方案的复杂度级别
在 表 1.3 中,我总结了解决方案每一层中不同技术选择的复杂度级别。
您的解决方案的复杂性取决于涉及多少编程语言和框架。涉及的编程语言和框架越多,上市时间和成本就越高。
在跨平台和后端框架方面,我们的选择比前端更多。在编程语言方面,我们可以选择 JavaScript 或 C#来开发 Web 客户端。因此,对于后端框架的最经济选择是选择基于 JavaScript 或 C#的后端框架。这就是我们在表 1.3中看到的复杂度级别 1 和 2 的内容。
如果我们选择混合框架,例如 Ionic 或 Blazor 混合,来开发移动应用,我们可以使用一种语言和框架来覆盖所有前端和移动开发。在这种情况下,如果我们还选择同一种语言的后端框架,与所有其他配置相比,这个配置所需的时间和成本是最小的。正如我们在表 1.3中可以看到的,我们在复杂度级别 1 有 JavaScript 和.NET 选项。
对产品所有者来说,最经济的选择可能不是最好的,因为产品所有者可能对解决方案的用户体验和性能有所顾虑。就前端和后端框架而言,.NET 和 JavaScript 框架是成熟且经过验证的解决方案。然而,在考虑解决方案性能时,真正的担忧应该是关于混合框架的。我们可以选择.NET MAUI 或 React Native 进行移动开发。当使用 React Native 时,最经济的选择是使用 React 作为前端框架。这样,我们仍然可以使用一种语言和类似的框架来覆盖移动和 Web 开发。有许多商业解决方案与此选项相匹配。由于我们无法在.NET MAUI 和 Blazor 或 React 和 React Native 之间共享 UI 代码,这个类别是一个复杂度级别 2 的选项。
在后端开发中,关于语言和框架的选项太多,其中许多都是经过验证的解决方案。可供后端开发使用的语言列表非常长,例如 Java、C#、JavaScript、Python、Ruby、Go 和 Rust。无论出于什么原因,我们可能无法选择与移动和 Web 开发相同的后端编程语言。这意味着复杂度级别 3 的解决方案。在这种情况下,项目团队可以通过选择与后端框架一起使用的 React 和 React Native 来降低复杂性。在实际项目中,.NET MAUI 和 Blazor 通常与.NET 后端一起使用。我们很少看到.NET MAUI 或 Blazor 与非.NET 后端框架一起使用的情况。
Flutter 和 React Native 是市场上最受欢迎的两个跨平台框架。.NET MAUI 还需要更多时间才能赶上市场。如果我们使用 Flutter,我们将进入复杂度级别 4。在这种情况下,我们必须在解决方案配置中涉及三种编程语言。
在某些大型项目中,用户体验比其他因素更重要。在这种情况下,我们选择原生应用开发。涉及的语言和框架数量显著增加,因为列表中添加的每个操作系统都会增加一个编程语言。这是最复杂的情况,复杂度为 5 级。
.NET 和 JavaScript 的比较
没有一种跨平台工具或框架是最佳选择。最终的选择通常是根据具体的业务需求来决定的。然而,从上面的表格中,我们可以看到.NET 生态系统为您的需求提供了全面的工具。大型系统的发展团队通常需要具有不同编程语言和框架经验的人员。使用.NET,编程语言和框架的复杂性可以大大简化。
我们概述了在 Web 应用、原生应用和后端服务开发中使用的工具和框架。如果我们从更高的层面来看,即.NET 生态系统层面,JavaScript 的生态系统几乎与我们在.NET 解决方案中拥有的相匹配。JavaScript 和.NET 解决方案几乎可以在所有层面上提供工具或框架。在更高层面上对 JavaScript 和.NET 进行比较将很有趣。
JavaScript 是为网页浏览器创建的一种语言,但由于网络开发的需求,其能力得到了扩展。JavaScript 的限制在于它是一种脚本语言,因此它缺乏在 C#中可以找到的语言特性。然而,这种限制并不限制其使用和普及。表 1.4是两种技术的比较:
比较领域 | .NET | JavaScript |
---|---|---|
编程语言 | C#, F#, VB, C++ | JavaScript, TypeScript, CoffeeScript 等。 |
运行时 | CLR | V8/SpiderMonkey/JavaScriptCore |
支持的 IDE | Microsoft Visual Studio, Rider, MonoDevelop, Visual Studio Code | Visual Studio Code, Webstorm, Atom |
Web | ASP.NET MVC/Blazor | React, Angular, Vue.js 等。 |
原生应用 | WinForms, WinUI, WPF, UWP | - |
桌面应用 | .NET MAUI/Avalonia/Uno Platform/Xamarin | Electron, NW.js |
移动应用 | React Native, Cordova, 或 Ionic | |
后端 | ASP.NET Core | Node.js |
表 1.4:.NET 和 JavaScript 的比较
从 表 1.4 中,我们可以看到 .NET 有一个良好的基础设施来支持多种语言。以 公共类型系统 (CTS) 和 公共语言运行时 (CLR) 作为 .NET 实现的核心,它自然地支持多种语言,并具有在所有支持的语言中共享 基础类库 (BCL) 的能力。JavaScript 作为一种脚本语言有其局限性,因此发明了 TypeScript 和 CoffeeScript 等语言来增强它。TypeScript 是由微软开发的,旨在将现代、面向对象的语言特性引入 JavaScript。TypeScript 编译成 JavaScript 以执行,因此它可以很好地与现有的 JavaScript 库协同工作。
.NET 和 JavaScript 的跨平台框架不仅涵盖移动开发,还包括桌面开发。在 .NET 环境中,.NET MAUI、Uno Platform 和 Avalonia 可以支持桌面和移动的跨平台开发。在 JavaScript 生态系统中,React Native、Ionic 和 Cordova 用于移动开发,而 Electron 或 NW.js 用于桌面开发。
由于 .NET 是 Windows 操作系统的内置组件,因此它被用于开发如 WinForms、UWP 和 WPF 这样的原生应用程序。Windows 操作系统本身是跨平台编程框架支持的主要目标之一。
这个比较帮助我们选择并评估跨平台全栈开发的技术堆栈。作为一名 .NET MAUI 开发者,这项分析可以帮助你了解 .NET MAUI 在 .NET 生态系统中的位置。要了解更多关于 .NET 生态系统的情况,让我们在下一节快速回顾一下 .NET 整体格局的历史。
探索 .NET 的整体格局
在我们深入探讨 .NET MAUI 的细节之前,让我们先对 .NET 的整体格局有一个概览。本节是为那些刚接触 .NET 的新手准备的。如果你是 .NET 开发者,你可以跳过这一节。
自从微软引入 .NET 平台以来,它已经从 Windows 的专有软件框架演变为跨平台和开源平台。
有许多方式来观察 .NET 技术堆栈。基本上,它包含以下组件:
-
公共基础设施(编译器和工具套件)
-
基础类库 (BCL)
-
运行时(WinRT 或 Mono)
.NET Framework
.NET 的历史始于 .NET Framework。这是一个由微软开发的专有软件框架,主要在 Microsoft Windows 上运行。.NET Framework 最初是一个面向未来的应用程序框架,旨在标准化 Windows 生态系统中的软件堆栈。它围绕 公共语言基础设施 (CLI) 和 C# 构建。尽管主要的编程语言是 C#,但它被设计成一个语言无关的框架。支持的语言可以共享相同的 CTS 和 CLR。大多数 Windows 桌面应用程序都是使用 .NET Framework 开发的,并且它作为 Windows 操作系统的一部分进行分发。
Mono
将 .NET 打造为开源框架的第一尝试是由一家名为 Ximian 的公司做出的。当 CLI 和 C# 在 2001 年由 ECMA 批准,在 2003 年由 ISO 批准时,为独立实现打开了大门。
在 2001 年,开源项目 Mono 启动,旨在在 Linux 上实现 .NET Framework。
由于当时 .NET Framework 是一种专有技术,因此 .NET Framework 和 Mono 都有自己的编译器、BCL 和运行时。
随着时间的推移,微软逐渐转向开源;.NET 源代码对开源社区开放。Mono 项目从 .NET 代码库中采用了部分源代码和工具。
同时,Mono 项目也经历了许多变化。一度,Mono 由 Xamarin 拥有。Xamarin 开发了基于 Mono 的 Xamarin 平台,以支持 Android、iOS、UWP 和 macOS 上的 .NET 平台。2016 年,微软收购了 Xamarin,Xamarin 成为 .NET 生态系统中的跨平台解决方案。
.NET Core
在收购 Xamarin 之前,微软已经开始努力使 .NET 成为跨平台框架。第一次尝试是在 2016 年发布 .NET Core 1.0。.NET Core 是一个免费且开源的框架,适用于 Windows、Linux 和 macOS。它可以用来创建现代 Web 应用、微服务、库和控制台应用程序。由于 .NET Core 应用可以在 Linux 上运行,我们可以使用容器和云基础设施来构建微服务。
在 .NET Core 3.x 发布后,微软致力于在各种平台上整合和统一 .NET 技术。这个统一版本旨在取代 .NET Core 和 .NET Framework。为了避免与 .NET Framework 4.x 混淆,这个统一框架被命名为 .NET 5。自 .NET 5 以来,可以在所有平台上使用一个共同的 BCL。在 .NET 5 中,仍然存在两个运行时,分别是用于 Windows 的 Windows 运行时(WinRT),以及用于移动和 macOS 的 Mono 运行时。
自 .NET 5 以来,.NET 发布支持两种类型的发布,分别是 长期支持(LTS)和 标准期限支持(STS)。在这本书中,我们将使用 .NET 8 发布,这些是 LTS 发布。
.NET Standard 和可移植类库
在 .NET 5 之前,我们有 .NET Framework、Mono 和 .NET Core,在不同平台上拥有不同的 BCL 子集。为了在不同运行时或平台之间共享代码,使用了一种称为 可移植类库(PCLs)的技术。当你创建一个 PCL 时,你必须选择你想要支持的平台的组合。兼容性级别由开发者决定。如果你想重用 PCL,你必须仔细研究支持的平台列表。
尽管 PCL 提供了一种共享代码的方法,但它并不能很好地解决兼容性问题。为了克服兼容性问题,微软引入了 .NET Standard。
.NET 标准不是一个独立的 .NET 版本,而是一组必须在大多数 .NET 实现上支持(.NET Framework、Mono、.NET Core、.NET 5 和 6 等)的 .NET API 规范。
自 .NET 5 以来,统一的 BCL 可用,但 .NET 标准仍将是这个统一 BCL 的一部分。如果你的应用程序只需要支持 .NET 5 或更高版本,你实际上不需要太关心 .NET 标准版。然而,如果你想与旧版本的 .NET 兼容,.NET 标准版仍然是你的最佳选择。由于这是一个可以支持大多数现有 .NET 实现和所有未来 .NET 版本的版本,本书将使用 .NET 标准版 2.0 来构建我们的数据模型。
微软将不再发布新的 .NET 标准版本,但 .NET 5、.NET 6 以及所有未来的版本将继续支持 .NET 标准版 2.1 及更早版本。表 1.5 展示了 .NET 标准版 2.0 可以支持的平台和版本。这同时也是本书数据模型兼容性列表。
.NET 实现 | 版本支持 |
---|---|
.NET 和 .NET Core | 2.0, 2.1, 2.2, 3.0, 3.1, 5.0, 6.0 |
.NET Framework 1 | 4.6.1.2, 4.6.2, 4.7.1, 4.7.2, 4.8 |
Mono | 5.4, 6.4 |
Xamarin.iOS | 10.14, 12.16 |
Xamarin.Mac | 3.8, 5.16 |
Xamarin.Android | 8.0, 10.0 |
Universal Windows Platform | 10.0.16299, TBD |
Unity | 2018.1 |
表 1.5:.NET 标准版 2.0 兼容的实现
使用 Xamarin 进行跨平台开发
正如我们在上一节中提到的,Xamarin 是 Mono 项目的一部分,旨在支持 Android、iOS 和 macOS 上的 .NET。Xamarin 将底层操作系统功能导出到 .NET 运行时。Xamarin.Forms 是 Xamarin 的跨平台 UI 框架。.NET MAUI 是 Xamarin.Forms 的发展。在我们讨论 .NET MAUI 和 Xamarin.Forms 之前,让我们回顾一下各种平台上 Xamarin 实现的以下图示。
图 1.1:Xamarin 实现
图 1.1 展示了 Xamarin 的整体架构。Xamarin 允许开发者在每个平台上创建原生 UI,并用 C# 编写可以在多个平台上共享的业务逻辑。
从 Xamarin 到 .NET MAUI 的过渡,或者更具体地说,从 Xamarin.Forms 到 .NET MAUI 的过渡,并不是一场革命。.NET MAUI 实质上代表了一个新的 Xamarin.Forms 版本,而不是 Xamarin 的其他组件。Xamarin.Android 现在已成为 .NET Android,主要区别在于名称变更。然而,整体架构并没有经历重大修改。
在支持的平台上,Xamarin 包含了几乎所有底层平台 SDK 的绑定。Xamarin 还提供了直接调用 Objective-C、Java、C 和 C++ 库的功能,赋予你使用大量第三方代码的能力。你可以使用用 Objective-C、Swift、Java 和 C/C++ 编写的现有 Android、iOS 或 macOS 库。
Mono 运行时在这些平台上用作.NET 运行时。它有两种操作模式——JIT 和 AOT。JIT,或即时编译,在执行时动态生成代码。在 AOT,或提前编译,模式下,Mono 预先编译一切,以便可以在不允许动态代码生成的操作系统中使用。
正如我们在图 1.1中可以看到的,JIT 可以在 Android 和 macOS 上使用,而 AOT 用于 iOS,其中不允许动态代码生成。
使用 Xamarin 开发原生应用程序有两种方式。
我们可以使用每个平台上的原生 API,就像 Android、iOS 和 macOS 的开发者一样开发原生应用程序。区别在于您使用.NET 库和 C#而不是直接使用特定于平台的语言和库。这种方法的优势在于,我们可以使用一种语言,并通过.NET BCL 共享大量组件,即使在不同的平台上工作也是如此。我们还可以利用底层平台的力量,就像原生应用程序开发者一样。
如果我们想在用户界面层重用代码,可以使用 Xamarin.Forms 而不是原生 UI。
Xamarin.Forms
Xamarin.Android、Xamarin.iOS 和 Xamarin.Mac 提供了一个.NET 环境,几乎在其各自平台上暴露了所有原始 SDK 的能力。例如,作为开发者,您使用 Xamarin.Android 时几乎具有与原始 Android SDK 相同的性能。为了提高代码共享,创建了开源 UI 框架 Xamarin.Forms。Xamarin.Forms 包含一系列跨平台 UI 组件。用户界面设计可以使用 XAML 标记语言实现,这与 WinUI 或 WPF 中的 Windows 用户界面设计类似。
Xamarin.Essentials
由于 Xamarin 暴露了底层平台 SDK 的能力,您可以使用.NET API 访问设备功能。然而,实现是平台特定的。例如,当您在 Android 或 iOS 上使用位置服务时,可用的.NET API 可能不同。为了进一步改善跨平台的代码共享,可以使用 Xamarin.Essentials 来访问原生设备功能。Xamarin.Essentials 为原生设备功能提供了一个统一的.NET 接口。如果您使用 Xamarin.Essentials 而不是原生 API,您的代码可以在平台上重用。
Xamarin.Essentials 提供的一些功能示例包括:
-
设备信息
-
文件系统
-
加速度计
-
电话拨号
-
文字转语音
-
屏幕锁定
使用 Xamarin.Forms 和 Xamarin.Essentials 一起,大多数实现,包括业务逻辑、用户界面设计和一些特定于设备的特性,可以在平台上共享。在第七章使用特定平台的功能中,我们将学习 Xamarin.Essentials 是如何移植的,从而使其对.NET MAUI 可用。
比较不同平台上的用户界面设计
在各种平台上,大多数现代应用程序开发都使用模型-视图-控制器(MVC)设计模式。为了分离业务逻辑和用户界面设计,Android、iOS/macOS 和 Windows 上使用了不同的方法。在这些平台上,尽管使用的编程语言不同,但它们都使用 XML 或 HTML 来设计用户界面。
在 iOS/macOS 上,开发者可以使用 Xcode 中的 Interface Builder 生成.storyboard或.xib文件。这两个都是基于 XML 的脚本文件,用于保存用户界面信息,并在运行时与 Swift 或 Objective-C 代码一起解释以创建用户界面。2019 年,苹果宣布了一个新的框架,SwiftUI。使用 SwiftUI,开发者可以以声明式的方式直接使用 Swift 语言构建用户界面。
在 Android 平台上,开发者可以使用 Android Studio 中的布局编辑器图形化地创建用户界面,并将结果存储在布局文件中。布局文件是 XML 格式,可以在运行时加载以创建用户界面。
在 Windows 平台上,用户界面设计使用 XAML。XAML,或可扩展应用程序标记语言,是一种用于 Windows 平台用户界面设计的基于 XML 的语言。对于 WPF 或 UWP 应用程序,可以使用 XAML Designer 进行用户界面设计。在.NET MAUI 中,基于 XAML 的 UI 是默认的应用程序 UI。另一种模式,MVU,也可以使用。在 MVU 模式中,用户界面直接使用 C#实现,而不使用 XAML。MVU 的编码风格类似于 SwiftUI。
即使在苹果平台上的 SwiftUI 或.NET MAUI 中的 MVU 可以使用,经典的用户界面实现仍然使用 XML 或 HTML。让我们在表 1.6中进行比较。
平台 | IDE | 编辑器 | 语言 | 文件扩展名 |
---|---|---|---|---|
Windows | Visual Studio | XAML Designer | XAML/C# | .xaml |
Android | Android Studio | 布局编辑器 | XML/Java/Kotlin | .layout |
iOS/macOS | Xcode | Interface Builder | XML/Swift/Objective C | .storyboard 或 .xib |
.NET MAUI/Xamarin.Forms | Visual Studio | N.A. | XAML/C# | .xaml |
.NET MAUI Blazor | Razor/C# | .razor |
表 1.6:用户界面设计选项比较
在表 1.6中,我们可以看到不同平台上用户界面设计选项的比较。
.NET MAUI 和 Xamarin.Forms 使用 XAML 方言在所有支持的平台上设计用户界面。对于.NET MAUI,我们还有另一种用户界面设计的选择,那就是 Blazor。Blazor UI 是用 Razor 语法编写的,Razor 语法是 HTML、CSS 和 C#的组合。我们将在本章后面讨论 Blazor。
在 Xamarin.Forms 中,我们使用 XAML 创建用户界面,在 C#中编写代码背后的部分。底层实现仍然是每个平台上的本地控件,因此 Xamarin.Forms 应用程序的外观和感觉与本地应用程序相同。
Xamarin.Forms 提供的功能示例包括:
-
XAML 用户界面语言
-
数据绑定
-
手势
-
影响
-
样式
虽然 Xamarin.Forms 允许共享几乎所有 UI 代码,但仍然需要在各个平台项目中管理应用程序使用的多数资源。这些资源可以包括图像、字体、字符串等。在 Xamarin.Forms 项目结构中,有一个通用的 .NET Standard 项目,并伴随各种特定平台的项目。
大多数开发工作应该在通用的 .NET Standard 项目中进行。虽然字体图标等资源可以作为嵌入资源在通用项目中共享,但大多数其他资源的管理仍然局限于独立的特定平台项目。
转向 .NET MAUI
随着 .NET 的统一,Xamarin 已经成为 .NET 平台的一部分,Xamarin.Forms 正在以 .NET MAUI 的形式与 .NET 集成。
.NET MAUI 是一个一等 .NET 成员,具有 Microsoft.Maui
命名空间。
转向 .NET MAUI 也是一个机会,让微软从零开始重新设计和重建 Xamarin.Forms,并解决一些在较低层次上悬而未决的问题。与 Xamarin.Forms 相比,.NET MAUI 使用单一项目结构,更好地支持热重载,并支持 MVU 和 Blazor 开发模式。
需要注意的是,MVU 目前不是一个稳定的构建 .NET MAUI 应用程序的方法;它只是被宣布了。
图 1.2 展示了 .NET MAUI 的架构图;您可以在 Microsoft 文档中找到它。从 图 1.2 中,我们可以看到所有支持的操作系统中都有一个通用的 BCL。在 BCL 之下,根据平台,有两个运行时,WinRT 和 Mono 运行时。对于每个平台,都有一个专门的 .NET 实现来提供对原生应用程序开发的全面支持。
图 1.2:.NET MAUI 架构
与 Xamarin.Forms 相比,我们可以在 表 1.7 中看到 .NET MAUI 有许多改进。
.NET MAUI 使用单一项目结构来简化项目管理。我们可以在一个位置管理资源、依赖注入和配置,而不是在每个平台上分别管理。我们将在 第二章,构建我们的第一个 .NET MAUI 应用程序 中了解更多关于单一项目结构的内容。
.NET MAUI 作为 .NET 的一部分完全集成,因此我们可以使用 .NET SDK 命令行创建和构建项目。在这种情况下,我们在开发环境方面有更多的选择。
特性 | .NET MAUI | Xamarin.Forms |
---|---|---|
项目结构 | 单个项目 | 多个项目 |
资源管理 | 所有平台的一个位置 | 每个平台独立管理 |
| 完全集成于 .NET | 在 Microsoft.Maui
和其他 IDE 中可以选择命名空间,除了 Visual Studio,还支持命令行支持。我们可以在控制台中创建、构建和运行:
dotnet new maui
dotnet build -t:Run -f net8.0-android
dotnet build -t:Run -f net8.0-ios
dotnet build -t:Run -f net8.0-maccatalyst
Xamarin.Forms 和 Visual Studio 作为 IDE 中的命名空间 |
---|
设计改进 |
模型视图更新(MVU)模式 |
Blazor 混合 |
表 1.7:.NET MAUI 改进
.NET MAUI Blazor 应用程序
在表 1.6中,我们比较了不同平台上的用户界面设计选项,我们提到在.NET MAUI 中还有另一种设计跨平台用户界面的方法,那就是 Blazor。
Blazor,于 ASP.NET Core 3.0 发布,是一个使用.NET 构建交互式客户端 Web UI 的框架。通过.NET MAUI 和 Blazor,我们可以以 Blazor 混合应用程序的形式构建跨平台应用程序。这样,原生应用程序和 Web 应用程序之间的界限变得模糊。.NET MAUI Blazor 混合应用程序使 Blazor 组件能够与原生平台功能和 UI 控件集成。Blazor 组件可以完全访问设备的原生功能。
图 1.3:.NET MAUI Blazor 混合
如我们在图 1.3中看到的,在.NET MAUI 中使用 Blazor Web 框架的方式是通过一个BlazorWebView
组件。我们可以使用.NET MAUI Blazor 在单个视图中混合本地和 Web UI。在.NET MAUI Blazor 应用程序中,应用程序可以利用 Blazor 组件模型(Razor 组件),它使用 Razor 语法中的 HTML、CSS 和 C#。应用程序的 Blazor 部分可以重用现有常规 Web 应用程序中使用的组件、布局和样式。BlazorWebView
组件可以与原生元素一起组合;此外,这些组件利用平台功能并与它们的原生对应项共享状态。
使用.NET 开发本地应用程序
使用.NET MAUI,Xamarin.Android、Xamarin.iOS 和 Xamarin.Mac 已更新到.NET for Android、.NET for iOS 和.NET for Mac。正如我们在图 1.4中看到的,我们可以使用.NET 开发本地应用程序。
图 1.4:使用.NET 进行本地应用程序开发
由于我们在 Android、iOS 和 macOS 上都有完整的.NET 实现,我们可以使用.NET 工具在这些平台上开发本地应用程序。这与 Xamarin-native 项目相同。开源项目keepass2android
是使用Xamarin.Android
开发本地 Android 应用程序的一个很好的例子:play.google.com/store/search?q=keepass2android
。
随着最新的.NET 版本发布,有多种方式可以开发跨平台应用程序,例如.NET MAUI 应用程序、本地应用程序、Blazor 混合应用程序、Avalonia 和 Uno。我们可以看到.NET 生态系统中存在多种可能性。
.NET 8 中.NET MAUI 的新特性是什么?
.NET 8 引入了许多新变化,在此我们将回顾与.NET MAUI 相关的方面。
对 iOS-like 平台的本地 AOT 支持
.NET 8 为类似 iOS 的平台引入了原生 AOT 支持,允许在各种平台上构建和运行 .NET iOS 和 .NET MAUI 应用程序。初步测试显示,.NET iOS 应用程序的磁盘大小减少了 40%,而使用原生 AOT 的 .NET MAUI iOS 应用程序增加了 25%。然而,由于这仅仅是支持的第一步,因此不应得出性能结论。原生 AOT 支持是应用部署的可选功能,而 Mono 仍然是开发和部署的默认运行时。
Visual Studio Code 的 .NET MAUI 扩展
虽然仍处于预览阶段,但 Visual Studio Code 现在提供 .NET MAUI 扩展,使其可用于 .NET MAUI 开发。.NET MAUI 扩展是一个新的 Visual Studio Code 扩展,允许您在 VS Code 中开发并在设备、模拟器和仿真器上调试您的应用。它建立在 C# 和 C# Dev Kit 扩展之上,通过强大的 IntelliSense、直观的解决方案资源管理器、包管理等功能,增强了您的 .NET 开发。
开发环境设置
在本节中,我们将介绍 Visual Studio 和 Visual Studio Code 中的开发环境设置。
使用 Visual Studio
Windows 和 macOS 都可以用于 .NET MAUI 开发,但仅使用其中之一,您将无法构建所有目标。您需要 Windows 和 Mac 电脑来构建所有目标。在本书中,使用 Windows 环境来构建和测试 Android 和 Windows 目标,而 iOS 和 macOS 目标则在 Mac 电脑上构建。
.NET MAUI 应用可以针对以下平台:
-
Android 5.0 (API 21) 或更高版本
-
iOS 11 或更高版本
-
macOS 10.15 或更高版本,使用 Mac Catalyst
-
Windows 11 和 Windows 10 版本 1809 或更高版本,使用 Windows UI 库 (WinUI) 3
.NET MAUI Blazor 应用使用特定平台的 WebView 控件,因此它们有以下附加要求:
-
Android 7.0 (API 24) 或更高版本
-
iOS 14 或更高版本
-
macOS 11 或更高版本,使用 Mac Catalyst
.NET MAUI 的 Android、iOS、macOS 和 Windows 构建目标可以在 Windows 电脑上的 Visual Studio 中构建。在此环境中,需要一个网络连接的 Mac 来构建 iOS 和 macOS 目标。为了在 Windows 环境中调试和测试 iOS MAUI 应用,必须在该配对的 Mac 上安装 Xcode。
.NET MAUI 的 Android、iOS 和 macOS 目标可以在 macOS 上构建和测试。请参阅 表 1.8 了解 Windows 和 macOS 上的构建配置。
目标平台 | Windows | macOS |
---|---|---|
Windows | 是 | 否 |
Android | 是 | 是 |
iOS | 是(与 Mac 配对) | 是 |
macOS | 仅构建(与 Mac 配对) | 是 |
表 1.8:.NET MAUI 开发环境
在 Windows 上安装 .NET MAUI
.NET MAUI 可以作为 Visual Studio 2022 的一部分进行安装。Visual Studio Community Edition 是免费的,我们可以从 Microsoft 网站下载它:visualstudio.microsoft.com/vs/community/
。
启动 Visual Studio 安装程序后,我们看到 图 1.5 中所示的内容。请在选项列表中选择 .NET 多平台应用程序 UI 开发 和 .NET 桌面开发。我们还需要为 .NET MAUI Blazor 应用程序选择 ASP.NET 和 Web 开发,这将在本书的第二部分中介绍。
图 1.5:Visual Studio 2022 安装
安装完成后,我们可以使用 dotnet
命令在命令行中检查安装,如下所示。
图 1.6:检查 dotnet 工作负载列表
我们现在准备好在 Windows 上创建、构建和运行 .NET MAUI 应用程序了。
在 macOS 上安装 .NET MAUI
尽管微软宣布 Visual Studio for Mac 将于 2023 年 8 月退役,但开发者仍然可以使用它来在 Mac 上进行 .NET MAUI 开发。Visual Studio for Mac 17.6 将继续支持 12 个月,直到 2024 年 8 月 31 日。请参阅以下公告:devblogs.microsoft.com/visualstudio/visual-studio-for-mac-retirement-announcement/
。
Visual Studio Community Edition 的安装与我们在 Windows 上所做类似。安装包可以从相同的链接下载。
启动 Visual Studio 安装程序后,我们看到 图 1.7 中所示的内容。
图 1.7:Visual Studio for Mac 2022 安装程序
请从 图 1.7 中的选项列表中选择 .NET 和 .NET MAUI。
安装完成后,我们也可以使用 dotnet
命令在命令行中检查安装。
我们现在准备好在 macOS 上创建、构建和运行 .NET MAUI 应用程序了。
带有 .NET MAUI 扩展的 Visual Studio Code
我们还可以使用 Visual Studio Code 设置开发环境。通过在 Visual Studio Code 中安装它,开始使用 .NET MAUI 扩展非常简单。你可以搜索 .NET MAUI 扩展并安装它,如 图 1.8 所示。
图 1.8:带有 .NET MAUI 扩展的 Visual Studio Code
当你安装 .NET MAUI 扩展 时,C# 开发工具包和 C# 扩展将自动安装。一旦安装了 .NET MAUI 扩展,你就可以像在 Visual Studio 中一样,在解决方案资源管理器中加载项目并探索项目。
图 1.9:Visual Studio Code 中的解决方案资源管理器
由于 Visual Studio Code 可以支持 Windows、macOS 和 Linux,我们可以在这三个平台上开发 .NET MAUI 应用程序。请参考以下表格以了解支持的目标平台。
操作系统 | 支持的目标平台 |
---|---|
Windows | Windows, Android |
macOS | Android, iOS, macOS |
Linux | Android |
表 1.9:支持的目标平台
使用 Visual Studio Code,可以在 Linux 上开发.NET MAUI 应用程序,但它只能支持 Android 作为目标平台。
摘要
在本章中,我们探讨了与.NET MAUI 和 Xamarin 相关的主题,讨论了与.NET MAUI 相比的各种跨平台技术。这次分析为不同框架的优缺点提供了见解。此外,我们还比较了 JavaScript 和 C#生态系统,因为大多数跨平台框架都使用这些语言。通过介绍.NET 景观和可用的跨平台框架,你现在在深入.NET MAUI 的世界之前,对基本概念有了全面的理解。
在下一章中,我们将探讨如何从头开始构建.NET MAUI 应用程序。
进一步阅读
-
Avalonia UI 和 MAUI – 为每个人提供一些东西:
avaloniaui.net/
-
.NET MAUI – 你可以在 Microsoft 官方文档中找到更多关于.NET MAUI 的信息:
docs.microsoft.com/en-us/dotnet/maui/
-
KeePass – KeePass 的官方网站可在:
keepass.info/
找到
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第二章:构建我们的第一个 .NET MAUI 应用程序
在本章节中,我们将创建一个新的 .NET MAUI 项目,并对其进行自定义,以便我们可以在应用程序开发中使用它。我们将开发的应用程序是一个密码管理应用程序。在随后的章节中,我们将逐步向其中引入各种功能。到 第一部分 结束时,我们将拥有一个功能齐全的密码管理应用程序。
对于之前有 Xamarin.Forms 经验的人来说,会记得 Shell 作为一种方便的应用程序容器,通过提供定义应用程序关键组件的统一结构来简化应用程序开发。虽然微软没有直接提供 .NET MAUI Shell 的 Visual Studio 模板,但我们可以有效地利用来自 Xamarin.Forms 的模板。为了在我们的应用程序中包含 Shell,我们将重用 Xamarin.Forms 中找到的项目模板。此外,将 Xamarin.Forms Shell 模板迁移到 .NET MAUI 的过程将为将 Xamarin.Forms 项目迁移到 .NET MAUI 提供宝贵的见解。
本章节将涵盖以下主题:
-
设置新的 .NET MAUI 项目
-
应用程序启动和生命周期管理
-
配置资源
-
创建具有 Shell 支持的新 Xamarin.Forms 项目
-
将此 Xamarin.Forms 项目迁移到 .NET MAUI
技术要求
要测试和调试本章节中的源代码,您需要在 Windows 或 macOS 上安装 Visual Studio 2022。请参阅 第一章,开始使用 .NET MAUI 中的 开发环境设置 部分,获取详细信息。
要检查本章节的源代码,我们可以使用以下 Git 命令:
$ git clone https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git -b 2nd/chapter02
管理本书中的源代码
由于我们将在本书中逐步开发密码管理应用程序,因此每个章节的源代码都是基于前一个章节构建的。为了持续改进我们的应用程序,我们将为每个章节的源代码设置单独的分支。如果您想使用一条命令克隆所有章节的源代码,您可以从主分支克隆。在主分支中,所有章节都在单独的文件夹中。如果您不想使用 Git,您也可以从发布区域下载源代码的压缩文件,如图中所示(图 2.1):
图 2.1:GitHub 中的源代码
由于新的 .NET MAUI 版本可能会不时发布,因此发布区域中的 Git 标签和版本将根据新的 .NET MAUI 版本和错误修复进行更新。
本书中的源代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition
。
从 GitHub 下载源代码有三种方法。
下载压缩文件中的源代码
源代码可以在发布区域下载,或者使用以下 URL:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/releases/tag/V1.0.0
。
当有新版本发布时,发布标签可能会更改。
按章节克隆源代码
要检查出章节的源代码,可以使用以下命令,例如:
$ git clone https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git -b 2nd/chapter02
我使用以下命名约定为分支命名:[xxx]/chapter[yy]
,其中x
是版本号,y
是章节号,例如2nd/chapter01
。
从主分支克隆源代码
要从主分支检查出所有章节的源代码,可以使用以下命令:
$ git clone https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git
设置新的.NET MAUI 项目
在本章中,我们将创建和配置一个新的.NET MAUI 项目,这将成为我们密码管理器应用程序进一步开发的基础。鉴于默认的.NET NAUI 项目模板非常简单,我们需要一个更健壮的项目框架来建立基本的项目结构。
Xamarin.Forms 项目模板提供了合适的选项。特别是,有一个模板集成了 Shell 和模型-视图-视图模型(MVVM)模式设置。我们将将其迁移到我们的.NET MAUI 项目中,这也会给我们提供学习如何将 Xamarin.Forms 项目迁移到.NET MAUI 的机会。最终,我们将创建我们自己的 Visual Studio 项目模板。
要创建一个新的.NET MAUI 项目,我们可以使用 Visual Studio 或命令行。
使用 Visual Studio 创建新项目
要创建一个新的.NET MAUI 项目,请按照以下步骤操作:
-
启动 Visual Studio 2022,在启动屏幕上选择创建新项目。这将打开创建新项目向导。
-
在屏幕顶部有一个搜索框。我们可以在搜索框中输入
maui
,.NET MAUI 相关的项目模板将会显示(见图2.2):图 2.2:新项目设置 – 创建新项目
.NET MAUI 应用程序或库有三个模板:
-
.NET MAUI 应用程序 – 这是基于 XAML 的.NET MAUI 应用程序。
-
.NET MAUI Blazor 混合应用程序 – 此模板可用于创建.NET MAUI Blazor 应用程序。
-
.NET MAUI 类库 – 这是构建.NET MAUI 类库的选项。当我们开发.NET MAUI 应用程序时,我们可以将共享组件作为.NET MAUI 类库来构建。
-
-
让我们选择.NET MAUI 应用程序并点击下一步按钮;它进入下一步以配置我们的新项目,如图2.3所示:
图 2.3:新项目设置 – 配置您的项目
-
输入项目名称和解决方案名称为
PassXYZ.Vault
并点击下一步按钮。项目创建后,项目结构将类似于图 2.4,并将显示以下内容:-
通用文件 – 在一个新项目中,模板中包含三个文件 –
App.xaml
、MainPage.xaml
和MauiProgram.cs
。这是我们将在整本书中工作的文件组。它们是平台无关的。业务逻辑和 UI 都可以在这里开发并在所有平台上共享。 -
平台特定文件 – 在
Platforms
文件夹中有五个子文件夹(Android
、iOS
、MacCatalyst
、Windows
和Tizen
)。由于我们不会支持 Tizen,我们可以将其从我们的项目中删除。 -
资源 –
Resources
文件夹中包含各种资源,从图像、字体、启动画面、样式和原始资产等。这些资源可以在所有支持的平台中使用。
-
图 2.4:.NET MAUI 项目结构
在 .NET MAUI 项目中,只有一个项目结构。稍后,我们将看到 Xamarin.Forms 的开发涉及多个项目。
使用 dotnet 命令创建新项目
虽然. NET MAUI 作为 Visual Studio 安装的一部分被安装,但它也可以使用命令行独立安装。这种灵活性允许使用替代的开发工具,例如 Visual Studio Code,而不是 Visual Studio。要从命令行创建和构建 .NET MAUI 应用程序,我们可以使用 dotnet
命令。
要找出已安装哪些项目模板,我们可以参考以下命令:
C:\ > dotnet new --list
要使用命令行创建新项目,我们可以执行以下命令:
C:\ > dotnet new maui -n "PassXYZ.Vault"
在创建新的 .NET MAUI 项目后,我们可以构建和测试它。在我们继续之前,让我们花些时间看看 .NET MAUI 应用程序启动代码和生命周期。
应用程序启动和生命周期
.NET MAUI 中的生命周期管理对于高效资源管理至关重要,确保用户体验的流畅和一致性,安全地处理应用程序,以及理解和调试应用程序行为。它允许应用程序在后台或前台时保存和恢复应用程序的状态,从而节约资源。当应用程序进入后台时,它提供了执行某些操作的机会,例如保存数据或暂停活动。此外,当应用程序在活动状态之间切换时,它通过管理敏感数据提供了增强的安全性。因此,理解应用程序生命周期对于构建健壮、高效且用户友好的 .NET MAUI 应用程序至关重要。
在 .NET MAUI 项目中,应用程序启动和生命周期管理由以下两个文件处理:
-
MauiProgram.cs
-
App.xaml/App.xaml.cs
.NET 通用宿主用于应用程序启动和配置。当应用程序启动时,创建一个 .NET 通用宿主对象来封装应用程序的资源及其生命周期功能,如下所示:
-
依赖注入(DI)
-
日志记录
-
配置
-
生命周期管理
这使得应用程序可以在单个位置进行初始化,并提供了配置字体、服务和第三方库的能力。本章将探讨除依赖注入(DI)之外的所有内容,依赖注入将在第六章“使用依赖注入的软件设计”中介绍。
.NET 通用宿主
如果你是一个 Xamarin 开发者,你可能不熟悉 .NET 通用宿主。.NET 通用宿主是在 .NET Core 中引入的,它是一个用于构建跨平台 .NET 应用程序的统一托管模型。它提供了一种一致的方式来配置、运行和管理各种类型的 .NET 应用程序(如控制台应用程序、微服务和 Web 应用程序)中的服务和后台任务。在 .NET MAUI 中,采用了相同的模式,用于启动和配置管理。
让我们检查 清单 2.1 中的应用程序启动代码:
namespace PassXYZ.Vault;
public static class **MauiProgram**
{
public static MauiApp **CreateMauiApp**() //(1)
{
var builder = MauiApp.CreateBuilder(); //(2)
builder
.UseMauiApp<App>() //(4)
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
return builder.Build(); //(3)
}
}
清单 2.1: MauiProgram.cs
(epa.ms/MauiProgram2-1
)
在 清单 2.1 中,我们可以看到以下内容:
(1) 在每个平台上,入口点位于特定平台的代码中。入口点调用 CreateMauiApp
函数,这是 MauiProgram
静态类的一个方法。
(2) 在 CreateMauiApp
内部,代码调用 CreateBuilder
函数,这是 MauiApp
静态类的一个方法,并返回一个 MauiAppBuilder
实例,它提供了一个 .NET 通用宿主接口。我们可以使用这个 .NET 通用宿主接口的实例来配置我们应用程序中使用的资源或服务。
(3) CreateMauiApp
函数的返回值是一个 MauiApp
实例,它是我们应用程序的入口点。
(4) 在 UseMauiApp
方法中引用的 App
类是我们应用程序的根对象。让我们回顾一下 清单 2.2 中 App
类的定义:
namespace PassXYZ.Vault;
public partial class **App** : **Application** //(1)
{
public App()
{
InitializeComponent();
MainPage = new **AppShell**(); //(2)
}
}
清单 2.2: App.xaml.cs
(epa.ms/App2-2
)
在 清单 2.2 中,我们可以看到以下内容:
(1) App
类是从 Application
类派生的,而 Application
类是在 Microsoft.Maui.Controls
命名空间中定义的。
(2) AppShell
是 Shell 的一个实例,它定义了应用程序初始页的 UI。
Application
类在应用程序运行和视图显示的 Window
类中创建一个实例。在 App
类中,我们可以重写 CreateWindow
方法来管理生命周期,我们很快就会看到。
生命周期管理
.NET MAUI 应用程序通常在四个执行状态中操作:
-
运行
-
未运行
-
停止
-
已停止
在状态转换期间,将触发预定义的生命周期事件。定义了六个跨平台的生命周期事件,正如我们可以在 表 2.1 中看到的那样:
事件 | 描述 | 状态转换 | 重写方法 |
---|---|---|---|
创建 | 此事件在原生窗口创建后触发。 | 未运行 -> 运行 | OnCreated |
激活 | 当窗口被激活并且是或将成为焦点窗口时,此事件被触发。 | 未运行 -> 运行 | OnActivated |
非活动 | 当窗口不再是焦点窗口时,此事件被触发。然而,窗口可能仍然可见。 | 运行 -> 非活动 | OnDeactivated |
停止 | 当窗口不再可见时,此事件被触发。 | 非活动 -> 停止 | OnStopped |
恢复 | 当应用在停止后恢复时,此事件被触发。 | 停止 -> 运行 | OnResumed |
销毁 | 当原生窗口被销毁和释放时,此事件被触发。 | 停止 -> 未运行 | OnDestroying |
表 2.1:生命周期事件和覆盖方法
请参考以下 Microsoft 文档以了解更多有关生命周期事件的信息:learn.microsoft.com/en-us/dotnet/maui/fundamentals/app-lifecycle
。
这些生命周期事件与 Application
创建的 Window
类的实例相关联。对于每个事件,都定义了一个相应的覆盖方法。我们可以订阅生命周期事件或创建覆盖函数来处理生命周期管理。
订阅窗口生命周期事件
要订阅生命周期事件,如 列表 2.3 中所示,在 (1) 处,我们可以在 App
类中覆盖 CreateWindow
方法来创建一个 Window
实例,我们可以在其上订阅事件:
using System.Diagnostics;
namespace PassXYZ.Vault;
public partial class App : Application {
public App() {
InitializeComponent();
MainPage = new MainPage();
}
protected override Window CreateWindow(IActivationState
activationState) //(1)
{
Window window = base.CreateWindow(activationState);
window.Created += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 1\. Created event");
};
window.Activated += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 2\. Activated event");
};
window.Deactivated += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 3\. Deactivated event");
};
window.Stopped += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 4\. Stopped event");
};
window.Resumed += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 5\. Resumed event");
};
window.Destroying += (s, e) => {
Debug.WriteLine("PassXYZ.Vault.App: 6\. Destroying event");
};
return window;
}
}
列表 2.3:App.xaml.cs
与生命周期事件 (epa.ms/App2-3
)
在 列表 2.3 中,我们修改了 App.xaml.cs
的代码,并订阅了所有六个事件,以便我们可以在 Visual Studio 输出窗口中运行测试并观察状态。如下面的调试输出所示,我们在 Windows 环境中运行并测试了我们的应用。
在我们启动我们的应用后,我们可以看到 Created
和 Activated
事件被触发。然后,我们最小化我们的应用,我们可以看到 Deactivated
和 Stopped
事件被触发。当我们再次恢复应用时,Resumed
和 Activated
事件被触发。最后,我们关闭我们的应用,并触发一个 Destroying
事件:
PassXYZ.Vault.App: 1\. Created event
PassXYZ.Vault.App: 2\. Activated event
PassXYZ.Vault.App: 4\. Stopped event
PassXYZ.Vault.App: 3\. Deactivated event
PassXYZ.Vault.App: 5\. Resumed event
PassXYZ.Vault.App: 2\. Activated event
PassXYZ.Vault.App: 5\. Resumed event
PassXYZ.Vault.App: 2\. Activated event
The thread 0x6f94 has exited with code 0 (0x0).
PassXYZ.Vault.App: 6\. Destroying event
The program '[30628] PassXYZ.Vault.exe' has exited with code 0 (0x0).
消费生命周期覆盖方法
或者,我们可以消费生命周期覆盖方法。我们可以从 Window
类创建自己的派生类:
-
在 Visual Studio 中,右键单击项目节点,然后选择 添加 并然后 新建项…。
-
在 添加新项 窗口中,从模板中选择 C# 类 并将其命名为
PxWindow
。我们创建了一个新类,如下所示在 列表 2.4:
using System.Diagnostics;
namespace PassXYZ.Vault;
public class PxWindow : Window
{
public PxWindow() : base() {}
public PxWindow(Page page) : base(page) {}
protected override void OnCreated() {
Debug.WriteLine("PassXYZ.Vault.App: 1\. OnCreated");
}
protected override void OnActivated() {
Debug.WriteLine("PassXYZ.Vault.App: 2\. OnActivated");
}
protected override void OnDeactivated() {
Debug.WriteLine("PassXYZ.Vault.App: 3\. OnDeactivated");
}
protected override void OnStopped() {
Debug.WriteLine("PassXYZ.Vault.App: 4\. OnStopped");
}
protected override void OnResumed() {
Debug.WriteLine("PassXYZ.Vault.App: 5\. OnResumed");
}
protected override void OnDestroying() {
Debug.WriteLine("PassXYZ.Vault.App: 6\. OnDestroying");
}
}
列表 2.4:PxWindow.cs
(epa.ms/PxWindow2-4
)
在 列表 2.4 中,我们创建了一个新的类,PxWindow
。在这个类中,我们定义了我们的生命周期覆盖方法。我们可以在 App.xaml.cs
中使用这个新类。
接下来,让我们看看 App.xaml.cs
的修改版本 (列表 2.5):
namespace PassXYZ.Vault;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(
IActivationState activationState) //(1)
{
return new PxWindow(new MainPage());
}
}
列表 2.5:修改后的 App.xaml.cs 与 PxWindow (epa.ms/App2-5
)
当我们重复之前的测试步骤时,我们可以在 Visual Studio 输出窗口中看到以下输出。输出看起来非常类似于之前的一个。基本上,这两种方法对生命周期管理的影响是相同的:
PassXYZ.Vault.App: 1\. OnCreated
PassXYZ.Vault.App: 2\. OnActivated
PassXYZ.Vault.App: 4\. OnStopped
PassXYZ.Vault.App: 3\. OnDeactivated
PassXYZ.Vault.App: 5\. OnResumed
PassXYZ.Vault.App: 2\. OnActivated
PassXYZ.Vault.App: 5\. OnResumed
PassXYZ.Vault.App: 2\. OnActivated
PassXYZ.Vault.App: 6\. OnDestroying
The program '[25996] PassXYZ.Vault.exe' has exited with code 0 (0x0).
我们通过 Window
类学习了 .NET MAUI 的应用生命周期管理。我们可以订阅生命周期事件或覆盖可覆盖的方法来管理应用生命周期。表 2.1 展示了这两种方法的比较。
如果你是一名 Xamarin.Forms 开发者,你可能知道在 Application
类中定义了生命周期方法。在 .NET MAUI 中,以下虚拟方法仍然可用:
-
OnStart
– 当应用启动时调用 -
OnSleep
– 每次应用进入后台时调用 -
OnResume
– 当应用从后台恢复时调用
要观察这些方法的行为,我们可以在 App
类中覆盖以下方法,如 列表 2.6 所示:
using System.Diagnostics;
namespace PassXYZ.Vault;
public partial class App : Application
{
public App()
{
InitializeComponent();
MainPage = new MainPage();
}
protected override void **OnStart**() { //(1)
Debug.WriteLine("PassXYZ.Vault.App: OnStart");
}
protected override void **OnSleep**() { //(2)
Debug.WriteLine("PassXYZ.Vault.App: OnSleep");
}
protected override void **OnResume**() { //(3)
Debug.WriteLine("PassXYZ.Vault.App: OnResume");
}
}
列表 2.6: App.xaml.cs
(epa.ms/App2-6
)
当我们在 Windows 上测试前面的代码时,我们可以在 Visual Studio 输出窗口中看到以下调试信息:
PassXYZ.Vault.App: OnStart
PassXYZ.Vault.App: OnSleep
The thread 0x6844 has exited with code 0 (0x0).
The thread 0x6828 has exited with code 0 (0x0).
The thread 0x683c has exited with code 0 (0x0).
PassXYZ.Vault.App: OnResume
如 列表 2.6 所示,根据应用的不同状态,将激活特定的方法。
(1) 当应用启动时,OnStart
方法会被调用。
(2) 当我们最小化应用时,OnSleep
方法会被调用。
(3) 当我们从任务栏恢复应用时,OnResume
方法会被调用。
我们已经了解了 .NET MAUI 应用的生命周期状态,并且了解到我们可以订阅生命周期事件或使用覆盖方法来管理应用的生命周期。现在让我们关注应用启动时的资源配置。
配置资源
资源管理是 .NET MAUI 和 Xamarin 之间的主要区别之一。
跨平台开发具有独特的挑战,因为每个平台都有自己管理资源的方法。这种多样性可以为开发团队带来重大的管理任务。例如,我们必须包含多种图像大小以适应不同的分辨率。
在 Xamarin 中,大多数资源都在平台特定的项目中单独管理。如果我们想添加一个图像,我们必须分别将不同大小的图像文件添加到所有平台项目中。
.NET MAUI 提供了一种优雅的解决方案来有效地管理资源。一个单一项目支持所有平台的设计目标有助于在一个地方管理资源。
在 .NET MAUI 中,可以根据资源在项目中所起的作用,使用基于构建操作的标签将资源文件分类到不同的类别中,正如我们在 表 2.2 中所看到的:
资源类型 | 构建操作 | 示例 |
---|---|---|
图像 | MauiImage |
dotnet_bot.svg |
图标 | MauiIcon |
appicon.svg |
启动画面图像 | MauiSplashScreen |
appiconfg.svg |
字体 | MauiFont |
OpenSans-Regular.ttf |
使用外部 CSS 定义样式 | MauiCss |
N/A |
原始资产 | MauiAsset |
N/A |
XAML UI 定义 | MauiXaml |
N/A |
表 2.2:.NET MAUI 资源类型
最后三个使用频率不高,因此我们将重点关注使用更常见资源类型的示例。
添加资源文件后,可以在 Visual Studio 的属性窗口中设置构建操作。如果我们查看项目文件,我们可以看到以下ItemGroup
:
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg"
ForegroundFile="Resources\AppIcon\appiconfg.svg"
Color="#512BD4" />
<!— Splash Screen -->
<MauiSplashScreen Include= "Resources\Splash\splash.svg"
Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
</ItemGroup>
如果我们按照默认文件夹设置的约定放置资源,资源将被视为相应的类别,并且构建操作将自动设置。
应用图标
在我们的应用设置中,如上ItemGroup
所示,我们在Resources\AppIcon
文件夹中有一个 SVG 图像文件appicon.svg
,其构建操作设置为MauiIcon
。在构建时,此文件用于为目标平台上的各种目的生成图标图像,例如在设备上或在应用商店中。
可以将此 SVG 文件与其他图像一起移动到Resources\Images
文件夹中。在这种情况下,我们应该在项目文件中使用以下条目:
<MauiIcon Include="Resources\Images\appicon.png" ForegroundFile="Resources\Images\appiconfg.svg" Color="#512BD4" />
缺点是构建操作对同一文件夹中的文件处理不一致 - appicon.svg
位于我们的项目中的Resources\AppIcon
文件夹,而不是Resources\Images
。
启动屏幕
启动屏幕的配置与应用图标的配置类似。我们在Resources/Splash
文件夹中有一个 SVG 图像文件splash.svg
,其构建操作设置为MauiSplashScreen
:
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" />
如应用图标、启动屏幕和其他图像等资源简单,可以直接在项目文件中进行配置。
一些常用资源,如自定义字体和服务,可能需要在代码中或同时在代码和项目文件中进行配置。我们将在下一节讨论自定义字体的配置,并将依赖注入留到第六章,使用依赖注入的软件设计。
设置自定义字体图标
自定义字体可以作为资源的一部分进行管理。使用自定义字体图标,我们可以显著减少我们应用中的图像资源数量。在移动应用中,视觉表示通常通过图像来传递。我们在各种导航活动中使用图像。在 Android 和 iOS 开发中,我们需要管理不同屏幕分辨率的图像资源。
使用自定义字体作为图标而不是图像有许多优点。字体图标是矢量图标而不是位图图标。矢量图标是可缩放的,这意味着您不需要根据设备的不同大小和分辨率使用不同的图像。图标字体缩放可以通过FontSize
属性来处理。字体文件的大小也比图像小得多。包含数百个图标的字体文件可能只有几 KB 大小。
图标的颜色可以通过TextColor
属性进行更改。对于静态图像,我们无法更改图标颜色。
最后,字体文件可以在共享项目中管理,因此我们不需要在不同的平台上分别管理字体。
在 Xamarin.Forms 和.NET MAUI 中,我们可以使用自定义字体(图标字体)而不是图像作为应用程序图标。
在.NET MAUI 中,显示文本的控件通常具有可定义的属性来配置字体设置。可以配置的属性包括:
-
FontAttributes
,这是一个有三个成员的枚举:None
、Bold
和Italic
。此属性的默认值为None
。 -
FontSize
,这是字体大小的属性,类型为double
。 -
FontFamily
,这是字体家族的属性,类型为string
。
自定义字体设置
自定义字体的设置分为两个阶段——添加字体文件和配置它们。在.NET MAUI 和 Xamarin.Forms 中,都可以将自定义字体文件添加到共享项目中。然而,它们的配置过程有所不同。在 Xamarin.Forms 中,配置是通过AssemblyInfo.cs
处理的。在.NET MAUI 中,这是通过.NET 通用宿主管理的。
在 Xamarin.Forms 中,完成此操作的过程如下:
-
将字体文件添加到 Xamarin.Forms 共享项目作为嵌入资源(构建操作:
EmbeddedResource
)。 -
使用
ExportFont
属性在AssemblyInfo.cs
等文件中注册字体文件。也可以指定一个可选的别名。
在.NET MAUI 中,过程如下:
- 将字体文件添加到
Resources
->Fonts
文件夹。构建操作设置为MauiFont,正如我们在图 2.5中看到的那样:
图 2.5:.NET MAUI 资源
- 代替在程序集注册字体文件,.NET MAUI 通过启动代码中的.NET 通用宿主初始化大部分资源,正如在列表 2.7的(1)处所示的图 2.7。字体文件通过
ConfigureFonts
方法添加,这是MauiAppBuilder
类的一个扩展方法。
在我们的项目中,我们使用以下开源项目中的 Font Awesome 图标库:github.com/FortAwesome/Font-Awesome
。
可以从前面的网站下载fa-brands-400.ttf
、fa-regular-400.ttf
和fa-solid-900.ttf
字体文件。
让我们回顾列表 2.7中的源代码,看看如何将这些字体添加到应用程序配置中:
namespace PassXYZ.Vault;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts => //(1)
{
fonts.AddFont("fa-regular-400.ttf","FontAwesomeRegular");
fonts.AddFont("fa-solid-900.ttf", "FontAwesomeSolid");
fonts.AddFont("fa-brands-400.ttf","FontAwesomeBrands");
fonts.AddFont("OpenSans-Regular.ttf","OpenSansRegular");
fonts.AddFont("OpenSans-SemiBold.ttf","OpenSansSemiBold");
});
return builder.Build();
}
}
列表 2.7:MauiProgram.cs
(epa.ms/MauiProgram2-7
)
在上述代码中,我们可以通过在MauiAppBuilder
对象上调用ConfigureFonts
(1)方法来添加字体。要向ConfigureFonts
传递参数,我们调用接口IFontCollection
的扩展方法AddFont
来添加字体。
显示字体图标
要在.NET MAUI 应用程序中显示字体图标,可以在FontImageSource
对象中定义字体图标数据。这个类是ImageSource
类的派生类,包含表 2.3中显示的属性:
属性名称 | 类型 | 描述 |
---|---|---|
Glyph |
string |
Unicode 字符值,例如 "" |
Size |
double |
字体在设备无关单位中的大小 |
FontFamily |
string |
表示字体家族的字符串,例如 FontAwesomeRegular |
Color |
Color |
字体图标颜色在 Microsoft.Maui.Graphics.Color |
表 2.3:FontImageSource 属性
以下 XAML 示例显示了一个字体图标在 Image
视图中:
<Image BackgroundColor="#D1D1D1">
<Image.Source>
<**FontImageSource** Glyph=""
FontFamily="FontAwesomeRegular"
Size="32" />
</Image.Source>
</Image>
如果你对前面示例中的 XAML 语法不熟悉,不要担心。我们将在下一章中介绍它。在前面代码中,一个 User
图标在 Image
控件中显示,该图标来自我们刚刚在配置中添加的 FontAwesomeRegular
字体家族。User
图标的十六进制格式的 Glyph
是 \uf007
,在这里以 C# 转义格式呈现。当我们将其用于 XML 时,我们必须使用的转义格式是 
。
相应的 C# 代码如下:
Image image = new Image {
BackgroundColor = Color.FromHex("#D1D1D1")
};
image.Source = new **FontImageSource** {
Glyph = "\uf007",
FontFamily = "FontAwesomeRegular",
Size = 32
};
在前面的示例中,我们使用十六进制数字的字符串表示形式来引用字体图标中的 Glyph
。然而,这在实际应用中并不实用。最好将字体符号定义为 C# 字符串常量,以便进行更有意义的引用。这里可以应用几种方法。在我们的情况下,我们使用开源的 IconFont2Code 工具来生成字符串常量。IconFont2Code 可以在 GitHub 上找到,使用以下 URL:github.com/andreinitescu/IconFont2Code
。
在我们的项目中,我们使用 Font Awesome。通过 IconFont2Code 网站,我们可以从项目的 Resources\Fonts
文件夹上传字体库。IconFont2Code 然后为我们生成相应的代码,如下例所示:
namespace PassXYZ.Vault.Resources.Styles;
static class FontAwesomeRegular
{
public const string Heart = "\uf004";
public const string Star = "\uf005";
public const string Scan = "\uf006";
public const string User = "\uf007";
public const string Qrcode = "\uf008";
public const string Fingerprint = "\uf009";
public const string Clock = "\uf017";
public const string ListAlt = "\uf022";
public const string Flag = "\uf024";
public const string Bookmark = "\uf02e";
...
public const string SmileBeam = "\uf5b8";
public const string Surprise = "\uf5c2";
public const string Tired = "\uf5c8";
}
我们可以将生成的 C# 文件保存在 Resources\Styles
文件夹中。前面的文件可以在这里找到:Resources\Styles\FontAwesomeRegular.cs
。
在前面的 FontAwesomeRegular
静态类中,字体图标可以像 XAML 文件中的普通文本一样使用:
<Button
Text="Click me"
FontAttributes="Bold"
Grid.Row="3"
SemanticProperties.Hint="Counts the number of times …"
Clicked="OnCounterClicked"
HorizontalOptions="Center">
<Button.ImageSource>
<FontImageSource
**FontFamily**="FontAwesomeSolid"
**Glyph**="{x:Static app:FontAwesomeSolid.PlusCircle}"
**Color**="{DynamicResource SecondaryColor}"
**Size**="16" />
</Button.ImageSource>
</Button>
在前面的代码中,我们向 Button
控件添加了一个圆形加号图标,该图标出现在文本 "Click me"
之前。为了在生成的 C# 类中引用图标名称,我们引入了一个 app
命名空间,如下所示:
xmlns:app="clr-namespace:PassXYZ.Vault.Resources.Styles"
到目前为止,我们已经创建了我们的项目并配置了我们需要的资源。现在是时候构建和测试我们的应用程序了。
构建和调试
如我们在 第一章 中提到的,开始使用 .NET MAUI,我们不能使用单个平台构建和测试每个目标。请参阅 表 1.8 了解 Windows 和 macOS 平台上的可用构建目标。为了简化,我们将在 Windows 上构建和测试 Windows 和 Android 的目标。对于 iOS 和 macOS 构建,我们将在 macOS 平台上进行。
一旦我们设置好一切,我们就可以开始构建和调试我们的应用程序。
让我们从 Windows 平台上的构建和测试开始。我们可以根据需要选择要运行或调试的框架,如图 图 2.6 所示:
图 2.6:构建和调试
Windows
我们可以通过选择net8.0-windows10.0.19041.0作为框架,在本地机器上运行或调试 Windows 构建。但是,为了完成此操作,我们必须首先在 Windows 上启用开发者模式,如果尚未激活的话。请参阅 图 2.7 以获取在 Windows 10 或 11 上启用开发者模式的指导:
-
打开开始菜单。
-
搜索开发者设置并选择它。
-
打开开发者模式。
-
如果收到有关开发者模式的警告消息,请阅读它,并选择是。
图 2.7:开发者模式
Android
对于 Android 构建,可以使用 Android 模拟器或设备进行测试。然而,在构建或调试之前,我们需要连接设备或设置模拟器的一个实例。有关如何配置设备或创建模拟器实例的说明,请参阅以下 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/
。
我们可以通过选择net8.0-android作为框架,从 Visual Studio (图 2.6) 运行或调试。
或者,我们也可以使用以下命令从命令行构建和运行 net8.0-android
:
dotnet build -t:Run -f net8.0-android
图 2.8:在 Android 和 Windows 上运行
在 Android 和 Windows 目标上运行应用程序后,我们可以看到前面的屏幕 (图 2.8)。
iOS 和 macOS
我们能够在 Mac 计算机上构建和测试 iOS 和 macOS 目标。鉴于 Microsoft 关于退役 Microsoft Visual Studio 2022 for Mac 的公告,我们将继续演示如何利用命令行操作构建和测试 iOS 和 macOS 目标。
构建 iOS 目标并进行测试
要构建和测试 iOS 目标,我们可以在项目文件夹中使用以下命令:
dotnet build -t:Run -f net8.0-ios -p:_DeviceName=:v2:udid=02C556DA-64B8-440B-8F06-F8C56BB7CC22
要选择一个目标 iOS 模拟器,我们需要使用以下参数提供设备 ID:
-p:_DeviceName=:v2:udid=
要找到设备 ID,我们可以在 Mac 计算机上启动 Xcode 并转到Windows -> 设备和模拟器,如图 图 2.9 所示:
图 2.9:Xcode 中的设备和模拟器
除了在 Mac 上构建之外,值得注意的是,iOS 目标也可以在 Windows 上使用 Visual Studio 2022 进行构建和测试,前提是配置已相应设置。有关如何使用热重启部署 iOS 应用程序的说明,请参阅以下 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/ios/hot-restart
。
构建 macOS 目标并进行测试
对于 macOS 目标,我们可以使用以下命令来构建和测试:
dotnet build -t:Run -f net8.0-maccatalyst
在 图 2.10 中,我们可以看到我们的项目在 iOS 和 macOS 上的截图。外观和感觉与 Android 和 Windows 类似。
图 2.10:在 iOS 和 macOS 上运行
Android、iOS 和 macOS 的环境设置涉及平台特定的细节。请参阅 Microsoft 文档以获取详细说明。
我们的应用程序现在运行良好,但你可以看到它是一个非常简单的应用程序,只有一个窗口。为了为我们的后续开发打下更好的基础,我们将使用 Shell 作为导航框架。在 Xamarin.Forms 中有一个很好的基于 Shell 的模板,我们可以用它来创建应用程序的初始代码。
从 Xamarin 迁移
在本节中,我们将展示将 Xamarin.Forms 项目模板迁移到 .NET MAUI 的过程。需要注意的是,这只是一个示例,因为存在许多类型的 Xamarin 项目。因此,本章只讨论了将 Xamarin.Forms 项目迁移到 .NET MAUI 的示例。有关迁移 Xamarin-native 项目和其他相关主题的更多信息,请参阅 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/migration/
。
尽管我们在这个章节中主要将 Xamarin.Forms Shell 模板作为示例进行移植,但这个新的项目模板对我们后续的开发至关重要。为了提供更多背景信息,我将简要介绍 PassXYZ.Vault 从 Xamarin.Forms 迁移到 .NET MAUI 的计划。这将概述我们在本书中需要克服的挑战,这应该能帮助你为迁移自己的应用程序时可能遇到的挑战做好准备。
我们现在可以成功运行我们创建的应用程序。在本书的其余部分,我们将开发一个名为 PassXYZ.Vault 的密码管理应用程序。该应用程序的 1.x.x 版本使用 Xamarin.Forms 实现,你可以在 GitHub 上找到它:github.com/passxyz/Vault
。
1.x.x 版本使用 Xamarin.Forms 5.0.0 开发。我们计划使用 .NET MAUI 重新构建它,并在本书中讨论这个过程。.NET MAUI 的发布将标记为 2.x.x,源代码将在以下位置提供:github.com/passxyz/Vault2
。
1.x.x 和 2.x.x 版本都使用 Shell 作为导航框架,分别通过 Microsoft.Maui.Controls.Shell
和 Xamarin.Forms.Shell
在 .NET MAUI 和 Xamarin.Forms 中得到支持。Shell 在所有平台上提供一致的导航用户体验。我们将在第五章 使用 .NET MAUI Shell 和 NavigationPage 进行导航 中深入了解 Shell 及其导航功能。
由 Visual Studio 创建的 .NET MAUI 和 Xamarin.Forms 的项目模板都包含 Shell。然而,默认的 .NET MAUI 项目模板只包含最简单的 Shell 形式,如图 2.8 所示:
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="PassXYZ.Vault.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:PassXYZ.Vault"
Shell.FlyoutBehavior="Disabled">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</Shell>
列表 2.8: AppShell.xaml
(epa.ms/AppShell2-8
)
MainPage
在 ShellContent
中显示,呈现一个基本 UI,内容不多。在我们的应用程序中,我们将使用 MVVM 模式 通过 Shell 构建用户界面。为了做到这一点,需要包含 MVVM 模式和 Shell 导航结构的样板代码。
MVVM 模式是 .NET MAUI 应用程序开发中常用的 UI 设计模式。随着我们在这本书中继续探讨主题,我们将多次遇到它。
我们可以选择从头开始创建此代码。然而,Xamarin.Forms 模板包括我在 PassXYZ.Vault
版本 1.x.x 中使用的样板代码。因此,可以为 .NET MAUI 创建相同的项目模板。这个过程也让我们了解了如何迁移或重用现有的 Xamarin.Forms 代码。
从 Xamarin.Forms 迁移和重用 Shell 模板
Xamarin.Forms 提供了一个更通用的 Shell 模板,可以用来生成带有飞出或标签页 Shell 导航选项的样板代码。我们可以使用这个模板来设置一个新的 Xamarin.Forms 项目。然后,我们可以在刚刚创建的 .NET MAUI 应用程序中实现这个样板代码。
要创建一个新的 Xamarin.Forms 项目,请按照以下步骤操作:
- 启动 Visual Studio 2022 并选择 创建新项目。这打开 创建新项目 向导。在搜索框中,我们可以输入
Xamarin
,所有与 Xamarin 相关的项目模板都将显示(见图 2.11):
图 2.11:新 Xamarin 项目
- 从列表中选择 移动应用 (Xamarin.Forms) 并点击 下一步。在下一屏幕,如图 2.12 所示,我们可以选择不同的位置并使用相同的项目名称
PassXYZ.Vault
,然后点击 创建 按钮:
图 2.12:配置 Xamarin 项目
- 我们还有一步,如图 2.13 所示。让我们选择 飞出 模板并点击 创建:
图 2.13:配置 Xamarin 项目 – 飞出
新解决方案创建后,我们可以看到解决方案中有四个项目,如图 2.14 所示:
-
PassXYZ.Vault – 这是一个由其他项目共享的 .NET Standard 项目,所有平台无关的代码都应该在这里。
-
PassXYZ.Vault.Android – 这是一个特定于 Android 平台的项目。
-
PassXYZ.Vault.iOS – 这是一个特定于 iOS 平台的项目。
-
PassXYZ.Vault.UWP – 这是一个特定于 UWP 的项目。
我们观察到,Xamarin.Forms 的项目结构与 .NET MAUI 的项目结构相当不同。解决方案由多个项目组成,平台特定的项目中分别管理资源。大部分的开发工作是在 .NET Standard 项目中进行的,PassXYZ.Vault。我们将重点关注迁移和重用此项目中的代码。
图 2.14:Xamarin.Forms 项目结构
此 Xamarin.Forms 项目的源代码可在此处找到:github.com/shugaoye/PassXYZ.Vault2/tree/xamarin
。
当不涉及平台特定代码时,迁移过程相对简单。我们在这里处理的是最简单的情况,但重要的是要注意,生产代码可能比这个例子复杂得多。因此,任何迁移都应在仔细分析后进行。
让我们专注于 .NET Standard 项目。其内容包含用于 MVVM 模式和 Shell UI 的样板代码——这正是我们所需要的。我们可以将 表 2.4 中突出显示的文件复制到 .NET MAUI 项目中,并相应地调整源代码中的命名空间。
下面是迁移过程的步骤:
- 请参考 表 2.4,它显示了与 .NET Standard 项目中的文件和文件夹列表相对应的操作列表:
Xamarin.Forms | 操作 | .NET MAUI |
---|---|---|
App.xaml |
无 | 保持 .NET MAUI 版本。它定义了 Application 类的实例。 |
AppShell.xaml |
替换 | 覆盖 .NET MAUI 版本并将命名空间更改为 .NET MAUI。此文件定义了 Shell 导航层次结构。 |
Views/ |
复制 | 在 .NET MAUI 项目中新建文件夹。需要更改命名空间。 |
ViewModels/ |
复制 | 在 .NET MAUI 项目中新建文件夹。需要更改命名空间。 |
Services/ |
复制 | 导出模型的接口。在 .NET MAUI 项目中新建文件夹。需要更改命名空间。 |
Models/ |
复制 | 在 .NET MAUI 项目中新建文件夹。需要更改命名空间。 |
表 2.4:.NET Standard 项目中的操作
- 在 .NET MAUI 项目中,请参考 表 2.5 替换以下命名空间:
旧命名空间 | 新命名空间 |
---|---|
using Xamarin.Forms |
using Microsoft.Maui AND using Microsoft.Maui.Controls |
using Xamarin.Forms.Xaml |
using Microsoft.Maui.Controls.Xaml |
表 2.5:.NET MAUI 和 Xamarin.Forms 中的命名空间
-
测试并修复任何错误。
在 图 2.15 中,我们可以看到在迁移过程中更改的文件列表:
图 2.15:迁移过程中更改的文件(https://bit.ly/3NlfqvO)
对于这个简单的案例,所有必要的更改都关联到命名空间。然而,在现实世界中的情况并不总是遵循这种模式。尽管这个过程很简单,但对于.NET MAUI 的新手来说,它仍然可能有些令人畏惧。实际上,你不需要亲自执行这个过程。作为替代方案,我创建了一个新的.NET MAUI 项目模板,您可以使用它。
在构建和测试这个更新后的应用程序后,我们可以在图 2.16提供的屏幕截图中查看结果:
图 2.16:PassXYZ.Vault 与.NET MAUI Shell
在图 2.16中,我们可以看到默认 Shell 菜单中包含三个页面:
-
关于:这是一个告知用户应用程序功能的页面。
-
浏览:这是项目列表的入口点。
-
注销:这是登录页面的链接,您可以在那里登录或注销。
这段样板代码将作为本书中我们项目进一步开发的基石。为了封装本节中我们所做的工作,我创建了一个相应的 Visual Studio 项目模板。使用这个模板可以轻松地生成所需的项目结构。
注意,这个例子仅仅说明了基本的迁移过程。在实际项目中,还需要考虑许多其他因素,例如:
-
将资源(字体、图像等)从平台文件夹中移出
-
将 Customer Renderer 转换为 Handler
-
更新依赖项(NuGet 包)
-
将
DependencyService
更改为 DI
上述列表只是一个例子。我们只能在详细分析项目后找出所有考虑因素。
Visual Studio 项目模板
该项目模板可以从 Visual Studio Marketplace 下载为 Visual Studio 扩展包:marketplace.visualstudio.com/items?itemName=shugaoye.maui
。
当您访问上述 URL 时,您将看到图 2.17所示的页面:
图 2.17:Visual Studio Marketplace 中的项目模板
在安装此项目模板后,我们可以创建一个新的.NET MAUI 项目,如图图 2.18所示:
图 2.18:创建新的.NET MAUI MVVM 项目
使用此模板创建的项目,其项目结构与本章中的相同。此项目模板的源代码可以在以下位置找到:github.com/passxyz/MauiTemplate
。
摘要
在本章中,我们创建了一个新的 .NET MAUI 项目。我们学习了如何使用 .NET 泛型宿主配置我们的 .NET MAUI 应用,并调整了资源配置以使用自定义字体(Font Awesome)。我们还了解了 .NET MAUI 应用程序的生命周期,并通过重写 CreateWindow
方法以及创建 Window
类的派生类来测试订阅生命周期事件的过程。为了生成具有 MVVM 模式和 Shell 支持的样板代码,我们创建了一个新的 .NET MAUI 项目模板。这个教程旨在演示如何将 Xamarin.Forms 代码迁移到 .NET MAUI。
在我们接下来的章节中,我们将学习如何使用 XAML 创建用户界面,这可以用于构建 WPF、UWP、Xamarin.Forms 和 .NET MAUI 的用户界面。我们将继续使用 XAML 创建和增强我们的密码管理器应用的用户界面。
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第三章:使用 XAML 进行用户界面设计
在上一章中,我们创建了一个名为 PassXYZ.Vault
的新 .NET MAUI 项目。随着我们阅读本书的进展,我们将使用所获得的技术和知识对其进行增强。在上一章中,我们简要了解了 XAML 中用户界面的实现。在本章中,我们将更深入地探讨使用 XAML 创建用户界面。
可扩展应用程序标记语言 (XAML) 是一种基于 XML 的语言,用于定义 Windows Presentation Foundation (WPF)、通用 Windows 平台 (UWP)、Xamarin.Forms 和 .NET MAUI 的用户界面。这些平台中的 XAML 语法相同,但词汇不同。
XAML 允许开发者在基于 XML 的 标记语言 中而不是任何 编程语言 中定义用户界面。我们可以在代码中编写所有用户界面,但使用 XAML 进行用户界面设计将更加简洁且视觉上更连贯。由于 XAML 不使用编程语言,因此它不能包含代码。这是一个缺点,但也是一个优点,因为它迫使开发者将逻辑与用户界面设计分离。
本章将涵盖以下主题:
-
如何创建 XAML 页面
-
基本 XAML 语法
-
XAML 标记扩展
-
如何使用主-详细信息模式设计用户界面
-
.NET MAUI 应用的本地化
技术要求
要测试和调试本章中的源代码,您需要在您的电脑上安装 Visual Studio 2022。请参阅 第一章,开始使用 .NET MAUI 中的 开发环境设置 部分,以获取详细信息。
本章的源代码可在以下 GitHub 仓库中找到:
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter03 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书中源代码的信息,请参阅 第二章,构建我们的第一个 .NET MAUI 应用 中的 管理本书中的源代码 部分。
创建 XAML 页面
在我们深入探讨 XAML 语法之前,让我们首先了解如何在 Visual Studio 中以及通过 dotnet 命令行创建 XAML 页面。
要使用 Visual Studio 创建 XAML 页面,请右键单击项目节点,然后选择 添加 > 新项…。这将弹出如图 3.1 所示的对话框:
图 3.1:添加 XAML 页面
在此屏幕上,从模板中选择 内容页面 并点击 添加。此操作将生成一对文件——一个 XAML 文件和一个 C# 后置代码文件。
同样可以使用 dotnet
命令实现。为了定位所有 .NET MAUI 模板,我们可以在 PowerShell 控制台中使用以下 dotnet
命令:
图 3.2:列出模板的 dotnet 命令
从前面的输出中,我们可以观察到 XAML 内容页的短名为 maui-page-xaml
。我们可以使用以下命令创建一个 XAML 页面:
图 3.3:创建 XAML 页面
前一个命令将生成两个名为 ItemsPage.xaml
和 ItemsPage.xaml.cs
的文件。你可能注意到了关于创建后操作的警告信息。这是一个已知问题,你可以在github.com/dotnet/maui/issues/4994
找到更多关于它的信息。
然而,这并不是我们需要担心的事情。
什么是“后台代码”?
在 .NET MAUI 中,术语 后台代码 指的是与 用户界面(UI) 定义文件关联的代码文件,通常是 XAML 文件。后台代码文件包含处理 UI 事件、数据绑定和其他与 UI 相关的应用功能逻辑。后台代码文件是与相关 XAML 文件同名的 C# (.cs) 文件。例如,如果我们有一个 ItemsPage.xaml
文件,后台代码文件将被命名为 ItemsPage.xaml.cs
。
后台代码文件包含一个从 .NET MAUI 基础页面类型继承的类,通常是 ContentPage
、NavigationPage
或 TabbedPage
。类声明被标记为与 XAML 文件中的类定义匹配的部分类,这允许 XAML UI 定义与其对应的代码无缝集成。
XAML 语法
由于 XAML 是一种基于 XML 的语言,为了更好地理解它,我们首先需要了解基本的 XML 语法。在 XML 文件中,它以一个 XML 声明或序言开始。XML 或 XAML 文件的内容包括一系列元素。每个元素都可能与其关联一些属性。
让我们以我们在 第二章 中创建的项目为例,回顾 App.xaml
,作为例子:
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:PassXYZ.Vault"
x:Class="PassXYZ.Vault.App">
<Application.Resources>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="…/Colors.xaml" />
<ResourceDictionary Source="…/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</Application.Resources>
</Application>
列表 3.1: App.xaml
(epa.ms/App3-1
)
XML 声明
在 App.xaml
的开头,我们可以看到以下 XML 声明:
<?xml version = "1.0" encoding = "UTF-8" ?>
这个声明指定了正在使用的 XML 版本和字符编码。在 App.xaml
中,使用了 XML 版本 1.0,并将字符编码设置为 UTF-8。
元素
在 Listing 3.1 中,App.xaml
的内容从 Application
根元素开始。每个 XML 文档必须包含一个根元素,它包含所有其他元素。元素可以有子元素,也称为嵌套元素,例如 ResourceDictionary
。
元素由开始标签、内容和结束标签组成,如 Application
标签所示:
<Application>
…
</Application>
开始标签被括号(例如,<Application>
)包围,结束标签在元素名称之前包含一个反斜杠(例如,</Application>
)。内容可以是任何文本或嵌套元素。
对于空元素,可以在开始标签的末尾添加一个反斜杠来省略结束标签,如下所示:
<Application />
讨论 XML 元素时,我们可能会使用术语 元素、节点 和 标签。元素指的是该元素的开始和结束标签一起。标签指的是元素的开始或结束标签。节点指的是一个元素及其所有内部内容,包括所有子元素。
一个 XAML 文档由许多嵌套元素组成,只有一个顶级元素,称为根元素。在 .NET MAUI 中,根元素通常是 Application
、ContentPage
、Shell
或 ResourceDictionary
。
对于每个 XAML 文件,我们通常都有一个对应的 C# 代码隐藏文件。让我们回顾一下 清单 3.2 中的代码隐藏文件:
using PassXYZ.Vault.Services;
using PassXYZ.Vault.Views;
namespace PassXYZ.Vault;
public partial class App : Application //(1)
{
public App()
{
InitializeComponent(); //(2)
MainPage = new AppShell();
}
}
清单 3.2: App.xaml.cs
(epa.ms/App3-2
)
在 XAML 中,元素通常代表实际在运行时实例化的 C# 类。XAML 和代码隐藏文件一起定义了一个完整的类。例如,App.xaml
(清单 3.1) 和 App.xaml.cs
(清单 3.2) 定义了 App
类,它是 Application
的子类。
(1) App
类的全名是 PassXYZ.Vault.App
,与使用 x:Class
属性在 XAML 文件中定义的相同:
x:Class="PassXYZ.Vault.App"
(2) 在 App
类的构造函数中,调用 InitializeComponent
方法来加载 XAML 并解析它。此时创建 XAML 文件中定义的 UI 元素。我们可以通过 x:Name
属性定义的名称访问这些 UI 元素,正如我们很快将看到的。
属性
一个元素可以有多个唯一的属性。属性提供了关于 XML 元素额外信息。XML 属性是一个附加到元素上的名称-值对。在 XAML 中,一个元素代表一个 C# 类,而属性代表这个类的成员:
<Button x:Name="loginButton" VerticalOptions="Center"
IsEnabled="True" Text="Login"/>
如上所示,为 Button
元素定义了四个属性 – x:Name
、VerticalOptions
、IsEnabled
和 Text
。要定义一个属性,我们需要使用等号指定属性名称和值。属性值应使用双引号或单引号括起来。例如,IsEnabled
是属性名称,"True"
是属性值。
在这个例子中,x:Name
属性是一个特殊的属性。它并不指向 Button
类的成员,而是指向持有 Button
类实例的变量。如果没有 x:Name
属性,将创建一个匿名的 Button
类实例。声明了 x:Name
属性后,我们可以通过代码隐藏文件中的 loginButton
变量来引用 Button
类的实例。
XML 和 XAML 命名空间
在 XML 或 XAML 中,我们可以像在 C# 中一样声明命名空间。命名空间有助于将元素和属性分组,以避免在不同作用域中使用相同名称时的名称冲突。命名空间可以使用 xmlns
属性和以下语法定义:
XAML 命名空间定义有两个组成部分:一个前缀和一个标识符。前缀和标识符可以是任何字符串,这由 XML 1.0 规范中允许的 W3C 命名空间所决定。如果省略前缀,则命名空间成为默认命名空间。
在 清单 3.1 中,以下命名空间是默认的:
这个默认命名空间允许我们无需前缀即可引用 .NET MAUI 类,例如 ContentPage
、Label
或 Button
。
对于命名空间声明,使用前缀 x
,如下所示:
xmlns:x
命名空间声明指定了 XAML 内在的元素和属性。这是我们将在使用 XAML 进行 UI 设计时使用的重要命名空间之一。为了理解其用法,我们可以在后续章节中使用 C# 和 XAML 创建具有相同结构的相同内容页面。
使用 XAML 创建一个新的页面
首先,让我们使用 XAML 创建一个新的页面。要创建 XAML 中的内容页面,我们可以使用 dotnet
命令,就像我们之前做的那样:
dotnet new maui-page-xaml -n NewPage1
The template ".NET MAUI ContentPage (XAML)" was created successfully.
Processing post-creation actions...
The post action 84c0da21-51c8-4541-9940-6ca19af04ee6 is not supported.
Description: Opens NewPage1.xaml in the editor.
前面的命令生成了一个 XAML 文件 (NewPage1.xaml
) 和一个 C# 代码隐藏文件 (NewPage1.xaml.cs
)。我们可以更新 XAML 文件,如下所示。由于我们未添加任何逻辑,我们可以忽略此示例中的代码隐藏文件 (NewPage1.xaml.cs
):
NewPage1.xaml
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MauiApp1.NewPage1" //(1)
Title="NewPage1">
<StackLayout x:Name="layout"> //(2)
<Label Text="Welcome to .NET MAUI!"
VerticalOptions="Center"
HorizontalOptions="Center" />
<BoxView HeightRequest="150" WidthRequest="150"
HorizontalOptions="Center">
<BoxView.Color>
<Color x:FactoryMethod="FromRgba"> //(3)
<x:Arguments> //(4)
<x:Int32>192</x:Int32> //(5)
<x:Int32>75</x:Int32>
<x:Int32>150</x:Int32>
<x:Int32>128</x:Int32>
</x:Arguments>
</Color>
</BoxView.Color>
</BoxView>
</StackLayout>
</ContentPage>
NewPage1.xaml.cs
namespace MauiApp1;
public partial class NewPage1 : ContentPage {
public NewPage1() {
InitializeComponent();
}
}
为了与刚刚创建的 XAML 版本进行比较,让我们在下一节中仅使用 C# 创建相同的内容页面。然后我们将查看前面代码中编号的行。
使用 C# 创建相同的新的页面
要仅使用 C# 代码创建相同的内容页面,请使用以下命令:
dotnet new maui-page-csharp -n NewPage1
The template ".NET MAUI ContentPage (C#)" was created successfully.
Processing post-creation actions...
The post action 84c0da21-51c8-4541-9940-6ca19af04ee6 is not supported.
Description: Opens NewPage1.cs in the editor.
前面的命令在 NewPage1.cs
C# 文件中生成一个内容页面。我们可以在 C# 中像这样实现相同的逻辑:
NewPage1.cs
namespace MauiApp1;
public class NewPage1 : ContentPage { //[1]
public NewPage1() {
var layout = new StackLayout //[2]
{
Children = {
new Label { Text = "Welcome to .NET MAUI!" },
new BoxView {
HeightRequest = 150,
WidthRequest = 150,
HorizontalOptions = LayoutOptions.Center,
Color = Color.FromRgba(192, 75, 150, 128) //[3]
}
}
};
Content = layout;
}
}
在这里,我们在 XAML 和 C# 中都创建了相同的内容页面 (NewPage1
) 两次。XAML 不能包含编程逻辑,但它可以用来声明用户界面元素,并将逻辑放在 C# 代码隐藏文件中。在 NewPage1
的两个版本中,我们创建了一个包含 Label
和 BoxView
元素的内容页面。在 XAML 版本中,我们使用了 xmlns:x
命名空间中定义的属性来指定 UI 元素:
(1) 在 XAML 中创建了一个名为 NewPage1
的内容页面。x:Class
属性指定了类名——即 NewPage1
。在 C# 代码隐藏文件中,定义了一个 NewPage1
的部分类。在构造函数中,调用 InitializeComponent
方法来加载 XAML 中定义的 UI。
[1] 我们可以直接使用 C# 并作为 ContentPage
的派生类来创建相同的内容页面 NewPage1
。
我们在内容页面中定义了一个 StackLayout
,并且用于引用它的变量名在 XAML 和 C# 版本中都是 layout
:
(2) 在 XAML 中,x:Name
指定了 StackLayout
的变量名。
[2] 在 C# 中,我们可以将变量声明为 layout
。
(3) x:FactoryMethod
指定了一个可以用来初始化对象的工厂方法。
[3] 在 C# 代码中,我们可以直接调用 Color.FromRgba
函数,但在 XAML 中我们必须使用 x:FactoryMethod
属性来完成同样的操作。
(4) x:Arguments
用于在 XAML 中调用 Color.FromRgba
时指定参数。
(5) x:Int
用于指定整数参数。对于其他数据类型,我们可以使用 x:Double
、xChar
或 x:Boolean
。
有关 xmlns:x
命名空间的更多信息,请参阅 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/xaml/namespaces/
。
**公共语言运行时 (CLR) 类型可以通过声明带有前缀的 XAML 命名空间在 XAML 中引用。如 清单 3.1 所示,我们可以像这样引用我们的 C# 命名空间 PassXYZ.Vault
:
xmlns:local="clr-namespace:PassXYZ.Vault"
要声明 CLR 命名空间,我们可以使用 clr-namespace:
或 using:
。如果 CLR 命名空间定义在不同的程序集,则使用 assembly=
来指定包含引用 CLR 命名空间的程序集。值是程序集的名称,不带文件扩展名。在我们的例子中,它已被省略,因为 PassXYZ.Vault
命名空间位于我们的应用程序代码相同的程序集中。
我们将在本章的后面部分看到更多命名空间的使用。
XAML 标记扩展
尽管我们可以使用 XAML 元素初始化类实例,并使用 XAML 属性设置类成员,但我们只能将它们设置为 XAML 文档中的预定义常量。
为了通过允许从各种来源设置元素属性来增强 XAML 的功能和灵活性,我们可以使用 XAML 标记扩展。使用 XAML 标记扩展,我们可以将属性设置为在别处定义的值,或者是在运行时由代码处理的结果。
XAML 标记扩展可以指定在大括号中,如下所示:
<Button Margin="0,10,0,0" Text="Learn more"
Command="{Binding OpenWebCommand}"
BackgroundColor="{DynamicResource PrimaryColor}"
TextColor="White" />
在前面的代码中,BackgroundColor
和 Command
属性都已设置为标记扩展。BackgroundColor
已设置为 DynamicResource
,而 Command
已设置为在视图模型中定义的 OpenWebCommand
方法。
在这里,我们提供了对标记扩展的简要介绍,所以现在不必担心标记扩展的用法。当我们稍后使用它们时,我们将更深入地探讨标记扩展。在下一章,第四章,探索 MVVM 和数据绑定中,我们将详细说明数据绑定的用法。
请参考以下 Microsoft 文档以获取有关标记扩展的更多信息:learn.microsoft.com/en-us/dotnet/maui/xaml/markup-extensions/consume
。
现在我们已经了解了 XAML 的基础知识,我们可以用它来工作于我们的用户界面设计。
构建用户界面
在具备基本的 XAML 知识后,让我们从宏观的角度看一下 .NET MAUI 用户界面构建块。随着我们在后续章节中遇到它们,我们将更深入地探讨它们。
页面是顶级用户界面元素,通常占据所有屏幕或窗口。我们在本章开头介绍了如何使用 Visual Studio 模板或 dotnet
命令创建页面。每个页面通常至少包含一个布局元素,用于组织页面上的控件设计。页面的例子包括 ContentPage
、NavigationPage
、TabbedPage
、FlyoutPage
和 Shell
。
在内容页面中,我们利用视图(或控件)作为用户界面的构建块。为了将视图组织成组,我们可以使用布局组件作为视图的容器。
布局
布局是容器组件,有助于在您的应用中组织和排列 UI 元素(或视图)。它们根据特定规则控制 UI 组件的位置、大小和对齐。
布局允许您创建一致且适应不同屏幕尺寸和设备方向的用户界面。例子包括 StackLayout
、Grid
、FlexLayout
、RelativeLayout
和 AbsoluteLayout
。
StackLayout
StackLayout
以一维堆栈的形式组织元素,无论是水平还是垂直。它通常用作父布局,包含其他子布局。默认方向是垂直。然而,我们不应使用嵌套的 StackLayout
水平和垂直地生成类似于表格的布局。以下代码展示了不良实践的例子:
<StackLayout>
<StackLayout Orientation="Horizontal">
<Label Text="Name:" />
<Entry Placeholder="Enter your name" />
</StackLayout>
<StackLayout Orientation="Horizontal">
<Label Text="Age:" />
<Entry Placeholder="Enter your age" />
</StackLayout>
<StackLayout Orientation="Horizontal">
<Label Text="Address:" />
<Entry Placeholder="Enter your address" />
</StackLayout>
</StackLayout>
在前面的代码中,我们使用了一个默认方向为垂直的 StackLayout
作为父布局。然后,我们嵌套了多个具有水平方向的 StackLayout
控件,以生成数据输入表单。
然而,使用嵌套的 StackLayout
创建类似于表格的布局并不优化此类场景,并且可能会导致性能和布局问题。在这种情况下,我们应该使用 Grid
控件。
StackLayout
是一个常用的布局控件。StackLayout
有两种子类型,帮助我们直接水平或垂直设计布局。
HorizontalStackLayout
HorizontalStackLayout
是一个一维的水平堆栈。例如,我们可以生成如下行:
<HorizontalStackLayout>
<Label Text="Name:" />
<Entry Placeholder="Enter your name" />
</HorizontalStackLayout>
VerticalStackLayout
VerticalStackLayout
是一个一维的垂直堆栈。例如,我们可以在表单提交后显示错误信息,如下所示:
<VerticalStackLayout>
<Label Text="The Form Is Invalid" />
<Button Text="OK"/>
</VerticalStackLayout>
Grid
Grid
以行和列的形式组织元素。我们可以使用 RowDefinitions
和 ColumnDefinitions
属性来指定行和列。在先前的例子中,我们创建了一个表单,用户可以使用嵌套的 StackLayout
输入他们的姓名、年龄和地址。我们可以在 Grid
布局中这样做:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="50" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Label Text="Name:" />
<Entry Grid.Column="1"
Placeholder="Enter your name" />
<Label Grid.Row="1" Text="Age:" />
<Entry Grid.Row="1" Grid.Column="1"
Placeholder="Enter your age" />
<Label Grid.Row="2" Text="Address:" />
<Entry Grid.Row="2"
Grid.Column="1"
Placeholder="Enter your address" />
</Grid>
在前面的例子中,我们创建了一个有两列和三行的 Grid
布局。
FlexLayout
FlexLayout
与 StackLayout
类似,它将子元素水平或垂直地堆叠显示。区别在于,如果子元素太多无法适应一行或一列,FlexLayout
还可以自动换行。例如,我们可以创建一个包含五个标签的 FlexLayout
行。如果我们指定 Direction
属性为 Row
,这些标签将显示在一行中。我们还可以指定 Wrap
属性,如果行中元素过多,它会导致项目换行到下一行:
<FlexLayout Direction="Row" Wrap="Wrap">
<Label Text="Item 1" Padding="10"/>
<Label Text="Item 2" Padding="10"/>
<Label Text="Item 3" Padding="10"/>
<Label Text="Item 4" Padding="10"/>
<Label Text="Item 5" Padding="10"/>
</FlexLayout>
AbsoluteLayout
AbsoluteLayout
是一种布局控制,它允许您根据 X 和 Y 坐标以及宽度和高度定位和调整子元素的大小。它在需要精细控制 UI 元素精确位置和大小的场景中特别有用。
这里是 AbsoluteLayout
的常见用例:
-
重叠 UI 元素:
AbsoluteLayout
允许您将元素放置在其他元素之上,这可以创建一些有效的视觉效果或将内容显示在背景图像之上。 -
自定义控件:如果您正在开发需要精确控制组件布局的自定义控件,
AbsoluteLayout
应该是您的首选选择。 -
复杂的 UI 展示:您可能需要创建复杂的 UI,这些 UI 不适合标准的网格或堆叠布局。在这种情况下,
AbsoluteLayout
提供了您所需的控制,以精确地定位项目。 -
基于父大小的定位:
AbsoluteLayout
允许您根据父元素的边界定位子元素。这使得放置元素在特定位置或响应某些事件变得更容易。 -
动画:如果您需要动画元素,例如在屏幕上移动它们或调整它们的大小,
AbsoluteLayout
可以通过直接访问子元素的位置、宽度和高度属性来简化此过程。
通常来说,使用 AbsoluteLayout
构建布局有三个好处:
-
精确控制:
AbsoluteLayout
提供了对子元素的位置、大小和层级的控制,这在处理自定义或复杂的 UI 设计时非常有用。 -
性能:由于
AbsoluteLayout
不需要复杂的计算来排列元素,因此与其他布局类型相比,它可以提供更好的性能,尤其是在处理大量子元素时。 -
响应式布局:由于
AbsoluteLayout
支持比例值,它可以帮助创建可以适应不同屏幕尺寸和方向的响应式设计。
然而,需要注意的是,在所有地方都使用AbsoluteLayout
并不推荐。它更适合那些其他布局无法满足所需的设计或功能要求的特定场景。AbsoluteLayout
的缺点包括难以维护 UI 的响应性以及当父元素或子元素发生变化时可能出现意外行为。相反,当其他布局(例如Grid
、StackLayout
或FlexLayout
)的功能足以满足您的需求时,您应该使用这些布局。
在以下示例中,我们在布局中的(0,0)位置创建了一个BoxView
控件,其宽度和高度都等于10
:
<AbsoluteLayout Margin="20">
<BoxView Color="Silver"
AbsoluteLayout.LayoutBounds="0, 0, 10, 10" />
</AbsoluteLayout>
我们已提供了布局控件的概述。有关更详细的信息,请参阅以下 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/user-interface/layouts/
。
视图
视图(也称为控件)是用户与之交互或显示屏幕上内容的单个 UI 元素。它们被放置在布局中,然后放置在页面上。视图包括基本的 UI 控件,如Label
、Button
、Entry
和Image
,以及更高级的 UI 控件,如CollectionView
、ListView
和WebView
。
请参阅以下关于.NET MAUI 控件的相关 Microsoft 文档:learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/
。
在本节中,我们将介绍一些在这本书中会频繁使用的控件。
标签
Label
用于显示单行或多行文本。它可以显示具有特定格式的文本,例如颜色、间距、文本装饰,甚至 HTML 文本。要创建一个Label
,我们可以使用最简单的格式,如下所示:
<Label Text="Hello world" />
图片
在用户界面设计中,我们通常使用图标来装饰其他控件或显示图像作为背景。Image
控件可以显示来自本地文件、URI、嵌入资源或流的图像。以下代码展示了如何以最简单的方式创建一个Image
控件:
<Image Source="dotnet_bot.png" />
编辑器
在我们的应用程序中,用户需要输入或编辑单行文本或多行文本。为此,我们提供了两个控件:Editor
和Entry
。
Editor
可用于输入或编辑多行文本。以下是一个Editor
控件的示例:
<Editor Placeholder="Enter your description here" />
输入框
Entry
可用于输入或编辑单行文本。为了设计登录页面,我们可以使用Entry
控件输入用户名和密码。当用户与Entry
交互时,可以通过Keyboard
属性自定义键盘的行为。
当用户输入密码时,可以将IsPassword
属性设置为反映登录页面上的典型行为。以下是一个密码输入的示例:
<Entry Placeholder="Enter your password" Keyboard="Text" IsPassword="True" />
ListView
在用户界面设计中,一个常见的用例是显示一组数据。在 .NET MAUI 中,可以使用多个控件来显示数据集合,例如 CollectionView
、ListView
和 CarouselView
。在我们的应用中,我们将使用 ListView
来显示密码条目、组和条目的内容。我们将在 第四章,探索 MVVM 和数据绑定 中介绍 ListView
的使用。
在所有这些构建块就绪后,我们可以构建一个内容页面。通常,一个应用程序由多个实现不同功能的页面组成。为了创建一个功能齐全的应用,我们需要在这些页面之间进行导航。
导航指的是在您的应用中在不同页面或视图之间移动的过程,使用户能够与多个屏幕交互并访问一系列功能。导航是应用设计的一个关键方面,因为它决定了用户的旅程以及帮助他们找到所需信息的途径。在 .NET MAUI 中,使用 NavigationPage
、TabbedPage
、Shell
或必要时自定义导航来处理导航管理。
主从 UI 设计
在应用内实现导航有多种方式。在我们的应用导航设计中,我们采用了主从模式。
主从模式是一种广泛使用的用户界面设计方法。在常用应用中可以找到许多它的例子。例如,在 Windows 的邮件应用中,主视图中显示了一封封电子邮件的列表,以及所选电子邮件的详细信息:
图 3.4:Windows 中的邮件
在 图 3.4 中,设计中包含三个面板。左侧面板类似于导航抽屉。当从左侧面板选择一个文件夹时,中间面板会显示一封封电子邮件。当前选中的电子邮件在右侧面板中显示。
注意
导航抽屉提供了访问目的地和应用功能的方式,例如桌面环境中的菜单。它通常从左侧滑入,并通过在屏幕左上角轻触图标来触发。它显示一个选择列表以进行导航,并在移动和网页用户界面设计中广泛使用。Xamarin.Forms 和 .NET MAUI Shell
使用导航抽屉作为它们的高级导航方法。
原始的 KeePass UI 设计,如 图 3.5 所示,在主页上也使用了三个面板(左侧、右侧和底部)。左侧面板是一个经典的树形视图,类似于导航抽屉。右侧面板用于显示密码条目列表,而底部面板用于展示条目的详细信息:
图 3.5:KeePass UI 设计
主从模式在各种设备类型和显示尺寸上都表现良好。
考虑到不同的显示尺寸,可以使用两种流行的模式:
-
并排
-
堆叠
并排
当在大屏幕上有足够的水平空间时,并排方法通常是一个明智的选择。图 3.4 中的邮件应用和 图 3.5 中的 KeePass 应用是很好的例子。在这种模式下,主视图和详细视图可以同时看到。
堆叠
在使用移动设备时,屏幕尺寸通常较小,垂直空间大于水平空间。在这种情况下,堆叠方法更为合适。
在堆叠模式下,主视图占据整个屏幕空间。在选择后,详细视图随后占据整个屏幕空间:
图 3.6: PassXYZ.Vault
在 图 3.6 中,我们可以从用户的角度观察应用导航。我们有多个飞出项可供选择:
-
关于
-
浏览
-
注销
选择 浏览 后,我们查看主页面 (ItemsPage
) 上的项目列表。从该页面,如果我们选择一个项目,我们将转到项目的详情页面 (ItemDetailPage
)。如果我们想选择另一个项目,我们必须返回主页面并再次选择。
我们将在 第五章,使用 .NET MAUI Shell 和 NavigationPage 进行导航 中讨论飞出项。在本节中,我们将检查 ItemsPage
和 ItemDetailPage
的实现。然而,在深入具体细节之前,让我们先探讨布局,它们作为用户界面元素的容器。
主从 UI 设计中的导航
如 图 3.6 所示,我们在导航方案中采用了堆叠的主从模式。有一个飞出菜单来显示页面列表。在这个页面列表中,使用 ItemsPage
类型的页面来显示密码条目列表。当用户选择一个条目时,ItemDetailPage
会显示密码条目的详细信息。
让我们回顾 ItemsPage
和 ItemDetailPage
的实现。
ItemDetailPage
在我们的应用中,ItemDetailPage
作为主从模式中的详情页面,显示项目的内容。在 ItemDetailPage
中,我们基本上展示了 Item
数据模型。虽然现在看起来很简单,但我们将在这本书中逐步增强它:
using System;
namespace PassXYZ.Vault.Models {
public class Item {
public string Id { get; set; }
public string Text { get; set; }
public string Description { get; set; }
}
}
列表 3.3: Item.cs
(epa.ms/Item3-3
)
如 列表 3.3 所示,Item
类包括三个属性:ID
、Text
和 Description
。Item
实例由 ItemDetailViewModel
中的 LoadItemId
函数加载,如下所示:
public async void LoadItemId(string itemId)
{
if (itemId == null) {
throw new ArgumentNullException(nameof(itemId)); }
var item = await dataStore.GetItemAsync(itemId);
if (item == null) {
logger.LogDebug("cannot find {itemId}", itemId);
return; }
Id = item.Id;
Name = item.Name;
Description = item.Description;
}
在 LoadItemId
中,调用 IDataStore
接口的 GetItemAsync
方法通过其 ID 获取项目。
在加载项目后,我们可以在 ItemDetailPage.xaml
中向用户展示数据,如 列表 3.4 所示:
<?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="PassXYZ.Vault.Views.ItemDetailPage"
Title="{Binding Title}">
<StackLayout Spacing="20" Padding="15">
<Label Text="Name:" FontSize="Medium" />
<Label Text="{Binding Name}" FontSize="Small"/>
<Label Text="Description:" FontSize="Medium" />
<Label Text="{Binding Description}" FontSize="Small"/>
</StackLayout>
</ContentPage>
列表 3.4: ItemDetailPage.xaml
(epa.ms/ItemDetailPage3-4
)
列表 3.4 表示 ItemDetailPage
的 XAML 文件。项目详情内容页面包含一个 StackLayout
实例和四个 Label
实例。
在 StackLayout
中,默认方向是 Vertical
,导致 Label
控件在项目详情页上垂直排列(参见图 3.4)。Name
和 Description
都通过数据绑定与视图模型中的模型数据相关联,这将在下一章中介绍。
ItemsPage
ItemsPage
在我们的应用中充当主从模式的主页,展示用户可以浏览的项目列表。
清单 3.5 展示了 ItemsPage
的实现。为了显示项目列表,使用了 ListView
控件。ListView
是一个用于显示可滚动垂直列表的可选择数据项的控件:
<?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="PassXYZ.Vault.Views.ItemsPage" //(1)
Title="{Binding Title}"
xmlns:local="clr-namespace:PassXYZ.Vault.ViewModels" //(5)
xmlns:model="clr-namespace:PassXYZ. Vault.Models" //(6)
x:DataType="local:ItemsViewModel" //(2)
x:Name="BrowseItemsPage"> //(3)
<ContentPage.ToolbarItems...>
<StackLayout>
<ListView x:Name="ItemsListView" //(4)
ItemsSource="{Binding Items}"
VerticalOptions="FillAndExpand"
HasUnevenRows="False"
RowHeight="84"
RefreshCommand="{Binding LoadItemsCommand}"
IsPullToRefreshEnabled="true"
IsRefreshing="{Binding IsBusy, Mode=OneWay}"
CachingStrategy="RetainElement"
ItemSelected="OnItemSelected">
<ListView.ItemTemplate>
<DataTemplate...>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
</ContentPage>
清单 3.5:ItemsPage.xaml
(epa.ms/ItemsPage3-5
)
让我们更详细地检查这段代码:
(1) x:Class
:这用于定义在标记和代码后文件之间共享的部分类名称。PassXYZ.Vault.Views.ItemsPage
是在此定义的类名。
(3) x:Name
:虽然 x:Class
在 XAML 中定义了类名,但 x:Name
定义了实例名称。我们可以在代码后文件中引用 BrowseItemsPage
实例名称。
(2) x:DataType
:将 x:DataType
设置为在视图模型中定义的适当类型可以启用编译绑定,这可以显著提高性能。此处引用的视图模型是 ItemsViewModel
。
除了标准命名空间外,我们还定义了另外两个命名空间,这样我们就可以引用视图模型中的对象 (5) 和模型 (6)。我们将在下一章中讨论视图模型和模型。
(4) 我们定义了一个 ListView
控件来显示项目列表。ListView
控件包含许多属性。当使用 ListView
控件时,以下属性必须被定义:
-
ItemsSource
,是IEnumerable
类型,指定要显示的项目集合。它绑定到在视图模型中定义的Items
。 -
ItemTemplate
,是DataTemplate
类型,指定应用于要显示的项目集合中每个项目的模板。
在 清单 3.5 中,DataTemplate
被折叠。展开它后,我们将看到以下代码片段。这个默认实现来自 Visual Studio 模板。这个数据模板的外观不够理想,我们将在稍后进行改进:
<DataTemplate>
<ViewCell>
<StackLayout Padding="10" x:DataType="model:Item">
<Label Text="{Binding Text}"
LineBreakMode="NoWrap"
Style="{DynamicResource ListItemTextStyle}"
FontSize="16" />
<Label Text="{Binding Description}"
LineBreakMode="NoWrap"
Style="{DynamicResource
ListItemDetailTextStyle}"
FontSize="13" />
</StackLayout>
</ViewCell>
</DataTemplate>
这个 DataTemplate
实现包含一个 ViewCell
,它由一个包含两个 Label
控件的 StackLayout
组成,如 图 3.6 预览中所示。
DataTemplate
实现必须引用一个 Cell
类来显示项目。以下是一些内置的单元格:
-
TextCell
,在单独的行上显示主要和次要文本。 -
ImageCell
,在单独的行上展示图像以及主要和次要文本。 -
SwitchCell
,展示了文本和一个可以打开或关闭的开关。 -
EntryCell
,展示一个标签和可编辑的文本。 -
ViewCell
,这是一个由View
定义的样式的自定义单元格。当需要完全自定义ListView
中每个项目的外观时,应使用此单元格类型。
通常,SwitchCell
和 EntryCell
只在 TableView
中使用,不能在 ListView
中使用。
Name and Description is not straightforward. In KeePass, an icon is usually attached to the password entry. By using a new data template, we can enhance its appearance, like so:
<DataTemplate>
<ViewCell>
<Grid Padding="10" x:DataType="model:Item" > //(1)
<Grid.RowDefinitions> //(2)
<RowDefinition Height="32" />
<RowDefinition Height="32" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.RowSpan="2" Padding="10"> //(3)
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
</Grid.ColumnDefinitions>
<Image Grid.Column="0" Source="icon.png"
HorizontalOptions="Fill" VerticalOptions="Fill" />
</Grid>
<Label Text="{Binding Text}" Grid.Column="1"
LineBreakMode="NoWrap" MaxLines="1"
Style="{DynamicResource ListItemTextStyle}"
FontAttributes="Bold" FontSize="Small" />
<Label Text="{Binding Description}"
Grid.Row="1" Grid.Column="1"
LineBreakMode="TailTruncation" MaxLines="1"
Style="{DynamicResource ListItemDetailTextStyle}"
FontSize="Small" />
</Grid>
</ViewCell>
</DataTemplate>
让我们更详细地检查这段代码:
(1) 为了改善 ViewCell
的外观,我们将 StackLayout
替换为 Grid
作为布局类。Grid
是一种布局,它将子项组织成行和列。
(2) 由于我们想要显示带有左侧图标的两行,因此我们创建了一个包含两列和两行的网格,如图所示:
图 3.7:条目或组的布局
我们可以为 Name
和 Description
使用不同的字体样式,这样用户可以轻松地通过视觉来区分它们。
(3) 为了在第一个两列中居中图标,我们将两行合并到一个 Grid
控件中。可以通过 Grid.RowSpan
属性来合并行。
一个 Grid
可以作为一个包含其他子布局的父布局。为了保持图标的具体大小并将其定位在合并单元格的中心,我们可以使用另一个 Grid
作为 Image
控件的父布局。这个子 Grid
只包含一个具有特定大小的行和列。
在 Image
控件中,我们可以使用资源中的默认图像(icon.png
)。一旦我们在下一章介绍我们的模型,就可以进行自定义。
在自定义的 ViewCell
中,我们可以显示与相关图标关联的数据键值对。
参考图 3.8 查看改进的预览:
图 3.8:改进的 ItemsPage
通过这些知识,我们已经涵盖了使用 XAML 进行用户界面设计的 fundamentals。用户界面设计中的一个常见挑战是提供对多种语言的支持。在本章的剩余部分,我们将学习如何在 XAML 中设计用户界面时支持多种语言。
支持多种语言 - 本地化
为了适应多种语言,我们可以利用 .NET 内置的应用程序本地化机制。在 .NET 中,资源文件可以用于支持本地化,通过将应用程序用户界面所需的所有文本和其他资源集中在一个位置。在 XAML 文件中,我们可以使用 x:Static
标记扩展来访问资源文件中定义的字符串。
创建 .resx 文件
我们可以为每种支持的语言生成一个资源文件。资源文件是具有 .resx
扩展名的 XML 文件,在构建过程中编译成二进制资源文件。要添加资源文件,右键单击项目节点,然后选择 添加 > 新项... > 资源文件,如图 3.9 所示:
图 3.9:创建资源文件
我们可以在 Properties
文件夹中创建 Resources.resx
资源文件。
为了支持不同的文化,我们可以在资源文件名中添加包含文化信息的附加资源文件:
-
Resources.resx
:为默认文化提供的资源文件,我们将将其设置为en-US(美国英语)。 -
Resources.zh-Hans.resx
:为zh-Hans文化提供的资源文件,即简体中文。 -
Resources.zh-Hant.resx
:为zh-Hant文化提供的资源文件,即繁体中文。
创建资源文件后,以下ItemGroup
将被添加到项目文件中:
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
要编辑资源文件,请单击资源文件,并在资源编辑器中编辑它,如图3.10所示:
图 3.10:资源编辑器
资源文件包括不同语言的键值对列表:
-
Name
字段代表可以在 XAML 和 C#文件中引用的字符串名称。 -
Value
字段包含将根据系统语言设置使用的特定语言的字符串。 -
Comment
字段被用作键值对的备注。
要指定默认语言,我们需要在项目文件中的<PropertyGroup>
中设置NeutralLanguage
的值,如下所示:
<PropertyGroup>
…
<NeutralLanguage>en-US</NeutralLanguage>
…
</PropertyGroup>
在我们的项目中,我们将使用美国英语作为默认文化,因此将NeutralLanguage
设置为en-US
。
本地化文本
一旦配置了资源文件,我们就可以在我们的 XAML 文件或 C#文件中使用本地化内容。目前,我们的项目包含五个内容页面。让我们修改AboutPage
以支持本地化,如图列表 3.6所示:
<?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="PassXYZ.Vault.Views.AboutPage"
xmlns:res="clr-namespace:PassXYZ.Vault.Properties" //(1)
Title="{Binding Title}">
<ContentPage.Resources...>
<ScrollView>
<StackLayout Margin="20">
<Grid Padding="10"...>
<StackLayout Padding="10" >
<Label HorizontalOptions="Center"
Text="{x:Static res:Resources.Appname}" //(2)
FontAttributes="Bold" FontSize="22" />
<Label x:Name="AppVersion"
HorizontalOptions="Center"
FontSize="Small" />
<Grid HorizontalOptions="Center"...>
<StackLayout...>
</StackLayout>
</StackLayout>
</ScrollView>
</ContentPage>
列表 3.6:AboutPage.xaml
(epa.ms/AboutPage3-6
)
文本本地化是通过生成的Resources
类完成的。这个类的命名基于默认资源文件名。在图 3.6的AboutPage.xaml
中,我们为Resources
类添加了一个新的命名空间(1):
xmlns:res ="clr-namespace:PassXYZ.Vault.Properties "
在Label
控件(2)中,为了显示我们的应用程序名称,我们可以使用x:Static
XAML 标记扩展来引用资源字符串,如下所示:
<Label HorizontalOptions="Center"
Text="{x:Static res:Resources.Appname}"
FontAttributes="Bold" FontSize="22" />
在列表 3.6中,我们为了简洁起见折叠了大部分源代码。请参考本书 GitHub 仓库的短 URL 来查看完整的源代码。
本地化文本可以在 XAML 和 C#中使用。要在 C#中使用资源字符串,我们可以查看列表 3.6中的Title
属性。AboutPage
的Title
属性连接到AboutViewModel
类中的Title
属性。让我们看看我们如何在列表 3.7中使用资源字符串:
using System;
using System.Windows.Input;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Maui.Controls;
**using** **PassXYZ.Vault.Properties;** //(1)
namespace PassXYZ.Vault.ViewModels
{
public partial class AboutViewModel : ObservableObject
{
[ObservableProperty]
private string? title = **Properties.Resources.About**; //(2)
[RelayCommand]
private async Task OpenWeb()...
public string GetStoreName()...
public DateTime GetStoreModifiedTime()...
}
}
列表 3.7:AboutViewModel.cs
(epa.ms/AboutViewModel3-7
)
如图 3.7所示,(1) 我们首先添加了PassXYZ.Vault.Properties
命名空间。(2) 我们将资源字符串称为Properties.Resources.About
。
在我们更新AboutPage
以支持本地化后,我们可以在支持的语言中进行测试,如图3.11所示:
图 3.11:不同语言中的 AboutPage
在 AboutPage
中,许多资源字符串被用于本地化。在 清单 3.6 和 清单 3.7 中,我们折叠了大部分代码;你可以通过这本书的 GitHub 仓库的短网址来在线查看源代码。
.NET MVVM 社区工具包
.NET MVVM 社区工具包是一个旨在简化基于 .NET 库的 模型-视图-视图模型(MVVM)模式应用程序开发过程的辅助工具和工具集。
工具包提供了一系列旨在减少样板代码的功能,包括转换器、辅助工具、行为、命令和服务,旨在促进 MVVM 模式中类之间的通信。
下一章将介绍更多关于 .NET MVVM 社区工具包的详细信息。
摘要
在本章中,我们探讨了 XAML 语法,并将我们所获得的知识应用于增强 ItemsPage
的外观。我们将在整本书中持续改进其他页面的用户界面。为了支持多语言,我们深入研究了 .NET 本地化,并为 US-en
、zh-Hans
和 zh-Hant
语言创建了多个资源文件。此外,我们还发现了如何使用 XAML 标记扩展访问资源文件中的字符串。最后,我们以 AboutPage
为例,展示了在 XAML 和 C# 中使用本地化文本的方法。
在下一章中,我们将通过介绍 MVVM 和数据绑定来继续改进我们的应用程序。
进一步阅读
-
.NET 多平台应用 UI 文档:
learn.microsoft.com/en-us/dotnet/maui/
-
XAML - .NET MAUI:
learn.microsoft.com/en-us/dotnet/maui/xaml/
-
XAML 标记扩展:
learn.microsoft.com/en-us/dotnet/maui/xaml/fundamentals/markup-extensions
-
KeePass – 一个开源密码管理器:
keepass.info/
留下评论!
喜欢这本书吗?通过留下亚马逊评论帮助像你一样的读者。扫描下面的二维码获取 40% 的折扣码。
*限时优惠
第四章:探索 MVVM 和数据绑定
在上一章中,我们探讨了如何使用 XAML 构建用户界面(UI)。在本章中,我们将深入探讨模型-视图-视图模型(MVVM)模式和数据绑定。MVVM 模式,一种广泛采用的架构模式,对于创建可维护、可扩展和可测试的应用程序至关重要。在.NET MAUI 的上下文中,MVVM 将用户界面逻辑、数据和应用程序的结构分离成三个不同的组件:模型、视图和视图模型。这种分离导致代码库整洁有序,使得开发和维护应用程序更加容易。数据绑定是一种将视图(UI)与视图模型连接的技术,使得 UI 能够同步和自动地反映视图模型的数据变化。
在本章中,我们的初步重点是介绍MVVM和数据绑定。为了更好地设计和编写更干净的代码,我们将使用.NET 社区工具包的一部分,即 MVVM 工具包。使用 MVVM 工具包的结果是更简洁、更干净的视图模型代码。
在现实世界的应用程序中,大多数逻辑都是在模型和服务层实现的。没有更复杂的模型层,我们无法深入探讨关于 MVVM 和数据绑定的复杂主题。因此,在本章的后半部分,我们展示了实际的模型层,包括两个.NET 库,KPCLib和PassXYZLib。
在建立了实际的模型层之后,我们介绍了数据绑定的两个高级主题:绑定到集合和使用自定义视图。
本章将涵盖以下主题:
-
理解MVVM和MVC
-
数据绑定
-
.NET 社区工具包
-
介绍数据模型和服务层
-
绑定到集合
-
使用自定义视图
技术要求
要测试和调试本章的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。有关详细信息,请参阅第一章,开始使用.NET MAUI中的开发环境设置部分。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/2nd/chapter04
。
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter04 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书源代码的信息,请参阅第二章,构建我们的第一个.NET MAUI 应用程序中的管理本书的源代码部分。
理解 MVVM 和 MVC
在软件设计中,我们通常遵循并重用良好的实践和设计模式。模型-视图-控制器(MVC)模式是一种解耦系统责任的方法。它可以帮助将 UI 实现和业务逻辑分离到不同的部分。
图 4.1:MVC 模式
如图 4.1所示,MVC 模式将系统的职责分为三个不同的部分:
-
模型:模型代表应用程序的数据和业务逻辑。它负责存储应用程序的数据,处理数据验证,并执行任何必要的数据处理。模型类通常与数据源(如数据库、Web API 或文件存储)交互,以获取和存储数据。模型类通常可以实施为纯旧 CLR 对象(POCOs)或数据传输对象(DTOs)。POCO 是一个不依赖于任何框架特定类的类,因此 POCO 类可以很好地与 LINQ 或 Entity Framework 一起使用。DTO 是 POCO 类的子集,仅包含数据而没有逻辑或行为。DTO 类可用于在层之间传递数据。模型不依赖于视图或控制器,因此它可以单独实现和测试。
-
视图:视图负责应用程序的用户界面和用户交互。视图不应包含任何业务逻辑或直接的数据处理。相反,它向用户显示数据和界面元素,并捕获他们的输入。
-
控制器:控制器根据用户的操作更新模型和视图。我们对模型和视图的理解在时间上并没有太大的变化,但自从 MVC 模式被引入以来,对控制器就有不同的理解和实现。
MVVM 模式受 MVC 模式的启发,但其目标是提供一种针对基于 XAML 的应用程序 UI 开发的改进方法。随着基于 XAML 的 UI 框架的兴起,MVVM 获得了动力,并成为 WPF、Silverlight 以及后来的 Xamarin.Forms 应用程序的事实上的模式。
随着 Xamarin.Forms 演变为.NET MAUI,MVVM 在多平台移动和桌面应用程序开发中继续是一个突出的模式。
图 4.2:MVVM 模式
如图 4.2所示,在 MVVM 中,ViewModel 用于替代控制器。MVVM 与 MVC 之间的区别如下:
-
视图和模型的解耦:ViewModel 用于处理视图和模型之间的通信。视图通过 ViewModel 访问模型中的数据和逻辑。
-
视图和 ViewModel 之间的数据绑定:使用数据绑定,视图或 ViewModel 中的更改可以自动更新到另一个中。这有助于减少实现的复杂性。
在 MVC 和 MVVM 中,模型都可以单独测试。在 MVVM 中,还可以为 ViewModel 设计单元测试。
当视图发生变化时,这些变化将通过数据绑定反映在 ViewModel 中。ViewModel 将处理模型中的数据变化。同样,当模型中的数据发生变化时,ViewModel 会收到通知以更新视图。通知的常见解决方案是安装事件处理器来触发通知。使用数据绑定,实现大大简化。
PassXYZ.Vault 中的 MVVM
在我们的应用PassXYZ.Vault
中,我们使用 MVVM 来处理视图和 ViewModel 之间的数据交换。正如我们在图 4.3中可以看到的,我们有五个 XAML 内容页面和相同数量的 ViewModel 定义。在我们的模型中,我们有一个Item
类,这是我们模型类,并且可以通过IDataStore接口访问。
图 4.3:PassXYZ.Vault 中的 MVVM
数据绑定用作视图和 ViewModel 之间的通信通道。ViewModel 将通过IDataStore服务接口更新Item
模型。我们将在下一节通过分析ItemDetailPage
和ViewModel
来学习如何使用数据绑定。
数据绑定
让我们探索 MVVM 和数据绑定是如何工作的。我们可以在我们的应用中使用一个项目详情页面实现来分析数据绑定是如何工作的。以下列表包括了我们将要探索的视图、ViewModel 和模型:
-
视图:
ItemDetailPage
,参见前一章的列表 3.4 -
ViewModel:
ItemDetailViewModel
,参见前一章的列表 4.1 -
模型:
Item
(通过IDataStore接口访问),参见前一章的列表 3.3
ItemDetailPage
是一个用于显示Item
实例内容的视图。数据是从 ViewModel 中检索的。展示Item
内容的 UI 元素通过数据绑定连接到 ViewModel 实例。
图 4.4:数据绑定
正如我们在图 4.4中看到的,数据绑定用于同步目标和源对象的属性。数据绑定中有三个对象参与,它们是绑定目标
、绑定源
和绑定对象
。
绑定对象
在图 4.4中,Binding
类(Microsoft.Maui.Controls.Binding
)的对象代表了目标属性(在视图)和源属性(在 ViewModel)之间的连接。它管理属性值的同步并处理源和目标之间的通信。绑定对象是在你定义 XAML 标记中的绑定表达式或创建代码中的绑定时创建的。
在 XAML 中创建绑定对象的示例:
<Label Text="{Binding FirstName}" />
在 C#中创建绑定对象的示例:
label.SetBinding(Label.TextProperty, new Binding("FirstName"));
绑定目标
在 图 4.4 中,目标指的是绑定到 ViewModel 属性的视图中的 UI 元素(控件)。更具体地说,目标是参与绑定的 UI 元素的属性。目标属性的例子包括 Label
或 Entry
上的 Text
、Image
上的 Source
和 Button
上的 IsEnabled
。目标属性必须是可绑定属性才能参与数据绑定。
绑定源
源是包含绑定到视图中的目标属性属性的对象。通常,源对象是 ViewModel,它应该被设置为视图的 BindingContext
或其父元素之一。ViewModel 暴露了定义其数据类型、获取器和设置器方法的属性。源属性通过绑定表达式中的绑定路径来识别。
当你在 XAML 中定义绑定表达式或在代码中创建绑定对象时,会创建一个绑定对象并将其设置为指定的目标属性。XAML 解析器或绑定系统创建绑定对象,并用提供的绑定属性(如 Path
、Source
、Mode
、Converter
等)初始化它。
这里是一个涉及目标和源对象属性的列表:
-
目标 – 这是涉及的 UI 元素,并且这个 UI 元素必须是
BindableObject
的子元素。在ItemDetailPage
中使用的 UI 元素是Label
。 -
目标属性 – 这是目标对象的属性。它是一个
BindableProperty
。如果目标是Label
,如我们在这里提到的,目标属性可以是Label
的Text
属性。 -
源 – 这是数据绑定引用的源对象。在这里,它是
ItemDetailViewModel
。 -
源对象值路径 – 这是源对象中值的路径。在这里,路径是一个
ViewModel
属性,例如Name
或Description
。
让我们看看 ItemDetailPage
中的以下代码:
<StackLayout Spacing="20" Padding="15">
<Label Text="Name:" FontSize="Medium" />
<Label Text="{Binding Name}" FontSize="Small"/> //(1)
<Label Text="Description:" FontSize="Medium" />
<Label Text="{Binding Description}" FontSize="Small"/> //(2)
</StackLayout>
在这里的 XAML 中,有两个数据绑定源路径,分别是 Name
、(1)和 Description
、(2)。绑定目标是 Label
,目标属性是 Label
的 Text
属性。如果我们回顾 Label
的继承层次结构,它看起来是这样的:
Object -> BindableObject -> Element -> NavigableElement -> VisualElement -> View -> Label
我们可以看到 Element
、VisualElement
和 View
是 BindableObject
的派生类。数据绑定目标必须是 BindableObject
的子元素。
绑定源是 ViewModel 的 Name
、(1)和 Description
、(2)属性,如本处所示的 清单 4.1:
using PassXYZ.Vault.Models;
namespace PassXYZ.Vault.ViewModels {
[QueryProperty(nameof(ItemId), nameof(ItemId))]
public class ItemDetailViewModel : BaseViewModel {
private string itemId;
private string name;
private string description;
public string Id { get; set; }
public string Name { //(1)
get => name;
set => SetProperty(ref name, value);
}
public string Description... //(2)
public string ItemId...
public async void LoadItemId(string itemId) { //(3)
try {
var item = await DataStore.GetItemAsync
(itemId);
Id = item.Id;
Name = item.Name;
Description = item.Description;
}
catch (Exception) {
Debug.WriteLine("Failed to Load Item");
}
}
}
清单 4.1: ItemDetailViewModel.cs
(epa.ms/ItemDetailViewModel4-1
)
Name
、(1)和 Description
、(2)的值是从 LoadItemId()
方法中的模型加载的,(3)。你可能注意到类被 QueryPropertyAttribute
属性装饰。这用于在页面导航期间传递参数,将在下一章中介绍。
让我们使用以下,表 4.1,来总结代码中的数据绑定组件。
数据绑定元素 | 示例 |
---|---|
目标 | Label |
目标属性 | Text |
源对象 | ItemDetailViewModel |
源对象值路径 | Name 或 Description |
表 4.1: 数据绑定设置
绑定对象的属性
分析了前面的代码后,让我们看看绑定表达式的语法:
<object property="{Binding bindProp1=value1[, bindPropN=valueN]*}" ... />
绑定属性可以设置为一系列以 bindProp=value
形式的名称-值对。例如,请参见以下内容:
<Label Text="{Binding Path=Description}" FontSize="Small"/>
Path
属性是默认属性,如果它是属性列表中的第一个,则可以省略,如这里所示:
<Label Text="{Binding Description}" FontSize="Small"/>
我们在这里提到的绑定属性是 Binding
类的属性,您可以通过参考 Microsoft 关于 Binding
类的文档来找到详细信息:learn.microsoft.com/en-us/dotnet/api/microsoft.maui.controls.binding
。
除了 Path
属性,让我们再回顾另外两个重要的绑定属性,Source
和 Mode
。
源
Source
属性表示包含源属性的对象(该属性将被绑定到目标属性)。默认情况下,源对象是元素的 BindingContext
,通常是 ViewModel。但是,如果需要,您可以使用 Source
属性来绑定到其他对象。
示例:
<Label Text="{Binding Source={x:Reference someLabel}, Path=Text}" />
当我们将数据绑定设置到目标时,我们可以使用目标类的以下两个成员:
-
BindingContext
属性为我们提供了源对象 -
SetBinding
方法指定目标属性和源属性
在我们的例子中,我们在 ItemDetailPage
的 C# 代码隐藏文件中将 BindingContext
属性设置为 ItemDetailViewModel
的一个实例 (1),如这里所示的 清单 4.2。它是在页面级别设置的,并适用于此页面上所有绑定目标:
using PassXYZ.Vault.ViewModels;
using System.ComponentModel;
using Microsoft.Maui;
using Microsoft.Maui.Controls;
namespace PassXYZ.Vault.Views
{
public partial class ItemDetailPage : ContentPage
{
public ItemDetailPage()
{
InitializeComponent();
BindingContext = new ItemDetailViewModel(); //(1)
}
}
}
清单 4.2: ItemDetailPage.xaml.cs
(epa.ms/ItemDetailPage4-2
)
除了使用 Binding
标记扩展,我们还可以直接使用 SetBinding
方法创建绑定,就像这里所做的那样:
<StackLayout Spacing="20" Padding="15">
<Label Text="Text:" FontSize="Medium" />
<Label x:Name="labelText" FontSize="Small"/> //(2)
<Label Text="Description:" FontSize="Medium" />
<Label Text="{Binding Description}" FontSize="Small"/>
</StackLayout>
(2) 在 XAML 代码中,我们移除了 Binding
标记扩展,并将实例名称指定为 labelText
。在 C# 代码隐藏文件中,我们可以在 ItemDetailPage
的构造函数中调用 SetBinding
方法 (3) 来创建 Text
属性的数据绑定:
public ItemDetailPage()
{
InitializeComponent();
BindingContext = new ItemDetailViewModel();
labelText.SetBinding(Label.TextProperty, "Text"); //(3)
}
绑定模式
Mode
属性指定绑定中数据流的方向,例如数据是否只从 ViewModel 流向 UI,或者是否双向流动。
在 ItemDetailPage
中,所有 UI 元素都是 Label
对象,用户无法编辑。这是从源到目标的单向绑定。源对象中的更改将导致目标对象更新。
.NET MAUI 支持四种绑定模式。让我们通过参考 图 4.5 来回顾它们。
图 4.5: 绑定模式
让我们看看.NET MAUI
支持的绑定模式:
-
OneWay
绑定通常用于向用户展示数据的情况。在我们的应用中,我们将检索密码条目列表,并在ItemsPage
上显示这个列表。当用户点击列表中的项目时,密码详情将显示在ItemDetailPage
上。OneWay
绑定在这两种情况下都使用。 -
TwoWay
绑定会导致源属性或目标属性的变化自动更新另一个。在我们的应用中,当用户编辑密码条目的字段或当用户在LoginPage
上输入用户名和密码时,目标 UIEntry
组件和源视图模型对象都设置为TwoWay
。 -
OneWayToSource
是OneWay
绑定模式的逆操作。当目标属性发生变化时,源属性将被更新。当我们向NewItemPage
添加新的密码条目时,我们可以使用OneWayToSource
而不是TwoWay
绑定模式来提高性能。 -
OneTime
绑定是一种在图 4.4中没有显示的绑定模式。目标属性从源属性初始化,但源属性的任何进一步更改都不会更新目标属性。这是一种比OneWay
绑定模式更简单的形式,具有更好的性能。
如果我们没有在数据绑定中指定绑定模式,则使用默认绑定模式。如果需要,我们可以覆盖默认绑定模式。
在我们的ItemsPage
代码中,我们使用ListView
控件来显示密码组条目列表,因此我们应该将IsRefreshing
属性设置为OneWay
绑定模式:
IsRefreshing="{Binding IsBusy, Mode=OneWay}"
当我们在NewItemPage
中添加新项目时,我们使用Entry
和Editor
控件来编辑属性。我们可以使用OneWayToSource
或TwoWay
绑定模式:
<Label Text="Text" FontSize="Medium" />
<Entry Text="{Binding Text, Mode=TwoWay}" FontSize="Medium"/>
<Label Text="Description" FontSize="Medium" />
<Editor Text="{Binding Description, Mode=OneWayToSource}"
AutoSize="TextChanges" FontSize="Medium" Margin="0" />
在 ViewModel 中更改通知
在图 4.5中,我们可以看到数据绑定目标是BindableObject
的派生类。除了这个要求外,在数据绑定设置中,数据源(ViewModel)需要实现INotifyPropertyChanged
接口,以便当属性发生变化时,会引发PropertyChanged
事件来通知变化。
在 MVVM 模式中,ViewModel 通常是数据绑定的数据源,因此我们需要在我们的 ViewModel 中实现INotifyPropertyChanged
接口。如果我们为每个 ViewModel 类都这样做,将会产生大量的重复代码。在 Visual Studio 模板中,一个BaseViewModel
类,如我们在列表 4.3中所见,包含在样板代码中,并在我们的应用中使用。其他 ViewModel 继承这个类:
namespace PassXYZ.Vault.ViewModels;
public class BaseViewModel : INotifyPropertyChanged //(1)
{
public IDataStore<Item> DataStore =>
DependencyService.Get<IDataStore<Item>>();
bool isBusy = false;
public bool IsBusy {
get { return isBusy; }
set { SetProperty(ref isBusy, value); } //(2)
}
string title = string.Empty;
public string Title {
get { return title; }
set { SetProperty(ref title, value); }
}
protected bool SetProperty<T>(ref T backingStore,
T value,
[CallerMemberName] string propertyName = "",
Action onChanged = null) {
if (EqualityComparer<T>.Default.Equals
(backingStore, value))
return false;
backingStore = value;
onChanged?.Invoke();
OnPropertyChanged(propertyName);
return true;
}
#region INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged; //(4)
protected void OnPropertyChanged([CallerMemberName]
string propertyName = "") { //(3)
var changed = PropertyChanged;
if (changed == null)
return;
changed.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
列表 4.3 BaseViewModel.cs
(epa.ms/BaseViewModel4-3
)
在BaseViewModel
类(列表 4.3)中,我们可以看到以下内容:
(1) BaseViewModel
实现了INotifyPropertyChanged
接口,该接口定义了一个事件,PropertyChanged
(4)。
(3) 当属性在设置器中变更时,会调用 OnPropertyChanged
方法。在 OnPropertyChanged
中,会触发 PropertyChanged
事件。PropertyChanged
事件处理器的副本存储在 changed 本地变量中,因此这种实现适用于多线程环境。当触发 PropertyChanged
事件时,需要传递属性名称作为参数,以指示哪个属性已更改。可以使用 CallerMemberName
属性来查找调用者的方法名或属性名,因此我们不需要硬编码属性名。
(2) 当我们在 ViewModel 中定义属性时,设置器中会调用 OnPropertyChanged
方法——但如您所见,在我们的代码中,我们调用 SetProperty<T>
而不是直接调用 OnPropertyChanged
。SetProperty<T>
在调用 OnPropertyChanged
之前会执行额外的工作。它会检查值是否已更改。如果没有变化,它将返回并执行无操作。如果值已更改,它将更新后端字段并调用 OnPropertyChanged
来触发变更事件。
如果我们回顾 列表 4.1 中的 ItemDetailViewModel
,它继承自 BaseViewModel
类。在 Name
和 Description
属性的设置器中,我们调用 SetProperty<T>
来设置值并触发 PropertyChanged
事件:
public string Name {
get => name;
set => SetProperty(ref name, value);
}
public string Description {
get => description;
set => SetProperty(ref description, value);
}
在本节中,我们学习了数据绑定和 INotifyPropertyChanged
接口。我们需要创建样板代码来定义具有变更通知支持的属性。为了简化代码并在幕后自动生成样板代码,我们可以使用 MVVM Toolkit。
.NET Community Toolkit
在本节中,我们将使用 MVVM Toolkit 对 ViewModel 代码进行重构。MVVM Toolkit 是 .NET Community Toolkit 中的一个模块,专门设计用于构建遵循 MVVM 设计模式的程序。
.NET Community Toolkit(之前称为 Windows Community Toolkit)是一组辅助函数、自定义控件和应用程序服务,旨在简化并加速 .NET 应用程序的开发。该工具包是开源的,并由社区维护,为各种 .NET 开发平台提供了一套工具,例如 .NET MAUI、Xamarin、UWP、WPF 和 WinUI。该工具包提供了有用的组件,开发者可以开箱即用,轻松构建具有丰富用户界面、常见应用程序服务、动画等的应用程序。
MVVM Toolkit 提供了一套基础类、实用工具和属性,使在 .NET 应用程序中实现 MVVM 模式更加高效和直接。MVVM Toolkit 提供以下关键组件:
-
ObservableObject
:这是一个简化实现引发 PropertyChanged 事件的对象(如 ViewModels)的基础类。它实现了INotifyPropertyChanged
接口,并提供SetProperty
方法来处理属性变更通知。 -
RelayCommand
和AsyncRelayCommand
:实现ICommand
接口的类,旨在处理执行方法和检查命令是否可以执行。它们使得为 ViewModel 创建响应 UI 操作的命令变得容易。 -
依赖注入 支持:MVVM 工具包提供了对依赖注入的内建支持,这使得使用流行的依赖注入库(如
Microsoft.Extensions.DependencyInjection
)将服务集成到 ViewModel 中变得简单。 -
信使(事件聚合器):MVVM 工具包提供了一个轻量级的信使服务,它允许组件之间进行解耦的消息传递通信,例如不同的 ViewModels。这促进了关注点的分离,并使每个组件更容易进行测试。
请在 进一步阅读 部分查找有关 MVVM 工具包的更多信息。
如何使用 MVVM 工具包
要在 .NET MAUI 中使用 MVVM 工具包,请按照以下步骤操作:
-
将 .NET
CommunityToolkit.Mvvm
NuGet 包添加到我们的项目文件中,如下所示:<ItemGroup> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> </ItemGroup>
-
使用 MVVM 工具包源生成器重构 ViewModel。源生成器是 C# 编译器的一个功能,允许在编译时执行自定义代码以修改编译输出。MVVM 工具包支持使用源生成器根据属性自动生成 ViewModel 和
ICommand
模板代码,从而简化 ViewModel 的创建。
MVVM 工具包可以帮助我们通过使用源生成器简化 ViewModel。如果我们想在 ViewModel 中添加一个作为数据绑定源的属性,我们需要实现 INotifyPropertyChanged
接口。例如,在 ItemDetailViewModel
中,我们实现 Description
属性如下:
private string description;
public string Description {
get => description;
set => SetProperty(ref description, value);
}
在 Setter 中,调用 SetProperty
方法,它将更新后端字段并调用 OnPropertyChanged
来触发更改事件。SetProperty
和 OnPropertyChanged
都在 BaseViewModel
类中定义,正如我们在 清单 4.3 中所看到的。
使用 MVVM 工具包,我们可以从 ObservableObject
继承,而不是从 BaseViewModel
继承。ObservableObject
实现了与我们在 BaseViewModel
中所做类似的 INotifyPropertyChanged
接口。使用 ObservableObject
,我们可以将上述实现简化如下:
[ObservableProperty]
private string description;
如我们所见,使用 ObservableProperty
属性,我们可以在代码中仅定义后端字段。源生成器将帮助我们生成模板代码。我们可以在 XAML 中使用 Description
属性如下:
<Label Text="{Binding Description}"
FontSize="Small"
TextType ="Html"
Style="{DynamicResource ListItemDetailTextStyle}" />
在 MVVM 模式下,我们可以将 ViewModel 属性定义为可观察属性以支持双向绑定。我们还可以将 ViewModel 属性定义为 ICommand
接口,以使用数据绑定处理 UI 事件。
在我们的应用中,我们在 ItemsPage
中显示项目列表。在 ItemsPage
中,我们需要从数据源加载项目列表,并且还需要支持添加新项目。我们需要定义两个 ICommand
接口如下:
public class ItemsViewModel : BaseViewModel
{
...
public Command LoadItemsCommand { get; } //(1)
public Command AddItemCommand { get; } //(2)
public ItemsViewModel()
{
...
LoadItemsCommand = new Command(
async () => await LoadItems()); //(3)
AddItemCommand = new Command(AddItem); //(4)
...
}
private async Task LoadItems() //(5)
{
...
}
private async void AddItem(object obj) //(6)
{
await Shell.Current.GoToAsync(nameof(NewItemPage));
}
...
}
在上述实现中,我们定义了两个属性,LoadItemsCommand
(1) 和 AddItemCommand
(2),其类型为 Microsoft.Maui.Controls.Command
。在 ViewModel 的构造函数中,我们用私有方法 LoadItems
(5) 和 AddItem
(6) 初始化它们(3)(4)。
使用 MVVM 工具包,我们可以简化实现如下:
public partial class ItemsViewModel : BaseViewModel
{
...
[RelayCommand]
private async void AddItem(object obj) //(1)
{
await Shell.Current.GoToAsync(nameof(NewItemPage));
}
...
[RelayCommand]
private async Task LoadItems() //(2)
{
...
}
}
我们可以看到,我们只需要在 AddItem
(1)
和 LoadItems
(2)
前添加 RelayCommandAttribute
来实现 ICommand
属性。源生成器将帮助我们生成其余的代码。
在介绍了 XAML UI 设计的基本知识、MVVM 模式和数据绑定之后,我们可以使用我们刚刚学到的知识来改进我们的应用。
改进数据模型和服务
在介绍了 MVVM 模式、数据绑定和 MVVM 工具包之后,我们掌握了如何使用数据绑定的基础知识。在本章的剩余部分,我们将探讨数据绑定的高级主题。我们将首先讨论如何绑定到集合,然后我们将介绍自定义视图。使用自定义视图,我们可以使 XAML 代码更简洁、更简洁。
为了检验这些主题,需要一个更复杂的模型层。我们不会创建一个假设的模型层,而是将使用我们应用中的实际模型层,该模型层包括两个 .NET 库:KPCLib 和 PassXYZLib。
为了介绍我们应用的模型层,让我们再次回顾一下用例。我们正在开发一个跨平台密码管理器应用,该应用兼容流行的 KeePass 数据库格式。我们有以下用例:
-
用例 1:
LoginPage
– 作为密码管理器用户,我希望登录到密码管理器应用,以便我可以访问我的密码数据。 -
用例 2:
AboutPage
– 作为密码管理器用户,我希望了解我正在使用的数据库和应用概览。 -
用例 3:
ItemsPage
– 作为密码管理器用户,我希望看到一组和条目列表,以便我可以探索和检查我的密码数据。 -
用例 4:
ItemDetailPage
– 作为密码管理器用户,我希望在列表中选择密码条目后,能看到该密码条目的详细信息。 -
用例 5:
NewItemPage
– 作为密码管理器用户,我希望在我的数据库中添加密码条目或创建一个新的组。
这五个用例是从 Visual Studio 模板继承的,对于密码管理器应用的当前用户故事来说已经足够了。我们将使用这些用户故事在本章中改进我们的应用。
我们使用 MVVM 模式实现了我们的应用,但下面的 Item
模型过于简单,不足以在密码管理器应用中使用:
public class Item
{
public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
我们密码管理器应用的主要功能封装在模型层中。我们将使用两个 .NET 包,KPCLib 和 PassXYZLib 来构建我们的模型。这两个包包含了我们需要的所有密码管理功能。
KPCLib
我们将使用的是来自KeePass的库,名为KeePassLib
。KeePass 和KeePassLib
都是为.NET Framework 构建的,因此它们只能在 Windows 上使用。我将KeePassLib
移植并重新构建为一个.NET Standard 2.0 库,打包为 KPCLib。KPCLib 可以在以下 NuGet 和 GitHub 上找到:
-
GitHub:
github.com/passxyz/KPCLib
KPCLib 既用作包名也用作命名空间。KPCLib 的包包括两个命名空间,KeePassLib
和KPCLib
。KeePassLib
命名空间是 KeePass 的原始命名空间,以下是一些更改:
-
更新并构建为.NET Standard 2.0
-
将
PwEntry
和PwGroup
更新为从Item
抽象类派生的类
在 KPCLib 命名空间中,定义了一个Item
抽象类。我创建了一个新类并将其作为PwEntry
和PwGroup
的父类,是因为KeePass
和PassXYZ.Vault
之间的导航设计不同。
如果我们查看图 4.5中的 KeePass UI,我们可以看到它是一个经典的 Windows 桌面 UI。导航是围绕类似于 Windows 资源管理器的树视图设计的。
图 4.6: KeePass UI
两个类,PwGroup
和PwEntry
,表现得像目录和文件。一个PwGroup
实例就像一个目录,它包含一个子列表——PwGroup
和PwEntry
。所有PwGroup
实例都在右侧面板上的树视图中显示。当选择一个PwGroup
实例时,该组中PwEntry
的列表将显示在右侧面板上。PwEntry
包括密码条目的内容,例如用户名和密码。PwEntry
的内容在底部面板上显示。
在PassXYZ.Vault
的用户界面设计中,我们使用.NET MAUI Shell 模板。这是一个堆叠型主-详细模式的实现。在堆叠型主-详细模式中,使用单个列表来显示项目。在这种情况下,PwGroup
和PwEntry
的实例都可以在同一个列表中显示。选择一个项目后,我们将根据项目的类型执行相应的操作。
PwGroup 和 PwEntry 的抽象
为了更好地处理 PassXYZ.Vault 的用户界面设计,我们可以将PwGroup
和PwEntry
抽象为Item
抽象类,如图4.7所示。
图 4.7: Item 类的类图
参考图4.7中的 UML 类图和列表 4.4中的Item.cs
源代码,我们可以看到在Item
抽象类中定义了以下属性。这些属性在PwEntry
和PwGroup
中都有实现:
(1) Name
: Item
的名称
(2) Description
: Item
的描述
(3) Notes
: 用户定义的Item
注释
(4) IsGroup
: 如果实例是PwGroup
则为true
,如果是PwEntry
则为false
(5) Id
: 实例的 ID(类似于数据库中的主键的唯一值)
(6) ImgSource
: 图标的图像源(PwGroup
和 PwEntry
都可以关联一个图标)
(7) LastModificationTime
: 项目的最后修改时间
(8) Item
: 实现了 INotifyPropertyChanged
接口,并且能够在 MVVM 模型中很好地用于数据绑定:
using System.Text;
namespace KPCLib
{
public abstract class Item : INotifyPropertyChanged //(8)
{
public abstract DateTime LastModificationTime {get;
set;};} //(7)
public abstract string Name { get; set; } //(1)
public abstract string Description { get;} //(2)
public abstract string Notes { get; set; } //(3)
public abstract bool IsGroup { get; } //(4)
public abstract string Id { get; } //(5)
virtual public Object ImgSource { get; set; } //(6)
#region INotifyPropertyChanged ...
}
}
列表 4.4: Item.cs
(epa.ms/Item4-4
)
PassXYZLib
要在 PassXYZ.Vault
中使用 KeePassLib,我们需要使用一些 .NET MAUI API 来扩展我们应用所需的功能。为了将业务逻辑与 UI 分离并扩展 KeePassLib 的 .NET MAUI 功能,创建了一个 .NET MAUI 类库,PassXYZLib,以封装扩展的模型到一个单独的库中。PassXYZLib 既是包名也是命名空间。
要将 PassXYZLib 添加到我们的项目中,我们可以将其添加到 PassXYZ.Vault.csproj
项目文件中,如下所示:
<ItemGroup>
<PackageReference Include="PassXYZLib" Version="2.1.2" />
</ItemGroup>
我们也可以在此处从命令行添加 PassXYZLib 包。从命令行进入项目文件夹,执行以下命令以添加包:
dotnet add package PassXYZLib
更新模型
在我们将 PassXYZLib 包添加到项目后,我们可以访问 KPCLib、KeePassLib
和 PassXYZLib
命名空间。要替换当前模型,我们需要从项目中移除 Models/Item.cs
文件。
之后,我们需要将 PassXYZ.Vault.Models
命名空间替换为 KPCLib。在 图 4.8a 中,我们可以看到 PassXYZ.Vault.Models
命名空间中的 Item
被使用。
图 4.8a:更新到 KPCLib 之前的 PassXYZ.Vault.Models
在替换 PassXYZ.Vault.Models
命名空间后,在 图 4.8b 中使用了 KBCLib
命名空间下的 Item
。比较过渡前后的实现,我们可以观察到代码的其他部分基本保持不变。通过采用 MVVM 模式,大部分业务逻辑都被封装在模型层中。
图 4.8b:更新到 KPCLib
对于视图模型和视图中的剩余更改,所有修改都涉及命名空间更改,因此不需要进一步解释。
更新服务
主要更改可以在 MockDataStore.cs
中找到。在 MockDataStore
类中,我们更改了命名空间和模拟数据的初始化。
为了将模型与系统的其余部分解耦,我们使用 IDataStore
接口来封装实际的实现。在这个阶段,我们使用模拟数据来实现服务以进行测试,因此使用了 MockDataStore
类。我们将在 第六章,使用依赖注入进行软件设计 中用实际实现替换它,使用依赖注入。
依赖反转和依赖注入
在第六章“使用依赖注入的软件设计”中,我们将学习依赖倒置原则(DIP),这是 SOLID 设计原则之一。我们将学习如何使用依赖注入来管理IDataStore
接口到实际实现的映射。
在原始代码中,我们创建了PassXYZ.Vault.Models.Item
的新实例来初始化模拟数据。在替换模型后,我们不能直接创建KPCLib.Item
,因为它是一个抽象类。相反,我们可以使用 JSON 数据创建新的PxEntry
实例,并将PxEntry
实例分配给Item
列表:
Static string[] jsonData =…;
readonly List<Item> items;
public MockDataStore() {
items = new List<Item>() {
new PxEntry(jsonData[0]),
new PxEntry(jsonData[1]),
new PxEntry(jsonData[2]),
new PxEntry(jsonData[3]),
new PxEntry(jsonData[4])
};
}
要创建抽象类的实例,可以使用工厂模式。为了使测试代码简单,我们没有在这里使用它。本书后面的实际实现中使用了工厂模式。
我们现在已经用我们自己的模型替换了示例代码中的模型。随着这个变化,我们可以改进ItemsPage
和ItemDetailPage
以反映更新的模型。
我们将在下一节使用数据绑定到集合来更新视图和 ViewModel。
绑定到集合
在上一节中,我们使用 PassXYZLib 替换了模型。当我们介绍数据绑定时,我们使用了ItemDetailPage
和ItemDetailViewModel
来解释如何将源属性绑定到目标属性。
对于项目详情页面,我们创建了一个从单一源到单一目标的绑定。然而,有许多情况需要将数据集合绑定到 UI,例如ListView
或CollectionView
,以显示一组数据。
图 4.9:绑定到集合
如图 4.9 所示,当我们从集合对象到集合视图创建数据绑定时,要使用的是ItemsSource
属性。在.NET MAUI 中,可以使用ListView
和CollectionView
等集合视图,它们都有ItemsSource
属性。
对于集合对象,我们可以使用任何实现了IEnumerable
接口的集合。然而,集合对象的更改可能无法自动更新 UI。为了自动更新 UI,源对象需要实现INotifyCollectionChanged
接口。
我们可以使用INotifyCollectionChanged
接口实现我们的集合对象,但最简单的方法是使用ObservableCollection<T>
类。如果可观察集合中的任何项发生变化,绑定的 UI 视图会自动收到通知。
在此基础上,让我们回顾一下我们的模型、ViewModel和视图的类图,如图 4.9 所示:
-
模型:
Item
,PwEntry
,PwGroup
,Field
-
ViewModel:
ItemsViewModel
,ItemDetailViewModel
-
视图:
ItemsPage
,ItemDetailPage
当我们向用户显示项目列表时,用户可能会对选定的项目进行操作。如果项目是一个组,我们将在一个 ItemsPage
实例中显示组和条目。如果项目是一个条目,我们将在内容页上显示条目的内容,这是一个 ItemDetailPage
实例。在 ItemDetailPage
上,我们向用户显示字段列表。每个字段都是一个键值对,并作为 Field
类的实例实现。
总结来说,我们向用户展示两种类型的列表——项目列表或字段列表。项目列表在 ItemsPage
中显示,字段列表在 ItemDetailPage
中显示。
图 4.10:模型、视图和 ViewModel 的类图
在这个类图中,我们可以看到 PwEntry
和 PwGroup
都是从 Item
继承而来的。ItemsViewModel
中有一个项目列表,ItemDetailViewModel
中有一个字段列表。在视图中,ItemsPage
包含对 ItemsViewModel
的引用,而 ItemDetailPage
包含对 ItemDetailViewModel
的引用。
在我们完善设计之后,我们可以查看实现。我们将审查 ItemDetailViewModel
和 ItemDetailPage
的实现,以验证设计变更:
[QueryProperty(nameof(ItemId), nameof(ItemId))]
public partial class ItemDetailViewModel : BaseViewModel
{
readonly IDataStore<Item> dataStore;
ILogger<ItemDetailViewModel> logger;
public ObservableCollection<Field> Fields { get; set; } //(1)
public ItemDetailViewModel(IDataStore<Item> dataStore,
ILogger<ItemDetailViewModel> logger)
{
this.dataStore = dataStore;
this.logger = logger;
Fields = new ObservableCollection<Field>(); //(2)
}
[ObservableProperty]
private string? title;
[ObservableProperty]
private string? id;
[ObservableProperty]
private string? description;
[ObservableProperty]
private bool isBusy;
private string? itemId;
public string ItemId {
get {
if(itemId == null)
{ throw new NullReferenceException(nameof(itemId)); }
return itemId;
}
set {
itemId = value;
LoadItemId(value);
}
}
public override void OnItemSelecteion(object sender)
{
logger.LogDebug("OnItemSelecteion is invoked.");
}
public async void LoadItemId(string itemId)
{
if (itemId == null)
{ throw new ArgumentNullException(nameof(itemId)); }
var item = await dataStore.GetItemAsync(itemId);
if (item == null)
{ throw new NullReferenceException(itemId); }
Id = item.Id;
Title = item.Name;
Description = item.Description;
if (!item.IsGroup) {
PwEntry dataEntry = (PwEntry)item; //(3)
Fields.Clear();
List<Field> fields =
dataEntry.GetFields(GetImage: FieldIcons.GetImage); //(4)
foreach (Field field in fields) {
Fields.Add(field);
}
logger.LogDebug($"ItemDetailViewModel:
Name={dataEntry.Name}.");
}
}
}
如此代码所示,我们可以看到与本章开头 列表 4.1 相比,ItemDetailViewModel
的差异:
(1) 定义了一个 Fields
属性,其类型为 ObservableCollection<Field>
,用于保存字段列表。
(2) Fields
变量在 ItemDetailViewModel
的构造函数中初始化。
(3) 我们可以将 item
强制转换为 PwEntry
实例。
(4) 我们可以通过调用在 PassXYZLib 库中定义的扩展方法 GetFields
来获取字段列表。
在审查了 ItemDetailViewModel
的更改后,让我们审查 列表 4.5 中的 ItemDetailPage
的更改:
<?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="PassXYZ.Vault.Views.ItemDetailPage"
xmlns:local="clr-namespace:PassXYZ.Vault.ViewModels"
xmlns:model="clr-namespace:KPCLib;assembly=KPCLib" //(1)
x:DataType="local:ItemDetailViewModel"
Title="{Binding Title}">
<StackLayout>
<ListView
x:Name="FieldsListView"
ItemsSource="{Binding Fields}" //(2)
VerticalOptions="FillAndExpand"
HasUnevenRows="False"
RowHeight="84"
IsPullToRefreshEnabled="true"
IsRefreshing="{Binding IsBusy, Mode=OneWay}"
CachingStrategy="RetainElement"
ItemSelected="OnFieldSelected">
<ListView.ItemTemplate>
<DataTemplate> //(3)
...
</DataTemplate>
</ListView.ItemTemplate>
<ListView.Footer>
<StackLayout Padding="5" Orientation="Horizontal">
<Label
Text="{Binding Description}"
FontSize="Small"
Style="{DynamicResource ListItemDetailTextStyle}"
TextType ="Html"/>
</StackLayout>
</ListView.Footer>
</ListView>
</StackLayout>
</ContentPage>
列表 4.5: ItemDetailPage.xaml
(epa.ms/ItemDetailPage4-5
)
在 ItemDetailPage
中,我们可以看到与 第三章 中 使用 XAML 进行用户界面设计 的 列表 3.4 相比,有许多更改。ListView
用于在条目中显示字段:
(1) 在 DataTemplate
中使用 Field
时,需要添加一个 xmlns:model
命名空间。由于 Field
类位于不同的程序集,我们需要指定程序集的名称,如下所示:
xmlns:model="clr-namespace:KPCLib;assembly=KPCLib"
(2) 我们将 Fields
属性绑定到 ListView
的 ItemsSource
属性。
(3) DataTemplate
用于定义 ListView
中每个项目的外观。它在 列表 4.5 中是折叠的。
让我们展开它并审查此代码块中 DataTemplate
的实现:
<DataTemplate>
<ViewCell>
<Grid Padding="10" x:DataType="model:Field" > //(1)
<Grid.RowDefinitions...>
<Grid.ColumnDefinitions...>
<Grid Grid.RowSpan="2" Padding="10">
<Grid.ColumnDefinitions...>
<Image Grid.Column="0" Source="{Binding ImgSource}" //(2)
HorizontalOptions="Fill"
VerticalOptions="Fill" />
</Grid>
<Label Text="{Binding Key}" Grid.Column="1".../> //(3)
<Label Text="{Binding Value}" Grid.Row="1" //(4)
Grid.Column="1".../>
</Grid>
</ViewCell>
</DataTemplate>
在 DataTemplate
中,每个字段的布局定义在 ViewCell
元素中。在 ViewCell
元素中,我们定义了一个 2x2 的 Grid
布局。第一列用于显示字段图标。字段中的键和值在第二列的两个行中显示:
(1) 在Grid
布局中的x:DataType
属性被设置为Field
,Grid
中的以下数据绑定将引用Field
的属性。Field
类定义在我们的模型中,该模型位于 KPCLib 包中。
(2) 要显示字段图标,Image
控制的Source
属性被设置为Field
的ImgSource
属性。
Field
的Key
属性和Value
属性被分配给Label
控制的Text
属性。
通过这次分析,我们学习了如何为集合创建数据绑定。在ItemsPage
和ItemsViewModel
中使用的数据绑定与此实现类似。区别在于这里我们使用了一个Field
的集合,而在ItemsPage
中使用了Item
类的集合。完成更改后,我们可以在Figure 4.11中看到 UI 的改进。
图 4.11:改进后的 ItemsPage 和 ItemDetailPage
在改进的 UI 中,我们在ItemsPage
(在左侧)上显示项目列表。列表中的项目可以是条目(如 Facebook、Twitter 或 Amazon),或组,我们将在下一章中看到。
当用户点击一个项目,例如GitHub,ItemDetailPage
(在右侧)上会显示关于GitHub的详细信息。在项目详情页面上,显示了关于此账户(GitHub)的信息。
使用自定义视图
我们在Listing 4.5中实现了ViewCell
的实例,在DataTemplate
中使用。这个ViewCell
用于显示带有图标的键值对。同样的实现被用在ItemsPage
和ItemDetailPage
中,唯一的区别是数据绑定。这里我们重复了代码。为了重构实现,我们可以创建一个自定义视图(或自定义控件)。
在.NET MAUI 中,自定义视图是由开发者创建的用户界面组件,用于满足定制需求,提供可重用的 UI 逻辑,或扩展现有 UI 组件的功能。自定义视图可以通过组合现有控件,从View
、ViewCell
或ContentView
等基类派生,并重写特定方法来自定义渲染或行为。
要创建一个可以在ItemsPage
和ItemDetailPage
中重用的自定义视图,我们首先应该在Views
目录下创建一个名为Templates
的新文件夹。在 Visual Studio 中,我们可以右键点击Templates
文件夹,基于.NET MAUI ContentView(XAML)模板添加一个新项,命名为KeyValueView
:
<?xml version="1.0" encoding="utf-8" ?>
<ViewCell ...
xmlns:vm="clr-namespace:PassXYZ.Vault.ViewModels"
x:Class="PassXYZ.Vault.Views.Templates.**KeyValueView**"> //(1)
<Grid Padding="10" VerticalOptions="FillAndExpand"> //(2)
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.RowSpan="2" Padding="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
</Grid.ColumnDefinitions>
<Image x:Name=**"imageField"** Grid.Column="0" //(5)
HorizontalOptions="Fill" VerticalOptions="Fill">
<Image.Source>
...
</Image.Source>
</Image>
</Grid>
<Label x:Name=**"keyField"** ... /> //(3)
<Label x:Name=**"valueField"** ... /> //(4)
<Grid.GestureRecognizers>
<TapGestureRecognizer
NumberOfTapsRequired="1"
Command="{Binding Source=
{RelativeSource AncestorType=
{x:Type vm:BaseViewModel}},
Path=ItemSelectionChangedCommand}"
CommandParameter="{Binding .}">
</TapGestureRecognizer>
</Grid.GestureRecognizers>
</Grid>
</ViewCell>
列表 4.6:KeyValueView.xaml
(epa.ms/KeyValueView4-6
)
在Listing 4.6中我们可以看到类名是KeyValueView
(1),并且我们创建了一个 2x2 的网格 (2)。在这个网格中,有两行用于显示键 (3) 和值 (4),以及一个图标 (5)。
当我们使用 KeyValueView
时,我们可以为键、值和图标建立数据绑定。为了支持数据绑定,我们需要将键、值和图标定义为可绑定属性。让我们回顾一下 Listing 4.7 中所示的实现。
public partial class KeyValueView : ViewCell {
public KeyValueView() {
InitializeComponent();
}
public static readonly BindableProperty KeyProperty =
BindableProperty.Create(nameof(Key), typeof(string),
typeof(KeyValueView), string.Empty,
propertyChanging: (bindable, oldValue, newValue) =>
{
var control = bindable as KeyValueView;
var changingFrom = oldValue as string;
var changingTo = newValue as string;
if(control == null) {
throw new NullReferenceException(nameof(control)); }
if(changingTo == null) {
throw new NullReferenceException(nameof(changingTo));
}
control.Key = changingTo;
});
public string Key { //(1)
get { return (string)GetValue(KeyProperty); }
set {
keyField.Text = value;
SetValue(KeyProperty, value);
}
}
public static readonly BindableProperty ValueProperty =
BindableProperty.Create(nameof(Value), typeof(string),
typeof(KeyValueView), string.Empty,
propertyChanging: (bindable, oldValue, newValue) =>
{
var control = bindable as KeyValueView;
var changingFrom = oldValue as string;
var changingTo = newValue as string;
if (control == null)
{ throw new NullReferenceException(nameof(control)); }
if (changingTo == null) {
throw new NullReferenceException(nameof(changingTo));
}
control.Value = changingTo;
});
public string Value { //(2)
get { return (string)GetValue(ValueProperty); }
set {
valueField.Text = value;
SetValue(ValueProperty, value);
}
}
public static readonly BindableProperty SourceProperty =
BindableProperty.Create(nameof(Source),
typeof(ImageSource), typeof(KeyValueView), default!,
propertyChanging: (bindable, oldValue, newValue) =>
{
var control = bindable as KeyValueView;
var changingFrom = oldValue as ImageSource;
var changingTo = newValue as ImageSource;
if (control == null)
{ throw new NullReferenceException(nameof(control)); }
if (changingTo == null) {
throw new NullReferenceException(nameof(changingTo));
}
control.Source = changingTo;
});
public ImageSource Source { //(3)
get { return (ImageSource)GetValue(SourceProperty); }
set {
imageField.Source = value;
SetValue(SourceProperty, value);
}
}
}
列表 4.7: KeyValueView.xaml.cs
(epa.ms/KeyValueView4-7
)
要在我们自定义控件类中实现可绑定属性键 (1)、值 (2) 和源 (3),我们必须使用静态 BindableProperty.Create
方法定义 BindableProperty
。此方法应包括属性名称、属性类型、声明类型和默认值作为参数:
public static readonly BindableProperty **KeyProperty** =
BindableProperty.Create(nameof(Key), typeof(string),
typeof(KeyValueView), string.Empty,
propertyChanging: (bindable, oldValue, newValue) =>
{
var control = bindable as KeyValueView;
var changingFrom = oldValue as string;
var changingTo = newValue as string;
if(control == null) {
throw new NullReferenceException(nameof(control)); }
if(changingTo == null) {
throw new NullReferenceException(nameof(changingTo));
}
control.Key = changingTo;
});
之后,我们需要实现相应的属性,包括获取器和设置器。它们将通过 GetValue
和 SetValue
方法与 BindableProperty
交互:
public string Key {
get { return (string)GetValue(KeyProperty); }
set {
keyField.Text = value;
SetValue(KeyProperty, value);
}
}
我们现在已经创建了自定义视图 KeyValueView
,并能够相应地重构之前的 DataTemplate
实现。修改后的实现如下:
<DataTemplate x:DataType="model:Field">
<template:KeyValueView
Key="{Binding Key}"
Value="{Binding Value}"
Source="{Binding ImgSource}"/>
</DataTemplate>
在引入新的数据模型后,设计没有经历重大变化。我们增强了 UI,使其更具意义,但大多数复杂性仍然隐藏在我们的模型库——KPCLib 和 PassXYZLib 中。这是通过采用 MVVM 模式观察到的优势,它允许我们将模型(业务逻辑)与 UI 设计分离。
摘要
在本章中,我们了解了 MVVM 模式并将其应用于我们的应用程序开发。MVVM 模式的一个关键特性是视图和 ViewModel 之间的数据绑定。我们深入研究了数据绑定,并在我们应用程序的实现中使用了它。
为了深入了解数据绑定的复杂性,我们研究了集合绑定和自定义视图中的数据绑定利用。通过使用数据绑定和自定义视图,我们能够重构 XAML 代码,从而得到更干净、更简洁的代码库。
为了展示高级数据绑定用法,我们需要一个更复杂的模型层。在本章中,我们通过引入两个包——KPCLib 和 PassXYZLib 来增强模型。我们用这两个包中的模型替换了示例代码中的模型。随后,我们更新了 ItemsPage
和 ItemDetailPage
的 UI,以反映对模型所做的更改。
在下一章中,我们将细化我们的用户故事,并继续改进 UI,借鉴我们对 Shell 和导航的了解。
进一步阅读
-
MVVM 工具包简介:
learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/
-
KeePass 是一个免费的开源密码管理器:
keepass.info/
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第五章:使用.NET MAUI Shell 和 NavigationPage 进行导航
在前面的章节中,我们处理了用户界面(UI)设计、MVVM 模式和数据绑定。这些元素使我们能够在页面级别设计 UI。对于现实世界的应用,页面间的导航能力至关重要。因此,大多数应用框架都包括它们自己的独特导航机制,.NET MAUI 也不例外。在本章中,我们将介绍.NET MAUI 中的导航机制。我们将首先演示如何使用 NavigationPage 完成最基本的导航,然后我们将深入探讨一个更结构化的导航机制——Shell
。
随后,我们将使用Shell
增强我们应用的导航功能。在第二章,构建我们的第一个.NET MAUI 应用中,我们使用Shell
模板创建了我们的应用。尽管如此,我们的应用尚未达到多级导航所需的复杂性。通过集成Shell
,我们将执行多级导航。为了实现这一点,有必要完善我们的模型以支持导航实现。到本章结束时,我们的应用将能够支持登录、从飞出菜单中选择页面以及切换到项目详情或导航到子组。在深入导航设计之后,我们将了解导航在.NET MAUI 中的工作方式。
本章将涵盖以下主题:
-
实现导航
-
使用
Shell
-
改进设计和导航
技术要求
要测试和调试本章的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。有关详细信息,请参阅第一章,使用.NET MAUI 入门中的开发环境设置部分。
本章的源代码可在以下 GitHub 分支中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter05
。
要检查本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter05 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书源代码的信息,请参阅第二章,构建我们的第一个.NET MAUI 应用中的管理本书的源代码部分。
实现导航
在本章中,我们将实现密码管理器应用的导航逻辑。这包括以下功能:
-
登录并连接到数据库
-
在密码数据库中探索数据
导航设计对用户体验有重大影响。在 .NET MAUI 中,有一个内置机制可以帮助开发者高效地实现导航。正如我们在前面的章节中看到的,我们可以在我们的应用程序中使用 Shell
。在本章中,我们将学习 Shell
并使用 Shell
提供的功能增强我们的应用程序。在我们深入 Shell
之前,我们将探索 .NET MAUI 中的基本导航机制。
实现导航最常见的方式是层次结构和模态:
-
层次结构导航提供了一种用户可以向前和向后导航页面的导航体验。这种模式通常在屏幕顶部使用工具栏或导航栏来显示左上角的上一个或返回按钮。它通常维护一个后进先出(LIFO)的页面堆栈来处理导航。LIFO 代表 last in, first out,意味着最后进入的页面是第一个弹出的页面。
-
模态导航在用户如何响应它方面与层次结构导航不同。如果屏幕上显示模态页面,用户必须在完成或取消所需任务之前才能执行其他操作。在完成或取消所需任务之前,用户不能从模态页面导航离开。
INavigation 接口和 NavigationPage
在 .NET MAUI 中,通过 INavigation
接口支持层次结构和模型导航。INavigation
接口由一个称为 NavigationPage
的特殊页面支持。NavigationPage
用于管理其他页面的导航堆栈。NavigationPage
的继承层次结构如下所示:
- 对象 | 可绑定对象 | 元素 | 可导航元素 | 可视元素 | 页面 | NavigationPage
NavigableElement
定义了一个名为 Navigation
的属性,该属性实现了 INavigation
接口。这个继承属性可以从任何 VisualElement
或 Page
用于导航目的,如下所示:
public Microsoft.Maui.Controls.INavigation Navigation { get; }
要使用 NavigationPage
,我们必须将第一个页面添加到导航堆栈中,作为应用程序的根页面。我们可以在下面的代码片段中看到一个例子:
public partial class App : Application
{
...
public App ()
{
InitializeComponent();
MainPage = new NavigationPage (new TheFirstPage());
}
...
}
我们在 App
类的构造函数中构建导航堆栈,App
类是 Application
的派生类。TheFirstPage
是 ContentPage
的派生类,被推入导航堆栈。
使用导航堆栈
有两种方式可以导航到或从页面。当我们想要浏览新页面时,我们可以将新页面添加到导航堆栈中。这个动作称为 推。如果我们想回到上一个页面,我们可以从堆栈中 弹 出上一个页面:
图 5.1:推和弹
如 图 5.1 所示,我们可以使用 INavigation
接口中的 PushAsync()
或 PopAsync()
方法分别切换到新页面或返回到上一个页面。
如果我们处于 Page1 页面,我们可以通过 GotoPage2()
事件处理器切换到 Page2。在这个函数中,我们将新页面 Page2 推送到堆栈:
async void GotoPage2 (object sender, EventArgs e) {
await Navigation.PushAsync(new Page2());
}
一旦我们处于 Page2 页面,我们可以通过 BackToPage1()
事件处理器返回。在这个函数中,我们从堆栈中弹出前一页:
async void BackToPage1 (object sender, EventArgs e) {
await Navigation.PopAsync();
}
在前面的例子中,我们使用分层导航方法导航到一个新页面。要显示模态页面,我们可以使用模态堆栈。例如,在我们的应用程序中,如果我们想在 ItemsPage
中创建一个新项目,我们可以在 ItemsViewModel
中调用 PushModalAsync()
:
await Shell.Current.Navigation.PushModalAsync(NewItemPage(type));
在创建新项目后,我们可以在 NewItemViewModel
中调用 PopModalAsync()
来关闭模态页面:
_ = await Shell.Current.Navigation.PopModalAsync();
在 NewItemPage
模型页面中,在完成任务或取消任务之前,我们无法导航到其他页面。PopAsync()
和 PopModalAsync()
都返回一个 Task<Page>
类型的可等待任务。
要获取有关 NavigationPage
的更多信息,请参阅以下 Microsoft 文档:
learn.microsoft.com/en-us/dotnet/maui/user-interface/pages/navigationpage
操作导航堆栈
在分层导航中,我们不仅可以从堆栈中推送或弹出页面,还可以操作导航堆栈。
插入页面
我们可以使用 InsertPageBefore
方法将页面插入到堆栈中:
public void InsertPageBefore (Page page, Page before);
InsertPageBefore
方法需要两个参数:
-
page
: 这是将要添加的页面。 -
before
: 这是插入页面之前的那一页。
在 图 5.1 中,当我们处于 Page2 页面时,我们可以在它之前插入另一个页面,Page1:
Navigation.InsertPageBefore(new Page1(), this);
移除页面
我们还可以使用 RemovePage()
方法从堆栈中移除特定的页面:
public void RemovePage (Page page);
在 图 5.1 中,假设我们在 Page3 页面时有一个 Page2 的引用,我们可以从堆栈中移除 Page2。调用 PopAsync()
后,我们将返回到 Page1:
// the reference page2 is an instance of Page2
Navigation.RemovePage(page2);
await Navigation.PopAsync();
总结来说,我们学习了如何使用 NavigationPage
构建导航堆栈。在创建导航堆栈后,我们可以利用 INavigation
接口来执行导航操作。对于简单的应用程序,这种方法可能足够。然而,对于更复杂的应用程序,这种方法可能需要大量的工作。幸运的是,.NET MAUI 提供了一个结构化的替代方案,称为 Shell
。Shell
通过提供用于定义飞出菜单、标签栏和其他导航 UI 的统一、声明性语法来设计,以改善应用程序的导航结构。利用 Shell
可以让我们以更少的努力为用户提供增强的导航体验。
使用 Shell
INavigation
接口和 NavigationPage
提供基本的导航功能。仅依赖它们将需要我们自行创建复杂的导航机制。幸运的是,.NET MAUI 提供了内置的页面模板供选择,可以提供各种导航体验。
如 图 5.2 中的类图所示,根据不同的用例提供了内置的页面。所有这些页面 – TabbedPage
、ContentPage
、FlyoutPage
、NavigationPage
和 Shell
– 都是 Page
的派生类:
图 5.2:.NET MAUI 内置页面的类图
ContentPage
、TabbedPage
和 FlyoutPage
可以根据您的需求创建各种 UI:
-
ContentPage
是最常用的页面类型,可以包含任何布局和视图元素。它适用于单页设计。 -
TabbedPage
可以用来托管多个页面。每个子页面可以通过位于页面顶部或底部的标签系列进行选择。 -
FlyoutPage
可以显示项目列表,类似于桌面应用程序中的菜单项。用户可以通过菜单中的项目导航到单个页面。
虽然 Shell
也是 Page
的派生类,但它包括一个通用的导航用户体验,这简化了开发者的任务。Shell
通过减少应用程序开发的复杂性并集中高度可定制的丰富功能在一个位置来帮助开发者。
Shell
提供以下功能:
-
一个地方来描述应用程序的视觉层次结构
-
一个高度可定制的通用导航用户体验
-
一种基于 URI 的导航方案,与我们网页浏览器中的非常相似
-
集成搜索处理程序
Shell
的顶级构建块是弹出菜单和标签。我们可以使用弹出菜单和标签来创建我们应用程序的导航结构。
弹出菜单
弹出菜单可以用作 Shell
应用程序的最高级菜单。在我们的应用程序中,我们必须同时使用弹出菜单和标签来创建最高级导航设计。在本节中,我们将探讨弹出菜单;在下一节中,我们将讨论在我们的应用程序中使用标签。
在 图 5.3 中,我们可以看到在我们的应用程序中弹出菜单的外观。从弹出菜单中,我们可以切换到 AboutPage
、ItemsPage
或 LoginPage
。要访问弹出菜单,我们可以从屏幕左侧边缘滑动或点击弹出图标,即汉堡图标 (1)。当我们点击弹出菜单中的 根组 (2) 时,我们将看到密码条目或组列表。
图 5.3:弹出菜单
弹出菜单由弹出项或菜单项组成。在 图 5.3 中,关于 和 根 组 是弹出项,而 注销 是菜单项。
弹出菜单项
每个弹出菜单项都是一个 FlyoutItem
对象,它包含一个 ShellContent
对象。我们可以在 AppShell.xaml
文件中这样定义弹出菜单项。我们将一个 string
资源分配给 Title
属性 (1) 并将 ImageSource
分配给 Icon
属性 (2)。这些对应于 FlyoutItem
类的属性:
<FlyoutItem
Title="{x:Static resources:Resources.About}" //(1)
Icon="tab_info.png" > //(2)
<Tab>
<ShellContent Route="AboutPage" ContentTemplate=
"{DataTemplate local:AboutPage}" />
</Tab>
</FlyoutItem>
<FlyoutItem x:Name="RootItem" Title="Browse"
Icon="tab_home.png">
<Tab>
<ShellContent Route="RootPage" ContentTemplate=
"{DataTemplate local:ItemsPage}" />
</Tab>
</FlyoutItem>
Shell
有隐式转换运算符,可以用来移除 FlyoutItem
和 Tab
对象,这样前面的 XAML 代码也可以简化,如下所示:
<ShellContent Title="{x:Static resources:Resources.About}"
Icon="tab_info.png" Route="AboutPage"
ContentTemplate="{DataTemplate local:AboutPage}" />
<ShellContent x:Name="RootItem" Title="Browse"
Icon="tab_home.png" Route="RootPage"
ContentTemplate="{DataTemplate local:ItemsPage}" />
菜单项
在某些情况下,使用弹出菜单项导航到内容页面可能不是必要的;相反,我们可能希望执行一个动作。在这种情况下,可以使用菜单项。对于我们的场景,我们已将Logout
指定为执行动作的菜单项,而不是导航到另一个内容页面:
<MenuItem Text="Logout" IconImageSource="tab_login.png"
Clicked="OnMenuItemClicked">
</MenuItem>
从前面的 XAML 代码中我们可以看到,每个菜单项都是一个MenuItem
对象。MenuItem
类有一个Clicked
事件和一个Command
属性。当MenuItem
被点击时,我们可以执行一个动作。在前面的菜单项中,我们将OnMenuItemClicked
作为事件处理程序。
让我们更仔细地看看清单 5.1中所示的AppShell.xaml
。在这个文件中,我们定义了两个弹出菜单项和一个菜单项。弹出菜单项允许我们选择AboutPage
(1) 和ItemsPage
(2),而菜单项允许我们注销 (3)。
<Shell
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
...
Title="PassXYZ.Vault"
x:Class="PassXYZ.Vault.AppShell"
FlyoutIcon="{FontImage FontFamily=FontAwesomeSolid,
Color=White,
Glyph={x:Static style:FontAwesomeSolid.Bars}}"
FlyoutBackgroundColor="{StaticResource Secondary}">
<TabBar> //(4)
<ShellContent Title="Login" Route="LoginPage"
Icon="{FontImage FontFamily=FontAwesomeSolid,
Color=Black,
Glyph={x:Static style:FontAwesomeSolid.UserAlt}}"
ContentTemplate="{DataTemplate local:LoginPage}" />
<ShellContent Title="SignUp" Route="SignUpPage"
Icon="{FontImage FontFamily=FontAwesomeSolid,
Color=Black,
Glyph={x:Static style:FontAwesomeSolid.Users}}"
ContentTemplate="{DataTemplate local:SignUpPage}" />
</TabBar>
<FlyoutItem Title="About" //(1)
Icon="{FontImage FontFamily=FontAwesomeSolid, Color=Black,
Glyph={x:Static style:FontAwesomeSolid.Question}}">
<ShellContent Route="AboutPage"
ContentTemplate="{DataTemplate local:AboutPage}">
</ShellContent>
</FlyoutItem>
<FlyoutItem x:Name="RootItem" Title="Browse" //(2)
Icon="{FontImage FontFamily=FontAwesomeSolid, Color=Black,
Glyph={x:Static style:FontAwesomeSolid.Home}}">
<ShellContent Route="RootPage"
ContentTemplate="{DataTemplate local:ItemsPage}">
</ShellContent>
</FlyoutItem>
<MenuItem Text="Logout" Clicked="OnMenuItemClicked" //(3)
IconImageSource="{FontImage FontFamily=FontAwesomeSolid,
Color=Black,
Glyph={x:Static style:FontAwesomeSolid.SignOutAlt}}">
</MenuItem>
</Shell>
清单 5.1:PassXYZ.Vault 中的AppShell.xaml
(epa.ms/AppShell5-1
)
还为LoginPage
和SignUpPage
定义了一个TabBar
(4)。现在让我们来回顾一下标签。
标签
在Shell
中使用标签可以创建类似于TabbedPage
的导航体验。如图图 5.4所示,Android 和 iOS 平台在底部的标签栏上都有两个标签。然而,当在 Windows 平台上实现时,标签的显示方式会有所不同。
图 5.4:Android 上的标签栏和标签
如图 5.5所示,在 Windows 上,标签栏位于顶部:
图 5.5:Windows 上的标签栏和标签
为了将标签页集成到我们的应用中,我们需要创建一个TabBar
对象。这个TabBar
对象可以包含一个或多个Tab
对象,每个Tab
对象代表标签栏上的一个单独的标签。此外,每个Tab
对象可以包含一个或多个ShellContent
对象。接下来的 XAML 代码展示了它与定义弹出菜单时使用的代码的相似性:
<TabBar>
<Tab Title="{x:Static resources:Resources.action_id_login}"
Icon="tab_login.png">
<ShellContent Route="LoginPage"
ContentTemplate="{DataTemplate local:LoginPage}" />
</Tab>
<Tab Title="{x:Static resources:Resources.menu_id_users}"
Icon="tab_users.png">
<ShellContent Route="SignUpPage"
ContentTemplate="{DataTemplate local:SignUpPage}" />
</Tab>
</TabBar>
与我们在弹出菜单 XAML 代码中采取的方法类似,我们可以通过删除Tab
标签来简化之前的代码。通过使用Shell
的隐式转换运算符,我们可以删除Tab
对象。如所示,我们可以省略Tab
标签,并在ShellContent
标签内直接定义Title
和Icon
属性:
<TabBar>
<ShellContent Title="{x:Static resources:Resources.action_id_login}"
Icon="tab_login.png"
Route="LoginPage"
ContentTemplate="{DataTemplate local:LoginPage}" />
<ShellContent Title="{x:Static resources:Resources.menu_id_users}"
Icon="tab_users.png"
Route="SignUpPage"
ContentTemplate="{DataTemplate local:SignUpPage}" />
</TabBar>
在我们同时在AppShell.xaml
中定义TabBar
对象和FlyoutItem
对象的情况下,TabBar
对象将禁用弹出菜单项。这就是为什么在我们启动应用时,我们看到的是一个显示登录或注册页面的标签页界面。一旦用户成功登录,我们可以将他们导航到RootPage
,这是在清单 5.1中展示的已注册路由。在下一节中,我们将深入了解注册路由和使用这些已注册路由进行导航的过程。
Shell 导航
在Shell
中,通过注册的路由来实现页面导航。类似于网页浏览器,.NET MAUI 使用基于 URI 的导航。URI 可能看起来像以下这样:
//RootPage/ItemDetailPage?ID="your entry ID"
或者,它可能看起来像以下这样:
Group1/ItemDetailPage1
如您所见,URI 格式允许我们指定应用程序界面中的路径,并且可能包括额外的参数。以双斜杠“//”开始 URI 表示导航的根。就像在文件系统导航中一样,我们也可以使用“..”来执行向后导航。这样,程序员可以以直观且高效的方式导航导航堆栈。
注册路由有两种方法。第一种方法是在 Shell 的可视层次结构内注册路由。第二种方法需要通过使用Routing
类中找到的RegisterRoute
静态方法显式地注册它们。
注册绝对路由
我们可以选择在 Shell 的视觉层次结构中注册路由,如列表 5.1所示。路由可以通过FlyoutItem
、TabBar
、Tab
或ShellContent
的Route
属性来指定。
在AppShell.xaml
文件中,我们注册了以下路由。
路由 | 页面 | 描述 |
---|---|---|
LoginPage |
LoginPage |
此路由显示用户登录页面 |
SignUpPage |
SignUpPage |
此路由显示用户注册页面 |
AboutPage |
AboutPage |
此路由显示有关我们应用程序的页面 |
RootPage |
ItemsPage |
此路由显示用于导航密码数据库的页面 |
表 5.1:视觉层次结构中注册的路由
要在 Shell 的可视层次结构中导航到某个路由,我们可以使用绝对路由 URI。绝对 URI 以双斜杠“//”开头,例如//LoginPage
。
注册相对路由
也可以在不预先在视觉层次结构中定义的情况下导航到页面。例如,密码输入详细页面ItemDetailPage
可以在密码组的任何层次结构级别导航到。在我们的应用程序中,我们可以通过在代码后文件AppShell.xaml.cs
中使用RegisterRoute
显式注册以下路由:
public static AppShell? CurrentAppShell
{ get; private set; } = default!;
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(ItemDetailPage),
typeof(ItemDetailPage));
Routing.RegisterRoute(nameof(NewItemPage),
typeof(NewItemPage));
Routing.RegisterRoute(nameof(ItemsPage),
typeof(ItemsPage));
CurrentAppShell = this;
}
在前面的代码中,我们定义了以下路由。
路由 | 页面 | 描述 |
---|---|---|
ItemDetailPage |
ItemDetailPage |
这是显示密码条目详细信息的路由 |
NewItemPage |
NewItemPage |
这是添加新项目(条目或组)的路由 |
ItemsPage |
ItemsPage |
这是显示用于导航密码数据库的页面的路由 |
表 5.2:注册的详细页面路由
为了说明相对路由的使用,我们将通过添加一个新项目来继续。当需要添加新项目时,我们可以使用相对路由导航到NewItemPage
,如下所示:
await Shell.Current.GoToAsync(nameof(NewItemPage));
在此场景中,搜索NewItemPage
路由,如果识别到该路由,页面将被显示并添加到导航堆栈中。这个导航堆栈与我们解释基本导航时使用的INavigation
接口所讨论的是相同的。当定义一个相对路由并导航到它时,我们传递一个字符串作为路由的名称。为了防止错误,我们可以通过使用nameof
表达式来使用类名作为路由名称。
一旦我们在NewItemPage
中输入了新项目的详细信息,我们可以点击保存或取消按钮。在保存或取消按钮的事件处理程序中,我们可以使用提供的代码返回到上一页:
await Shell.Current.Navigation.PopModalAsync();
或者,我们也可以使用以下代码返回:
await Shell.Current.GoToAsync("..");
就像在文件系统导航中一样,这里的“..”代表导航堆栈中的父页面。
如前述代码所示,有两种返回的方法。第一种选项是使用INavigation
接口的PopModalAsync
方法。由于Shell
是Page
的派生类,它通过继承的Navigation
属性实现了INavigation
接口。我们可以调用PopModalAsync
模态导航方法来返回,其中NewItemPage
作为模态页面。
第二种方法涉及使用GoToAsync
函数返回。由于NewItemPage
是一个模态页面,你可能想知道在调用GoToAsync
时如何区分模态页面和非模态页面。在Shell
导航中,这种区分由页面展示模式确定。NewItemPage
的内容页面定义如下:
<?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="PassXYZ.Vault.Views.NewItemPage"
**Shell.PresentationMode**="ModalAnimated" //(1)
Title="New Item">
<ContentPage.Content...>
</ContentPage >
如观察所示,Shell.PresentationMode
(1) 属性是在内容页面中定义的。根据我们实现动画的偏好,我们可以为这个属性分配不同的值。对于标准内容页面,我们可以将其设置为NotAnimated
或Animated
。对于模态页面,选项是Modal
、ModalAnimated
或ModalNotAnimated
。如果保持不变,默认值设置为Animated
。
要返回上一级,使用GoToAsync
方法并指定路由为“..”。这种方法让人联想到文件系统导航或浏览器 URL 导航。相对路由“..”表示返回到父路由。它也可以与一个路由结合使用,以访问父级页面,如下所示:
await Shell.Current.GoToAsync("../AboutPage");
在表 5.1和表 5.2中,你会注意到ItemsPage
既注册为绝对路由RootPage
,也注册为相对路由ItemsPage
。重要的是要注意,ItemsPage
可能包含不同级别的密码组。当位于顶级时,它作为绝对路由,而在所有后续的导航层次级别中,它作为相对路由。
向页面传递数据
为了阐述将 ItemsPage
注册为绝对和相对路由背后的原因,让我们检查我们应用的导航层次结构,如图 5.6 所示。
图 5.6:导航层次结构
在我们的应用中,一旦用户成功登录,主页将展示位于密码数据库顶层的一组条目和组,被称为根组。这类似于文件系统的导航结构,在根目录下显示顶级文件和文件夹。
第一个 ItemsPage
实例使用 RootPage
路由,可以通过飞出项访问。假设根组内部有名为 Group1 和 Group2 的子组,如图 5.6 所示。我们可以导航到这些子组,这些子组也代表 ItemsPage
的实例。由于这些实例使用相对路由并依赖于按需推入的导航堆栈,因此它们不能预先定义。这些导航堆栈可以延伸到密码数据库中实际数据的深度。
ItemsPage
的两个不同路由在 AppShell.xaml
和 AppShell.xaml.cs
中定义如下:
RootPage
路由(绝对路由):
<FlyoutItem x:Name="RootItem" Title="Browse"
Icon="{FontImage FontFamily=FontAwesomeSolid,
Color=Black,
Glyph={x:Static style:FontAwesomeSolid.Home}}">
<ShellContent Route="RootPage"
ContentTemplate="{DataTemplate local:ItemsPage}">
</ShellContent>
</FlyoutItem>
ItemsPage
路由(相对路由):
Routing.RegisterRoute(nameof(ItemsPage),
typeof(ItemsPage));
在本节中,你可能想知道如何从根组导航到 Group1 或 Group2。如果 ItemsPage
能够显示 Group1 或 Group2 的内容,我们如何通知 ItemsPage
需要显示哪个组?
在 Shell
导航中,可以使用查询参数将数据传输到内容页面。语法类似于在网页浏览器中传递的 URL 参数。例如,以下 URL 可以用于在 Google 上搜索 .net:www.google.com.hk/search?q=.net.
通过在路由后附加一个问号(?
),以及一对查询参数 ID 和它们的相应值,可以实现所需的结果。在上面的例子中,键是 q
,值是 .net
。
在根组列表中选择一个项目后,它可以是条目或组。点击事件激活 ItemsViewModel
中的 OnItemSelection
方法,如 Listing 5.2 所示:
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using KPCLib;
using PassXYZ.Vault.Services;
using PassXYZ.Vault.Views;
namespace PassXYZ.Vault.ViewModels {
[QueryProperty(nameof(ItemId), nameof(ItemId))] //(1)
public partial class ItemsViewModel : BaseViewModel {
readonly IDataStore<Item> dataStore;
ILogger<ItemsViewModel> logger;
public ObservableCollection<Item> Items { get; }
public ItemsViewModel(IDataStore<Item> dataStore,
ILogger<ItemsViewModel> logger) {
this.dataStore = dataStore;
this.logger = logger;
Title = "Browse";
Items = new ObservableCollection<Item>();
IsBusy = false;
}
[ObservableProperty]
private Item? selectedItem = default;
[ObservableProperty]
private string? title;
[ObservableProperty]
private bool isBusy;
[RelayCommand]
private async Task AddItem(object obj) {
await Shell.Current.GoToAsync(nameof(NewItemPage));
}
public override async void OnItemSelecteion(object sender) {
Item? item = sender as Item;
if (item == null)
{
logger.LogWarning("item is null.");
return;
}
logger.LogDebug($"Selected item is {item.Name}");
if (item.IsGroup)
{
await Shell.Current.GoToAsync( //(3)
$"{nameof(ItemsPage)}?
{nameof(ItemsViewModel.ItemId)}={item.Id}");
}
else
{
await Shell.Current.GoToAsync( //(4)
$"{nameof(ItemDetailPage)}?
{nameof(ItemDetailViewModel.ItemId)}={item.Id}");
}
}
[RelayCommand]
private async Task LoadItems() {
try {
Items.Clear();
var items = await dataStore.GetItemsAsync(true);
foreach (var item in items) {
Items.Add(item);
}
logger.LogDebug($"IsBusy={IsBusy},
added {Items.Count()} items");
}
catch (Exception ex) {
logger.LogError("{ex}", ex);
}
finally {
IsBusy = false;
logger.LogDebug("Set IsBusy to false");
}
}
public string ItemId { //(2)
get {
return SelectedItem == null ?
string.Empty : SelectedItem.Id;
}
set {
if (string.IsNullOrEmpty(value))
{
SelectedItem = null;
}
else {
var item = dataStore.GetItem(value);
if (item != null) {
SelectedItem = item;
}
else {
throw new ArgumentNullException(nameof(ItemId),
"cannot find the selected item");
}
}
}
}
public void OnAppearing() {
if (SelectedItem == null) {
Title = dataStore.SetCurrentGroup();
}
else {
Title = dataStore.SetCurrentGroup(SelectedItem);
}
// load items
logger.LogDebug($"Loading group {Title}");
IsBusy = true;
}
}
}
列表 5.2:ItemsViewModel.cs
(epa.ms/ItemsViewModel5-2
)
根据项目类型,我们可能导航到 ItemsPage
(3)或 ItemDetailPage
(4)。在这两种情况下,我们将项目的 Id
传递给 ItemId
查询参数,该参数在 ItemsViewModel
和 ItemDetailViewModel
中定义。
在 Listing 5.2 的上下文中,(1)ItemId
在 ItemsViewModel
中被设置为 QueryPropertyAttribute
。QueryPropertyAttribute
的第一个参数对应于接收数据的属性名称,在这个例子中是 ItemId
(2)。
第二个参数对应于id
参数。在选择列表中的一个组后,视图模型的OnItemSelected
方法(3)被触发,并将所选组的Id
作为ItemId
查询参数的值传递。
当ItemsPage
与ItemId
查询参数一起加载时,ItemId
属性(2)被设置。在ItemId
属性的设置器中,我们检查查询参数值是否为空。如果为空,这可能意味着我们初始导航到没有查询参数的RootPage
路由。在这种情况下,我们只需将SelectedItem
设置为null
。
如果它不为空,我们将找到项目并将其设置为SelectedItem
。
(4)如果我们从列表中选择一个条目,我们可以导航到带有项目Id
作为查询参数值的ItemDetailPage
。为了适应这个查询参数,我们可以修改ItemDetailViewModel
的ItemId
属性如下:
[QueryProperty(nameof(ItemId), nameof(ItemId))]
public partial class ItemDetailViewModel : BaseViewModel
{
readonly IDataStore<Item> dataStore;
ILogger<ItemDetailViewModel> logger;
public ObservableCollection<Field> Fields { get; set; }
public ItemDetailViewModel(IDataStore<Item> dataStore,
ILogger<ItemDetailViewModel> logger) {
this.dataStore = dataStore;
this.logger = logger;
Fields = new ObservableCollection<Field>();
}
[ObservableProperty]
private string? title;
[ObservableProperty]
private string? id;
[ObservableProperty]
private string? description;
[ObservableProperty]
private bool isBusy;
private string? itemId;
public string ItemId { //(1)
get {
if(itemId == null) {
throw new NullReferenceException(nameof(itemId));
}
return itemId;
}
set {
itemId = value;
LoadItemId(value); //(2)
}
}
public override void OnItemSelecteion(object sender) {
logger.LogDebug("OnItemSelecteion is invoked.");
}
public void LoadItemId(string itemId) {
if (itemId == null) {
throw new ArgumentNullException(nameof(itemId)); }
var item = dataStore.GetItem(itemId); //(3)
if (item == null) {
throw new NullReferenceException(itemId); }
Id = item.Id;
Title = item.Name;
Description = item.Description;
if (!item.IsGroup)
{
PwEntry dataEntry = (PwEntry)item; //(4)
Fields.Clear();
List<Field> fields = dataEntry.GetFields(
GetImage: FieldIcons.GetImage); //(5)
foreach (Field field in fields) {
Fields.Add(field);
}
logger.LogDebug($"ItemDetailViewModel:
Name={dataEntry.Name}.");
}
}
}
在ItemDetailViewModel
类中,我们实现以下逻辑:
-
ItemId
(1)作为接受查询参数的属性。 -
当设置
ItemId
时,调用LoadItemId
方法(2)来加载项目。 -
在
LoadItemId
中,数据服务方法GetItem
(3)被调用,以使用其相应的Id
获取项目。 -
在这种情况下,项目是一个
PwEntry
实例(4),可以相应地进行转换。 -
PassXYZLib 扩展方法
GetFields
(5)被用来更新字段列表。
在前两个章节中,我们了解了基本导航和Shell
导航,并使用Shell
增强了我们的导航设计。此时,重新审视 MVVM 模式并进一步细化我们的数据模型,以改善密码管理器应用的质量至关重要。
改进我们的应用
在第四章,探索 MVVM 和数据绑定中,我们分析了各种用例并开发了一些。在本节中,利用我们所获得的知识,我们将增强现有的用例并引入新的用例。
我们将致力于以下用例:
- 用例 1:作为密码管理器用户,我想登录到密码管理器应用,以便我可以访问我的密码数据。
在这个用例中,我们尚未完全实现用户登录;我们计划在下一章中完成这项工作。目前,我们将实现一个伪逻辑,涵盖所有方面,除了数据层。
在之前的第四章,探索 MVVM 和数据绑定中,我们介绍了一个支持一级导航的用例。
- 用例 3:作为密码管理器用户,我想看到一组和条目的列表,以便我可以探索我的密码数据。
为了适应多级导航,我们将在本节中实现以下用例:
-
用例 6:作为密码管理器用户,当我点击当前列表中的一个组时,我想看到属于该组的组和条目。
-
用例 7:作为密码管理器用户,当我在密码数据中导航时,我希望能够返回到上一个组或父组。
在之前的实现(用例 1)中,登录后,我们使用绝对路径实现了对根页面的导航。然而,LoginService
的实现尚未完成。我们需要在本章中实现它。
在加载根页面(用例 3)后,目前我们只能浏览根级别的条目和组。我们还没有从根级别进入子组的能力。为了解决这个限制,我们在本章中引入了用例 6 和 7。在用例 6 和 7 中,我们的目标是使用相对路径进行前后导航。
使用 MVVM 模式,我们通过服务访问我们的模型,这些服务通常被抽象为接口,与实际实现分离。IDataStore
接口就是这样一个例子。为了支持用例 6 并增强用例 1,我们需要开发一个新的接口,称为 IUserService
,以支持用户登录。
理解改进的设计
为了理解服务和增强的模型,让我们回顾 图 5.7 中的增强设计:
图 5.7:MVVM 中的模型和服务
图 5.7 展示了一个类图,说明了我们设计的大部分内容。为了简化,我排除了某些元素。例如,你可以自己将 NewItemPage
或 SignUpPage
添加到 图 5.7 和 表 5.3 中。
模型 | 视图 | 视图模型 |
---|---|---|
数据模型 | 服务 | |
User |
IUserService |
LoginPage |
Field |
ItemDetailPage |
ItemDetailViewModel |
Field |
ItemDetailPage |
ItemDetailViewModel |
表 5.3:MVVM 模式中的类和接口
注册绝对路径和相对路径
在本节中,当介绍页面和路径时,你可能注意到页面和路径的名称可能是相同的。为了区分它们,我将使用粗体字体来标识页面,使用斜体字体来标识路径。
在我们的应用中,AppShell
类内部注册了绝对路径和相对路径。绝对路径,如 LoginPage
、SignUpPage
、RootPage
和 AboutPage
,作为 Shell
的部分创建。相反,相对路径——ItemsPage
、ItemDetailPage
和 NewItemPage
——在 AppShell
的构造函数中定义。
为了便于多级导航,页面 ItemsPage
被注册为绝对路径 RootPage
和相对路径 ItemsPage
。
在应用程序加载时,使用LoginPage路由。登录后,应用导航到RootPage。如果用户选择子组,将加载相关的ItemsPage路由,从而允许在多级结构中进行更深入的导航。当用户选择条目时,将加载相关的ItemsDetailPage路由。
模型和服务
为了保存应用程序数据,我们通常将其存储在数据库中,这可以是关系型数据库或 NoSQL 数据库。在我们的案例中,我们的密码数据库不是关系型数据库。然而,在我们设计的过程中,我们可以使用关系型数据库的类似逻辑来制定我们的业务逻辑。我们的模型由三个不同的类表示——User
、Item
和Field
。
在我们的设计背景下,Item
和Field
分别代表密码条目和该条目中的内容。一个条目可以想象成表格中的一行,字段作为单元格。为了模拟密码条目,我们使用 KeePassLib 中的PwEntry
。在这个例子中,组指的是条目的集合,PwGroup
用于模拟这个组。组可以比作数据库中的表,而共享相同键值的字段可以比作列。为了开发我们的数据服务接口,我们可以在数据库中处理数据时采用类似的方法。
我们如何在数据库中管理数据?你可能熟悉 CRUD 操作这个术语。在我们的情况下,我们可以使用增强的创建
、读取
、更新
、删除
和列表
(CRUDL)操作来定义我们服务的接口。
为了处理密码条目和组,我们可以使用以下IDataStore
接口:
public interface IDataStore<T>
{
T? GetItem(string id, bool SearchRecursive = false);
Task<T?> GetItemAsync(string id, bool SearchRecursive =
false);
Task AddItemAsync(T item);
Task UpdateItemAsync(T item);
Task<bool> DeleteItemAsync(string id);
Task<IEnumerable<T>> GetItemsAsync(bool forceRefresh =
false);
}
在IDataStore
接口中,我们定义了以下 CRUDL 操作:
-
创建:我们使用
AddItemAsync
来添加条目或组 -
读取:我们使用
GetItem
或GetItemAsync
来读取条目或组 -
更新:我们使用
UpdateItemAsync
来更新条目或组 -
删除:我们使用
DeleteItemAsync
来删除条目或组 -
列表:我们使用
GetItemsAsync
来获取当前组中的条目和组列表
为了管理用户,我们可以使用以下IUserService
接口:
public interface IUserService<T>
{
T GetUser(string username);
Task AddUserAsync(T user);
Task DeleteUserAsync(T user);
List<string> GetUsersList();
Task<bool> LoginAsync(T user);
void Logout();
}
我们可以定义一组 CRUDL 操作来处理用户:
-
创建:我们可以使用
AddUserAsync
创建新用户 -
读取:我们可以使用
GetUser
获取用户信息 -
删除:我们可以使用
DeleteUserAsync
删除用户 -
列表:我们可以使用
GetUsersList
获取用户列表 -
登录和登出:我们可以使用
User
实例来登录或登出
IDataStore into ItemsViewModel using constructor injection as an example:
readonly IDataStore<Item> dataStore;
ILogger<ItemsViewModel> logger;
public ObservableCollection<Item> Items { get; }
public ItemsViewModel(IDataStore<Item> dataStore, ILogger<ItemsViewModel> logger)
{
this.dataStore = dataStore;
this.logger = logger;
Title = "Browse";
Items = new ObservableCollection<Item>();
IsBusy = false;
}
在ItemsViewModel
类的构造函数中,我们通过依赖注入初始化了IDataStore
和ILogger
服务。
IUserService
的实现
在我们的应用程序中,UserService
类是 IUserService
接口的实现。这个代码可以在 列表 5.3 中找到。为了简化测试过程,我们没有在本章实现所有功能,因为我们继续使用模拟数据存储:
using KPCLib;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using User = PassXYZLib.User;
namespace PassXYZ.Vault.Services;
public class UserService : IUserService<User>
{
readonly IDataStore<Item> dataStore;
ILogger<UserService> logger;
private User? _user = default;
public UserService(IDataStore<Item> dataStore, //(1)
ILogger<UserService> logger) {
this.dataStore = dataStore;
this.logger = logger;
}
public User GetUser(string username) {
User user = new User();
user.Username = username;
logger.LogDebug($"Path={user.Path}");
return user;
}
public async Task DeleteUserAsync(User user) {
await Task.Run(() => {
logger.LogDebug($"Remove Path={user.Path}");
});
}
public List<string> GetUsersList() {
return User.GetUsersList();
}
public async Task AddUserAsync(User user) {
if (user == null) {
throw new ArgumentNullException(nameof(user), "User cannot be
null"); }
_user = user;
await dataStore.SignUpAsync(user);
}
public async Task<bool> LoginAsync(User user) {
if (user == null) {
throw new ArgumentNullException(nameof(user), "User cannot be
null"); }
_user = user;
return await dataStore.ConnectAsync(user);
}
public void Logout() {
dataStore.Close();
logger.LogDebug("Logout");
}
}
列表 5.3: UserService.cs
(epa.ms/UserService5-3
)
在 UserService
构造函数 (1) 中,通过依赖注入初始化 IDataStore
实例。可用的功能可以分为两种类型。例如 GetUser
、DeleteUserAsync
和 GetUserList
等功能可以使用 User
类的方法实现。同时,AddUserAsync
、LoginAsync
和 Logout
等方法使用 IDataStore
实例实现。
改进登录过程
在用户管理的过程中,我们可能需要向系统中添加新用户或删除过时的用户。对于我们的应用程序,它一次只允许一个用户登录。为了支持这个功能,我们可以通过单例模式实现一个类。或者,我们可以实现一个类并利用依赖注入来达到类似单例模式的效果。例如,我们可以创建一个继承自 User
类的 LoginService
类,如 列表 5.4 所示:
using System.Diagnostics;
using PassXYZLib;
namespace PassXYZ.Vault.Services;
public class LoginService : PxUser { //(1)
private IUserService<User> _userService;
private const string PrivacyNotice = "Privacy Notice";
public static bool IsPrivacyNoticeAccepted {
get => Preferences.Get(PrivacyNotice, false);
set => Preferences.Set(PrivacyNotice, value);
}
public LoginService(IUserService<User> userService) {
_userService = userService; //(2)
}
public async Task<bool> LoginAsync() {
return await _userService.LoginAsync(this); //(3)
}
public async Task SignUpAsync() {
await _userService.AddUserAsync(this); //(4)
}
public override void Logout() {
_userService.Logout();
}
public async Task<string> GetSecurityAsync() {
if (string.IsNullOrWhiteSpace(Username)) {
return string.Empty; }
string data = await SecureStorage.GetAsync(Username);
return data;
}
public async Task SetSecurityAsync(string password) {
if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(password)) { return; }
await SecureStorage.SetAsync(Username, password);
}
public async Task<bool> DisableSecurityAsync() {
...
}
}
列表 5.4: LoginService.cs
(epa.ms/LoginService5-4
)
(1) LoginService
类是从 PxUser
子类派生的,而 PxUser
子类又继承自 User
类。
(2) 在 LoginService
中,我们通过依赖注入初始化 IUserService
接口。
(3) 要执行用户登录,我们可以通过传递一个 LoginService
实例作为参数来调用 IUserService
方法。(4) 同样的过程也适用于用户注册的情况。
登录视图模型
在介绍了模型和服务层之后,我们现在可以专注于登录和注册功能的视图模型和视图。让我们首先检查视图模型的实现,如 列表 5.5 所示:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PassXYZLib;
using PassXYZ.Vault.Views;
using PassXYZ.Vault.Services;
using System.Diagnostics;
namespace PassXYZ.Vault.ViewModels
{
public partial class LoginViewModel : ObservableObject
{
private LoginService _currentUser;
ILogger<LoginViewModel> _logger;
public LoginViewModel(LoginService user, //(1)
ILogger<LoginViewModel> logger)
{
_currentUser = user;
_logger = logger;
}
[RelayCommand(CanExecute = nameof(ValidateLogin))]
private async Task Login(object obj)
{
...
bool status = await _currentUser.LoginAsync(); //(2)
...
}
private bool ValidateLogin()
{
var canExecute = !String.IsNullOrWhiteSpace(Username)
&& !String.IsNullOrWhiteSpace(Password);
return canExecute;
}
[RelayCommand(CanExecute = nameof(ValidateSignUp))]
private async Task SignUp()
{
...
await _currentUser.SignUpAsync(); //(3)
...
}
private bool ValidateSignUp()
{
var canExecute = !String.IsNullOrWhiteSpace(Username)
&& !String.IsNullOrWhiteSpace(Password)
&& !String.IsNullOrWhiteSpace(Password2);
if (canExecute) {
return Password!.Equals(Password2);
}
return canExecute;
}
[ObservableProperty]
private bool isBusy = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))] //(4)
[NotifyCanExecuteChangedFor(nameof(SignUpCommand))]
private string? username = default;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
[NotifyCanExecuteChangedFor(nameof(SignUpCommand))]
private string? password = default;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SignUpCommand))]
private string? password2 = default;
public bool IsDeviceLockEnabled
{
...
}
public List<string> GetUsersList() {
return User.GetUsersList();
}
public void Logout() {
_currentUser.Logout();
}
}
}
列表 5.5: LoginViewModel.cs
(epa.ms/LoginViewModel5-5
)
(1) 在 LoginViewModel
中,我们通过依赖注入注入一个 LoginService
实例,并将其保存到私有成员变量 _currentUser
中。这个变量用于跟踪所有登录和注册活动。
随后,我们创建了 LoginCommand
(2) 和 SignUpCommand
(3),使用 .NET Community Toolkit 中的 RelayCommand
属性。在这些函数中,我们调用 LoginAsync
和 SignUpAsync
方法从 LoginService
。
为了验证这两个命令,我们创建了ValidateLogin
和ValidateSignUp
方法来执行必要的检查。在ValidateLogin
中,我们在允许登录过程继续之前确保用户名和密码都不为空。在ValidateSignUp
中,我们不仅验证用户名和密码,还确认密码和确认密码字段匹配。(4)为了触发验证方法,我们添加了NotifyCanExecuteChangedFor
属性来装饰属性。这是 MVVM 工具包实现的一部分,我们在第四章,探索 MVVM 和数据绑定中讨论了它。
现在,我们已经升级了我们的模型和服务以优化登录过程。通过查看图 5.7中的类图,我们已经修改了视图、视图模型和服务层的源代码来改进我们的应用。实际模型包含在两个库中,KPCLib
和PassXYZLib
。我们通过IDataStore
和IUserService
接口公开了这些库的功能。通过为这两个接口创建实现类,我们进一步增强了我们的模型。在下一节中,我们将重点关注视图层并检查升级的 UI。
登录 UI
现在,我们可以增强登录和注册 UI。目前,登录页面只包含一个按钮。让我们向LoginPage.xaml
添加用户名字段和密码字段,如图图 5.8所示。
![img/B21554_05_08.png]
图 5.8:LoginPage
在这个新的 UI 设计中,我们做了以下更改:
-
我们在框架内创建了一个 4x3 的网格布局(1)。
-
在前两行中,我们添加了两个
Entry
控件来保存用户名(2)和密码(3)。我们在Entry
控件的Text
字段和LoginViewModel
的属性之间建立了数据绑定。 -
在第三行,我们引入了一个
ActivityIndicator
控件(4)来显示登录状态,它与视图模型的IsBusy
属性绑定。 -
在最后一行中,我们定义了一个
Button
控件(5)用于登录操作。Button
控件有一个实现ICommand
接口的Command
属性。我们使用数据绑定将这个Command
属性链接到视图模型中负责执行登录操作的方法。
摘要
在本章中,我们专注于基本导航原则和Shell
框架。我们选择Shell
作为我们应用设计的导航基础,检查了其功能,并讨论了如何将其集成到我们应用的 UI 中。
在我们完成大部分 UI 设计后,我们通过修改两个服务接口IDataStore
和IUserService
来增强了我们的模型。我们在视图、视图模型和服务层进行更改后改进了登录过程。在服务层,我们仍然使用MockDataStore
类。然而,我们还没有在IDataStore
服务中完成实际登录活动的实现。我们将把这个留到下一章。
在完成大部分 UI 设计后,我们通过修改两个服务接口 IDataStore
和 IUserService
来细化我们的模型。通过在视图、视图模型和服务层中进行修改,我们增强了登录过程。在服务层中,我们继续使用 MockDataStore
类。然而,用于执行实际登录活动的 IDataStore
服务实现尚未完成,我们将在下一章中解决这个问题。
在下一章中,我们将深入探讨 .NET MAUI 中的依赖注入,这与 Xamarin.Forms 的做法大不相同。我们将指导您如何使用依赖注入注册我们的服务,以及如何通过构造函数注入或属性注入初始化我们的服务。此外,我们将开发实际的服务以取代 MockDataStore
。
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第六章:基于依赖注入的软件设计
在介绍了.NET MAUI 中的导航和 Shell 之后,我们已经为构建一个综合应用程序打下了基础。然而,我们目前正使用一个模拟数据服务,我们打算在本章中对其进行修改。在深入探讨之前,让我们首先回顾一下软件设计的最佳实践,从设计原则概述开始。稍后,我们将探讨如何在我们的应用程序中利用依赖注入。
软件设计原则和模式通常是软件设计最佳实践的骨架。这些原则提供了规则和指南,软件设计师在构建高效和清晰的设计结构时遵循这些规则和指南。它们在塑造软件设计过程中起着关键作用,因为它们规定了最有效的实践。设计模式是经验丰富的面向对象软件开发者采用的有效最佳实践。它们作为模板,旨在解决特定上下文中的重复性设计问题,提供可重用的解决方案,这些解决方案可以应用于软件设计中的常见问题。
依赖注入(DI)是一种软件设计模式和技巧,它确保一个类不依赖于其依赖项。它通过解耦对象的利用与其创建来实现这一点。这里的目的是创建一个更适应性强、模块化且易于调试和维护的系统。DI 体现在依赖倒置原则(DIP)中,这是面向对象编程和设计中的五个 SOLID 原则之一。
在本章中,我们将探讨以下主题:
-
设计原则概述
-
实现依赖注入(DI)
-
替换模拟数据存储
DI 是实现依赖倒置设计原则的方法,也称为 DIP。DIP 是 SOLID 设计原则之一,我们将学习如何将 SOLID 原则融入我们的设计过程。在本章的开头,我们将提供 SOLID 设计原则的概述,然后再深入讨论 DI。
技术要求
要测试和调试本章的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。有关详细信息,请参阅第一章,开始使用.NET MAUI中的开发环境设置部分。
本章的源代码可在以下 GitHub 分支中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter06
。
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter06 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书源代码的信息,请参阅第二章,构建我们的第一个.NET MAUI 应用程序中的管理本书的源代码部分。
设计原则概述
设计原则是高级指导方针,提供了关于设计考虑的有价值建议。这些原则可以提供基本指导,帮助你做出更好的设计决策。一些通用设计原则不仅适用于软件设计,也适用于其他设计领域。
在我们探讨软件开发中常用的设计原则(SOLID)之前,让我们先回顾一些通用设计原则。
探索设计原则的类型
设计原则涵盖了一个广泛的主题领域。因此,而不是深入细节,我将从我在开发过程中实施设计原则的经验中提供见解,对本书中讨论的原则提供一个简洁的概述。我们将从高级原则如 DRY、KISS 和 YAGNI 开始,然后逐步过渡到在软件开发中更常用的那些。在面向对象编程(OOP)的领域,最广泛使用的设计原则是 SOLID 原则。
重复即冗余 (DRY)
正如人们常说的,不要重复造轮子;我们应该努力重用现有的组件,而不是重新开发已经创建的内容。
简单至上,愚蠢 (KISS)
我们应该选择简单直接的方法,而不是在设计过程中引入不必要的复杂性。
你不会需要它 (YAGNI)
我们应该在需要时实现功能。在软件开发中,有一种趋势是使设计具有前瞻性。这可能会创造出实际上并不需要的东西,并增加解决方案的复杂性。
SOLID 设计原则
SOLID 设计原则在软件开发中被广泛采用,并作为众多设计模式的高级指导方针。SOLID 是一个首字母缩略词,它包含了以下五个原则:
-
单一职责原则 (SRP): 一个类应该只有一个职责。遵循这个设计原则,开发者应该只有一个理由去修改一个类。通过在实现过程中考虑这个原则,生成的代码更容易理解,并且更有效地适应不断变化的需求。
-
开闭原则 (OCP): 类应该对扩展开放,但对修改封闭。这个原则背后的核心概念是在引入新功能时防止对现有代码造成破坏。
-
里氏替换原则 (LSP): 如果父类型的对象可以在某个上下文中使用,那么具有子类型的对象也应该能够以相同的方式运行,而不会引起任何错误或中断。
-
接口隔离原则 (ISP): 设计不应该实现它不使用的接口,并且一个类不应该被迫依赖于它不打算实现的方法。我们应该设计简洁简单的接口,而不是庞大复杂的接口。
-
依赖倒置原则(DIP):这个原则强调软件模块的解耦。高级模块不应该直接依赖于低级模块。两者都应该依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象。
设计原则是帮助我们做出更好设计决策的指南。然而,最终的责任在于我们,在实施过程中确定最合适的行动方案,而不是仅仅依赖于这些原则。
由于我们将专注于本章中 DI 的使用,请参阅进一步阅读部分以获取有关设计原则(SOLID)和设计模式的更多信息。
使用设计原则
讨论了各种设计原则后,让我分享一些在实践实施中获得的见解和经验教训。
在我们应用程序的模型中,我使用了来自 Dominik Reichl 的KeePassLib。在将其移植到.NET Standard 的过程中,我修改了继承层次结构,如图图 6.1所示:
图 6.1:Item、PwEntry 和 PwGroup 的类图
在将 KeePassLib 移植到.NET Standard 的过程中,我开发了一个抽象父类Item
,用于组(PwGroup
)和条目(PwEntry
)。这种修改似乎违反了 SOLID 原则中的 OCP。这种方法的理由源于我从过去实施中学到的一课。
在早期版本中,在 1.2.3 之前,我没有按照描述的方式实现KPCLib。相反,我直接使用了PwGroup
和PwEntry
,这需要分别处理组和条目。这导致了ItemsPage
和ItemsViewModel
的复杂性增加。这种方法最显著的影响是无法明确区分模型和视图模型。因此,我不得不在视图模型中直接使用KeePassLib来管理许多细节。然而,在引入Item
抽象父类之后,我成功地隐藏了服务(IDataStore
和IUserService
)和PassXYZLib
中的大部分复杂实现。这导致了视图和视图模型中任何依赖 KeePassLib 的代码的消除。
这种变化的灵感来自 KISS 原则,而不仅仅是遵循 OCP。在考虑其他 SOLID 原则,如 LSP 和 SRP 时,这种修改显著提高了整体架构。重要的是要认识到,在实际工作中,各种设计原则之间可能会出现冲突。最终,我们的责任是做出明智的决定,而不是教条地遵循设计原则。最有效的设计决策通常源于从先前的失败中获得的见解。
回到我们的主要焦点,我们现在将讨论通过采用 SOLID 设计原则之一——依赖反转来增强设计。作为 SOLID 设计原则的一部分,依赖反转强调软件模块的分离,并提供了一些如何实现这一点的指导。其背后的基本概念是在可能的情况下优先依赖抽象。在实践中,DI 是一种经常用来实现依赖反转概念的技巧。
实现依赖注入(DI)
DI 是一种可以在 .NET MAUI 中使用的技巧。尽管这不是一个新颖的概念,但它已经在像 ASP.NET Core 和 Java Spring Framework 这样的后端框架中被广泛使用。DI 通过将对象的用法与其创建解耦,从而简化了依赖反转(DIP),消除了对对象的直接依赖。在我们的应用中,一旦我们分离了 IDataStore
接口的实现,我们就可以开始使用模拟实现,并随后替换为实际实现。
在 .NET MAUI 中,我们将称之为 MS.DI 的 Microsoft.Extensions.DependencyInjection
服务作为内置功能可供我们使用。
在 .NET 领域,除了 MS.DI 之外,还有许多可用的 DI 容器。其中一些替代方案,如 Autofac DI 容器和 Simple Injector DI 容器,与 MS.DI 相比提供了更强大的功能和灵活性。在这个时候,人们可能会想知道为什么我们选择 MS.DI 而不是其他强大且灵活的 DI 容器。在这种情况下,重新审视 KISS(保持简单)和 YAGNI(不要过早优化)原则是至关重要的。我们不应该选择一个更强大的解决方案,假设我们可能会在未来使用某些功能。相反,最简单、最有效的方法是利用我们已有的东西,而不需要任何额外的努力。
使用 MS.DI,我们可以避免引入额外的依赖。无论我们的意图如何,它已经包含在 .NET MAUI 的配置中。通过简单地添加几行代码,我们就可以增强我们的设计。其他 DI 容器可能提供更复杂的功能,但我们需要在我们的代码中包含额外的依赖,并在使用它们之前进行必要的配置。如果你正在处理复杂系统设计,建议评估可用的 DI 容器,并选择最适合你系统的那个。在我们的场景中,PassXYZ.Vault 是一个相对简单的应用,我们不会直接从 Autofac 或 Simple Injector 提供的先进 DI 功能中受益。MS.DI 提供的功能对于我们的实现来说是足够的。
在我们的应用中,我们旨在解耦的模块是来自 KeePass 提供的第三方库的模型层。如图 图 6.2 所示,我们的系统由三个不同的程序集组成:KPCLib、PassXYZLib 和 PassXYZ.Vault。
图 6.2:包图
KPCLib 包包含两个命名空间,KeePassLib 和 KPCLib。PassXYZLib 作为扩展包,使用 .NET MAUI 特定的实现来增强 KPCLib 包的功能。我们的应用程序 PassXYZ.Vault 直接依赖于 PassXYZLib 包,间接依赖于 KPCLib 包。根据 DI 原则,我们旨在建立对抽象的依赖,而不是对具体实现的依赖。为了实现这一点,我们设计了两个接口,IDataStore
和 IUserService
,使我们能够从实际实现中解耦。
需要访问 KPCLib 和 PassXYZLib 的实际实现被封装在实现这两个接口的类中:IDataStore
和 IUserService
。访问 KPCLib 和 PassXYZLib 中功能所需的其余代码可以利用这两个接口。关键点是我们始终有灵活性,在必要时替换 IDataStore
和 IUserService
的实现。剩余的代码不会受到这些更改的影响。
为了将 MS.DI 作为 DI 服务使用,涉及两个主要步骤:注册和解析。
初始时,我们必须在程序启动期间注册我们的接口(如 IDataStore
)及其相应的实现(如 MockDataStore
)。之后,我们可以在整个程序中利用这些已注册的接口,而无需手动创建它们。然后,我们可以使用 DI 容器解决这些已注册的依赖项。
ServiceCollection
类作为注册的手段,而 ServiceProvider
类则促进解析,如图 6.3 所示:
图 6.3:MS.DI 的使用
图 6.3 展示了 ServiceCollection
和 ServiceProvider
的简化类图,位于顶部。ServiceCollection
作为 IServiceCollection
接口的默认实现,而 ServiceProvider
则作为 IServiceProvider
接口的默认实现。
这些接口,IServiceCollection
和 IServiceProvider
,允许我们注册和解析依赖。在图 6.3 的底部,有一个序列图说明了如何使用这两个接口来实现这一点。为了更清晰地理解,我们将使用 IDataStore
服务作为示例来解释 IServiceCollection
和 IServiceProvider
的使用。
为了使用 DI 实现 IDataStore
服务,我们可以遵循后续代码块中概述的步骤:
// Registration
var services = new ServiceCollection(); //(1)
services.AddSingleton <IDataStore<Item>, MockDataStore>(); //(2)
// Resolution
ServiceProvider provider =
services.BuildServiceProvider(validateScopes: true); //(3)
IDataStore<Item> dataStore =
provider.GetRequiredService<IDataStore<Item>>(); //(4)
(1)首先,我们必须创建一个实现 IServiceCollection
接口的 ServiceCollection
类的实例。
(2) IServiceCollection
接口本身并不指定任何方法。相反,在 MS.DI 命名空间中定义了一系列扩展方法。在这些方法中,可以使用 AddSingleton
扩展方法来注册实现 IDataStore
接口的具体 MockDataStore
类。AddSingleton
方法将在下一节中解释。此方法使用泛型类型来指定接口及其实现。此外,AddSingleton
扩展方法还有几个重载变体可供使用。
(3) 为了访问对象,我们可以通过调用与 IServiceCollection
关联的 BuildServiceProvider
扩展方法来获取 ServiceProvider
的实例。ServiceProvider
类符合 IServiceProvider
接口。值得注意的是,IServiceProvider
接口位于 System
命名空间中,并专门定义了 GetService
方法。其他方法被指定为扩展方法,可以在 Microsoft.Extensions.DependencyInjection
命名空间中找到,如图 6.3 所示。
(4) 一旦我们有了 ServiceProvider
的实例,我们可以使用 GetRequiredService
扩展方法解析 IDataStore
接口。
要管理服务的范围,我们可以在该范围内解析它如下:
IServiceScope scope = provider.CreateScope();
IDataStore<Item> dataStore = scope.ServiceProvider
.GetRequiredService<IDataStore<Item>>();
我们将在下一节中讨论范围。
尽管 MS.DI 是一个轻量级的依赖注入服务,但它为.NET MAUI 应用程序提供了足够的功能,如下所述:
-
实例的终身管理
-
构造函数、方法和属性注入
在接下来的章节中,我们将更深入地探讨这些功能。
终身管理
当使用依赖注入(DI)时,你应该考虑注册服务的实例在销毁或创建新实例之前应该被重用或保留多长时间。终身管理对于定义服务实例生成和共享的范围至关重要。
在生命周期管理方面,以下是一些需要考虑的方面:
-
资源管理:资源,例如由服务使用的数据库连接,不应无限期地保持开启状态。例如,如果单例服务保持数据库连接开启,它将保留该资源直到应用程序的生命周期结束。
-
性能:每次需要服务时都创建一个新的服务实例可能并不总是最有效的方法,尤其是如果构建服务是资源密集型的。
-
隔离:如果你的服务需要隔离(例如,如果它维护某些状态),根据满足这种隔离要求的范围配置其生命周期是至关重要的。
-
线程问题:单例服务需要是线程安全的,因为它们在不同的请求之间共享,通常在单独的线程上并行处理。
使用 MS.DI,我们可以通过配置 ServiceCollection
来管理这些实例的生命周期。
依赖注入中通常有三种生命周期类型,我们可以使用扩展方法来配置它们:
-
Singleton:当首次请求服务时创建服务的单个实例,并在整个应用程序的生命周期内重用。所有调用者都接收相同的实例,这意味着单例服务表现得像一个共享的全局资源。单例服务可以保留状态,并且对于提供资源(如日志记录、缓存或配置)的集中式管理非常有用。可以使用扩展方法
AddSingleton
在整个应用程序的生命周期内创建单个实例。 -
Scoped:作用域服务为每个作用域创建一个新的实例,通常在 Web 应用程序中为每个请求创建。每个作用域都有一个服务的实例,该实例在该特定作用域内的所有组件之间共享。作用域服务适用于维护特定于单个请求或用户交互的状态,例如用户信息、请求详情或请求上下文中的数据库连接。可以使用扩展方法
AddScoped
创建一个实例并在定义的作用域内重用该实例。 -
Transient:每次请求服务时,
Transient
服务都会创建一个新的实例,确保每个调用者都得到一个唯一的实例,而不共享状态或资源。Transient
服务适用于没有内部状态或资源管理需求的服务的服务。它们通常轻量级,不需要在不同组件之间共享。可以使用扩展方法AddTransient
为每次调用创建一个实例。
Figure 6.4:
var services = new ServiceCollection();
services.AddSingleton< IUserService<User>, UserService>(); //(1)
services.AddScoped<IDataStore<Item>, DataStore>(); //(1)
services.AddTransient<ItemsViewModel>(); //(1)
ServiceProvider rootContainer =
services.BuildServiceProvider(validateScopes: true); //(2)
var userService =
rootContainer.GetRequiredService<IUserService<User>>();
IServiceScope scope1 = rootContainer.CreateScope(); //(3)
IDataStore<Item> dataStore1 =
scope1.ServiceProvider.GetRequiredService<IDataStore<Item>>();
IServiceScope scope2 = rootContainer.CreateScope(); //(3)
IDataStore<Item> dataStore2 = Scope2.ServiceProvider. GetRequiredService<IDataStore<Item>>();
在上述代码中,在标记为(1)的行中,我们将IUserService
注册为Singleton
对象,IDataStore
注册为Scoped
对象,ItemsViewModel
注册为Transient
对象。
在注册之后,在标记为(2)的行中,我们实例化了一个ServiceProvider
并将其存储在rootContainer
变量中。在标记为(3)的行中,利用rootContainer
,我们生成了两个作用域,分别命名为scope1和scope2。
这些创建的对象的生命周期管理可以在图 6.4中检查:
图 6.4:MS.DI 中的生命周期管理
变量userService
被创建为一个Singleton
对象,确保只有一个实例存在,并且其生命周期与应用程序相同。两个作用域——即scope1和scope2——具有由我们的设计决定的独立生命周期。Scoped
对象——dataStore1和dataStore2——与它们所属的作用域具有相同的作用域。同时,ItemViewModel
的实例是Transient
对象。
在三种方法——AddSingleton
、AddScoped
和AddTransient
——中,已经定义了多种重载变体,以满足与ServiceCollection
配置相关的各种需求。
在我们的应用程序中,我们有IDataStore
接口实现的两个版本:
-
DataStore
:这个版本代表实际的实现。 -
MockDataStore
:这个版本用于测试目的。
使用 MS.DI,我们能够在调试构建中使用MockDataStore
,并在发布构建中使用DataStore
。此配置可以按照后续代码片段中所示执行:
bool isDebug = false;
var services = new ServiceCollection();
services.AddSingleton<DataStore, DataStore>();
services.AddSingleton<MockDataStore, MockDataStore>();
services.AddSingleton<IDataStore<Item>>(c => {
if (isDebug)
{
return c.GetRequiredService<MockDataStore>();
}
else
{
return c.GetRequiredService<DataStore>();
}
});
DataStore and MockDataStore, and the interface IDataStore for distinct build configurations. When configuring IDataStore, a delegate can be employed to resolve the object. The isDebug variable may be adjusted using build configurations, enabling it to be set as true or false, depending on whether the build is for debugging or release purposes.
.NET MAUI 配置 DI
MS.DI 被纳入.NET 发布版,使其在.NET 5 或后续版本的所有类型的应用程序中可用。正如我们在上一节中讨论的,我们可以使用ServiceCollection
和ServiceProvider
来实现 DI。然而,在.NET MAUI 中利用 MS.DI 有一个更直接的方法。由于 DI 作为.NET 通用主机配置的一部分集成,我们无需手动创建ServiceCollection
的实例。这使我们能够直接使用预配置的 DI 服务,无需任何额外的工作。
要深入了解.NET MAUI 中预配置的 DI 服务,让我们回顾一下图 6.5中所示的.NET MAUI 应用程序启动过程。此图包含一个类图和一个时序图,展示了参与过程的类。
请注意,图 6.5中的数字代表对象的类型((1) = MauiProgram,(2) = MauiApp,和(3) = MauiAppBuilder):
图 6.5:.NET MAUI DI 配置
在图 6.5的顶部,我们可以看到.NET MAUI 应用程序的初始化涉及四个不同的类:
-
平台入口点:.NET MAUI 应用程序的初始化发生在特定平台的代码中。对于.NET MAUI 项目,这可以在Platforms文件夹中找到。为每个平台定义了不同的类,如表 6.1所示。在图 6.5中,我们使用
MauiApplication
的 Android 版本作为代表示例。平台 入口点类 实现接口 Android MauiApplication
IPlatformApplication
iOS/macOS MauiUIApplicationDelegate
Windows MauiWinUIApplication
表 6.1:不同平台中的入口点
所有入口点类都实现了
IPlatformApplication
接口,如下面的代码片段所示:public interface IPlatformApplication { static IPlatformApplication? Current { get; set; } IServiceProvider Services { get; } IApplication Application { get; } }
IPlatformApplication
接口定义了一个名为Services
的属性,其类型为IServiceProvider
。一旦应用程序初始化,这个属性就可以直接用来解析 DI 对象。所有平台入口点类也实现了一个重写方法,
CreateMauiApp
,它调用在MauiProgram
类中定义的静态方法。请参考以下代码,表 6.1和图 6.5。 -
MauiProgram
(1): 在随后的MauiProgram
实现代码中,可以明显看出,每个 .NET MAUI 应用程序都必须定义一个静态的MauiProgram
类,并包含一个CreateMauiApp
方法。CreateMauiApp
方法由所有平台入口点的重写函数调用。这个重写函数最终返回一个MauiApp
实例:protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
-
MauiApp
(2): 在CreateMauiApp
中,通过调用函数MauiApp.CreateBuilder
创建一个MauiAppBuilder
实例。 -
MauiAppBuilder
(3):MauiAppBuilder
包含一个名为Services
的属性,该属性是IServiceCollection
接口类型。这个属性允许我们为 .NET MAUI 应用程序配置依赖注入。
根据对 .NET MAUI 应用程序启动过程的上述分析,很明显,IServiceCollection
和 IServiceProvider
都在这个过程中被初始化。因此,我们可以方便地使用它们,而无需额外的配置。
下面的代码展示了 MauiProgram
的实现。在这里,我们注册了接口——IDataStore
和 IUserService
——以及包括 LoginService
、视图模型和页面在内的多个类。需要注意的是,所有这些组件都是单例对象,除了 ItemsViewModel
和 ItemsPage
:
public static class MauiProgram { //(1)
public static MauiApp CreateMauiApp() { //(2)
var builder = MauiApp.CreateBuilder(); //(3)
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts => {
fonts.AddFont("fa-regular-400.ttf",
"FontAwesomeRegular");
fonts.AddFont("fa-solid-900.ttf",
"FontAwesomeSolid");
fonts.AddFont("fa-brands-400.ttf",
"FontAwesomeBrands");
fonts.AddFont("OpenSans-Regular.ttf",
"OpenSansRegular");
fonts.AddFont("OpenSans-SemiBold.ttf",
"OpenSansSemiBold");
});
#if DEBUG
builder.Logging.AddDebug();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
#endif
builder.Services.AddSingleton<IDataStore<Item>, DataStore>();
builder.Services.AddSingleton<IUserService<User>, UserService>();
builder.Services.AddSingleton<LoginService>();
builder.Services.AddSingleton<LoginViewModel>();
builder.Services.AddSingleton<LoginPage>();
builder.Services.AddSingleton<SignUpPage>();
builder.Services.AddSingleton<ItemDetailViewModel>();
builder.Services.AddSingleton<ItemDetailPage>();
builder.Services.AddSingleton<NewItemViewModel>();
builder.Services.AddSingleton<NewItemPage>();
builder.Services.AddSingleton<AboutViewModel>();
builder.Services.AddSingleton<AboutPage>();
builder.Services.AddTransient<ItemsViewModel>();
builder.Services.AddTransient<ItemsPage>();
return builder.Build(); }
}
在为接口和类配置了依赖注入之后,我们可以在实现中使用它们。IServiceProvider
接口使我们能够有效地解析对象。在实现依赖注入时,有三种主要的注入依赖的方法:构造函数注入、方法注入和属性注入。在接下来的章节中,我们将探讨如何将这些方法应用到我们的编程中。
构造函数注入
使用构造函数注入,一个类的必要依赖作为构造函数的参数提供,允许我们通过构造函数本身来解析依赖。在 ItemsPage
的代码背后,ItemsPage
依赖于其视图模型 ItemsViewModel
。我们可以将 ItemsPage
的构造函数设置如下:
public partial class ItemsPage : ContentPage {
ItemsViewModel viewModel;
public ItemsPage(ItemsViewModel viewModel) {
InitializeComponent();
BindingContext = this.viewModel = viewModel;
}
protected override void OnAppearing() {
base.OnAppearing();
viewModel.OnAppearing();
}
}
在 ItemsPage
的构造函数中,我们通过参数 viewModel
注入依赖。在这个例子中,MS.DI 根据在 MauiProgram
中定义的配置解析 viewModel
。
构造函数注入是最常见且最推荐的形式的依赖注入,因为对象总是带有所需的依赖项被创建。最大的优点是它使依赖项明确,对象永远不会处于不完整的状态。
通常情况下,可能无法通过构造函数注入依赖,例如当一个类包含可选依赖项或需要动态更改依赖项时。在这些情况下,使用方法注入或属性注入将是推荐的方法。
方法注入
与在对象实例化时使用构造函数注入来提供所需依赖项不同,方法注入直接将依赖项传递给使用它们的那些方法。方法注入是依赖注入(DI)技术中的一种,其中依赖项通过方法参数提供给对象。
在我们的代码中,我们可以通过方法而不是构造函数来设置依赖项,如下面的代码所示:
namespace PassXYZ.Vault.ViewModels {
[QueryProperty(nameof(ItemId), nameof(ItemId))]
public partial class ItemsViewModel : BaseViewModel {
readonly IDataStore<Item> dataStore;
ILogger<ItemsViewModel> logger;
public ObservableCollection<Item> Items { get; }
public ItemsViewModel(ILogger<ItemsViewModel> logger) {
this.logger = logger;
Items = new ObservableCollection<Item>();
}
public SetDataStore(IDataStore<Item> store) {
this.dataStore = store;
}
...
public async Task AddItem(Item item) {
if (item == null) {
logger.LogDebug("Item cannot be null");
return;
}
await dataStore.AddItemAsync(item);
}
...
}
}
在此示例中,IDataStore
是通过SetDataStore
方法设置的,而不是通过构造函数注入。然而,它有一个缺点,即依赖对象可能会忘记设置依赖项。结果,对象可能处于不完整的状态。
在提供的代码示例中,使用方法注入使我们能够在同一代码中利用实际的和模拟的 DataStore 实现。与构造函数注入相比,方法注入还可以提供更细粒度的控制,以创建、传递和销毁依赖项。然而,它也可能使方法的调用更加复杂,因为调用者负责以参数的形式提供依赖项。
通常,为了简单性和更好的封装性,构造函数注入更受欢迎,但方法注入在某些特定用例中可能非常有价值。
属性注入
在属性注入中,依赖项是通过属性设置的。通常,方法和属性注入可以互相替换。它与方法注入类似——在许多情况下,我们可能无法使用构造函数注入,因此我们必须使用方法或属性注入。方法或属性注入的问题在于,依赖对象可能会忘记设置依赖项,因此对象可能处于不完整的状态。
通过在面向对象的语言中使用属性或注解,可以部分缓解这个问题,我们将在稍后探讨这种方法。
在.NET MAUI 中,我们可以通过IServiceProvider
解决依赖项。在一个.NET MAUI 应用程序中,托管环境为我们生成一个IServiceProvider
接口,如图 6.5 所示。
要获取IServiceProvider
接口,我们可以使用平台特定入口点中定义的IPlatformApplication
接口,如图 6.1 所示:
namespace PassXYZ.Vault.Services;
public static class ServiceHelper
{
public static TService GetService<TService>()
=> Current.GetService<TService>(); //(2)
public static IServiceProvider Current => //(1)
#if WINDOWS10_0_17763_0_OR_GREATER
MauiWinUIApplication.Current.Services;
#elif ANDROID
MauiApplication.Current.Services;
#elif IOS || MACCATALYST
MauiUIApplicationDelegate.Current.Services;
#else
null;
#endif
}
列表 6.1: ServiceHelper.cs
(epa.ms/ServiceHelper6-1
)
(1) 在ServiceHelper
类中,我们定义了一个名为Current
的静态变量,它维护对IServiceProvider
的引用。我们可以通过IPlatformApplication
接口的Services
属性来获取IServiceProvider
。
(2) 定义了一个名为GetService
的静态方法,该方法反过来调用IServiceProvider
接口的GetService
方法。
ServiceHelper
对于ServiceHelper的实现,我参考了MauiApp-DI GitHub 项目。感谢 James Montemagno 在 GitHub 上提供的示例代码!
(github.com/jamesmontemagno/MauiApp-DI
)
通过使用ServiceHelper
类,我们可以获取IDataStore
的一个实例,如下所示:
public static IDataStore<Item> DataStore =>
ServiceHelper.GetService<IDataStore<Item>>();
你可能会注意到,之前提供的属性注入代码与构造函数注入相比显得不那么优雅。我们必须手动设置依赖项。在最坏的情况下,对象可能处于不完整的状态。
到目前为止,我还没有发现一种更有效的方法来实现.NET MAUI 中的这一功能。然而,在本书的后续章节中,当我们介绍 Blazor 混合应用时,我们将能够更有效地利用 C#属性来处理属性注入。在 Blazor 中解析IDataStore
接口时,可以采用更直接的方法,以下是一个示例:
[Inject]
public IDataStore<Item> DataStore { get; set; } = default!;
我们可以使用Inject
C#属性来隐式解析依赖,而不需要显式调用ServiceHelper
中的GetService
方法。
通过使用依赖注入(DI),我们可以无缝地将IDataStore
接口的模拟实现替换为实际实现。这种实现可以简化密码数据库的 CRUD 操作。在下一节中,我们将更详细地探讨这个新类。
替换模拟数据存储
如前几节所述,我们可以在MauiProgram.cs
中注册数据存储服务的实现,如下所示:
builder.Services.AddSingleton<IDataStore<Item>, MockDataStore>();
builder.Services.AddSingleton<IUserService<User>, UserService>();
MockDataStore for the IDataStore interface. This is a mock implementation to simplify the initial development. Now, it’s time to substitute this with the actual implementation. We will replace the above code with the following:
builder.Services.AddSingleton<IDataStore<Item>, DataStore>();
builder.Services.AddSingleton<IUserService<User>, UserService>();
在这里,DataStore
是IDataStore
服务的实际实现,我们将在本章的剩余部分中完全实现它。
密码数据库是 KeePass 2.x 格式的本地数据库。在这个数据库中,密码信息被组织成组和条目。KeePassLib
命名空间包含一个PwDatabase
类,该类旨在管理数据库操作。
要理解PwDatabase
、PwGroup
和PwEntry
之间的关系,我们可以参考图 6.6中的类图:
图 6.6:KeePass 数据库的类图
在PwDatabase中,定义了类型为PwGroup的RootGroup属性,它包含数据库中存储的所有组和条目。可以从RootGroup导航到特定的条目,KeePass 数据库的数据结构。PwEntry定义了一组标准字段,如图 6.7 所示:
图 6.7:组、条目和字段
如果我们有一个只包含标准字段的条目列表,它将类似于一个表格。在 图 6.7 中,当前组包含五个条目(GitHub、Google、Facebook、Instagram 和 Chase Bank)以及一个子组(Cloud)。在左侧,ItemsPage
的截图显示了当前组中的项目。如果选择 Google 项目,它将出现在右侧截图的条目中。用户可以选择向条目添加额外字段,这使得 KeePass 数据库不同于关系数据库;它更类似于键值数据库。每个字段都是一个键值对,例如 URL 字段。
为了在我们的应用程序中利用 PwDatabase
,我们定义了一个派生类,称为 PxDatabase
。这个类引入了额外的属性和方法,例如 CurrentGroup
、DeleteGroup
、DeleteEntry
等。
要访问数据库,可以打开数据库文件并在其上执行 CRUD 操作。然而,在构建跨平台应用程序时,直接处理数据库文件可能对最终用户来说不方便。在 PassXYZ.Vault 中,使用了用户的概念而不是使用数据文件。在 PassXYZLib 中,定义了一个 User
类来封装底层文件操作。
为了访问数据库,我们在 IDataStore
和 IUserService
接口中定义了数据库初始化和 CRUD 操作。DataStore
和 UserService
具体类用于实现这两个接口。
初始化数据库
数据库初始化被包含在登录过程中,因此后续的登录方法定义在 IUserService
接口中:
Task<bool> LoginAsync(T user);
UserService
类是 IUserService
接口的一个实现。在 UserService
类中,定义了 LoginAsync
方法,如下所示:
public async Task<bool> LoginAsync(User user)
{
if (user == null) {
throw new ArgumentNullException(
nameof(user), "User cannot be null"); }
_user = user;
return await dataStore.ConnectAsync(user);
}
在 LoginAsync
方法中,调用 IDataStore
的 ConnectAsync
方法来执行实际任务。下面我们来查看 ConnectAsync
的实现:
public async Task<bool> ConnectAsync(User user)
{
return await Task.Run(() => //(1)
{
if (string.IsNullOrEmpty(user.Username) ||
string.IsNullOrEmpty(user.Password)) {
throw new ArgumentNullException(nameof(user),
"Username or password cannot be null");
}
_db.Open(user); //(2)
if (_db.IsOpen)
{
_db.CurrentGroup = _db.RootGroup;
}
return _db.IsOpen;
});
}
在 ConnectAsync
函数中,(1),使用一个独立任务来管理数据库的打开过程。调用 PxDatabase
的 Open
方法(2),并将 User
类的实例作为参数传递给 Open
方法。
在成功建立连接并初始化数据库后,我们需要实现数据库操作所需的方法。这可以包括数据检索、数据插入、数据更新和数据删除等任务。这些代表在数据库系统中交互、管理和维护数据的基本操作。
执行 CRUD 操作
root group provides the first list. We use the SetCurrentGroup method (1) to establish the current navigation location.
当用户导航到另一个组时,SetCurrentGroup
方法被调用,并带有一个参数来设置导航中的新位置:
public interface IDataStore<T>
{
Task<bool> AddItemAsync(T item);
Task<bool> UpdateItemAsync(T item);
Task<bool> DeleteItemAsync(string id);
T? GetItem(string id);
Task<IEnumerable<T>> GetItemsAsync(
bool forceRefresh = false);
string SetCurrentGroup(T? group = default); //(1)
Task<bool> ConnectAsync(User user);
Task SignUpAsync(User user);
void Close();
T? CreateNewItem(ItemSubType type);
}
列表 6.2: IDataStore.cs
(epa.ms/IDataStore6-2
)
添加项目
CRUD 的初始操作涉及创建或添加项目。这个项目可以是添加到当前组中的条目或组。执行此添加操作的用户界面可以在 ItemsPage
中的工具栏项中找到,如下所示:
<ContentPage.ToolbarItems>
<ToolbarItem Text="Add" Command="{Binding AddItemCommand}"
IconImageSource="{FontImage FontFamily=FontAwesomeSolid,
Color=White,
Glyph={x:Static style:FontAwesomeSolid.PlusCircle}}"/>
</ContentPage.ToolbarItems>
我们可以看到在 图 6.8 中的 ItemsPage
的右上角显示了工具栏项图标:
图 6.8:添加项目
当点击 + 按钮时,通过数据绑定调用 ItemsViewModel
中的 AddItemCommand
命令。
AddItemCommand
命令在视图中调用以下 AddItem
方法:
[RelayCommand]
private async Task AddItem(object obj)
{
string[] templates = {
Properties.Resources.item_subtype_group,
Properties.Resources.item_subtype_entry,
Properties.Resources.item_subtype_notes,
Properties.Resources.item_subtype_pxentry
};
var template = await Shell.Current.DisplayActionSheet(
Properties.Resources.pt_id_choosetemplate,
Properties.Resources.action_id_cancel, null, templates); //(1)
ItemSubType type;
if (template ==
Properties.Resources.item_subtype_entry) {
type = ItemSubType.Entry;
}
else if (template ==
Properties.Resources.item_subtype_pxentry) {
type = ItemSubType.PxEntry;
}
else if (template ==
Properties.Resources.item_subtype_group) {
type = ItemSubType.Group;
}
else if (template ==
Properties.Resources.item_subtype_notes) {
type = ItemSubType.Notes;
}
else if (template ==
Properties.Resources.action_id_cancel) {
type = ItemSubType.None;
}
else {
type = ItemSubType.None;
}
if (type != ItemSubType.None) {
var itemType = new Dictionary<string, object> { //(2)
{ "Type", type }
};
await Shell.Current.GoToAsync( //(3)
nameof(NewItemPage), itemType);
}
}
列表 6.3:ItemsViewModel.cs
(epa.ms/ItemsViewModel6-3
)
(1) 在 AddItem
函数中,显示了一个 ActionSheet
,允许用户选择项目类型。项目类型可以是组或条目。
(2) 获取项目类型后,我们可以构建一个包含项目类型和查询参数名称的字典。然后,我们将这个字典对象存储在名为 itemType
的变量中。
(3) 这个 itemType
变量可以作为查询参数传递给 NewItemPage
。在 第五章,使用 .NET MAUI Shell 和 NavigationPage 进行导航 中,我们学习了如何将字符串值作为查询参数传递给 Shell 导航中的页面。在这里,我们可以在将其包装在字典中之后,将对象作为查询参数传递给页面。
要添加新项目,用户界面在 NewItemPage
中定义,而逻辑在 NewItemViewModel
中管理。让我们查看 列表 6.4 中显示的 NewItemViewModel
实现:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using KPCLib;
using PassXYZLib;
using PassXYZ.Vault.Services;
using static System.Net.Mime.MediaTypeNames;
namespace PassXYZ.Vault.ViewModels;
[QueryProperty(nameof(Type), nameof(Type))] //(1)
public partial class NewItemViewModel : ObservableObject
{
readonly IDataStore<Item>? _dataStore;
ILogger<NewItemViewModel> _logger;
private ItemSubType _type = ItemSubType.Group;
public NewItemViewModel(IDataStore<Item> dataStore,
ILogger<NewItemViewModel> logger) {
this._dataStore = dataStore ??
throw new ArgumentNullException(nameof(dataStore));
this._logger = logger;
}
private void SetPlaceholder(ItemSubType type) {
if (type == ItemSubType.Group) {
Placeholder = Properties.Resources.action_id_add +
" " + Properties.Resources.item_subtype_group;
}
else
{
Placeholder = Properties.Resources.action_id_add +
" " + Properties.Resources.item_subtype_entry;
}
}
public ItemSubType Type { //(2)
get => _type;
set {
_ = SetProperty(ref _type, value);
SetPlaceholder(_type);
}
}
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? name;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? description;
[ObservableProperty]
private string? placeholder;
[RelayCommand]
private async Task Cancel() {
await Shell.Current.GoToAsync("..");
}
[RelayCommand(CanExecute = nameof(ValidateSave))]
private async Task Save() {
if(_dataStore == null) {
throw new ArgumentNullException(
"dataStore cannot be null"); }
Item? newItem = _dataStore.CreateNewItem(_type); //(3)
if (newItem != null) {
newItem.Name = Name;
newItem.Notes = Description;
await _dataStore.AddItemAsync(newItem); //(4)
}
await Shell.Current.GoToAsync("..");
}
private bool ValidateSave() {
var canExecute = !String.IsNullOrWhiteSpace(Name)
&& !String.IsNullOrWhiteSpace(Description);
_logger.LogDebug("ValidateSave: {canExecute}", canExecute);
return canExecute;
}
}
列表 6.4:NewItemViewModel.cs
(epa.ms/NewItemViewModel6-4
)
NewItemPage
的设计非常直接,包含两个控件:Entry 和 Editor,分别用于编辑项目的名称和备注。Entry 控件用于输入或编辑单行文本,而 Editor 控件用于修改多行文本。在 NewItemViewModel
视图模型中,我们可以观察到添加新项目的流程,如下所示:
(1) 使用 QueryPropertyAttribute
定义了查询参数。(2) 声明为 ItemSubType
的 Type
属性用于获取查询参数。随后,获取的项目类型存储在 _type
后备变量中。在 NewItemPage
中,创建了两个工具栏项,并将它们的行为关联到视图模型中找到的 Save 和 Cancel 方法。
在用户界面中输入名称和备注并点击 Save 按钮(3),使用定义在 IDataStore
接口中的 CreateNewItem
工厂方法创建一个新的项目实例。(4)一旦新项目实例填充了用户输入,就可以通过调用 AddItemAsync
方法将其添加到数据库中。
我们现在已经实现了添加操作。在下一节中,让我们继续实现剩余的数据操作。
编辑或删除项目
在 CRUD 操作中,创建操作不需要现有项目。但是,要执行更新和删除操作,需要现有项目的实例。
在读取操作中,当项目是一个组时,我们通过向ItemsPage
发送一个ItemId
查询参数来实现它,并在ItemsViewModel
视图模型的ItemId
设置器中识别该组。相反,如果项目是一个条目,我们向ItemDetailPage
发送一个ItemId
查询参数,并在ItemDetailViewModel
的ItemId
设置器中定位该条目。
对于更新、编辑和删除操作,我们可以利用上下文操作。这些操作允许我们有效地在ListView
中操作项目。需要注意的是,上下文操作在不同平台上具有独特的外观,如图6.9所示:
图 6.9:上下文操作
在 iOS 平台上,你可以通过向左滑动项目来执行对项目的操作。在 Android 系统中,你可以通过长按项目来访问上下文操作菜单,该菜单将出现在屏幕的右上角。在 Windows 上,你可能熟悉通过右击鼠标来显示上下文操作菜单。
在我们的应用程序中,我们在ItemsPage
中引入了一个上下文操作菜单。我们按照以下示例在ItemsPage
内的ItemViewCell
中配置上下文操作:
<ListView.ItemTemplate>
<DataTemplate x:DataType="model:Item">
<template:ItemViewCell Key="{Binding Name}"
Value="{Binding Description}" Source="{Binding ImgSource}"
ParentPage="{x:Reference itemsPage}"/>
</DataTemplate>
</ListView.ItemTemplate>
ItemViewCell
是一个继承自KeyValueView
的自定义视图,该视图在第四章,探索 MVVM 和数据绑定中引入。让我们查看列表 6.5中的ItemViewCell
代码:
using System.Diagnostics;
using KPCLib;
using PassXYZ.Vault.ViewModels;
namespace PassXYZ.Vault.Views.Templates;
public class ItemViewCell : KeyValueView {
public ItemViewCell() {
SetContextAction(GetEditMenu(), OnEditAction);
SetContextAction(GetDeleteMenu(), OnDeleteAction);
}
private void OnEditAction(object? sender, //(1)
System.EventArgs e) {
if(sender is MenuItem menuItem)
{
if(menuItem.CommandParameter is Item item &&
ParentPage.BindingContext is ItemsViewModel vm)
{
vm.Update(item);
}
}
}
private async void OnDeleteAction(object? sender, //(2)
System.EventArgs e) {
if (sender is MenuItem menuItem) {
if (menuItem.CommandParameter is Item item &&
ParentPage.BindingContext is ItemsViewModel vm)
{
await vm.Delete(item);
}
}
}
}
列表 6.5:ItemViewCell.cs
(epa.ms/ItemViewCell6-5
)
在ItemViewCell
中,我们为编辑和删除上下文操作定义了两个菜单项。我们将两个事件处理器((1)OnEditAction
和(2)OnDeleteAction
)分配给它们相应的上下文操作。在事件处理器中,我们调用视图模型方法Update
和Delete
来执行所需的操作。
让我们查看ItemsViewModel
中Update
和Delete
函数的源代码,如下所示:
public async void Update(Item item) {
if (item == null) {
return;
}
await Shell.Current.Navigation.PushAsync(
new FieldEditPage(async (string k, string v, //(1)
bool isProtected) => {
item.Name = k;
item.Notes = v;
await dataStore.UpdateItemAsync(item); //(2)
}, item.Name, item.Notes, true));
}
public async Task Delete(Item item) {
if (item == null) {
return;
}
if (Items.Remove(item)) {
_ = await dataStore.DeleteItemAsync(item.Id); //(3)
}
else {
throw new NullReferenceException("Delete item error");
}
}
在ItemsViewModel
中,要编辑或更新项目,(1),我们使用一个名为FieldEditPage
的内容页面来执行编辑过程。在调用FieldEditPage
的构造函数时,一个匿名函数作为参数传递。当用户在FieldEditPage
中完成编辑时,将调用此函数。在此函数中,(2),调用IDataStore
接口的UpdateItemAsync
方法来更新项目。
删除操作相当直接。我们可以简单地从 IDataStore
接口调用 DeleteItemAsync
方法 (3) 来消除该条目。
一旦实现了 CRUD 操作,我们的应用程序将具备密码管理应用程序所需的必要功能。我们可以通过注册新用户来创建一个新的数据库。在创建新数据库后,我们可以登录以访问我们的数据。此外,在生成条目和组之后,我们有能力根据需要修改或删除它们。
摘要
在本章中,我们首先介绍了设计原则。随后,我们深入探讨了 SOLID 设计原则,并分享了从我们应用程序开发中获得的见解。其中最重要的 SOLID 原则是 DIP。DI 是一种将 DIP 应用于实际实施的技术。在我们的应用程序中,我们利用 .NET MAUI 内置的 DI 服务来解耦依赖关系,使我们能够将服务的实现与接口分离。
我们积累了关于 .NET MAUI 的丰富知识,并通过用实际实现替换 MockDataStore
成功完成了我们的应用程序实现。我们在新的 IDataStore
服务之上建立了 CRUD 操作,从而实现了一个功能齐全的密码管理应用程序。
尽管我们在应用程序中集成了基本功能,但用户通常期望密码管理应用程序中具有额外的期望功能,例如指纹扫描和一次性密码。其中一些功能是平台特定的,这需要了解平台集成。在下一章中,我们将深入探讨各种平台集成主题,以进一步提高我们的应用程序。
进一步阅读
-
《面向 ASP.NET 开发者的 SOLID 原则与设计模式入门》,作者 Bipin Joshi
-
Autofac 是一个用于 .NET Core、ASP.NET Core、.NET 4.5.1+ 以及更多环境的 控制反转(IoC)容器:
autofac.org/
-
Simple Injector 是一个支持 .NET 4.5 和 .NET Standard 的 DI 容器:
simpleinjector.org/
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第七章:使用平台特定功能
在过去的六章中,我们开发了一个基本的密码管理器应用程序。到目前为止,所有平台特定功能都由 .NET MAUI 管理,使得开发的功能看起来是平台无关的。然而,处理平台特定功能并非总是可以避免的。在本章中,我们将深入探讨这些功能的实现。
我们将探讨如何在支持的平台上利用设备功能。通过应用本章获得的知识,我们将把指纹支持和定制的 Markdown 视图集成到我们的应用程序中。
虽然本章确实涉及了平台特定功能,但其主要焦点不是平台特定编程。深入了解平台特定实现需要针对 Android、iOS、WinUI 等特定编程知识。鉴于这需要相当多的知识,我们不会教您为每个平台编写自己的插件或特定的 UI 控件,而是会考虑创建这些元素所涉及的一些高级概念。为了帮助您熟悉这些概念,我们的重点将放在如何扩展这些功能并将它们集成到我们的应用程序中。
要使用 .NET 进行原生应用程序开发,您需要为每个平台编写一本专门的书籍。您可以参考 进一步阅读 部分,了解更多关于使用 Xamarin 进行 Android 和 iOS 原生应用程序开发的信息。
本章将涵盖以下主题:
-
实现平台特定代码
-
.NET MAUI 插件
-
自定义控件
技术要求
要测试和调试本章中的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。请参阅 第一章,使用 .NET MAUI 入门 中的 开发环境设置 部分,以获取详细信息。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter07
.
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter07 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书中源代码的信息,请参阅 第二章,构建我们的第一个 .NET MAUI 应用程序 中的 管理本书中的源代码 部分。
实现平台特定代码
在.NET MAUI 应用程序开发领域,我们可能会遇到许多需要编写特定平台代码的实例。在本节中,我们将探讨实现此类代码的指南。随后,我们将在本章中探讨两种最常见需要编写特定平台代码的场景。第一种场景涉及访问.NET MAUI API 中不可直接使用的平台功能。第二种场景可能需要创建自定义控件或自定义现有控件。
在深入探讨如何访问平台 API 之前,区分.NET MAUI API 和平台 API 这两个术语非常重要。在第一章 开始使用.NET MAUI中,我们有以下.NET MAUI 应用程序的架构图(图 7.1)。
图 7.1: .NET MAUI 架构
由于架构图中的大多数组件都是开源的,我已经为这些组件编号以便讨论。您可以在进一步阅读部分引用这些编号,以在 GitHub 上找到相应的源代码:
-
.NET MAUI API (6) 是.NET MAUI 框架提供的跨平台 API。这些 API 在各个平台之间保持一致性和统一性。
-
平台 API (7)(8)(9)(10),另一方面,涉及由底层目标平台(Android、iOS/macOS 和 Windows)提供的特定平台 API。这些 API 允许您访问每个平台特有的本地功能、设备和功能。
通常,要将平台代码集成到.NET MAUI 中,我们可以利用条件编译、接口以及部分类和方法等技术。
条件编译
在.NET MAUI 中,可以通过定义针对每个平台的编译器指令来使用条件编译调用特定平台的代码。这些指令允许你在编译过程中根据目标平台包含或省略特定的代码段。
在随后的代码片段中,Android 上的 Markdown 编码有所不同。通过使用条件编译,我们可以独立管理 Android 代码:
public void DisplayMarkdown(string markdown)
{
#if !ANDROID
string markDownTxt = HttpUtility.JavaScriptStringEncode(markdown);
#else
string markDownTxt = markdown;
#endif
MainThread.BeginInvokeOnMainThread(async () =>
{
await InvokeJsMethodAsync("MarkdownToHtml", markDownTxt);
});
}
这种模块化方法使得在保持共享代码库的同时,有效地使用特定平台的 API,从而促进了更顺畅的跨平台开发过程。所描述的条件编译方法通常用于简单的实现。然而,如果特定平台的实现涉及更复杂的逻辑,最好利用接口和部分类。这些提供了更结构化的方法,有助于更好地组织和管理代码,尤其是在处理复杂、特定平台的逻辑时。
接口、部分类和方法
使用接口、部分类和方法来实现平台特定功能,在构建跨平台应用程序时为开发者提供了许多好处,确保了代码组织的清洁性、可维护性和灵活性。以下是使用这些技术的优势:
-
抽象和模块化:
-
接口:接口提供了一种定义必须由平台特定类实现合约的方法,封装了平台特定代码,同时确保了不同平台实现的一致性。这促进了关注点的清晰分离,并允许模块化代码,使其更容易管理和维护。
-
部分类:部分类允许你将单个类的实现拆分到多个文件中,这在处理平台特定功能时特别有用。每个平台的实现可以分别放入不同的文件中,从而产生更干净、更有组织的代码。
-
-
代码重用性和可维护性:
使用接口允许你创建可重用的组件,这些组件可以轻松地插入到不同的平台特定实现中,而无需修改共享代码。这提高了可维护性,因为平台特定代码的变化不会影响应用程序的其他部分,从而减少了错误的可能性并简化了更新。
部分方法作为部分类中的可选方法实现。它们允许你在共享代码中定义方法签名,而不提供实现。平台特定代码可以在需要时提供实现,否则可以留空。这种方法通过避免不必要的空方法实现,使代码库更干净,并有助于在平台之间保持更一致的结构。
-
灵活性和可测试性:
接口在实现平台特定功能时提供了灵活性,因为不同的实现可以轻松地交换用于测试目的或支持未来平台。它允许依赖注入,使得在开发和测试期间编写单元测试和模拟平台特定组件变得更加容易。
为了利用.NET MAUI 不支持的平台功能,我们通常创建称为插件的组件。在接下来的部分中,我们将使用指纹插件作为一个案例研究,通过接口、部分类或抽象类来深入了解.NET MAUI 插件的实现。
.NET MAUI/Xamarin 插件
为了以跨平台的方式利用平台功能,我们通常创建称为插件的组件。在.NET MAUI 插件(或 Xamarin 插件)中,我们建立跨平台 API 来访问原生平台功能或服务。这些插件使你能够在共享项目中编写平台无关的代码,同时同时在每个平台上(Android、iOS、macOS 和 Windows)利用原生功能。
插件抽象了平台特定的代码,使您能够在共享项目中使用标准化的 API 来访问原生功能。这简化了开发,并帮助您维护一个更整洁、更易于阅读的代码库,遵循模型-视图-视图模型(MVVM)模式。
虽然我使用了“.NET MAUI 插件”这个术语,但重要的是要注意,它并不仅限于.NET MAUI。实际上,可以开发一个同时被.NET MAUI 和 Xamarin.Forms 使用的插件。通常,插件是一个多目标.NET 项目,开发者可以决定支持的平台数量。本章关于插件的内容也适用于.NET MAUI Blazor 混合应用。我们将在下一章深入探讨.NET MAUI Blazor 混合应用的开发。
由于插件开发可以相当灵活和强大,插件库的兼容性由个别开发者决定。为了标准化社区中的各种插件,让我们在下一节中评估.NET MAUI 或 Xamarin 插件的演变路径。
.NET MAUI/Xamarin 插件的演变
在以前,为了在没有内置跨平台接口的情况下利用设备功能,我们通常可以找到由社区开发的 Xamarin 插件。由 Xamarin 开发者创建的 Xamarin 插件以跨平台格式打包。然而,这些插件缺乏标准化,可能会导致存在多个针对相同设备功能的插件。
随着 Xamarin 生态系统的发展和成熟,Xamarin.Essentials 被引入作为一种全面、统一的替代方案。通过将流行的插件整合到一个单一的多平台库中,它简化了在 Android、iOS 和 Windows 设备上使用原生 API 的过程。
Xamarin.Essentials 既是一个库也是一个命名空间。在这个命名空间中,我们可以访问硬件接口,例如电池、手电筒、振动、地理位置传感器等。
随着 NET MAUI 的出现,Xamarin.Essentials 经历了进一步的发展,结果在Microsoft.Maui
命名空间下形成了一系列单独的命名空间,如图 7.2 所示。
图 7.2:Xamarin.Essentials 的演变
在 Xamarin.Essentials 中,所有功能都整合在一个单一的命名空间下,从而形成一个庞大且扁平的库。相比之下,.NET MAUI 通过将功能划分为多个命名空间,采用了更精细的设计,如图 7.1 所示。
命名空间 | 描述 |
---|---|
Microsoft.Maui.ApplicationModel |
在这个命名空间中,它包含允许访问平台特定应用程序级信息和活动的 API。这些包括应用操作、应用信息、浏览器、启动器、主线程、地图、权限和版本跟踪等示例。 |
Microsoft.Maui.ApplicationModel.Communication |
在这个命名空间中,我们可以访问各种通信服务,包括联系人、电子邮件、网络、电话拨号器、短信和网页身份验证功能。 |
Microsoft.Maui.ApplicationModel.DataTransfer |
剪贴板和共享 API 可以在这个命名空间中找到。 |
Microsoft.Maui.Devices |
在这个命名空间中,我们有能力访问各种硬件传感器和加速器,包括电池、设备显示、设备信息、设备传感器、手电筒、地理编码、地理位置、触觉反馈和振动。 |
Microsoft.Maui.Media |
我们可以在这个命名空间中访问视频和照片,例如媒体选择器、截图、语音合成或单位转换器。 |
Microsoft.Maui.Storage |
要访问偏好设置或安全存储中的各种本地存储,我们可以使用这个命名空间。在这里,我们可以找到一个跨平台的文件选择器和文件系统辅助工具。 |
表 7.1:Microsoft.Maui 中的设备功能
尽管列表 7.1 中列出的跨平台 API 允许我们访问各种设备功能,但仍有一些平台功能不可用。在这种情况下,我们必须要么实现自己的解决方案,要么利用社区开发的插件。例如,没有可用的跨平台 API 支持指纹功能。为了在我们的应用中集成指纹功能,我们需要依赖社区开发的插件。
由于.NET MAUI API 目前不支持指纹功能,我们将使用一个名为Plugin.Fingerprint
的开源插件,该插件之前在PassXYZ.Vault
的 Xamarin 版本中使用过。在本章中,我们将利用相同的插件来促进 PassXYZ.Vault 的.NET MAUI 版本的指纹支持。Plugin.Fingerprint
是一个可以支持.NET MAUI 和 Xamarin.Forms 的库的例子。
Plugin.Fingerprint 的介绍
在本次会议中,我们将利用Plugin.Fingerprint
作为一个案例研究来展示.NET MAUI 插件的实现。您可以在以下 GitHub URL 找到我们将要使用的指纹插件:github.com/smstuebe/xamarin-fingerprint
。
要在我们的项目中使用Plugin.Fingerprint
,我们可以通过以下命令将包添加到项目中:
dotnet add package Plugin.Fingerprint
为了实现一个插件,通常的做法是首先定义一个接口。这个接口作为访问插件提供的功能的一种方式。
具体实现分为两个组件,跨平台方面和特定平台方面,这些是通过部分类或抽象类实现的。
图 7.3:Plugin.Fingerprint
图 7.3 展示了 Plugin.Fingerprint
类的类图。很明显,Plugin.Fingerprint
建立了一个名为 IFingerprint
的接口。一个抽象类 FingerprintImplementationBase
执行这个接口的实现。这个抽象类负责跨平台功能,并概述了为特定平台实现指定的抽象方法。每个平台都有一个单独的类 FingerprintImplementation
,定义了特定平台的实现。在下面的代码块中,我们将检查 IFingerprint
的代码:
using System.Threading;
using System.Threading.Tasks;
namespace Plugin.Fingerprint.Abstractions
{
public interface IFingerprint
{
Task<FingerprintAvailability> GetAvailabilityAsync(
bool allowAlternativeAuthentication = false);
Task<bool> IsAvailableAsync(bool allowAlternativeAuthentication = false);
Task<FingerprintAuthenticationResult> AuthenticateAsync( AuthenticationRequestConfiguration authRequestConfig, CancellationToken cancellationToken = default);
Task<AuthenticationType> GetAuthenticationTypeAsync();
}
}
IFingerprint
接口定义了四个方法:
-
GetAvailabilityAsync
检查指纹认证的可用性。 -
IsAvailableAsync
作为GetAvailabilityAsync
的包装器,提供更简单的访问方式。 -
AuthenticateAsync
处理实际的认证,使用指纹数据。 -
GetAuthenticationTypeAsync
允许用户检索当前可用的认证类型。
现在,让我们检查实现 IFingerprint
接口的 FingerprintImplementationBase
:
public abstract class FingerprintImplementationBase : IFingerprint
{
public async Task<FingerprintAuthenticationResult>
**AuthenticateAsync**(
AuthenticationRequestConfiguration authRequestConfig,
CancellationToken cancellationToken = default) {
if (authRequestConfig is null)
throw new ArgumentNullException(nameof(authRequestConfig));
var availability = await GetAvailabilityAsync(
authRequestConfig.AllowAlternativeAuthentication);
if (availability != FingerprintAvailability.Available) {
var status = availability == FingerprintAvailability.Denied ?
FingerprintAuthenticationResultStatus.Denied :
FingerprintAuthenticationResultStatus.NotAvailable;
return new FingerprintAuthenticationResult {
Status = status,
ErrorMessage = availability.ToString() };
}
return await NativeAuthenticateAsync(
authRequestConfig, cancellationToken);
}
public async Task<bool> **IsAvailableAsync**(
bool allowAlternativeAuthentication = false) {
return await GetAvailabilityAsync
(allowAlternativeAuthentication)
== FingerprintAvailability.Available;
}
public abstract Task<FingerprintAvailability>
**GetAvailabilityAsync**(
bool allowAlternativeAuthentication = false);
public abstract Task<AuthenticationType>
**GetAuthenticationTypeAsync**();
protected abstract Task<FingerprintAuthenticationResult>
**NativeAuthenticateAsync**(
AuthenticationRequestConfiguration authRequestConfig,
CancellationToken cancellationToken);
}
在 FingerprintImplementationBase
类中,AuthenticateAsync
方法通过调用 NativeAuthenticateAsync
方法实现。后者定义为在平台层实现的抽象方法。
它还定义了 GetAvailabilityAsync
和 GetAuthenticationTypeAsync
为抽象方法,这些方法随后在平台层实现。
IsAvailableAsync
方法简单地调用 GetAvailabilityAsync
并比较返回值。
为了实例化 IFingerprint
接口,Plugin.Fingerprint
使用一个名为 CrossFingerprint
的类。这个类利用创建型设计模式,结合懒加载,在运行时生成 IFingerprint
接口实例,如下面的代码所示:
public partial class CrossFingerprint {
private static Lazy<IFingerprint> _implementation = //(1)
new Lazy<IFingerprint>(CreateFingerprint,
LazyThreadSafetyMode.PublicationOnly);
public static IFingerprint Current { //(2)
get => _implementation.Value;
set {
_implementation = new Lazy<IFingerprint>(() => value);
}
}
static IFingerprint CreateFingerprint() { //(3)
#if NETSTANDARD2_0
throw NotImplementedInReferenceAssembly();
#else
return new FingerprintImplementation();
#endif
}
public static void Dispose() {
if (_implementation != null && _implementation.IsValueCreated)
{
_implementation = new Lazy<IFingerprint>(CreateFingerprint,
LazyThreadSafetyMode.PublicationOnly);
}
}
private static Exception NotImplementedInReferenceAssembly() {
return new NotImplementedException("This functionality is not implemented in the portable version of this assembly. You should reference the NuGet package from your main application project in order to reference the platform-specific implementation.");
}
}
在 CrossFingerprint
中,定义了一个名为 Current
的静态属性 (2),可以用来获取 IFingerprint
的实例。属性 Current
返回一个 Lazy<IFingerprint>
对象的值,该对象由变量 _implementation
(1) 指定,实现了懒加载。在 Lazy<IFingerprint>
中,使用工厂方法 CreateFingerprint
(3) 创建 IFingerprint
实例。
现在我们已经介绍了 Plugin.Fingerprint
,让我们探索如何使用此插件在我们的应用程序中集成指纹支持。
使用 Plugin.Fingerprint 支持指纹功能
要使用 Plugin.Fingerprint
集成指纹功能,我们必须首先在我们的项目中配置它,然后再进行任何代码修改。这一步涉及将 NuGet 包 Plugin.Fingerprint
添加到我们的项目文件中,具体如下:
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="EJL.MauiHybridWebView" Version="1.0.0-preview3" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
**<****PackageReference****Include****=****"Plugin.Fingerprint"****Version****=****"3.0.0-beta.1"** **/>**
<PackageReference Include="PassXYZLib" Version="2.1.2" />
</ItemGroup>
目前,请忽略 NuGet 包 EJL.MauiHybridWebView
。我们将在本章的后面部分深入讨论它。
由于用户必须在每个平台上访问特定于设备的功能,因此在我们的应用程序的相应配置文件中配置所需的权限是至关重要的。
对于 Android 平台,需要在AndroidManifest.xml
文件中请求特定的权限:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- only if you target android below level 28 -->
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
对于 iOS 平台,我们需要在Info.plist
中执行相同的操作,如下所示:
<key>NSFaceIDUsageDescription</key>
<string>Need your face to unlock secrets!</string>
将指纹功能集成到我们的应用程序中是我们希望实现的目标。通过使用 MVVM 模式,我们最初可以在我们的视图中集成此指纹功能。随后,我们可以在我们的 XAML 页面上使用此功能。
我们的用户将使用指纹认证进行登录,因此我们可以将指纹插件集成到LoginViewModel
中。用户将通过LoginPage
进行登录过程。然而,在LoginPage
中使用指纹认证之前,他们必须首先在我们的应用设置中启用指纹功能。这些设置的实现可以在SettingsPage
中找到。IFingerprint
、LoginViewModel
、LoginPage
和SettingsPage
之间的关系如图 7.4 所示:
图 7.4:插件.Fingerprint 集成
由于我们正在使用依赖注入来管理依赖项,因此需要首先在MauiProgram.cs
中找到的配置中包含IFingerprint
接口,如下所示:
builder.Services.AddSingleton(typeof(IFingerprint), CrossFingerprint.Current);
完成上述设置和配置后,现在让我们查看以下提供的LoginViewModel
代码:
public partial class LoginViewModel : ObservableObject {
private LoginService _currentUser;
ILogger<LoginViewModel> _logger;
private readonly IFingerprint _fingerprint;
public LoginViewModel(LoginService user,
ILogger<LoginViewModel> logger, IFingerprint fingerprint) { //(1)
_currentUser = user;
_logger = logger;
_fingerprint = fingerprint;
}
...
[RelayCommand(CanExecute = nameof(ValidateFingerprintLogin))]
private async Task FingerprintLogin() { //(2)
var cancel = new CancellationTokenSource();
var dialogConfig = new AuthenticationRequestConfiguration(
Username,
Properties.Resources.fingerprint_login_message) {
CancelTitle = "Cancel fingerprint login",
FallbackTitle = "Use Password",
AllowAlternativeAuthentication = true,
};
var result = await _fingerprint.AuthenticateAsync(
dialogConfig, cancel.Token);
if (result.Authenticated) {
Password = await _currentUser.GetSecurityAsync();
if (!string.IsNullOrWhiteSpace(Password)) {
await Login();
}
else {
_logger.LogWarning("GetSecurityAsync() error.");
}
}
else {
_logger.LogWarning("Failed to login with fingerprint.");
}
}
...
public async void CheckFingerprintStatus() { //(3)
_currentUser.Username = Username;
var password = await _currentUser.GetSecurityAsync();
IsFingerprintIsAvailable =
await _fingerprint.IsAvailableAsync();
IsFingerprintEnabled = IsFingerprintIsAvailable &&
!string.IsNullOrWhiteSpace(password);
}
[ObservableProperty]
private bool isFingerprintEnabled = false; //(4)
[ObservableProperty]
private bool isFingerprintAvailable = false; //(5)
...
public async Task<bool> AuthenticateAsync(string reason, //(6)
string? cancel = null, string? fallback = null,
string? tooFast = null) {
CancellationTokenSource cancelToken;
cancelToken = new CancellationTokenSource();
var dialogConfig = new AuthenticationRequestConfiguration(
"Verify your fingerprint", reason) {
CancelTitle = cancel,
FallbackTitle = fallback,
AllowAlternativeAuthentication = false
};
dialogConfig.HelpTexts.MovedTooFast = tooFast;
var result = await _fingerprint.AuthenticateAsync(
dialogConfig, cancelToken.Token);
return result.Authenticated;
}
}
列表 7.1:LoginViewModel.cs
(epa.ms/LoginViewModel7-1
)
在LoginViewModel
中,我们通过构造函数依赖注入获取IFingerprint
接口的实例 (1) 并将其保存在成员变量_fingerprint
中。然后,使用此IFingerprint
实例来实现指纹状态验证和认证功能。
已实现CheckFingerprintStatus
(3) 函数来评估设备的性能和状态,具体确定设备是否支持指纹识别以及是否为当前用户启用。该函数将更新IsFingerprintEnabled
(4) 和IsFingerprintAvailable
(5) 属性。
要启用指纹认证,FingerprintLogin
(2) 被集成以通过指纹识别执行登录。使用AuthenticateAsync
(6) 函数进行指纹验证;然而,它并不执行登录过程。
在实现视图模型后,我们可以专注于开发用户界面。为了集成指纹功能,用户最初需要激活它。我们为此使用设置页面。当指纹功能启用时,用户可以使用指纹访问系统。此登录功能被集成到LoginPage
中。
指纹设置
请参考图 7.5 中的指纹配置。用户界面设计明显简约,仅使用开关控件来激活或禁用指纹功能。
图 7.5: SettingsPage
SettingsPage
的用户界面相对简单,这使我们能够跳过讨论其 XAML。相反,我们将专注于检查列表 7.2 中的SettingsPage
的后台代码文件:
public partial class SettingsPage : ContentPage {
private LoginService _currentUser;
ILogger<LoginViewModel> _logger;
private readonly LoginViewModel _viewModel;
public SettingsPage(LoginViewModel viewModel, //(1)
LoginService user, ILogger<LoginViewModel> logger) {
InitializeComponent();
BindingContext = _viewModel = viewModel;
_currentUser = user;
_logger = logger;
Title = Properties.Resources.menu_id_settings;
}
private void SetFingerprintSwitcher() {
FingerprintSwitcher.IsEnabled =
_viewModel.IsFingerprintAvailable;
FingerprintSwitcher.On = _viewModel.IsFingerprintEnabled;
if (_viewModel.IsFingerprintAvailable) {
FingerprintSwitcher.Text =
Properties.Resources.settings_fingerprint_remark;
}
else {
FingerprintSwitcher.Text =
Properties.Resources.settings_fingerprint_disabled;
}
}
protected override void OnAppearing() { //(2)
base.OnAppearing();
…
try {
_viewModel.CheckFingerprintStatus(); //(3)
}
catch (Exception ex) {_logger.LogError($"{ex}");}
SetFingerprintSwitcher(); //(4)
}
private async void OnTimerTappedAsync(object sender,
System.EventArgs e) ...
private async void SetResultAsync(bool result) {
if (result) {
try {
await _currentUser.SetSecurityAsync(_currentUser.Password);
_viewModel.IsFingerprintEnabled = true;
}
catch (Exception ex) {
_logger.LogError(
"SettingsPage: in SetResultAsync, {ex}", ex);
}
}
else {
FingerprintSwitcher.Text = "Turn on fingerprint error.";
}
SetFingerprintSwitcher();
}
private async void OnSwitcherToggledAsync(object sender, //(5)
ToggledEventArgs e) {
if (!_viewModel.IsFingerprintAvailable) { return; }
if (e.Value) {
try {
string data = await _currentUser.GetSecurityAsync();
if (data == null) {
var status = await _viewModel.AuthenticateAsync( //(6)
Properties.Resources.fingerprint_login_message);
SetResultAsync(status);
}
}
catch (Exception ex) {_logger.LogError("{ex}", ex); }
}
else {
_ = await _currentUser.DisableSecurityAsync();
}
}
}
列表 7.2: SettingsPage.xaml.cs
(epa.ms/SettingsPage7-2
)
在SettingsPage
中,我们通过依赖注入引入了LoginViewModel
(1) 和LoginService
。我们重写生命周期方法OnAppearing
(2) 来通过调用CheckFingerprintStatus
(3) 评估指纹支持。
随后,我们执行函数SetFingerprintSwitcher
(4) 来更新 UI。当用户切换开关时,事件处理器OnSwitcherToggleAsync
(5) 被激活。在此事件处理器中,我们通过调用AuthenticateAsync
(6) 验证指纹,并随后在安全存储中保存主密码。如果不使用指纹认证,我们将从安全存储中移除主密码以禁用此功能。
使用指纹登录
在SettingsPage
中激活指纹功能后,用户可以利用指纹登录,如图 7.6 所示。启用指纹功能后,密码字段旁边会出现指纹图标。点击此图标,用户可以访问指纹登录用户界面。
图 7.6: LoginPage
我们可以通过查看以下提供的 XAML 代码来检查此指纹图标的实现:
<ImageButton x:Name="fpButton" Grid.Row="1" Grid.Column="2"
VerticalOptions="End" IsVisible="{Binding IsFingerprintEnabled}"
Command="{Binding **FingerprintLoginCommand**}"
BackgroundColor="White" BorderColor="White"
HeightRequest="32" WidthRequest="32" BorderWidth="0">
<ImageButton.Source>
<FontImageSource FontFamily="FontAwesomeSolid"
Glyph="{x:Static styles:FontAwesomeSolid.Fingerprint}"
Color="{DynamicResource Primary}" />
</ImageButton.Source>
</ImageButton>
指纹图标被设计为ImageButton
,只有当通过数据绑定将IsFingerprintEnabled
设置为 true 时才会可见。点击图标,将触发FingerprintLoginCommand
。通过利用 MVVM 模式,我们只需将此ImageButton
添加到 XAML 页面,而其余逻辑则在视图模型中实现。
现在我们已经完成了.NET MAUI/Xamarin 插件的介绍。我们的应用程序指纹支持是通过使用Plugin.Fingerprint
实现的。在下一节中,我们将探讨另一个案例——创建自定义控件或自定义现有控件。
自定义控件
.NET MAUI 控件建立在原生控件的基础上。可能会有这样的情况,我们希望这些原生控件能够表现出定制的行为以满足我们的特定需求。此外,还可能存在我们需要创建自己的控件的情况,尤其是当所需的原生控件在 .NET MAUI 中不可用时。
在本节中,我们将讨论 .NET MAUI 的跨平台控件实现,并使用示例来说明如何通过新功能增强跨平台控件。
在我们的应用程序中,我们可能希望将密码输入作为安全便签来展示,而不是在详情页上的标准密码输入。为了提升用户体验,我们旨在支持 Markdown 文本而不是纯文本。由于 .NET MAUI 目前没有提供 Markdown 视图控件,我们必须创建自己的。我们将使用这个场景作为示例来演示如何扩展现有控件的功能以开发新的控件。
处理器概述
在 .NET MAUI 中,处理器在渲染过程中起着至关重要的作用,通过将跨平台视图元素(控件)转换为相应的平台特定原生 UI 组件。处理器的实现方式与 Plugin.Fingerprint
实现中采用的方法相似,其中使用特定于控制的接口来区分跨平台视图和原生视图。由于我们计划使用 WebView
控件来实现 MarkdownView
,我们将使用 WebView
控件作为示例来解释处理器。
WebView
在 .NET MAUI 应用程序中显示网页内容和 HTML。在 .NET MAUI 中,每个支持的平台(iOS、Android、macOS 和 Windows)都有对应于 WebView
控件的处理器,将其映射到原生控件。例如,iOS 和 macOS 使用 WKWebView
,Android 使用 WebView
,Windows 使用 WebView2
。
图 7.7: .NET MAUI 处理器
如 图 7.7 所示,架构由三层组成:虚拟视图、处理器和原生视图。跨平台控件通过处理器实现的特定于控制的接口与原生视图交互。在 WebView
的情况下,它通过 IWebView
接口与 WebViewHandler
通信。
IWebView
接口由 WebViewHandler
实现,它使用部分类来分离平台特定的实现,例如:
-
WebViewHandler.iOS.cs
-
WebViewHandler.Android.cs
-
WebViewHandler.WinUI.cs
-
WebViewHandler.MacCatalyst.cs
在处理器中,VirtualView
属性被定义为对跨平台控制的引用,而 PlatformView
属性则指向原生视图。使用属性映射器来建立跨平台控制 API 与原生视图 API 之间的连接。
.NET MAUI 处理程序的实现说明了 .NET MAUI 和 Xamarin.Forms 之间的架构差异。有关 .NET MAUI 处理程序的更多信息,您可以参考 Microsoft 文章“使用处理程序创建自定义控件”,该文章位于“进一步阅读”部分。这篇文章提供了关于使用处理程序创建和应用自定义控件的深入见解。
使用 HybridWebView
在我们引入处理程序时,我们将研究使用 WebView
实现 Markdown 视图的实现。显示 Markdown 文本的常见方法是将它转换为 HTML,然后在 WebView
中呈现 HTML 内容。我们的挑战在于将 Markdown 文本转换为 HTML。有一些 .NET 库可以完成这项任务,例如可以在 github.com/xoofx/markdig
找到的 Markdig。
选择这种方法需要我们在运行时将 Markdown 文本转换为 HTML,然后组装一个 HTML 页面。由于将使用 WebView
,一个更直接的方法是直接将 Markdown 文本传递给 WebView
,并允许 WebView
使用 JavaScript 库来解释文本。然而,默认的 WebView
缺乏足够的 JavaScript 互操作性来支持此过程。作为解决方案,我们将使用由 Eilon Lipton 开发的开源项目 HybridWebView
来实现我们的 Markdown 视图,该项目可以在 github.com/Eilon/MauiHybridWebView
找到。
HybridWebView
通过支持更复杂的 JavaScript 互操作性来改进 WebView
。这种增强允许以下功能:
-
我们可以通过使用 .NET MAUI 原始资源来配置一个 JavaScript 库和资源,将它们打包成类似静态网站的方式。在运行时,我们可以在
WebView
中加载它,它类似于 单页应用(SPA)。有一个示例应用演示了如何将现有的 React 应用程序转换为在WebView
中运行的 .NET MAUI 应用程序。 -
HybridWebView
还增强了事件处理,并允许 JavaScript 函数调用 .NET 函数,反之亦然。
在 HybridWebView
的帮助下,让我们探讨如何将 MarkdownView
集成到我们的应用中。
实现 MarkdownView
MarkdownView
的概念是将我们希望显示的 Markdown 文本直接传递给 HybridWebView
。这使用了一个 JavaScript 库来渲染文本。为了实现这一点,我们需要一个基于 JavaScript 的 Markdown 解释器。鉴于 Markdown 在众多网络应用中被广泛使用,为此目的有几个 JavaScript 库可用。我们将使用一个紧凑且高效的库,称为 Marked,可以在 github.com/markedjs/marked
找到。
利用 Marked 库非常简单。你可以简单地按如下方式调用它:
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Marked in the browser</title>
</head>
<body>
<div id="content"></div>
<script src="img/marked.min.js">
</script>
<script>
document.getElementById('content').innerHTML =
marked.parse('This is rendered by **marked**.');
</script>
</body>
</html>
我们可以看到,通过将其传递给marked.parse
函数来实现 Markdown 字符串的显示。
然而,在我们继续之前,我们必须确定将这个 Markdown 字符串传递给WebView
的适当时机。如果我们简单地从.NET 方面调用一个 JavaScript 函数,HTML 文件中的 JavaScript 函数可能还没有完全准备好。因此,等待 HTML 文件在WebView
中完全加载是至关重要的。
通过使用 jQuery 库,我们可以获得一个通知.NET 当 HTML 页面准备就绪的事件,如图 7.8所示。
图 7.8:HTML 和 JavaScript 资源
如图 7.8所示,我们在Resources
文件夹下的Raw
目录中创建了一个名为hybrid_root
的文件夹。在hybrid_root
文件夹中,我们将 JavaScript 文件存储在js
文件夹中,将 CSS 文件存储在styles
文件夹中。
js
文件夹包含了 Marked 和 jQuery 的本地副本,当显示 Markdown 字符串时,无需进行网络请求。图 7.8的左侧显示了hybrid_app.xhtml
的内容。在这个文件中,我们使用 jQuery 函数$(document).ready()
在页面加载时向.NET 方面发送一个事件。一旦.NET 方面接收到这个事件,它就可以通过传递一个 Markdown 字符串来调用MarkdownToHtml
函数。这样,我们就可以使用MarkdownView
显示任何 Markdown 字符串。
在解释了 HTML 和 JavaScript 的必要准备工作之后,让我们来看看在.NET 方面我们需要实现什么。我们可以从查看列表 7.3中的MarkdownView
实现开始。
public class MarkdownView : HybridWebView.HybridWebView
{
public MarkdownView() {
HybridAssetRoot = "hybrid_root"; //(1)
MainFile = "hybrid_app.xhtml"; //(2)
}
public void DisplayMarkdown(string markdown) {
#if !ANDROID
string markDownTxt =
HttpUtility.JavaScriptStringEncode(markdown); //(3)
#else
string markDownTxt = markdown;
#endif
MainThread.BeginInvokeOnMainThread(async () => { //(4)
await InvokeJsMethodAsync("MarkdownToHtml", markDownTxt);
});
}
}
列表 7.3:MarkdownView.cs
(epa.ms/MarkdownView7-3
)
在MarkdownView
中,我们必须将网络资源的根设置为hybrid_root
(1),hybrid_app.xhtml
(2)是需要加载的 HTML 文件。
创建了一个名为DisplayMarkdown
的函数来展示 Markdown 文本,它将调用 JavaScript 函数MarkdownToHtml
(4)来显示文本。由于 Markdown 文本的编码在 Android、Windows 和 iOS/macOS 之间有所不同,因此对于 Windows 和 iOS/macOS 平台,必须调用字符串编码函数JavaScriptStringEncode
(3)。在实现MarkdownView
之后,我们可以开发用户界面来渲染 Markdown 文本,如列表 7.4所示:
<?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"
xmlns:vw="clr-namespace:PassXYZ.Vault.Views"
x:Class="PassXYZ.Vault.Views.NotesPage"
Title="{Binding Title}">
<vw:MarkdownView x:Name="markdownview"
RawMessageReceived="OnHybridWebViewRawMessageReceived"
VerticalOptions="FillAndExpand"/>
</ContentPage>
列表 7.4:NotesPage.xaml
(epa.ms/NotesPage7-4
)
在列表 7.4中,定义了一个名为markdownview
的MarkdownView
。注册了一个事件处理器OnHybridWebViewRawMessageReceived
,其实现可以在列表 7.5中的代码后文件中找到:
using PassXYZ.Vault.ViewModels;
namespace PassXYZ.Vault.Views;
public partial class NotesPage : ContentPage {
ItemDetailViewModel _viewModel;
public NotesPage(ItemDetailViewModel viewModel) {
InitializeComponent();
BindingContext = _viewModel = viewModel;
#if DEBUG
markdownview.EnableWebDevTools = true;
#endif
}
protected override void OnAppearing() { //(1)
base.OnAppearing();
markdownview.Reload();
}
private void OnHybridWebViewRawMessageReceived(object sender,
HybridWebView.HybridWebViewRawMessageReceivedEventArgs e) {
markdownview.DisplayMarkdown(_viewModel.MarkdownText); //(2)
}
}
列表 7.5:NotesPage.xaml.cs
(epa.ms/NotesPage7-5
)
在 NotesPage.xaml.cs
文件中,我们重写了 OnAppearing
函数 (1)。此函数确保每次页面加载时都会重新加载 MarkdownView
。
OnHybridWebViewRawMessageReceived
事件处理器是我们调用 DisplayMarkdown
(2) 来展示 Markdown 文本的地方。图 7.9 显示了完成后的用户界面。
图 7.9:MarkdownView
随着 MarkdownView
的引入,我们获得了扩展现有控件以适应必要的新功能的知识。
摘要
在本章中,我们介绍了在 .NET MAUI 项目中实现特定平台代码的指南。在许多情况下,可能需要实现特定平台的代码。无论我们旨在创建插件还是自定义控件,从接口定义开始都是至关重要的。预定义的接口有助于实现跨平台和本地实现的无缝分离。为了实现接口,我们有选择使用抽象类或部分类,这样我们可以将每个平台的实现分散到单独的文件中。
HybridWebView
的引入为我们打开了集成或重用 JavaScript 库的大门。通过构建混合解决方案,我们可以利用 .NET 和 JavaScript 生态系统的优势。HybridWebView
使开发包含各种 JavaScript 框架的 .NET 解决方案成为可能。
完成了密码管理器应用程序的当前版本后,我们现在已经到达了本书第一部分的结尾。
在 第二部分 中,我们将深入探讨 .NET MAUI 中的 Blazor 混合应用程序,这是在 Xamarin.Forms 中不存在的新特性。通过使用 Blazor,我们可以将一些前沿的前端开发方法引入 .NET MAUI 开发。
进一步阅读
-
Xamarin 生物识别/指纹插件:
github.com/smstuebe/xamarin-fingerprint
-
.NET MAUI HybridWebView:
github.com/Eilon/MauiHybridWebView
-
Marked – Markdown 解析器:
marked.js.org
-
Android 平台上的 Xamarin 移动应用程序开发:
www.oreilly.com/library/view/xamarin-mobile-application/9781785280375/
-
在 macOS 上开始使用 Xamarin 进行开发:使用 Xamarin.iOS 和 Visual Studio for Mac 创建 iOS、watchOS 和 Apple tvOS 应用程序:
www.oreilly.com/library/view/beginning-xamarin-development/9781484231326/
-
.NET MAUI 源代码:
github.com/dotnet/maui
-
.NET for Android 源代码 – .NET for Android 和 Xamarin.Android 都是从这个仓库构建的,具有不同的构建配置:
github.com/xamarin/xamarin-android
-
.NET for iOS 源代码 – .NET for iOS 和 Xamarin.iOS 都是从这个仓库构建的,具有不同的构建配置:
github.com/xamarin/xamarin-macios
-
.NET for Mac 源代码 – .NET for iOS 和.NET for Mac 共享相同的代码库,但具有不同的构建配置:
github.com/xamarin/xamarin-macios
-
使用处理程序创建自定义控件:
learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/create?view=net-maui-8.0
-
介绍共享类库 – 从单个项目中多目标 Xamarin.Forms 和.NET MAUI:
egvijayanand.in/2022/05/25/introducing-shared-class-library-multi-target-xamarin-forms-and-dotnet-maui-from-a-single-project/
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码以获取 40%的折扣码。
限时优惠
第二部分
实现 .NET MAUI Blazor
在本书的第二部分,我们将探讨构建 .NET MAUI Blazor 混合应用程序的过程。Blazor 是由微软开发的一个现代网络框架,允许开发者使用 C# 和 Razor 语法而不是 JavaScript 来创建交互式网络应用程序。你可能熟悉 JavaScript 框架,如 React、Angular 和 Vue 等。与大多数依赖 JavaScript 的前端框架不同,Blazor 使用 C# 作为替代。
此外,Blazor 还可以用于开发 Blazor 混合应用程序。在 Blazor 混合应用程序中,Razor 组件通过嵌入的 Web View 控件在设备上以原生方式运行,这使得应用程序能够访问设备功能,就像原生应用程序一样。
在 第二部分 中,我们将重新设计我们的应用程序为 Blazor 混合应用程序。我们将介绍 Blazor 绑定,这是一个新主题,允许你在 .NET MAUI 框架内利用 Blazor 的力量,扩展你的应用程序开发选项。
第二部分 包含以下章节:
-
第八章,介绍 Blazor 混合应用程序开发
-
第九章,理解 Blazor 路由和布局
-
第十章,实现 Razor 组件
第八章:介绍 Blazor 混合应用程序开发
在.NET MAUI 中,构建用户界面(UI)的另一种方法是使用 Blazor。Blazor 是由微软开发的现代 Web 框架,允许开发人员使用 C#和 Razor 语法而不是 JavaScript 来创建交互式 Web 应用程序。此外,Blazor 还可以用于.NET MAUI 应用程序的开发,作为 Blazor 混合应用程序的一部分。Blazor 的基本构建块是 Razor 组件,当使用 Blazor 和 Blazor 混合时,这些组件可以在原生和 Web 应用程序之间重用。与 XAML UI 相比,Blazor UI 提供了更高的可重用性,包括原生和 Web 应用程序。在本章中,我们将介绍 Blazor 及其在各种场景中的实现。此外,我们将介绍 Razor 组件,并解释如何使用这些组件开发 Blazor 混合应用程序。
本章我们将涵盖以下主题:
-
Blazor 是什么?
-
如何创建一个.NET MAUI Blazor 项目
-
如何创建一个新的 Razor 组件
技术要求
要测试和调试本章中的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。请参阅第一章,使用.NET MAUI 入门中的开发环境设置部分以获取详细信息。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter08
。
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter08 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书中源代码的信息,请参阅第二章,构建我们的第一个.NET MAUI 应用程序中的管理本书中的源代码部分。
Blazor 是什么?
Blazor 是一个用于使用 HTML、CSS 和 C#构建 Web 应用程序的框架。当使用 Blazor 在 ASP.NET Core 中开发 Web 应用程序时,您有两个选项可以考虑:Blazor Server 和 Blazor WebAssembly(Wasm)。此外,.NET MAUI 使 Blazor 能够创建原生应用程序,引入了第三种变体——Blazor 混合应用程序。
在 Web 应用程序开发中,任务通常涉及创建前端 UI 和后端服务。后端服务可以通过 RESTful API 或远程过程调用(RPCs)访问。由 HTML、CSS 和 JavaScript 组成的 UI 组件在浏览器中加载并显示为网页。在 ASP.NET Core 架构中,与用户交互相关的组件可以在服务器上渲染。这种托管模型被称为 Blazor Server。或者,我们可以在浏览器中执行大多数 UI 组件,这被称为 Blazor Wasm 托管模型。
在某些情况下,应用程序可能需要访问特定于设备的特性,例如传感器或摄像头;为了满足这些需求,开发者通常创建原生应用程序。然而,Blazor 提供了一个额外的解决方案——Blazor 混合应用程序。现在,让我们更详细地讨论 Blazor 托管模型。
托管模型
Blazor 是一个用于构建 Web UI 组件的 Web 框架,通常被称为 Razor 组件,可以通过各种方法进行托管。这些组件可以在 ASP.NET Core(Blazor 服务器)的服务器端或 Web 浏览器的客户端(Blazor Wasm)中运行。
此外,Razor 组件可以通过在嵌入的 Web View 控件中渲染来实现,用于原生移动和桌面应用程序(Blazor 混合)。尽管托管模型有所不同,但构建 Razor 组件的过程保持一致。因此,相同的 Razor 组件可以在所有托管模型中无需修改地使用。
Blazor 服务器
在传统的 Web 应用程序开发中,用户交互的逻辑是在服务器端执行的。在 MVC 设计模式中,处理用户交互是应用程序架构的一个组成部分。当浏览器中发生用户交互时,它会被发送回服务器进行处理。因此,整个页面可能会根据用户的请求重新加载。
为了提高性能,Blazor 服务器采用了一种类似于单页应用程序(SPA)框架的设计。当响应用户请求时,Blazor 服务器处理它,并且只将涉及用户操作的文档对象模型(DOM)更改发送到浏览器。如图 8.1 所示,Blazor 服务器的处理逻辑与 SPA 类似,关键区别在于 Razor 组件是在服务器上渲染,而不是在浏览器上。为了促进客户端和服务器之间的实时通信,使用了开源库 SignalR 作为服务器和浏览器之间的连接。
图 8.1:Blazor 服务器
Razor 组件与 Blazor 组件
人们可能会在 Blazor 和 Razor 之间感到困惑。Razor 作为 ASP.NET 的模板引擎在 2010 年被引入。Razor 语法是一种标记语法,开发者可以在其中将 C#代码嵌入到 HTML 页面中。Blazor 是一个使用 Razor 语法作为编程语言的 Web 框架,大约在 2018 年被引入。Blazor 是一个基于组件的框架,一个 Blazor 应用程序由 Razor 组件组成。换句话说,Blazor 是 Razor 组件的托管模型。Blazor 组件和 Razor 组件被广泛互换使用,但正确的术语是 Razor 组件。
一个 Razor 组件位于具有 .razor
扩展名的文件中,并在运行时编译为 .NET 类。这个 .razor
文件也可以拆分为两个文件,分别具有 .razor
和 .razor.cs
扩展名。这个想法与我们在本书第一部分学到的 XAML 和代码后置非常相似。
在 Blazor 服务器中,应用的状态在服务器上维护,客户端不需要 .NET 运行时。这种模型可以导致初始加载时间更快,因为浏览器只下载一个小型的初始负载。然而,它需要与服务器保持持续连接,这可能会影响可伸缩性并引入 UI 更新的延迟。
Blazor Wasm
Blazor Wasm 是一种托管模型,它将 Razor 组件渲染到网页浏览器中。如图 8.2 所示,Razor 组件被加载到浏览器中,并使用 .NET 运行时编译成 Wasm:
图 8.2:Blazor Wasm
在浏览器中,启动页面加载 .NET 环境和 Razor 组件。这些 Razor 组件在运行时通过 .NET 中间语言(IL)解释器编译成 Wasm,该解释器管理 DOM 变化。这个过程通常被称为 即时(JIT)编译。使用 JIT,编译在运行时进行,与 预编译(AOT)编译相比,性能较慢。Blazor Wasm 应用可以编译成 AOT Wasm 以提高运行时性能,但代价是下载大小大大增加。
随着 .NET 8 的引入,.NET 8 中引入了一个名为 jiterpreter 的新运行时功能,该功能使 .NET IL 解释器能够实现部分 JIT 支持,从而提高运行时性能。
Blazor Wasm 应用可以作为静态文件部署,托管在各种网络服务器平台或静态站点托管提供商上。可选地,Blazor Wasm 还可以通过 API 调用来与服务器通信,以检索数据或卸载复杂操作。
Wasm
Wasm 是一种基于栈的虚拟机的二进制指令格式。Wasm 被大多数现代网页浏览器支持。使用 Wasm,我们可以使用许多编程语言来开发客户端组件。
作为单页应用(SPA)框架,Blazor 可以与其他基于 JavaScript 的 SPA 框架进行比较,例如 React、Angular 和 Vue。存在许多 JavaScript SPA 框架,表 8.1 包含了 Blazor 和 React 的比较。尽管也可以使用其他 JavaScript 框架进行比较,但选择 React 的决定是基于 React Native 可以用于开发混合应用的事实。这与其他 .NET MAUI Blazor 的一些相似之处,将在下一节讨论:
功能 | React | Blazor Wasm | Blazor 服务器 |
---|---|---|---|
语言 | JavaScript/JSX/TypeScript | C# | C# |
运行时 | JavaScript 引擎 | Wasm | ASP.NET Core |
支持渐进式网络应用(PWA) | 是 | 是 | 否 |
托管 | 可灵活选择 | 可灵活选择 | ASP.NET Core |
静态站点托管 | 是 | 是 | 否 |
将处理卸载到客户端 | 是 | 是 | 否 |
性能 | 轻量级且性能出色 | 由于.NET 运行时的额外下载时间,首次加载较重 | 与 JavaScript 框架类似性能 |
表 8.1:Blazor 与 React 的比较
JavaScript 和 Wasm 都是现代浏览器的基本功能。使用 JavaScript 或 Wasm 的 SPA 框架在浏览器中运行无需额外的依赖。Blazor Wasm 支持 JavaScript Interop,允许使用 JavaScript 组件与 Blazor 一起使用。
Blazor 和 React 都支持 PWA 开发,这使得单页应用(SPA)能够在离线模式下运行。
Blazor Wasm 和 React 都在客户端运行,使用客户端渲染(CSR)。因此,仅依赖这些库构建 Web 应用可能会对搜索引擎优化(SEO)和初始加载性能产生负面影响。这是因为屏幕上内容的正确渲染需要相当长的时间。实际上,为了显示完整的 Web 应用,浏览器必须下载整个应用包,解析其内容,执行它,然后渲染结果。这个过程对于大型应用可能需要几秒钟。
另一方面,Blazor Server 采用服务器端渲染(SSR)来提升网页的性能和用户体验,尤其是对于网络连接或设备较慢的用户。SSR 通过仅发送首次渲染所需的 HTML 和 CSS 来减少网页的初始加载时间和带宽消耗,而 CSR 则需要下载并执行大量 JavaScript 代码才能开始渲染。此外,SSR 还能加快页面间的交互和过渡,因为服务器可以在用户点击链接后立即预渲染即将显示的页面并将其传输到浏览器。
SSR 的另一个优点是它可以提高网页的 SEO 和社交媒体分享效果,因为服务器可以为每个页面提供完整的 HTML 内容和元数据给爬虫和机器人。CSR 可能会使爬虫和机器人更难访问和索引网页内容,因为它们可能无法执行 JavaScript 或等待异步数据获取。
SSR 还可以确保网页的内容和布局在不同浏览器和设备上保持一致,因为服务器可以处理浏览器兼容性和响应性问题。
在最近的发展中,Blazor 和 JavaScript 框架都已转向混合渲染模式,以利用 CSR 和 SSR 的双重优势。随着.NET 8 的引入,现在有了自动渲染模式。此模式在.NET Wasm 运行时可以快速加载(在 100 毫秒内)时使用基于 Wasm 的渲染。这通常发生在运行时已经被下载并缓存,或者当使用高速网络连接时。如果这些条件不满足,自动渲染模式将默认切换到服务器渲染模式,同时后台下载.NET Wasm 运行时。
Blazor 混合
我们还可以将 Blazor 用作桌面或移动原生框架的 UI 层,这些框架被称为 Blazor 混合应用。在这样的应用中,Razor 组件通过集成的 WebView 控件在设备上以原生方式渲染。Wasm 不参与其中,因此应用程序具有与原生应用相同的性能。
在图 8.3中,我们可以观察到混合应用允许我们利用BlazorWebView控件在嵌入的 WebView 中构建和执行 Razor 组件。BlazorWebView控件可以在.NET MAUI 和 Windows 桌面环境中访问。通过结合使用.NET MAUI 和 Blazor,我们可以在移动、桌面和 Web 平台之间使用一套 Web UI 组件。
图 8.3:BlazorWebView
Blazor 混合应用可以使用.NET MAUI、WPF 或 Windows Forms 进行开发。这意味着我们可以创建一个作为 WPF、Windows Forms 或.NET MAUI 应用的 Blazor 混合应用。在第二部分中,我们将专注于构建.NET MAUI Blazor 混合应用。
我们已经介绍了三种 Blazor 主机模型。在这些模型中,Blazor 服务器和 Blazor 混合应用都提供了对.NET API 的全面支持。然而,Blazor Wasm 应用仅限于使用.NET API 的子集。
Blazor 绑定
除了我们之前介绍的主机模型之外,还有一种特殊的 Blazor 应用类型,称为 Blazor 绑定。Blazor 绑定或移动 Blazor 绑定是微软的一个实验性项目,旨在扩展 Blazor 的功能,使其不仅成为创建 Web 应用的跨平台技术,而且最终也成为移动开发的技术。
Blazor 是一个框架,允许您使用 C#而不是 JavaScript 构建交互式 Web 界面,适用于客户端和服务器代码。它依赖于 Wasm 在浏览器中直接运行 C#代码。
使用移动 Blazor 绑定,开发者可以编写 Blazor 语法和组件,但它们将在 iOS、macOS、Windows 和 Android 上以原生控件的形式渲染——类似于.NET MAUI XAML 应用的操作。
移动 Blazor 绑定的关键组件包括:
-
.NET 运行时:自.NET 5 以来,我们在所有支持平台上都有一个共同的 BCL。
-
Blazor:Blazor 允许在浏览器中使用 Wasm 运行 .NET Standard 兼容的代码。它还提供了一种使用 Razor 文件(
.razor
)定义 UI 组件的方法。 -
.NET MAUI:.NET MAUI 是一个用于从单个共享代码库构建 iOS、macOS、Android 和 Windows 的原生 UI 的框架。
-
BlazorBindings.Maui:这是 Oleksandr Liakhevych 发布的 NuGet 包。此包提供了基本的 Blazor Bindings 功能。
Mobile Blazor Bindings 允许开发者在移动应用程序开发中使用现有的 Blazor 开发技能,其中开发者可以使用 Razor 语法与 C# 构建用于 Web UI 或原生 UI 的 UI 组件。
重要的是要注意,截至 2024 年 1 月,Mobile Blazor Bindings 仍然处于实验阶段,不建议用于生产应用程序。Microsoft 的原始源代码仓库可以在这里找到:github.com/dotnet/MobileBlazorBindings/
。
目前,该项目处于预览阶段,并且没有处于活跃维护状态。然而,一些更新已从 Oleksandr Liakhevych 的 GitHub 仓库合并。Oleksandr Liakhevych 正在积极开发和维护他自己的仓库:github.com/Dreamescaper/BlazorBindings.Maui
。
我已经在实现本书第一、二章节的源代码时应用了 Oleksandr Liakhevych 的 Blazor Bindings。实现的代码可以在指定的分支中找到:BlazorBindings/chapter01
和 BlazorBindings/chapter02
:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition
。
要了解 Blazor Bindings,让我们比较 2nd/chapter01
和 BlazorBindings/chapter01
分支中的代码和截图,如下面的代码和 图 8.4 所示。2nd/chapter01
分支的代码是从 .NET MAUI 项目模板创建的,而 BlazorBindings/chapter01
分支中的代码是使用 Blazor 的等效实现。
MainPage.xaml
(2nd/chapter01
) – epa.ms/MainPage-CH01
<?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="PassXYZ.Vault.MainPage">
<ScrollView>
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Image Source="dotnet_bot.png" HeightRequest="185"
Aspect="AspectFit"
SemanticProperties.Description=
"dot net bot in a race car number eight" />
<Label Text="Hello, World!"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />
<Label Text="Welcome to .NET Multi-platform App UI"
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description=
"Welcome to dot net Multi platform App U I" />
<Button x:Name="CounterBtn" Text="Click me"
SemanticProperties.Hint=
"Counts the number of times you click"
Clicked="OnCounterClicked"
HorizontalOptions="Fill" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
在 MainPage.xaml
中,包含了一个 ScrollView
控件。这个 ScrollView
包含了一个 Image
、两个 Label
实例和一个 Button
,所有这些都被包含在 VerticalStackLayout
控件中。
MainPage.Razor
(BlazorBindings/chapter01
) – epa.ms/MainPage-BlazorBindings
@page "/main"
<ContentPage>
<ScrollView>
<VerticalStackLayout Spacing="25"
Padding="new(30,0)"
VerticalOptions="LayoutOptions.Center">
<Image Source="dotNetBotSource" HeightRequest="200"
HorizontalOptions="LayoutOptions.Center" />
<Label Text="Hello, World!" FontSize="32"
HorizontalOptions="LayoutOptions.Center" />
<Label Text=
"Welcome to .NET Multi-platform Blazor Bindings App UI"
FontSize="18"
HorizontalOptions="LayoutOptions.Center" />
<Button Text="@ButtonText"
HorizontalOptions="LayoutOptions.Fill"
OnClick="OnCounterClicked" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
@code {
ImageSource dotNetBotSource =
ImageSource.FromFile("dotnet_bot.png");
int count = 0;
string ButtonText => count switch
{
0 => "Click me",
1 => $"Clicked 1 time",
_ => $"Clicked {count} times"
};
void OnCounterClicked()
{
count++;
}
}
在 MainPage.razor
中,我们复制了相同的 UI,但使用了 Razor 语法。我们将在本章后面更深入地探讨 Razor 语法的细节。同时,我们可以比较 图 8.4 中的 UI。它们看起来似乎是相同的:
图 8.4:使用 Razor 语法重新创建主页面 UI
.NET 8 Blazor 托管模型的新特性
在我们之前关于 Blazor 托管模型的讨论中,我们提到了 Blazor 渲染模式的话题。在.NET 8 之前,ASP.NET Core 支持两种渲染模式:SSR 和 CSR。这些渲染模式在编译时与项目类型相关联。
然而,.NET 8 引入了一个新功能,以利用 SSR 和 CSR 的双重优势——交互式自动渲染。这种新的渲染模式最初使用服务器端的 ASP.NET Core 进行内容渲染和交互。
在 Blazor 捆绑包下载并激活 Wasm 运行时之后,它将切换到客户端的.NET Wasm 运行时进行后续的渲染和交互。交互式自动渲染通常提供最快的应用程序启动体验。
这与之前的 ASP.NET Core 版本有显著的不同。使用.NET 8,开发者可以在编译时和运行时指定单个组件的渲染模式。此外,这些渲染模式可以在组件和页面级别进行定义。
为了说明这一点,在以下示例中,我们将 SSR 应用于Dialog
组件:
<Dialog @rendermode="InteractiveServer" />
要在页面级别指定渲染模式,请参考以下代码片段:
@page "..."
@rendermode InteractiveServer
在此代码中,整个页面将在服务器端进行渲染。
支持在运行时指定渲染模式后,SSR 和 CSR 之间的界限变得模糊。开发者现在可以在单个应用程序内灵活切换渲染模式。
在.NET 8 之前,开发者可以使用 Visual Studio 模板创建 Blazor 服务器应用或 Blazor Wasm 应用。但.NET 8 引入了一个新的项目模板——Blazor Web 应用。我们将在下一部分进一步探讨这些项目模板。
.NET MAUI 和 Blazor 应用的项目模板
Blazor 服务器、Blazor Wasm 和 Blazor 混合应用在运行时使用不同的托管模型,因此它们具有不同的功能。Blazor Web 应用是.NET 8 中引入的新项目模板。使用此模板,我们可以在运行时混合渲染模式。在这本书中,我们的重点是 Blazor 混合应用。
我们可以使用命令行或 Visual Studio 创建不同的项目类型。
为了节省空间,我们将仅使用命令行来检查项目模板。要列出已安装的项目模板,我们可以运行以下命令:
dotnet new --list
These templates matched your input:
Template Name Short Name Language
------------------ ------------------- ----------
.NET MAUI App maui [C#]
.NET MAUI Blazo... maui-blazor [C#]
.NET MAUI Class... mauilib [C#]
Blazor Server App blazorserver [C#]
Blazor Web App blazor [C#]
Blazor WebAssem... blazorwasm [C#]
Razor Class Lib... razorclasslib [C#]
Class Library classlib [C#],F#,VB
在前面的列表中,我们过滤掉了不相关的项目类型。为了更好地理解不同的项目类型,我们可以回顾表 8.2中描述的摘要:
模板名称/简称 | SDK | 目标框架 |
---|---|---|
Blazor Wasm 应用(blazorwasm) | Microsoft.NET.Sdk.BlazorWebAssembly | net8.0 |
Blazor 服务器应用(blazorserver) | Microsoft.NET.Sdk.Web | net8.0 |
Blazor Web 应用(blazor) | Microsoft.NET.Sdk.WebMicrosoft.NET.Sdk.BlazorWebAssembly | net8.0 |
.NET MAUI 应用(maui) | Microsoft.NET.Sdk | net8.0-androidnet8.0-iosnet8.0-maccatalystnet8.0-windows10.0.19041.0 |
.NET MAUI Blazor 应用程序 (maui-blazor) | Microsoft.NET.Sdk.Razor | net8.0-androidnet8.0-iosnet8.0-maccatalystnet8.0-windows10.0.19041.0 |
.NET MAUI 类库 (mauilib) | Microsoft.NET.Sdk | net8.0-androidnet8.0-iosnet8.0-maccatalystnet8.0-windows10.0.19041.0 |
Razor 类库 (razorclasslib) | Microsoft.NET.Sdk.Razor | net8.0 |
类库 (classlib) | Microsoft.NET.Sdk | net8.0 |
表 8.2:.NET MAUI 和 Blazor 相关的项目类型
表 8.2 中所示的项目类型可以分为两组——Blazor 应用程序和 .NET MAUI 应用程序。让我们更详细地研究这些组。
Blazor 应用程序
在 Blazor Server 和 Blazor Wasm 模板中,目标框架都是 net8.0,但它们使用不同的 SDK。Blazor Server 应用程序可以使用 Microsoft.NET.Sdk.Web
完全利用服务器的功能,而 Blazor Wasm 通过 Microsoft.NET.Sdk.BlazorWebAssembly
只能访问有限的 .NET API。在 Blazor Web 应用程序的模板中,它混合了 Blazor Server 和 Blazor Wasm。
要在 Blazor Server 和 Blazor Wasm 之间共享 Razor 组件,可以使用 Razor 类库。这个库使用 Microsoft.NET.Sdk.Razor
。此外,可以跨所有 .NET 8.0 应用程序共享的标准 .NET 类库使用 Microsoft.NET.Sdk
。
.NET MAUI 应用程序
在 .NET MAUI 应用程序中,可以使用 Microsoft.NET.Sdk
创建基于 XAML 的 .NET MAUI 应用程序,而对于 .NET MAUI Blazor 应用程序,则使用 Microsoft.NET.Sdk.Razor
。这两种项目类型针对相同的目标框架集合。
为了共享组件,可以使用标准的 .NET 类库。如果需要在共享组件中集成 .NET MAUI 功能,可以使用 .NET MAUI 类库。例如,PassXYZLib
是一个 .NET MAUI 类库。尽管 .NET 类库和 .NET MAUI 类库都使用相同的 Microsoft.NET.Sdk
,但它们针对不同的框架。
创建新的 .NET MAUI Blazor 项目
要学习如何开发 Blazor 混合应用程序,我们需要将我们的 PassXYZ.Vault
项目升级以适应基于 Blazor 的用户界面。幸运的是,我们不需要从头开始——我们可以简单地修改我们的现有项目以支持 Blazor UI。通过这样做,我们可以在同一个项目中高效地构建基于 XAML 的应用程序和混合应用程序。在我们将 Blazor UI 集成到我们的应用程序之前,让我们首先创建一个具有相同应用程序名称的新 .NET MAUI Blazor 项目。这将允许我们在将我们的当前项目转换为 .NET MAUI Blazor 项目时引用新项目。
我们可以选择通过命令行或 Visual Studio 创建此新的 .NET MAUI Blazor 项目。在本节中,我们将演示这两种方法。
使用 dotnet 命令行生成 .NET MAUI Blazor 项目
让我们先使用.NET 命令行创建一个新项目。这可以在 Windows 和 macOS 平台上完成。要创建新项目,我们将使用表 8.2中提到的简称maui-blazor
:
dotnet new maui-blazor -o PassXYZ.Vault
The template ".NET MAUI Blazor Hybrid App" was created successfully.
在之前的命令中,我们通过指定简称maui-blazor
来选择项目模板,并将PassXYZ.Vault
指定为项目名称。创建项目后,它可以构建和执行:
C:\ > dotnet build -t:Run -f net8.0-android
MSBuild version 17.8.0-preview-23367-03+0ff2a83e9 for .NET
Determining projects to restore...
All projects are up-to-date for restore.
PassXYZ.Vault -> C:\PassXYZ.Vault\bin\Debug\net8.0-android\PassXYZ.Vault.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:01:43.79
在build
命令中,我们将net8.0-android
指定为目标框架来测试我们的新应用。我们可以将目标框架替换为其他支持的框架,如net8.0-ios
、net8.0-maccatalyst
或net8.0-windows10.0.19041.0
。
参考图 8.6 查看此新应用的屏幕截图及其项目结构。通过这种方式,我们已经使用命令行成功创建了一个新项目。现在,让我们探讨如何使用 Windows 上的 Visual Studio 完成相同的任务。
在 Windows 上使用 Visual Studio 创建.NET MAUI Blazor 混合应用
要使用 Visual Studio 在 Windows 上创建.NET MAUI Blazor 混合应用项目,首先启动 Visual Studio 并选择“创建新项目”,然后在搜索框中输入MAUI
以过滤可用选项。如图 8.5 所示,从项目模板列表中选择“.NET MAUI Blazor 混合应用”:
图 8.5:创建新的.NET MAUI Blazor 混合应用项目
使用向导完成项目创建后,我们可以选择net8.0-android
作为构建和运行项目的目标框架。为了节省空间,本节中将使用 Android 平台作为我们的主要示例;然而,如果您愿意,也可以探索和测试其他目标框架。
运行新项目
要执行项目,请在 Visual Studio 中按F5或Ctrl + F5,或从命令行使用dotnet
命令。请参考图 8.6 中的截图,说明此过程和项目结构。
图 8.6:屏幕截图和项目结构
使用模板创建的应用 UI 类似于 SPA,顶部有导航菜单,适用于 Android 设备。在屏幕更大的 Windows 上执行时,导航菜单以并排方式显示在屏幕左侧。项目结构紧密模仿标准.NET MAUI 应用,但有以下显著差异:
-
wwwroot/
:此文件夹是网页静态文件的根目录。 -
Pages/
:此文件夹包含应用中的 Razor 页面。 -
Shared/
:此文件夹包含可共享的 Razor 组件。 -
Main.razor
:这是 Blazor 应用的主页。 -
_Imports.razor
:这是一个在文件夹或项目级别导入 Razor 组件的辅助工具。
要了解.NET MAUI
应用和.NET MAUI Blazor
应用之间的区别,分析它们各自的启动代码是有帮助的。
.NET MAUI Blazor 混合应用的启动代码
所有.NET MAUI 应用都包含一个名为MauiProgram.cs
的文件,该文件处理它们的启动和配置。让我们检查.NET MAUI Blazor 混合应用的启动代码:
namespace PassXYZ.Vault;
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(); //(1)
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); //(2)
#endif
builder.Services.AddSingleton<WeatherForecastService>();
return builder.Build();
}
}
在.NET MAUI Blazor 混合应用中,我们可以看到以下 Blazor 配置已被添加:
(1) BlazorWebView
是通过调用AddMauiBlazorWebView()
添加的。
(2) 通过调用AddBlazorWebViewDeveloperTools()
添加开发者工具以进行调试。
启动过程的其余部分与基于 XAML 的.NET MAUI 应用的启动过程相同。在App.xaml.cs
文件中,从App
类继承的MainPage
属性被分配给MainPage.xaml
的一个实例,如下所示:
namespace PassXYZ.Vault;
public partial class App : Application {
public App() {
InitializeComponent();
MainPage = new MainPage();
}
}
XAML 应用和 Blazor 混合应用之间的主要区别在于MainPage.xaml
中使用的 UI 控件。让我们仔细检查MainPage.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"
xmlns:local="clr-namespace:PassXYZ.Vault"
x:Class="PassXYZ.Vault.MainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView HostPage="wwwroot/index.xhtml"> //(1)
<BlazorWebView.RootComponents> //(2)
<RootComponent Selector="#app" //(3)
ComponentType="{x:Type local:Main}" /> //(4)
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>
在MainPage.xaml
中,只定义了一个名为BlazorWebView
的 UI 元素。通过HostPage
属性和嵌套节点RootComponent
,我们可以有效地自定义BlazorWebView
。
我们可以将BlazorWebView
视为类似于浏览器。在浏览器中,UI 通常是从 HTML 文件加载的。HostPage
属性(1)用于指示应在 web 视图控件中加载的静态 HTML 页面。在我们的特定情况下,这指的是wwwroot/index.xhtml
,我们将在列表 8.1中对其进行检查。
在这个静态 HTML 文件中,我们必须指定 Razor 组件的位置并确定根组件。这两个都可以通过嵌套节点RootComponent
的属性来指定(2)。
在上一章中,我们发现 XAML 标签最终映射到一个 C#类。在这种情况下,BlazorWebView
和RootComponent
也都是 C#类。
在RootComponent
中,我们利用Selector
属性(3)定义一个 CSS 选择器,该选择器确定根 Razor 组件在我们应用程序中的位置。在我们的具体实例中,我们使用index.xhtml
文件中定义的#app
CSS 选择器。ComponentType
属性(4)建立了根组件的类型,在我们的例子中是Main
。
最后,让我们重新审视一下之前提到的 HTML 文件(index.xhtml
)。
<!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>PassXYZ.Vault</title>
<base href="/" />
<link rel="stylesheet"
href="css/bootstrap/bootstrap.min.css" /> //(1)
<link href="css/app.css" rel="stylesheet" />
<link href="PassXYZ.Vault.styles.css" rel="stylesheet" />
</head>
<body>
<div class="status-bar-safe-area"></div>
<div id="app">Loading...</div> //(2)
<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"> //(3)
</script>
</body>
</html>
列表 8.1:index.xhtml
(epa.ms/index8-1
)
我们可以看到index.xhtml
是一个简单的 HTML 文件:
(1) 它使用了 Bootstrap 框架的 CSS 样式表。
(2) id
选择器指定为app
,然后传递到MainPage.xaml
文件中RootComponent
的Selector
属性。
(3) 在index.xhtml
的末尾加载了一个名为blazor.webview.js
的 JavaScript 文件。这是负责初始化BlazorWebView
运行时环境的部分。
有了这些,我们已经提供了 .NET MAUI Blazor 混合应用的概述。在下一节中,我们将用利用 Blazor 的 UI 替换基于 XAML 的 UI。
迁移到 .NET MAUI Blazor 混合应用
在上一节中,我们创建了一个新的混合应用,该应用将作为迁移现有应用程序的参考。我们不必从头开始,可以通过调整项目配置来利用我们当前应用程序中的 XAML 和 Blazor UI。目前,我们将在单个应用程序中实现 XAML 和 Blazor UI 的组合,并在下一章中完全过渡到 Blazor。
要将我们的应用程序转换为 .NET MAUI Blazor 混合应用程序,必须实施以下修改。
通过在项目文件中将 Microsoft.NET.Sdk
替换为 Microsoft.NET.Sdk.Razor
来更改 SDK,因为 .NET MAUI Blazor 混合应用程序依赖于不同的 SDK。
-
在
PassXYZ.Vault.csproj
项目文件中,存在以下行:<Project Sdk="Microsoft.NET.Sdk">
-
这行需要替换为以下内容:
<Project Sdk="Microsoft.NET.Sdk.Razor">
-
将新建立的项目中的后续文件夹转移到我们的应用程序中:
-
wwwroot
-
Shared
-
-
将新项目中的后续文件转移到我们的应用程序中:
-
_Imports.razor
-
MainPage.xaml
-
MainPage.xaml.cs
-
Main.razor
-
-
通过添加以下代码来修改
MauiProgram.cs
:Builder.Services.AddMauiBlazorWebView(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); #endif
要查看这些更改的提交历史,请访问 epa.ms/Blazor7-1
。
通过这些调整,我们已经对配置进行了所有必要的修改,现在可以继续下一步。然而,在我们深入这些更改的工作之前,让我们首先熟悉基本的 Razor 语法。
理解 Razor 语法
Blazor 应用程序由 Razor 组件组成。如第三章使用 XAML 进行用户界面设计中讨论的,XAML 是一种起源于 XML 的语言。基于 XAML 的 UI 元素由 XAML 页面及其相应的 C# 代码隐藏文件组成。Razor 组件与此模式相似,主要区别在于 Razor 使用 HTML 作为其标记语言,C# 代码可以直接嵌入到 HTML 中。或者,我们可以选择将 C# 代码分离到代码隐藏文件中,从而在 UI 和其底层逻辑之间保持清晰的区分。
Razor 中的代码块
要创建最简单的 Razor 组件,它看起来如下所示:
<h3>Hello World!</h3>
@code {
// Put your C# code here
}
在上一个示例中,我们可以将页面设计得类似于 HTML 页面,同时在代码块中包含编程逻辑。Razor 页面或 Razor 组件作为 C# 类生成,文件名作为类名。新创建的 Razor 组件可以用作另一个 Razor 页面中的 HTML 标签。
隐式 Razor 表达式
在 Razor 语法中,我们可以使用 @
符号从 HTML 切换到 C#。这些被称为隐式 Razor 表达式。例如,我们可以使用以下隐式表达式设置 label
标签的文本为 C# 变量 currentUser.Username
:
<label>@currentUser.Username</label>
隐式表达式之间不应有空格。在隐式表达式中使用 C# 泛型是不可能的,因为尖括号(<>
)内的字符被解释为 HTML 标签。
显式 Razor 表达式
为了解决隐式表达式(例如,空白或使用泛型)带来的挑战,我们可以使用显式 Razor 表达式。这些显式表达式由一个 @
符号后跟括号组成。可以像以下示例中那样调用泛型方法:
<p>@(GenericMethod<int>())</p>
当我们打算将文本与表达式连接时,必须使用显式表达式,如下所示:
<p>@(currentUser.FirstName)_@(currentUser.LastName)</p>
在更复杂的场景中,我们可以利用显式 Razor 表达式,例如将 lambda 表达式传递给事件处理器。让我们考察一个在 C# 代码中嵌入 HTML 时使用显式 Razor 表达式的另一个实例。
表达式编码
有时,我们可能需要在 C# 代码中嵌入 HTML 字符串;然而,结果可能与我们的预期不同。
假设我们编写以下 C# 表达式:
@("<span>Hello World!</span>")
渲染后的结果将如下所示:
<span>Hello World</span>
为了保留 HTML 字符串,必须使用 MarkupString
关键字,如下所示:
@((MarkupString)"<span>Hello World</span>")
上述 C# 表达式的结果是:
<span>Hello World!</span>
这是期望的输出。随着我们创建 Razor 组件的进展,我们将更深入地探讨显式 Razor 表达式。
指令
除了 HTML 代码和 C# 代码块之外,还有一些保留关键字专门用于作为 Razor 指令。这些 Razor 指令由跟随 @
符号的隐式表达式表示,并包含特定的保留关键字。在前一节中,我们遇到了表示为 @code
的代码块。在这个例子中,@code
作为指令,包含保留关键字 code
。在本书中,我们将使用以下指令:
-
@attribute
:此用于向类添加指定的属性。 -
@code
:此用于定义代码块。 -
@implements
:此用于为生成的类实现接口。 -
@inherits
:此用于指定生成的类的父类。 -
@inject
:此用于通过依赖注入注入服务。 -
@layout
:此用于指定可路由的 Razor 组件的布局。 -
@namespace
:此用于为生成的类定义命名空间。 -
@page
:此用于定义页面的路由。 -
@using
:此类似于 C# 中的using
关键字,用于导入命名空间。
指令属性
在 Razor 页面中,HTML 标签可以充当类,属性可以作为类的成员。让我们考察以下示例:
<input type="text" @bind="currentUser.Username">
在这里,input
是一个 HTML 标签,它是一个类。属性type
作为input
标签的属性,其值是text
,这表示这个input
标签的类型。你可能已经注意到了另一个属性,@bind
,它看起来与常规属性有些不同。
它看起来像是一个 Razor 隐式表达式。实际上,它是一个隐式表达式,其中bind
是一个保留关键字。这个属性作为一个指令属性。Razor 指令和 Razor 指令属性之间的区别在于后者作为 HTML 标签的属性。在这本书中,我们将使用以下指令属性:
-
@bind
:这个指令用于数据绑定。 -
@on{EVENT}
:这个指令用于事件处理。 -
@on{EVENT}:preventDefault
:这个指令用于防止事件的默认行为。 -
@on{EVENT}:stopPropagation
:这个指令用于停止事件传播。 -
@ref
:这个指令用于提供一个引用组件实例的方法。 -
@typeparam
:这个指令用于声明一个泛型类型参数。
在熟悉了 Razor 标记语言的语法基础之后,是时候通过在我们应用程序中开发一个 Razor 组件来将其付诸实践了。
创建一个 Razor 组件
在开发.NET MAUI Blazor 混合应用程序时,我们有两种选择:要么完全使用 Blazor 构建整个 UI,要么将 Razor 组件与 XAML 组件结合。我们将首先探索第二种选择,因为我们已经在本书的第一部分完成了一个密码管理器应用程序。
使用 Razor 组件重新设计登录页面
我们旨在替换的第一个 UI 是登录页面。我们可以通过使用 Razor 页面而不是 XAML 页面来实现这一点,从而保持相同的功能。
在 Blazor 混合应用程序中,BlazorWebView
作为承载 Razor 组件的控件。我们可以将LoginPage.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"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.
WebView.Maui;
assembly=Microsoft.AspNetCore.Components.WebView.Maui"
xmlns:local="clr-namespace:PassXYZ.Vault.Pages"
x:Class="PassXYZ.Vault.Views.LoginPage"
Shell.NavBarIsVisible="False">
<b:BlazorWebView HostPage="wwwroot/login.xhtml"> //(1)
<b:BlazorWebView.RootComponents>
<b:RootComponent Selector="#login-app" //(3)
ComponentType="{x:Type local:Login}" /> //(2)
</b:BlazorWebView.RootComponents>
</b:BlazorWebView>
</ContentPage>
在上一页中,只有一个BlazorWebView
控件。我们应该关注以下方面:
(1) HostPage
属性被用来指示应该加载到BlazorWebView
中的 HTML 页面。在这个例子中,login.xhtml
(如清单 8.2所示)是指定的页面。
RootComponent
的属性指定了要使用的 Razor 组件和 CSS 选择器:
(2) ComponentType
属性指示我们将详细讨论的 Razor Login
组件。
(3) Selector
属性指示我们的 Web UI 将被加载的 CSS 选择器。我们在login.xhtml
中定义了 CSS #login-app
ID。这个login.xhtml
HTML 页面是在wwwroot
文件夹中创建和保存的。让我们在清单 8.2中看看它:
<!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>PassXYZ.Vault Login</title>
<base href="/" />
<link rel="stylesheet"
href="css/bootstrap/bootstrap.min.css" />
<link href="css/app.css" rel="stylesheet" />
<link href="PassXYZ.Vault.styles.css" rel="stylesheet" />
</head>
<body class="text-center">
<div id="login-app">Loading...</div> //(1)
<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>
清单 8.2:login.xhtml
(epa.ms/Login8-2
)
在 清单 8.2 中,我们可以看到它与之前考察过的 index.xhtml
非常相似。这里定义了 CSS ID "``login-app"
(1),其目的是加载我们的 Razor 组件:
<div id="login-app">Loading…</div>
在 .NET MAUI Blazor 混合应用模板中,默认的 CSS 框架是 Bootstrap (bootstrap.min.css
)。在撰写本文时,嵌入式 Bootstrap 版本是 5.1。然而,你可能在项目中找到更新的版本。
Bootstrap 是一个著名的 Web 开发框架,它提供了许多其使用的示例。例如,Bootstrap 网站上有一个创建登录页面的登录示例,如图 8.7 所示。我们将利用这个示例来构建我们的 Login
组件:
图 8.7:引导登录示例
你可以在 getbootstrap.com/docs/5.1/examples/sign-in/
找到这个登录示例。
此登录示例包括两个文件:
index.xhtml
(清单 8.3) 是登录页面的 UI。它定义了以下内容:
-
两个
<input>
标签用于用户名 (1) 和密码 (2) -
一个
<input>
标签 (3)用于记住用户名 -
一个
<button>
标签 (4)用于处理登录活动
它使用 Bootstrap CSS 样式和 signin.css
中定义的自身样式。
signin.css
(清单 8.4) 定义了特定于登录页面的 CSS 样式:
<!doctype html>
<html lang="en">
<head> ... </head>
<body class="text-center">
<main class="form-signin">
<form>
<img class="mb-4" src="img/bootstrap-logo.svg"
alt="" width="72" height="57">
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<input type="email" class="form-control"
id="floatingInput" placeholder="name@example.com"> //(1)
<label for="floatingInput">Email address</label>
</div>
<div class="form-floating">
<input type="password" class="form-control"
id="floatingPassword" placeholder="Password"> //(2)
<label for="floatingPassword">Password</label>
</div>
<div class="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me"> //(3)
Remember me
</label>
</div>
<button class="w-100 btn btn-lg btn-primary"
type="submit">Sign in</button> //(4)
<p class="mt-5 mb-3 text-muted">© 2017–2021</p>
</form>
</main>
</body>
</html>
清单 8.3:index.xhtml
(Bootstrap 登录示例)
在 signin.css
(清单 8.4)中,我们修改了用于 index.xhtml
登录部分的 form-signin
CSS 类:
html,
body {
height: 100%;
}
body {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
.form-signin .checkbox {
font-weight: 400;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
清单 8.4:signin.css
(Bootstrap 登录示例)
要创建一个新的 Razor 组件,首先需要在项目内创建一个名为 Pages
的文件夹。接下来,在 Visual Studio 中右键单击新创建的 Pages
文件夹,选择 添加 | Razor 组件…。将此组件命名为 Login.razor
并继续创建文件。创建完成后,将 <main>
标签之间的部分从 清单 8.3 复制并粘贴到 Razor 页面内的 <div>
标签中,如 清单 8.5 中所示:
@using System.Diagnostics
@using PassXYZ.Vault.Services
@using PassXYZ.Vault.ViewModels
@inject LoginViewModel viewModel //(1)
@inject LoginService currentUser //(2)
<div>
<main class="form-signin">
<form>
<img class="mb-4"...>
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<label for="floatingInput">Username</label>
<input type="text" @bind="@currentUser.Username" //(3)
class="form-control" id="floatingInput"
placeholder="Username">
</div>
<div class="form-floating">
<label for="floatingPassword">Password</label>
<input type="password"
@bind="@currentUser.Password" class="form-control"
id="floatingPassword" placeholder="Password"> //(4)
</div>
<div class="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me">
Remember me
</label>
</div>
<button class="w-100 btn btn-lg btn-primary"
type="submit" @onclick="OnLogin">Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2017–2021</p>
</form>
</main>
</div>
@code {
protected override void OnInitialized() {
}
private void OnLogin(MouseEventArgs e) {
viewModel.Username = currentUser.Username;
viewModel.Password = currentUser.Password;
viewModel.LoginCommand.Execute(null);
}
}
清单 8.5:Login.razor
我们使用指令注入将 LoginViewModel
实例化为 viewModel
变量 (1),并将 LoginService
实例化为 currentUser
变量 (2)。这使得我们能够通过 @
符号在 HTML 中引用 currentUser
的 Username
(3)和 Password
(4)属性。同样,我们可以定义 OnLogin
事件处理程序并将其与 onclick
事件关联。
在输入用户名和密码后,currentUser
属性会相应地填充。当点击登录按钮时,会触发 OnLogin
函数。因此,会执行视图模型的 LoginCommand
以启动登录过程。
Blazor 中的模型-视图-视图模型 (MVVM) 模式
利用 Blazor 进行 UI 设计的一个好处是,它允许我们最初使用 HTML 创建大部分 UI。在确保 UI 设计符合我们的预期后,我们再实现编程逻辑。通过采用我们在 第三章,使用 XAML 进行用户界面设计 中探讨的 MVVM 模式,我们可以在 Razor 组件开发中有效地分离责任。对于一个 Razor 组件,我们可以将 HTML 标记视为视图,将代码块视为 ViewModel。如果代码块中的逻辑变得过于复杂,我们可以选择将其分离到一个 C# 代码后文件中。
在登录页面上,我们可以继续使用来自 XAML 域的 LoginViewModel
。这是通过 LoginViewModel
中从 Blazor 到 XAML UI 的转换实现的。主要目标是展示在单个应用程序中 Blazor 和 XAML UI 的无缝集成。在下一章中,我们将完全用 Blazor UI 替换 XAML UI。
在一个 Razor 组件中,可以在单个文件中结合 HTML 和 C#,或者将它们分别放在一个 Razor 文件和一个 C# 代码后文件中,类似于 XAML。
让我们将这个概念应用到 Login.razor
上。通过将其拆分为两个文件,组件将被拆分为两个部分类,分别位于 Login.razor
和 Login.razor.cs
中,如 列表 8.6 和 列表 8.7 所示:
@namespace PassXYZ.Vault.Pages
<div>
<main class="form-signin">
<form>
<img class="mb-4"...>
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<label for="floatingInput">Username</label>
<input type="text" @bind="@currentUser.Username"
class="form-control" id="floatingInput"
placeholder="Username">
</div>
<div class="form-floating">
<label for="floatingPassword">Password</label>
<input type="password" @bind="@currentUser.
Password" class="form-control"
id="floatingPassword" placeholder="Password">
</div>
<div class="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me"> Remember me
</label>
</div>
<button class="w-100 btn btn-lg btn-primary"
type="submit" @onclick="OnLogin">Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2021–2022</p>
</form>
</main>
</div>
列表 8.6: Login.razor
(epa.ms/Login8-6
)
在 列表 8.6 中,HTML 标记仅存在于 Login.razor
中,这有效地将 UI 与底层逻辑分离,从而实现了更清晰的设计。现在,让我们来检查 列表 8.7 中的相应 C# 代码:
using Microsoft.AspNetCore.Components;
using System.Diagnostics;
using PassXYZ.Vault.Services;
using PassXYZ.Vault.ViewModels;
using Microsoft.AspNetCore.Components.Web;
namespace PassXYZ.Vault.Pages;
public partial class Login : ComponentBase {
[Inject]
LoginViewModel viewModel { get; set; } = default!;
[Inject]
LoginService currentUser { get; set; } = default!;
protected override void OnInitialized()
{
}
private void OnLogin(MouseEventArgs e)
{
viewModel.Username = currentUser.Username;
viewModel.Password = currentUser.Password;
viewModel.LoginCommand.Execute(null);
}
}
列表 8.7: Login.razor.cs
(epa.ms/Login8-7
)
在 列表 8.7 中,我们将所有代码从 @code
块转移到了 Login
类中的 C# 文件,该类继承自 ComponentBase
类。所有 Razor 组件都继承自 ComponentBase
。
你可能已经注意到了在 viewModel
和 currentUser
属性声明中使用 Inject
属性。这些属性是通过依赖注入进行初始化的。
Blazor 中的依赖注入
在 第六章,使用依赖注入进行软件设计 中,我们介绍了如何在 .NET MAUI 开发中使用依赖注入。该章节中提出的所有概念在这里同样适用;然而,Blazor 提供了额外的功能。使用 Blazor,我们可以在 HTML 和 C# 代码中利用依赖注入。
如 列表 8.5 所示,以下声明位于 Login.razor
文件的开始部分:
@inject LoginViewModel viewModel
在前面的代码中,我们通过依赖注入初始化了 viewModel
属性。这种方法使用 Razor 指令进行属性注入,在 Blazor 中比之前的版本使用起来更加简单。
当我们将它移动到 C#代码背后文件时,我们可以使用Inject
属性来完成同样的操作:
[Inject]
LoginViewModel viewModel { get; set; } = default!;
在 Web 开发中,我们经常结合使用 HTML 和 CSS 来设计网站的 UI。在 Bootstrap 示例中,存在一个signin.css
文件。现在,我们应该在哪里存储我们的 CSS 样式?我们将在下一节探讨这个话题。
CSS 隔离
在之前对 Bootstrap 登录示例的讨论中,我们提到了 HTML 文件和 CSS 文件的存在。现在,问题来了——CSS 文件应该放在哪里才能有效地在我们的登录页面上重用登录 CSS 样式?
在 HTML 设计中,使用像 Bootstrap 这样的 CSS 框架可能需要在页面级别进行样式定制。为了在 Blazor 中实现这一点,采用了一种称为 Razor 组件 CSS 隔离的技术。对于特定于组件或页面的 CSS 样式,我们可以将它们存储在具有.razor.css
扩展名的文件中。文件名应与同一文件夹中的.razor
文件相对应。例如,在我们的登录页面上,我们可以将 Bootstrap 示例中的sign-in.css
文件复制到Login.razor.css
,并根据列表 8.8中展示的进行调整:
div {
display: flex;
align-items: center;
background-color: #f5f5f5;
}
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
.form-signin .checkbox {
font-weight: 400;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
#first {
margin-top: 3em;
}
列表 8.8:Login.razor.css
(epa.ms/Login8-8
)
在Login.razor.css
中定义的样式仅应用于Login
组件的渲染输出。最后,让我们看看 Blazor 中的这个新登录 UI:
图 8.8:登录页面
在图 8.8中,从左到右,我们可以看到 Windows、iOS 和 Android 上的登录页面。我们观察到这个新 UI 的外观和感觉与 Bootstrap 登录示例非常相似,除了修改了图标。登录功能保持不变;然而,我们使用了 Blazor 来开发新的 UI。
由于 Blazor UI 是使用 Web 技术构建的,因此在不同平台上的一致性和用户体验保持一致。例如,在 Android 截图上,您可以看到在输入用户名和密码时输入字段与占位符的重叠。这种行为与您在所有三个平台上遇到的情况相符。尽管这个问题在桌面浏览器上不存在,但 BlazorWebView 的行为与传统桌面浏览器相比确实存在一些差异。因此,虽然 Blazor UI 在各个平台上保持一致性,但通过不同的浏览器访问时可能会有不同的行为。
使用这个 Razor 页面登录后,后续的编程逻辑继续与第六章中演示的依赖注入软件设计保持一致。在登录过程之后,UI 框架恢复到 XAML,因为没有进行其他修改。
本章中的代码展示了在单个应用程序中将 Blazor UI 和基于 XAML 的 UI 结合起来的潜力。然而,除非没有其他替代方案,否则建议避免这种做法。如图 8.8 所示,Blazor 和 XAML UI 使用不同的技术,这可能在开发过程中带来独特的挑战。通过混合它们,我们本质上继承了这两种类型 UI 的问题。这可能会在 UI 的设计和开发过程中引入未预见的复杂性。
摘要
本章中,我们探讨了 Blazor 以及如何开发 Blazor 混合应用程序。Blazor 是 .NET MAUI 中 UI 设计的替代解决方案。Blazor 与 XAML 之间的主要区别在于它们的外观:XAML UI 与原生界面非常相似,而 Blazor UI 则采用了 Web 应用程序的美学。在功能方面,两者提供类似的能力。此外,还可能将 Blazor 和 XAML 集成到单个应用程序中,并在两者中利用 MVVM 模式。
使用 Blazor 的一个优点是能够在 Blazor 混合应用程序和 Web 应用程序之间共享 UI 代码。如果您正在寻找兼容原生和 Web 应用程序的解决方案,.NET MAUI Blazor 可能是一个理想的选择。
在接下来的章节中,我们将过渡到在应用程序的所有 UI 中使用 Blazor。此外,我们还将讨论使用布局和路由技术进行的初始 UI 设计。
进一步阅读
-
.NET 8 预览版 2 中的 ASP.NET Core 更新:
devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-8-preview-2/#improved-blazor-webassembly-performance-with-the-jiterpreter
-
.NET 8 预览版 7 中的 ASP.NET Core 更新:
devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-8-preview-7/#auto-render-mode
-
ASP.NET Core 8.0 的新特性:
learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-8.0
-
ASP.NET Core Blazor 托管模型:
learn.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-8.0
-
ASP.NET Core Blazor 渲染模式:
learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-8.0
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第九章:理解 Blazor 路由和布局
在上一章中,我们探讨了使用 Blazor 设计登录页面的过程。尽管应用程序布局和导航层次结构仍然是基于 XAML 的,但我们的应用程序利用了结合了 Blazor 和 XAML 的混合 UI 实现。Blazor 为 .NET MAUI 应用程序提供了一种替代的 UI 设计方法。在本书的第二部分,我们将仅使用 Blazor 重建整个 UI。由于 UI 设计的初始步骤通常涉及布局和导航的实现,因此本章将介绍 Blazor 中的布局和路由概念。
我们在本章中将涵盖以下主题:
-
客户端 Blazor 路由
-
使用 Blazor 布局组件
-
实现导航元素
技术要求
要测试和调试本章的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。有关详细信息,请参阅第一章,Getting Started with .NET MAUI 中的 Development environment setup 部分。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter09
。
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter09 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书源代码的信息,请参阅第二章,Building Our First .NET MAUI App 中的 Managing the source code in this book 部分。
理解客户端路由
Blazor 的路由和布局与 XAML 领域中的 Shell 和导航概念相似。在第五章,Navigation Using .NET MAUI Shell and NavigationPage 中,我们讨论了 Shell 的路由策略时介绍了导航和 Shell。Shell 提供了一种基于 URI 的导航体验,它依赖于路由来导航到指定的页面。Blazor 的路由与此方法非常相似。
Blazor 路由允许在 Razor 页面之间实现无缝过渡。在 BlazorWebView
中渲染 Razor 页面类似于在浏览器中运行的 Web 应用程序。
在传统的 Web 应用程序中,在浏览器中加载 HTML 页面是从 Web 服务器获取页面。选择不同的路由随后会从服务器检索新页面。然而,对于 单页应用程序(SPAs)来说,这个过程略有不同。
Blazor WebAssembly 应用程序属于 SPAs 的范畴。当启动应用程序时,它会被加载到浏览器中,并且任何后续的页面导航都仅发生在客户端。这种方法被称为客户端路由,Blazor 混合应用程序也采用了这种方法。
设置 BlazorWebView
要实现客户端路由,必须在应用程序启动时安装路由器。在 .NET MAUI 应用程序中,XAML 和 Blazor 入口点都在 App.xaml.cs
中配置。要切换 UI 实现从 XAML 到 Blazor,请参考提供的代码中对 App.xaml.cs
所做的修改。
Blazor 混合应用程序在 BlazorWebView
中运行。要启动 Blazor 混合应用程序,首先需要创建一个 BlazorWebView
实例。在前一章中,我们在 LoginPage
中完成了此设置,并在登录后成功导航回 Shell。
要为整个应用程序配置 BlazorWebView
实例,需要替换 App
类中分配给 MainPage
属性的实例。为此,我们修改了 App
类的构造函数(位于 App.xaml.cs
),如下所示:
public App()
{
InitializeComponent();
#if MAUI_BLAZOR
MainPage = new MainPage(); //(1)
#else
Routing.RegisterRoute(nameof(ItemsPage),
typeof(ItemsPage));
Routing.RegisterRoute(nameof(ItemDetailPage),
typeof(ItemDetailPage));
Routing.RegisterRoute(nameof(NewItemPage),
typeof(NewItemPage));
MainPage = new AppShell();
#endif
}
我们可以使用一个符号,MAUI_BLAZOR
,来简化条件编译,使我们在构建过程中能够在 XAML 和 Blazor UI 之间切换。为了使用 Blazor UI,我们将 MainPage
属性分配给一个 MainPage
实例 (1)。在 MainPage
类中,我们定义了 BlazorWebView
控件如下:
<BlazorWebView HostPage="wwwroot/index.xhtml">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type
local:Main}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
在 BlazorWebView
中,加载一个 HTML 页面(index.xhtml
)以启动 Blazor UI 设置。让我们看看路由器配置是如何工作的。
设置 Router 组件
Blazor UI 采用基于 HTML 页面的设计,类似于从静态 HTML 页面起源的 SPA 结构。在 BlazorWebView
中,要加载的初始 HTML 页面是 index.xhtml
。这与我们在前一章中讨论的 login.xhtml
页面非常相似。
在 RootComponent
中加载的顶级 Razor 组件看起来像是 Main
组件,可以在此查看:
<Router AppAssembly="@typeof(Main).Assembly"> //(1)
<Found Context="routeData"> //(2)
<RouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound> //(3)
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this
address.</p>
</LayoutView>
</NotFound>
</Router>
列表 9.1:Main.razor
(epa.ms/Main9-1
)
如我们在 列表 9.1 中所见,我们在 Main.razor
中设置了 Router
组件 (1)。
Router
组件使用反射来扫描所有页面组件并构建一个路由表。AppAssembly
参数决定了哪些程序集将被扫描。在遇到导航事件时,路由器会查阅路由表以找到相应的路由。本质上,Router
组件是一个模板组件,我们将在下一章中进一步探讨。当找到匹配的路由时,将使用 Found
模板。相反,如果没有匹配的路由,则使用 NotFound
模板。
Found
模板 (2) 使用 RouteView
组件来渲染所选组件及其布局。布局在 DefaultLayout
属性中指定,我们将在下一节中对其进行检查。此外,要加载的新页面以及任何路由参数都通过 RouteData
类的实例传递。
如果找不到匹配项,将渲染 NotFound
模板,(3)。此模板使用 LayoutView
组件来显示错误消息。LayoutView
使用的布局通过 Layout
属性指定。
定义路由
在设置好路由器之后,我们可以继续创建页面并在这些页面内定义路由模板。然后,路由器将扫描页面中定义的路由模板以构建路由表。
在我们应用的顶层,可以通过参考 图 9.1 来建立导航层次结构和路由模板:
图 9.1:Razor 页面的导航层次结构
我们在 图 9.1 中展示了我们应用的主要页面。每个页面都有一个名称,该名称对应于 Razor 页面的类名。名称下方是路由模板。例如,对于 About
页面,路由模板可以声明如下:
@page "/about"
@page
指令包含两个组件——指令名称和路由模板。在此示例中,路由模板是 /about
,必须用引号括起来,并且始终以正斜杠 (/
) 开头。鉴于 Razor 页面的最终输出是一个 HTML 页面,导航到 Razor 页面可以像使用锚点标签 <a>
导航到网页一样处理,如下所示:
<a href="/about">About</a>
使用路由参数传递数据
当使用路由模板导航到页面时,可以通过路由参数将数据传递到页面。如果我们回想一下在 Shell 中使用查询参数传递数据的过程,路由参数的使用相当类似。
如 图 9.1 所示,在成功登录后,将显示 Items
页面,显示根组中的项目列表,如 图 9.2 所示。在此页面上,点击项目可以根据项目类型进行导航。为了识别选定的项目,将项目 Id
值作为参数传递到新页面。
图 9.2:Blazor 混合应用中的项目页面
在 Items
页面中,我们定义了以下路由模板:
@page "/group"
@page "/group/{SelectedItemId}"
第一个路由模板用于显示根页面,而第二个路由模板在选择一个组时生效。组 Id
值通过 SelectedItemId
路由参数传递到 Items
页面。为了指定路由参数的类型,我们可以结合数据类型相关的约束,如下所示:
@page "/user/{Id:int}"
在前面的页面指令中,我们将 Id
的数据类型定义为整数。有关路由约束的更多信息,请参阅相关的 Microsoft 文档。您可以通过以下链接访问相关文档:learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/routing?view=aspnetcore-8.0#route-constraints
。
使用 NavigationManager 导航
在 Razor 页面中,导航到另一个页面通常涉及使用 <a>
锚点标签。然而,在某些情况下,可能需要通过代码执行操作。这种情况之一是在处理事件时,在事件处理器中发生页面重定向。这正是我们的 Login
页面所面临的情况。让我们探讨如何在成功登录后使用 NavigationManager
导航到 Items
页面。
在我们的应用程序中,登录后,我们必须将用户重定向到 Items
页面以显示根组。Login
页面的用户界面与上一章保持不变;然而,我们已经修改了 Login.razor.cs
中的事件处理器,如下所示:
namespace PassXYZ.Vault.Pages;
public partial class Login : ComponentBase {
[Inject]
private IUserService<User> userService { get; set; } =
default!;
[Inject]
private IDataStore<Item> dataStore { get; set; } =
default!;
[Inject]
private NavigationManager navigationManager {get; set;} //(1)
private LoginService currentUser { get; set; } =
default!;
private async void OnLogin(MouseEventArgs e) {
bool status = await userService.LoginAsync
(currentUser);
if (status) {
navigationManager.NavigateTo("/group"); //(2)
}
}
}
我们通过依赖注入 (1) 获取 NavigationManager
的实例。
然后,我们调用 NavigationManager
的 NavigateTo("/group")
方法以方便导航到 Items
页面 (2)。
在本节中,我们探讨了路由和导航。作为下一步,我们可以在 Blazor UI 中实现类似于 Shell 导航的导航层次结构。
HTML 页面导航层次结构的顶层包括标题、工具栏、菜单和页脚。我们可以使用 Blazor 布局组件来设计此布局,它与 Shell 中的弹出和菜单项类似。我们之前在 第五章 中介绍了这些概念,即使用 .NET MAUI Shell 和 NavigationPage 进行导航。
使用 Blazor 布局组件
大多数网页通常具有固定元素,如标题、页脚或菜单。通过将布局与内容结合设计页面,我们可以最小化冗余代码。页面本身显示用户 intended 的内容,而布局则帮助构建视觉样式并提供导航方法。
Blazor 布局组件是从 LayoutComponentBase
派生的类。任何适用于常规 Razor 组件的功能也可以应用于布局组件。
在 清单 9.1 中,我们可以看到 MainLayout
是页面的默认布局。其定义可以在 清单 9.2 中找到,如下所示:
@inherits LayoutComponentBase //(1)
<div class="page">
<div class="sidebar">
<NavMenu /> //(2)
</div>
<main>
@Body //(3)
</main>
</div>
清单 9.2:MainLayout.razor
(epa.ms/MainLayout9-2
)
MainLayout 组件 (1) 继承自 LayoutComponentBase
类。该组件具有一个 NavMenu (2) 用于概述导航菜单。在 <main>
标签 (3) 内,@Body
Razor 语法指定了布局标记中渲染内容的位置。
仔细检查 NavMenu
组件非常重要,因为它作为应用程序中的主要导航方法。在审查代码之前,请参考 图 9.3 以查看 NavMenu 接口的视觉表示。NavMenu 包含三个菜单项:主页、关于和注销。
图 9.3:NavMenu
NavMenu
是一个 Razor 组件,负责定义导航中使用的链接。NavMenu
的源代码可以在清单 9.3中找到,如下所示:
<div class="top-row ps-3 navbar navbar-dark"> //(1)
<div class="container-fluid">
<a class="navbar-brand" href="">PassXYZ.Vault</a>
<button title="Navigation menu" class="navbar-toggler"
@onclick="ToggleNavMenu"> //(2)
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3"> //(3)
<NavLink class="nav-link" href="/group"> //(4)
<span class="oi oi-home" aria-hidden="true"></span>
Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="/about">
<span class="oi oi-plus" aria-hidden="true"></span>
About
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match=
"NavLinkMatch.All">
<span class="oi oi-list-rich" aria-hidden="true">
</span>
Logout
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string NavMenuCssClass => collapseNavMenu ?
"collapse" : null;
private void ToggleNavMenu() {
collapseNavMenu = !collapseNavMenu;
}
}
清单 9.3: NavMenu.razor
(epa.ms/NavMenu9-3
)
在NavMenu
组件的源代码中,我们可以观察到它由一个 Bootstrap navBar
组件和一些代码块内的 C#逻辑组成。NavBar
在以下<div>
标签中使用navbar
Bootstrap 类定义(1):
<div class="top-row ps-3 navbar navbar-dark">
如图 9.3所示,一个汉堡图标(2)位于屏幕的右上角,使用<button>
标签来切换NavMenu
。汉堡按钮 UI 使用 Bootstrap 类navbar-toggler
实现,如下详细说明:
<button title="Navigation menu" class="navbar-toggler"
@onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
定义了三个链接作为菜单项,使用nav-item
Bootstrap 类(3)。而不是使用锚标签<a>
,我们使用NavLink
(4)。NavLink
组件的功能类似于<a>
。然而,它根据其href
是否与当前 URL 匹配来切换一个active
CSS 类,如下所示:
<div class="nav-item px-3">
<NavLink class="nav-link" href="/group">
<span class="oi oi-home" aria-hidden="true"></span>
Home
</NavLink>
</div>
我们之前讨论了MainLayout
,它是我们应用中的默认布局。现在,让我们看看如何将此布局应用于组件。
将布局应用于组件
MainLayout
作为默认布局组件,适用于所有页面,除非指定了其他布局。在某些情况下,我们可能需要使用一个独特的布局而不是默认布局。
例如,在我们的应用中,我们为登录
页面使用了一个独特的布局组件,而不是默认布局(参考清单 9.4)。MainLayout
包含一个NavMenu
组件,我们不想在登录
页面上显示它,因为用户在登录之前不应该访问其他内容。让我们查看在应用特定布局后对登录
页面所做的修改,如清单 9.4所示:
@page "/"
@layout LogoutLayout //(1)
@namespace PassXYZ.Vault.Pages
<div class="text-center">
<main class="form-signin">
<form>
<img id="first" class="mb-4" src=
"passxyz-blue.svg"...>
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<label for="floatingInput">Username</label>
<input type="text"
@bind="@currentUser.Username"...>
</div>
<div class="form-floating">
<label for="floatingPassword">Password</label>
<input type="password" @bind=
"@currentUser.Password"...>
</div>
<div class="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me">
Remember me
</label>
</div>
<button...>Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2021–2022</p>
</form>
</main>
</div>
清单 9.4: Login.razor
(epa.ms/Login9-4
)
要应用特定的布局,我们可以使用@layout
Razor 指令(1)。在登录
页面上,我们实现了LogoutLayout
。LogoutLayout
的代码在清单 9.5中展示,如下所示:
@inherits LayoutComponentBase
<div class="page">
<main>
<div class="top-row px-4">
<a href="#" target="_blank">Sign-in</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
清单 9.5: LogoutLayout.razor
(epa.ms/LogoutLayout9-5
)
在LogoutLayout
中,我们移除了NavMenu
元素,并加入了一个登录链接,允许新用户注册账户。
嵌套布局
布局组件也可以嵌套。在MainLayout
中,我们没有为内容指定任何边距。虽然这个布局适合项目页面或项目详情页面上的内容列表视图,但它可能不适合像关于
页面这样的内容页面。为了改进这一点,我们可以为关于
页面使用一个嵌套在MainLayout
中的独特布局。让我们称它为PageLayout
,其实现可以在清单 9.6中找到:
@inherits LayoutComponentBase
@layout MainLayout
<article class="content px-4">
@Body
</article>
清单 9.6: PageLayout.razor
(epa.ms/PageLayout9-6
)
PageLayout
是一个布局组件,它使用MainLayout
。它将@Body
放置在一个应用了"content px-4"
样式的<article>
标签内,从而使内容采用适合段落文本的样式。
在About
页面上,我们可以以下方式指定布局为PageLayout
:
@page "/about"
@layout PageLayout
我们现在已经涵盖了 Blazor 中路由和布局的基础知识。有了这些知识,我们可以继续在我们的应用中实现导航元素。
实现导航元素
在第五章中,使用.NET MAUI Shell 和 NavigationPage 进行导航,我们介绍了 Shell 中的绝对和相对路由的概念。绝对路由可以在视觉导航层次结构中定义,而相对路由可以通过查询参数进行导航。
这种导航方法与我们在 Blazor 版本的应用中采用的策略相似。如图图 9.4所示,我们以类似于 XAML 版本的方式实现 Blazor UI 元素。
图 9.4:导航元素
Items
页面是用户登录应用后的主页面。在这个显示项目列表的页面上,以下 UI 元素与导航相关:
-
列表视图 – 用户可以浏览列表并选择一个项目。
-
上下文菜单 – 与列表视图中的每个项目相关联,使用户能够编辑或删除项目。
-
返回按钮 – 允许用户返回上一级。
-
添加按钮 – 允许用户添加新项目。
在本节中,我们将利用我们所学的知识来实现上述导航元素。
实现列表视图
在 XAML 版本中,用户登录应用后,导航从项目列表开始。列表视图是通过使用.NET MAUI ListView
控件实现的,该控件利用特定平台的 UI 组件以实现跨平台的一致外观和感觉。在 Blazor 版本中,我们使用基于 Web 的 UI,确保在不同平台上具有统一的外观。
当涉及到使用 Web UI 实现列表视图时,我们有众多选项可供选择。在本书中,我们遵循 Bootstrap 框架。我们的方法,如前一章所示,涉及重用 Bootstrap 示例中的 UI 设计。由于我们在本书中使用 Bootstrap 5.1,我们可以参考图 9.5中展示的列表组示例。
图 9.5:Bootstrap 列表组
之前的示例可以通过以下 URL 访问:getbootstrap.com/docs/5.1/components/list-group/
。
可以使用 Bootstrap 列表组来构建一个类似于 XAML 中的 ListView
的 UI 组件。为了实现这一点,我们可以将 CSS 类 "list-group"
应用到 HTML 标签,如 <ul>
或 <div>
,以创建列表组。在列表组内,将 "list-group-item"
CSS 类应用于组中的单个列表项。
在 XAML 版本中,我们使用上下文菜单来执行 CRUD 操作。然而,Bootstrap 列表组没有内置的上下文菜单,因此我们必须自己实现它。要在列表组内创建上下文菜单,我们可以使用 Bootstrap dropdown
组件。
为了使用 dropdown
组件,需要在 index.xhtml
中包含 JavaScript 依赖项,如下所示:
<script src="img/blazor.webview.js"
autostart="false"></script>
<script src="img/bootstrap.bundle.min.js">
</script>
我们在 blazor.webview.js
之后包含了 JavaScript 文件 bootstrap.bundle.min.js
。bootstrap.bundle.min.js
文件是 Bootstrap 发布包的一部分。
要创建一个名为 Items
的新 Razor 组件,只需在 Visual Studio 中的 Pages 文件夹上右键单击,然后选择 Add -> Razor Component…
。之后,插入 Listing 9.7 中提供的代码,并将 Razor 文件命名为 Items.razor
:
@page "/group"
@page "/group/{SelectedItemId}"
<!-- Back button and title -->
<div class="container">...
<!-- List view with context menu -->
<div class="list-group"> //(1)
@foreach (var item in items) {
<div class="dropdown list-group-item
list-group-item-action...> //(2)
<img src="img/@item.GetIcon()"...>
<a href="@item.GetActionLink()"
class="list-group-item...>
<div class="d-flex">
<div>
<h6 class="mb-0">@item.Name</h6>
<p class="mb-0 opacity-75">@item.Description
</p>
</div>
</div>
</a>
<button class="opacity-50 btn btn-light
dropdown-toggle"
type="button" id="itemsContextMenu"
data-bs-toggle="dropdown" aria-expanded="false">
<span class="oi oi-menu" aria-hidden="true"></span>
</button> //(3)
<ul class="dropdown-menu"
aria-labelledby="itemsContextMenu"> //(4)
<li>
<button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#editModel"> Edit </button>
</li>
<li>
<button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#deleteModel"> Delete </button>
</li>
</ul>
</div>
}
</div>
<!-- Editing Modal -->
<div class="modal fade" id="editModel" tabindex="-1"
aria-labelledby="editModelLabel" aria-hidden="true">...
<!-- Deleting Modal -->
<div class="modal fade" id="deleteModel" tabindex="-1"
aria-labelledby="deleteModelLabel" aria-hidden="true">...
<!-- New Modal -->
<div class="modal fade" id="newItemModel" tabindex="-1" aria-labelledby="newItemModelLabel" aria-hidden="true">...
列表 9.7:Items.razor
(epa.ms/Items9-7
)
在 Items.razor
(1) 中,我们可以复制使用具有 list-group
CSS 类的 <div>
标签的 Bootstrap 列表组示例代码。
我们根据我们的要求 (2) 定制了列表组项,如图 9.6 所示。列表组项是在 foreach
循环中使用 <div>
标签创建的,包含一个图标、一个名称、一个描述和一个上下文菜单:
<div class="dropdown list-group-item list-group-item-action...>
我们将 dropdown
、list-group-item
和 list-group-item-action
CSS 类应用到 <div>
标签上,将其转换为一个包含下拉菜单的列表组项。
图 9.6:列表组项
在列表组项中,我们使用 <img>
标签来显示项目的图标:
<img src="img/@item.GetIcon()"...>
我们可以通过从 Item
类中调用名为 GetIcon
的扩展方法来获取图标源。要创建此扩展方法,我们需要在 Shared
文件夹下添加一个新的类文件,并将其命名为 ItemEx.cs
,如图 Listing 9.8 所示。
使用 <a>
锚标签来显示项目的名称和描述。名称和描述在 <a>
标签中定义如下:
<a href="@item.GetActionLink()" class=
"list-group-item...>
<div class="d-flex">
<div>
<h6 class="mb-0">@item.Name</h6>
<p class="mb-0 opacity-75">@item.Description
</p>
</div>
</div>
</a>
可以使用 GetActionLink
扩展方法获取项目的链接,该方法也在 Listing 9.8 中定义。
上下文菜单是一个 Bootstrap 下拉组件,由一个 <button>
标签 (3) 和使用 <ul>
标签创建的无序列表组成 (4)。利用 Open Iconic 图标,此按钮显示为汉堡图标。
开源图标
在 Blazor UI 设计中,我们使用 Open Iconic 图标。Open Iconic 是一个开源图标集,包含 223 个 SVG、网络字体和位图格式的图标。对于 XAML 设计,使用 FontAwesome,它也可以在 Blazor 中与 Bootstrap 一起实现。然而,在使用之前需要额外的配置。Open Iconic 与 Blazor 模板捆绑在一起,并包含在 Bootstrap 中。因此,我们可以直接使用它而无需任何额外配置。例如,要在上下文菜单中显示汉堡图标,可以使用以下 HTML 标签:
<span class="oi oi-menu" aria-hidden="true"></span>
在下拉菜单中,有两个上下文动作按钮:Edit
和 Delete
。我们给这些按钮应用了 dropdown-item
CSS 类。上下文动作按钮触发一个对话框来执行 CRUD 操作,因此包含了两个 Bootstrap 模态 CSS 属性,data-bs-toggle
和 data-bs-target
。我们将在下一章讨论处理 CRUD 操作。
现在,让我们回顾一下我们将用于支持列表视图 UI 的 Item
扩展方法,如图 清单 9.8 所示:
using KeePassLib;
using KPCLib;
using PassXYZLib;
namespace PassXYZ.Vault.Shared {
public static class ItemEx {
public static string GetIcon(this Item item) { //(1)
if(item.IsGroup) {
// Group
if(item is PwGroup group) {
if(group.CustomData.Exists(
PxDefs.PxCustomDataIconName)) {
return $"/images/{group.CustomData.Get
(PxDefs.PxCustomDataIconName)}";
}
}
}
else {
// Entry
if(item is PwEntry entry) {
if(entry.CustomData.Exists
(PxDefs.PxCustomDataIconName)) {
return $"/images/{entry.CustomData.Get
(PxDefs.PxCustomDataIconName)}";
}
}
}
// 2\. Get custom icon
return item.GetCustomIcon();
}
/// <summary>
/// Get the action link of an item.
/// </summary>
public static string GetActionLink(this Item
item, string? action = default) { //(2)
string itemType = (item.IsGroup) ?
PxConstants.Group : PxConstants.Entry;
return (action == null) ? $"/{itemType}/{item.Id}" :
$"/{itemType}/{action}/{item.Id}";
}
/// <summary>
/// Get the parent link of an item.
/// </summary>
public static string? GetParentLink(this Item item) { //(3)
Item? parent = default;
if (item == null) return null;
if(item.IsGroup) {
PwGroup group = (PwGroup)item;
if (group.ParentGroup == null) return null;
parent = group.ParentGroup;
}
else {
PwEntry entry = (PwEntry)item;
if (entry.ParentGroup == null) return null;
parent = entry.ParentGroup;
}
return $"/{PxConstants.Group}/{parent.Id}";
}
}
}
清单 9.8:ItemEx.cs
(epa.ms/ItemEx9-8
)
在 清单 9.8 中,我们创建了一个名为 ItemEx
的静态类,用于实现 Item
类的扩展方法。在这个类中,我们定义了三个扩展方法来获取必要的导航 URL:
-
GetIcon
(1) – 返回图标图像的 URL -
GetActionLink
(2) – 根据项目类型返回所选项目的 URL -
GetParentLink
(3) – 返回父项目的 URL
在之前实现的列表视图 UI 中,我们创建了一个包含密码条目和组的列表。当选择一个项目时,实际上点击了一个锚点标签 <a>
。<a>
的 href
属性设置为 GetActionLink
方法的返回值。此返回值遵循 "/{itemType}/{item.Id}"
路由模板格式,允许导航到所需的项目。此外,每个项目右侧都有一个上下文菜单按钮。点击它时,会出现一个上下文动作列表,允许用户选择一个动作来编辑或删除当前项目。
我们现在已经成功处理了大部分的导航操作;然而,还有两个操作尚未实现。在进入子组后,我们无法进行回退导航,也无法添加新项目。在下一节中,我们将解决这两个功能。
添加新项目和回退导航
为了方便导航回上一页和添加新项目,我们可以在标题栏中添加一个 Back
按钮和一个 Add
按钮。这模拟了 XAML 版本的导航页面,如图 图 9.7 所示:
图 9.7:项目页面标题栏
如标题栏所示,包括三个 UI 元素:
-
标题 – 表示当前项目组的标题
-
返回 按钮 – 促进回退导航;然而,在没有父组时保持隐藏
-
添加 按钮 – 启用添加新项的功能
要检查实现,让我们扩展 Back
按钮和 Title
部分的代码,如下所示,如 Listing 9.7 中所示:
<!-- Back button and title -->
<div class="container">
<div class="row">
<div class="col-12">
<h1>
@if (selectedItem!.GetParentLink() != null) {
<a class="btn btn-outline-dark" href=
"@selectedItem!.GetParentLink()"><span
class="oi oi-chevron-left"
aria-hidden="true"></span></a> //(1)
}
@(" " + Title)
<button type="button" class="btn btn-outline-dark
float-end" data-bs-toggle="modal" data-bs-
target="#newItemModel"><span class="oi
oi-plus" aria-hidden="true"></span></button> //(2)
</h1>
</div>
</div>
</div>
返回按钮 (1) 是通过一个锚标签 <a>
实现的。这个锚标签的 href
属性被设置为 Item
扩展方法的返回值,即 GetParentLink
。这个函数返回父项的链接,以路由模板格式,从而可以通过这个链接进行导航回退。如果没有父组,例如根组,则“返回”按钮保持不可见。
添加按钮 (2) 是通过一个 <button>
标签实现的,并显示在标题栏的右侧。为了将按钮定位在屏幕的右侧,我们可以利用 Bootstrap 类,float-end
。当用户点击此按钮时,会出现一个新的项目对话框。此对话框使用以下属性进行配置:
data-bs-toggle="modal" data-bs-target="#newItemModel"
在 Items.razor
中,如 Listing 9.7 所示,使用了三个 Bootstrap 模态对话框:
<!-- Editing Modal -->
<div class="modal fade" id="editModel" tabindex="-1"
aria-labelledby="editModelLabel" aria-hidden="true">...
<!-- Deleting Modal -->
<div class="modal fade" id="deleteModel" tabindex="-1"
aria-labelledby="deleteModelLabel" aria-hidden="true">...
<!-- New Modal -->
<div class="modal fade" id="newItemModel" tabindex="-1" aria-labelledby="newItemModelLabel" aria-hidden="true">...
这些对话框用于执行 CRUD 操作。为了实现它们,我们还重用了 Bootstrap 中的代码。虽然这种方法相对简单,但它确实涉及大量的代码重复。为了节省空间,细节已在 Listing 9.7 中折叠。在下一章中,我们将深入研究模态对话框的实现,并展示如何将代码转换为可重用的 Razor 组件。
摘要
在本章中,我们探讨了 Blazor 的路由和布局,这些是构建我们应用程序导航层次结构的基本组件。到本章结束时,我们现在能够执行基本的导航,类似于我们应用程序的 XAML 版本提供的功能。
在本章的整个 UI 构建过程中,我们观察到 Blazor 的 UI 设计技术与传统 Web UI 设计实践相一致。这允许重用来自现有框架(如 Bootstrap)的代码。
在创建自定义用户界面时,首先在游乐场中设计初始布局通常是有益的。一旦对 UI 设计感到满意,可以将 HTML 和 CSS 代码复制到 Razor 文件中,以构建 Razor 组件。Blazor 开发者也可以利用几个在前端开发者中流行的游乐场,包括 CodePen、JSFiddle、CodeSandbox 和 StackBlitz。
在本章中,我们使用了 Bootstrap 示例来构建我们的 UI。虽然这种方法为实施 Web UI 提供了一个简单的方法,但它导致了大量的代码重复。在下一章中,我们将简化我们的代码,并将其转换为可重用的 Razor 组件。通过使用这些 Razor 组件,我们将执行 CRUD 操作以添加、编辑和删除项。
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第十章:实现 Razor 组件
在上一章中,我们探讨了 Blazor 路由和布局,并随后通过设置应用程序的路由和布局来构建导航框架。在设置导航框架之后,我们创建了顶级页面。Razor 页面的实现允许进行密码数据库导航,类似于 XAML 版本。虽然 Razor 页面确实是 Razor 组件,但它们是不可重用的。在本章中,我们将介绍创建可重用 Razor 组件。此外,我们将深入研究数据绑定和 Razor 组件的生命周期,以全面理解这些方面。掌握这些知识后,我们将通过将重复代码转换为可重用 Razor 组件来优化我们的代码。最终,我们将使用 Razor 组件在我们的应用程序中实现 CRUD 操作。
本章我们将涵盖以下主题:
-
理解 Razor 组件
-
创建 Razor 类库
-
创建可重用 Razor 组件
-
理解 Razor 组件的生命周期
-
重构 Razor 组件
-
使用模板化组件
技术要求
要测试和调试本章的源代码,您需要在您的 PC 或 Mac 上安装 Visual Studio 2022。有关详细信息,请参阅 第一章,开始使用 .NET MAUI 中的 开发环境设置 部分。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter10
。
要查看本章的源代码,可以使用以下命令:
$ git clone -b 2nd/chapter10 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书中源代码的信息,请参阅本书 第二章,构建我们的第一个 .NET MAUI 应用程序 中的 管理源代码 部分。
理解 Razor 组件
尽管我们在前两章中开发和使用了 Razor 组件,但我们还没有深入探讨它们。在本节中,我们将继续完善上一章的应用程序,同时更深入地探索 Razor 组件,从而更好地理解这些组件周围的关键概念。
Blazor 应用程序使用 Razor 组件构建。我们应用程序中的第一个 Razor 组件是 Main
,它定义在 Main.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>
安装在 Main
组件内的 Router
组件负责处理页面路由和选择默认布局组件。所有其他 Razor 页面都由 Router
组件管理。这些由 Router
加载的 Razor 页面定义了路由模板,用于显示用户界面。在我们的项目中,Razor 页面位于 Pages
文件夹中。
要了解 Blazor 混合应用的启动顺序,可以参考下面的 图 10.1:
图 10.1:Blazor 混合应用程序的启动
作为 .NET MAUI 应用程序,Blazor 混合应用程序的起点也是 MauiProgram
类 (1)。在 MauiProgram
类中,创建了一个 App
类的实例 (2)。第一个要加载到该实例中的 XAML 页面是 MainPage
类 (3)。这个 MainPage
包含了一个 BlazorWebView
的实例 (4),它托管了 Blazor 页面。有关如何将 Razor 组件 Main
加载到 BlazorWebView
的更多信息,请参阅 第八章,介绍 Blazor 混合应用程序开发。一旦 Main
组件被加载到 BlazorWebView
中,它就展示了导航堆栈的根。在这种情况下,导航堆栈的根页面是 Login
页面。
此外,还有一些可重用的 Razor 组件,它们是 Razor 页面的构建块。这些 Razor 组件存储在 Shared
文件夹中。
本质上,每个具有 .razor
扩展名的文件代表一个 Razor 组件,它在执行时被编译成一个 C# 类。文件名作为类名,而文件夹名则贡献于命名空间。例如,Login
Razor 组件位于 Pages
文件夹中,因此文件夹名 Pages
被包含在命名空间中。因此,Login
类的完整名称是 PassXYZ.Vault.Pages.Login
。
Razor 组件可以编写在一个单独的文件中,也可以分为一个 Razor 文件(.razor
)和一个代码后 C# 文件(.cs
)。代码后 C# 文件定义了一个包含所有编程逻辑的部分类。这种方法在我们创建 第八章,介绍 Blazor 混合应用程序开发中的 Login
组件时被采用。
图 10.2:Razor 组件命名约定
如 图 10.2 所示,在创建 Login
组件时,我们加入了 Bootstrap CSS 风格以用于样式化。Razor 组件提供了 CSS 隔离,这简化了 CSS 的使用,并防止了与其他组件或库的冲突。此外,Razor 组件可以在 .razor.css
文件中包含它们自己的 CSS 风格。
继承
由于 Razor 组件是一个 C# 类,它包含了 C# 类固有的所有功能。因此,Razor 组件可以作为另一个 Razor 组件的子类。在 第九章,理解 Blazor 布局和路由中,我们观察到在创建布局组件时,所有布局组件都是 LayoutComponentBase
的派生类。如下面的 MainLayout.razor
代码所示,我们使用 @inherits
指令指定 LayoutComponentBase
作为基类:
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar"><NavMenu/></div>
<main>@Body</main>
</div>
每个 Razor 组件都是 ComponentBase
类的派生类。因此,可以使用 C# 文件创建一个从 ComponentBase
类派生的 Razor 组件,而不需要 Razor 标记文件。例如,我们可以在一个 C# 类中创建一个 AppName
Razor 组件,如下所示:
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace PassXYZ.Vault.Pages;
public class AppName : ComponentBase
{
protected override void BuildRenderTree
(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
builder.OpenElement(0, "div");
builder.AddContent(1, "PassXYZ.Vault");
builder.CloseElement();
}
}
AppName
是一个没有 Razor 标记文件(.razor
)设计的 Razor 组件;然而,它与其他 Razor 组件的功能相同,如下所示:
...
<AppName/>
...
上述示例用于演示如何在 C# 代码中创建 Razor 组件。然而,通常建议使用 Razor 标记而不是 C# 代码来创建 UI 组件。
在本节中,我们介绍了 Razor 组件。我们可以将可重用的 Razor 组件放在共享文件夹中,以确保它们在整个项目中可访问。此外,可以创建一个 Razor 类库来在多个项目中共享 Razor 组件。在下一节中,我们将深入了解将这些 Razor 组件打包成库的过程。
创建 Razor 类库
在我们的项目中,我们创建可重用组件并将它们存储在 Shared
文件夹中。这些组件可以被其他组件使用,例如布局组件或 NavMenu
。此外,我们可以在 Razor 类库中封装 Razor 组件,使它们可以在各种项目中使用。
Razor 类库中的组件不特定于单个项目,这使得它们适用于任何 Blazor 项目,无论是 Blazor 混合应用、Blazor WebAssembly 还是 Blazor 服务器应用。
在这本书中,我们使用 Bootstrap 构建了 Razor 组件。你可以在 GitHub 上找到许多基于 Bootstrap 构建的开放源码 Razor 类库。其中一些库足够健壮,适用于商业产品开发。以下是一些示例:
-
BootstrapBlazor
:github.com/dotnetcore/BootstrapBlazor
-
Blazorise
:github.com/Megabit/Blazorise
-
Havit.Blazor
:github.com/havit/Havit.Blazor/
这些开源项目被构建为 Razor 类库,使得它们可以像其他 .NET 库一样被重用。Razor 类库可以作为 NuGet
包发布,从而能够无缝集成到我们的 Blazor 项目中。例如,BootstrapBlazor
库可以在以下链接找到:www.nuget.org/packages/BootstrapBlazor/
。
在本节中,我们将开发一个类似于上述开源项目的 Razor 类库。我们将在我们的 Razor 类库中包含可重用的 Razor 组件。然后,这个库可以发布为 NuGet 包。我们有选择使用 Visual Studio 或 .NET 命令行创建 Razor 类库的选项。
要使用 Visual Studio 创建 Razor 类库,我们可以按照以下步骤在我们的解决方案中添加一个新的项目,如图 10.3 所示:
-
在项目模板中搜索并选择Razor 类库。
-
点击下一步,并将项目命名为PassXYZ.BlazorUI。
-
在下一屏上,点击创建以建立库。
图 10.3:创建 Razor 类库
要使用 dotnet 命令行创建项目,我们可以导航到解决方案文件夹,并在命令提示符中执行以下命令:
dotnet new razorclasslib -n PassXYZ.BlazorUI
dotnet new
命令使用 razorclasslib
模板创建一个新的项目,并将项目命名为 PassXYZ.BlazorUI
。为了将项目包含在解决方案中,我们可以执行以下命令:
dotnet sln add PassXYZ.BlazorUI\PassXYZ.BlazorUI.csproj
为了使用这个骨架项目创建我们的 Razor 组件,我们必须从 PassXYZ.BlazorUI
项目中删除未使用的 Component1.*
和 ExampleJsInterop.cs
文件。
要在 PassXYZ.BlazorUI
项目中使用 Razor 组件,我们必须在 PassXYZ.Vault
项目中包含项目引用。为此,我们可以在 Visual Studio 中右键单击项目节点并选择 Add -> Project Reference
。或者,我们可以直接修改 PassXYZ.Vault.csproj
项目文件,添加以下行:
<ItemGroup>
<ProjectReference
Include="..\PassXYZ.BlazorUI\PassXYZ.BlazorUI.csproj"
/>
</ItemGroup>
为了创建一个使用此库的新 Razor 组件,我们必须通过添加以下行更新 PassXYZ.Vault_Imports.razor
文件:
@using PassXYZ.BlazorUI
在 Razor 类库中使用静态资产
我们在我们的 Razor 组件中使用了 Bootstrap,因此有必要在 Razor 类库中包含 Bootstrap CSS 和 JavaScript 文件。从 Blazor 应用的角度来看,我们可以将这些静态资产放置在项目的 wwwroot
文件夹或组件库的 wwwroot
文件夹中。以 Bootstrap CSS 文件为例,如果我们将其存储在项目的 wwwroot
目录中,我们可以在 index.xhtml
中使用以下路径引用它:
<script src="img/bootstrap.bundle.min.js"/>
如果我们选择将其存储在组件库的 wwwroot
文件夹中,我们可以使用以下路径引用它:
<script src="_content/PassXYZ.BlazorUI/css/bootstrap/
bootstrap.bundle.min.js"/>
区别在于在组件库中引用 URL 的必要性,该 URL 应以 _content/{LibraryProjectName}
开头。
一旦我们建立了 Razor 类库项目,我们就可以继续在其中包含额外的组件。
创建可重用的 Razor 组件
在本节中,我们将重构我们的代码以开发可重用组件。这个过程将使我们能够更深入地了解 Razor 组件的功能,并学习如何优化它们以提高可重用性。
在 第八章 中,我们介绍了 Blazor 混合应用开发。此外,我们在 第九章 中引入了布局和路由功能,即理解 Blazor 路由和布局。因此,我们的应用现在可以浏览和更新密码数据库。然而,我们尚未实现大多数 CRUD 操作。在本章中完善 Razor 组件后,我们将继续添加这些功能。
为了导航密码数据库,我们开发了两个 Razor 组件——Items
和ItemDetail
。Items
类用于展示当前组内的密码条目和分组列表,而ItemDetail
类用于展示单个密码条目的内容。
通过查看Items
和ItemDetail
的布局,如图 10.4 所示,我们可以观察到两个页面在外观和整体设计上具有相似性。
图 10.4:Items 和 ItemDetail 的 UI 布局
两个页面的布局都包含侧边栏、页眉和列表视图。侧边栏在layout
组件中定义,而页眉和列表视图在Items
和ItemDetail
中实现,部分代码是重复的。在本章中,我们将通过将重复的部分抽象为可重用组件来优化我们的代码。
在页眉中有两个按钮:添加
和返回
。返回
按钮允许用户导航回父组,而添加
按钮允许用户添加新条目或字段。
在列表视图条目中,我们可以使用上下文菜单来执行条目级操作,如编辑或删除。上下文菜单包含针对所选条目或字段特定操作的菜单项。当执行编辑或删除 CRUD 操作时,选择菜单项后,将出现与所选操作相关的模态对话框。
在当前实现中,Items
和ItemDetail
将所有 UI 元素都包含在一个单独的 Razor 标记中。我们将开始通过将其分解为更小的、可重用的组件来简化代码,这将导致更清晰的实现。
在本章中,我们将把模态对话框、页眉和列表视图转换为 Razor 组件。让我们从模态对话框开始。为了方便添加、编辑和删除操作,我们需要两种类型的对话框:
-
编辑对话框:用于添加或编辑条目或字段
-
确认对话框:在删除条目或字段之前进行确认
在第九章,理解 Blazor 路由和布局中,我们通过利用 Bootstrap 示例中的 HTML 和 CSS 代码实现了模态对话框。然而,我们没有对这些元素进行彻底检查,因为我们的标记文件看起来既长又复杂。在本章中,我们将分析代码并将其转换为 Razor 组件。
创建基本模态对话框组件
为了改进编辑和确认对话框,我们首先可以构建一个基本模态对话框。通过利用这个基本模态对话框,我们可以根据需要创建编辑器或确认对话框。
要在PassXYZ.BlazorUI
项目中创建一个新的 Razor 组件,请右键单击项目节点,然后从项目模板中选择添加
-> 新建项…
-> Razor 组件
。将 Razor 组件命名为ModalDialog
,并为它创建一个 C#代码后文件。接下来,将列表 10.1中的代码输入到ModalDialog.razor
中,将列表 10.2中的代码输入到ModalDialog.razor.cs
中。
UI 代码是从第九章中找到的Items
和ItemDetail
代码派生出来的,如列表 10.1所示:
<div class="modal fade" id=@Id tabindex="-1"
aria-labelledby="ModelLabel" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"> //(1)
<h5 class="modal-title" id="ModelLabel">@Title</h5> //(2)
<button type="button" class="btn-close" //(3)
data-bs-dismiss="modal" aria-label="Close"/>
</div>
<div class="modal-body"> //(4)
<form class="row gx-2 gy-3">
@ChildContent //(5)
<div class="col-12">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal" @onclick=
"OnClickClose">
@CloseButtonText //(6)
</button>
<button type="submit" class="btn btn-primary"
data-bs-dismiss="modal" @onclick=
"OnClickSave">
@SaveButtonText //(7)
</button>
</div>
</form>
</div>
</div></div>
</div>
列表 10.1: ModalDialog.razor
(epa.ms/ModalDialog10-1
)
在列表 10.1中显示的标记代码中,我们观察到带有 Bootstrap 样式的典型 HTML 代码片段。我们在 HTML 中加入了 C#变量来构建组件 UI。
此基本对话框 UI 包含一个标题栏 (1) 和一个主体 (4)。在标题栏中,有一个标题 (2) 和一个关闭按钮 (3)。主体包含一个子内容区域 (5) 和两个按钮(关闭 (6)/保存 (7))。
为了查看此基本模态对话框的布局的视觉表示,请参阅图 10.5:
图 10.5:基本对话框
虽然 HTML 和 CSS 代码与 Bootstrap 示例非常相似,但我们已将所有硬编码的内容替换为 C#变量。如果我们使用此模态对话框组件来构建新的组件,以下将作为示例:
<ModalDialog Id=@id Title="Please confirm"
OnSaveAsync=@OnSaveClicked
SaveButtonText="Save" CloseButtonText="Close">
Do you want to delete UserName?
</ModalDialog>
<button class="dropdown-item" data-bs-toggle="modal"
data-bs-target="#@Id">Please confirm</button>
在上述标记代码中,我们使用<ModalDialog>
组件标签定义了模态对话框。每个模态对话框都分配了一个唯一的 ID 以供识别。我们可以通过点击按钮来显示对话框,其中模态对话框 ID 被提供以方便其识别。
在<ModalDialog>
组件标签内,我们为ModalDialog
组件中定义的多个属性赋值,包括ID
、标题
、按钮文本
、事件处理器
等。
数据绑定
而不是直接将字符串或数据分配给 HTML 元素的属性,我们可以选择将变量分配给它。这种能力是 Razor 组件提供的数据绑定功能。在本节中,我们将探讨如何有效地使用数据绑定。在数据绑定中,将变量分配给 DOM 元素的属性会导致从 Razor 组件到 DOM 元素的数据流。相反,响应 DOM 事件会导致数据从 DOM 元素流向 Razor 组件。
由于我们可以像使用 DOM 元素一样使用 Razor 组件,因此子 Razor 组件和父 Razor 组件之间的数据流类似于 Razor 组件和 DOM 元素之间的数据交换。
例如,我们可以将id
变量绑定到ModalDialog
的Id
属性上,通过OnSaveClicked
事件处理器来管理按钮点击事件:
<ModalDialog Id=@id Title="Please confirm"
OnSaveAsync=@OnSaveClicked
SaveButtonText="Save" CloseButtonText="Close">
在前面的示例中,数据从 id
变量流向 ModalDialog
的 Id
属性。当 OnSaveClicked
事件处理器被调用时,数据从 ModalDialog
流回当前上下文。ModalDialog
的属性 Id
和 OnSaveAsync
在 C# 代码隐藏文件中定义。在下一节中,我们将检查 ModalDialog
的 C# 代码隐藏文件。
组件参数
Razor 组件的属性可以使用组件参数定义。为了建立组件参数,我们需要创建带有 [Parameter]
属性的公共属性。
在 列表 10.2 中所示的 ModalDialog
类中,我们声明了七个组件参数:Id
、Title
、ChildContent
、OnClose
、OnSaveAsync
、CloseButtonText
和 SaveButtonText
。这些组件参数可以用于数据绑定:
using Microsoft.AspNetCore.Components;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace PassXYZ.BlazorUI;
public partial class ModalDialog : IDisposable
{
[Parameter]
public string? Id { get; set; } //(1)
[Parameter]
public string? Title { get; set; } //(2)
[Parameter]
public RenderFragment ChildContent { get; set; } //(3)
[Parameter]
public Func<Task>? OnClose { get; set; } //(4)
[Parameter]
public Func<Task<bool>>? OnSaveAsync { get; set; } //(5)
[Parameter]
[NotNull]
public string? CloseButtonText { get; set; } //(6)
[Parameter]
[NotNull]
public string? SaveButtonText { get; set; } //(7)
private async Task OnClickClose() {
if (OnClose != null) { await OnClose(); }
}
private async Task OnClickSave() {
if (OnSaveAsync != null) { await OnSaveAsync(); }
}
void IDisposable.Dispose() {
GC.SuppressFinalize(this);
}
}
列表 10.2: ModalDialog.razor.cs
(epa.ms/ModalDialog10-2
)
ModalDialog
组件的参数定义如下:
-
Id
(1): 这个标识符用于区分模态对话框。 -
Title
(2): 这代表模态对话框的标题。 -
ChildContent
(3): 这是为了插入子组件的内容而指定的。我们指的是的数据类型是RenderFragment
委托,它代表一段 UI 内容。有关更多详细信息,请参阅以下 Microsoft 文档。我们将在第 11 章 中更深入地探讨这个主题:learn.microsoft.com/en-us/aspnet/core/blazor/performance?view=aspnetcore-8.0#define-reusable-renderfragments-in-code
.
定义了两个事件处理器 – OnClose
(4) 和 OnSaveAsync
(5) – 用于管理按钮点击操作。我们可以使用 CloseButtonText
(6) 和 SaveButtonText
(7) 来自定义两个按钮上的文本。
我们可以像处理 HTML 属性一样处理组件参数。C# 字段、属性或方法返回值可以被分配给 ModalDialog
的组件参数。一旦我们创建了基本的 ModalDialog
组件,我们就可以用它来开发 Editor
和 Confirmation
对话框组件。
让我们创建一个新的模态对话框,ConfirmDialog
,它将提示用户确认删除项目。要在 PassXYZ.BlazorUI
项目中创建新的 ConfirmDialog
组件,您可以在项目节点上右键单击,然后从项目模板中选择 Add
-> New Item…
-> Razor Component
。我们可以将 Razor 组件命名为 ConfirmDialog
并输入 列表 10.3 中提供的以下代码:
<ModalDialog Id=@Id Title=@($w"Deleting {Title}")
OnSaveAsync=@OnSave
SaveButtonText="Confirm" CloseButtonText="Cancel">
Please confirm to delete @Title?
</ModalDialog>
@code {
[CascadingParameter(Name = "Id")]
public string Id { get; set; } = "confirmDialog"; //(1)
[Parameter]
public string? Title { get; set; } //(2)
[Parameter]
public Action? OnConfirmClick { get; set; }
async Task<bool> OnSave() {
OnConfirmClick?.Invoke();
return await Task.FromResult(true);
}
}
列表 10.3: ConfirmDialog.razor
(epa.ms/ConfirmDialog10-3
)
我们在ConfirmDialog
中定义了Id
(1) 和Title
(2) 组件参数,并通过数据绑定将它们的值传递给基类。此外,我们通过使用OnSave
事件处理器来订阅OnSaveAsync
事件。我们还定义了自己的事件处理器OnConfirmClick
作为组件参数,允许其他组件订阅它。
在ConfirmDialog
中,我们实际上是通过嵌套组件来绑定参数的。在这种情况下,数据流应遵循以下建议的方向:
-
变更通知应向上通过层次结构传播。
-
新参数值应向下通过层次结构传播。
Id
和Title
属性值由使用ConfirmDialog
的组件分配,并且它们的值会级联到ModalDialog
。Save
或Close
按钮事件在ModalDialog
组件内部启动,然后通过链向上传播到ConfirmDialog
和更高层次的组件。以Save
按钮为例,事件遵循此处所示的上行方向:
onclick (DOM) -> OnSaveAsync (ModalDialog) -> OnConfirmClick (ConfirmDialog)
流程从 DOM 中的onclick
事件开始。ModalDialog
定义了自己的事件OnSaveAsync
,该事件由onclick
事件处理器激活。另一方面,ConfirmDialog
建立了一个名为OnConfirmClick
的事件,该事件由OnSaveAsync
事件处理器启动。
嵌套组件
ConfirmDialog
是嵌套组件的一个例子。如所示,我们可以通过使用 HTML 语法声明它们来在其他组件内嵌入组件。嵌入的组件看起来像 HTML 标签,标签名对应组件类型。例如,我们可以在ConfirmDialog
内部使用ModalDialog
,如下所示:
<ModalDialog ...>Please confirm to delete @Title?</ModalDialog>
嵌套组件是构建 Blazor 组件层次结构的一种手段。在面向对象的编程语言中,继承和组合是扩展和重用类的方法。在 Blazor 中,嵌套组件内部使用组合来增强功能。继承代表了一个'is-a'
关系,而组合则代表了一个'has-a'
关系。在嵌套组件的情况下,父组件包含子组件。
在 Microsoft Blazor 和 ASP.NET Core 文档中,使用"ancestor"
和"descendant"
或"parent"
和"child"
等术语来描述嵌套组件之间的关系。在这种情况下,父子和关系不是继承关系,而是组合关系。一个更合适的术语可能是“外部组件”或“内部组件”。然而,为了与 Microsoft 文档保持一致,我将在我们的讨论中不选择替代术语。请注意,当我们讨论嵌套组件和数据绑定时,祖先和后代关系代表了一种“有”关系或组合关系。
在我们之前的例子中,ConfirmDialog
组件作为外部组件,而 ModalDialog
作为内部组件。它们之间的关系是 ConfirmDialog
在其中包含 ModalDialog
。
子内容渲染
当构建嵌套组件时,经常会出现一个组件设置另一个组件内容的情况。外部组件提供位于内部组件打开和关闭标签之间的内容。在 ConfirmDialog
的情况下,ModalDialog
的内容配置如下:
<ModalDialog Id=@Id Title=@($"Deleting {Title}")
OnSaveAsync=@OnSave
SaveButtonText="Confirm" CloseButtonText="Cancel">
Please confirm to delete @Title?
</ModalDialog>
这是通过使用一个独特的组件参数 ChildContent
来实现的,它属于 RenderFragment
类型。在之前的代码中,将 "请确认删除 @Title?"
字符串分配给 ModalDialog
的 ChildContent
参数。
ConfirmDialog
代表了嵌套组件的一个相对简单的例子。让我们考虑另一个例子,EditorDialog
,以进一步检查 Razor 组件的功能。如前所述,我们需要两个对话框来管理添加、编辑和删除操作。ConfirmDialog
用于在删除项目或字段之前寻求用户确认。为了添加或编辑项目或字段,我们需要一个具有编辑功能的对话框。
我们可以遵循相同的步骤来创建一个新的组件,EditorDialog
。在项目模板中选择 添加
-> 新建项…
-> Razor 组件
后,我们可以将 EditorDialog
命名给 Razor 组件并创建相应的 C# 后置代码文件。随后,我们可以将 清单 10.4 中的代码输入到 EditorDialog.razor
中,将 清单 10.5 中的代码输入到 EditorDialog.razor.cs
中。
让我们检查 EditorDialog
的 Razor 标记代码,如 清单 10.4 所示:
<ModalDialog Id=@Id Title=@Key OnSaveAsync=@OnSaveClicked
SaveButtonText ="Save" CloseButtonText="Close">
@if (IsKeyEditingEnable) { //(1)
<input type="text" class="form-control" id="keyField"
@bind="Key" placeholder=@KeyPlaceHolder required> //(2)
}
@ChildContent
<div>
<textarea class="form-control" id="valueField"
style="height: 100px"
placeholder=@ValuePlaceHolder
@bind="Value" required /> //(3)
</div>
</ModalDialog>
清单 10.4: EditorDialog.razor
(epa.ms/EditorDialog10-4
)
使用 ModalDialog
构建的 EditorDialog
旨在编辑键值对。我们希望通过此组件支持两种用例:创建新的键值对,其中键和值都是可编辑的,以及修改现有的键值对,其中可能只需要更改值字段。
为了便于这些场景,我们使用一个名为 IsKeyEditingEnabled
的组件参数 (1) 来检测条件。为了创建新的键值对,键输入被渲染为一个 <input>
元素 (2),而为了编辑现有的键值对,键在标题区域显示。在两种情况下,值都可以使用 <textarea>
元素 (3)
进行编辑。这构成了我们 EditorDialog
组件的核心功能。
如 图 10.6 所示,UI 描述了两个不同的对话框。在左侧,当我们打算添加新字段时,会出现一个对话框,要求我们输入字段名称和内容。同时,在右侧,当我们打算编辑现有的 URL 字段时,会显示一个对话框。字段名称显示在标题中,内容可以在 <textarea>
元素中修改。
图 10.6:编辑字段
在 EditorDialog
组件中,当我们使用 <input>
和 <textarea>
HTML 元素编辑键和值时,会显示初始值。这个初始值是从 Razor 组件设置到 DOM 的。一旦我们做出更改,数据就会从 DOM 流回 Razor 组件,这展示了双向数据绑定的例子。
双向数据绑定
可以使用 @bind
Razor 指令属性来建立双向数据绑定。这种语法允许 HTML 元素属性绑定到一个字段、属性、表达式值或方法的结果。在 清单 10.4 中,<input>
元素的值绑定到了 EditorDialog
组件内的 Key
属性:
<input type="text" class="form-control" id="keyField"
@bind="Key" placeholder=@KeyPlaceHolder required>
在双向数据绑定的情况下,每当 Key
属性发生变化时,DOM 元素 <input>
的值都会更新。同样,当用户在 DOM 中修改 <input>
的值时,Key
属性也会相应地更新。
在上一个示例中,我们有选择用两种单向数据绑定来替换 @bind
指令属性,如下面的代码所示:
<input type="text" class="form-control" id="keyField"
value="@Key"
@onchange="@((ChangeEventArgs e) => Key = e?.Value?
.ToString())"
placeholder=@KeyPlaceHolder required>
当渲染 EditorDialog
组件时,<input>
元素的值是从 Key
属性派生出来的。当用户在文本框中输入值并改变元素焦点时,会触发 onchange
事件,随后用修改后的值更新 Key
属性。
对于 <input>
元素,@bind
指令属性的默认事件是 onchange
事件。我们可以使用 @bind:event="{event}"
属性来修改事件。{event}
占位符应代表一个 DOM 事件。例如,我们可以使用以下代码片段将 onchange
事件替换为 oninput
事件:
<input type="text" class="form-control" id="keyField"
@bind="Key" @bind:event="oninput" placeholder=@KeyPlaceHolder required>
绑定到组件参数
在前一个部分,我们探讨了 Razor 组件和 DOM 元素之间的双向数据绑定。由于 Razor 组件可以像 DOM 元素一样工作,因此也可以在两个 Razor 组件之间建立双向数据绑定。这通常发生在需要父组件和子组件之间进行通信的情况下。
我们可以使用@bind-{PROPERTY}
语法将内部组件的组件参数绑定到外部组件的属性。在这种情况下,{PROPERTY}
指的是要绑定的属性。我们已解释过,@bind
指令属性可以被两个单向数据绑定设置替代,这涉及到将变量分配给<input>
的值属性,并将事件处理程序分配给onchange
事件。虽然编译器可以自动添加@bind
的事件处理程序,但它不能为@bind-{PROPERTY}
做同样的事情。因此,我们需要定义自己的EventCallback<TValue>
类型的事件来与组件参数绑定。事件名称必须遵循{PARAMETER NAME}Changed
格式。为了说明@bind-{PROPERTY}
指令属性的使用,让我们以我们的EditorDialog
组件为例。
在我们的代码中,我们在ItemDetail
组件中使用EditorDialog
来编辑字段,或者在Items
组件中编辑项目。让我们以字段编辑为例进行考察:
<EditorDialog Id=@_dialogEditId
@bind-Key="listGroupField.Key" //(1)
@bind-Value="listGroupField.Value" //(2)
IsKeyEditingEnable=@_isNewField
OnSave="UpdateFieldAsync"
KeyPlaceHolder="Field name"
ValuePlaceHolder="Field content">
@if (_isNewField) {
<div class="form-check">
<input class="form-check-input" type="checkbox"
@bind="listGroupField.IsProtected"
id="flexCheckDefault">
<label class="form-check-label"
for="flexCheckDefault">
Password
</label>
</div>
}
</EditorDialog>
在ItemDetail
组件的前一个代码中,我们可以为Key
(1) 和 Value
(2) 创建与Field
类型的listGroupField
的数据绑定。我们需要在EditorDialog
的 C#代码后部分实现{PARAMETER NAME}Changed
事件,如下所示 清单 10.5:
namespace PassXYZ.BlazorUI;
public partial class EditorDialog {
[Parameter]
public string? Id { get; set; }
bool _isKeyEditingEnable = false;
[Parameter]
public bool IsKeyEditingEnable ...
[Parameter]
public EventCallback<bool>? IsKeyEditingEnableChanged {
get; set; }
string _key = string.Empty;
[Parameter]
public string Key { //(1)
get => _key;
set {
if(_key != value) {
_key = value;
KeyChanged?.InvokeAsync(_key); //(3)
}
}
}
[Parameter]
public EventCallback<string>? KeyChanged { get; set; } //(2)
[Parameter]
public string? KeyPlaceHolder { get; set; }
string _value = string.Empty;
[Parameter]
public string Value ...
[Parameter]
public EventCallback<string>? ValueChanged { get; set; }
[Parameter]
public string? ValuePlaceHolder { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; } =
default!;
[Parameter]
public Action<string, string>? OnSave { get; set; }
async Task<bool> OnSaveClicked() {
OnSave?.Invoke(Key, Value);
return true;
}
}
清单 10.5: EditorDialog.razor.cs
(epa.ms/EditorDialog10-5
)
在 清单 10.5 中,我们利用Key
属性作为示例来说明组件参数绑定过程。Key
属性定义为具有[Parameter]
属性的组件参数。相关的事件KeyChanged
定义为EventCallback<TValue>
类型。当用户修改文本输入并且元素焦点改变时,Key
属性的 setter 被调用。在Key
属性的 setter 中,触发KeyChanged
事件,该事件通知外部的ItemDetail
组件。因此,链接变量listGroupField.Key
被更新。
与级联值和参数通信
我们可以利用数据绑定在父组件和子组件之间传递数据。当向中间子组件传递数据时,数据绑定非常有效。然而,可能存在需要将数据传递到位于几层深度的组件的情况。在这种情况下,利用数据绑定需要创建多个级别的链式数据绑定,随着链式级别的扩展,复杂性也随之增加。例如,当从Items
传递数据到ModalDialog
时,我们首先需要与ConfirmDialog
建立数据绑定。然后,必须在ConfirmDialog
和ModalDialog
之间创建另一个级别的数据绑定。
在 Items
组件中,我们需要将对话框的 Id
传递给 ModalDialog
。Id
是必要的,用于识别我们希望显示的特定对话框实例。如下所示,我们在 Items
组件中定义 ConfirmDialog
。Id
在 Items
中定义,并通过组件参数传递给 ConfirmDialog
:
<ConfirmDialog Id="@_dialogDeleteId"
Title=@listGroupItem.Name
OnConfirmClick="DeleteItemAsync" />
然后,ConfirmDialog
必须将 Id
传递给 ModalDialog
:
<ModalDialog Id=@Id Title=@($"Deleting {Title}")
OnSaveAsync=@OnSave
SaveButtonText="Confirm" CloseButtonText="Cancel">
Please confirm to delete @Title?
</ModalDialog>
在 ModalDialog
中,Id
被用作 <div>
元素的属性:
<div class="modal fade" id=@Id tabindex="-1"
aria-labelledby="ModelLabel" aria-hidden="true"> ...
为了防止多级数据绑定,我们可以使用级联值和参数作为技术,以促进通过组件层次结构的数据流。
CascadingValue
是 Blazor 框架中的一个组件。外部组件通过使用 CascadingValue
提供级联值,而内部组件可以通过使用 [CascadingParameter]
属性来获取它。为了演示其用法,我们可以修改 Items
组件的代码如下:
<CascadingValue Value="@_dialogDeleteId" Name="Id">
<ConfirmDialog Title=@listGroupItem.Name
OnConfirmClick="DeleteItemAsync" />
</CascadingValue>
我们使用 <CascadingValue>
标签来使用级联值。在 <CascadingValue>
标签内,我们将 _dialogDeleteId
变量分配给 Value
属性,将 Id
字符串分配给 Name
属性。由于此 Id
并未直接由 ConfirmDialog
使用,因此可以安全地删除 ConfirmDialog
中的 Id
组件参数。
在 ModalDialog
中,我们通过使用 [CascadingParameter]
属性将 Id
属性修改为参数,而不是组件参数:
[CascadingParameter(Name = "Id")]
public string Id { get; set; } = default!;
当只使用一个级联值时,指定其名称不是强制性的,因为编译器可以通过数据类型定位它。然而,为了防止歧义,我们可以使用 Name
属性给级联值赋予一个名称。接下来,我们将检查 Items
组件的最终修改,该组件为 ConfirmDialog
和 EditorDialog
都使用了级联值:
<CascadingValue Value="@_dialogEditId" Name="Id">
<EditorDialog @bind-Key="listGroupItem.Name"
@bind-Value="listGroupItem.Notes"
IsKeyEditingEnable=true
OnSave="UpdateItemAsync" KeyPlaceHolder="Item name"
ValuePlaceHolder="Please provide a description">
@if (_isNewItem) {
<select @bind="newItem.SubType" class="form-select"
aria-label="Group">
<option selected value=@ItemSubType.Group>
@ItemSubType.Group</option>
<option value=@ItemSubType.Entry>
@ItemSubType.Entry</option>
<option value=@ItemSubType.PxEntry>
@ItemSubType.PxEntry</option>
<option value=@ItemSubType.Notes>
@ItemSubType.Notes</option>
</select>
}
</EditorDialog>
</CascadingValue>
<CascadingValue Value="@_dialogDeleteId" Name="Id">
<ConfirmDialog Title=@listGroupItem.Name
OnConfirmClick="DeleteItemAsync" />
</CascadingValue>
如所示,在实现级联值之后,ConfirmDialog
和 EditorDialog
就不再需要直接处理 Id
字段。因此,与之前的版本相比,代码更加紧凑。
在本节中,我们探讨了可重用组件的创建。一些 Razor 组件可能依赖于数据或网络服务,在它们的创建或销毁过程中需要额外的步骤。我们可以将这些操作作为 Razor 组件生命周期管理的一部分来完成。
在下一节中,让我们更详细地考察 Razor 组件的生命周期。
理解 Razor 组件生命周期
Razor 组件,就像任何其他对象一样,具有生命周期。它由一系列同步和异步的生命周期方法组成,开发者可以重写这些方法以在组件初始化和渲染期间执行额外的操作。
请参阅 图 10.7 以了解 Razor 组件生命周期的概述:
图 10.7:Razor 组件生命周期
在图 10.7 中,很明显,我们有能力在初始化和渲染阶段添加钩子。以下方法可以被重写以捕获初始化事件:
-
SetParametersAsync
-
OnInitialized
和OnInitializedAsync
-
OnParametersSet
和OnParametersSetAsync
SetParametersAsync
和 OnInitializedAsync
只在第一次渲染时被调用。OnParametersSetAsync
每次参数更改时都会被调用。
以下方法可以被重写以自定义渲染:
-
ShouldRender
-
OnAfterRender
和OnAfterRenderAsync
我们将详细检查这些生命周期方法,并演示如何在我们的代码中利用它们。
SetParametersAsync
SetParametersAsync
是对象创建后的第一个钩子,其签名如下:
public override Task SetParametersAsync(ParameterView parameters)
ParameterView
参数包括组件参数或级联参数值。SetParametersAsync
方法通过 [Parameter]
或 [CascadingParameter]
属性将值分配给每个属性。此函数可以被重写以包含在设置参数之前必须执行的任何逻辑。SetParametersAsync
后续的钩子是 OnInitializedAsync
。
OnInitialized 和 OnInitializedAsync
OnInitialized
和 OnInitializedAsync
在组件初始化时被调用。它们的签名分别如下:
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
通过重写这两个函数,我们可以在这一阶段将逻辑集成到我们的组件中。然而,请注意,这些函数仅在组件创建后立即调用一次。对于资源密集型的初始化任务,可以使用异步方法,例如通过 RESTful API 调用下载数据。如图 10.7 所示,一旦异步方法完成,DOM 需要重新渲染。
OnParametersSet 和 OnParametersSetAsync
当组件参数被设置或修改时,会调用 OnParametersSet
和 OnParametersSetAsync
。我们注意到有两种版本以适应同步和异步场景。异步版本 OnParametersSetAsync
可以用于管理耗时任务。异步任务完成后,必须重新渲染 DOM 以显示任何更新。
这些方法的签名分别如下:
protected override void OnParametersSet()
protected override async Task OnParametersSetAsync()
这两种方法在组件参数或级联参数更改时都会被调用,并且可能被多次调用。相比之下,OnInitializedAsync
只会被调用一次。
如图 10.7 所示,在初始化阶段,DOM 可能会进行多次渲染,从而导致异步调用的调用。涉及此渲染过程的方法包括 ShouldRender
和 OnAfterRenderAsync
。
ShouldRender
ShouldRender
方法返回一个布尔值,指示组件是否应该被渲染。如图图 10.7所示,第一次渲染会忽略此方法。因此,组件至少需要渲染一次。此方法具有以下签名:
protected override bool ShouldRender()
OnAfterRender 和 OnAfterRenderAsync
OnAfterRender
和OnAfterRenderAsync
在组件完成其渲染过程后调用。它们的相应签名如下:
protected override void OnAfterRender(bool firstRender)
protected override async Task OnAfterRenderAsync(bool
firstRender)
这些方法可以用来执行与渲染内容相关的附加初始化任务,例如在组件中调用 JavaScript 代码。此方法具有一个布尔型firstRender
参数,使我们能够仅附加一次 JavaScript 事件处理器。尽管此方法有一个异步版本,但在异步任务完成后,框架不会安排另一个渲染周期。
要检查生命周期方法的影响,我们可以通过将所有生命周期方法集成到ConfirmDialog
组件中来进行测试,如下所示:
public ConfirmDialog()
{
Debug.WriteLine($"ConfirmDialog-{Id}: is created");
}
public override Task SetParametersAsync
(ParameterView parameters)
{
Debug.WriteLine($"ConfirmDialog-{Id}:
SetParametersAsync called");
return base.SetParametersAsync(parameters);
}
protected override void OnInitialized()
=> Debug.WriteLine($"ConfirmDialog-{Id}: OnInitialized
called - {Title}");
protected override async Task OnInitializedAsync() =>
await Task.Run(() => {
Debug.WriteLine($"ConfirmDialog-{Id}: OnInitializedAsync
called - {Title}");
});
protected override void OnParametersSet()
=> Debug.WriteLine($"ConfirmDialog-{Id}: OnParametersSet
called - {Title}");
protected override async Task OnParametersSetAsync() =>
await Task.Run(() => {
Debug.WriteLine($"ConfirmDialog-{Id}:
OnParametersSetAsync called - {Title}");
});
protected override void OnAfterRender(bool firstRender)
=> Debug.WriteLine($"ConfirmDialog-{Id}: OnAfterRender
called with firstRender = {firstRender}");
protected override async Task OnAfterRenderAsync(bool
firstRender) => await Task.Run(() => {
Debug.WriteLine($"ConfirmDialog-{Id}:
OnAfterRenderAsync called - {Title}");
});
protected override bool ShouldRender() {
Debug.WriteLine($"ConfirmDialog-{Id}: ShouldRender called
- {Title}");
return true;
}
我们已覆盖了ConfirmDialog
中的所有生命周期方法,并添加了调试输出以显示进度。在启动我们的应用程序后,我们可以观察到以下输出:
ConfirmDialog-: is created
ConfirmDialog-: SetParametersAsync called
ConfirmDialog-deleteModel: OnInitialized called -
ConfirmDialog-deleteModel: OnInitializedAsync called -
ConfirmDialog-deleteModel: OnParametersSet called -
ConfirmDialog-deleteModel: OnParametersSetAsync called -
ConfirmDialog-deleteModel: ShouldRender called -
ConfirmDialog-deleteModel: ShouldRender called -
ConfirmDialog-deleteModel: OnAfterRender called with
firstRender = True
ConfirmDialog-deleteModel: OnAfterRenderAsync called -
ConfirmDialog-deleteModel: OnAfterRender called with
firstRender = False
ConfirmDialog-deleteModel: OnAfterRenderAsync called -
ConfirmDialog-deleteModel: OnAfterRender called with
firstRender = False
ConfirmDialog-deleteModel: OnAfterRenderAsync called -
当我们首次启动应用程序并且Items
页面出现时,会显示之前的输出。我们可以观察到在调用SetParametersAsync
方法之前,Id
级联参数尚未设置。由于我们已覆盖了异步方法,因此同时安排了多个渲染周期。因此,由于并行渲染,ShouldRender
和OnAfterRenderAsync
方法被多次调用。
现在,让我们考虑另一种情况,即当我们点击Items
页面上的上下文菜单时发生的情况。点击项目上下文菜单(例如 Google 图标)时,ConfirmDialog
将再次初始化。结果输出如下:
ConfirmDialog-deleteModel: SetParametersAsync called
ConfirmDialog-deleteModel: OnParametersSet called - Google
ConfirmDialog-deleteModel: ShouldRender called - Google
ConfirmDialog-deleteModel: OnParametersSetAsync called –
Google
ConfirmDialog-deleteModel: ShouldRender called - Google
ConfirmDialog-deleteModel: OnAfterRender called with
firstRender = False
ConfirmDialog-deleteModel: OnAfterRenderAsync called –
Google
ConfirmDialog-deleteModel: OnAfterRender called with
firstRender = False
ConfirmDialog-deleteModel: OnAfterRenderAsync called –
Google
由于Title
组件参数已更改,因此再次调用了SetParametersAsync
方法。在后续调用中,我们可以观察到Title
组件参数被设置为Google
。
在我们的代码中,我们使用OnParametersSet
在Items.razor.cs
中加载项目列表,以及在ItemDetail.razor.cs
中加载字段列表。让我们检查ItemDetail.razor.cs
中的OnParametersSet
:
protected override void OnParametersSet() {
base.OnParametersSet();
if (SelectedItemId == null) { //(1)
throw new InvalidOperationException(
"ItemDetail: SelectedItemId is null");
}
selectedItem = DataStore.GetItem(SelectedItemId, true); //(2)
if (selectedItem == null) {
throw new InvalidOperationException(
"ItemDetail: entry cannot be found with SelectedItemId");
}
else {
if (selectedItem.IsGroup) {
throw new InvalidOperationException(
"ItemDetail: SelectedItemId should not be a group
here.");
}
fields.Clear();
List<Field> tmpFields = selectedItem.GetFields(); //(3)
foreach (Field field in tmpFields) {
fields.Add(field);
}
notes = selectedItem.GetNotesInHtml();
}
}
(1) 在OnParametersSet
中,我们检查SelectedItemId
组件参数是否为null
。这代表所选项目的ID
。(2) 如果它不是null
,我们可以通过调用名为GetItem
的IDataStore
方法来定位项目。(3) 一旦我们获得了所选项目的实例,我们可以通过调用GetFields
方法来检索字段列表。
Items.razor.cs
中OnParametersSet
的实现与此非常相似。有关更多详细信息,您可以参考以下 GitHub 链接:epa.ms/Items10-6
。
到目前为止,我们已经开发了一个几乎完整的密码管理器应用程序,其 UI 使用 Blazor 构建。我们已经建立了可重用的模态对话框组件来适应上下文菜单,使我们能够执行 CRUD 操作。我们需要解决的最后一个组件是实现这些 CRUD 操作的实际实现。
实现 CRUD 操作
在前几节中讨论了为 CRUD 操作准备模态对话框之后,我们现在可以继续在本节中实现这些 CRUD 操作。
项目的 CRUD 操作
要添加或更新项目,我们可以利用Items.razor.cs
中的UpdateItemAsync
方法来适应这两种情况。为了区分创建新项目和更新现有项目,我们定义了一个私有_isNewItem
字段,如下所示:
bool _isNewItem = false;
接下来,我们将看到如何添加或编辑项目。
添加新项目
要添加新项目,只需点击Items
页面标题栏中的+
按钮,如图 10.8所示:
图 10.8:添加新项目
可以在这里查看此页眉的 Razor 标记:
<div class="container"><div class="row">
<div class="col-12"><h1>
@if (selectedItem?.GetParentLink() != null) {
<a class="btn btn-outline-dark"
href="@selectedItem?.GetParentLink()">
<span class="oi oi-chevron-left"
aria-hidden="true"></span></a> //(1)
}
@(" " + Title) //(2)
<button type="button"
class="btn btn-outline-dark float-end"
data-bs-toggle="modal"
data-bs-target="#@_dialogEditId"
@onclick="@(() => _isNewItem=true)">
<span class="oi oi-plus" aria-hidden="true">
</span></button> //(3)
</h1></div>
</div></div>
页眉部分包括Back
按钮(1),Title
(2)和Add
按钮(3)。Back
按钮仅在存在父链接时显示。
点击Add
按钮后,将显示一个具有在_dialogEditId
变量中定义的Id
的模态对话框。onclick
事件处理器将_isNewItem
设置为true
,使得模态对话框事件处理器能够识别该操作是添加新项目。
编辑或删除项目
要编辑或删除一个项目,请点击项目上的上下文菜单,如图 10.9所示:
图 10.9:编辑或删除项目
点击上下文菜单按钮后,将显示一个菜单项列表。让我们检查Items.razor
中找到的上下文菜单的标记:
<div class="list-group">
@foreach (var item in items) {
<div class="dropdown list-group-item list-group-item-action
d-flex gap-1 py-2" aria-current="true">
<img src="img/@item.GetIcon()" alt="twbs" width="32"
height="32"
class="rounded-circle flex-shrink-0 float-start">
<a href="@item.GetActionLink()" class="..."> ...
<button class="opacity-50 btn btn-light
dropdown-toggle" type="button"
id="itemsContextMenu"
data-bs-toggle="dropdown" aria-expanded="false"
@onclick="@(() => listGroupItem=item)"> //(1)
<span class="oi oi-menu" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu" aria-labelledby=
"itemsContextMenu">
<li><button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#@_dialogEditId"
@onclick="@(() => _isNewItem=false)"> //(2)
Edit</button></li>
<li><button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#@_dialogDeleteId"> //(3)
Delete</button></li>
</ul>
</div>
}
</div>
在前面的标记代码中,定义了一个上下文菜单按钮(1)。点击此按钮后,将显示两个菜单项,Edit
(2)和Delete
(3)。由于上下文菜单标记代码在foreach
循环中运行,我们需要获取要编辑或删除的项目的引用。在 C#代码后逻辑中,使用listGroupItem
变量来引用所选项目。
我们可以通过上下文菜单按钮的onclick
事件处理器来捕获这个引用。
在选择Edit
菜单项时,将_isNewItem
变量设置为false
至关重要。这种调整使得模态对话框的事件处理器能够识别我们正在修改现有项目。
在完成所有之前的设置后,现在是时候检查模态对话框中的事件处理器了。首先,让我们看看Items.razor.cs
中的UpdateItemAsync
事件处理器:
private async void UpdateItemAsync(string key, string value) {
if (listGroupItem == null) { return; }
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value))
{ return; }
listGroupItem.Name = key;
listGroupItem.Notes = value;
if (_isNewItem) { //(1)
// Add new item
if (listGroupItem is NewItem aNewItem) {
Item? newItem = DataStore.CreateNewItem
(aNewItem.SubType);
if (newItem != null) {
newItem.Name = aNewItem.Name;
newItem.Notes = aNewItem.Notes;
items.Add(newItem);
await DataStore.AddItemAsync(newItem);
}
}
}
else {
// Update the current item
await DataStore.UpdateItemAsync(listGroupItem);
}
}
UpdateItemAsync
事件处理器可以管理添加和编辑项的操作。它检查 _isNewItem
变量 (1) 以确定我们是要添加还是编辑项。随后,它调用 IDataStore
方法来执行添加或更新操作。
现在,让我们检查删除项的事件处理器:
private async void DeleteItemAsync() {
if (listGroupItem == null) return;
if (items.Remove(listGroupItem)) {
_ = await DataStore.DeleteItemAsync
(listGroupItem.Id);
}
}
在 DeleteItemAsync
事件处理器中,项只是从列表中移除,并调用 IDataStore
方法来执行删除操作。
字段的 CRUD 操作
字段的 CRUD 操作与我们在项中实现的操作相似。要添加或更新字段,我们可以使用 ItemDetail.razor.cs
中的 UpdateFieldAsync
方法来管理这两种情况。为了确定我们是要创建新字段还是更新现有字段,我们定义一个私有 _isNewField
字段如下:
bool _isNewField = false;
CRUD 操作的 UI 与我们在上一节中讨论的内容相似。要查看 添加
按钮和上下文菜单项,请参阅 图 10.10。
图 10.10:添加、编辑或删除字段
我们可以按照以下方式检查 ItemDetail.razor
中页面标题的 Razor 标记代码:
<div class="container">
<div class="row"><div class="col-12">
<h1>
@if (selectedItem?.GetParentLink() != null) {
<a class="btn btn-outline-dark"
href="@selectedItem?.GetParentLink()">
<span class="oi oi-chevron-left"
aria-hidden="true"></span></a>
}
@(" " + selectedItem!.Name)
<button type="button" class="btn btn-outline-dark
float-end"
data-bs-toggle="modal" data-bs-
target="#@_dialogEditId"
@onclick="@(() => _isNewField=true)">
<span class="oi oi-plus"
aria-hidden="true"></span></button>
</h1>
</div></div>
</div>
如我们所观察到的,上述源代码与 Items.razor
中的源代码非常相似,只是将 _isNewItem
变量替换为 _isNewField
。我们可以考虑稍后将其页面标题精炼成一个可重用的组件。
与上一节类似,现在让我们检查列表组和上下文菜单的源代码:
<div class="list-group">
@foreach (var field in fields) {
@if(field.ShowContextAction == null) {
<div class="dropdown list-group-item ...
aria-current="true">
<span class="oi oi-pencil" aria-hidden="true">
</span>
<div class="d-flex gap-2 w-100
justify-content-between"> ...
<button class="opacity-50 btn btn-light
dropdown-toggle" type="button"
id="itemDetailContextMenu"
data-bs-toggle="dropdown" aria-expanded="false"
@onclick="@(() => listGroupField=field)"> //(1)
<span class="oi oi-menu" aria-hidden="true">
</span>
</button>
<ul class="dropdown-menu"
aria-labelledby="itemDetailContextMenu">
<li><button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#@_dialogEditId"
@onclick="@(() => _isNewField=false)"> //(2)
Edit
</button></li>
<li><button class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#@_dialogDeleteId"> //(3)
Delete
</button></li>
@if (field.IsProtected) {
<li><button class="dropdown-item"
@onclick="OnToggleShowPassword"> //(4)
@if (field.IsHide) { <span>Show</span> }
else { <span>Hide</span> }
</button></li>
}
</ul>
</div>
}
}
</div>
上述 ItemDetail.razor
的源代码具有一个上下文 菜单 按钮 (1) 和用于 添加 (2)、编辑 (3) 和 显示 (4) 菜单项的三个按钮。你可能已经注意到,源代码与 Items.razor
中的源代码相当相似,包括一个列表组和上下文菜单。
我们将在本章后面进一步将其开发成一个可重用的组件。上下文菜单的变化在于添加了一个显示或隐藏字段的菜单项,如果它是像密码这样的受保护字段。我们利用 onclick
事件处理器 OnToggleShowPassword
来设置 IsHide
字段属性,从而切换密码字段的可见性。
最后,让我们来检查 ItemDetail.razor.cs
中模态对话框的事件处理器:
private async void UpdateFieldAsync(string key, string
value) {
if (selectedItem == null || listGroupField == null) {
throw new NullReferenceException("Selected item is
null");
}
if (string.IsNullOrEmpty(key) ||
string.IsNullOrEmpty(value)) { return; }
listGroupField.Key = key;
listGroupField.Value = value;
if (_isNewField) {
// Add a new field
Field newField = selectedItem.AddField
(listGroupField.Key,
((listGroupField.IsProtected) ?
listGroupField.EditValue : listGroupField.Value),
listGroupField.IsProtected);
fields.Add(newField);
}
else {
// Update the current field
var newData = (listGroupField.IsProtected) ?
listGroupField.EditValue : listGroupField.Value;
selectedItem.UpdateField(listGroupField.Key, newData,
listGroupField.IsProtected);
}
await DataStore.UpdateItemAsync(selectedItem);
}
UpdateFieldAsync
事件处理器管理字段的添加和编辑。它接收两个参数 – key
和 value
– 这些参数从模态对话框传递过来,并用于设置 listGroupField
的字段。通过检查 _isNewField
变量,处理器确定意图是添加还是编辑字段。随后,它调用 IDataStore
方法来执行添加或更新操作。
要删除字段,下面的 DeleteFieldAsync
事件处理器被触发:
private async void DeleteFieldAsync() {
if (listGroupField == null || selectedItem == null) {
throw new NullReferenceException(
"Selected item or field is null");
}
listGroupField.ShowContextAction = listGroupField;
selectedItem.DeleteField(listGroupField);
await DataStore.UpdateItemAsync(selectedItem);
}
在 DeleteFieldAsync
事件处理器中,我们只需从所选项中删除字段,并调用 IDataStore
方法来更新数据库。
在实现了 CRUD 操作后,我们现在已经成功完成了密码管理器功能的实现。我们利用 Blazor UI 开发了一个新的密码管理器应用程序。与本书第一部分中描述的版本相比,本版本的差异在于我们使用 Blazor 构建所有用户界面。Blazor UI 的外观和功能与 Web 应用程序相似,而 XAML UI 则保留了原生应用程序的特性。
尽管我们已经成功实现了所有必需的功能,但我们可以在当前实现中观察到一些重复的代码。为了解决这个问题,我们可以重构我们的代码,将重复的部分转换为 Razor 组件。
重构 Razor 组件
在当前实现中,我们可以观察到大部分重复的代码都出现在Items
和ItemDetail
页面中。在本章的剩余部分,我们将将这些重复的代码转换为 Razor 组件。
我们将创建以下组件:
-
Navbar
:此组件显示导航栏。 -
Dropdown
:此组件支持上下文菜单。 -
ListView
:此组件显示项目列表。
ListView
组件是最复杂的,所以我们将在本节末尾处理它。现在,让我们首先关注Navbar
和Dropdown
组件。
创建 Navbar 组件
让我们检查图 10.11中的导航栏 UI。我们可以观察到导航栏具有一个Back按钮、一个Title和一个Add按钮:
图 10.11:导航栏
Items and ItemDetail pages, resulting in a duplication:
<div class="container">
<div class="row">
<div class="col-12">
<h1>
@if (selectedItem?.GetParentLink() != null) { //(1)
<a class="btn btn-outline-dark"
href="@selectedItem?.GetParentLink()">
<span class="oi oi-chevron-left"
aria-hidden="true"></span></a> //(2)
}
@(" " + Title) //(3)
<button type="button"
class="btn btn-outline-dark float-end"
data-bs-toggle="modal"
data-bs-target="#@_dialogEditId"
@onclick="@(() => _isNewItem=true)"> //(4)
<span class="oi oi-plus" aria-hidden="true">
</span>
</button>
</h1>
</div>
</div>
</div>
在前面的代码中,(1) 当存在父级链接时,会显示Back
按钮。(2) Back
按钮是通过一个<a>
标签实现的。(3) Title
作为一个字符串,出现在<h1>
标签内。(4) Add
按钮是通过一个<button>
标签实现的。Back
和Add
按钮的样式都采用了 Bootstrap 格式化。
为了将前面的代码转换为 Razor 组件,我们可以在PassXYZ.BlazorUI
项目中生成一个新的 Razor 组件,并将其命名为Navbar
。Navbar
组件将显示图 10.11中所示的用户界面元素,包括一个Back
按钮、一个标题和一个Add
按钮。为了分离 UI 和逻辑,我们将创建一个Navbar.razor.cs
C#代码后文件和一个 Razor 标记文件,Navbar.razor
。我们将在 C#代码后文件中定义组件参数和事件处理器,如列表 10.6所示:
public partial class Navbar
{
[Parameter]
public string? ParentLink { get; set; } //(1)
[Parameter]
public string? DialogId { get; set; } //(2)
[Parameter]
public string? Title { get; set; } //(3)
[Parameter]
public EventCallback<MouseEventArgs> OnAddClick { get;
set; } //(4)
private void OnClickClose(MouseEventArgs e) {
OnAddClick.InvokeAsync();
}
}
列表 10.6:Navbar.razor.cs
(epa.ms/Navbar10-7
)
在Navbar
中,定义了四个组件参数和一个事件处理器。我们可以使用ParentLink
参数 (1) 为Back
按钮分配父链接。Title
的值根据Title
参数 (3) 设置。对于Add
按钮,需要提供一个Id
和对话框的事件处理器;因此,使用了DialogId
(2) 和OnAddClick
(4) 参数。
现在,让我们检查Navbar
的 Razor 文件,它展示在Listing 10.7中:
@namespace PassXYZ.BlazorUI
<div class="container">
<div class="row">
<div class="col-12">
<h1>
@if (ParentLink != null) { //(1)
<a class="btn btn-outline-dark"
href="@ParentLink"> //(1)
<span class="oi oi-chevron-left"
aria-hidden="true"></span>
</a>
}
@(" " + Title) //(3)
<button type="button"
class="btn btn-outline-dark float-end"
data-bs-toggle="modal"
data-bs-target="#@DialogId" //(2)
@onclick="OnClickClose"> //(4)
<span class="oi oi-plus" aria-hidden="true">
</span>
</button>
</h1>
</div>
</div>
</div>
列表 10.7: Navbar.razor
(epa.ms/Navbar10-8
)
我们可以观察到代码与Items
和ItemDetail
中使用的代码非常相似。关键的区别在于,我们将硬编码的值替换为组件参数(ParentLink
(1),DialogId
(2),Title
(3),和OnClickClose
(4))。通过整合这个新的Navbar
组件,我们可以通过使用Navbar
组件来修改Items
中的代码,如下所示:
<Navbar ParentLink="@selectedItem?.GetParentLink()"
Title="@Title" DialogId="@_dialogEditId"
OnAddClick="@(() => {_isNewItem=true;})" />
然后,我们可以按照以下方式替换ItemDetail
中的代码:
<Navbar ParentLink="@selectedItem?.GetParentLink()"
Title="@selectedItem?.Name" DialogId="@_dialogEditId"
OnAddClick="@(() => {_isNewField=true;})" />
如观察到的,我们通过消除重复来简化了代码,从而实现了更优雅和简洁的展示。
完成了Navbar
的工作后,我们现在将注意力转向Dropdown
组件。
创建上下文菜单的 Dropdown 组件
要开发一个类似于上下文菜单的组件,我们可以重用 Bootstrap 的Dropdown
组件。如图 10.12 所示,上下文菜单包括一个上下文菜单按钮和一系列菜单项。点击上下文菜单按钮后,用户将看到菜单项的显示。
图 10.12: 上下文菜单
当前上下文菜单的代码在Items
和ItemDetail
页面中都有复制,如下所示:
<button class="opacity-50 btn btn-light dropdown-toggle"
type="button" id="itemsContextMenu"
data-bs-toggle="dropdown"
aria-expanded="false"
@onclick="@(() => listGroupItem=item)">
<span class="oi oi-menu" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu"
aria-labelledby="itemsContextMenu">
<li><button class="dropdown-item" data-bs-toggle="modal"
data-bs-target="#@_dialogEditId"
@onclick="@(() => _isNewItem=false)">
Edit
</button></li>
<li><button class="dropdown-item" data-bs-toggle="modal"
data-bs-target="#@_dialogDeleteId">
Delete
</button></li>
</ul>
Bootstrap 的Dropdown
组件包括一个按钮和无序列表。我们必须为按钮定义一个事件处理器以执行必要的操作。在前面的代码中,我们将item
变量设置为listGroupItem
。关于菜单项,每个都实现为一个<button>
标签,并接受一个对话框 ID 和事件处理器作为参数。当点击菜单项时,将显示相应的模态对话框。
我们可以在PassXYZ.BlazorUI
项目中创建两个新的 Razor 组件,分别命名为Dropdown
和MenuItem
。为了分离 UI 和逻辑,我们可以在 C#代码后文件(Listing 10.9)和 Razor 文件(Listing 10.8)中实现它们,我们现在将演示这些。
让我们先检查列表 10.8 中的Dropdown
组件 UI:
@namespace PassXYZ.BlazorUI
<button class="opacity-50 btn btn-light dropdown-toggle"
type="button" id="itemDetailContextMenu"
data-bs-toggle="dropdown"
aria-expanded="false" @onclick="OnClick">
<span class="oi oi-menu" aria-hidden="true"></span>
</button> //(1)
<ul class="dropdown-menu"
aria-labelledby="itemDetailContextMenu">
@ChildContent
</ul> //(2)
列表 10.8: Dropdown.razor
(epa.ms/Dropdown10-9
)
在 Dropdown
组件中,我们定义了一个按钮 (1) 和一个无序列表 (2)。按钮的点击事件被设置为 OnClick
事件处理器。无序列表中的项作为 Dropdown
组件的子内容出现。组件参数可以在 C# 的 Dropdown.razor.cs
代码隐藏文件中找到,如 列表 10.9 所示:
namespace PassXYZ.BlazorUI;
public partial class Dropdown
{
[Parameter]
public EventCallback<MouseEventArgs> OnClick {get;set;} //(1)
[Parameter]
public RenderFragment ChildContent { get; set; } //(2)
}
列表 10.9: Dropdown.razor.cs
(epa.ms/Dropdown10-10
)
在 Dropdown.razor.cs
中,定义了两个组件参数 OnClick
(1) 和 ChildContent
(2)。MenuItem
组件可以作为 Dropdown
组件的子内容。MenuItem
的 UI 代码可以在 列表 10.10 中查看:
@namespace PassXYZ.BlazorUI
<li>
<button class="dropdown-item" data-bs-toggle="modal"
data-bs-target="#@Id" @onclick="OnClick">
@ChildContent
</button>
</li>
列表 10.10: MenuItem.razor
(epa.ms/MenuItem10-11
)
MenuItem
组件指定了三个组件参数:Id
、OnClick
和 ChildContent
。这些参数在 MenuItem.razor.cs
中定义,如 列表 10.11 所示:
namespace PassXYZ.BlazorUI;
public partial class MenuItem
{
[Parameter]
public string? Id { get; set; } //(1)
[Parameter]
public EventCallback<MouseEventArgs> OnClick {get; set;} //(2)
[Parameter]
public RenderFragment ChildContent { get; set; } //(3)
}
列表 10.11: MenuItem.razor.cs
(epa.ms/MenuItem10-12
)
(1) Id
参数用于指定当菜单项被点击时的对话框 ID。(2) OnClick
用于为按钮点击事件注册事件处理器。(3) ChildContent
用于显示子内容,例如菜单项的名称。
我们已经成功实现了上下文菜单的组件。现在,我们可以用这些上下文菜单组件替换 Items
和 ItemDetail
页面中的冗余代码。在 Items
页面上实现上下文菜单的步骤如下:
<Dropdown OnClick="@(() => currentItem.Data=listGroupItem=item)">
<MenuItem Id="@_dialogEditId"
OnClick="@(() => _isNewItem=false)">Edit</MenuItem>
<MenuItem Id="@_dialogDeleteId">Delete</MenuItem>
</Dropdown>
在 ItemDetail
页面上,上下文菜单的实现方式如下:
<Dropdown OnClick="@(() = >
{currentField.Data=listGroupField=field;})">
<MenuItem Id="@_dialogEditId"
OnClick="@(() => _isNewField=false)">Edit</MenuItem>
<MenuItem Id="@_dialogDeleteId">Delete</MenuItem>
@if (field.IsProtected) {
<MenuItem OnClick="OnToggleShowPassword">
@(field.IsHide ? "Show":"Hide")
</MenuItem>
}
</Dropdown>
在对 Items
和 ItemDetail
页面的代码进行精炼后,我们实现了模态对话框、导航栏和上下文菜单组件。因此,代码看起来更加优雅和简洁。尽管如此,仍有进一步优化的空间。在 Items
和 ItemDetail
页面中,主要的 UI 逻辑都围绕列表视图展开。我们可以通过实现 ListView
组件来优化这部分代码。为了创建 ListView
组件,我们需要利用一个称为模板组件的高级功能。
使用模板组件
在构建 Razor 组件时,组件参数是父组件和子组件之间的通信渠道。在讨论嵌套组件时,我们提到了一个特殊的 ChildContent
组件参数,其类型为 RenderFragment
。该参数允许父组件设置子组件的内容。例如,以下代码中 MenuItem
的内容可以分配一个 HTML 字符串:
<MenuItem Id="@_dialogDeleteId">
<strong>Delete</strong>
</MenuItem>
我们可以通过 MenuItem
定义的以下组件参数来实现这一点,如 列表 10.11 所示:
[Parameter]
public RenderFragment ChildContent { get; set; }
如果我们想显式指定 ChildContent
参数,可以按照以下方式实现:
<MenuItem Id="@_dialogDeleteId">
<ChildContent>
<strong>Delete</strong>
</ChildContent>
</MenuItem>
ChildContent
是一个独特的组件参数,可以在标记语言中隐式使用。要使用ChildContent
,我们需要创建一个能够接受RenderFragment
类型 UI 模板作为其参数的组件。此外,我们可以在开发新组件时定义多个 UI 模板作为参数。这种类型的组件被称为模板组件。
一个RenderFragment
类型的渲染片段表示为渲染而指定的 UI 的一部分。此外,还有一个泛型版本,RenderFragment<TValue>
,它接受一个类型参数。在调用RenderFragment
时可以提供一个特定类型。
创建 ListView 组件
要创建ListView
,我们需要使用多个 UI 模板作为组件参数。我们可以在PassXYZ.BlazorUI
项目中创建一个新的 Razor 组件,并将其命名为ListView
。类似于我们对Navbar
和上下文菜单所做的那样,我们可以将 UI 和代码分离到 Razor 文件(列表 10.12)和 C#代码后文件(列表 10.13)中:
@namespace PassXYZ.BlazorUI
@typeparam TItem
<div class="list-group">
@if (Header != null) {
@Header //(1)
}
@if (Row != null && Items != null) {
@foreach (var item in Items) {
<div class="dropdown list-group-item
list-group-item-action
d-flex gap-1 py-2" style="border: none"
aria-current="true">
@Row.Invoke(item) //(2)
</div>
}
}
@if (Footer != null) {
<div class="container">
<article>@Footer</article>
</div> //(3)
}
</div>
列表 10.12: ListView.razor
(epa.ms/ListView10-13
)
在ListView
Razor 文件中,我们定义了三个 UI 模板,Header
(1)、Row
(2) 和 Footer
(3)。我们像ChildContent
一样渲染Header
和Footer
,但Row
组件参数看起来是不同的。Row
组件的渲染过程如下:
@Row(item)
或者,我们可以这样渲染:
@Row.Invoke(item)
我们使用item
参数来渲染它。Row
的类型是RenderFragment<TValue>
,正如我们在列表 10.13中展示的那样:
namespace PassXYZ.BlazorUI;
public partial class ListView<TItem>
{
[Parameter]
public RenderFragment? Header { get; set; } //(1)
[Parameter]
public RenderFragment<TItem>? Row { get; set; } //(2)
[Parameter]
public IEnumerable<TItem>? Items { get; set; } //(3)
[Parameter]
public RenderFragment? Footer { get; set; } //(4)
}
列表 10.13: ListView.razor.cs
(epa.ms/ListView10-14
)
我们将ListView
定义为具有TItem
类型参数的泛型ListView<TItem>
类型。在ListView
组件中,我们可以使用Header
(1) 参数指定列表视图的标题,使用Footer
(4) 参数指定页脚。ListView
可以通过Items
参数 (3) 绑定到任何IEnumerable<TItem>
类型的数据集合。Row
(2) 参数可以用来为foreach
循环中的单个项目建立 UI 模板。
使用 ListView 组件
到目前为止,让我们来检查Items
和ItemDetail
页面中ListView
组件的使用情况。我们将以ItemDetail
页面作为我们讨论的例子:
<ListView Items="fields"> //(1)
<Row Context="field"> //(2)
@if (field.ShowContextAction == null) {
<span class="oi oi-pencil" aria-hidden="true"></span>
<div class="d-flex gap-2 w-100
justify-content-between">
<div>
<h6 class="mb-0">@field.Key</h6>
<p class="mb-0">@field.Value</p>
</div>
</div>
<Dropdown
OnClick="@(() =>
{currentField.Data=listGroupField=field;})">
<MenuItem Id="@_dialogEditId"
OnClick="@(() => _isNewField=false)">
Edit
</MenuItem>
<MenuItem Id="@_dialogDeleteId">Delete</MenuItem>
@if (field.IsProtected) {
<MenuItem OnClick="OnToggleShowPassword">
@(field.IsHide ? "Show":"Hide")
</MenuItem>
}
</Dropdown>
}
</Row>
<Footer>
@((MarkupString)notes)
</Footer>
</ListView>
由于我们已经将Header
、Row
和Footer
定义为可选参数,因此没有必要指定所有这些参数。在ItemDetail
页面中,我们使用Row
和Footer
。(1) 首先,我们需要将字段列表传递给Items
参数。(2) 在foreach
循环中,每个字段都被传递给ListView
作为Row
的参数,Row
的定义如下:
<Row Context="field">
Context
属性的"field"
值被用来指定Row
的参数。在Row
的 UI 模板中,我们展示了field
的关键值,并使用Dropdown
和MenuItem
组件创建了一个上下文菜单,这些组件在上一节中已实现。
通过使用ListView
组件,我们显著提升了ItemDetail
页面的实现。这一改进是通过创建我们自己的 Razor 组件来实现的。
完成代码重构后,我们完成了.NET MAUI Blazor Hybrid
应用的引入。在第二部分中,我们使用 Blazor 重新创建了我们的应用,同时保持了相同的功能。
如果你有过 Blazor Web 应用开发的经验,你可能不会在 Blazor Hybrid 应用和 Blazor Web 应用之间看到太大的差异。这正是 Blazor Hybrid 应用的优点。你现在可能想知道,我们如何在 Blazor Hybrid 应用中访问原生 API?让我们在下一节中简要概述。
从 Blazor Hybrid 应用访问原生 API
当涉及到访问原生 API 时,.NET MAUI 应用和 Blazor Hybrid 应用之间没有显著差异。正如我们在第七章中学习的使用平台特定功能,始终创建一个封装平台层原生访问的抽象层是至关重要的。因此,我们在.NET MAUI 或 Blazor Hybrid 应用中不会直接从跨平台代码中访问原生 API。在我们的应用中,虽然我们需要访问原生 API,但我们将通过一个抽象层来访问原生 API。
从.NET 访问平台 API 随着时间的推移经历了显著的发展,从 Xamarin 插件过渡到 Xamarin.Essentials 提供的统一 NuGet 依赖项。正如第七章中讨论的使用平台特定功能,Xamarin.Essentials 旨在将所有原生访问标准化到一个库中。最初,.NET MAUI 的计划是将所有内容迁移到.NET MAUI Essentials。然而,.NET MAUI 团队后来意识到,将它们分解成更逻辑上细粒度的命名空间,如Microsoft.Maui.Storage
或Microsoft.Maui.Devices
等,更有意义。
在我们的应用中,我们通过Services
文件夹中定义的类访问平台级别的 API,例如LoginService.cs
。例如,我们在LoginService
中定义了一个属性IsPrivacyNoticeAccepted
,用来存储用户是否接受了隐私通知。我们使用Preferences
API 将数据存储在平台特定的持久存储中。Preferences
API 定义在Microsoft.Maui.Storage
命名空间中,如下面的代码所示:
Public static bool IsPrivacyNoticeAccepted
{
get => Preferences.Get(PrivacyNotice, false);
set => Preferences.Set(PrivacyNotice, value);
}
Preferences
等 API 由 Microsoft 作为.NET MAUI 库的一部分提供。如果我们发现某些内容不支持 Microsoft API,我们必须创建自己的插件或使用社区提供的插件。请参阅第七章了解如何创建和使用.NET MAUI 插件。
摘要
在本章中,我们解释了创建 Razor 组件的过程。我们涵盖了数据绑定和组件生命周期等主题。随后,我们开发了一套模态对话框组件以优化我们的代码。通过利用 Razor 组件,我们可以消除重复代码并增强 UI 设计。我们在模态对话框的事件处理程序中集成了 CRUD 操作。因此,我们现在有了密码管理器应用的新版本。
在下一章中,我们将过渡到本书的第三部分。在第三部分中,我们将介绍单元测试的实现以及将.NET MAUI 应用程序发布到应用商店的过程。
留下评论!
喜欢这本书吗?帮助像你这样的读者留下亚马逊评论。扫描下面的二维码获取 40%的折扣码。
*限时优惠
第三部分
测试和部署
在本书的第一部分中,我们深入探讨了.NET MAUI 应用开发,而在第二部分中,我们研究了.NET MAUI Blazor 混合应用开发。因此,我们现在从第一部分和第二部分中得到了两个不同的密码管理应用版本。XAML 和 Blazor 混合应用在设计上都使用了 MVVM 模式。在介绍 MVVM 模式时,我们强调了独立测试视图模型和模型的能力。因此,在本书的前两部分之后,我们在应用开发方面有了充分的实践。
在本书的第三部分中,我们将关注软件开发的其他重要方面,即.NET MAUI 中的单元测试和.NET MAUI 应用的部署过程。通过学习如何在单元测试中创建模拟组件,你将学会如何在隔离环境中检查软件模块。
第三部分包括以下章节:
-
第十一章,开发单元测试
-
第十二章,在应用商店中部署和发布
第十一章:开发单元测试
测试在当代软件开发中确保软件质量方面发挥着至关重要的作用。软件开发生命周期中涉及多种类型的测试,包括单元测试、集成测试和系统测试。单元测试用于在隔离环境中检查软件模块或组件,通常由开发人员执行。通过一个精心设计的单元测试策略,可以在软件开发生命周期的早期阶段发现编程问题,使单元测试成为保证软件质量最有效和最经济的途径。
在 .NET MAUI 应用程序开发中,我们可以利用 .NET 生态系统中的现有单元测试框架或库。通过使用测试框架或库,我们可以加快单元测试的开发。一个有效的测试框架通常是设计用于与 持续集成 (CI) 和 持续部署 (CD) 环境无缝集成的。在本章中,我们将演示如何设置单元测试并在 .NET MAUI 应用程序开发生命周期中执行单元测试用例。
本章将涵盖以下主题:
-
.NET 中的单元测试
-
模拟 .NET MAUI 组件
-
使用 bUnit 进行 Razor 组件测试
技术要求
要测试和调试本章的源代码,您需要设置 .NET 8 环境。您可以通过以下 Microsoft 链接中的说明在 Windows、macOS 或 Linux 上安装 .NET 8:dotnet.microsoft.com/en-us/download/dotnet/8.0
。
单元测试可以使用 dotnet
命令从命令行执行,或者如果您使用的是 Windows,可以通过 Visual Studio 中的测试资源管理器执行。
要设置 Visual Studio 2022,请参阅 第一章,使用 .NET MAUI 入门 中的 开发环境设置 部分,以获取详细信息。
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter11
。
要查看本章的源代码,我们可以使用以下命令:
$ git clone -b 2nd/chapter11 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
要了解更多关于本书源代码的信息,请参阅 第二章,构建我们的第一个 .NET MAUI 应用程序 中的 管理本书中的源代码 部分。
.NET 中的单元测试
要开发单元测试用例,通常使用单元测试框架来提高效率。在 .NET 环境中,有几种单元测试框架可供选择,如下所示:
-
Microsoft 测试框架(MSTest)与 Visual Studio 一起捆绑提供。MSTest(V1)的初始版本不是一个开源产品,它首次与 Visual Studio 2005 的发布一同出现。有关 MSTest(V1)的更多信息,请参阅 Lars Powers 和 Mike Snell 合著的《Microsoft Visual Studio 2005 Unleashed》一书。后来,Microsoft 将 MSTest(V2)开源,托管在 GitHub 上,首个版本大约在 2017 年发布。
-
NUnit 是一个从 JUnit 转移过来的开源测试框架。它是 .NET 的首个单元测试框架,其最早的版本于 2004 年在 SourceForge 上发布。有关 2.0 版本的信息,请参阅“进一步阅读”部分的发布说明。最新的版本已经转移到 GitHub 上。
-
xUnit 是由 NUnit 的创造者 Jim Newkirk 和 Brad Wilson 开发的一个现代且可扩展的框架。与 NUnit 相比,他们对这个新的测试框架进行了重大改进。要了解更多关于这些改进的信息,请参阅 Jim 的博客,Why Did we Build xUnit 1.0?xUnit 的第一个稳定版本大约在 2015 年发布。
所有这些框架都非常受欢迎,可以根据项目需求进行选择。在本章中,我们将使用 xUnit 来开发单元测试用例,因为它是一个更新的框架,与 NUnit 相比提供了许多增强功能。
无论你选择哪个单元测试框架,开发单元测试的过程都非常一致。即使你在项目中使用不同的框架,本章的内容仍然适用。单元测试用例旨在仅在跨平台目标框架上运行,而不是在特定平台上的框架。在这本书中,我们使用 .NET 8.0;因此,单元测试的目标框架是 net8.0
,而不是 net8.0-android
或 net8.0-ios
。
在本章中,我们将首先为模型层开发单元测试,该层对 XAML 和 Blazor 都适用。随后,我们将介绍使用 bUnit 为 Blazor 应用进行单元测试的开发。bUnit 是一个兼容所有三个测试框架(xUnit、NUnit 和 MSTest)的多功能测试库。
设置单元测试项目
为了获得一些实践经验,让我们创建一个单元测试项目。我们可以使用 Visual Studio 或 .NET 命令行来创建 xUnit 项目:
- 要使用 Visual Studio 开始,我们可以向当前解决方案添加一个新项目,如图 图 11.1 所示:
图 11.1 – 使用 Visual Studio 创建 xUnit 项目
-
首先,在搜索框中输入
xunit
并选择 C# 的 xUnit 测试项目。 -
在下一屏上,将项目命名为
PassXYZ.Vault.Tests
并点击 下一步。 -
随后,将框架设置为 .NET 8.0 并点击 创建。
如果你正在使用 Visual Studio Code,第一步是在你的环境中安装.NET MAUI 扩展。一旦扩展安装完成,你就可以使用解决方案资源管理器导航你的项目,就像在 Visual Studio 中做的那样。请注意,截至 2024 年 3 月,.NET MAUI 扩展仍处于预览阶段。
要创建一个 xUnit 项目,请点击位于解决方案中的+按钮。此操作将显示以下所示的项目模板列表:
图 11.1a – 使用 Visual Studio Code 创建 xUnit 项目
要使用命令行创建项目,我们首先应创建文件夹,然后使用.NET 命令来建立项目,如下所示:
mkdir PassXYZ.Vault.Tests
cd PassXYZ.Vault.Tests
dotnet new xunit
dotnet test
创建项目后,我们可以使用dotnet test
命令来运行测试用例。模板中的默认测试用例将被执行,然后我们将继续向该项目添加更多测试用例。我们的测试目标包含来自PassXYZ.Vault
和PassXYZ.BlazorUI
项目的组件,因此我们需要将这些两个项目作为引用项目包含在内。PassXYZ.BlazorUI
的目标框架是net8.0
,这允许我们直接添加它。然而,由于PassXYZ.Vault
的目标框架是平台特定的,我们必须在将其作为引用包含到PassXYZ.Vault.Tests
之前进行一些修改。
由于我们需要针对目标框架net8.0
构建单元测试,因此有必要修改PassXYZ.Vault.csproj
文件,将net8.0
作为目标框架之一包含在内:
<TargetFrameworks>**net8.0**;net8.0-android;net8.0-ios;net8.0-
maccatalyst</TargetFrameworks>
在支持的平台构建PassXYZ.Vault
项目时,我们预期会生成一个可执行文件,因为它是一个应用程序。然而,当为net8.0
目标框架构建PassXYZ.Vault
时,我们的目标是进行测试。PassXYZ.Vault
应生成为一个库,以便测试框架可以利用它来运行测试用例。在这种情况下,我们期望构建一个具有.dll
扩展名的文件,而不是.exe
,这需要以下修改:
<OutputType **Condition****=****"'****$(TargetFramework)'!='net8.0'"**>
Exe</OutputType>
在前面的构建设置中,已添加一个条件来验证输出类型的目标框架。如果目标框架不是net8.0
,则构建输出将生成一个可执行文件。相反,如果目标框架设置为net8.0
,则将生成一个库。
在进行这些更改后,我们可以通过右键单击解决方案节点并选择添加 -> 项目引用,或者通过编辑PassXYZ.Vault.Tests
的项目文件来包含这些行,将引用项目添加到PassXYZ.Vault.Tests
中:
<ItemGroup>
<ProjectReference
Include="..\PassXYZ.BlazorUI\PassXYZ.BlazorUI.csproj" />
<ProjectReference
Include="..\PassXYZ.Vault\PassXYZ.Vault.csproj" />
</ItemGroup>
为了测试 MAUI 项目,还需要将以下配置添加到PassXYZ.Vault.Tests
项目中:
<UseMaui>true</UseMaui>
我们现在已经成功设置了 xUnit 项目。接下来,让我们继续添加我们的测试用例。
为模型开发测试用例
我们使用 xUnit 作为我们应用的单元测试工具。它提供了一种全面且高效的方式来顺序运行测试,彼此之间相互独立,支持桌面和移动应用。
xUnit 中的两个基本概念是 Fact
和 Theory
。
在 xUnit 中,Fact
指的是一个无参数的测试方法,用于确认代码中某些条件的正确性。它声明某事应该是始终正确的。一个“事实”测试在测试套件每次运行时都会运行,因为它们应该是不可变条件,无论执行时的数据状态如何。
另一方面,Theory
允许进行参数化测试。它允许开发者使用不同的数据集多次运行单个测试方法,减少代码冗余,同时提高覆盖率。它基于这样一个想法:如果测试方法的合同对于特定的数据集是正确的,那么它应该对所有数据集都是正确的。
我们将首先添加测试用例来检查模型层,因为模型层的测试用例设置在 XAML 和 Blazor 版本的应用中是相同的。
在模型层,主要实现位于 PassXYZLib
库中。有关模型层的单元测试的更多信息,请参阅 PassXYZLib
源代码:github.com/shugaoye/PassXYZLib
。
在我们的应用中,IDataStore
是用于导出 PassXYZLib
的接口。因此,让我们在 PassXYZ.Vault.Tests
项目中创建一个新的测试类 DataStoreTest
来测试这个接口,IDataStore
。为了评估 IDataStore
接口,我们可以创建一个新的测试用例,如下测试“添加项目”:
public class DataStoreTests
{
[Fact] //(1)
public async void Add_Item()
{
// Arrange //(2)
IDataStore<Item> datastore = new MockDataStore();
ItemSubType itemSubType = ItemSubType.Entry;
// Act //(3)
var newItem = datastore.CreateNewItem(itemSubType);
newItem.Name = $"{itemSubType.ToString()}01";
await datastore.AddItemAsync(newItem);
var item = datastore.GetItem(newItem.Id);
// Assert //(4)
Assert.Equal(newItem.Id, item.Id);
}
}
xUnit 使用属性来通知框架关于测试用例配置。在这个测试用例中,我们使用 [Fact]
属性 (1) 来指定这个方法为一个测试用例。为了定义一个测试用例,我们可以遵循一个常见的模式 – Arrange
、Act
和 Assert
:
-
Arrange
(2) – 我们将为测试准备所有必要的设置。为了添加一个项目,我们首先需要初始化IDataStore
接口,然后我们将定义一个变量来保存项目类型。 -
Act
(3) – 我们执行我们想要测试的方法,这些方法是CreateNewItem
和AddItemAsync
。 -
Assert
(4) – 我们检查我们期望的结果。在我们的例子中,我们尝试使用item.Id
来检索新项目。之后,我们检查确保检索到的项目 ID 与我们期望的相同。
正如你可能已经注意到的,我们在上一个测试用例中测试了 Entry
类型。Entry
类型只是项目类型之一 – 我们有很多。为了测试所有这些类型,我们需要创建许多测试用例。xUnit 支持另一种测试用例类型 [Theory]
,它帮助我们使用一个测试用例测试不同的场景。
我们可以使用“删除项目”测试用例来演示如何使用 [Theory]
属性在一个测试用例中测试不同的场景。在这个测试用例中,我们可以在一个测试用例中删除不同项目类型的项目:
public class DataStoreTests
{
...
[Theory] //(1)
[InlineData(ItemSubType.Entry)] //(2)
[InlineData(ItemSubType.Group)]
[InlineData(ItemSubType.Notes)]
[InlineData(ItemSubType.PxEntry)]
public async void Delete_Item(ItemSubType itemSubType)
{
// Arrange
IDataStore<Item> datastore = new MockDataStore();
var newItem = datastore.CreateNewItem(itemSubType); //(3)
newItem.Name = $"{itemSubType.ToString()}01";
await datastore.AddItemAsync(newItem);
// Act
bool result = await
datastore.DeleteItemAsync(newItem.Id); //(4)
Debug.WriteLine($"Delete_Item: {newItem.Name}");
// Assert
Assert.True(result); //(5)
}
...
}
当我们使用 [Theory]
属性创建测试用例时,(1),我们可以通过 itemSubType
参数传递不同的项目类型。itemSubType
参数的值使用 [InlineData]
属性定义,(2)。
为了安排测试数据,我们使用 itemSubType
参数创建一个新的项目,(3)。然后,我们执行 DeleteItemAsync
方法,(4),这是我们想要测试的方法。
最后,我们检查返回值,(5)。如果项目成功删除,则结果为真。否则,结果为假。
我们已经学习了使用 [Fact]
属性创建测试用例的过程,以及如何使用 [Theory]
属性处理不同的场景。在下一节中,我们将深入探讨与测试用例开发相关的进一步主题。
在测试之间共享上下文
在我们之前的测试用例中,我们为每个测试创建了一个新的 IDataStore
实例。是否有可能共享一个 IDataStore
实例,而不是为每个测试重复创建相同的实例?通过在 xUnit 中将测试设置共享到一组测试用例中,我们可以最小化重复。
在 xUnit 中,有三种方法可以在测试之间共享设置和清理代码:
-
构造函数 和 Dispose:我们可以使用类构造函数来共享设置和清理代码,而不共享实例。
-
类固定器:我们可以使用固定器在单个类中共享对象实例。
-
集合固定器:我们可以使用集合固定器在多个测试类中共享对象实例。
使用构造函数进行共享
为了从之前的测试中移除重复的设置代码,我们可以将 IDataStore
实例的创建移动到 DataStoreTests
测试类的构造函数中,如下所示:
public class DataStoreTests
{
IDataStore<Item> datastore;
public DataStoreTests()
{
datastore = new MockDataStore();
Debug.WriteLine("DataStoreTests: Created");
}
...
}
在此代码中,我们添加了一个私有成员变量 datastore
,并在 DataStoreTests
的构造函数中创建了一个 IDataStore
实例。我们还添加了调试输出,以便我们可以监控 IDataStore
接口的创建。让我们调试 DataStoreTests
类的执行,以便我们可以在这里看到调试输出:
DataStoreTests: Created
Delete_Item: Entry01
DataStoreTests: Created
Delete_Item: Group01
DataStoreTests: Created
Delete_Item: PxEntry01
DataStoreTests: Created
Delete_Item: Notes01
DataStoreTests: Created
Create_Item: PxEntry
DataStoreTests: Created
Create_Item: Group
DataStoreTests: Created
Create_Item: Entry
DataStoreTests: Created
Create_Item: Notes
DataStoreTests: Created
Add_Item: Done
我们可以从调试输出中看到,为每个测试用例创建了一个 DataStoreTests
类。我们在测试方法内部或构造函数中创建 IDataStore
实例没有区别。所有测试用例仍然彼此隔离。当我们使用 [Theory]
属性使用一个方法测试不同的场景时,每个场景在运行时都像是一个单独的测试用例。为了更好地理解这一点,我们可以使用 dotnet
命令列出所有定义的测试:
dotnet test -t
Determining projects to restore...
All projects are up-to-date for restore.
Microsoft (R) Test Execution Command Line Tool Version 17.3.0
(x64)
Copyright (c) Microsoft Corporation. All rights reserved.
The following Tests are available:
PassXYZ.Vault.Tests.DataStoreTests.Add_Item
PassXYZ.Vault.Tests.DataStoreTests.Delete_Item(itemSubType:
Entry)
PassXYZ.Vault.Tests.DataStoreTests.Delete_Item(itemSubType:
Group)
PassXYZ.Vault.Tests.DataStoreTests.Delete_Item(itemSubType:
Notes)
PassXYZ.Vault.Tests.DataStoreTests.Delete_Item(itemSubType:
PxEntry)
PassXYZ.Vault.Tests.DataStoreTests.Create_Item(itemSubType:
Entry)
PassXYZ.Vault.Tests.DataStoreTests.Create_Item(itemSubType:
Group)
PassXYZ.Vault.Tests.DataStoreTests.Create_Item(itemSubType:
Notes)
PassXYZ.Vault.Tests.DataStoreTests.Create_Item(itemSubType:
PxEntry)
我们可以看到,由 [InlineData]
属性定义的每个参数都显示为单独的测试用例。它们在运行时都是隔离的测试用例。
在列出所有测试后,我们可以使用 dotnet
命令选择性地执行它们。
如果我们想运行 DataStoreTests
类中的所有测试,我们可以使用此命令:
dotnet test --filter DataStoreTests
如果我们只想运行 Add_Item
测试,我们可以使用此命令:
dotnet test --filter DataStoreTests.Add_Item
如我们从调试输出中可以看到的,尽管我们在构造函数中创建了一个 IDataStore
的实例,但每个测试都会重新创建该实例。在测试类构造函数中创建的实例不会在测试之间共享。尽管效果仍然相同,但代码看起来更简洁。
然而,在某些场景下,我们可能希望在不同测试之间共享实例。为了实现这一点,我们可以利用类固定器。在下一节中,我们将检查这些特定的情况。
使用类固定器共享
当在所有测试案例中使用工具时,共享设置可能比每次都创建相同的设置更有效。让我们用一个日志函数作为例子来说明这种方法。
为了生成测试报告,我们的目标是创建一个测试日志,用于监控单元测试的执行。Serilog
库是用于此目的的库。Serilog
允许我们将消息记录到不同的通道。为了使用 Serilog
,我们首先必须设置它,然后在所有测试执行完毕后清理它。在这种情况下,我们更倾向于在所有测试之间共享单个 Serilog
实例,而不是为每个测试创建一个。这种设置使我们能够为所有测试生成一个综合的日志文件,而不是为每个单独的测试生成多个日志文件。
要集成 Serilog
,我们需要将 Serilog
包添加到项目中。这可以通过在项目的 PassXYZ.Vault.Tests
文件夹中执行以下 dotnet
命令来完成:
dotnet add package Serilog
dotnet add package Serilog.Sinks.File
在将 Serilog
库添加到项目中后,我们现在可以创建一个用于演示目的的类固定器,SerilogFixture
:
public class SerilogFixture : IDisposable { //(1)
public ILogger Logger { get; private set; }
public SerilogFixture() {
Logger = new LoggerConfiguration() //(2)
.MinimumLevel.Debug()
.WriteTo.File(@"logs\xunit_log.txt")
.CreateLogger();
Logger.Debug("SerilogFixture: initialized");
}
public void Dispose() {
Logger.Debug("SerilogFixture: closed");
Log.CloseAndFlush(); //(3)
}
}
public class IDataStoreTests : IClassFixture<SerilogFixture> { //(4)
IDataStore<Item> datastore;
SerilogFixture serilogFixture;
public DataStoreTests(SerilogFixture fixture) { //(5)
serilogFixture = fixture; //(6)
datastore = new MockDataStore();
serilogFixture.Logger.Debug("DataStoreTests: Created");
}
[Fact]
public async void Add_Item() ...
...
}
如果我们想使用类固定器,我们可以按照以下步骤创建它们:
-
我们可以创建一个新的类作为固定器类,并将设置代码添加到构造函数中。在这里,我们创建了一个固定器类,
SerilogFixture
(1),并在构造函数中初始化了ILogger
接口,(2)。 -
由于我们需要在测试用例执行后清理设置,我们需要为固定器类实现
IDisposable
接口,并将清理代码放在Dispose
方法中。我们在SerilogFixture
中实现了IDisposable
,并在Dispose
方法中调用了Serilog
函数,Log.CloseAndFlush
(3)。 -
要使用固定器,测试用例需要实现
IClassFixture<T>
接口。我们在DataStoreTests
测试类中实现了这一点,(4)。 -
要访问固定件实例,我们可以将其作为构造函数参数添加,它将自动提供。在
DataStoreTests
的构造函数 (5) 中,我们将参数分配给私有成员变量serilogFixture
(6)。在测试用例中,我们可以使用这个变量访问Serilog
。
为了验证此设置,我们将所有调试输出替换为 Serilog
的 Debug
。在执行 DataStoreTests
中的测试后,我们可以在 xunit_log.txt
日志文件中看到这里的日志消息:
2022-08-28 10:25:39.273 +08:00 [DBG] SerilogFixture: initialized
2022-08-28 10:25:39.332 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.350 +08:00 [DBG] Delete_Item: Entry01
2022-08-28 10:25:39.355 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.355 +08:00 [DBG] Delete_Item: Group01
2022-08-28 10:25:39.356 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.357 +08:00 [DBG] Delete_Item: PxEntry01
2022-08-28 10:25:39.358 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.358 +08:00 [DBG] Delete_Item: Notes01
2022-08-28 10:25:39.359 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.359 +08:00 [DBG] Create_Item: PxEntry
2022-08-28 10:25:39.360 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.360 +08:00 [DBG] Create_Item: Group
2022-08-28 10:25:39.361 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.361 +08:00 [DBG] Create_Item: Entry
2022-08-28 10:25:39.362 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.362 +08:00 [DBG] Create_Item: Notes
2022-08-28 10:25:39.362 +08:00 [DBG] DataStoreTests: Created
2022-08-28 10:25:39.364 +08:00 [DBG] Add_Item: Done
2022-08-28 10:25:39.367 +08:00 [DBG] SerilogFixture: closed
如预期,SerilogFixture
类仅初始化一次,使其实例可以在 DataStoreTests
中的所有测试中使用。这与为每个单独的测试初始化的 IDataStore
接口形成对比。
使用集合固定件的共享
如前节所示,利用类固定件允许我们在单个测试类中共享测试设置上下文。然而,可能存在需要跨多个测试类共享测试设置的情况。在这种情况下,我们可以使用集合固定件来实现这一点。
在 Serilog
的情况下,我们可以在多个测试类之间使用它,使我们能够在一个日志文件中查看所有日志消息。为了为所有测试类实现统一的 Serilog
设置,我们可以在项目中实现集合固定件。通过使用集合固定件,我们可以在 PassXYZ.Vault.Tests
项目中创建两个新类,SerilogFixture
和 SerilogCollection
,如 Listing 11.1 所示:
namespace PassXYZ.Vault.Tests;
public class SerilogFixture : IDisposable {
public ILogger Logger { get; private set; }
public SerilogFixture() {
Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(@"logs\xunit_log.txt")
.CreateLogger();
Logger.Debug("SerilogFixture: initialized");
}
public void Dispose() {
Logger.Debug("SerilogFixture: closed");
Log.CloseAndFlush();
}
}
[CollectionDefinition("Serilog collection")] //(1)
public class SerilogCollection:ICollectionFixture<SerilogFixture>
{
} //(2)
列表 11.1: SerilogFixture.cs
(epa.ms/SerilogFixture11-1
)
要实现集合固定件,我们可以遵循以下步骤:
-
创建一个新的类文件,
SerilogFixture.cs
,其中包含SerilogFixture
和SerilogCollection
类。 -
使用
[CollectionDefinition]
属性 (1) 装饰集合定义类SerilogCollection
并为其分配一个唯一名称,以帮助识别测试集合。 -
确保集合定义类
SerilogCollection
实现了ICollectionFixture<T>
接口 (2)。
要使用集合固定件,我们可以对我们的测试类进行以下修改:
-
我们可以将
[Collection]
属性附加到所有将被包含在集合中的测试类。我们在属性中将测试集合定义命名为Serilog collection
。在我们的例子中,如 Listing 11.2 所示,我们向DataStoreTests
类添加了[Collection("Serilog collection")]
属性 (1)。 -
要访问固定件实例,我们可以遵循与上一节中类固定件相同的做法,将其包括为构造函数参数。然后它将自动提供。在
DataStoreTests
的构造函数中,我们将fixture
参数分配给serilogFixture
变量 (2)。 -
为了节省空间,列表 11.2 中没有提供完整的代码。缺失的部分用“...”符号表示。
Add_Item
测试用例是用Fact
构建的,用于检查条目的创建。Create_Item
和Delete_Item
测试用例都使用了Theory
,使我们能够测试各种条目。
namespace PassXYZ.Vault.Tests;
[Collection("Serilog collection")] //(1)
public class DataStoreTests {
IDataStore<Item> datastore;
SerilogFixture serilogFixture;
public DataStoreTests(SerilogFixture fixture) {
datastore = new MockDataStore();
serilogFixture = fixture; //(2)
serilogFixture.Logger.Debug("DataStoreTests
initialized");
}
[Fact]
public async void Add_Item() {
// Arrange
ItemSubType itemSubType = ItemSubType.Entry;
// Act
var newItem = datastore.CreateNewItem(itemSubType);
newItem.Name = $"{itemSubType.ToString()}01";
await datastore.AddItemAsync(newItem);
var item = datastore.GetItem(newItem.Id);
// Assert
Assert.Equal(newItem.Id, item.Id);
serilogFixture.Logger.Debug("Add_Item done");
}
[Theory]
[InlineData(ItemSubType.Entry)]
[InlineData(ItemSubType.Group)]
[InlineData(ItemSubType.Notes)]
[InlineData(ItemSubType.PxEntry)]
public async void Delete_Item(ItemSubType itemSubType)...
[Theory]
[InlineData(ItemSubType.Entry)]
[InlineData(ItemSubType.Group)]
[InlineData(ItemSubType.Notes)]
[InlineData(ItemSubType.PxEntry)]
public void Create_Item(ItemSubType itemSubType) ...
}
列表 11.2: DataStoreTests.cs
(epa.ms/DataStoreTests11-2
)
通过这些示例,我们展示了如何在模型层创建单元测试。我们迄今为止获得的知识也可以应用于其他 .NET 应用程序的单元测试。
在介绍了模型层单元测试之后,我们将继续探讨本章下一部分使用 bUnit 库进行 Razor 组件单元测试。
模拟 .NET MAUI 组件
在为 .NET MAUI 开发单元测试时,我们将展示基于 XAML 和基于 Blazor 的应用程序的测试用例开发。在这两种情况下,我们都会在设计时采用 MVVM 模式。模型层的单元测试用例对于两者都是相同的;然而,对视图和视图模型的测试存在显著差异。在基于 XAML 的应用程序中开发视图和视图模型的单元测试可能相当复杂。为了测试视图模型,需要解决 XAML 组件的依赖关系。例如,在我们的应用程序的 XAML 版本中,我们需要在视图模型中调用 Shell
导航方法,如下面的代码所示:
await Shell.Current.GoToAsync(
$"{nameof(ItemsPage)}?{nameof(ItemsViewModel.ItemId)}={item
.Id}");
为了解决开源项目 Xamarin.Forms
中的依赖关系,有一个名为 Xamarin.Forms.Mocks
的项目可以帮助模拟 Xamarin.Forms
组件。对于 .NET MAUI XAML 应用程序,我们需要一个类似解决方案来开发视图模型的单元测试用例,但目前似乎还没有等效的解决方案。此外,还有一个针对 Android 和 iOS 的本地用户界面测试框架 Xamarin.UITest
。然而,它目前还不兼容 .NET MAUI。另外,由于 Xamarin.UITest
不是一个跨平台解决方案,因此本书中不会讨论它。
为了部分解决本书中关于视图模型测试的依赖问题,我们可以直接引用 .NET MAUI 源代码。位于 github.com/dotnet/maui
,该源代码包括包含模拟 .NET MAUI 组件的原始单元测试代码。
我已经将 .NET MAUI 源代码的一些元素纳入到单元测试项目 PassXYZ.Vault.Tests
中。为了解决视图模型中的 Shell
导航问题,包含了来自 PassXYZ.Vault.Tests/maui/ShellTestBase.cs
的以下代码:
-
在
NewItemViewModel
测试中,我们可以使用以下命名空间将ShellTestBase
类纳入:using Microsoft.Maui.Controls.Core.UnitTests;
-
实现上述命名空间后,可以使用以下代码创建模拟的
Shell
:TestShell shell = new TestShell();
-
一旦我们有一个模拟的
Shell
实例,我们就可以在以下测试用例中使用这个模拟的Shell
实例:[Fact] public async void CancelNewItem() { NewItemViewModel vm = new(dataStore, logger); await shell.GoToAsync("//About/Maui/"); vm.CancelCommand.Execute(null); Assert.Equal("//About/Maui/content", Shell.Current.CurrentState.Location.ToString()); }
对于 Blazor 混合应用,我们没有上述问题。我们可以访问一个可靠的测试库 bUnit,它可以用于测试 Razor 组件。这使我们能够为 Blazor 应用程序的视图、视图模型和模型层开发单元测试用例。
使用 bUnit 进行 Razor 组件测试
在 .NET MAUI 开发中,我们无法为所有基于 XAML 的 UI 组件的所有视图和视图模型层创建单元测试用例;然而,使用 Blazor 是可以做到的。bUnit 是一个出色的测试库,可以用于为 Razor 组件开发单元测试。bUnit 库简化了使用 xUnit、NUnit 或 MSTest 为 Razor 组件创建单元测试用例的过程。在本章的剩余部分,我们将结合使用 xUnit 和 bUnit。使用 bUnit 的单元测试用例结构与我们在上一节中讨论的 xUnit 测试用例类似。
在本章的剩余部分,我们将专注于测试以下 Razor 组件,这些组件是在本书的第二部分创建的:
-
PassXYZ.BlazorUI
项目的 Razor 组件 -
PassXYZ.Vault
项目的 Razor 组件
要使用 bUnit 测试 Razor 组件,需要修改 PassXYZ.Vault.Tests
的项目配置。
更改 bUnit 的项目配置
为了建立测试环境,我们必须添加 bUnit 和 Moq 包,以及更新 SDK 类型。以下修改可以应用于 xUnit PassXYZ.Vault.Tests
测试项目:
-
将 bUnit 添加到项目中。
要将 bUnit 库集成到项目中,首先导航到项目文件夹,然后在控制台中执行以下命令:
cd PassXYZ.Vault.Tests dotnet add package bunit
此外,我们还需要包含 Moq 包,这是一个在测试设置过程中将使用的模拟库。
dotnet add package Moq
-
更改项目配置。
要测试 Razor 组件,我们还需要将项目的 SDK 更改为
Microsoft.NET.Sdk.Razor
。在
PassXYZ.Vault.Tests.csproj
项目文件中,我们需要替换以下行:<Project Sdk="Microsoft.NET.Sdk">
with
<Project Sdk="Microsoft.NET.Sdk.Razor">
一旦项目配置就绪,我们可以使用 bUnit 创建一个简单的单元测试用例来测试我们的 Razor 组件。
创建 bUnit 测试用例
在我们的 PassXYZ.Vault 应用中,有两种类型的 Razor 组件可以进行测试。共享的 Razor 组件位于 PassXYZ.BlazorUI
项目中,作为通用 Razor 组件,可以在不同的项目中使用。第二组 Razor 组件可以在 PassXYZ.Vault
项目的 Pages
文件夹中找到。这些组件特定于 PassXYZ.Vault
应用,并使用来自 PassXYZ.BlazorUI
项目的共享组件。
要测试 PassXYZ.BlazorUI
项目中的 Razor 组件,我们可以单独检查每个组件。这些测试用例是单元测试用例。位于 PassXYZ.Vault
项目 Pages
文件夹中的 Razor 组件作为 UI 页面。由于这些页面使用了来自其他包的 UI 组件,它们具有更多的依赖项。因此,这些测试用例可以被视为集成测试用例。
首先,让我们在 PassXYZ.BlazorUI
项目中为 ModalDialog
Razor 组件创建一个测试用例。要测试 ModalDialog
,我们可以生成一个名为 ModalDialogTests
的 xUnit 测试类,如 列表 11.3 所示:
namespace PassXYZ.Vault.Tests {
[Collection("Serilog collection")]
public class ModalDialogTests : TestContext { //(1)
SerilogFixture serilogFixture;
public ModalDialogTests(SerilogFixture serilogFixture) {
this.serilogFixture = serilogFixture;
}
[Fact]
public void ModalDialogInitTest() {
string title = "ModalDialog Test"; //(2)
var cut = RenderComponent<ModalDialog>( //(3)
parameters => parameters.Add(p => p.Title, title) //(4)
.Add(p => p.CloseButtonText, "Close")
.Add(p => p.SaveButtonText, "Save"));
cut.Find("h5").TextContent.MarkupMatches(title); //(5)
serilogFixture.Logger.Debug("ModalDialogInitTest:
done");
}
...
}
}
列表 11.3:ModalDialogTests.cs
(epa.ms/ModalDialogTests11-3
)
如在 ModalDialogTests
单元测试类中所示,它与为模型层创建的单元测试类有很强的相似性。我们重用了之前建立的集合固定装置,并在构造函数中初始化它。在 ModalDialogInitTest
测试用例中,我们继续使用 Arrange
、Act
和 Assert
模式来实现测试用例。
所有 bUnit 测试类都继承自 TestContext
(1)。在 Arrange
阶段,我们使用预定义的字符串初始化一个局部 title
变量,(2)。在 Act
阶段,我们调用一个泛型方法 RenderComponent<T>
(3),并使用 ModalDialog
类型作为类型参数。我们将 title
变量,(4),作为组件参数传递。RenderComponent<T>
的结果存储在 cut
变量中。在 Assert
阶段,我们确认渲染后的标题文本与传递的参数一致,利用 bUnit 方法 Find
(5)。bUnit 方法 Find
可以用来查找任何 HTML 标签。在 ModalDialog
中,标题被渲染为 <h5>
HTML 标签。
在 ModalDialogInitTest
测试用例中,我们观察了 bUnit 测试的结构。对于 bUnit 测试,我们首先渲染被测试的组件。渲染结果存储在 cut
变量中,(3),这是一个 IRenderedComponent
接口的实例。为了验证结果,我们可以参考 IRenderedComponent
实例的属性或调用其方法。
当 Razor 组件在 TestContext
中渲染时,它们表现出与其他任何 Razor 组件相同的生命周期。我们可以向被测试的组件传递参数,并且它们可以生成类似于在浏览器中的行为的输出。
在前面的示例中,当渲染 ModalDialog
组件时,我们可以使用 Add
方法将其参数传递给它,该方法属于 ComponentParameterCollectionBuilder<TComponent>
类型的参数构建器。
使用 C# 代码渲染简单组件可能不会引起问题。然而,当向组件传递多个参数时,使用 C# 代码可能不太方便。通过使用 bUnit,我们可以在 Razor 文件中开发测试用例,这显著提高了单元测试开发的经验。
在 Razor 文件中创建测试用例
要在 Razor 标记文件中直接创建测试用例,我们可以使用 Razor 标记声明组件,类似于我们在 Razor 页面中使用它们的方式。这种方法消除了在 C#代码中调用 Razor 组件或使用函数调用传递参数的需要。对于 Razor 页面,我们可以使用Razor 模板来渲染 Razor 组件。
我们可以通过为更复杂的EditorDialog
组件开发测试用例来展示在 Razor 标记文件中创建测试的过程。我们之前在第十章,实现 Razor 组件中创建了EditorDialog
组件。在列表 11.4中,我们将检查此组件的单元测试:
@inherits TestContext //(1)
<h3>EditorDialogTests</h3>
@code {
bool _isOnCloseClicked = false;
string _key = string.Empty;
string _value = string.Empty;
string updated_key = "key updated";
string updated_value = "value udpated";
void OnSaveClicked(string key, string value) {
_key = key; _value = value;
}
void OnCloseHandler() {
_isOnCloseClicked = true;
}
[Fact]
public void EditorDialog_Init_WithoutArgument() ...
[Fact]
public void Edit_OnClose_Clicked() {
var cut = Render(@<EditorDialog Key="@_key"
Value="@_value"
OnSave=@OnSaveClicked
OnClose=@OnCloseHandler>
</EditorDialog>); //(2)
cut.Find("button[class='btn btn-secondary']").Click(); //(3)
Assert.True(_isOnCloseClicked); //(4)
}
[Fact]
public void Edit_With_KeyEditingEnabled() { //(5)
var cut = Render(@<EditorDialog Key="@_key"
Value="@_value"
IsKeyEditingEnable="true"
OnSave=@OnSaveClicked>
</EditorDialog>);
cut.Find("input").Change(updated_key);
cut.Find("textarea").Change(updated_value);
cut.Find("button[type=submit]").Click();
Assert.Equal(_key, updated_key);
Assert.Equal(_value, updated_value);
}
[Fact]
public void Edit_With_KeyEditingDisabled() ...
}
列表 11.4:EditorDialogTests.razor
(epa.ms/EditorDialogTests11-4
)
我们可以在PassXYZ.Vault.Tests
项目中开发一个新的 Razor 组件,称为EditorDialogTests
。作为 bUnit 测试类,它是TestContext
的子类(1)。在这个类中,我们通过利用Razor 模板在代码块中生成测试用例。
让我们首先检查Edit_OnClose_Clicked
测试用例。在这种情况下,我们首先渲染EditorDialog
组件,然后测试关闭按钮。
要渲染EditorDialog
组件,我们调用TestContext
的Render
方法(2)。与上一个示例相比,在这种情况下,我们可以直接渲染 Razor 标记而不是调用 C#函数。这里使用的 Razor 标记被称为Razor 模板。有关 Razor 模板的更多信息,请参阅以下 Microsoft 文档:learn.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-5.0#razor-templates-1
。
Razor 模板
可以按以下格式定义:
@<{HTML tag}>…</{HTML tag}>
它由一个@
符号和一对开闭 HTML 标签组成。Razor 模板可以在 Razor 文件的代码块中使用。它们不能在 C#或 C#代码隐藏文件中使用。
RenderFragment or RenderFragment<TValue>. In *Listing 11.4*, we use Razor templates to pass parameters to the EditorDialog, as demonstrated in the subsequent code:
var cut = Render(@<EditorDialog Key="@_key"
Value="@_value" OnSave=@OnSaveClicked
OnClose=@OnCloseHandler>
</EditorDialog>);
EditorDialog
渲染后,我们可以找到关闭按钮并模拟点击操作(3):
cut.Find("button[class='btn btn-secondary']").Click();
在OnCloseHandler
事件处理程序中,_isOnCloseClicked
变量(4)被设置为true
,这样我们就可以断言结果。
在Edit_With_KeyEditingEnabled
测试用例(5)中,组件渲染后,我们可以模拟用户交互来设置组件中的键和值字段。之后,我们可以模拟点击保存按钮,如下所示:
cut.Find("input").Change(updated_key);
cut.Find("textarea").Change(updated_value);
cut.Find("button[type=submit]").Click();
点击按钮后,事件处理程序被触发。在OnSaveClicked
事件处理程序中,我们接收修改后的键和值,使我们能够断言结果。
Assert.Equal(_key, updated_key);
Assert.Equal(_value, updated_value);
如这两个测试案例所示,在 Razor 文件中创建测试时,设计 bUnit 测试变得更加简单。通过利用 Razor 模板,我们可以渲染组件,并模拟各种用户交互,使我们能够交互式地测试组件。
Razor 模板作为结合 Razor 标记和 C#代码的优秀工具,使我们能够从两种方法的优势中受益。然而,在使用 Razor 模板时存在某些限制。在下一节中,我们将探讨如何克服这些限制。
使用RenderFragment
委托
尽管 Razor 模板可以帮助简化测试设置,但它们有其局限性,尤其是在复杂的测试案例场景中。对于复杂的测试案例,Razor 模板可能会变得相当长。如果我们打算在其他测试案例中重用相同的 Razor 模板,我们就需要复制它们,这可能会导致大量的代码重复。这是使用 Razor 模板的主要缺点之一。
在这种情况下,我们可以使用一个RenderFragment
委托。正如其名称所示,它是RenderFragment
或RenderFragment<TValue>
的委托类型。Razor 模板的数据类型是RenderFragment
或RenderFragment<TValue>
。RenderFragment
委托是 Razor 模板的委托类型。
你可以在以下 Microsoft 文档中找到有关RenderFragment
委托的更多信息:learn.microsoft.com/en-us/aspnet/core/blazor/performance?view=aspnetcore-3.1#define-reusable-renderfragments-in-code-2
。
为了演示如何使用RenderFragment
委托,让我们为EditorDialog
组件设置一个更复杂的测试。EditorDialog
可以用来编辑Item
或Field
。我们可以使用一个项目编辑案例来展示如何使用RenderFragment
委托。
我们有选择在PassXYZ.Vault.Tests
项目中创建一个新的测试类,名为ItemEditTests
。为了区分 Razor 标记和 C#代码,我们可以将ItemEditTests
测试类分为一个 Razor 文件(ItemEditTests.razor
)和一个 C#代码后文件(ItemEditTests.razor.cs
)。用于测试的标记可以在 Razor 文件中声明,如列表 11.5所示:
@inherits TestContext
@namespace PassXYZ.Vault.Tests
<h3>ItemEditTests</h3>
@code {
private RenderFragment _editorDialog => __builder =>
{
<CascadingValue Value="@_dialogId" Name="Id">
<EditorDialog IsKeyEditingEnable=@isNewItem
OnSave=@OnSaveClicked Key=@testItem.Name
Value=@testItem.Notes>
@if (isNewItem) {
<select id="itemType" @bind="testItem.ItemType"
class="form-select" aria-label="Group">
<option selected value="Group">Group</option>
<option value="Entry">Entry</option>
<option value="PxEntry">PxEntry</option>
<option value="Notes">Notes</option>
</select>
}
</EditorDialog>
</CascadingValue>
};
}
列表 11.5: ItemEditTests.razor
(epa.ms/ItemEditTests11-5
)
我们在ItemEditTests.razor
的@code
块中定义了一个RenderFragment
委托,名为_editorDialog
。RenderFragment
委托必须接受一个名为__builder
的RenderTreeBuilder
类型的参数。在标记代码中,我们可以访问测试类中定义的变量。
现在,让我们看看列表 11.6中 C#代码后文件中_editorDialog
的使用:
namespace PassXYZ.Vault.Tests;
[Collection("Serilog collection")]
public partial class ItemEditTests : TestContext {
readonly SerilogFixture serilogFixture;
bool isNewItem { get; set; } = false;
NewItem testItem { get; set; }
string _dialogId = "editItem";
string updated_key = "Updated item";
string updated_value = "This item is updated.";
public ItemEditTests(SerilogFixture fixture) {
testItem = new() {
Name = "New item",
Notes = "This is a new item."
};
serilogFixture = fixture;
}
void OnSaveClicked(string key, string value) {
testItem.Name = key; testItem.Notes = value;
}
[Fact]
public void Edit_New_Item() {
isNewItem = true;
var cut = Render(_editorDialog); //(1)
cut.Find("#itemType").Change("Entry");
cut.Find("input").Change(updated_key);
cut.Find("textarea").Change(updated_value);
cut.Find("button[type=submit]").Click();
Assert.Equal(updated_key, testItem.Name);
Assert.Equal(updated_value, testItem.Notes);
}
[Fact]
public void Edit_Existing_Item() {
isNewItem = false; //(3)
var cut = Render(_editorDialog); //(1)
var ex = Assert.Throws<ElementNotFoundException>(() =>
cut.Find("#itemType").Change("Entry")); //(2)
Assert.Equal("No elements were found that matches the
selector '#itemType'", ex.Message); //(4)
cut.Find("textarea").Change(updated_value);
cut.Find("button[type=submit]").Click();
Assert.Equal(updated_value, testItem.Notes);
}
}
列表 11.6: ItemEditTests.razor.cs
(epa.ms/ItemEditTests11-6
)
由于_editorDialog
定义了Item
编辑,我们可以为它开发多个测试用例。我们可以看到,为了多个测试用例,例如Edit_New_Item
和Edit_Existing_Item
,我们渲染了_editorDialog
(1)。通过使用RenderFragment
委托,我们的测试代码看起来更加优雅和简洁。如果不采取这种方法,我们可能需要在多个位置复制大量的标记代码。直接使用 C#代码可能会导致更多的代码重复。
在这两个测试用例中,我们通过设置值然后点击Save
按钮来测试EditorDialog
,采用了一个类似的过程。在标记代码中,我们定义了一个<select>
标签。我们可以在测试代码中更改<select>
标签的选项,(2)。这个<select>
标签是根据isNewItem
变量的值有条件地渲染的。
在Edit_Existing_Item
测试中,我们还可以检查当isNewItem
变量(3)设置为false
时的负面情况。在这种情况下,由于<select>
标签没有被渲染,会抛出一个异常。我们可以观察到 bUnit 也可以通过验证异常的内容(4)来利用测试负面情况。
在前面的示例中,我们为PassXYZ.BlazorUI
项目中的共享组件开发了 bUnit 测试。由于这些共享组件作为高级 UI 的可重用构建块,其中许多声明了多个组件参数。利用RenderFragment
委托或Razor 模板可以帮助简化测试设置过程。
在检查PassXYZ.Vault
项目的Pages
文件夹中的 Razor 页面时,我们发现Items
、ItemDetail
和Login
也充当 Razor 组件。然而,它们并不是为重用而设计的。这些 Razor 页面具有定义的路由模板,并且缺乏广泛的组件参数。这些 Razor 页面中存在的组件参数用于路由目的。在为这些 Razor 页面设计测试用例时,建议在 C#类中而不是在 Razor 文件中实现测试。
测试 Razor 页面
在开发测试 Razor 页面期间,我们将熟悉一些非常有用的 bUnit 功能。由于我们无法审查我们应用中所有 Razor 页面的测试,我们将使用ItemDetail
作为示例。ItemDetail
是一个设计用来显示密码条目内容的 Razor 页面,并且它有一个定义的路由。
@page "/entry/{SelectedItemId}"
当我们想要显示ItemDetail
页面时,我们需要传递一个Item
实例的Id
信息给它,并且这个实例不能是一个组。ItemDetail
页面的初始化是在OnParametersSet()
生命周期方法中完成的,正如我们在这里可以看到的:
protected override void OnParametersSet() {
base.OnParametersSet();
if (SelectedItemId != null) {
selectedItem = DataStore.GetItem(SelectedItemId, true);
if (selectedItem == null) {
throw new InvalidOperationException( //(2)
"ItemDetail: entry cannot be found with SelectedItemId");
}
else {
if (selectedItem.IsGroup) {
throw new InvalidOperationException( //(3)
"ItemDetail: SelectedItemId should not be group here.");
}
else { //(4)
fields.Clear();
List<Field> tmpFields = selectedItem.GetFields();
foreach (Field field in tmpFields) {
fields.Add(field);
}
notes = selectedItem.GetNotesInHtml();
}
}
}
else {
throw new InvalidOperationException( //(1)
"ItemDetail: SelectedItemId is null");
}
}
我们将开发一个ItemDetailTests
测试类来覆盖OnParametersSet()
中的所有执行路径。为了覆盖所有执行路径,我们可以找到以下测试用例:
-
测试用例 1
:初始化没有选择项目Id
的ItemDetail
实例。在这种情况下,我们将得到一个InvalidOperationException
异常,(1)。 -
测试用例 2
:使用错误的项目Id
初始化ItemDetail
实例。在这种情况下,我们将得到一个InvalidOperationException
异常,(2)。 -
测试用例 3
:使用有效的项目Id
初始化ItemDetail
实例,但项目类型为分组。在这种情况下,我们将得到一个InvalidOperationException
异常,(3)。 -
测试用例 4
:使用有效的项目Id
和项目类型为条目初始化ItemDetail
实例,(4)。
我们可以在ItemDetailTests
bUnit 测试类中实现这些测试用例,如下所示在列表 11.7中:
namespace PassXYZ.Vault.Tests;
[Collection("Serilog collection")]
public class ItemDetailTests : TestContext {
SerilogFixture serilogFixture;
Mock<IDataStore<Item>> dataStore;
public ItemDetailTests(SerilogFixture fixture) {
serilogFixture = fixture;
dataStore = new Mock<IDataStore<Item>>(); //(1)
Services.AddSingleton<IDataStore<Item>>
(dataStore.Object); //(2)
}
[Fact]
public void **Init_Empty_ItemDetail**() { //(3)
var ex = Assert.Throws<InvalidOperationException>(
() => RenderComponent<ItemDetail>());
Assert.Equal(
"ItemDetail: SelectedItemId is null", ex.Message);
}
[Fact]
public void **Load_ItemDetail_WithWrongId**() {
var ex = Assert.Throws<InvalidOperationException>(() =>
RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, "Wrong Id")));
Assert.Equal("ItemDetail: entry cannot be found with
SelectedItemId", ex.Message);
}
[Fact]
public void **Load_ItemDetail_WithGroup**() {
Item testGroup = new PwGroup(true, true) {
Name = "Default Group",
Notes = "This is a group in ItemDetailTests."
};
dataStore.Setup(x => x.GetItem(It.IsAny<string>(),
It.IsAny<bool>())).Returns(testGroup);
var ex = Assert.Throws<InvalidOperationException>(() =>
RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, testGroup.Id)));
Assert.Equal("ItemDetail: SelectedItemId should not be
group here.", ex.Message);
}
[Fact]
public void **Load_ItemDetail_WithEmptyFieldList**() {
Item testEntry = new PwEntry(true, true) {
Name = "Default Entry",
Notes = "This is an entry with empty field list."
};
dataStore.Setup(x => x.GetItem(It.IsAny<string>(),
It.IsAny<bool>())).Returns(testEntry);
var cut = RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, testEntry.Id));
cut.Find("article").MarkupMatches(
$"<article><p>{testEntry.Notes}</p></article>");
}
}
列表 11.7:ItemDetailTests.cs
(epa.ms/ItemDetailTests11-7
)
第一个测试用例在Init_Empty_ItemDetail
(3)中实现。在测试设置期间,我们尝试直接渲染ItemDetail
组件而不提供选择的项目Id
。我们预计会遇到InvalidOperationException
异常。
在执行测试用例之前,我们必须首先解决IDataStore
依赖项。ItemDetail
依赖于IDataStore<Item>
接口,我们可以通过依赖注入来解决这个问题。在我们的应用程序中,这个依赖项在MauiProgram.cs
文件中注册。
使用 bUnit,可以通过TestContext
支持依赖注入。我们可以使用AddSingleton
(2)来注册依赖项。为了隔离测试,我们使用Moq
模拟框架 (1)来替换IDataStore
的实际实现,这样我们可以减少测试设置的复杂性。
使用Moq
,我们只需要在我们的测试设置中模拟所需的方法或属性。这有助于将我们的测试与其依赖项隔离。要使用Moq
框架,我们可以使用所需的接口或类作为类型参数创建一个Moq
对象。稍后,当我们使用它时,我们可以定义目标接口或类的行为。在构造函数中,我们创建一个Mock
对象,并使用dataStore.Object
注册IDataStore<Item>
接口:
dataStore = new Mock<IDataStore<Item>>();
Services.AddSingleton<IDataStore<Item>>(
dataStore.Object);
在我们在构造函数中注册IDataStore
之后,我们可以再次执行第一个测试用例。这次,我们可以获取异常并验证消息是否如我们所期望:
[Fact]
public void Init_Empty_ItemDetail() {
var ex = Assert.Throws<InvalidOperationException>(
() => RenderComponent<ItemDetail>());
Assert.Equal("ItemDetail: SelectedItemId is null",
ex.Message);
}
接下来,让我们看看第二个测试用例。在第二个测试用例中,我们向ItemDetail
传递一个无效的Id
并尝试渲染它:
[Fact]
public void Load_ItemDetail_WithWrongId() {
var ex = Assert.Throws<InvalidOperationException>(() =>
RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, "Wrong Id")));
Assert.Equal("ItemDetail: entry cannot be found with
SelectedItemId", ex.Message);
}
在这种情况下,我们也得到了预期的异常,我们可以使用Assert.Equal
来验证其内容。
在第三个测试用例中,我们向ItemDetail
提供有效的Id
,但项目是分组类型。这是一个在集成测试或用户验收测试期间可能难以重现的场景。然而,在单元测试中,验证这一点相对简单,如下所示:
[Fact]
public void Load_ItemDetail_WithGroup() {
Item testGroup = new PwGroup(true, true) {
Name = "Default Group",
Notes = "This is a group in ItemDetailTests."
};
dataStore.Setup(x => x.GetItem(It.IsAny<string>(),
It.IsAny<bool>())).Returns(testGroup);
var ex = Assert.Throws<InvalidOperationException>(() =>
RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, testGroup.Id)));
Assert.Equal("ItemDetail: SelectedItemId should not be
group here.", ex.Message);
}
为了测试这一点,我们需要创建一个组并将其分配给 testGroup
变量。在这个测试用例中,我们必须调用 IDataStore
的 GetItem
方法。由于我们在设置中模拟了 IDataStore
,因此我们在这里也需要模拟 GetItem
方法。当调用时,Moq
方法返回 testGroup
。一旦测试设置准备就绪,我们就可以用 testGroup.Id
渲染 ItemDetail
。这个测试的预期结果是预期的异常。
在最后的测试用例中,我们将传递一个有效的项目 Id
,并且项目类型是一个条目:
[Fact]
public void Load_ItemDetail_WithEmptyFieldList() {
Item testEntry = new PwEntry(true, true) {
Name = "Default Entry",
Notes = "This is an entry with empty field list."
};
dataStore.Setup(x => x.GetItem(It.IsAny<string>(),
It.IsAny<bool>())).Returns(testEntry);
var cut = RenderComponent<ItemDetail>(parameters =>
parameters.Add(p => p.SelectedItemId, testEntry.Id));
cut.Find("article").MarkupMatches(
$"<article><p>{testEntry.Notes}</p></article>");
Debug.WriteLine($"{cut.Markup}");
}
这个测试用例与第三个测试用例类似,除了我们可以创建一个条目并将其分配给 testEntry
变量。在用 testEntry.Id
渲染 ItemDetail
之后,我们可以验证渲染的 <article>
HTML 标签是我们预期的。
到目前为止,我们已经探讨了使用 bUnit 测试 Razor 组件。很明显,我们可以使用 bUnit 达到令人印象深刻的测试覆盖率。这成为 Blazor UI 设计的优势之一。
我们现在已经解决了本章中我们打算探索的所有与 .NET MAUI 单元测试开发相关的话题。
摘要
在本章中,我们讨论了 .NET MAUI 应用程序的单元测试开发。尽管有多个测试框架可用,但我们选择了 xUnit 作为本章的框架。在 MVVM 模式下,模型层的单元测试与任何其他 .NET 应用程序一致。我们为 IDataStore
接口开发了测试用例来评估我们的模型层。对于视图和视图模型的单元测试,我们专注于使用 bUnit 测试库的 Blazor 混合应用程序。通过结合 xUnit 框架和 bUnit 库,我们可以为 Blazor 混合应用程序开发端到端的单元测试。使用 bUnit,我们解决了有关 Razor 模板、RenderFragment
委托、依赖注入和 Moq 框架等问题。
通过在本章中获得的单元测试理解,你现在应该能够开发自己的单元测试。有关 .NET 单元测试开发的更多信息,请参阅 进一步阅读 部分。
单元测试可以集成到 CI/CD 流程中。这种设置允许在开发过程中自动执行单元测试。我们将在下一章中更详细地探讨这个主题。
进一步阅读
-
《Microsoft Visual Studio 2005 Unleashed》 by 拉尔斯·鲍尔斯和迈克·斯奈尔:
www.amazon.com/Microsoft-Visual-Studio-2005-Unleashed/dp/0672328194
-
MSTest:
github.com/microsoft/testfx
-
《强化 Visual Studio 单元测试》 by 约翰·罗宾斯:
learn.microsoft.com/en-us/archive/msdn-magazine/2006/march/bugslayer-strengthening-visual-studio-unit-tests
-
《NUnit 快速参考》由比尔·汉密尔顿著:
www.amazon.com/NUnit-Pocket-Reference-Running-OReilly/dp/0596007396
-
SourceForge 上的 NUnit 发布:
sourceforge.net/projects/nunit/
-
为什么我们构建 xUnit 1.0:
xunit.net/docs/why-did-we-build-xunit-1.0
-
xUnit 文档:
xunit.net/
-
xUnit.NET 2.0 版本发布说明:
xunit.net/releases/2.0
在 Discord 上了解更多
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
第十二章:在应用商店中部署和发布
在完成开发工作后,我们的目标是将在各种应用商店发布我们的应用程序。由于.NET MAUI 是一个跨平台框架,我们可以为 Android、iOS、macOS 和 Windows 构建相同的源代码。虽然可以将我们的应用程序部署到 GitHub 等仓库,但大多数平台用户依赖于应用商店。因此,我们需要了解如何为不同的应用商店准备我们的应用程序。这正是本章的重点。在本章中,我们将讨论在发布前准备应用程序包所需的步骤。
本章我们将涵盖以下主题:
-
准备应用程序包以供发布
-
使用 GitHub Actions 自动化构建过程
技术要求
为了测试和调试本章的源代码,我们需要在 Windows 和 macOS 上安装.NET MAUI。关于环境设置的完整细节,请参阅第一章“开始使用.NET MAUI”中的“开发环境设置”部分。
我们将使用 Windows 构建 Windows 和 Android 包,而 iOS 和 macOS 包将使用 macOS 构建。
本章的源代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition/tree/main/2nd/chapter12
。
要查看本章的源代码,请使用以下命令:
$ git clone -b 2nd/chapter12 https://github.com/PacktPublishing/.NET-MAUI-Cross-Platform-Application-Development-Second-edition.git PassXYZ.Vault2
关于本书中源代码的更多信息,请参阅第二章“本书源代码的管理”部分。
准备应用程序包以供发布
在前面的章节中,进行.NET MAUI 开发只需要最少的平台特定知识。然而,当准备将我们的应用程序发布到各个应用商店时,我们无法避免处理平台特定的信息。在本章中,我们将讨论准备应用程序发布所需的必要步骤,然后我们将展示如何使用 GitHub Actions 自动化此过程。
有许多 CI/CD 工具可用于自动化构建和部署过程。对于.NET MAUI,GitHub Actions 和 Azure DevOps 都是可行的选项。您可以参考“进一步阅读”部分以获取有关其他替代方案的更多详细信息。然而,在本章中,我们的主要重点是使用 GitHub Actions 作为构建和部署工具。
准备发布
为了准备发布,我们将关注在提交包到应用商店之前需要完成的任务。有关实际发布过程的详细信息,请参阅每个应用商店提供的文档。
在准备发布的过程中,我们的目标是回答以下问题:
-
应用程序如何在应用商店中识别?
-
应用程序开发者是如何被识别的?
-
应用程序支持哪些设备?
在不同平台上构建和签名应用程序包需要特定平台的配置。在 .NET MAUI 中,特定平台的信息包含在 Visual Studio 项目文件和特定平台的配置文件中。在 Visual Studio 项目文件中使用条件编译来指定特定平台的信息。请参阅 表 12.1 了解每个平台所需更改的概述。
项目 | Android | Windows | iOS | macOS |
---|---|---|---|---|
包格式 | .apk/.aab |
.msix |
.ipa |
.app/.pkg |
签名 | Keystore |
签名证书Package.StoreAssociation.xml |
分发证书分发配置文件 | |
应用标识符 | package="com.passxyz.vault2" |
Publisher="CN=F81DB 4B-AF4A-473E-ADEA-A55EE7432C05" |
<key>CFBundleIdentifier</key>``<string>com.passxyz.vault2</string> |
|
应用版本 | android:versionCode="1" |
Version="0.3.8.0" |
<key>CFBundleVersion</key>``<string>2</string> |
|
配置文件 | AndroidManifest.xml |
Package.appxmanifest |
Info.plist |
表 12.1:构建配置
表 12.1 列出了如何使用在 Visual Studio 项目文件中定义的 ApplicationId
和 ApplicationVersion
变量来识别应用程序。对于每个平台,都使用特定的平台配置文件。
对于 Android 的分发,会生成一个 .apk
文件或 .aab
文件。.apk
文件是原始的 Android 包格式,用于在设备或模拟器上安装应用程序包,而 .aab
文件用于提交到 Google Play 商店。在提交之前,我们需要使用密钥库对包进行签名。ApplicationId
和 ApplicationVersion
映射到 Android 配置文件 AndroidManifest.xml
中的包 ID 和版本码。
对于 iOS 或 macOS 的分发,会生成一个 .ipa
文件用于 iOS,以及一个 .app
或 .pkg
文件用于 macOS。为了对 iOS 或 macOS 的包进行签名,我们需要一个分发证书和一个分发配置文件。ApplicationId
映射到包标识符,而 ApplicationVersion
映射到 Info.plist
中的包版本。
对于 Windows 的分发,使用 MSIX 包格式,并且使用 .msix
文件扩展名构建包。Windows 使用 全局唯一标识符(UUID)作为 ApplicationId
,它作为 ApplicationGuid
生成。
ApplicationVersion
映射到 Package.appxmanifest
中 Identity
标签的 Version
属性。
什么是 MSIX?
MSIX 是一种为所有 Windows 应用设计的现代 Windows 应用程序包格式。它旨在提供更可靠和安全的安装过程,以及增强的系统资源使用和简化更新。MSIX 允许开发者通过各种渠道打包和分发他们的应用程序,包括 Microsoft Store、网页或第三方平台。
有关 MSIX 的更多信息,请参阅 Microsoft 文档:learn.microsoft.com/en-us/windows/msix/overview
。
在以下章节中,我们将解释如何为每个平台生成发布包。我们将在 Windows 上构建 Windows 和 Android 包,而 iOS 和 macOS 包将在 macOS 上构建。我们将演示如何使用 Visual Studio 和命令行来完成此操作。
发布到 Microsoft Store
我们可以使用 Visual Studio 或 Windows 命令行来为 Microsoft Store 构建一个 .msix
包。
在 Visual Studio 中,我们需要将目标框架设置为 net8.0-windows10.0.19041.0
并将构建类型设置为 发布
。
然后,我们可以右键单击项目节点并选择“发布...”菜单项。
将出现一个带有 选择分发方法 选项的窗口 (图 12.1)。在此处,选择 使用新应用名称的 Microsoft Store 并单击 下一步 按钮:
图 12.1:选择分发方法
在进行下一步之前,如 图 12.2 所示,我们需要准备好一个应用名称。
要创建一个新的应用名称,请按照以下步骤操作:
-
访问以下 URL 的 Microsoft Store 开发者仪表板:
partner.microsoft.com/en-us/dashboard/
。 -
注册并创建一个新的应用名称。
一旦我们有了应用名称,我们就可以将其与我们的应用关联,如 图 12.2 所示:
图 12.2:将您的应用与 Microsoft Store 关联
单击 下一步 按钮后,Visual Studio 将在 Microsoft Store 中搜索应用名称。Microsoft Store 中创建的应用名称,如 图 12.3 所示,将被检索以继续发布过程:
图 12.3:选择应用名称
选择应用名称后,单击 下一步 按钮。将出现一个选择和配置包的屏幕,如 图 12.4 所示。在此处,我们可以选择我们的应用包并配置其他设置,然后再进行发布过程。
图 12.4:选择和配置包
要继续此过程,我们需要在此处创建一个发布配置文件。要创建 MSIX 发布配置文件,请按照以下步骤操作:
-
在 发布配置文件 下单击下拉菜单。将显示一个对话框,如 图 12.4 所示。
-
在对话框中单击 确定 按钮以创建一个新的 MSIX 发布配置文件。
一旦我们有了发布配置文件,点击创建按钮(现在将变为可用状态)以创建包。构建和包创建过程可能需要一些时间。完成后,我们将看到一个类似于图 12.5中所示的画面,表明 MSIX 包已准备好提交。
图 12.5:MSIX 包
新包的位置如图 12.5所示。有一个选项可以通过运行Windows 应用认证工具包来验证包。
在前面的步骤中,project
文件夹中已经创建了两个与应用程序发布相关的文件:
-
Package.StoreAssociation.xml
:此文件将应用程序与 Microsoft Store 关联。 -
Properties\PublishProfiles\MSIX-win10-x86.pubxml
:这是发布配置文件。
这两个文件可能包含敏感信息,因此它们不应被检入 Git 仓库。
要将构建过程集成到 CI/CD 环境中,我们需要使用命令行执行构建过程。要从项目文件夹中构建 .msix
包,请执行以下命令:
dotnet publish PassXYZ.Vault/PassXYZ.Vault.csproj -c Release -f net8.0-windows10.0.19041.0
一旦构建了 .msix
包,我们就可以将其上传到 Microsoft Store 的应用程序提交的“包”部分。
发布到 Google Play 商店
为了准备提交到 Google Play 商店,您需要在 Google Play 控制台中创建一个新的应用程序。创建新的应用程序需要 Google 账户。
每个 Android 应用都有一个独特的应用程序 ID 或包 ID,它在配置文件 AndroidManifest.xml
中定义。此配置文件由 Visual Studio 从项目文件生成,可在 Platforms/Android/AndroidManifest.xml
中找到。让我们回顾一下我们的应用程序的 AndroidManifest.xml
,如清单 12.1所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.passxyz.vault2" //(1)
android:installLocation="auto"
android:versionCode="1"> //(2)
<application
android:allowBackup="true"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:supportsRtl="true"></application>
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name=
"android.permission.INTERNET" />
</manifest>
清单 12.1:AndroidManifest.xml
(epa.ms/AndroidManifest12-1
)
在我们的应用程序中,应用程序 ID 是 "com.passxyz.vault2"
(1),它由 ApplicationId
生成,版本是 android:versionCode
的值 (2),它由 ApplicationVersion
生成。
应用程序标识符和版本的声明可以在 PassXYZ.Vault.csproj
项目文件中找到:
<!-- App Identifier -->
<ApplicationId>com.passxyz.vault2</ApplicationId>
<ApplicationIdGuid>8606B3B5-C03C-41D7-825F-B33718CF791C
</ApplicationIdGuid>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
要对 Android 包进行签名,我们需要创建一个密钥库文件。有关创建密钥库文件和签名 Android 应用程序的更多信息,请参阅以下 Android 文档:developer.android.com/studio/publish/app-signing
。
一旦我们有了密钥库文件并准备好了所需的配置,请在 Visual Studio 中按照以下步骤操作:
-
将目标框架设置为
net8.0-android
,将构建类型设置为Release
。 -
右键单击项目节点,并选择发布…。
完成这些步骤后,构建将开始,并将创建存档,如图 12.6所示:
图 12.6:为 Android 创建存档
包创建完成后,我们可以通过点击分发…按钮来签名它。然后,我们需要选择一个分发渠道,如图图 12.7所示:
图 12.7:选择渠道
虽然可以选择Google Play来签名和提交包,但我们将选择Ad Hoc进行签名。稍后我们将手动将签名的包提交到 Google Play。
选择Ad Hoc
后,我们将看到一个不同的屏幕,如图图 12.8所示,在那里我们可以继续签名我们的 Android 包的过程:
图 12.8:使用密钥库文件进行签名
如图 12.8所示,点击+按钮添加密钥库文件。添加密钥库文件后,点击另存为按钮来签名包。
签名的.aab
文件可以通过 Google Play Console 提交到 Google Play Store。
如果你没有现有的密钥库文件,你可以按照前面提到的指南创建一个新的。密钥库文件的默认位置是%USERPROFILE%\AppData\Local\Xamarin\Mono for Android\Keystore\
。
安全存储你的密钥库文件和密码至关重要,因为当你提交应用程序更新版本到 App Store 时,它们将再次被需要。
如果你选择图 12.7中的Google Play选项,显示的屏幕如图图 12.9所示。然后,你需要使用你的客户端 ID和客户端密钥访问 Google Play API。尽管Google Play和Ad Hoc选项有很强的相似性,但选择Google Play允许你自动将构建结果提交到 Google Play Store:
图 12.9:使用客户端 ID 和客户端密钥连接到 Google Play Store
要从命令行创建包,请在项目文件夹中执行以下命令:
dotnet publish PassXYZ.Vault/PassXYZ.Vault.csproj -c Release -f net8.0-android
要了解如何将签名的 Android 应用程序包上传到 Google Play Store,请参阅以下 Android 文档:developer.android.com/studio/publish/upload-bundle
。
发布到苹果的 App Store
我们可以同时讨论将 iOS 或 macOS 应用程序提交到 App Store 的过程,因为它们有很多相似之处。
在本节中,我们将讨论在 macOS 环境中构建、签名和部署.NET MAUI 应用程序到 iOS 和 macOS 的过程。
由于苹果的要求,构建、签名和部署.NET MAUI 应用程序到 iOS 或 macOS 需要特定的工具和环境。以下是你需要的:
-
macOS: 由于 Apple 的限制,您必须在 Mac 上构建 iOS 和 macOS 应用。这可以是一个本地机器,或者您可以使用 GitHub Actions 或 Azure Pipelines 等云 macOS 环境,这些环境提供托管 macOS 代理。
-
Xcode: 安装最新稳定的 Xcode 版本。这是 iOS SDK 和模拟器所必需的。
-
.NET 8: .NET MAUI 是基于 .NET 8 构建的,因此您需要安装 .NET 8 SDK。
-
MAUI: 安装最新稳定的 .NET MAUI 版本,包括 .NET MAUI 工作负载和模板。
-
Apple 开发者账号: 要签名并将应用部署到 App Store,您需要一个 Apple 开发者账号。这需要年度订阅费用,但这是将您的应用分发给用户所必需的。
-
配置文件和证书: 要签名您的应用,您需要一个配置文件和相应的分发证书。您可以在 Apple 开发者门户中创建和管理这些文件,或者通过 Xcode 自动管理它们。有关如何创建签名证书和配置文件的详细信息,请参阅以下文档:
learn.microsoft.com/en-us/dotnet/maui/ios/deployment/provision
。
为了同时满足 Xamarin 和 .NET MAUI 构建的需求,您可能在 macOS 上安装了多个 Xcode。您可以使用以下命令检查您 Mac 上的所有 Xcode 安装:
% ls /Applications | grep 'Xcode'
Xcode.app
Xcode_14.3.1.app
Xcode_15.2.app
要检查当前选定的 Xcode,您可以使用此命令:
% xcrun xcode-select–print-path
/Applications/Xcode_15.2.app/Contents/Developer
如果您需要选择特定的 Xcode 版本,可以使用以下命令:
% sudo xcode-select -s /Applications/Xcode.app
一旦安装并设置好这些工具,您就可以使用 .NET CLI 进行命令行构建和部署,来构建、签名和部署您的 .NET MAUI iOS 或 macOS 应用。
在 iOS 或 macOS 应用中,捆绑标识符和捆绑版本用于标识一个应用。这些信息存储在 Info.plist
配置文件中。捆绑标识符由 ApplicationId
生成,而捆绑版本由 Visual Studio 项目文件中的 ApplicationVersion
生成。
iOS 应用可以通过 App Store 独家分发。提交的包是一个具有 .ipa
扩展名的文件。尽管 macOS 应用也可以通过 App Store 分发,但它们的包可以直接安装。
虽然一些发布步骤可以在 Windows 环境中执行,但您仍然需要连接到一个可网络访问的 macOS 计算机。为了减少复杂性,我们使用 macOS 环境来构建 iOS 和 macOS 应用。在构建包之前,我们需要更新 Visual Studio 项目文件以配置我们自己的签名证书和分发配置文件:
<PropertyGroup Condition="$(TargetFramework.Contains('-ios')) and '$(Configuration)' == 'Release'">
<RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
<CodesignKey>iPhone Distribution: Shugao Ye (W9WL9WPD24)
</CodesignKey>
<CodesignProvision>passxyz_2023</CodesignProvision>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework.Contains('-
maccatalyst')) and '$(Configuration)' == 'Release'">
<CodesignEntitlement>Entitlements.plist
</CodesignEntitlement>
<CodesignKey>
3rd Party Mac Developer Application: Shugao Ye
(W9WL9WPD24)
</CodesignKey>
<CodesignProvision>passxyz.maccatalyst</CodesignProvision>
</PropertyGroup>
如上图所示,您可以为 iOS 和 macOS 构建使用条件配置。iOS 和 macOS 使用不同的签名证书和分发配置文件。
如果您不确定设置是否正确,可以使用Xcode
进行验证。在 Xcode 中创建一个应用涉及使用与我们的应用相同的"com.passxyz.vault2"
捆绑 ID。之后,检查签名配置,如图 12.10 所示。这个过程允许您比较 Visual Studio 项目和 Xcode 中相应的签名配置,帮助识别任何设置上的差异或问题。
图 12.10:Xcode 中的 iOS 签名设置
如果签名证书或配置文件存在问题,Xcode 将报告错误信息。一旦 Xcode 中的设置正确,就可以在 Visual Studio 项目中使用相同的设置而不会出现任何问题。
在所有配置就绪后,我们可以使用以下命令在项目文件夹中构建.ipa
文件:
dotnet publish PassXYZ.Vault/PassXYZ.Vault.csproj -c Release -f net8.0-ios /p:CreatePackage=true /p:ArchiveOnBuild=True
一旦命令执行成功,将生成一个.ipa
文件。我们可以将此文件提交到 App Store。有三种方法可以用来将包上传到 App Store。有关更多详细信息,请参阅以下文档:developer.apple.com/help/app-store-connect/manage-builds/upload-builds
。
从文档中我们知道,我们可以使用Xcode、altool或Transporter上传一个包。
由于我们没有直接使用 Xcode 来构建.NET MAUI 应用,因此我们不会使用 Xcode 将构建上传到 Apple Store。如果目标是建立一个全面的 CI/CD 解决方案,可以使用altool将构建上传到 Apple Store。
然而,在本章中,我们的主要关注点是持续集成(CI),将持续交付(CD)留给你独立深入研究。CD 的设置通常需要更多的账户特定配置。
我们将在这里使用 Transporter 应用。在登录到 Transporter 应用后,我们可以将包上传到 App Store,如图 12.11 所示:
图 12.11:使用 Transporter 应用上传包
macOS 包的构建和上传过程与 iOS 应用类似。有三种不同的框架(AppKit、Mac Catalyst 和 SwiftUI)可以用来构建 macOS 应用。在.NET MAUI 中,Mac Catalyst 用于平台特定的实现。
默认情况下,Mac Catalyst 应用中未启用 App 沙盒,因此我们需要启用它。要在 macOS 应用中启用它,我们需要将一个Entitlements.plist
文件添加到构建配置中。
我们可以查看列表 12.2 中的Entitlements.plist
文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only
</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
列表 12.2:Entitlements.plist
(epa.ms/Entitlements12-2
)
就像在 iOS 构建中一样,我们可以在 Xcode 中验证 macOS 构建的配置,如图图 12.12所示。通过这样做,您确保在构建和提交到 App Store 之前,我们的 macOS 应用程序已正确设置签名和配置。
图 12.12:Xcode 中 macOS 应用程序的签名设置
在所有配置就绪后,我们可以使用以下命令在我们的项目文件夹中构建包:
dotnet publish PassXYZ.Vault/PassXYZ.Vault.csproj -c Release -f net8.0-maccatalyst /p:CreatePackage=true /p:EnablePackageSigning=true"
在成功构建包之后,我们可以使用 Transporter 应用程序将.pkg
文件上传到 App Store,如图图 12.13所示。这将证明 iOS 和 macOS 包已成功上传到 App Store。然后,您可以继续到 App Store Connect 完成剩余的提交过程。
图 12.13:使用 Transporter 应用程序上传 macOS 应用程序
在将包上传到 Microsoft Store、Google Play Store 和 Apple App Store 之后,我们可以在最终发布之前使用商店提供的测试工具测试上传的包:
-
Apple App Store:TestFlight可以在生产发布之前用于测试 iOS/macOS 应用程序
-
Google Play Store:在生产发布之前可以设置 Alpha 或 beta 测试
-
Microsoft Store:包航班可以在 Microsoft Store 上用于测试上传的包
现在我们已经学会了为支持的平台准备应用程序包的基本步骤,我们可以探索在CI/CD
环境中设置.NET MAUI 应用程序的自动化构建,例如 GitHub Actions 或 Azure DevOps。实施 CI/CD 可以简化开发和部署过程,确保您的应用程序在您进行更新和改进时持续进行测试、构建和准备发布。
GitHub Actions
由于我们的源代码托管在 GitHub 上,让我们以 GitHub Actions 为例,向您介绍如何设置.NET MAUI 开发的 CI 工作流程。GitHub Actions 是一个自动化平台,可以帮助简化并自动化与项目相关的关键任务,如构建、测试和部署代码更新。这个强大的功能确保您的代码始终保持验证状态,随时准备部署,使开发者能够专注于编写新功能和修复错误,同时保持质量保证。
理解 GitHub Actions
对于.NET MAUI 应用程序开发,我们的目标是构建、测试并将我们的应用程序部署到应用商店或指定的发布渠道。在本节中,我们将专注于使用 GitHub Actions 进行 CI,而不是同时进行 CI 和 CD。要将应用程序部署到各种商店,有许多针对特定账户的设置步骤;请参阅.NET MAUI 文档以获取详细信息:learn.microsoft.com/en-us/dotnet/maui/deployment/
.
GitHub Actions 工作流程是一个自动构建和部署项目交付成果的过程。工作流程通常从push
或pull_request
事件等事件开始,或者当提交问题。一旦工作流程被触发,定义的作业将在运行者内部开始执行某些任务。每个作业由一个或多个步骤组成,这些步骤要么运行脚本,要么执行操作。
总结来说,GitHub Actions 包括事件、运行者、作业/步骤、操作和运行者。这些组件协同工作,以自动化你的开发和部署过程中的各种任务,使其更加高效且错误率更低。
工作流程
GitHub Actions 工作流程由.github/workflows
目录中的 YAML 文件定义。YAML 是 JSON 的超集,提供了一种更易于阅读的语言。一个仓库可以有一个或多个工作流程来执行不同的作业。参考图 12.14了解PassXYZ.Vault
项目中定义的工作流程。
图 12.14:Windows 运行者的工作流程
如图 12.14所示,此示例说明了工作流程如何执行 Android 和 Windows 构建。工作流程由push
或pull_request
事件触发,或手动触发。它在一个 Windows 运行者内部运行以执行构建。当工作流程被触发时,两个作业Android Build和Windows Build开始执行。每个作业包括四个步骤来执行构建,如图 12.14所示。
在我们的项目中,我们定义了以下两个工作流程:
-
passxyz-ci-macos.yml
: 这是一个在 macOS 运行者上构建 iOS 和 macOS 的工作流程。 -
passxyz-ci-windows.yml
: 这是一个在 Windows 运行者上构建 Android 和 Windows 的工作流程。
我们可以在列表 12.3和列表 12.4中看到 YAML 文件:
name: PassXYZ.Vault CI Build (Windows)
on: //(1)
push: //(2)
branches: [ chapter12 ]
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request: //(3)
branches: [ chapter12 ]
workflow_dispatch: //(4)
permissions:
contents: read
env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNETVERSION: 8.0.x
DOTNETSDK: 'net8.0'
PROJECT_NAME: PassXYZ.Vault
jobs: //(5)
# MAUI Android Build
build-android: //(6)
runs-on: windows-latest //(7)
name: Android Build
steps: //(8)
- name: Checkout
uses: actions/checkout@v3 //(9)
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: ${{env.DOTNETVERSION}}
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore Dependencies
run: dotnet restore ${{env.PROJECT_NAME}}/$
{{env.PROJECT_NAME}}.csproj
- name: Build MAUI Android
run: dotnet publish ${{env.PROJECT_NAME}}/$
{{env.PROJECT_NAME}}.csproj -c Release -f ${{env.DOTNETSDK}}-android --no-restore
- name: Upload Android Artifact
uses: actions/upload-artifact@v3
with:
name: passxyz-android-ci-build
path: ${{env.PROJECT_NAME}}/bin/Release/
${{env.DOTNETSDK}}-android/*Signed.a*
# MAUI Windows Build
build-windows:
runs-on: windows-latest
name: Windows Build
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: ${{env.DOTNETVERSION}}
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore Dependencies
run: dotnet restore ${{env.PROJECT_NAME}}
/${{env.PROJECT_NAME}}.csproj
- name: Build MAUI Windows
run: dotnet publish ${{env.PROJECT_NAME}}/
${{env.PROJECT_NAME}}.csproj -c Release -f ${{env.DOTNETSDK}}-windows10.0.19041.0
- name: Upload Windows Artifact
uses: actions/upload-artifact@v3
with:
name: passxyz-windows-ci-build
path: ${{env.PROJECT_NAME}}/...
列表 12.3: passxyz-ci-windows.yml
(epa.ms/passxyz-ci-windows12-3
)
这些工作流程文件定义了自动化不同目标平台构建过程的必要步骤,有助于确保我们的.NET MAUI 应用始终准备好部署。
在接下来的会话中,我们将详细分析它,提供关于这些工作流程如何帮助自动化你的.NET MAUI 应用构建过程的见解。了解这些工作流程文件的结构和功能将使你能够自定义和增强你的开发流程,确保你的应用始终准备好部署并满足你的特定要求。
事件
事件对于触发工作流程至关重要,并且它们在on:
关键字(1)之后定义。在先前的流程中,我们定义了push
(2),pull_request
(3)和workflow_dispatch
(4)事件。对于push
和pull_request
事件,我们监控主分支的活动,并且也不忽略任何与构建相关的提交,例如 Markdown 文件或配置文件。
有关可用于触发工作流程的事件的更多信息,请参阅以下 GitHub 文档:docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
。
理解触发工作流程的事件可以使您自定义在项目更改时执行特定操作的时间。这有助于在适应您独特的开发流程需求的同时维护应用程序的质量。
任务
当工作流程被触发时,它将启动定义的任务的执行。任务在 jobs
: (5) 关键字之后定义。一个或多个任务可以在工作流程中定义,每个任务通过一个任务 ID 来标识,例如 build-android
(6)。在 Listing 12.3 中有两个任务,build-android
和 build-windows
。每个任务都可以定义一个名称、一个运行器和多个步骤。
任务是工作流程的核心组件,包含在特定事件发生时需要执行的任务序列。通过定义针对您应用程序开发需求特定的任务,您可以在构建过程中确保采取正确的操作,从而实现更流畅、更高效的开发流程。
运行器
运行器是执行任务的平台类型。在我们的配置中,Android 和 Windows 任务都使用 Windows 运行器执行。运行器在 runs-on
: (7) 关键字之后定义。有关运行器配置的更多信息,请参阅 GitHub Actions 文档。我们使用的运行器是 windows-latest
,这是运行器镜像的标签。
选择适合您任务的正确运行器对于确保您的应用程序正确且高效地构建至关重要。通过了解可用的运行器选项及其预安装的工具,您可以更好地根据您的 .NET MAUI 应用程序的独特需求定制您的开发流程。
步骤
在任务中可以定义多个步骤,它们在 steps
: (8) 关键字之后定义。在 Android 和 Windows 构建中,存在多个步骤:checkout、install .NET MAUI workload、restore dependencies、build 和 upload。每个步骤都可以运行一个脚本或操作。在 checkout 步骤中,在 uses:
(9) 关键字之后使用 checkout
动作。操作是 GitHub Actions 平台上的自定义应用程序,用于执行复杂但频繁重复的任务。使用操作允许代码重用,类似于面向对象编程中组件的功能。要使用操作,只需指定操作名称和可选的版本号。在我们的脚本中,我们可以将 checkout 操作指定为 actions/checkout@v3
。
checkout
动作的源代码托管在 GitHub 上,可以在以下网站找到:github.com/actions/checkout
。
一旦检出源代码,我们需要在 run
语法之后使用以下命令安装 .NET MAUI 工作负载:
dotnet workload install maui
在恢复和构建步骤中,我们可以在 run
语法之后的 dotnet
命令运行以下内容:
dotnet restore ${{env.PROJECT_NAME}}/${{env.PROJECT_NAME}}.csproj
构建完成后,我们可以使用另一个 upload-artifact
动作上传工件。
我们已经引入了 passxyz-ci-windows.yml
工作流程,该工作流程执行 Android 和 Windows 构建。现在,让我们回顾 passxyz-ci-macos.yml
工作流程,该工作流程执行 iOS 和 macOS 构建,如清单 12.4所示:
name: PassXYZ.Vault CI Build (MacOS)
on:
push:
branches: [ chapter12 ]
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request:
branches: [ chapter12 ]
workflow_dispatch:
permissions:
contents: read
env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNETVERSION: 8.0.x
DOTNETSDK: 'net8.0'
PROJECT_NAME: PassXYZ.Vault
jobs:
# MAUI iOS Build
build-ios:
runs-on: macos-14 //(1)
name: iOS Build
steps:
- name: Setup Xcode
run: sudo xcode-select -s /Applications/Xcode_15.1.0.app
- name: Checkout
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: ${{env.DOTNETVERSION}}
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore Dependencies
run: dotnet restore ${{env.PROJECT_NAME}}
/${{env.PROJECT_NAME}}.csproj
- name: Build MAUI iOS
run: dotnet build ${{env.PROJECT_NAME}}
/${{env.PROJECT_NAME}}.csproj -c Release -f
${{env.DOTNETSDK}}-ios --no-restore /p:buildForSimulator=True
/p:packageApp=True /p:ArchiveOnBuild=False
- name: Upload iOS Artifact
uses: actions/upload-artifact@v3
with:
name: passxyz-ios-ci-build
path: ${{env.PROJECT_NAME}}/bin/Release/
${{env.DOTNETSDK}}-ios/iossimulator-x64/**/*.app
# MAUI MacCatalyst Build
build-mac:
runs-on: macos-14
name: MacCatalyst Build
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: ${{env.DOTNETVERSION}}
- name: Install MAUI workload
run: dotnet workload install maui
- name: Restore Dependencies
run: dotnet restore ${{env.PROJECT_NAME}}
/${{env.PROJECT_NAME}}.csproj
- name: Build MAUI MacCatalyst
run: dotnet publish ${{env.PROJECT_NAME}}
/${{env.PROJECT_NAME}}.csproj -c Release
-f${{env.DOTNETSDK}}-maccatalyst --no-restore -p:BuildIpa=True
- name: Upload MacCatalyst Artifact
uses: actions/upload-artifact@v3
with:
name: passxyz-macos-ci-build
path: ${{env.PROJECT_NAME}}/bin/Release/${{env.DOTNETSDK}}-
maccatalyst/maccatalyst-x64/publish/*.pkg
清单 12.4:passxyz-ci-macos.yml
(epa.ms/passxyz-ci-macos12-4
)
此工作流程遵循类似的原则,包括恢复、安装、构建和上传 iOS 和 macOS 版本的 .NET MAUI 应用的工件步骤。这里的区别在于使用了 macos-14
(1) 运行器。其余步骤与 Windows 或 Android 构建类似。
我们现在已经介绍了在 GitHub Actions 中配置所有构建的过程。您可以在 GitHub 上检查构建状态,以查看应用构建在 .NET MAUI 项目中的进展和成功情况。监控构建状态可以帮助您在开发过程中识别任何潜在的应用问题,并保持代码质量。
图 12.15:Android 和 Windows 构建状态
如图 12.15所示,Android 和 Windows 构建均成功完成。构建工件可以在构建完成后从 GitHub 下载。这确保了您能够访问最新的应用构建版本,使得分发它们进行测试或部署变得更加容易。
图 12.16:iOS 和 MacCatalyst 构建状态
如图 12.16所示,iOS 和 Mac Catalyst 构建均成功完成。
通过成功配置 GitHub Actions 以支持您的 .NET MAUI 项目,您可以维护一个持续集成(CI)流程,确保代码质量并简化开发过程。此外,这个自动化工作流程简化了应用构建的管理,使开发者能够专注于功能开发和错误修复。
我们现在已经介绍了如何使用 GitHub Actions 自动化打包应用以供应用商店提交的过程。有了这些技术,您可以确保您的 .NET MAUI 应用开发流程更加顺畅和高效,从而实现代码质量更高,应用商店部署更快。
摘要
CI/CD 是当今开发过程中的常见实践。在本章中,我们介绍了如何准备构建,以便生成的包可以用于提交到各种应用商店。构建包提交之后的流程没有涵盖,因为这些是特定于平台和账户的话题。请参阅进一步阅读部分,了解更多关于如何将应用程序发布到 Google Play、Apple Store 和 Microsoft Store 的信息。
在讨论了每个平台的构建过程之后,我们展示了如何使用 GitHub Actions 自动化此过程。
通过本书您所学的所有技能,您应该能够开发自己的 .NET MAUI 应用程序,并准备好将您的应用程序提交到支持的 App Store。随着您作为开发者的成长,请记住探索新技术并深化对 .NET MAUI 的理解,以创建更好的应用程序并简化您的开发流程。
这里有一些在线资源,可以帮助您学习 .NET MAUI 并了解最新的趋势和新闻:
-
官方 .NET 博客:微软 .NET 团队经常发布关于 .NET MAUI 的更新和教程。
-
.NET MAUI GitHub 仓库:这是 .NET MAUI 的官方仓库。您在这里可以找到最新的代码、问题和关于 .NET MAUI 的讨论。
-
Microsoft 文档:这是微软的官方文档,它提供了学习 .NET MAUI 的全面指南。
-
Microsoft Learn:一个可以找到各种学习路径和模块的平台。您可以找到关于 .NET 的资源,以及可能关于 .NET MAUI 的未来内容。
-
.NET 社区站立会议:由微软工程师主持的定期社区站立会议,他们在这里讨论最新的趋势和 .NET MAUI 的更新。
-
Stack Overflow:一个专业和爱好者程序员的问答网站。
-
.NET MAUI YouTube 教程:您可以通过在 YouTube 上观看分步教程来学习 .NET MAUI。例如,Xamarin 开发者 YouTube 频道发布了关于 .NET MAUI 的教程和讨论。
-
Twitter:关注官方 .NET 账号 (
@dotnet
) 和其他微软开发者,获取最新的更新和发展动态。
进一步阅读
-
开始使用 DevOps 和 .NET MAUI:
devblogs.microsoft.com/dotnet/devops-for-dotnet-maui/
-
将您的应用程序上传到 Play Console:
developer.android.com/studio/publish/upload-bundle
-
发布 iOS 平台的 .NET MAUI 应用程序:
learn.microsoft.com/en-us/dotnet/maui/ios/deployment/?view=net-maui-8.0
-
发布 Mac Catalyst 平台的 .NET MAUI 应用程序:
learn.microsoft.com/en-us/dotnet/maui/mac-catalyst/deployment/?view=net-maui-8.0
-
发布 Android 平台的 .NET MAUI 应用程序:
learn.microsoft.com/en-us/dotnet/maui/android/deployment/overview?view=net-maui-8.0
-
发布 Windows 平台的 .NET MAUI 应用程序:
learn.microsoft.com/en-us/dotnet/maui/windows/deployment/overview?view=net-maui-8.0
-
将 iOS 构建上传到 Apple Store:
developer.apple.com/help/app-store-connect/manage-builds/upload-builds
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像你一样的读者。扫描下面的二维码获取 40% 的折扣码。
限时优惠