-NET-MAUI-MVVM-模式-全-

.NET MAUI MVVM 模式(全)

原文:zh.annas-archive.org/md5/17e1b4932020ca7c7d2be06ec258ef83

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在软件设计模式和框架是稳健应用开发核心的时代,掌握 模型-视图-视图模型MVVM)模式对于步入 .NET MAUI 领域的开发者来说变得至关重要。“.NET MAUI 中的 MVVM 模式”:一本关于基本模式、最佳实践和技术的 definitive guide,已经精心制作,旨在为渴望踏上这一旅程的开发者提供指南。

本书与众不同的地方在于其对 .NET MAUI 的专注。我们不仅泛泛而谈 MVVM,而是深入探讨其在特定框架中的实际应用。即使你是 MVVM 或 .NET MAUI 的初学者,这本指南也旨在使其既易于接近又富有成效。

我们的旅程从深入探索 MVVM 设计模式和 .NET MAUI 框架的核心组件开始。随着我们的进展,我们将导航数据绑定的复杂性,利用社区工具包的力量,以及有效地管理集合。我们将进一步深入理解依赖注入、服务模式和消息传递,你将学习关于不同导航方法的知识,理解用户输入和验证,并掌握处理远程数据的技术。后几章将指导你创建适合 MVVM 的控件,本地化你的应用程序,以及单元测试的关键方面。

从这些基础知识到高级技术,到本旅程结束时,你将完全具备将 MVVM 有效地集成到你的 .NET MAUI 项目中的知识。

欢迎来到你的 definitive guide!

本书面向的对象

本书面向的是那些希望在 .NET MAUI 的背景下利用 MVVM 模式力量的开发者。无论你是刚开始接触 .NET MAUI 的爱好者,还是希望提升自己在 .NET MAUI 和 MVVM 方面的专业水平的资深专业人士,这本指南都能满足你的需求。

虽然不需要有 .NET MAUI 或 Xamarin.Forms 的先前经验,但具备 C# 的基础知识是必不可少的。熟悉 C# 的开发者会发现内容易于接近且结构化,可以无缝地将技能提升到 .NET MAUI 和 MVVM 的美妙世界。

从移动应用程序开发领域的初学者到寻求在 .NET MAUI 的 MVVM 实现方面获得专业知识的老兵,本书为所有人提供了一本全面的指南。

本书涵盖的内容

第一章MVVM 设计模式,概述了 MVVM 模式的核心组件,展示了它们的作用和相互作用。它说明了将 UI 与逻辑分离以提高可测试性和维护性的价值。它解决了常见的 MVVM 错误观念,并提供了对 MVVM 的基础理解,无论具体技术如何。

第二章什么是.NET MAUI?,概述了.NET MAUI 的整体格局、其关键特性和优势以及其架构。它提供了关于.NET MAUI 从 Xamarin 和 Xamarin Forms 起源以及其演变历程的见解。

第三章.NET MAUI 中的数据绑定构建块,展示了.NET MAUI 中的数据绑定(MVVM 的基石)是.NET MAUI 的一个基本组成部分。本章解构了支撑这一基本功能的关键原则、元素和技术。

第四章.NET MAUI 中的数据绑定,在上一章的基础上进一步深入其复杂性和细微差别,全面探索.NET MAUI 中数据绑定的所有方面。

第五章社区工具包,聚焦于两个有影响力的社区工具包:MVVM 工具包和.NET MAUI 社区工具包。它展示了这些社区驱动的倡议如何增强.NET MAUI 应用程序中 MVVM 的有效性。

第六章与集合一起工作,概述了如何使用.NET MAUI 中的 MVVM 有效地显示数据集。从绑定数据和适应其变化到制作数据模板,本章提供了以 MVVM 为中心的指南,用于展示数据和促进用户与之交互。

第七章依赖注入、服务和消息传递,阐明了通过服务注册和依赖注入将 ViewModel 与视图对齐的方法。它突出了将服务注入 ViewModel 的细微差别,以及通过消息传递促进 ViewModel 之间无缝通信,同时保持关注点分离并提高可测试性。

第八章.NET MAUI 中的导航,全部关于基于 MVVM 的导航。本章概述了.NET MAUI Shell 是什么以及其对导航的影响。它展示了如何构建一个针对利用 Shell 的.NET MAUI 应用程序以及不使用 Shell 的应用程序量身定制的导航服务。

第九章处理用户输入和验证,展示了如何有效地处理用户输入,从在 ViewModel 级别验证数据,到标准自定义验证规则,再到在存在未保存更改的情况下提示用户确认或取消。

第十章与远程数据一起工作,展示了如何处理远程数据,介绍了用于保持关注点分离的存储库,并利用依赖注入。它说明了如何使用 Refit 进行简化的 API 交互,以及如何处理异步任务及其 UI 影响。

第十一章创建 MVVM 友好的控件,深入探讨了创建针对 MVVM 定制的控件,这些控件支持通过命令进行数据绑定和交互。

第十二章使用 MVVM 进行本地化,概述了在应用程序中本地化硬编码标签的技术,以及如何有效地从 API 获取特定语言的数据。

第十三章单元测试,展示了单元测试有多容易且多么重要。它指导您设置单元测试项目、生成数据、模拟依赖关系以及测试 MAUI 代码。

第十四章故障排除和调试技巧,突出了常见的故障及其解决方案。它展示了与数据绑定、依赖注入以及构建自定义控件和转换器相关的问题和陷阱。

要充分利用本书

您需要熟悉 C#的基础知识。

在本书的早期,我们将说明如何使用 Visual Studio 2022 或 Visual Studio Code 设置您的开发环境。本书中描述的步骤基于 Visual Studio,但所有解释和展示的内容也可以在任何支持.NET MAUI 的 IDE 中完成。

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

下载示例代码文件

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

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“RecipeDetailViewModel代表菜谱的详细信息。目前,它只包含一个Title属性,我们现在给它一个硬编码的值"Classic Caesar Salad"。”

代码块设置如下:

<Grid ColumnDefinitions="*, Auto">
    <Label
        FontAttributes="Bold" FontSize="22"
        Text="{Binding Path=Title, Mode=OneTime}"
        VerticalOptions="Center" />
    <Image
        x:Name="favoriteIcon"
        Grid.Column="1" Margin="5"
        HeightRequest="35" Source="favorite.png"
        VerticalOptions="Center" WidthRequest="35">
    </Image>
</Grid>

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

private bool _hideAllergenInformation = true;
public bool HideAllergenInformation
{
    get => _hideAllergenInformation;
    set => SetProperty(ref _hideAllergenInformation, value);
}

Messages。“

小贴士或重要注意事项

看起来像这样。

联系我们

欢迎读者反馈。

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

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

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

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

分享您的想法

一旦您阅读了《.NET MAUI 中的 MVVM 模式》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载本书的免费 PDF 副本

感谢您购买本书!

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

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

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

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

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取这些好处:

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

packt.link/free-ebook/9781805125006

  1. 提交您的购买证明

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

第一部分:关键概念和组件

在本部分中,我们首先讨论 Model-View-ViewModel 设计模式,而不将其与特定平台绑定。接下来,我们将介绍.NET MAUI 框架,并强调为什么它与 MVVM 配合得如此之好。随着我们继续前进,您将了解社区工具包的好处,并亲身体验集合。这个基础为您在以后学习更高级的主题奠定了基础。

本部分包含以下章节:

  • 第一章MVVM 设计模式

  • 第二章.NET MAUI 框架

  • 第三章在.NET MAUI 中的数据绑定构建块

  • 第四章在.NET MAUI 中的数据绑定

  • 第五章社区工具包

  • 第六章, 与集合一起工作

第一章:什么是 MVVM 设计模式?

MVVM模型-视图-视图模型模式是.NET 生态系统中非常常用的设计模式,其中它已被证明非常适合使用XAML构建图形用户界面的前端框架。这一点并不难理解。

本书将为您提供对 MVVM 设计模式及其在.NET MAUI项目中有效应用的良好理解。需要注意的是,虽然我们将专注于在.NET MAUI 的上下文中应用 MVVM,但 MVVM 模式本身并不仅限于.NET 生态系统。它是一个广泛使用的设计模式,在包括 WPF、WinUI 等在内的各种软件开发生态系统中获得了流行。我们将深入研究.NET MAUI 的各个方面,以了解它如何支持并启用 MVVM 的使用。在整个书中,我们将构建“食谱!”应用作为实际示例,展示在.NET MAUI 中应用 MVVM 模式的各个方面。

在本章中,我们将学习 MVVM 设计模式及其核心组件。我们将探讨该模式在关注点分离方面带来的附加价值以及为什么这如此重要。一个示例应用将展示 MVVM 可以为您的代码带来的附加价值。最后,我们将讨论一些关于 MVVM 的常见误解。

到本章结束时,您将了解 MVVM 是什么,其主要组件是什么,以及每个组件的作用。您还将看到 MVVM 在提高代码的可测试性和可维护性方面带来的价值。

在本章中,我们将涵盖以下主要内容:

  • MVVM 的核心组件

  • 关注点的分离很重要

  • MVVM 的实际应用

  • 关于 MVVM 的常见误解

在深入探讨.NET MAUI 中 MVVM 的细节之前,熟悉 MVVM 模式的核心组件至关重要。在接下来的部分中,我们将定义关键术语,以确保我们有一个坚实的基础和共同的了解,以便继续前进。

技术要求

虽然本章提供了 MVVM 的理论概述,但稍后会有一些代码展示 MVVM 的实际应用,以便您开始看到这种模式带来的价值。要自己实现此示例,您需要以下条件:Visual Studio 2022(17.3 或更高版本),或任何允许您创建.NET MAUI 应用的 IDE。在第二章**,什么是.NET MAUI?的末尾,我们将探讨如何为开发.NET MAUI 应用准备您的机器。示例代码也可以在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI

查看 MVVM 的核心组件

MVVM 提供了一种非常清晰的方式来分离 UI 和业务逻辑,促进代码的可重用性、可维护性和可测试性,同时允许灵活的 UI 设计和更改。

随着应用程序规模和复杂性的增长,将业务逻辑放在代码背后(code-behind)很快就会变得具有挑战性。代码背后指的是将业务逻辑放置在与用户界面元素相同的文件中的做法,这通常会导致大量代码通过事件处理器被调用。这通常会导致 UI 元素和底层业务逻辑之间的紧密耦合,因为 UI 元素在代码中被直接引用和操作。因此,调整 UI 和执行单元测试可能会变得更加困难。在本章的“MVVM 实战”部分,我们将看到在代码背后放置业务逻辑意味着什么,以及它如何复杂化维护性、测试等。

初看起来,MVVM 可能有点令人不知所措或困惑。为了完全理解这个模式是什么以及为什么它如此受 XAML 开发者的欢迎,我们首先需要剖析 MVVM 模式并查看其核心组件。

模式的名称已经揭示了三个基本的核心组件:模型视图视图模型。还有两个对于高效使用 MVVM 至关重要的辅助元素:命令数据绑定。这些元素中的每一个都有自己的独特角色和责任。

以下图表显示了 MVVM 的核心组件以及它们之间是如何相互作用的:

图 1.1 – MVVM 的核心组件

图 1.1 – MVVM 的核心组件

如果你想要有效地使用 MVVM 模式,重要的是你不仅要了解每个组件的责任,还要了解它们之间是如何相互作用的。在较高层次上,视图(View)了解视图模型(ViewModel),而视图模型(ViewModel)了解模型(Model),但模型(Model)对视图模型(ViewModel)并不知情。这种分离防止了 UI 和业务逻辑之间的紧密耦合。

为了有效地使用 MVVM 模式,重要的是要了解如何将应用程序代码组织成合适的类,以及这些类是如何相互作用的。现在,让我们来看看核心组件。

模型

模型负责表示应用程序的数据和业务逻辑。它封装了数据并提供了一种操作它的方法。模型可以与应用程序内的其他组件通信,例如数据库或 Web 服务。

它通常使用代表应用程序领域对象的类来实现,例如客户、订单或产品。这些类通常包含表示对象属性的属性和定义对象行为的方法。

模型被设计成与应用程序中使用的 UI 框架独立。因此,如果需要,它可以在其他应用程序中重用。它可以独立于应用程序的 UI 进行维护和测试。

注意

对于刚开始接触 MVVM 的开发者来说,往往不清楚模型可以是或应该是哪种类型的对象:数据传输对象DTOs)、普通 CLR 对象POCOs)、领域实体、代理对象、服务等等。所有这些类型的对象都可以被视为模型。

此外,在大多数情况下,模型(Model)是不同类型对象的组合。不要将模型视为单一的事物。实际上,它包括视图和 ViewModel 以外的所有“外部”内容——它是应用程序的领域和业务逻辑。最终,模型类型的选取将取决于应用程序的具体需求、用例和架构。一些开发者可能更喜欢使用 DTOs(数据传输对象)以实现简单易用,而其他人可能更喜欢使用领域实体以实现更好的封装和与领域模型的保持一致。

然而,无论使用的是哪种具体的模型类型,都重要的是将业务逻辑保持在模型“层”内,并在 ViewModel 中避免它。这有助于保持关注点的分离,并使 ViewModel 专注于展示逻辑,而模型则专注于业务逻辑。

视图(View)

视图负责向用户展示数据。它由(UI)元素组成,例如按钮、标签、集合和输入。视图接收来自用户的输入或操作,并与 ViewModel 通信,以便它可以对这些交互做出反应。

在 .NET MAUI 中,视图通常使用 XAML 实现,这是一种用于定义 UI 元素和布局的标记语言。XAML 文件定义了 UI 的结构及其与 ViewModel 的绑定。需要注意的是,尽管 MVVM 模式通常与基于 XAML 的 UI 相关联,但其原则并不局限于特定的 UI 框架。MVVM 模式也可以应用于其他 UI 框架。在 .NET MAUI 中,大多数应用程序使用 XAML 定义 UI,但也可以完全使用 C# 创建 UI。即使采用这种方法,开发者仍然可以有效地应用 MVVM 模式来分离视图、ViewModel 和模型的关注点。

视图最重要的方面是它应该尽可能简单,没有任何业务规则或逻辑。其主要目的是向用户展示数据并接收他们的输入。视图的焦点应放在展示逻辑上,包括格式化和布局,并且不应包含任何业务逻辑或应用程序逻辑,以在 MVVM 中保持适当的关注点分离。

小贴士

通过保持视图简单并专注于展示逻辑,代码可以更容易维护和测试。它还使得在不影响底层业务逻辑的情况下更改 UI 成为可能,从而在适应和随时间演变应用程序方面提供了更大的灵活性。

视图(The View),无论它是使用 XAML 还是 C# 实现的,都是应用程序的用户界面层。本质上,它是构成用户界面的 UI 组件集合,例如页面和控制元素。

ViewModel

ViewModel 位于 Model 和 View 之间。它是 UI 和业务逻辑之间的“粘合剂”。其职责是向 View 暴露数据以供显示,并处理任何用户输入或 UI 事件。它为 View 提供了一个与包含应用程序实际数据和业务逻辑的 Model 交互的接口。让我们逐步深入了解 ViewModel 的功能:

  1. 暴露数据:ViewModel 从 Model 中检索数据并通过公共属性将其暴露给 View。这些属性通常绑定到 View 中的元素,例如文本框或标签。这样,Model 中的数据就会显示在屏幕上。

  2. 反映更改:现在,你可能想知道当用户在屏幕上更改某些内容时会发生什么。这就是数据绑定发挥作用的地方。数据绑定就像 View 和 ViewModel 之间的一条实时通信通道。当用户在 View 中修改数据时,ViewModel 会立即得到通知并相应地更新其属性。同样,如果 ViewModel 中发生更改,View 也会得到更新。

  3. 处理用户交互:ViewModel 不仅被动地提供数据;它还负责处理用户交互,例如按钮点击和文本输入。例如,如果 View 中有一个 保存 按钮,ViewModel 需要知道当用户点击它时应该做什么。

  4. 更新 Model:当用户与 UI 交互并做出更改时,ViewModel 在更新 Model 中发挥着至关重要的作用。ViewModel 接收用户的更改并将它们转换为 Model 可以理解和处理的行为。

ViewModel 充当中间人,处理 View 和 Model 之间数据和行为的流动,确保用户界面准确反映底层数据并对用户交互做出正确响应。这种模块化方法,即它将特定责任与 View 和 Model 分开处理,有助于编写更干净、更易于维护的代码。最后,重要的是要理解 ViewModel 不应与任何特定的 UI 框架绑定。独立于 UI 框架允许它在不同的应用程序之间共享,并便于从应用程序的其他部分独立进行测试。

命令

命令是用于表示可以由 UI 触发的行为的重要概念。它是 View 将用户操作传达给 ViewModel 的方式。命令是一个实现 ICommand 接口的对象,该接口定义了两个方法:ExecuteCanExecute。当命令被触发时调用 Execute 方法,而 CanExecute 方法用于确定命令在其当前状态下是否可以执行。

命令可以看作是传统事件驱动编程中事件的等价物。命令和事件都作为处理应用程序中用户操作或其他触发器的机制。例如,当用户在视图中点击按钮时,ViewModel 中的命令被触发,ViewModel 采取适当的行动,例如保存数据或获取新信息。命令非常灵活,不仅限于按钮。它们可以与各种 UI 元素相关联,如菜单项、工具栏按钮,甚至是手势控制。让我们举一个实际例子:假设你有一个文本编辑应用程序。你可以有一个与Execute方法关联的命令,当调用该方法时执行必要的操作。

在 MVVM 模式中使用命令的一个关键好处是,它们允许视图和 ViewModel 之间更好地分离关注点。通过使用命令,视图可以设计而无需了解与特定 UI 元素相关的底层功能。这意味着 ViewModel 可以处理与命令相关的所有逻辑,而无需与 UI 紧密耦合。

为了将命令绑定到视图中的 UI 元素,命令需要由 ViewModel 作为公共属性公开。然后视图可以使用数据绑定绑定到这个属性。当命令绑定到视图中的 UI 元素时,该 UI 元素会监听事件,例如按钮点击。当该事件被触发时,包含响应事件的代码的命令的Execute方法被调用。

总体而言,命令是 MVVM 模式中一个强大且灵活的概念,它使得视图和 ViewModel 之间的关注点分离更加有效。

关于命令的更多信息

我们将在第三章中深入了解命令及其在实际中的应用,即.NET MAUI中的数据绑定构建块。

数据绑定

数据绑定是 MVVM 的核心特性,它允许 ViewModel 以松耦合的方式与视图通信,以及视图与 ViewModel 之间的通信。数据绑定允许您将 ViewModel 中的数据属性绑定到视图中的 UI 元素,例如输入字段、标签和列表视图。它用于同步视图和 ViewModel 之间的数据。当 ViewModel 中的数据发生变化时,数据绑定引擎会更新视图,反之亦然,具体取决于绑定的配置方式。这允许 UI 反映应用程序的当前状态。

绑定过程涉及三个组件:一个对象(ViewModel),一个目标对象(UI 元素),以及一个绑定表达式,该表达式指定了两个对象应该如何连接。

数据可以以不同的方向流动:从 ViewModel 到视图,反之亦然,或者两者都流动。以下是数据流动的方式:

  • 单向: 从视图模型到视图,这允许视图模型上属性的值在视图中显示。这种类型的绑定通常用于当视图模型的更新应自动更新视图上的值时。

  • 单向到源: 与单向相反,数据只从视图流向视图模型。在视图中输入的值将自动反映在视图模型上。

  • 一次性: 与单向类似,数据从视图模型流向视图,但数据绑定引擎将不会监听绑定属性上发生的任何更改。一旦初始值在 UI 上显示,该属性的任何后续更改都不会反映在视图中。这在进行大量数据绑定时可以产生显著的正面性能影响。

  • 双向: 数据以两种方式流动;从视图到视图模型,以及从视图模型到视图。对 UI 中数据的更改会自动传播回视图模型。使用双向数据绑定的常见场景是在视图中显示用户可以修改的属性。在这种情况下,该属性通常使用双向数据绑定绑定到一个输入字段。这允许属性的初始值在输入字段中显示,并且用户所做的任何更改都会自动反映到视图模型,无需额外的事件处理或手动更新。

数据绑定是一个非常强大的概念,是 MVVM 模式中的一个基本组件。它允许视图模型以简单的属性形式向视图公开数据和行为,这种方式与 UI 框架无关。通过数据绑定,这些属性可以以松耦合的方式绑定到 UI。通过使用数据绑定,MVVM 中的视图和视图模型可以无缝同步,无需任何手动代码。绑定模式决定了数据如何在视图和视图模型之间传播。此外,数据绑定还允许视图通过命令将用户输入回传到视图模型,然后视图模型可以更新模型。

更多关于数据绑定的信息

第三章**数据绑定构建块在.NET MAUI 中第四章**.NET MAUI 中的数据绑定中,详细介绍了关于数据绑定以及如何在.NET MAUI 中有效使用它的所有必要信息。

现在我们已经对 MVVM 的核心组件有了很好的理解,并对每个组件的责任有了深入了解,让我们更详细地讨论一下为什么 MVVM 很重要以及它增加了什么价值。

关注点分离很重要

关注点分离是软件开发中的一个重要原则,旨在将软件设计和实现划分为具有特定和明确责任的不同部分。这个原则通过降低每个组件的复杂性,允许更模块化和可重用的代码,帮助开发者创建更可维护和灵活的应用程序。

在实际应用中,关注点分离意味着将系统的不同方面分离并独立处理,不重叠关注点。这是通过创建具有各自明确责任和接口的独立层或模块,并最小化它们之间的依赖关系来实现的。

让我们以一家餐厅的管理系统为例。在这样的系统中,可能会有几个关注点,如餐桌预订、点餐、厨房操作和结账。根据关注点分离的原则,这些领域中的每一个都应该由一个独立的模块来处理。

每个模块都有自己的责任集,并且会通过定义良好的接口与其他模块进行通信。例如,当顾客点菜时,点餐模块会与厨房操作模块通信,以便准备菜品。

这种分离意味着,如果您需要更改餐桌预订的工作方式,您可以在不影响厨房操作或结账的情况下进行更改。这种分离使代码库更加有序,更容易维护,并允许对单个模块进行集中的测试。

让我们探讨一下关注点分离如何提高可维护性和可测试性。这两个方面对于任何软件应用的长期成功至关重要。

可维护性

通过应用 MVVM,我们正在将 UI 与业务逻辑分离,并将 ViewModel 与视图松散耦合。这极大地提高了可维护性,因为每个组件都有其独特的角色和关注点。

例如,视图可以很容易地更改或更新。UI 元素可以移动或替换为更现代的元素,而无需更改 ViewModel,只要不需要显示额外的数据。这是因为视图和 ViewModel 之间唯一的接口是通过数据绑定。因此,更新视图不应影响 ViewModel,除非需要显示额外的或更改后的数据。

此外,当视图需要显示额外的数据或 UI 元素更新需要进一步的数据时,ViewModel 可能也需要更新。然而,如果所需数据已经在 Model 中可用,ViewModel 可以在 Model 和视图之间进行转换,而不会影响 Model 本身。ViewModel 负责管理 Model 和视图之间的数据流,确保应用程序不同层之间有清晰的关注点分离。

此外,Model 业务逻辑的任何更改都不应对 ViewModel 或 View 产生重大影响。由于 ViewModel 充当 Model 和 View 之间的中介,它负责将 Model 中的数据转换为 View 使用。因此,如果任何底层业务逻辑应该发生变化,ViewModel 可以处理转换而不影响 View,确保对 Model 所做的任何更改对用户都是透明的。

即使需要更新 Model 或 ViewModel,由于 MVVM 模式的特点,这些更改可以轻松地相互独立测试,最重要的是,独立于 UI。这种分离使得测试既高效又专注。

测试性

测试性是软件开发中一个非常重要的因素。不仅测试确保测试的代码执行其预期的功能,而且还保证它继续按照最初的设计运行。当运行一组成功的测试时,如果在代码更新时不小心破坏了功能,则会立即提供反馈。这对于维护代码库的质量和稳定性无疑是至关重要的。本质上,全面的测试在保持代码的可维护性方面发挥着关键作用,允许随着时间的推移进行高效的修改和改进。

注意

通过在设计应用程序时考虑测试性,开发者可以创建更可靠、可维护和可扩展的代码。

关注点的分离对于实现测试性至关重要,因为它允许开发者隔离和测试应用程序的各个独立组件,而无需担心对系统其他部分的依赖。

从 MVVM 设计模式的视角来看,关注点的分离和测试性密切相关。由于 View、ViewModel 和 Model 被视为不同的组件,每个组件都能够独立于其他组件编写和测试。因为 ViewModel 与 View 解耦,并且对任何特定的 UI 框架都是不可知的,因此很容易为其编写自动单元测试。这些自动测试比手动或自动 UI 测试更快、更可靠,这允许开发者尽早在开发过程中识别错误和回归,在它们变得难以修复和昂贵之前。同样,Model 逻辑也可以在独立于 ViewModel 和 View 的情况下进行有效测试。

这就是理论。现在,让我们看看一个 MVVM 示例!

MVVM 实践

让我们看看一个非常简单的应用程序,它会在屏幕上显示用户的每日名言。当用户点击屏幕上的按钮时,应用程序将从 API 获取每日名言并显示在屏幕上。一旦按钮被点击,它应该被隐藏。代码-behind 方法将向您展示如何在不使用 MVVM 模式的情况下编写此应用程序,而第二个示例则展示了使用 MVVM 实现相同功能,并考虑了可测试性。

代码-behind 方法

在下面的代码片段中,没有关注点的分离;所有的代码都在 XAML 页面的代码-behind 中处理。虽然这段代码看起来似乎在执行预期的操作,但没有简单、快速或健壮的方法来测试它是否工作。这是因为所有的逻辑都在按钮的点击事件处理器中处理。

MainPage.xaml中,我们定义了一个Button和一个Label

<Grid>
    <Button
        x:Name="GetQuoteButton"
        Clicked="Button_Clicked"
        Text="Get Quote of the day" />
    <Label
        x:Name="QuoteLabel"
        HorizontalOptions="Center"
        IsVisible="false"
        Style="{DynamicResource TitleStyle}"
        VerticalOptions="Center" />
</Grid>

两个按钮和标签控件都有一个Name,这样就可以从代码-behind 中引用它们。标签默认情况下是不可见的。

在代码-behind(MainPage.xaml.cs)中,我们按照以下方式处理按钮点击事件:

private async void Button_Clicked(
    object sender, EventArgs e)
{
    GetQuoteButton.IsVisible = false;
    try
    {
        var client = new HttpClient();
        var quote = await
            client.GetStringAsync(
            "https://my-quotes-api.com/quote-of-the-day");
        QuoteLabel.Text = quote;
        QuoteLabel.IsVisible = true;
    }
    catch (Exception)
    {
    }
}

在按钮点击时,GetQuoteButton被隐藏,并调用获取名言。QuoteLabelText属性被分配了检索到的名言的值。

注意,这段代码中有一个微小的错误:如果 API 调用失败,抛出的异常会被静默捕获,但GetQuoteButton的可见性不会被恢复为可见,这会导致应用程序无法使用。但由于没有简单的方法来测试这段代码,这种情况很可能直到应用程序进入 QA 阶段才被发现,希望手动测试能够发现这个问题。

使用 MVVM

现在,让我们看看如何使用 MVVM 模式来转换这个应用程序,同时考虑到关注点的分离和可测试性。可能这个例子中的一些内容现在还不清楚,这是完全可以接受的。在阅读这本书的过程中,所有这些方面都应该会很快变得清晰。

让我们首先看看这个应用程序中的模型是什么。

模型

在我们的示例应用程序中,我们正在处理的主要数据是从 API 获取的名言。与这个 API 通信以获取数据的逻辑可以被视为模型。我们不再像之前那样直接在代码-behind 中发起 HTTP 请求,而是可以将这个逻辑封装到一个单独的类中。这个类将负责获取名言,并充当我们的模型。下面是这个类的可能样子:

public class QuoteService : IQuoteService
{
    private readonly HttpClient httpClient;
    public QuoteService()
    {
        httpClient = new HttpClient();
    }
    public async Task<string> GetQuote()
    {
        var response = await
        httpClient.GetAsync("https://my-quotes-api.com/
        quote-of-the-day");
        if (response.IsSuccessStatusCode)
        {
            return await
            response.Content.ReadAsStringAsync();
        }
        throw new Exception("Failed to retrieve quote.");
    }
}
public interface IQuoteService
{
    Task<string> GetQuote();
}

如您所见,我们创建了一个QuoteService类,其中包含从 API 获取名言的逻辑。这个类实现了一个名为IQuoteService的接口。通过定义一个接口,我们使得替换实现或为测试目的模拟此服务变得更加容易。

通过将之前在代码后部中存在的逻辑封装在一个专门的类中,这已经为我们提供了一个更清晰的关注点分离。

这涵盖了模型-视图-ViewModel 模式中的模型部分。让我们看看 ViewModel 可能的样子。

ViewModel

ViewModel 充当视图和模型之间的中介。它持有视图将绑定到的数据和命令。对于我们的简单应用程序,我们需要在 ViewModel 中包含以下内容:

  • 一个属性用于在检索到引言后持有每日引言

  • 两个属性用于控制按钮和标签的可见性

  • 一个在按钮被点击时将被触发的命令;这将负责检索引言

我们还需要有一个接受类型为IQuoteService的参数的构造函数。这就是依赖注入发挥作用的地方。

更多关于依赖注入的信息

依赖注入是使类可测试和模块化的关键,并且在 MVVM 中非常常用。第七章**,依赖注入、服务和消息传递,深入探讨了这一概念。

让我们看看这可能在代码中是什么样子:

public class MainPageViewModel : INotifyPropertyChanged
{
    private readonly IQuoteService quoteService;
    public MainPageViewModel(IQuoteService quoteService)
    {
        this.quoteService = quoteService;
    }
    public event PropertyChangedEventHandler
    PropertyChanged;
}

构造函数接受一个IQuoteService实例作为参数。这个实例被分配到类中的quoteService字段。这样,ViewModel 就可以访问引言检索服务,允许它在需要时检索引言。

还要注意,这个类实现了INotifyPropertyChanged接口,因此需要实现PropertyChanged事件。其主要目的是确保当 ViewModel 中的数据发生变化时,UI 会得到通知,这样视图和 ViewModel 就可以保持同步。第三章**,.NET MAUI 中的数据绑定构建块,对此进行了更深入的探讨!

QuoteOfTheDay是持有检索到的引言的属性。这只是一个简单的属性,持有字符串值。它“特殊”的地方在于它会触发PropertyChanged事件,以通知数据绑定引擎关于更新值的更新:

private string quoteOfTheDay;
public string QuoteOfTheDay
{
    get => quoteOfTheDay;
    set
    {
        quoteOfTheDay = value;
        PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(
            nameof(QuoteOfTheDay)));
    }
}

控制按钮和标签可见性的两个属性分别是IsButtonVisibleIsLabelVisible。同样,这些也是简单的公共属性,会触发PropertyChanged事件:

private bool isButtonVisible = true;
public bool IsButtonVisible
{
    get => isButtonVisible;
    set
    {
        isButtonVisible = value;
        PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(
            nameof(IsButtonVisible)));
    }
}
bool isLabelVisible;
public bool IsLabelVisible
{
    get => isLabelVisible;
    set
    {
        isLabelVisible = value;
        PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(
            nameof(IsLabelVisible)));
    }
}

在我们实现当用户点击按钮时应调用的命令之前,让我们首先实现该命令需要执行的逻辑:

private async Task GetQuote()
{
    IsButtonVisible = false;
    try
    {
        var quote = await quoteService.GetQuote();
        QuoteOfTheDay = quote;
        IsLabelVisible = true;
    }
    catch (Exception)
    {
    }
}

GetQuote方法是非同步的,这意味着它允许非阻塞执行,这在从网络源获取数据时尤为重要。它首先将IsButtonVisible属性设置为false,这应该会隐藏屏幕上的按钮。接下来,我们调用quoteService字段的GetQuote方法,这将出去获取一个引言。这个服务实际上是如何获取引言的并不重要,因为这不是 ViewModel 的关注点。一旦我们从quoteServiceGetQuote方法收到引言,我们就将这个值分配给QuoteOfTheDay属性。将IsLabelVisible属性设置为true,以便显示引言的标签变得可见。

最后,我们可以创建一个触发此方法的命令:

public ICommand GetQuoteCommand => new Command(async _ => await GetQuote());

通过GetQuoteCommand,我们现在可以调用 ViewModel 上定义的GetQuote方法。

在 Model 和 ViewModel 已经就绪的情况下,我们终于可以看看 View 了。

View

实际上,对 UI 的更改并不多:我们仍然需要一个按钮来触发QuoteOfTheDay的检索,以及一个标签来显示它。由于我们不再从代码背后访问标签,因此不需要设置x:Name属性:

<Grid>
    <Button
        Text="Get Quote of the day" />
    <Label
        HorizontalOptions="Center"
        IsVisible="false"
        Style="{DynamicResource TitleStyle}"
        VerticalOptions="Center" />
</Grid>

此外,我们之前在代码背后使用的Button_Clicked事件处理器可以从代码中移除。而且当我们身处其中时,我们还可以将页面的BindingContext分配给MainPageViewModel的一个实例:

public partial class MainPage_MVVM : ContentPage
{
    public MainPage_MVVM()
    {
        InitializeComponent();
        BindingContext = new MainPageViewModel(
            new QuoteService());
    }
}

BindingContext基本上是我们将要绑定的源。有了这个,我们对 XAML 进行一些最后的调整,包括数据绑定语句:

<Grid>
    <Button
        Command="{Binding GetQuoteCommand}"
        IsVisible="{Binding IsButtonVisible}"
        Text="Get Quote of the day" />
    <Label
        HorizontalOptions="Center"
        IsVisible="{Binding IsLabelVisible}"
        Style="{DynamicResource TitleStyle}"
        Text="{Binding QuoteOfTheDay}"
        VerticalOptions="Center" />
</Grid>

这些绑定语句“链接”了我们 ViewModel 公开暴露的属性到 UI 元素的属性上。当用户点击按钮时,MainPageViewModel上的GetQuoteCommand将被调用,这反过来将执行GetQuote方法。在执行GetQuote方法的同时,IsButtonVisibleIsLabelVisible属性正在被更新,从QuoteService检索到的引言将被设置为QuoteOfTheDay属性的值。通过数据绑定,这些更改将立即自动反映在 View 上。

就这样!这基本上是我们之前拥有的相同的应用程序。然而,这次它是使用 MVVM 模式编写的,同时考虑到关注点的分离和可测试性。

立即引人注目的是,MVVM 示例中的代码更多。这主要是因为 ViewModel。幸运的是,ViewModel 应该相当简单。它们不应该包含任何业务逻辑。在这个例子中,业务逻辑位于QuoteService类中,ViewModel 会调用它来获取QuoteOfTheDay值。ViewModel 上的属性用于表示 View 的状态,例如控制按钮和标签的可见性,以及保存QuoteService将返回的Quote

到现在为止,应该很清楚,这个例子中的每个部分都有自己的职责:

  • 视图层由 MainPage_MVVM 负责。它包含视觉元素,如 LabelButton,并将它们布局。

  • 在此场景中,QuoteService 的单一职责是获取一个 Quote

  • MainPageViewModel 将所有这些粘合在一起。它提供视图所需的属性和值,以及任何用于处理交互的命令。

每个组件只有一个改变的理由,这使得代码比将所有内容放在代码后部更容易维护。与之前的示例相比,不仅可维护性提高了,而且可测试性也得到了很大提升。不要只听我的话;让我们看看我们如何测试我们应用程序的功能。

测试 ViewModel

最后,让我们快速看看这对可测试性意味着什么。以下代码示例展示了 MainPageViewModel 的一些单元测试。再次强调,这里可能不是所有内容都清楚,但本书将彻底涵盖所有内容。此外,第十三章**,单元测试,完全致力于编写 ViewModels 的单元测试。在这些测试中,我们使用 IQuoteService 接口。模拟是单元测试中用来创建一个模仿真实对象行为的假或模拟对象的技术。这对于隔离正在测试的代码和消除对外部元素(如数据库或 API)的依赖特别有用。在测试类构造函数中,它在每个测试之前运行,我们创建一个新的模拟实例,该实例的 GetQuote 方法返回一个空字符串:

private Mock<IQuoteService> quoteServiceMock;
public MainPageViewModelTests()
{
    quoteServiceMock = new Mock<IQuoteService>();
    quoteServiceMock.Setup(m => m.GetQuote())
      .ReturnsAsync(string.Empty);
}

这个模拟实例可以在创建 MainPageViewModel 类的新实例时作为参数传递。这允许我们在没有任何外部依赖的情况下测试 ViewModel。

第一个片段显示了两个测试,测试 IsButtonVisible 属性的值:

[Fact]
public void ButtonShouldBeVisible()
{
    var sut = new
        MainPageViewModel(quoteServiceMock.Object);
    Assert.True(sut.IsButtonVisible);
}
[Fact]
public void GetQuoteCommand_ShouldSetButtonInvisible()
{
    var sut = new
        MainPageViewModel(quoteServiceMock.Object);
    sut.GetQuoteCommand.Execute(null);
    Assert.False(sut.IsButtonVisible);
}

前面的第一个测试检查 IsButtonVisible 属性的初始值是否为 true。我们创建一个新的 MainPageViewModel 实例,传入模拟的 IQuoteService 实例。现在我们可以使用我们的 sut 变量(系统正在测试)来进行断言,看看一切是否按预期工作。第二个测试检查一旦调用 GetQuoteCommandIsButtonVisible 属性就变为 false

下一个测试检查由注入的 IQuoteServiceGetQuote 方法返回的 Quote 值是否被设置为 QuoteOfTheDay 属性的值:

[Fact]
public void GetQuoteCommand_GotQuote_ShowQuote()
{
    var quote = "My quote of the day";
    quoteServiceMock.Setup(m =>
        m.GetQuote()).ReturnsAsync(quote);
    var sut = new
        MainPageViewModel(quoteServiceMock.Object);
    sut.GetQuoteCommand.Execute(null);
    Assert.Equal(quote, sut.QuoteOfTheDay);
}

在前面的测试中,我们定义了模拟的 IQuoteSerivceGetQuote 方法应该返回特定的值。执行 GetQuoteCommand 后,QuoteOfTheDay 属性应该具有相同的值。

最后,我们有一个不测试应用程序的“快乐路径”的测试。相反,它测试在quoteService未能检索引言后,IsButtonVisible属性是否设置为true,使用户可以再次尝试:

[Fact]
public void GetQuoteCommand_ServiceThrows_ShouldShowButton()
{
    quoteServiceMock.Setup(m =>
        m.GetQuote()).ThrowsAsync(new Exception());
    var sut = new
        MainPageViewModel(quoteServiceMock.Object);
    sut.GetQuoteCommand.Execute(null);
    Assert.True(sut.IsButtonVisible);
}

这个测试失败了,揭示了实现中的一个问题:ViewModel 中的GetQuote方法静默地处理来自IQuoteService的任何异常,但未能重新启用按钮,使应用程序处于无用的状态。即使没有运行应用程序一次,不需要部署任何其他组件,也不需要依赖于每日引言 API 的可用性,应用程序的行为实际上正在被测试,一个简单的错误已经在开发过程的早期就被识别出来了。这些测试确保应用程序按预期运行,但同时也确保它在未来保持这种运行方式。如果代码的更改会引入不同的(意外的)行为,自动化测试将失败,通知开发者他们已经破坏了某些内容,在发布应用程序之前需要修复它。像这样的单元测试非常有价值,并且非常容易编写,只要存在清晰的关注点分离,并且应用程序是以可测试性为前提编写的。MVVM 模式对此非常完美!就像这个例子一样,ViewModel 可以在完全隔离的情况下进行测试,因为它没有绑定到视图或任何特定的 UI 框架。这个 ViewModel 可以在任何类型的.NET 应用程序中工作!

与具有代码背后实现的先前的例子相比,测试变得具有挑战性:必须部署应用程序,并使用 UI 测试框架来启动应用程序,与 UI 控件交互,并验证 UI 是否显示预期的内容。自动化 UI 测试的编写、运行和维护可能很耗时。然而,您是否更愿意完全依赖手动测试和 QA,而不是利用自动化测试的优势来确保应用程序行为的质量和可靠性?

当使用 MVVM 模式时,编写单元测试来测试应用程序的不同区域变得非常容易,因为它促进了关注点的分离,并且应该是 UI 框架无关的。当所有内容都在代码背后时,通过自动化 UI 测试来测试业务逻辑会变得非常复杂,难以维护,并且很快就会出错。UI 测试有其目的,因为它们可以测试应用程序的用户界面是否按预期运行。这两种类型的测试都很重要,在确保应用程序质量方面发挥着不同的作用。但是(自动化的)UI 测试应该只做这件事:测试 UI。您的 ViewModel 和业务逻辑应该已经通过其他自动化测试进行了测试。

关于 MVVM 的常见误解

关于 MVVM 存在一些常见的误解,这些误解可能导致对其原则和最佳实践的误解。让我们消除其中的一些误解,并对该模式提供清晰的解释。

在代码背后不应该有代码

虽然 MVVM 的主要目的是将表示逻辑与应用逻辑分离,但这并不意味着代码隐藏中不应该有任何代码。代码隐藏仍然可以用来处理简单的 UI 相关事件或与视图紧密耦合的任何逻辑。

实际上,在某些情况下,将一些代码放在代码隐藏中可能比尝试将所有内容移动到视图模型中更高效、更易于维护。例如,处理 UI 动画、滚动、控制焦点或复杂的视觉行为可能比通过数据绑定实现更容易。

为了确保在 MVVM 中正确分离关注点,必须避免在视图的代码隐藏中包含业务逻辑。代码隐藏应保持最小化,并尽可能简单,以保持视图和视图模型之间的分离。

模型应该是 DTO、领域实体或 POCO

虽然在 MVVM 中使用模型的对象类型一直是一个有争议的话题,但在我看来,这并不是一个关键因素。MVVM 的主要原则是将业务逻辑从视图中移除,并在视图模型中只包含简单的验证逻辑。因此,模型可以是任何对象类型,通常是不同类型对象的组合。模型不是单一类型的事物;它是包含应用程序实体、业务逻辑、存储库等所有“在视图和视图模型之外”的内容。重要的是要记住,视图和视图模型不应包含任何业务或持久化逻辑。

视图(View)和视图模型(ViewModel)之间不应相互了解

虽然视图模型不应该了解视图以保持关注点的分离,但视图可以引用视图模型。需要注意的是,只要视图模型不依赖于视图,这并不违反 MVVM 的原则。在.NET MAUI 等平台上使用“编译绑定”可以提供显著的性能提升,但为了使它们能够工作,视图必须了解它所绑定的类型。

更多关于编译绑定的信息

想了解更多关于编译绑定的信息?第四章**,.NET MAUI 中的数据绑定,为您提供了全面的介绍。

此外,可能存在一些情况下,视图需要从代码隐藏中直接调用视图模型上的方法。在 UI 非常复杂且命令无法轻松绑定的情况下,这可能是有必要的。

MVVM 过于复杂,仅适用于大型应用程序

MVVM 本身并不一定复杂,但要熟练掌握它可能需要一些学习和实践。对于不习惯使用这种设计模式的开发者来说,理解关注点分离的概念并在 MVVM 中实现它可能有点挑战性。此外,正确设置绑定可能需要一些努力,尤其是在处理大型和复杂的视图时。然而,一旦您理解了它,您会发现开发过程变得更加简单,代码也变得更加易于维护和测试。尽管可能存在学习曲线,但在应用程序开发中采用 MVVM 仍然值得,即使是小型和简单的应用程序。这类应用程序需要随着时间的推移进行维护和更新。随着时间的推移,它们的业务逻辑也将从单元测试中受益,对吧?

话虽如此,对于具有非常简单 UI 的应用程序,例如只有几个 UI 元素并且几乎没有业务逻辑反映在 UI 上的应用程序,MVVM 可能是过度设计。然而,再次审视之前仅包含两个 UI 控件的示例,我们发现它通过应用 MVVM 设计模式受益于可测试性。

摘要

总结来说,MVVM 模式将数据、UI 和逻辑的关注点分开,这使得应用程序更容易进行测试、修改和扩展。通过使用模型来表示数据和业务逻辑,视图来向用户展示数据,以及视图模型在模型和视图之间进行调解,MVVM 模式促进了职责的清晰分离,这使得开发和维护复杂应用程序变得更加容易。此外,使用命令和数据绑定提供了一种强大的方式来处理用户输入并保持 UI 与应用程序状态同步。理解 MVVM 的组件对于构建可维护、可扩展且易于测试的 .NET MAUI 应用程序至关重要。

第二章**,什么是 .NET MAUI?中,我们将深入了解 .NET MAUI,以便您对该框架有一个良好的理解。如果您已经对 .NET MAUI 有深入的了解,您可以跳过这一章。如果您对其基础知识有所了解,这将是一个很好的复习。

第二章:什么是.NET MAUI?

为不同平台编写移动应用程序很困难,尤其是当涉及到创建在不同设备和操作系统上运行顺畅的跨平台应用程序时。.NET MAUI多平台应用程序用户界面)是一个旨在通过允许开发者构建针对 iOS、macOS、Android 和 Windows 的原生和性能卓越的跨平台桌面和移动应用程序来简化这一过程的框架——所有这些都可以从单个代码库中完成。

本章中,我们将探讨.NET MAUI 框架。为了彻底理解这个框架是什么以及它做什么,我们将讨论其核心概念、工作原理、特性和优势。我们还将查看开始构建.NET MAUI 应用程序所需的要素,安装必要的组件,以及创建新应用程序。

阅读本章后,您将对.NET MAUI 框架及其工作原理有一个扎实的理解。您还将了解如何安装必要的工具以开始构建.NET MAUI 应用程序,并能够从头开始创建新应用程序。有了这些知识,您将准备好开始使用.NET MAUI 框架开发跨平台应用程序。

本章中,我们将探讨以下主要主题:

  • .NET MAUI 概述

  • 它是如何工作的?

  • 创建您的第一个.NET MAUI 应用程序

在我们开始将 MVVM 应用于.NET MAUI 之前,了解该框架本身是至关重要的。

技术要求

本章的最后部分,创建您的第一个.NET MAUI 应用程序,将指导您完成创建.NET MAUI 应用程序所需的所有设置。

示例代码可以在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter02

.NET MAUI 概述

.NET MAUI 是微软的一个框架,用于构建针对 iOS、macOS、Android 和 Windows 的原生和性能卓越的跨平台桌面和移动应用程序,所有这些都可以从单个代码库中完成。此外,得益于三星在添加对 Tizen OS 支持方面的努力,.NET MAUI 的适用范围超出了这些主要平台。这种额外的支持为开发者提供了针对更广泛设备的机会,例如运行 Tizen 的智能电视、可穿戴设备和物联网设备。然而,为了本书的目的,我们将专注于为 iOS、macOS、Android 和 Windows 构建应用程序。使用.NET MAUI,开发者可以使用 XAML 或 C#创建原生跨平台用户界面。关键思想是在所有支持的平台上共享代码,包括业务逻辑和 UI(图 2.1)。

图 2.1:.NET MAUI 高级概述

图 2.1:.NET MAUI 高级概述

重要的是要注意,在.NET MAUI 中拥有这个共享代码库并不会限制你使用 C#访问原生平台特定 API。实际上,.NET MAUI 旨在允许开发者访问原生功能,同时保持统一的代码库。.NET MAUI 丰富的跨平台 API 库为常见任务提供了一个抽象层,这些任务通常是特定平台的。然而,当某个功能需要.NET MAUI 的跨平台 API 中不可用的原生平台 API 时,你仍然可以直接通过 C#使用原生平台 API。通过部分类、编译器指令或依赖注入等机制,.NET MAUI 确保开发者能够为他们的应用实现最佳程度的定制和功能。

回忆起 Xamarin.Forms

这可能对之前听说过或使用过Xamarin.Forms的开发者来说很熟悉。

事实上,.NET MAUI 是 Xamarin.Forms 的演变,具有许多明显和微妙的不同之处。其中一个显著的不同点是其从.NET 6 开始集成到.NET 中。这种集成意味着开发者不再需要安装额外的 NuGet 包或扩展来使用.NET MAUI,简化了设置过程。一旦安装了 MAUI 所需的.NET 工作负载,开发者就可以立即开始构建应用程序。

此外,作为.NET 的一等公民,MAUI 从 Visual Studio 中受益于改进的工具和开发者体验,使得使用该框架创建移动应用变得更加方便和高效。这种无缝集成和增强的工具支持开发者更轻松、更有效地构建跨平台应用。

但主要的核心思想保持不变:使开发者能够使用.NET 构建原生和性能卓越的跨平台应用,同时拥有单一共享的代码库,用于业务逻辑和 UI 代码。这种方法简化了开发过程,并促进了不同平台间的代码重用,同时提供了在需要时访问平台特定 API 的灵活性,确保开发者能够利用原生功能并按需定制他们的应用。

.NET MAUI 建立在 Xamarin.Forms 相同的基础原则上,但还结合了其前驱近十年的开发者经验。通过改进性能、项目结构和工具等方面,.NET MAUI 旨在简化创建跨平台应用的过程,同时保持共享业务逻辑和 UI 代码的核心思想,并允许轻松访问平台特定 API。

跨平台 UI 和更多

.NET MAUI 当然允许我们为移动和桌面应用程序创建共享 UI。该框架为我们提供了诸如GridVerticalStackLayoutAbsoluteLayout等概念,这使我们能够以许多不同的方式执行布局控制。作为开发者,你可以选择是否要在 XAML 中或代码中定义你的布局。无论你选择哪种方法,都可以实现相同的效果。此外,我们还获得了数据绑定,这是我们在上一章中学到的 MVVM 的一个基本概念。通过内置的页面类型,如FlyoutPageTabbedPageNavigationPage等,我们可以创建具有高级导航模式的应用程序。记住,所有这些最终都会转换成运行本地 UI 的原生应用!

跨平台 API

但我们不要忘记.NET MAUI 不仅仅只是 UI 相关。.NET MAUI 提供了跨平台 API,这些 API 抽象了常见任务的平台特定实现,使开发者能够使用单个统一的 API 访问原生设备功能——访问设备的指南针、地理位置、文件系统等——仅举几个例子。这使我们能够为这些任务编写平台无关的代码,进一步简化开发过程并促进不同平台间的代码重用。.NET MAUI 提供的跨平台 API 包括以下内容:

  • 设备信息: 获取设备特定的信息,例如型号、制造商、平台和操作系统版本。

  • 地理位置: 访问设备的位置服务以获取 GPS 坐标,执行地理编码和反向地理编码,并跟踪设备的移动。

  • 连接性: 确定设备的网络连接状态并监控变化。

  • 权限: 请求和管理应用程序所需的各项运行时权限,如位置、相机和存储访问。

  • 传感器: 利用设备传感器,如加速度计、陀螺仪、磁力计和气压计,来收集有关设备方向、运动和环境的数据。

  • 首选项: 存储和检索用于应用程序设置和用户首选项的简单键值数据。使用安全存储API 来存储需要安全的数据。

  • 启动器: 使用该应用的 URI 方案启动另一个应用。如果您想使用操作系统的默认浏览器打开网站,还可以使用浏览器API。

还有许多许多其他功能!

那么,Xamarin.Essentials 呢?

Xamarin.Essentials 是一个开源库,旨在为移动应用程序创建跨平台的 API,作为常见平台特定任务的抽象层。随着 Xamarin.Forms 向.NET MAUI 的演变,这些 API 现在更无缝地集成到框架本身中。这意味着在.NET MAUI 应用程序中不需要 Xamarin.Essentials NuGet 包。

跨平台生命周期事件

.NET MAUI 引入了统一的 app 生命周期,这简化了跨平台应用状态的管理。在传统的 Xamarin.Forms 开发中,每个平台都有自己的生命周期事件和模式,这有时会导致在处理跨平台场景时出现不一致性和复杂性增加。

通过Window类,我们现在可以使用单一的事件集来响应我们应用程序的生命周期,无论目标平台是什么。

订阅这些事件的最简单方法是通过在App类中的CreateMethod获取应用程序窗口的引用,然后订阅相关的事件。

这些事件使我们能够以一致的方式处理所有支持平台上的几个生命周期事件。根据我们的需求或场景,我们可以使用这些事件处理器,例如,在Stopped事件处理器中停止长时间运行的过程,或者在Resumed事件中刷新当前页面的数据。在银行应用程序中,我经常看到当应用程序被置于后台或没有获得焦点时,其 UI 会变得模糊或模糊不清。这通常可以通过使用DeactivatedActivated事件以跨平台的方式进行处理。

与.NET MAUI 一样,这些抽象不应阻止您访问平台 API 或执行特定平台的事情。如果您需要响应某个特定平台的生命周期事件,您仍然可以这样做。通过MauiAppBuilderConfigureLifecycleEvents扩展方法,我们可以在底层平台的生命周期事件上定义应该被调用的委托:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    return builder
        .UseMauiApp<App>()
        .ConfigureLifecycleEvents(lifecycle =>
        {
        ...
        }).Build();
}

例如,如果我们想让我们的应用程序通过自定义 URI 方案激活,我们可能希望拦截触发应用程序打开的 URL 并对其做出反应。要获取该 URL,只能在每个平台的特定生命周期事件上完成:Android 上的OnCreate方法和 iOS 上的OpenUrl。我们可以分别在MainActivityAppDelegate中重写这些方法,就像我们以前使用 Xamarin.Forms 一样。或者,我们可以使用前面提到的ConfigureLifecycleEvents钩入这些平台事件:

.ConfigureLifecycleEvents(lifecycle =>
{
#if ANDROID
    lifecycle.AddAndroid(android =>
    {
        android.OnCreate((activity, bundle) =>
        {
            //Get url activity.Intent.Data
        });
    });
#elif IOS
    lifecycle.AddiOS(ios =>
    {
        ios.OpenUrl((app, url, options) =>
        {
            return true;
        });
    });
#endif
})

与在平台特定的类和文件中分散这种类型的代码相比,我认为前面的解决方案要优雅得多,因为所有内容都在一个地方。我认为这大大提高了代码的可读性和可维护性!

单个项目结构(多目标)

.NET MAUI 还引入了一种全新的单项目结构。与每个目标平台都有一个项目以及一个共享代码项目不同,使用.NET MAUI,我们可以从单个项目构建针对不同平台的应用程序。不仅共享代码,还包括平台特定的实现、资源,如图像、字体和应用程序图标,都直接包含在这个单一的项目中。

这得益于 MSBuild,它是 .NET 和 .NET MAUI 所使用的构建系统。通过多目标构建,我们基本上可以将所有代码放在一个项目中,并定义项目应该为哪些平台构建。这些目标平台在项目的 csproj 文件中定义。在 .NET MAUI 中,默认情况下看起来是这样的:

<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-
  maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform
  ('windows'))">$(TargetFrameworks);net8.0-
    windows10.0.19041.0</TargetFrameworks>

因此,对于一个标准的 .NET MAUI 项目,这意味着我们针对的是 Android、iOS、Mac Catalyst 和 Windows – 后者仅在 Windows 上运行时。

在编译时,MSBuild 会为所有配置的平台构建,只取与它编译的平台相关的源文件,并根据指定的目标框架处理依赖项。换句话说:一个项目会产生多个编译库或应用,这些库或应用本身只包含为编译平台相关的源代码。但 MSBuild 如何知道要取哪些特定平台的文件呢?有几种方法可以通知构建系统要取哪些源文件:

  • 在你的代码中使用 #if, #elif, #else, 和 #endif 来条件编译特定平台的代码块。这些指令允许你在单个源文件中包含特定平台的代码,从而更容易地在不同平台间共享代码,并减少对多个文件的需求。例如,被 #if ANDROID#endif 包围的代码只有在明确编译为 Android 时才会被包含和编译。关于处理特定平台生命周期事件的先前代码示例中也包含这些指令,因为它们所包围的代码仅与特定平台相关,并使用该平台特定的 API。

  • 当为相应平台编译时,MSBuild 只会包含 Platforms/AndroidPlatforms/iOSPlatforms/WindowsPlatforms/MacCatalyst 中的内容。这允许我们,在不使用预处理器指令的情况下,在这些文件夹中编写访问原生 API 的特定平台代码。这一点可以从查看默认的 Platforms/iOS 中的 Program 类中得到证明,其中使用了 UIKit.UIApplication 对象,这是一个特定于 iOS 的对象。为了类比,Platforms/Android 中的 MainActivity 类使用了 Android.App.ActivityAttribute,这是特定于 Android 的。

    不仅这些特定平台的文件夹包含特定平台的代码,还包含特定平台独有的资源 – 例如 Platforms/Windows 中的 Package.appxmanifestPlatforms/MacCatalyst 中的 Info.plist

  • 你可以将这些与特定平台的文件夹结合使用在 csproj 文件中。

    请查看 learn.microsoft.com/dotnet/maui/platform-integration/configure-multi-targeting 以获取有关如何配置基于文件名的多目标配置以及如何将其与特定平台文件夹结合使用的详细方法。您想使用的方法主要是个人偏好,完全取决于您和您的团队来决定。

在 Visual Studio 中,当处于 .NET MAUI 项目内部时,还有一个额外的下拉菜单可用,称为 平台选择器,允许您选择一个目标平台。默认情况下,当在 C# 代码文件中时,此下拉菜单位于代码编辑器的右上角 (图 2.2):

图 2.2:平台选择器

图 2.2:平台选择器

在此下拉菜单中选择一个条目不会影响编译。所选平台的作用是为该特定平台配置开发环境。这意味着,当使用预处理器指令时,它会显示特定平台的代码,并确保智能感知和代码导航功能调整以在特定平台的代码上工作。

图 2.3 展示了 IDE 如何将所选目标平台(Android)中未编译的代码变灰。

图 2.3:Android 作为选定的目标平台

图 2.3:Android 作为选定的目标平台

在所选配置中,被 #if ANDROID 包围的代码将被编译;然而,iOS 特定的代码将被忽略。此外,即使在 IDE 中 iOS 部分有错误(OpenUrls 方法不存在,因为 OpenUrl 是正确的名称),我们也不会得到红色的波浪线。只有在将目标平台在 平台选择器 下拉菜单中选为 iOS 时,此错误才会变得可见 (图 2.4).

图 2.4:iOS 作为选定的目标平台

图 2.4:iOS 作为选定的目标平台

通过选择 iOS 作为构建目标,所有特定于 iOS 的代码都会亮起,而其他平台的特定代码则会变灰。

此下拉菜单在编写特定平台的代码时非常有帮助!

注意

总结来说,.NET MAUI 是 一个用于构建移动和桌面设备的跨平台原生应用的现代框架。它支持 Android、iOS、macOS 和 Windows。它提供了一个共享的 UI 层,包含丰富的控件,跨平台的常见任务 API,不同平台间统一的应用生命周期事件,以及简化开发和部署的单个项目结构。

它是如何工作的?

既然我们已经对 .NET MAUI 有了一个很好的理解,你可能会想知道这实际上是如何工作的。这段 .NET 代码最终是如何变成在不同平台上具有本地 UI 的原生应用的?这并不是魔法,但要理解它是如何工作的,我们需要看看它的内部结构。

使用 .NET 创建原生应用

在编译时,为每个选定的平台创建原生应用程序。.NET 基类库BCL)的必要部分,其中包含 .NET 数据类型、接口和库,被嵌入到原生应用程序中,并针对目标平台进行定制。BCL 依赖于 .NET 运行时来为您的应用程序代码创建执行环境。对于 Android、iOS 和 macOS 平台,Mono 运行时作为 .NET 运行时实现,为执行环境提供动力。同时,在 Windows 上,.NET CoreCLR 负责为您的应用程序提供运行时环境。

这种机制并不仅限于 .NET MAUI。这实际上是 .NET for Android.NET for iOS.NET for MacWinUI 的基础。这些听起来可能像是新事物,但实际上,这些是我们之前可能知道的新名字,即 Xamarin.AndroidXamarin.iOSXamarin.Mac

.NET for Android、.NET for iOS 和 .NET for Mac 提供了对平台特定 API 的绑定,使开发者能够使用熟悉的 .NET 结构访问特定的功能和控件。在 Android 上,当应用程序编译时,.NET 代码与 Mono 运行时一起打包,并在 Android 设备上使用 即时编译JIT)执行。由于苹果对 JIT 编译的限制,.NET for iOS 应用程序使用 提前编译AOT)编译,将 .NET 代码转换为在 iOS 设备上直接运行的本地 ARM 代码。

WinUI 是一个使用 C# 和 XAML 构建 Windows 应用程序的现代、原生 UI 框架,它基于 .NET。这是 Windows UI 库的最新版本,它提供了一套 UI 控件、样式和功能,用于构建流畅且高性能的 Windows 应用程序。它使用 .NET CoreCLR 作为执行环境,而不是 Mono 运行时。

这些框架中的每一个都可以用来创建使用 .NET 的原生应用程序,利用每个平台特有的 UI 模式和范式来构建 UI。

小贴士

尽管 .NET for Android 和 .NET for iOS 提供了一种共享大量代码的方法,但为每个平台创建和维护具有原生 UI 的应用程序仍然可能非常具有挑战性。开发者需要精通每个平台的具体细节,并深入了解它们之间的差异。这可能导致更高的开发成本、更长的上市时间和增加的维护工作量。

.NET MAUI,另一个抽象层

正是在这里,.NET MAUI 出现了。它提供了一种方便且高效的方法,使用单个代码库为多个平台创建真正的原生移动应用程序。.NET MAUI 在之前提到的 .NET 平台上添加了一个抽象层,使我们能够为它们构建共享的 UI (图 2**.5)。

图 2.5: .NET MAUI 架构

图 2.5: .NET MAUI 架构

您的 .NET MAUI 应用中的代码主要与 .NET MAUI API 交互,该 API 又反过来与原生平台 API 进行通信。此外,您的 .NET MAUI 代码可以直接访问特定平台的 API,以利用独特的平台功能或自定义。

让我们探索将跨平台定义的 UI 转换为特定于每个平台的原生 UI 的过程。

从虚拟视图到原生视图

在 .NET MAUI 中定义 UI 时,平台无关的 控件虚拟视图 在运行时会映射到每个平台的原生 UI 元素或 原生视图。.NET MAUI 控件的定义通常是它在所有支持平台上表示的原生控件的共同分母。在某些情况下,特定平台的功能通过特定平台的扩展提供。

但我们如何从虚拟视图转换到原生视图呢?

.NET MAUI 中引入的 处理程序 架构负责将虚拟视图映射到每个支持平台的原生 UI 元素。处理程序是轻量级且性能良好的组件,它们取代了在 Xamarin.Forms 中使用的传统渲染器。每个 MAUI 控件都有相应的处理程序,负责在特定平台上创建、更新和管理原生 UI 元素。处理程序处理属性更改、事件和平台特定的自定义,将共享的 MAUI 控件代码转换为特定平台的原生控件和行为。

图 2.6 展示了 Microsoft.Maui.Controls.Button(虚拟视图)实例如何映射到特定的原生视图:

  • 在 iOS 上,处理程序将按钮映射到 UIKit.UIButton。由于 .NET MAUI 利用 Mac Catalyst 创建原生 macOS 应用程序,因此 macOS 也使用相同的 UIKit.UIButton。Mac Catalyst 是苹果开发的一项技术,允许开发者利用相同的项目和源代码在 iOS、iPadOS 和 macOS 上创建原生应用。

  • 在 Android 上,按钮是通过处理程序映射到 AndroidX.AppCompat.Widget.AppCompatButton 实例。

  • 当映射 .NET MAUI 按钮时,Windows 上的处理程序创建 Microsoft.UI.Xaml.Controls.Button 实例。

图 2.6 所示,ButtonHandlers 没有直接引用 Button 实现;同样,Button 实现也没有引用 ButtonHandlers。相反,每个控件都实现了一个抽象控件的接口。处理程序通过特定于控件的接口访问,例如 IButton 用于 Button

图 2.6:.NET MAUI 的处理程序架构

图 2.6:.NET MAUI 的处理程序架构

我们已经讨论了很多关于 映射 的内容,但本质上,这正是这些处理器中发生的事情。处理器有一个或多个 PropertyMapper 并允许传递额外的参数并不是巧合。处理跨平台事件,例如 ScrollView 上的 ScrollTo,就是一个例子。为了使原生视图滚动到请求的位置,位置本身需要作为参数传递给处理器。这样,处理器就可以使用这个参数值在原生视图中调用必要的操作。

与 Xamarin.Forms 的渲染器实现相比,处理器的架构提供了几个优点:改进的性能、更容易的自定义和更好的可维护性,这使得它在从 Xamarin.Forms 过渡到 .NET MAUI 时成为一个重要的改进点。

现在我们已经涵盖了大量的理论,并且您了解了底层的工作原理,是时候卷起袖子开始创建一些有形的东西了。我知道您迫不及待地想要深入其中,那么让我们开始构建我们的第一个 .NET MAUI 应用程序!

创建您的第一个 .NET MAUI 应用

让我们创建我们的第一个 .NET MAUI 应用!在我们开始编写任何代码之前,我们需要通过安装一些组件来设置我们的机器。所以,让我们一起走过这些初始设置步骤,看看我们有哪些选择。一旦设置完成,我们就会进入令人兴奋的部分:从头开始逐步创建一个 .NET MAUI 应用。

设置您的机器

开始使用 .NET MAUI 进行开发相当简单,尤其是如果您使用 Visual Studio 作为您的 IDE。即使您不想使用 Visual Studio,安装过程也应该相当直接。

关于 .NET SDK 版本和工作负载

.NET MAUI 从 .NET 6 开始提供,在撰写本书时,.NET 8 是最新的版本。需要注意的是,每个 .NET 版本都有一项特定的支持策略:有提供 3 年免费支持和补丁的长期支持LTS)版本,以及提供 18 个月免费支持和补丁的标准支持STS)版本。然而,对于 .NET MAUI 来说,这并不适用。微软需要确保 .NET MAUI 支持所有受支持平台上的最新和最优秀的 API。新功能和改进将主要针对下一个版本的 .NET 开发,其中一些可能会回滚到当前版本。因此,当涉及到 .NET MAUI 时,支持策略与一般的 .NET 支持策略不同。最好使用可用的最新版本的 .NET,这样您就可以访问最新的平台 API 和性能最佳的 .NET MAUI 版本,无论它是 LTS 还是 STS 版本。

.NET 6 引入了 工作负载 的概念。工作负载是一组工具、模板和库,用于特定的开发场景或目标平台。当安装 .NET MAUI 工作负载时,我们正在安装构建跨平台原生应用程序所需的全部内容。它将安装 .NET MAUI 类库、构建和运行时组件、特定平台的 SDK 和工具、项目模板等。

.NET MAUI 工作负载可以通过两种方式安装和管理:使用 Visual Studio 安装程序或如果您选择不使用 Visual Studio,则通过命令行。

小贴士

为了确保您的 .NET MAUI 工作负载保持稳定状态,重要的是坚持以下选项之一:您要么使用 Visual Studio 安装并从那里管理它,要么从命令行进行操作。不要混合使用两种方法!

安装 Visual Studio 和 .NET MAUI 工作负载

当使用 Visual Studio(2022 17.3 或更高版本)时,我们可以使用 Visual Studio 安装程序来安装 .NET MAUI 工作负载。这是迄今为止安装和管理您的工作负载最简单的方法。.NET MAUI 开发支持所有版本的 Visual Studio:从免费的社区版到付费的企业版。

如果您的计算机上尚未安装 Visual Studio,您首先需要通过访问 visualstudio.microsoft.com/downloads/ 下载 Visual Studio 安装程序。一旦安装程序下载完成,启动它,并选择 安装 (图 2.7)。

图 2.7:安装 Visual Studio

图 2.7:安装 Visual Studio

或者,如果您已经安装了 Visual Studio,您可以启动 Visual Studio 安装程序并选择 修改 (图 2.8)。

图 2.8:修改 Visual Studio

图 2.8:修改 Visual Studio

无论您是在安装 Visual Studio 还是修改它,在下一个对话框中,您可以选择要安装的工作负载。如图 图 2.9 所示,这是我们需要检查 .NET 多平台应用程序 UI 开发 的地方。

图 2.9:安装 .NET MAUI 工作负载

图 2.9:安装 .NET MAUI 工作负载

这将安装所有必要的组件,以便您能够构建 .NET MAUI 应用程序。

在 Visual Studio 新鲜安装后,启动它,并选择 继续不使用代码 (图 2.10)。

图 2.10:不使用代码启动 Visual Studio

图 2.10:不使用代码启动 Visual Studio

为了能够在 Android 模拟器或 iOS 模拟器上调试,我们需要安装或配置以下内容:

  • 前往 工具 | Android | Android 设备管理器。从这里,您可以添加您可以使用来部署您的 .NET MAUI 应用程序的 Android 模拟器。

  • 工具 | iOS | 连接到 Mac,您可以按照步骤连接到您网络中的 Mac。连接到 Mac 后,您可以在 iOS 模拟器或连接到您的 Mac 的物理设备上调试您的应用。

热重启

当然,您也可以在连接到您的 PC 的物理设备上进行调试。这在 iOS 设备上也是可能的!使用 Visual Studio 热重启,您可以直接在 iPhone 上进行调试,而无需将 Mac 连接到 Windows 机器。唯一的限制是您需要一个 Apple 开发者账号、一个活跃的 Apple 开发者计划 注册(这是付费的),并且需要在您的 PC 上安装 iTunes。一旦您将 iOS 设备连接到您的计算机并在 Visual Studio 中将其选为调试目标,Visual Studio 将引导您设置热重启,这需要您输入您的 Apple 开发者账号详细信息。我建议您访问 learn.microsoft.com/dotnet/maui/ios/hot-restart 以获取如何设置 iOS 上热重启的逐步指南。虽然这对于调试来说很棒,但为了发布 iOS 或 macOS 应用,您仍然需要一个 Mac 来构建和签名您的应用!

使用命令行安装 .NET MAUI 工作负载

或者,如果您选择的是除 Visual Studio 以外的其他 IDE,您需要通过命令行手动安装 .NET MAUI 工作负载。

但在安装 .NET MAUI 之前,您需要确保您的机器上已安装 .NET。您可以通过运行以下命令来完成此操作:

dotnet --list-sdks

这应该会为您提供所有已安装 .NET SDK 的概述。如前所述,.NET 6 是进行 .NET MAUI 开发所需的最低 .NET SDK。如果前面的命令失败,如 图 2**.11 所示,这意味着 .NET 尚未安装到您的机器上。您应该访问 dotnet.microsoft.com/download/dotnet,选择 .NET 的最新版本,并下载适当的安装程序或二进制文件。

图 2.11: 'dotnet' 不可识别

图 2.11: 'dotnet' 不可识别

一旦在您的机器上安装了最新的 .NET 版本,您可以通过以下命令通过命令行安装 .NET MAUI 工作负载:

dotnet workload install maui

这将安装构建跨平台应用所需的全部内容,使用您喜欢的 IDE 通过 .NET MAUI 进行开发。

注意,这不会安装任何用于管理 Android 模拟器的额外工具,例如,也不会提供开箱即用的工具,让您轻松连接到 Mac 或部署到 iPhone。

安装 Visual Studio Code 和 .NET MAUI 扩展

如果您更喜欢使用 Visual Studio Code,无论是在 Windows、macOS 还是甚至 Linux 上,都有一些好消息。.NET MAUI 扩展C# 开发工具包扩展 一起,为您提供开始使用 Visual Studio Code 创建 MAUI 应用所需的一切。支持的目标平台取决于您运行的操作系统。在 Windows 上,您可以创建 Windows 和 Android 应用;在 macOS 上,您可以开发除 Windows 之外的所有应用;在 Linux 上,您只能创建 Android 应用。让我们看看如何将 Visual Studio Code 配置好以创建 .NET MAUI 应用:

  1. code.visualstudio.com/ 安装 Visual Studio Code。从 maui 安装 .NET MAUI 扩展,如图 2.12* 所示:

图 2.12: .NET MAUI 扩展

图 2.12: .NET MAUI 扩展

  1. 一旦安装了扩展,Visual Studio Code 中就会弹出 欢迎 页面:

图 2.13: .NET MAUI 扩展欢迎页面

图 2.13: .NET MAUI 扩展欢迎页面

这个 欢迎 页面会指导您完成所有额外的步骤,以使您的环境启动并运行。它涵盖了从安装 .NET MAUI 工作负载到下载和安装 Microsoft OpenJDK 的所有内容,这对于构建和调试 Android 应用至关重要。此外,它还告诉您在 Mac 上安装 Xcode 和 Xcode 命令行工具,这对于构建和调试 iOS 和 macOS 应用是必需的。

现在我们已经将首选的开发环境全部设置好,让我们创建一个 .NET MAUI 应用!请注意,在本书中,说明流程与 Visual Studio 中的流程相同。然而,在您选择的 IDE 中跟随应该不会成问题。

“食谱!”应用

在本书的整个过程中,我们将致力于开发 食谱! 应用。通过这个应用,用户可以添加他们喜欢的食谱并与全世界分享,允许其他用户对其进行评分和评论。

因此,让我们继续创建一个 .NET MAUI 应用,它将作为我们 食谱! 应用的起点。

创建新项目

随着兴奋感的增加,我们即将迈出开发 食谱! 应用的第一步。为了开始,我们将在 Visual Studio 中创建一个新项目:

  1. 启动 Visual Studio 并选择 创建新项目,如图 2.14* 所示:

图 2.14: 创建新项目

图 2.14: 创建新项目

  1. 接下来,在顶部的搜索栏中输入 maui 以查询列表。

图 2.15: 选择 .NET MAUI 应用模板

图 2.15: 选择 .NET MAUI 应用模板

  1. 接下来,输入 项目名称位置解决方案名称 的相关详细信息。

图 2.16: 配置您的新的项目

图 2.16: 配置您的新的项目

  1. 在最后一页,选择.NET 8(长期支持)或任何其他你想要的目标.NET 框架。

  2. 点击创建按钮。

这将在指定位置创建一个包含提供名称的.NET MAUI 项目的解决方案,如图图 2.17所示。:

图 2.17:.NET MAUI 项目

图 2.17:.NET MAUI 项目

运行你的应用程序

一开始,你就可以通过按F5键或在 Visual Studio 中点击运行按钮来运行你全新的跨平台应用程序。运行按钮旁边应该有Windows Machine字样,表示你将原生在 Windows 上运行你的应用程序。如果按钮没有显示Windows Machine,你可以点击向下箭头以获取更多选项,如图图 2.18所示。18*:

图 2.18:选择调试目标

图 2.18:选择调试目标

当你第一次调试 Windows 应用程序时,Visual Studio 可能会提示你启用 Windows 中的开发者模式。之后,你那闪亮的新应用程序应该已经在 Windows 上部署并运行了!

我们也可以立即在 Android 上进行调试:点击Windows Machine旁边的向下箭头,选择Android 模拟器,并从列表中选择一个模拟器。如果你是第一次运行 Android 应用程序,你将在 Visual Studio 的错误列表中看到一个错误消息,提示你接受 Android SDK 许可。双击该消息将打开Android SDK – 许可协议,你应该接受它以继续。再次按F5键,或在 Visual Studio 中点击包含你的模拟器名称的运行按钮。第一次启动模拟器并部署可能需要一些时间,但过一段时间后,你将看到一个带有你的原生 Android 应用程序运行的模拟器弹出。

相比之下,如果你连接到 Mac,你也可以选择 iOS 模拟器,并看到你的原生 iOS 在 iOS 模拟器上运行!如果你有连接到你的 PC 的 Android 或 iOS 设备,它也应该出现在列表中。选择它并运行应用程序应该会将你的应用程序部署到你的物理设备上。

.NET 热重载和 XAML 热重载

通过.NET 热重载,开发人员可以在应用程序运行时对其源代码进行更改,而无需暂停或重新构建应用程序。它使迭代更快,并能够实时反馈代码更改。我们为什么不趁应用程序运行时对我们的代码进行一些更改呢?

  1. 打开MainPage.xaml.cs文件。

  2. OnCounterClicked方法中,将count++改为count += 2

    保存你的更改或手动在 Visual Studio 中点击热重载Alt + F10)。在这个按钮的下拉菜单中,你还可以找到一个文件保存时热重载设置,你可能想要检查一下。勾选这个设置应该会在你保存更改时自动触发热重载

图 2.19:热重载

图 2.19:热重载

  1. 返回运行中的应用程序并点击按钮,您应该会看到每次点击时计数器增加两个。

此外,使用 XAML 热重载,我们甚至可以在应用程序运行时更新 XAML 代码,并立即看到更新的 UI。因此,当我们的应用程序正在运行时,让我们更新屏幕上的某些内容:

  1. 在您选择的平台运行您的应用程序。

  2. 当应用程序正在运行时,转到 Visual Studio 并打开 MainPage.xaml

  3. 将第一个 LabelText 属性从 "Hello, World" 更改为 "Recipes!"

    <Label
        FontSize="32"
        HorizontalOptions="Center"
    Text property of the second Label to "Find your next favorite dish.":
    
    

    <Label

    FontSize="18"

    HorizontalOptions="Center"

    将按钮的 BackgroundColor 属性设置为 "#FCB64A",并将 TextColor 属性设置为 "white":

    <Button
        x:Name="CounterBtn"
        BackgroundColor="#F8B146"
        Clicked="OnCounterClicked"
        HorizontalOptions="Center"
        Text="Click me"
        TextColor="white" />
    
    
    
  4. 保存您所做的更改,您应该会立即看到运行中的应用程序中的 UI 变更。

.NET 热重载和 XAML 热重载是出色的工具,因为它们避免了在应用程序进行小幅度增量更改时需要停止、重新构建和重新部署应用程序的需要。

添加启动画面和应用程序图标

.NET MAUI 的单项目方法使得能够管理该单个项目中的所有应用程序资源(如图像、图标、字体等)。在编译期间,相关 Platforms 文件夹内的所有资源以及 Resources 文件夹内的所有内容都会一起被选中。所有这些都会嵌入到生成的原生应用程序中。

因此,如果您愿意,可以添加一个 Platforms 子文件夹。同样适用于 Platforms 子文件夹。但在 MAUI 中,有一个更简单的方法:.NET MAUI 可以为我们生成启动画面和应用程序图标。让我们看看如何:

  1. splash.svg 文件中。

  2. 右键点击 Splash 文件夹,选择 Chapter 02/Assets/recipes-logo.svg 文件,该文件位于本章开头共享的代码仓库中。

  3. 将文件重命名为 splash.svg 并将 Build Action 设置为 MauiSplashScreen

  4. 通过在 csproj 文件中点击您的 MAUI 项目,打开项目的 csproj 文件,找到 MauiSplashScreen 标签。您可以为启动画面定义所需背景颜色并添加一个 Color 属性。给定的 svg 将以定义的背景色为中心显示在启动画面上:

    <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F8B146" />
    
  5. 清理并重新构建您的解决方案,然后再次运行您的应用程序。现在您应该会看到更新的启动画面,如图 图 2.20 所示:

图 2.20:iOS 和 Android 上的 Recipes! 应用程序启动画面

图 2.20:iOS 和 Android 上的 Recipes! 应用程序启动画面

注意

当您通过在 Windows PC 上使用热重启部署到物理 iOS 设备时,您的启动画面将不会更新,并且将保留标准的 .NET 启动画面。尽管您已正确配置了自定义启动画面,但这仍然是热重启的限制。为了验证您的启动画面,最好将其部署到连接到 Mac 的物理设备上。

不仅 .NET MAUI 能够为我们生成启动画面,它还可以生成我们的应用图标!以下是方法:

  1. appicon.svgappiconfg.svg 文件中。

  2. 右键单击 Resources 文件夹,选择 Chapter 02``/Assets/recipes-appicon.svg 文件。

  3. 将文件重命名为 appicon.svg 并将 构建操作 设置为 MauiIcon

  4. 查看项目的 csproj 文件,寻找 MauiIcon 标签。添加一个 Color 属性:

    <MauiIcon Include="Resources\AppIcon\appicon.svg" Color="#F8B146" />
    

清理并重新构建你的解决方案,以确保你的应用图标在设备上显示。在部署之前,你可能还想从你的设备上删除应用。一旦部署了你的应用,你应该能看到你的更新后的应用图标:

图 2.21:Recipes! 应用在 Android、Windows 和 iOS 上的图标

图 2.21:Recipes! 应用在 Android、Windows 和 iOS 上的图标

就这样!我们付出了很少的努力,就创建了一个包含启动画面和应用图标的跨平台应用。

摘要

在本章中,我们提供了 .NET MAUI 的概述:它是什么,它是如何工作的,以及如何使用 .NET MAUI 创建跨平台应用。我们详细介绍了创建应用的过程,包括启动画面和应用图标。此外,我们还探讨了 .NET 热重载和 XAML 热重载,这些功能使我们能够在应用运行时更新代码,大大提高了我们的效率。现在,你已经对 .NET MAUI 和 MVVM 设计模式有了全面的理解,我们可以继续我们的旅程,探索如何在 .NET MAUI 框架中有效地应用此模式。

第三章 中,我们将探讨 .NET MAUI 中可用的数据绑定构建块,这些构建块使我们能够使用 MVVM 模式构建跨平台应用。

进一步阅读

要了解更多关于本章所涉及主题的信息,请查看以下资源:

第三章:.NET MAUI 中的数据绑定构建块

在前面的章节中,我们熟悉了 MVVM 模式的核心概念,并探讨了.NET MAUI 的基础知识。在了解了 MVVM 原则和.NET MAUI 的能力之后,我们现在可以开始探讨如何将 MVVM 应用于.NET MAUI。

数据绑定是.NET MAUI 中的关键组件,它是 MVVM 模式的关键推动者。在本章中,我们将重点关注促进.NET MAUI 中数据绑定的基本概念、组件和技术。这些关键元素连接了应用程序的视图和 ViewModel 层,实现了高效的通信并确保了关注点的清晰分离。

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

  • 数据绑定的关键组件

  • 绑定模式和INotifyPropertyChanged接口

  • 处理与ICommand接口的交互

到本章结束时,你将深入理解.NET MAUI 附带的基本数据绑定构建块。这将帮助你理解.NET MAUI 中数据绑定的内部工作原理以及每个组件的作用。有了这个基础,你将准备好探索下一章中更高级的主题和技术。

技术要求

在本章中,我们将向Recipes!应用添加功能。所有必要的资源,包括本章中使用的所有代码,都可以在 GitHub 上找到,网址为github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter03

数据绑定的关键组件

让我们首先关注使.NET MAUI 中的数据绑定成为可能的核心组件:BindableObjectBindablePropertyBindingContext。这些组件协同工作,建立并管理您的视图和 ViewModel 之间的连接。理解这些元素的作用和功能至关重要,因为它们构成了.NET MAUI 中数据绑定的骨架。

让我们快速讨论在数据绑定中起作用的元素。

数据绑定元素

在我们深入关键组件之前,让我们回顾一下为了在.NET MAUI 应用程序中有效地使用数据绑定,我们需要理解哪些元素。这些元素在促进视图和 ViewModel 层之间的通信中发挥着至关重要的作用,使得数据和使用者交互的无缝同步成为可能:

  • INotifyPropertyChanged接口。此接口确保每当 ViewModel 中的数据发生变化时,视图都会收到通知,从而允许 UI 相应地更新。重要的是要理解,实现INotifyPropertyChanged接口并非使属性作为绑定源所必需的。实际上,任何属性,无论其封装类是否实现了INotifyPropertyChanged,都可以作为绑定源。

  • 你可能想要连接到绑定源的 UI 元素(或另一个BindableObject)上的BindableProperty。在.NET MAUI 中,大多数 UI 元素,如标签、文本框和按钮,都从Microsoft.Maui.Controls.BindableObject类派生,这使得它们能够作为绑定目标。与绑定源不同,并非每个属性都可以作为绑定目标,只有从BindableObject派生的类上的BindableProperty类型属性才能作为绑定目标。

  • 绑定上下文:绑定上下文建立了视图模型和视图之间的关系。它作为数据绑定引擎的参考点,提供对视图模型实例的连接。绑定上下文通常在页面级别或单个 UI 元素上设置。默认情况下,子元素从其父元素继承上下文。

  • 绑定路径:绑定路径是一个表达式,指定了需要绑定到绑定目标的绑定源属性。在最简单的情况下,绑定路径指的是视图模型中的单个属性名,但它也可以包括更复杂的表达式,例如属性链或索引器。绑定路径和绑定上下文的组合构成了绑定源。

  • 绑定模式:绑定模式决定了绑定源和目标之间数据流的方向,正如我们在第一章“什么是 MVVM 设计模式?”中看到的,它决定了数据流的流向。

  • 值转换器:值转换器修改 ViewModel 和视图之间的数据,反之亦然。它允许我们转换正在绑定的数据值,当 ViewModel 的数据类型与视图中的 UI 元素期望的数据类型不匹配时,特别有用。

在本节和第四章“.NET MAUI 中的数据绑定”中,将全面讨论数据绑定的各个方面。

让我们先看看使.NET MAUI 中的数据绑定成为可能的核心组件。

BindableObject

在.NET MAUI 中,Microsoft.Maui.Controls.BindableObject类是利用数据绑定的对象的基类。它通过实现与绑定过程相关的必要属性、方法和事件,为通过 UI 元素和其他对象启用数据绑定提供了基础。实际上,它是.NET MAUI 中数据绑定功能的核心,也是应用 MVVM 模式的关键组件。它使我们能够连接视图和视图模型,使它们能够相互通信并保持同步,而无需直接耦合。

.NET MAUI 中的大多数 UI 元素,如标签、按钮和文本框,都从BindableObject类派生。这种继承使得这些元素能够参与数据绑定。

从本质上讲,BindableObject存储了Microsoft.Maui.Controls.BindableProperty的实例并管理BindingContext。这些是启用有效数据绑定的重要方面,从而促进了视图和视图模型之间的无缝通信,促进了 MVVM。

可绑定属性

BindableProperty是一种特殊的属性,可以作为绑定目标。它基本上是一个高级 CLR 属性,如果你愿意的话,就是一个“强化”的属性。最终,可绑定属性充当存储与属性相关的数据的容器,例如其默认值和验证逻辑。它还提供了一个机制来启用数据绑定和属性更改通知。它与一个公共实例属性相关联,该属性作为获取和设置可绑定属性值的接口。在.NET MAUI 中,许多 UI 元素的属性都是可绑定属性,这使得我们可以通过数据绑定或使用样式来设置它们的值。

例如,让我们看看LabelText属性:

public partial class Label : View ...
{
    ....
    public static readonly BindableProperty TextProperty =
        BindableProperty.Create(nameof(Text),
            typeof(string), typeof(Label), default(string),
            propertyChanged: OnTextPropertyChanged);
    ...
    public string Text
    {
        get { return (string)GetValue(TextProperty); }
        set { SetValue(TextProperty, value); }
    }
    static void OnTextPropertyChanged(
        BindableObject bindable,
        object oldvalue, object newvalue)
    {
        ...
    }
    ...
}

让我们从查看Text属性开始。这只是一个 CLR 属性,但它不是从后端字段存储和检索值,而是执行以下操作:

  • 获取器通过调用GetValue方法并传入TextProperty来检索值。这个GetValue方法是在Label类继承的BindableObject基类上的一个方法。

  • 设置器调用BindableObjectSetValue方法,传入TextProperty和给定的值。

GetValueSetValue方法中使用的TextProperty是一个BindableProperty。它是静态的,属于Label类。如前所述,BindableProperty是一种“容器”,它包含有关属性的各种信息,包括其值。

让我们分解这个TextProperty。这个属性被定义为BindableProperty类型的公共静态字段。它通过调用BindableProperty.Create方法并传入以下值来实例化:

  • BindableProperty字段。在这个特定的案例中,nameof(Text)用于引用Label类的Text属性。

  • Type定义了可绑定属性所引用的属性的数据类型。由于Text属性是string类型,因此传递了typeof(string)作为值。

  • Type。它指的是属性的拥有者类型,即属性所属的类。在这个特定的例子中,Text属性是在Label类中定义的,因此这个类型作为值传递。

  • defaultValue参数,其类型为object。这是一个可选参数,允许我们为可绑定属性提供一个默认值。在这种情况下,传递了default(string),这意味着默认值将是NULL

  • OnTextpropertyChanged:可选地,我们可以提供一个在属性值更改时需要调用的委托。

BindableProperty.Create方法有很多可选参数,如果需要的话,你可以提供这些参数。

更多关于可绑定属性的信息

第十一章创建 MVVM 友好的控件,我们将探讨如何构建我们自己的控件。理解 BindableProperty 的概念对于创建 MVVM 友好的控件至关重要。因此,你可以期待在那个章节中更深入地了解这一点。

BindableProperty 的概念一开始可能会让人感到有些令人困惑或难以理解。目前,只需记住所有 UI 元素都继承自 BindableObject,并且它们中的许多属性都是可绑定属性,这使得我们可以将它们用作绑定目标。

BindingContext

BindableObject 有一个类型为 objectBindingContext 属性,它充当绑定源和绑定目标之间的粘合剂。它将数据绑定引擎指向一个充当绑定源的类的实例。当你在一个 BindableObject 上设置 BindingContext 属性,例如一个 .NET MAUI 页面或 UI 元素时,你指定了该对象作用域内数据绑定表达式的源对象。该作用域内的子元素将默认继承 BindingContext,除非为它们显式设置了不同的 BindingContext

当在 BindableObject 上设置 BindingContext 时,数据绑定引擎将解析所有将 Source 设置为 BindingContext 的绑定。此外,当 BindingContext 属性的值发生变化时,这些绑定将使用更新的 BindingContext 重新评估。

既然我们已经讨论了所有允许我们在 .NET MAUI 中进行数据绑定的核心组件,让我们看看如何定义和使用它们。

实践中的数据绑定

在我们开始编写数据绑定之前,我们首先需要将我们的 ViewModels 添加到我们的解决方案中。为了完全接受关注点的分离,让我们将它们放入一个单独的项目中:

  1. Solution Explorer 中,右键单击你的解决方案,选择 Add | New Project…

  2. 从项目模板列表中选择 Class Library 并点击 Next

  3. Project name 下输入 Recipes.Client.Core 并点击 Next

  4. 选择 .NET 8.0 作为 Framework 并点击 Create

一旦创建了项目,让我们删除默认创建的 Class1.cs。接下来,我们想要将我们的 ViewModels 添加到这个项目中。为了保持一切井井有条,让我们首先创建一个 ViewModels 文件夹来放置所有的 ViewModels:

  1. ViewModels 中右键单击 Recipes.Client.Core 项目。

  2. 右键单击新添加的文件夹,选择 RecipeDetailViewModel,并添加以下代码:

    namespace Recipes.Client.Core.ViewModels;
    public class RecipeDetailViewModel
    {
        public string Title { get; set; } = "Classic Caesar Salad";
    }
    

    RecipeDetailViewModel 表示菜谱的详细信息。目前,它只包含一个 Title 属性,我们现在给它一个硬编码的值 "Classic Caesar Salad"

接下来,我们需要从 Recipes.Mobile 项目添加对 Recipes.Client.Core 项目的引用。为此,只需在 Recipes.Mobile 项目上右键单击,选择 Recipes.Client.Core 项目。

在我们深入数据绑定之前,作为最后一步,我们需要向我们的Recipes.Mobile项目添加一个新页面:

  1. 右键单击项目名称,选择添加 | 新建项…

  2. 选择RecipeDetailPage.xaml

  3. 打开App.xaml.cs,并在构造函数中将RecipeDetailPage的一个实例分配给MainPage属性:

    public App()
    {
        InitializeComponent();
        MainPage = new RecipeDetailPage();
    }
    

    这确保了在启动时,移动应用将显示我们新创建的RecipeDetailPage

  4. 最后,在RecipeDetailPage的代码背后,我们需要将其BindingContext属性分配给RecipeDetailViewModel的一个实例:

    using Recipes.Client.Core.ViewModels;
    namespace Recipes.Mobile;
    public partial class RecipeDetailPage : ContentPage
    {
        public RecipeDetailPage()
        {
            InitializeComponent();
            BindingContext = new RecipeDetailViewModel();
        }
    }
    

现在,我们可以专注于RecipeDetailPage并开始实现一些数据绑定。数据绑定可以使用 C#和 XAML 在代码中定义。两者都有其优缺点,但本质上取决于个人喜好。如果你想的话,甚至可以混合使用这两种方法,尽管我不建议这么做。最常见的是在 XAML 中定义数据绑定,以最小化代码背后的代码量。不过,让我们首先看看如何使用 C#定义数据绑定。

C#中的数据绑定

让我们转到我们的新RecipeDetailPage.xaml文件,并开始更新一些 XAML:

  1. ContentPage标签内移除任何默认的 XAML 元素。

  2. ContentPage内添加以下元素:

    <ScrollView>
        <VerticalStackLayout Padding="10">
        </VerticalStackLayout>
    </ScrollView>
    

    目前我们保持 UI 非常简单直接。这就是为什么我们将一切放入一个VerticalStackLayout中,它以垂直堆叠的方式组织子元素。我们用ScrollView包围VerticalStackLayout,以确保当屏幕上无法显示所有内容时,我们得到滚动条。

  3. VerticalStackLayout中添加一个名为lblTitleLabel

    <Label x:Name="lblTitle"
        FontAttributes="Bold" FontSize="22" />
    

    这个标签将显示菜谱的标题。

  4. 在代码背后,在RecipeDetailPage的构造函数中,我们现在可以添加数据绑定代码,以便在lblTitle标签中显示RecipeDetailViewModelTitle属性值:

    lblTitle.SetBinding(
        Label.TextProperty,
        nameof(RecipeDetailViewModel.Title),
        BindingMode.OneWay);
    

    因为我们已经给Label命名为lblTitle,所以会生成一个具有此名称的字段,这允许我们从代码背后访问这个标签。通过调用SetBinding方法,我们可以定义并应用一个绑定。

  5. 运行应用,你应该能在屏幕上看到RecipeDetailViewModelTitle,目前它是硬编码为"Classic Caesar Salad"

通过查看我们刚刚实现的第一条数据绑定,你应该能够识别出我们之前讨论的大多数数据绑定元素:

  • SetBinding方法是在BindableObject类上的一个方法。SetBinding方法需要的第一个参数是一个BindableProperty。在这种情况下,我们传递的是静态的Label.TextProperty,它对应于LabelText实例属性。我们调用SetBinding方法的BindableObject实例,以及我们作为第一个参数指定的BindableProperty,共同构成了绑定目标。

  • SetBinding方法期望的是一个要绑定的属性路径。这,加上BindingContext,形成了绑定源——但在这个情况下,BindingContext是什么?由于我们没有在Label上显式指定BindingContextBindingContext是从lblTitle的父元素继承的,即RecipeDetailPage。因此,标签的BindingContext也是RecipeDetailViewModel的实例。

  • BindingMode是本例中的第三个参数。然而,这个参数是可选的。在这种情况下,我们将BindingMode设置为OneTime。在第一章,“什么是 MVVM 设计模式?”中,我们简要讨论了不同的绑定模式,我们将在本章稍后更深入地讨论绑定模式。

在代码中定义数据绑定既简单又直接。然而,大多数情况下,数据绑定是在 XAML 中定义的。我个人认为在 XAML 中定义它们感觉更自然,并且在使用 XAML 创建 UI 时需要更少的上下文切换。那么,让我们看看吧!

XAML 中的数据绑定

由于我们将要在 XAML 中编写数据绑定,我们可以删除或注释掉在上一节步骤 4中添加到RecipeDetailPage构造函数中的绑定代码。

我们现在可以切换到RecipeDetailPage.xaml并更新标签,通过删除x:Name属性并添加包含绑定****标记扩展Text属性:

<Label
    FontAttributes="Bold"
    FontSize="22"
    x:Name property. Of course, if you need a reference to the label for any other reason (for example, you have some animation logic in the code-behind that animates the label), you need to keep the x:Name property.
But more importantly, let’s take a look at the `Binding` markup extension that we’ve set to the `Text` property of the label.
XAML markup extensions
XAML markup extensions are a feature of XAML that allows you to provide values for properties during the parsing of the XAML markup more dynamically and flexibly. Markup extensions use curly braces (`{}`) to enclose their syntax and enable you to add more complex logic or functionality to the XAML itself.
Markup extensions can be used to reference resources, create bindings, or even instantiate objects, among other things. They allow you to extend the capabilities of the XAML language.
A lot more about markup extensions can be found in the docs: [`learn.microsoft.com/dotnet/maui/xaml/fundamentals/markup-extensions`](https://learn.microsoft.com/dotnet/maui/xaml/fundamentals/markup-extensions).
The `Binding` markup extension is used to create a data binding between the `Text` property of the label and the `Title` property of the binding source. The binding source, in this case, is an instance of `RecipeDetailViewModel`, which is set as the `BindingContext` of the control’s parent, `RecipeDetailPage`.
The `Path=Title` part of the binding expression specifies that the `Title` property from the binding source should be used as the source of the binding. In this scenario, you can omit `Path=` and simply use `Title`, as the binding expression is smart enough to recognize it as a property path. So, the binding expression can be written as follows:

<Label

FontAttributes="Bold"

FontSize="22"

Mode=, 我们可以指示绑定模式,就像我们在上一个示例中所做的那样;在这个示例中,我们将它设置为 OneTime。

除了PathMode之外,绑定标记扩展还有更多属性,例如Source,它允许我们指向另一个绑定源,ConverterTargetNullValue等。第四章,“.NET MAUI 中的数据绑定”将更详细地介绍所有这些内容。

现在,让我们更详细地看看不同的绑定模式以及如何自动在绑定目标中反映绑定源的变化。

绑定模式和 INotifyPropertyChanged 接口

第一章,“什么是 MVVM 设计模式”中,我们已经讨论了数据如何流动:

  • 单向:从 ViewModel 到 View

  • 单向到源:从 View 到 ViewModel

  • 一次性:仅从 ViewModel 到 View 一次

  • 双向:从 ViewModel 到 View 和从 View 到 ViewModel

现在,让我们看看在.NET MAUI 中是如何处理这个问题的。

.NET MAUI 中的绑定模式

.NET MAUI 支持所有这些数据流,通过 Microsoft.Maui.Controls.BindingMode 枚举表示:OneWayOneWayToSourceOneTimeTwoWay。实际上还有一个第五个值:Default。记得我们在本章前面讨论可绑定属性时提到的吗?在创建可绑定属性时,我们可以设置一些可选值。其中之一是 defaultBindingMode。这允许我们在可绑定属性上设置默认绑定模式。例如,在 Entry 上,将 Text 属性的默认绑定模式设置为 TwoWay 是有意义的,因为它显示了绑定源的价值,并且用户能够更新该值。另一方面,Label 上的 Text 属性是只读的,因此那里的默认绑定模式是 OneWay。现在,回到 BindingMode 枚举,特别是 Default 值,当我们没有指定绑定模式或使用 Default 时,数据绑定引擎将使用绑定目标的可绑定属性上指定的默认绑定模式。

在数据绑定和 .NET MAUI 中的各种绑定模式背景下,有一个高效的方法来通知 UI 关于 ViewModel 属性的更改至关重要。INotifyPropertyChanged 接口通过提供一个机制来满足这一需求,允许 ViewModel 在属性值更新时通知 UI。

INofityPropertyChanged

INotifyPropertyChanged 接口,作为 System.ComponentModel 命名空间的一部分,允许您的绑定源将属性更改通知给绑定目标。此接口并不仅限于 .NET MAUI;当绑定到对象实现 INotifyPropertyChanged 接口时,它是 .NET PropertyChanged 事件的一部分。当事件被触发时,引擎将相应地更新 UI。

要实现 INotifyPropertyChanged 接口,您的 ViewModel 必须包含一个 PropertyChanged 事件,该事件可以在属性值更改时触发。此外,通常创建一个方法,通常称为 OnPropertyChanged,该方法触发此事件并提供更改属性的名称作为参数。此方法应在属性设置器中调用,在属性值更新后立即调用:

public class SampleViewModel : INotifyPropertyChanged
{
    private string _title = string.Emtpty;
    public string Title
    {
        get => _title;
        set
        {
            if(_title != value)
            {
                _title = value;
                OnPropertyChanged(nameof(Title));
            }
        }
    }
    public void OnPropertyChanged(string propertyName)
        => PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(propertyName));
    public event PropertyChangedEventHandler? PropertyChanged;
}

如前所述的示例所示,此 SampleViewModel 实现了 INotifyPropertyChanged 接口,这要求我们实现 PropertyChanged 事件的 PropertyChangedEventHandler 类型。可以通过调用 OnPropertyChanged 方法并传递已更改属性的名称来轻松调用 PropertyChanged 事件。在查看代码时,在 Title 属性的设置器中,当将新值分配给属性的备份字段时,会调用 OnPropertyChanged 方法,并传递属性的名称。在调用 PropertyChanged 事件时,通过 this 关键字将 SampleViewModel 的当前实例作为发送者传递,后跟一个 PropertyChangedEventArgs 实例,该实例需要更新的属性的名称。通过此事件,数据绑定引擎会收到已更新属性的通知,并且根据数据绑定模式,可以自动更新绑定目标。

CallerMemberNameAttribute 是在 OnPropertyChanged 方法的 propertyName 参数上非常常见的一种属性。此属性自动获取调用具有属性的方法或属性的名称。这使从属性的设置器调用 PropertyChanged 事件变得更加容易,因为不需要显式地作为参数传递属性的名称:

public class SampleViewModel : INotifyPropertyChanged
{
    ...
    public string Title
    {
        get => _title;
        set
        {
            if(_title != value)
            {
                _title = value;
                OnPropertyChanged();
            }
        }
    }
    public void OnPropertyChanged([CallerMemberName]string? propertyName = null)
        => PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(propertyName));
...
}

由于 Title 属性调用了 OnPropertyChanged 方法,但没有提供作为参数的显式值,因此调用者的名称(在这种情况下为 Title)将被作为值传递。在 第五章社区工具包 中,我们将看到如何在触发 PropertyChanged 事件时消除这里的大部分仪式。

不同的绑定模式在实际应用

在本章前面的示例中,我们将 RecipeDetailViewModelTitle 属性绑定了一次到 LabelText 属性。这意味着仅在设置绑定目标的 BindingContext 或将 BindingContext 分配为新实例时,才设置绑定源。在我们的当前设置中,BindingContextTitle 属性的初始值立即绑定。然而,由于一次性数据绑定的性质,对 Title 属性的任何后续更改(例如在 ViewModel 加载数据后更新它)都不会反映在 UI 中。由于我们目前使用的是静态数据来演示,所以这不会成为问题,但在实际应用中,这是一个需要注意的问题。随着我们继续阅读本书,我们将修订此绑定模式,以更有效地处理此类情况。

让我们添加更多的代码并讨论其他绑定模式。首先,让我们添加一个额外的 IngredientsListViewModel 并将我们的 RecipeDetailViewModel 扩展为具有该类型的属性:

  1. Recipes.Client.Core项目的ViewModels文件夹中,选择添加 | 类…

  2. 输入IngredientsListViewModel.cs作为名称并点击添加

  3. 让类实现INotifyPropertyChanged接口并添加OnPropertyChanged方法。将以下代码添加到新创建的类中:

    public class IngredientsListViewModel : INotifyPropertyChanged
    {
        public void OnPropertyChanged([CallerMemberName]string?  propertyName = null)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        public event PropertyChangedEventHandler? PropertyChanged;}
    

    这是对INotifyPropertyChanged接口的典型实现,包括PropertyChangedEvent和一个触发已实现PropertyChanged事件的OnPropertyChanged方法。

    1. 接下来,我们可以添加NumberOfServings属性,如下面的代码片段所示。为了简洁,省略了INotifyPropertyChanged接口的实现:
    public class IngredientsListViewModel : INotifyPropertyChanged
    {
        private int _numberOfServings = 4;
        public int NumberOfServings
        {
            get => _numberOfServings;
            set
            {
                if(_numberOfServings != value)
                {
                    _numberOfServings = value;
                    OnPropertyChanged();
                }
            }
        }
    ...
        //ToDo: add list of Ingredients
    }
    

    IngredientsListViewModel包含所有食材及其所需数量的列表,以及一个NumberOfServings属性,该属性表示列表中食材及其数量所针对的份量数。用户应该能够在 UI 中调整份量数并看到根据所选数量更新食材数量的情况。我们将在稍后添加食材列表。

    1. 转到RecipeDetailViewModel并添加一个类型为IngredientsListViewModelIngredientsList属性,并默认分配一个新实例:
    public IngredientsListViewModel
            IngredientsList { get; set; } = new ();
    

    在此更新后的代码到位后,我们现在可以转到RecipeDetailPage并再次关注 XAML。我们希望更新 UI 以允许用户选择所需的份量数。对于此用例,我们将创建一个OneWay和一个TwoWay数据绑定。

    前往RecipeDetail并在显示食谱标题的Label下方添加以下 XAML:

    <HorizontalStackLayout Padding="10">
        <Label Text="Number of servings:"
            VerticalOptions="Center" />
        <Label
            Margin="10,0" FontAttributes="Bold"
            Text="{Binding IngredientsList.NumberOfServings, Mode=OneWay}"
            VerticalOptions="Center" />
        <Stepper
            BackgroundColor="{OnPlatform WinUI={StaticResource Primary}}"
            Maximum="8" Minimum="1"
            Value="{Binding IngredientsList.NumberOfServings, Mode=TwoWay}" />
    </HorizontalStackLayout>
    

让我们看看通过这段代码添加的绑定语句:

  • 标签的Text属性使用以下绑定语句绑定到NumberOfServings属性:{Binding IngredientsList.NumberOfServings, Mode=OneWay}。标签显示当前选定的份量数。NumberOfServings属性是RecipeDetailViewModel上的IngredientsList属性的一部分,因此绑定路径设置为IngredientsList.NumberOfServings

    由于用户可以更改份量数,我们使用OneWay绑定模式。这确保了当NumberOfServings的值更新时,UI 会相应地反映变化。

  • Stepper控件的Value属性使用以下绑定语句绑定到NumberOfServings属性:{Binding IngredientsList.NumberOfServings, Mode=TwoWay}Stepper允许用户调整当前选定的份量数。

    由于用户可以更改份量数,并且 ViewModel 应该相应更新,我们使用TwoWay绑定模式。这确保了不仅 UI 反映了NumberOfServings属性值,而且当用户通过Stepper控件修改份量数时,ViewModel 也会更新。

    使用StepperNumberOfServings属性进行的增加或减少将由于OneWay绑定而在之前讨论的标签中反映出来,并且该属性会触发PropertyChanged事件。

在 XAML 代码中,这两个绑定都针对IngredientsList属性内的属性。在这个例子中,HorizontalStackLayout的所有子元素都绑定到RecipeDetailViewModelIngredientsList属性的属性。由于子元素从其父元素继承绑定上下文,如果需要,可以将绑定移动到IngredientsList属性的一个级别以上:

<HorizontalStackLayout Padding="10" BindingContext="{Binding IngredientsList}">
    <Label Text="Number of servings:"
        VerticalOptions="Center" />
    <Label
        Margin="10,0" FontAttributes="Bold"
        Text="{Binding NumberOfServings, Mode=OneWay}"
        VerticalOptions="Center" />
    <Stepper
        BackgroundColor="{OnPlatform WinUI={StaticResource Primary}}"
        Maximum="8" Minimum="1"
        Value="{Binding NumberOfServings, Mode=TwoWay}"/>
</HorizontalStackLayout>

在前面的例子中,我们将HorizontalStackLayoutBindingContext绑定到RecipeDetailViewModelIngredientsList属性。这允许我们简化子元素上的绑定语句。当然,只有当所有子元素都具有相同的绑定源时,你才能这样做:

作为最后的例子,让我们在我们的Recipes!应用中实现一个OneWayToSource数据绑定。在这里,我们的目标是显示每个菜谱的过敏信息。由于并非每个人都对查看此信息感兴趣,我们不会默认显示它。然而,我们希望允许用户勾选复选框以获取此信息:

  1. 前往RecipesDetailViewModel并让它实现INotifyPropertyChanged

    public class RecipeDetailViewModel : INotifyPropertyChanged
    {
        ...
        public event PropertyChangedEventHandler? PropertyChanged;
    }
    
    1. 创建一个接受propertyName参数并调用PropertyChanged事件的OnPropertyChanged方法,就像我们在IngredientsListViewModel中做的那样:
    public void OnPropertyChanged([CallerMemberName] string? propertyName = null)
                => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    
    1. 现在,让我们添加一个类型为boolShowAllergenInformation属性。这个属性将负责在RecipeDetailPage上显示过敏信息的可见性:
    private bool _showAllergenInformation;
    public bool ShowAllergenInformation
    {
        get => _showAllergenInformation;
        set
        {
            if (_showAllergenInformation!= value)
            {
                _showAllergenInformation = value;
                OnPropertyChanged();
            }
        }
    }
    
    1. 当这个属性的值发生变化时,它会触发PropertyChanged事件。这允许我们将它绑定到VisualElementIsVisible属性,这样每当 ViewModel 上的值发生变化时,UI 元素的可见性就会自动更新。
  2. 最后,我们可以前往RecipeDetailPage并在显示菜谱标题的标签和绑定到IngredientsListHorizontalStackLayout之间添加以下 XAML:

    <VerticalStackLayout Padding="10">
        <HorizontalStackLayout>
            <Label
                FontAttributes="Italic"
                Text="Show Allergen information"
                VerticalOptions="Center" />
            <CheckBox IsChecked="{Binding ShowAllergenInformation, Mode=OneWayToSource}" />
        </HorizontalStackLayout>
        <Label IsVisible="{Binding ShowAllergenInformation, Mode=OneWay}"
            Text="ToDo: add allergen information" />
    </VerticalStackLayout>
    "Show Allergen Information", followed by a checkbox that the user can toggle if they want to view the recipe’s allergen information. The IsChecked property is bound using the OneWayToSource mode to the ShowAllergenInformation property in the ViewModel. This means that when the user checks the box, the property will update accordingly.
    

    此外,我们使用OneWay模式将相同的ShowAllergenInformation属性绑定到标签的IsVisible属性。这个标签最终将显示过敏信息。因此,当用户切换复选框时,它会更新 ViewModel 中的ShowAllergenInformation属性,这反过来又触发了PropertyChangedEvent。这个事件将被绑定引擎捕获,它将更新标签的IsVisible属性,根据用户的偏好显示或隐藏过敏信息。

INotifyPropertyChanged不是绑定源的必要条件

重要的一点是要注意,绑定目标始终应该是继承自BindableObject的类的BindableProperty。另一方面,绑定源可以是任何类的任何属性。大多数情况下,绑定源类的实现INotifyPropertyChanged接口,但这不是必需的。只有在使用OneWayTwoWay绑定模式,并且绑定源上的值可以更新并需要在 UI 中反映的情况下,才需要实现INotifyPropertyChanged接口。或者,重新设置BindingContext也会更新所有值,但这意味着需要重新评估一切,这可能不是一个好主意。

既然我们已经看到了.NET MAUI 如何支持数据绑定和不同的绑定模式,让我们看看我们如何处理用户交互。

使用 ICommand 接口处理交互

在大多数应用中,用户交互起着至关重要的作用。常见的交互包括点击按钮、从列表中选择项目、切换开关等。为了在遵循 MVVM 模式的同时有效地处理这些交互,利用一个封装必要逻辑在 ViewModel 中的强大机制是至关重要的。ICommand接口正是为此目的而设计的,它允许您以干净和可维护的方式管理用户交互,同时确保视图和 ViewModel 之间有明确的关注点分离。在本节中,我们将探讨如何实现和使用ICommand来处理.NET MAUI 应用程序中的用户交互。

ICommand 接口

在.NET MAUI 应用程序的 MVVM 模式上下文中,ICommand接口在处理用户交互方面发挥着关键作用。ICommandSystem.Windows.Input命名空间的一部分,允许您在 ViewModel 中封装执行特定操作和确定该操作是否可以执行的逻辑。再次强调,此接口并不仅限于.NET MAUI;它是.NET BCL 的一个组成部分。

ICommand有两个主要成员:ExecuteCanExecute

  • Execute (object 参数):当命令被调用时,将执行此方法。如果需要,可以通过可选参数传递额外的数据。

  • CanExecute (object 参数):此方法指示命令在其当前状态下是否可以执行。如果需要,可以传递一个参数。

ICommand还公开了一个名为CanExecute方法更改的事件,该事件会通知 UI 重新评估命令是否可以执行。这使 UI 元素(如按钮)的自动启用/禁用成为可能,这些元素的状态基于应用程序的当前状态。

要在 ViewModel 中使用 ICommand,你可以创建一个实现 ICommand 接口的自定义命令类,或者使用内置的命令类,如 Microsoft.Maui.Controls.Command。还有来自 MVVM Toolkit 的第三方实现,例如 CommunityToolkit.Mvvm.Input.RelayCommand,我们将在 第五章社区工具包 中更详细地探讨。通常,这些实现包含一个名为 ChangeCanExecuteNotifyCanExecuteChanged 的方法,这被称为 CanExecuteChanged 事件。

现在,你已经熟悉了 ICommand 接口及其在 ViewModel 中的作用,是时候看看它在实际中的应用了。

将其付诸实践

作为简单的演示,我们希望允许用户添加或删除菜谱作为收藏。为此,让我们向 RecipeDetailPage 添加两个按钮,位于包含显示过敏信息 CheckBox 元素的 VerticalStackLayout 下方:

<VerticalStackLayout>
    <Button Command="{Binding AddAsFavoriteCommand}" Text="Add as favorite" />
    <Button Command="{Binding RemoveAsFavoriteCommand}" Text="Remove as favorite" />
</VerticalStackLayout>

第一个按钮的 Command 属性绑定到我们的绑定源 RecipeDetailViewModel 上的 AddAsFavoriteCommand 属性。这个按钮应该允许用户在菜谱尚未收藏时将其标记为收藏。第二个按钮则正好相反:它应该允许用户取消收藏菜谱。让我们看看这两个命令的实现:

  1. RecipeDetailViewModel 中,我们可以添加一个 IsFavorite 属性:

    private bool _isFavorite = false;
    public bool IsFavorite
    {
        get => _isFavorite;
        private set
        {
            if (_isFavorite != value)
            {
                _isFavorite = value;
                OnPropertyChanged();
            }
        }
    }
    

    这个属性包含一个 bool 值,用来指示用户是否已将此菜谱标记为收藏。

    1. 接下来,我们需要添加两个命令,AddAsFavoriteCommandRemoveAsFavoriteCommand
    public ICommand AddAsFavoriteCommand
    {
        get;
    }
    public ICommand RemoveAsFavoriteCommand
    {
        get;
    }
    

    这些是按钮的 Command 属性绑定到的两个类型为 ICommand 的属性。

    1. 现在,我们需要实例化这两个命令。虽然 .NET 包含一个 ICommand 接口,但它没有具体的实现。另一方面,.NET MAUI 确实有一个实现!为了从 Recipes.Client.Core 项目访问这个实现,我们需要配置项目以使用 .NET MAUI 框架。

    Recipes.Client.Core 项目中。这应该会打开相关的 csproj 文件,其中你需要添加 <UseMaui>true</UseMaui>

    <PropertyGroup>
      <TargetFramework>net8.0</TargetFramework>
      <ImplicitUsings>enable</ImplicitUsings>
      <UseMaui>true</UseMaui>
      <Nullable>enable</Nullable>
    </PropertyGroup>
    

    这使我们能够从我们的 Core 项目访问 .NET MAUI 特定的库,例如 Microsoft.Maui.Controls.Command,它实现了 ICommand 接口。

    1. RecipeDetailViewModel 构造函数中,我们现在可以实例化 AddAsFavoriteCommandRemoveAsFavoriteCommand
    private void AddAsFavorite() => IsFavorite = true;
    private void RemoveAsFavorite() => IsFavorite = false;
    private bool CanAddAsFavorite()
        => !IsFavorite;
    private bool CanRemoveAsFavorite()
        => IsFavorite;
    public RecipeDetailViewModel()
    {
        AddAsFavoriteCommand =
            new Command(AddAsFavorite, CanAddAsFavorite);
        RemoveAsFavoriteCommand =
            new Command(RemoveAsFavorite, CanRemoveAsFavorite);
    }
    

    在这个例子中,我们在 ViewModel 中有两个命令:AddAsFavoriteCommandRemoveAsFavoriteCommand。每个命令都使用一个相关的 Action 和一个 Func<bool> 来确定其可执行性。

    AddAsFavoriteCommand 有一个 AddAsFavorite 方法作为其操作,它只是将 IsFavorite 属性设置为 true。它的 CanExecute 方法由 CanAddAsFavorite 方法确定,当 IsFavorite 属性的值为 false 时返回 true

    另一方面,RemoveAsFavoriteCommand有一个RemoveAsFavorite方法作为其操作,它将IsFavorite属性设置为falseCanRemoveAsFavorite方法提供为此命令的CanExecute检查,并且当IsFavorite属性值为true时返回true

注意

需要注意的是,检查并遵守CanExecute方法的责任在于控件。它不应该是一个盲目依赖的东西,因为它可能没有实现或者没有按照你预期的样子工作。确保阅读控件的文档并彻底测试它。

总结来说,当IsFavorite属性为true时,只有RemoveAsFavoriteCommand可以执行,而AddAsFavoriteCommand不能执行。相反,当IsFavorite属性为false时,AddAsFavoriteCommand可以执行,而RemoveAsFavoriteCommand不能执行。这确保了根据IsFavorite属性当前的状态,可以执行适当的命令。

  1. 只缺少一个拼图碎片:每当IsFavorite属性的值发生变化时,两个命令的CanExecute方法都需要重新评估。为了做到这一点,我们需要更新IsFavorite属性的设置器:

    if (_isFavorite != value)
    {
        _isFavorite = value;
        OnPropertyChanged();
        ((Command)AddAsFavoriteCommand).ChangeCanExecute();
        ((Command)RemoveAsFavoriteCommand).ChangeCanExecute();
    }
    

在所有这些代码就绪后,我们可以再次运行应用程序。默认情况下,当食谱不是用户喜欢的时,只有第一个按钮(绑定到AddAsFavoriteCommand)是启用的。点击此按钮后,IsFavorite属性被更新,并且两个命令的ChangeCanExecute方法被调用。结果,第一个按钮变为禁用,而第二个按钮(绑定到RemoveAsFavoriteCommand)自动启用。这确保了根据IsFavorite属性正确地启用或禁用按钮。

或者,我们也可以使用单个命令并使用CommandParameter来处理IsFavorite属性的切换:

  1. 为了做到这一点,让我们添加一个新的SetFavoriteCommand属性,其类型为ICommand,并在RecipeDetailViewModel的构造函数中初始化它:

    public RecipeDetailViewModel()
    {
        ...
        SetFavoriteCommand =
            new Command<bool>(SetFavorite, CanSetFavorite);
    }
    private bool CanSetFavorite(bool isFavorite)
        => IsFavorite != isFavorite;
    private void SetFavorite(bool isFavorite)
        => IsFavorite = isFavorite;
    

    通过将命令分配给Command<bool>的实例,我们指定在ExecuteCanExecute方法中我们期望一个布尔类型的参数。

    1. IsFavorite属性的设置器中,我们现在需要调用SetFavoriteCommandCanExecuteChanged方法。
  2. 最后,我们可以更新两个按钮的绑定语句:

    <Button
        Command="{Binding SetFavoriteCommand}"
        CommandParameter="{x:Boolean true}"
        Text="Add as favorite" />
    <Button
        Command="{Binding SetFavoriteCommand}"
        CommandParameter="{x:Boolean false}"
        Text="Remove as favorite" />
    

    两个按钮调用相同的命令,但它们各自传递不同的参数,这些参数被传递到ExecuteCanExecute方法中。

使用 Maui 和 MVVM 最佳实践

在本章中,我们在Core项目中引入了UseMaui属性,这可能会与我们对 ViewModels 框架无关的先前声明相矛盾。虽然遵循 MVVM 模式,但建议保持 ViewModels 不受任何框架特定依赖的影响。然而,在这个特定的情况下,我们选择了更实用的方法来演示通过Microsoft.Maui.Controls.Command类实现ICommand的实例。在一个严格遵循 MVVM 的场景中,你希望在 ViewModels 中避免这样的依赖,以确保最大的灵活性和可维护性。

第五章社区工具包中,我们将探讨如何改进这段代码,使其更接近这些最佳实践。

作为额外的功能,我们可能还想显示一个图标来指示一个菜谱是否是收藏的。这个图标的可见性可以绑定到IsFavorite属性。当我们通过我们刚刚创建的按钮和命令切换这个属性时,图标的可见性也会更新:

  1. 首先,让我们将Chapter 03``/Assets/favorite.png文件添加到Resources/Images文件夹中的favorite.png文件。你可能需要调整文件选择器弹出窗口中的文件类型过滤器,以包括.png文件。

  2. RecipeDetailPage.xaml中,在显示菜谱标题的Label下方直接添加以下 XAML:

    <Image
        Margin="5" HeightRequest="35"
        IsVisible="{Binding IsFavorite}"
        Source="favorite.png" WidthRequest="35" />
    

    现在我们点击添加为收藏从收藏中移除按钮时,你应该看到收藏图标出现或消失。

ICommand接口在实现 MVVM 中扮演着至关重要的角色。通过在命令中封装用户交互,它促进了视图和 ViewModel 之间关注点的清晰分离。这允许代码更加可维护、可测试和模块化。正如所展示的,ICommand及其CanExecute功能确保了根据应用程序的状态,用户可以获得适当的操作,从而进一步增强用户体验。通过利用ICommand,开发者可以有效地实现 MVVM 模式。

随着我们探索ICommand接口,你可能想知道,还有额外的工具和资源可用于在.NET MAUI 中实现 MVVM。多亏了出色的.NET 和.NET MAUI 社区,各种社区工具包为你的项目提供了更多对 MVVM 模式的支持。

摘要

在本章中,我们深入探讨了使 .NET MAUI 中的 MVVM 模式得以实现的核心理念。我们讨论了基本构建块,包括 BindableObjectBindablePropertyBindingContext,以及它们如何促进视图和视图模型之间的无缝通信。此外,我们还考察了 INotifyPropertyChanged 接口在通知 UI 视图模型属性变化中的重要性,并展示了 ICommand 接口如何以解耦的方式处理用户交互。通过理解这些基本概念,我们可以清楚地看到为什么 .NET MAUI 和 MVVM 模式如此和谐地匹配。

当我们继续进入第 4 章 数据绑定在 .NET MAUI 中,我们将更深入地研究数据绑定,包括值转换器、回退值、元素和相对绑定、多绑定以及编译绑定等主题。这将使你能够充分利用数据绑定的全部功能。让我们继续我们的旅程!

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:


第四章:.NET MAUI 中的数据绑定

在上一章中,我们介绍了 .NET MAUI 数据绑定的基础知识。数据绑定不仅是 .NET MAUI 的核心功能,也是使用 MVVM 设计模式有效构建应用程序的关键组件。它创建了一个强大的链接,将您的 View 和 ViewModel 之间连接起来,促进了两者之间高效通信和同步。

随着我们深入数据绑定的领域,掌握一些高级技术和功能是至关重要的。这些是您能够尽可能高效地创建动态用户界面的基石。它们使我们能够设计出不仅更互动,而且更容易管理和维护的用户界面。

本章将涵盖以下主题:

  • ValueConverters 和 StringFormat

  • 回退

  • 元素和相对绑定

  • 多绑定

  • 编译绑定

到本章结束时,结合上一章的内容,您将全面深入地理解 .NET MAUI 的数据绑定。这些知识将使您能够有效地将这些技术应用到您的应用程序中。让我们开始吧!

技术要求

在本章中,我们将向 Recipes! 应用程序添加功能。所有必要的资产,包括本章中使用的所有代码,都可以在 GitHub 上找到,网址为 github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter04

ValueConverters 和 StringFormat

在许多情况下,ViewModel 中的数据并不完全符合 UI 预期的格式。例如,您可能有一个 DateTime 对象在 ViewModel 中,您希望在 View 中以特定的字符串格式显示,或者一个枚举应该表示为更友好的字符串。

这就是 ValueConvertersStringFormat 发挥作用的地方。这两种技术都充当中间人,将 ViewModel 数据转换成适合在 UI 中显示或交互的格式。

在本节中,我们将深入探讨如何创建和使用 ValueConverters 来有效地管理这些数据转换,以及格式化如何进一步细化数据的展示,确保其具有意义且用户友好。

创建和使用 ValueConverters

ValueConverter 在源(通常是 ViewModel)和目标(View)之间充当中间人。它提供了一种在数据从源到目标或反之亦然传递时转换或转换数据的方法(图 4**.1):

图 4.1:转换器的使用

图 4.1:转换器的使用

一个常见的场景可能涉及 ViewModel 属性,它是一种特定类型,例如枚举或复杂对象,需要在 UI 中以不同的方式显示。ValueConverter 可以将数据从一种类型转换为与 UI 兼容和适当的另一种类型。同样,在 UI 中接收到的用户输入可能需要在存储到 ViewModel 之前转换成不同的格式。

ValueConverter 是实现了Microsoft.Maui.Controls.IValueConverter接口的类,该接口定义了两个方法——ConvertConvertBack

object Convert(object value, Type targetType, object
  parameter, CultureInfo culture);
object ConvertBack(object value, Type targetType, object
  parameter, CultureInfo culture);

Convert方法负责将绑定源中的值转换为绑定目标。它接受原始值、目标类型、可选参数和文化信息作为参数。此方法执行转换并返回一个表示转换后数据的对象。然后使用转换后的数据来更新视图上的属性。确保返回的对象与绑定目标属性的预期类型或兼容类型相匹配非常重要。

让我们看看方法的参数:

  • value:这是需要转换的源数据。这通常是您希望在视图中显示的 ViewModel 中的数据。因为这可能是一切,所以它被提供为object

  • targetType:这是绑定目标属性的类型。这是方法应该将数据返回为的类型。例如,如果您正在将数据绑定到您的视图中的一个Microsoft.Maui.Graphics.Color类型的属性,那么targetType将是Microsoft.Maui.Graphics.Color

  • parameter:这是一个可选的parameter,可以用来向转换器传递额外的信息。

  • culture:这是在转换器中应该使用的System.Globalization.CultureInfo文化。这在处理日期、时间和数字时尤为重要,因为不同文化中这些值可能有不同的表示方式。

ConvertBack方法用于反转转换过程,将数据从绑定目标转换回绑定源。在许多情况下,ConvertBack方法没有实现,因为它仅在TwoWayOneWayToSource数据绑定中才有用,在这些数据绑定中,需要在传递给 ViewModel 之前将视图上的数据转换。返回值和参数与Convert方法类似。

到目前为止,这一切可能都有些抽象,所以让我们来看看如何构建和使用一个 ValueConverter。

创建一个 ValueConverter

为了展示 ValueConverters 的灵活性和强大功能,我们将通过引入评分指示器来增强我们的应用。仅仅显示一个数值并不是表示菜谱评分最吸引人或直观的方式。因此,我们将利用 ValueConverter 将这些数字替换为星形图标,创建一个视觉上吸引人且用户友好的评分表示。我们的自定义 ValueConverter,RatingToStarsConverter,将双精度值转换为字符串。结合特定的字体,这个字符串将显示为星形图标。但在我们开始构建转换器之前,我们需要先做一些准备工作:

  1. 首先,我们将创建 RecipeRatingsSummaryViewModel。右键单击 ViewModels 文件夹,并选择 RecipeRatingsSummaryViewModel.cs

  2. 目前,我们只向类中添加了一个 AverageRating 属性,其类型为 double

    public class RecipeRatingsSummaryViewModel
    {
        public double MaxRating { get; } = 4d;
        public double? AverageRating { get; set; } = 3.5d;
    }
    

    我们还包含了一个值来指示用户可以给出的最大星数。接下来,让我们向 RecipeDetailViewModel 添加一个额外的属性 RatingDetail,并默认分配一个新实例:

    public RecipeRatingsSummaryViewModel RatingDetail {
      get; set; } = new ();
    

在应用程序中显示图标的一种高效方法是使用专门的图标字体。这些字体既免费又可购买,允许您轻松地将各种图标集成到您的应用程序中。原理很简单:将所需的图标字体集成到您的应用中,然后在您希望显示图标的 Label 类上将其分配为 FontFamily。从那里,剩下的只是将 Label 类的 Text 属性设置为对应于您要显示的图标的值。

让我们在我们的应用中包含 Google 的 Material Design 图标字体,这样我们就可以使用这种字体来稍后显示菜谱的评分。您可以在 Chapter 04/Assets/Fonts 文件夹中找到 MaterialIcons-Regular.ttf 字体文件,或者您可以从 github.com/google/material-design-icons/tree/master/font 下载它:

  1. 在 Visual Studio 中,Recipes.Mobile 项目的 Resources/Fonts 文件夹,并选择 File Explorer 中的 Open Folder

  2. MaterialIcons-Regular.ttf 字体文件复制到这个文件夹中。

  3. 返回 Visual Studio,您应该在新添加的字体文件中看到 Solution Explorer。该文件的 Build Action 应该已经自动设置为 MauiFont,如图 图 4.2 所示:

图 4.2:MaterialIcons-Regular.ttf 文件属性

图 4.2:MaterialIcons-Regular.ttf 文件属性

  1. 接下来,为了能够在我们的 MAUI 应用中使用这种字体,我们需要通过 MauiAppBuilder 来添加它。转到 MauiProgram.cs 并添加以下内容:

    .ConfigureFonts(fonts =>
    {
        ...
        fonts.AddFont("MaterialIcons-Regular.ttf",
          "MaterialIconsRegular");
    });
    

    上述代码将允许我们通过设置 Label 类或任何显示文本的控件的 FontFamily 属性为 MaterialIconsRegular 来使用此字体。

现在所有这些都已就绪,我们最终可以开始编写我们的第一个 ValueConverter:RatingToStarsConverter。这个转换器应将菜谱的评分转换为小星星图标。

RatingToStarsConverter

RatingToStarsConverter应将任何双精度值转换为表示星星的字符串值。这正是转换器的作用——接收特定数据类型(双精度)的对象,并返回另一个数据类型(字符串)的对象。对于星星图标的可视化,我们可以使用我们之前添加的图标字体。fonts.google.com/icons?icon.set=Material+Icons提供了我们刚刚添加的字体中所有可用图标的概述。通过点击一个图标,你可以看到不同的定位方式。我们感兴趣的是代码值。我们想要使用的星星图标的代码是e838,而半星图标的代码是e839。掌握了这些知识,让我们看看如何创建RatingToStarsConverter

  1. Converters

  2. 右键点击Converters文件夹,选择添加 | 类…

  3. 输入我们的转换器名称,RatingToStarsConverter,然后点击添加

  4. 使该类实现Microsoft.Maui.Controls.IValueConverter接口,如下面的代码块所示:

    public class RatingToStarsConverter : IValueConverter
    {
        public object Convert(object value, Type
          targetType, object parameter, CultureInfo
            culture)
        {
            throw new NotImplementedException();
        }
        public object ConvertBack(object value, Type
          targetType, object parameter, CultureInfo
            culture)
        {
            throw new NotImplementedException();
        }
    }
    
  5. 现在,我们可以开始实现Convert方法。由于这个转换器可以在任何绑定语句中使用,我们首先需要检查绑定源是否确实是double类型的值:

    public object Convert(object value, Type targetType,
        object parameter, CultureInfo culture)
    {
        if (value is not double rating
            || rating < 0 || rating > 4)
        {
            return string.Empty;
        }
        ...
    }
    
  6. 如果value参数不是我们期望的类型,或者它不在期望的范围内,我们将返回一个默认值——在这种情况下,string.Empty

  7. 在验证提供的value之后,我们可以添加其余的逻辑:

    string fullStar = "\ue838";
    string halfStar = "\ue839";
    int fullStars = (int)rating;
    bool hasHalfStar = rating % 1 >= 0.5;
    return string.Concat(
        string.Join("", Enumerable.Repeat(fullStar,
        fullStars)), hasHalfStar ? halfStar : "");
    

    根据我们收到的评分值,我们将返回一个包含我们在项目中添加的MaterialIcons字体中定义的图标字符串。对于满星,我们必须生成一组fullStar图标。然后string.Join方法将这些单个图标字符串合并成一个字符串。如果评分包含 0.5 或更高的十进制值,我们还会将一个halfStar图标追加到字符串中。

    RatingToStarsConverter中,我们所需做的全部就是这些。我们不需要实现ConvertBack方法,因为这个特定的转换器将不会在TwoWayOneWayToSource场景中使用。当不实现ConvertBack方法时,添加一条注释说明有意不实现是良好的实践。

  8. 接下来,我们想要使用我们新创建的转换器,因此我们需要前往RecipeDetailPage。在那里,我们首先需要做的是将转换器的命名空间添加到我们的 XAML 中,如下面的代码片段所示:

    <ContentPage
        x:Class="Recipes.Mobile.RecipeDetailPage"
    
        xmlns:x="http://schemas.microsoft.com/winfx/2009
          /xaml"
        xmlns:conv="clr-namespace:Recipes.Mobile
          .Converters"
        Title="RecipeDetailPage">
    

    通过声明此 XML 命名空间,我们可以在使用 conv 前缀的此 XAML 页面中直接引用 Recipes.Mobile.Converters 命名空间内的任何内容。前缀可以是您选择的任何内容。要声明它,只需键入 xmlns:(XML 命名空间),然后跟上前缀,并将其设置为要引用的 CLR 命名空间。这种技术允许代码更干净、更有组织,因为您可以使用此前缀来引用指定命名空间中的类和组件。

  9. 现在,我们需要将 RatingToStarsConverter 的一个实例添加到我们的页面中,以便我们可以在后续的绑定语句中使用它。以下代码块显示了如何将转换器的实例作为资源添加到页面中:

    <ContentPage
        ...
        >
        <ContentPage.Resources>
            <conv:RatingToStarsConverter x:Key=
              "ratingToStarsConverter" />
        </ContentPage.Resources>
        ...
    

    通过给资源一个 Key 值,我们可以在以后引用它。

  10. 最后,我们现在可以实施 RatingDetail.AverageRating 属性的绑定,并使用 RatingToStarsConverter 作为此绑定的转换器:

    <Label
        FontFamily="MaterialIconsRegular"
        FontSize="18"
        Text="{Binding RatingDetail.AverageRating,
          Converter={StaticResource
    Binding Markup Extension, we can define Converter. We can reference the instance we’ve declared on top of this page by using the StaticResource Markup Extension and passing in the key value of the converter instance. The converter will return a particular string value that, in combination with the label’s FontFamily set to MaterialIconsRegular, will show icons on the screen.
    

关于资源、StaticResource 和 DynamicResource

我们刚刚添加到资源中的转换器现在可以在这个特定的页面上访问。需要注意的是,此资源的范围仅限于这个页面。这意味着如果您想在其他页面上使用此转换器,您也必须在它们的资源中声明它。

如果您计划在多个页面中使用此转换器,请考虑将其添加到您的 App.xaml 资源中。通过这样做,转换器在整个应用程序中全局可访问,消除了在每个页面上重新声明它的需要。这导致代码更干净、更易于维护,特别是对于像转换器这样的资源,它们通常在整个应用程序中使用。

StaticResource Markup Extension 在资源字典中查找资源并将其分配给设置的属性。此资源查找仅在加载使用 StaticResource 的页面或控件时执行一次。

另一方面,DynamicResource Markup Extension 用于值可以更改且 UI 需要更新以反映此更改的情况。它维护属性和资源之间的链接,因此当资源更改时,属性也会更新。DynamicResource 对于像主题切换这样的场景非常完美,其中资源字典中的值可以更新。

或者,如果您从代码后定义数据绑定,您也可以在代码中表达要使用的转换器。为此,您需要使用 x:Name 属性将您想要显示评分的标签命名为 lblRating

lblRating.SetBinding(Label.TextProperty,
$"{nameof(RecipeDetailViewModel.RatingDetail)}.{nameof
  (RecipeRatingsSummaryViewModel.AverageRating)}",
    converter: new RatingToStarsConverter());

SetBinding 方法接受一个可选的转换器参数,允许您指定应使用的转换器。当您运行应用程序时,您应该看到代表食谱评分的星星,如图下所示:

图 4.3:RatingToStartsConverter 在工作

图 4.3:RatingToStartsConverter 在工作

让我们进一步提升我们应用程序的用户界面和用户体验。为了实现这一点,我们将根据菜谱的平均评分为评分指示器分配独特的颜色。

RatingToColorConverter

使用RatingToColorConverter,我们应该能够根据平均评分给代表菜谱评分的星号上色。此外,我们还想在背景中始终显示四个星号,无论菜谱的平均得分如何。图 4.4**.4展示了我们想要实现的效果:

图 4.4:使用不同颜色的评分指示器

图 4.4:使用不同颜色的评分指示器

这个视觉提示作为一个评分尺度,帮助用户立即理解一个菜谱在评分方面的位置。由于背景星号需要与表示实际得分的星号颜色不同,我们新的 ValueConverter 必须接受一个参数来区分前景和背景颜色。

那么,让我们开始吧!就像我们之前做的那样,我们需要添加一个转换器并实现IValueConverter接口:

  1. 右键点击Recipe.Mobile项目中的Converters文件夹,然后选择添加 | 类…

  2. 输入RatingToColorConverter作为名称,然后点击添加

  3. 让这个类实现IValueConverter接口。

  4. Convert方法中,我们可以检查传入的参数。当将"background"值作为参数传递给这个转换器时,我们希望返回一个稍微不同的颜色:

    bool isBackground = parameter is string param
        && param.ToLower() == "background";
    
  5. 一旦我们有了这些信息,我们就可以继续实现这个方法的其余部分:

    var hex = value switch
    {
        double r when r > 0 && r < 1.4 => isBackground ?
          "#E0F7FA" : "#ADD8E6", //blue
        double r when r < 2.4 => isBackground ? "#F0C085"
          : "#CD7F32", //bronze
        double r when r < 3.5 => isBackground ? "#E5E5E5"
          : "#C0C0C0", //silver
        double r when r <= 4.0 => isBackground ? "#FFF9D6"
          : "#FFD700", //gold
        _ => null,
    };
    Return hex is null ? null : Color.FromArgb(hex);
    

    根据提供的评分,这个转换器返回特定的颜色。除此之外,如果转换器参数是"background",则返回一个稍微不同的颜色强调,这应该作为背景颜色。

  6. 以下代码块展示了我们如何将这个转换器作为资源添加到RecipeDetailPage。这允许我们在页面上使用这个转换器:

    <ContentPage.Resources>    ...
        <conv:RatingToColorConverter
          x:Key="ratingToColorConverter" />
    </ContentPage.Resources>
    

    在设置好之后,我们可以使用定义好的键,"ratingToColorConverter",在我们的数据绑定语句中引用这个转换器。

  7. 最后,用以下代码替换之前显示Rating属性的标签:

    <Grid>
        <Label
            FontFamily="MaterialIconsRegular"
            FontSize="18"
            Text="{Binding RatingSummary.MaxRating,
            Converter={StaticResource
              ratingToStarsConverter}}"
            TextColor="{Binding
              RatingSummary.AverageRating,
            Converter={StaticResource
              ratingToColorConverter},
                ConverterParameter=background}" />
        <Label
            FontFamily="MaterialIconsRegular"
            FontSize="18"
            Text="{Binding RatingSummary.AverageRating,
            Converter={StaticResource
              ratingToStarsConverter}}"
            TextColor="{Binding
              RatingSummary.AverageRating,
            Converter={StaticResource
              ratingToColorConverter}}" />
    </Grid>
    

    通过将两个标签都放置在Grid中,标签将重叠,因此第一个标签将作为评分指示器的背景。这个标签的Text属性绑定到RatingSummary.MaxRating属性,表示评分的上限。RatingToStarsIconConverter将这个值转换为星号图标。此外,它的TextColor属性绑定到 ViewModel 的RatingSummary.AverageRating属性,使用我们新创建的RatingToColorConverter来决定其颜色。请注意,我们已经将Binding Markup ExtensionConverterParameter属性设置为"background"。这个参数被转发到转换器,表示我们需要一个适合背景图标的颜色。

    第二个 Label 类的 TextTextColor 属性也绑定到 RecipeDetailViewModelRatingSummary.AverageRating 属性。RatingToColorConverter 被用作 TextColor 属性的转换器,根据菜谱的评分提供颜色。因为我们在这里没有使用 ConverterParameter,转换器理解它需要生成用于前景的颜色。

这两个简单的值转换器为菜谱的评分提供了一个很好的可视化效果。当运行应用程序时,我们应该看到如图所示的菜谱评分的彩色可视化效果:

图 4.5:使用不同颜色的评分指示器

图 4.5:使用不同颜色的评分指示器

在这些示例中,我们没有实现值转换器的 ConvertBack 方法,因为它们只在 TwoWayOneWayToSource 数据绑定中起作用。让我们快速看一下一个示例。

InverseBoolConverter

一个非常常见且简单的转换器是 InverseBoolConverter:它只接受一个 bool 值并返回其相反值。InverseBoolConverter 的实现通常还包括其 ConvertBack 方法的实现。这尤其重要,因为在双向数据绑定的上下文中,UI 上的操作可以触发绑定 bool 值的更新。通过提供 ConvertBack 的实现,我们确保 UI 的更改能够正确地反映回 ViewModel,保持数据同步。让我们看看如何创建一个实现了 ConvertBack 方法的转换器:

  1. 要创建 InverseBoolConverter,创建一个名为 InverseBoolConverter 的新类,并让它实现 IValueConverter 接口。

  2. 让我们编写一个返回 bool 值并接受一个 object 类型的 value 参数的 Inverse 方法:

    private bool Inverse(object value)
        => value switch
        {
            bool b => !b,
            _ => false
        };
    

    此方法接受一个 object 类型的 value 参数。在这个方法内部,我们检查提供的值是否为 bool 类型。如果是,我们返回 inverse;如果不是,我们返回 false

  3. 此方法现在可以由 ConvertConvertBack 方法使用,因为这两个方法都应该反转给定的 bool 值:

    public object Convert(object value, Type targetType,
      object parameter, CultureInfo culture)
    => Inverse(value);
    public object ConvertBack(object value, Type
      targetType, object parameter, CultureInfo culture)
    => Inverse(value);
    
  4. 要看到这个转换器的实际效果,我们可以转到 RecipeDetailViewModel,将 ShowAllergenInformation 属性更新为 HideAllergenInformation,并将其默认值更改为 true

    private bool _hideAllergenInformation = true;
    public bool HideAllergenInformation
    {
        get => _ hideAllergenInformation;
        set
        {
            if (_hideAllergenInformation != value)
            {
                _ hideAllergenInformation = value;
                OnPropertyChanged();
            }
        }
    }
    
  5. 因为这个属性的现在意义与之前相反,我们需要更新我们的 UI 上的绑定。这正是我们的新 InverseBoolConverter 发挥作用的地方。在我们将 InverseBoolConverter 添加到 RecipeDetailPage 的资源之后,我们可以更新 XAML 如下:

    <HorizontalStackLayout>
        <Label
            FontAttributes="Italic"
            Text="Show Allergen information"
            VerticalOptions="Center" />
        <CheckBox IsChecked="{Binding
          HideAllergenInformation, Mode=OneWayToSource,
            Converter={StaticResource
              inverseBoolConverter}}" />
    </HorizontalStackLayout>
    <Label IsVisible="{Binding HideAllergenInformation,
      Mode=OneWay, Converter={StaticResource
        inverseBoolConverter}}"
        Text="ToDo: add allergen information" />
    

    InverseBoolConverter将反转HideExtendedAllergenList属性值。在OneWay数据绑定场景中,将调用Convert方法,而当点击CheckBox时,将调用ConvertBack方法,这将触发IsCheckedProperty的更新,通过OneWayToSource数据绑定需要更新 ViewModel 上的属性。

ValueConverters 是数据绑定中的一个强大功能,它允许在 ViewModel 和 View 之间无缝转换数据。它们提供了一种干净、可维护的方式来控制数据的显示,并处理 ViewModel 中数据格式与在 View 中显示或输入所需格式之间的差异。

保持转换器简单

请记住,在有很多转换器的屏幕上,这些转换器可能会被多次调用,尤其是在集合中。因此,建议尽可能保持转换器简单,并考虑它们的性能。

作为一名开发者,掌握 ValueConverters 将极大地增强你使用.NET MAUI 构建动态、数据驱动应用程序的能力。在第五章“社区工具包”中,我们将看到.NET MAUI 社区工具包中包含了许多可供你用于项目的转换器。

另一种将 ViewModel 中的数据展示方式转换为转换的方法是提供StringFormat

StringFormat

尽管 ValueConverters 功能更强大,但将StringFormat提供给你的数据绑定可以提供一个快速直接的方法来修改数据在数据绑定表达式中的展示,从而避免为简单的转换创建单独转换器的开销。它利用标准的.NET 格式化约定来将绑定数据塑造成特定的字符串格式。当绑定的数据是原始或内置的.NET 数据类型,如DateTimeintfloatdouble等,并且你希望以特定方式格式化这些数据以供显示时,特别有用。

作为第一个示例,让我们在RecipeDetailPage上显示食谱的卡路里,如图图 4.6所示:

图 4.6:显示卡路里和烹饪时间

图 4.6:显示卡路里和烹饪时间

让我们看看这有多简单来实现:

  1. 将可选的CaloriesReadyInMinutes属性添加到RecipeDetailViewModel中:

    public int? Calories { get; set; } = 240;
    public int? ReadyInMinutes { get; set; } = 35;
    
  2. 现在,由于我们想在屏幕上显示这些属性,我们需要指明这个值的含义。我们不仅仅想显示原始值。为此,我们可以使用多个标签或转换器来丰富这些原始值,并添加额外的上下文。或者,我们可以使用StringFormat,如下所示:

    <Label Text="{Binding Calories,
      StringFormat='Calories: {0} kcal'}" />
    <Label Text="{Binding ReadyInMinutes,
      StringFormat='Ready in: {0} minutes'}" />
    

就像我们在 .NET 中使用 string.Format 方法一样,我们也可以使用 Binding Markup ExtensionStringFormat 属性。在格式字符串中,我们可以使用占位符 ({0}) 来指示绑定值应该插入的位置。这种方法提供了一种简单直接的方法将绑定值集成到格式化字符串表达式中。

并且与 string.Format 的相似性并不仅限于此。我们甚至可以使用数字、时间段以及日期和时间的格式化字符串。

为了演示这一点,让我们在 RecipeDetailPage 中添加一个 LastUpdated 时间戳。让我们看看以下步骤:

  1. 首先,我们需要在 RecipeDetailViewModel 中添加一个名为 LastUpdated 的属性:

    public DateTime LastUpdated { get; set; }
        = new DateTime(2020, 7, 3);
    
  2. 现在,我们可以转到 RecipeDetailPage 并将此值绑定到一个新的标签上:

    <Label
        FontSize="8"
        HorizontalOptions="End"
        Text="{Binding LastUpdated, StringFormat='Last
    string.Format method, we can add a format specifier to a placeholder. In this case, D is a standard DateTime format string representing the long date format specifier. It formats the bound DateTime value into a long date pattern. Of course, we could achieve the same result by creating a ValueConverter, but using the StringFormat property is a lot more concise and straightforward for such simple transformations. It saves us from the additional overhead of defining a separate converter class, thereby keeping our code cleaner and more maintainable.
    
  3. 此外,我们可能还想在屏幕上以文本形式显示平均评分,限制为 1 位小数:

    <Label FontSize="8"
        Text="{Binding RatingDetail.AverageRating,
          StringFormat='string.Format method.
    

让我们检查应用,特别是关注这两个标签。它们看起来是这样的:

图 4.7:利用 StringFormat

图 4.7:利用 StringFormat

ValueConverters 和 StringFormat 不仅促进了从 ViewModel 到更适合我们 UI 的数据的转换,还允许创建更动态、响应和用户友好的应用程序。通过使用 ValueConverters,我们可以处理复杂的转换,而 StringFormat 则帮助我们轻松地在绑定中格式化字符串。这两个机制使我们能够无缝地处理数据转换,而不会使 ViewModel 过载与 UI 相关的担忧。记住,有效的数据绑定不仅关乎数据链接;它还关乎以最直观的方式向用户展示数据。

但如果事情没有按计划进行呢?如果我们绑定到的数据是 null 呢?这就是我们绑定中的 TargetNullValueFallbackValue 处理这种情况并确保更稳健、更安全的用户界面的地方。

回退

有时候数据绑定可能会失败;绑定源无法解析(目前)或返回的值为 null。尽管 ValueConverters 和额外的代码可以解决许多此类情况,但我们也可以通过设置 TargetNullValueFallbackValue 属性来增强数据绑定的鲁棒性。这可以通过在绑定表达式中设置这些属性轻松完成。

TargetNullValue

TargetNullValue 属性可以在我们想要处理解析的绑定源目标返回 null 的情况下使用。换句话说,绑定引擎可以解析绑定的属性,但这个属性返回一个 null 值。

在我们的应用中,RecipeDetailViewModel 上的 Calories 属性被定义为可空的 int 类型。这使得我们在数据绑定中优雅地处理任何潜在的空值变得至关重要。如果我们保持绑定语句不变,当 Calories 属性为 null 时,标签会显示 "Calories: kcal"。看起来不太整洁,对吧?让我们来修复这个问题:

<Label Text="{Binding Calories, StringFormat='Calories: {0}
  kcal', TargetNullValue='No calories information
TargetNullValue property, we dictate what value should be used if the bound property returns null. Note that the defined StringFormat will not apply when TargetNullValue is used! We can do the same thing with the binding of the ReadyInMinutes property:

<Label Text="{Binding ReadyInMinutes, StringFormat='Ready

in: {0} 分钟', TargetNullValue='无烹饪时间

RecipeDetailPage。由于可能并非每次都会为食谱添加图片,我们需要确保提供 TargetNullValue 属性,以便显示默认图片。让我们看看我们如何实现这一点:

  1. 首先,我们需要将 Chapter 04``/Assets/caesarsalad.pngChapter 04``/Assets/fallback.png 图片添加到 Recipes.Mobile 项目的 Resources/Images 文件夹中。最简单的方法是使用操作系统的文件管理器来复制文件。

  2. RecipeDetailViewModel 中添加一个 Image 属性:

    public string Image { get; } = "caesarsalad.png";
    
    1. 接下来,将以下 XAML 添加到 RecipeDetailPage.xaml 中,位于 添加/移除为 收藏按钮之上:
    <Image Margin="-10,10"
      Aspect="AspectFill" HeightRequest="200"
      HorizontalOptions="Fill"
      Source="{Binding Image, TargetNullValue=fallback.png}"
      />
    

    因为在 RecipeDetailViewModel 中设置的值是 caesarsalad.png,所以应用会在屏幕上显示这张图片。然而,如果你将其设置为 null,则会显示定义好的 fallback.png 图片,因为它被指定为 TargetNullValue图 4**.8 展示了这种情况:

Figure 4.8: 显示食谱的图片(左)或回退值(右)

Figure 4.8: 显示食谱的图片(左)或回退值(右)

并非太复杂,对吧?当涉及到 ValueConverter 时,事情会变得稍微复杂一些。如果绑定的属性是 null,则此 null 值将被传递给 ValueConverter。只有当转换器返回 null 时,才会使用 TargetNullValue。如果 ValueConverter 返回非空值,则不会使用 TargetNullValue。虽然可以将 TargetNullValue 定义为 StaticResource 或使用 x:Static Markup Extension 来分配一个静态值,但无法使用绑定表达式设置其值。

查看 RecipeRatingsSummaryViewModelAverageRating 属性,我们可以将其默认值设置为 null 并更新 TextColor 绑定语句:

TextColor="{Binding RatingDetail.AverageRating,
Converter={StaticResource ratingToColorConverter},
ConverterParameter=background, TargetNullValue={x:Static
HotPink because RatingToColorConverter returns null when the provided value is null. When we update RatingToColorConverter so that it returns a default color if the value doesn’t fall within the expected range, the TargetNullValue will not be used:

public object Convert(object value, Type targetType, object

parameter, CultureInfo culture)

{

...

var hex = value switch

{

...

_ => "#EBEBEB"

};

return Color.FromArgb(hex);

}


 `TargetNullValue` can be very useful for handling properties that might return a null value. However, it won’t be helpful if the property or its source is inaccessible or doesn’t exist since it isn’t a null value issue but a problem with resolving the property itself. For example, in our app, it could be that the `RatingDetail` property of `RecipeDetailViewModel` is still null because it’s not (yet) loaded. For that, we can use the `FallBackValue` property.
FallbackValue
`FallbackValue` is used when the binding engine is unable to retrieve a value due to an error or if the source itself is `null`, rather than when the resolved binding source returns `null`. As an example, we can set the `RatingDetail` property on `RecipeDetailViewModel` to `null` instead of assigning it a new instance and update the following data binding:

<Label FontSize="8"

Text="{Binding RatingDetail.AverageRating,

StringFormat='{0:0.#} avg. rating',

Label 类在绑定引擎无法解析 RatingDetail.AverageRating 属性时显示 "Ratings not available"。就像 TargetNullValue 属性一样,当使用 FallbackValue 时,StringFormat 属性将被忽略。此外,当使用 FallbackValue 值时,此绑定语句上定义的转换器也将被忽略。

如果我们希望结合使用 TargetNullValueFallbackValue,我们可以这样做:

<Label
    FontSize="8"
    Text="{Binding RatingDetail.AverageRating,
      StringFormat='{0:0.#} avg. rating',
        FallbackValue='Ratings not available',
          "No ratings yet" will be displayed when the AverageRating property is set to null, whereas "Ratings not available" will be shown when the AverageRating property cannot be resolved due to the RatingDetail property being null.
Both `TargetNullValue` and `FallbackValue` are very valuable properties of the `Binding Markup Extension` and are very often overlooked. However, they can help tremendously in creating simple and easy-to-maintain UIs that make sense to the user. When both `FallbackValue` and `TargetNullValue` are defined in a binding, `TargetNullValue` takes precedence when the source property is null. `FallbackValue` is used when the binding system is unable to get a property value, such as when the path is incorrect or the source is not available. So, essentially, `TargetNullValue` is used for null values, while `FallbackValue` is used for binding errors.
Up until now, we’ve been binding to data on our ViewModel, but we can also bind to other elements in our visual tree. Let’s have a look at element and relative data binding.
Element and relative binding
The versatility of data binding extends beyond linking our Views with ViewModel data. It’s also possible to bind to different elements within our visual tree, opening up many new possibilities.
Both **element bindings** and **relative bindings** serve the purpose of allowing bindings to other elements. However, they differ in how they identify the source element:

*   In an element binding, you specify the source element by its name, which is defined by using the `x:Name` attribute in XAML. The binding refers to this specific named element.
*   In a relative binding, you refer to the source element concerning the position of the current element in the XAML tree. For example, you might bind to a property of the parent element or a property of the next sibling element, or you might even bind to a property of the element itself.

Let’s have a look at both types of binding in more detail. First up: element binding.
Element binding
With element binding, we can bind to the property of another element by referencing that element by its name. For example, in our *Recipes!* app, we could remove the `HideExtendedAllergenList` property from the ViewModel and update our XAML to this:

<Label Text="显示扩展过敏原列表?"

VerticalOptions="Center" />

<VerticalStackLayout Margin="10,0,0,0" IsVisible="{Binding

IsChecked, Source={Reference cbShowAllergens}}">

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(23)  评论(0)    收藏  举报