本书与众不同的地方在于其对 .NET MAUI 的专注。我们不仅泛泛而谈 MVVM,而是深入探讨其在特定框架中的实际应用。即使你是 MVVM 或 .NET MAUI 的初学者,这本指南也旨在使其既易于接近又富有成效。
我们的旅程从深入探索 MVVM 设计模式和 .NET MAUI 框架的核心组件开始。随着我们的进展,我们将导航数据绑定的复杂性,利用社区工具包的力量,以及有效地管理集合。我们将进一步深入理解依赖注入、服务模式和消息传递,你将学习关于不同导航方法的知识,理解用户输入和验证,并掌握处理远程数据的技术。后几章将指导你创建适合 MVVM 的控件,本地化你的应用程序,以及单元测试的关键方面。
本书面向的是那些希望在 .NET MAUI 的背景下利用 MVVM 模式力量的开发者。无论你是刚开始接触 .NET MAUI 的爱好者,还是希望提升自己在 .NET MAUI 和 MVVM 方面的专业水平的资深专业人士,这本指南都能满足你的需求。
虽然不需要有 .NET MAUI 或 Xamarin.Forms 的先前经验,但具备 C# 的基础知识是必不可少的。熟悉 C# 的开发者会发现内容易于接近且结构化,可以无缝地将技能提升到 .NET MAUI 和 MVVM 的美妙世界。
在本书的早期,我们将说明如何使用 Visual Studio 2022 或 Visual Studio Code 设置您的开发环境。本书中描述的步骤基于 Visual Studio,但所有解释和展示的内容也可以在任何支持.NET MAUI 的 IDE 中完成。
在本部分中,我们首先讨论 Model-View-ViewModel 设计模式,而不将其与特定平台绑定。接下来,我们将介绍.NET MAUI 框架,并强调为什么它与 MVVM 配合得如此之好。随着我们继续前进,您将了解社区工具包的好处,并亲身体验集合。这个基础为您在以后学习更高级的主题奠定了基础。
在本章中,我们将学习 MVVM 设计模式及其核心组件。我们将探讨该模式在关注点分离方面带来的附加价值以及为什么这如此重要。一个示例应用将展示 MVVM 可以为您的代码带来的附加价值。最后,我们将讨论一些关于 MVVM 的常见误解。
在深入探讨.NET MAUI 中 MVVM 的细节之前,熟悉 MVVM 模式的核心组件至关重要。在接下来的部分中,我们将定义关键术语,以确保我们有一个坚实的基础和共同的了解,以便继续前进。
随着应用程序规模和复杂性的增长,将业务逻辑放在代码背后(code-behind)很快就会变得具有挑战性。代码背后指的是将业务逻辑放置在与用户界面元素相同的文件中的做法,这通常会导致大量代码通过事件处理器被调用。这通常会导致 UI 元素和底层业务逻辑之间的紧密耦合,因为 UI 元素在代码中被直接引用和操作。因此,调整 UI 和执行单元测试可能会变得更加困难。在本章的“MVVM 实战”部分,我们将看到在代码背后放置业务逻辑意味着什么,以及它如何复杂化维护性、测试等。
初看起来,MVVM 可能有点令人不知所措或困惑。为了完全理解这个模式是什么以及为什么它如此受 XAML 开发者的欢迎,我们首先需要剖析 MVVM 模式并查看其核心组件。
如果你想要有效地使用 MVVM 模式,重要的是你不仅要了解每个组件的责任,还要了解它们之间是如何相互作用的。在较高层次上,视图(View)了解视图模型(ViewModel),而视图模型(ViewModel)了解模型(Model),但模型(Model)对视图模型(ViewModel)并不知情。这种分离防止了 UI 和业务逻辑之间的紧密耦合。
此外,在大多数情况下,模型(Model)是不同类型对象的组合。不要将模型视为单一的事物。实际上,它包括视图和 ViewModel 以外的所有“外部”内容——它是应用程序的领域和业务逻辑。最终,模型类型的选取将取决于应用程序的具体需求、用例和架构。一些开发者可能更喜欢使用 DTOs(数据传输对象)以实现简单易用,而其他人可能更喜欢使用领域实体以实现更好的封装和与领域模型的保持一致。
然而,无论使用的是哪种具体的模型类型,都重要的是将业务逻辑保持在模型“层”内,并在 ViewModel 中避免它。这有助于保持关注点的分离,并使 ViewModel 专注于展示逻辑,而模型则专注于业务逻辑。
视图负责向用户展示数据。它由(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 成为可能,从而在适应和随时间演变应用程序方面提供了更大的灵活性。
ViewModel 位于 Model 和 View 之间。它是 UI 和业务逻辑之间的“粘合剂”。其职责是向 View 暴露数据以供显示,并处理任何用户输入或 UI 事件。它为 View 提供了一个与包含应用程序实际数据和业务逻辑的 Model 交互的接口。让我们逐步深入了解 ViewModel 的功能:
ViewModel 充当中间人,处理 View 和 Model 之间数据和行为的流动,确保用户界面准确反映底层数据并对用户交互做出正确响应。这种模块化方法,即它将特定责任与 View 和 Model 分开处理,有助于编写更干净、更易于维护的代码。最后,重要的是要理解 ViewModel 不应与任何特定的 UI 框架绑定。独立于 UI 框架允许它在不同的应用程序之间共享,并便于从应用程序的其他部分独立进行测试。
命令可以看作是传统事件驱动编程中事件的等价物。命令和事件都作为处理应用程序中用户操作或其他触发器的机制。例如,当用户在视图中点击按钮时,ViewModel 中的命令被触发,ViewModel 采取适当的行动,例如保存数据或获取新信息。命令非常灵活,不仅限于按钮。它们可以与各种 UI 元素相关联,如菜单项、工具栏按钮,甚至是手势控制。让我们举一个实际例子:假设你有一个文本编辑应用程序。你可以有一个与Execute方法关联的命令,当调用该方法时执行必要的操作。
在 MVVM 模式中使用命令的一个关键好处是,它们允许视图和 ViewModel 之间更好地分离关注点。通过使用命令,视图可以设计而无需了解与特定 UI 元素相关的底层功能。这意味着 ViewModel 可以处理与命令相关的所有逻辑,而无需与 UI 紧密耦合。
为了将命令绑定到视图中的 UI 元素,命令需要由 ViewModel 作为公共属性公开。然后视图可以使用数据绑定绑定到这个属性。当命令绑定到视图中的 UI 元素时,该 UI 元素会监听事件,例如按钮点击。当该事件被触发时,包含响应事件的代码的命令的Execute方法被调用。
数据绑定是 MVVM 的核心特性,它允许 ViewModel 以松耦合的方式与视图通信,以及视图与 ViewModel 之间的通信。数据绑定允许您将 ViewModel 中的数据属性绑定到视图中的 UI 元素,例如输入字段、标签和列表视图。它用于同步视图和 ViewModel 之间的数据。当 ViewModel 中的数据发生变化时,数据绑定引擎会更新视图,反之亦然,具体取决于绑定的配置方式。这允许 UI 反映应用程序的当前状态。
数据绑定是一个非常强大的概念,是 MVVM 模式中的一个基本组件。它允许视图模型以简单的属性形式向视图公开数据和行为,这种方式与 UI 框架无关。通过数据绑定,这些属性可以以松耦合的方式绑定到 UI。通过使用数据绑定,MVVM 中的视图和视图模型可以无缝同步,无需任何手动代码。绑定模式决定了数据如何在视图和视图模型之间传播。此外,数据绑定还允许视图通过命令将用户输入回传到视图模型,然后视图模型可以更新模型。
在实际应用中,关注点分离意味着将系统的不同方面分离并独立处理,不重叠关注点。这是通过创建具有各自明确责任和接口的独立层或模块,并最小化它们之间的依赖关系来实现的。
让我们以一家餐厅的管理系统为例。在这样的系统中,可能会有几个关注点,如餐桌预订、点餐、厨房操作和结账。根据关注点分离的原则,这些领域中的每一个都应该由一个独立的模块来处理。
这种分离意味着,如果您需要更改餐桌预订的工作方式,您可以在不影响厨房操作或结账的情况下进行更改。这种分离使代码库更加有序,更容易维护,并允许对单个模块进行集中的测试。
例如,视图可以很容易地更改或更新。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 的情况下进行有效测试。
让我们看看一个非常简单的应用程序,它会在屏幕上显示用户的每日名言。当用户点击屏幕上的按钮时,应用程序将从 API 获取每日名言并显示在屏幕上。一旦按钮被点击,它应该被隐藏。代码-behind 方法将向您展示如何在不使用 MVVM 模式的情况下编写此应用程序,而第二个示例则展示了使用 MVVM 实现相同功能,并考虑了可测试性。
在下面的代码片段中,没有关注点的分离;所有的代码都在 XAML 页面的代码-behind 中处理。虽然这段代码看起来似乎在执行预期的操作,但没有简单、快速或健壮的方法来测试它是否工作。这是因为所有的逻辑都在按钮的点击事件处理器中处理。
现在,让我们看看如何使用 MVVM 模式来转换这个应用程序,同时考虑到关注点的分离和可测试性。可能这个例子中的一些内容现在还不清楚,这是完全可以接受的。在阅读这本书的过程中,所有这些方面都应该会很快变得清晰。
在我们的示例应用程序中,我们正在处理的主要数据是从 API 获取的名言。与这个 API 通信以获取数据的逻辑可以被视为模型。我们不再像之前那样直接在代码-behind 中发起 HTTP 请求,而是可以将这个逻辑封装到一个单独的类中。这个类将负责获取名言,并充当我们的模型。下面是这个类的可能样子:
立即引人注目的是,MVVM 示例中的代码更多。这主要是因为 ViewModel。幸运的是,ViewModel 应该相当简单。它们不应该包含任何业务逻辑。在这个例子中,业务逻辑位于QuoteService类中,ViewModel 会调用它来获取QuoteOfTheDay值。ViewModel 上的属性用于表示 View 的状态,例如控制按钮和标签的可见性,以及保存QuoteService将返回的Quote。
每个组件只有一个改变的理由,这使得代码比将所有内容放在代码后部更容易维护。与之前的示例相比,不仅可维护性提高了,而且可测试性也得到了很大提升。不要只听我的话;让我们看看我们如何测试我们应用程序的功能。
与具有代码背后实现的先前的例子相比,测试变得具有挑战性:必须部署应用程序,并使用 UI 测试框架来启动应用程序,与 UI 控件交互,并验证 UI 是否显示预期的内容。自动化 UI 测试的编写、运行和维护可能很耗时。然而,您是否更愿意完全依赖手动测试和 QA,而不是利用自动化测试的优势来确保应用程序行为的质量和可靠性?
当使用 MVVM 模式时,编写单元测试来测试应用程序的不同区域变得非常容易,因为它促进了关注点的分离,并且应该是 UI 框架无关的。当所有内容都在代码背后时,通过自动化 UI 测试来测试业务逻辑会变得非常复杂,难以维护,并且很快就会出错。UI 测试有其目的,因为它们可以测试应用程序的用户界面是否按预期运行。这两种类型的测试都很重要,在确保应用程序质量方面发挥着不同的作用。但是(自动化的)UI 测试应该只做这件事:测试 UI。您的 ViewModel 和业务逻辑应该已经通过其他自动化测试进行了测试。
虽然 MVVM 的主要目的是将表示逻辑与应用逻辑分离,但这并不意味着代码隐藏中不应该有任何代码。代码隐藏仍然可以用来处理简单的 UI 相关事件或与视图紧密耦合的任何逻辑。
实际上,在某些情况下,将一些代码放在代码隐藏中可能比尝试将所有内容移动到视图模型中更高效、更易于维护。例如,处理 UI 动画、滚动、控制焦点或复杂的视觉行为可能比通过数据绑定实现更容易。
虽然在 MVVM 中使用模型的对象类型一直是一个有争议的话题,但在我看来,这并不是一个关键因素。MVVM 的主要原则是将业务逻辑从视图中移除,并在视图模型中只包含简单的验证逻辑。因此,模型可以是任何对象类型,通常是不同类型对象的组合。模型不是单一类型的事物;它是包含应用程序实体、业务逻辑、存储库等所有“在视图和视图模型之外”的内容。重要的是要记住,视图和视图模型不应包含任何业务或持久化逻辑。
虽然视图模型不应该了解视图以保持关注点的分离,但视图可以引用视图模型。需要注意的是,只要视图模型不依赖于视图,这并不违反 MVVM 的原则。在.NET MAUI 等平台上使用“编译绑定”可以提供显著的性能提升,但为了使它们能够工作,视图必须了解它所绑定的类型。
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 框架开发跨平台应用程序。
重要的是要注意,在.NET MAUI 中拥有这个共享代码库并不会限制你使用 C#访问原生平台特定 API。实际上,.NET MAUI 旨在允许开发者访问原生功能,同时保持统一的代码库。.NET MAUI 丰富的跨平台 API 库为常见任务提供了一个抽象层,这些任务通常是特定平台的。然而,当某个功能需要.NET MAUI 的跨平台 API 中不可用的原生平台 API 时,你仍然可以直接通过 C#使用原生平台 API。通过部分类、编译器指令或依赖注入等机制,.NET MAUI 确保开发者能够为他们的应用实现最佳程度的定制和功能。
事实上,.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。
但我们不要忘记.NET MAUI 不仅仅只是 UI 相关。.NET MAUI 提供了跨平台 API,这些 API 抽象了常见任务的平台特定实现,使开发者能够使用单个统一的 API 访问原生设备功能——访问设备的指南针、地理位置、文件系统等——仅举几个例子。这使我们能够为这些任务编写平台无关的代码,进一步简化开发过程并促进不同平台间的代码重用。.NET MAUI 提供的跨平台 API 包括以下内容:
Xamarin.Essentials 是一个开源库,旨在为移动应用程序创建跨平台的 API,作为常见平台特定任务的抽象层。随着 Xamarin.Forms 向.NET MAUI 的演变,这些 API 现在更无缝地集成到框架本身中。这意味着在.NET MAUI 应用程序中不需要 Xamarin.Essentials NuGet 包。
例如,如果我们想让我们的应用程序通过自定义 URI 方案激活,我们可能希望拦截触发应用程序打开的 URL 并对其做出反应。要获取该 URL,只能在每个平台的特定生命周期事件上完成:Android 上的OnCreate方法和 iOS 上的OpenUrl。我们可以分别在MainActivity或AppDelegate中重写这些方法,就像我们以前使用 Xamarin.Forms 一样。或者,我们可以使用前面提到的ConfigureLifecycleEvents钩入这些平台事件:
.NET MAUI 还引入了一种全新的单项目结构。与每个目标平台都有一个项目以及一个共享代码项目不同,使用.NET MAUI,我们可以从单个项目构建针对不同平台的应用程序。不仅共享代码,还包括平台特定的实现、资源,如图像、字体和应用程序图标,都直接包含在这个单一的项目中。
因此,对于一个标准的 .NET MAUI 项目,这意味着我们针对的是 Android、iOS、Mac Catalyst 和 Windows – 后者仅在 Windows 上运行时。
在此下拉菜单中选择一个条目不会影响编译。所选平台的作用是为该特定平台配置开发环境。这意味着,当使用预处理器指令时,它会显示特定平台的代码,并确保智能感知和代码导航功能调整以在特定平台的代码上工作。
既然我们已经对 .NET MAUI 有了一个很好的理解,你可能会想知道这实际上是如何工作的。这段 .NET 代码最终是如何变成在不同平台上具有本地 UI 的原生应用的?这并不是魔法,但要理解它是如何工作的,我们需要看看它的内部结构。
.NET for Android、.NET for iOS 和 .NET for Mac 提供了对平台特定 API 的绑定,使开发者能够使用熟悉的 .NET 结构访问特定的功能和控件。在 Android 上,当应用程序编译时,.NET 代码与 Mono 运行时一起打包,并在 Android 设备上使用 即时编译 (JIT )执行。由于苹果对 JIT 编译的限制,.NET for iOS 应用程序使用 提前编译 (AOT )编译,将 .NET 代码转换为在 iOS 设备上直接运行的本地 ARM 代码。
尽管 .NET for Android 和 .NET for iOS 提供了一种共享大量代码的方法,但为每个平台创建和维护具有原生 UI 的应用程序仍然可能非常具有挑战性。开发者需要精通每个平台的具体细节,并深入了解它们之间的差异。这可能导致更高的开发成本、更长的上市时间和增加的维护工作量。
正是在这里,.NET MAUI 出现了。它提供了一种方便且高效的方法,使用单个代码库为多个平台创建真正的原生移动应用程序。.NET MAUI 在之前提到的 .NET 平台上添加了一个抽象层,使我们能够为它们构建共享的 UI (图 2**.5 )。
您的 .NET MAUI 应用中的代码主要与 .NET MAUI API 交互,该 API 又反过来与原生平台 API 进行通信。此外,您的 .NET MAUI 代码可以直接访问特定平台的 API,以利用独特的平台功能或自定义。
与 Xamarin.Forms 的渲染器实现相比,处理器的架构提供了几个优点:改进的性能、更容易的自定义和更好的可维护性,这使得它在从 Xamarin.Forms 过渡到 .NET MAUI 时成为一个重要的改进点。
现在我们已经涵盖了大量的理论,并且您了解了底层的工作原理,是时候卷起袖子开始创建一些有形的东西了。我知道您迫不及待地想要深入其中,那么让我们开始构建我们的第一个 .NET MAUI 应用程序!
让我们创建我们的第一个 .NET MAUI 应用!在我们开始编写任何代码之前,我们需要通过安装一些组件来设置我们的机器。所以,让我们一起走过这些初始设置步骤,看看我们有哪些选择。一旦设置完成,我们就会进入令人兴奋的部分:从头开始逐步创建一个 .NET MAUI 应用。
开始使用 .NET MAUI 进行开发相当简单,尤其是如果您使用 Visual Studio 作为您的 IDE。即使您不想使用 Visual Studio,安装过程也应该相当直接。
为了确保您的 .NET MAUI 工作负载保持稳定状态,重要的是坚持以下选项之一:您要么使用 Visual Studio 安装并从那里管理它,要么从命令行进行操作。不要混合使用两种方法!
当使用 Visual Studio(2022 17.3 或更高版本)时,我们可以使用 Visual Studio 安装程序来安装 .NET MAUI 工作负载。这是迄今为止安装和管理您的工作负载最简单的方法。.NET MAUI 开发支持所有版本的 Visual Studio:从免费的社区版到付费的企业版。
这应该会为您提供所有已安装 .NET SDK 的概述。如前所述,.NET 6 是进行 .NET MAUI 开发所需的最低 .NET SDK。如果前面的命令失败,如 图 2**.11 所示,这意味着 .NET 尚未安装到您的机器上。您应该访问 dotnet.microsoft.com/download/dotnet ,选择 .NET 的最新版本,并下载适当的安装程序或二进制文件。
现在我们已经将首选的开发环境全部设置好,让我们创建一个 .NET MAUI 应用!请注意,在本书中,说明流程与 Visual Studio 中的流程相同。然而,在您选择的 IDE 中跟随应该不会成问题。
相比之下,如果你连接到 Mac,你也可以选择 iOS 模拟器,并看到你的原生 iOS 在 iOS 模拟器上运行!如果你有连接到你的 PC 的 Android 或 iOS 设备,它也应该出现在列表中。选择它并运行应用程序应该会将你的应用程序部署到你的物理设备上。
当您通过在 Windows PC 上使用热重启部署到物理 iOS 设备时,您的启动画面将不会更新,并且将保留标准的 .NET 启动画面。尽管您已正确配置了自定义启动画面,但这仍然是热重启的限制。为了验证您的启动画面,最好将其部署到连接到 Mac 的物理设备上。
在本章中,我们提供了 .NET MAUI 的概述:它是什么,它是如何工作的,以及如何使用 .NET MAUI 创建跨平台应用。我们详细介绍了创建应用的过程,包括启动画面和应用图标。此外,我们还探讨了 .NET 热重载和 XAML 热重载,这些功能使我们能够在应用运行时更新代码,大大提高了我们的效率。现在,你已经对 .NET MAUI 和 MVVM 设计模式有了全面的理解,我们可以继续我们的旅程,探索如何在 .NET MAUI 框架中有效地应用此模式。
在前面的章节中,我们熟悉了 MVVM 模式的核心概念,并探讨了.NET MAUI 的基础知识。在了解了 MVVM 原则和.NET MAUI 的能力之后,我们现在可以开始探讨如何将 MVVM 应用于.NET MAUI。
数据绑定是.NET MAUI 中的关键组件,它是 MVVM 模式的关键推动者。在本章中,我们将重点关注促进.NET MAUI 中数据绑定的基本概念、组件和技术。这些关键元素连接了应用程序的视图和 ViewModel 层,实现了高效的通信并确保了关注点的清晰分离。
到本章结束时,你将深入理解.NET MAUI 附带的基本数据绑定构建块。这将帮助你理解.NET MAUI 中数据绑定的内部工作原理以及每个组件的作用。有了这个基础,你将准备好探索下一章中更高级的主题和技术。
在我们深入关键组件之前,让我们回顾一下为了在.NET MAUI 应用程序中有效地使用数据绑定,我们需要理解哪些元素。这些元素在促进视图和 ViewModel 层之间的通信中发挥着至关重要的作用,使得数据和使用者交互的无缝同步成为可能:
在代码中定义数据绑定既简单又直接。然而,大多数情况下,数据绑定是在 XAML 中定义的。我个人认为在 XAML 中定义它们感觉更自然,并且在使用 XAML 创建 UI 时需要更少的上下文切换。那么,让我们看看吧!
在大多数应用中,用户交互起着至关重要的作用。常见的交互包括点击按钮、从列表中选择项目、切换开关等。为了在遵循 MVVM 模式的同时有效地处理这些交互,利用一个封装必要逻辑在 ViewModel 中的强大机制是至关重要的。ICommand接口正是为此目的而设计的,它允许您以干净和可维护的方式管理用户交互,同时确保视图和 ViewModel 之间有明确的关注点分离。在本节中,我们将探讨如何实现和使用ICommand来处理.NET MAUI 应用程序中的用户交互。
在上一章中,我们介绍了 .NET MAUI 数据绑定的基础知识。数据绑定不仅是 .NET MAUI 的核心功能,也是使用 MVVM 设计模式有效构建应用程序的关键组件。它创建了一个强大的链接,将您的 View 和 ViewModel 之间连接起来,促进了两者之间高效通信和同步。
随着我们深入数据绑定的领域,掌握一些高级技术和功能是至关重要的。这些是您能够尽可能高效地创建动态用户界面的基石。它们使我们能够设计出不仅更互动,而且更容易管理和维护的用户界面。
ValueConverter 在源(通常是 ViewModel)和目标(View)之间充当中间人。它提供了一种在数据从源到目标或反之亦然传递时转换或转换数据的方法(图 4**.1 ):
一个常见的场景可能涉及 ViewModel 属性,它是一种特定类型,例如枚举或复杂对象,需要在 UI 中以不同的方式显示。ValueConverter 可以将数据从一种类型转换为与 UI 兼容和适当的另一种类型。同样,在 UI 中接收到的用户输入可能需要在存储到 ViewModel 之前转换成不同的格式。
为了展示 ValueConverters 的灵活性和强大功能,我们将通过引入评分指示器来增强我们的应用。仅仅显示一个数值并不是表示菜谱评分最吸引人或直观的方式。因此,我们将利用 ValueConverter 将这些数字替换为星形图标,创建一个视觉上吸引人且用户友好的评分表示。我们的自定义 ValueConverter,RatingToStarsConverter,将双精度值转换为字符串。结合特定的字体,这个字符串将显示为星形图标。但在我们开始构建转换器之前,我们需要先做一些准备工作:
在应用程序中显示图标的一种高效方法是使用专门的图标字体。这些字体既免费又可购买,允许您轻松地将各种图标集成到您的应用程序中。原理很简单:将所需的图标字体集成到您的应用中,然后在您希望显示图标的 Label 类上将其分配为 FontFamily。从那里,剩下的只是将 Label 类的 Text 属性设置为对应于您要显示的图标的值。
我们刚刚添加到资源中的转换器现在可以在这个特定的页面上访问。需要注意的是,此资源的范围仅限于这个页面。这意味着如果您想在其他页面上使用此转换器,您也必须在它们的资源中声明它。
这个视觉提示作为一个评分尺度,帮助用户立即理解一个菜谱在评分方面的位置。由于背景星号需要与表示实际得分的星号颜色不同,我们新的 ValueConverter 必须接受一个参数来区分前景和背景颜色。
ValueConverters 是数据绑定中的一个强大功能,它允许在 ViewModel 和 View 之间无缝转换数据。它们提供了一种干净、可维护的方式来控制数据的显示,并处理 ViewModel 中数据格式与在 View 中显示或输入所需格式之间的差异。
RecipeDetailPage。由于可能并非每次都会为食谱添加图片,我们需要确保提供 TargetNullValue 属性,以便显示默认图片。让我们看看我们如何实现这一点:
...
...
StringFormat='{0:0.#} avg. rating',
Label 类在绑定引擎无法解析 RatingDetail.AverageRating 属性时显示 "Ratings not available"。就像 TargetNullValue 属性一样,当使用 FallbackValue 时,StringFormat 属性将被忽略。此外,当使用 FallbackValue 值时,此绑定语句上定义的转换器也将被忽略。
In the preceding example, we are binding the `IsVisible` property of `VerticalStackLayout` to the `IsChecked` property of the `cbShowAllergenList` `CheckBox`. This eliminates the need for an additional property on `RecipeDetailViewModel` and keeps the ViewModel clean and focused.
This direct connection between UI components streamlines the logic, reduces the ViewModel’s responsibilities, and increases the maintainability of our code. It’s a clear demonstration of how element binding can simplify interactions within the user interface.
Relative binding
Relative binding in XAML provides a way to set the source of a binding relative to the position of the binding target in the UI tree, and it can reference either the binding target itself or one of its ancestors.
The three main forms of relative binding are as follows:
* **Self**: This mode is used to bind a property to another property on the same element. It’s useful when one property depends on the value of another.
* **FindAncestor**: This mode is used to bind a property to a property on an ancestor element in the visual tree. You can specify the type of the ancestor element and how far up the visual tree to search.
* `ControlTemplate` to bind a property to a property on the control the template is applied to. It is particularly useful when creating custom templates for a control.
Let’s have a look at these three forms of relative binding in more detail.
Self-relative binding mode
As an example of the self-relative binding mode, let’s take a look at the two buttons in our app that allow the user to set or remove a recipe as a favorite. Wouldn’t it be nice if we could hide the disabled button? We can easily achieve this by using the `Self` relative binding mode:
<Button
Command="{Binding AddAsFavoriteCommand}"
IsVisible="{Binding IsEnabled, Source={RelativeSource
Self}}"
Text="添加为收藏" />
`AddAsFavoriteCommand` is responsible for setting the button to enabled or disabled, depending on what the `CanExecute` method returns. With this relative binding, we are binding the `IsEnabled` property of the button itself to the `IsVisible` property. Now, when the button is disabled, it is not shown on the UI.
FindAncestor relative binding mode
In relative bindings, `AncestorType` is useful when you need to bind to a property of a parent element in the visual tree. It essentially “walks up” the tree of UI elements until it finds an instance of the specified type. By specifying `AncestorLevel`, we can define which ancestor to bind to. By default, `AncestorLevel` is 1, meaning it will bind to the nearest ancestor of the specified type. However, if you set `AncestorLevel` to 2, it will bind to the second nearest ancestor of the specified type, and so on. This offers a great deal of flexibility and control in choosing the specific ancestor in the visual tree that you want to bind to.
As a simple example, we can give the root `VerticalStackLayout` a `BackgroundColor` of `GhostWhite`. Now, if we want to bind the `TextColor` property of the two buttons on the bottom of the page to the `BackgroundColor` property of `VerticalStackLayout`, we could write the following:
<Button
BackgroundColor="LightSlateGray"
...
TextColor="{Binding BackgroundColor,
Source={RelativeSource AncestorLevel=2,
按钮的TextColor属性现在绑定到其第二个祖先(AncestorLevel=2)的BackgroundColor属性,该祖先为VerticalStackLayout类型(AncestorType={x:Type VerticalStackLayout})。请注意,当页面结构发生变化时,第二级可能没有其他此类祖先。
TemplatedParent relative binding mode
Control Template是一个 XAML 标记片段,它定义了控件应该如何渲染。当您处于控件模板内部时,您可以使用TemplatedParent来绑定到使用该模板的控件的属性。我们将在第十一章 中更详细地探讨这一点,创建 MVVM-Friendly Controls 。
Relative bindings in XAML offer a powerful way to connect properties of different elements within our user interface. One of its strongest aspects is its ability to traverse up the visual tree, enabling access to binding contexts of other elements. This feature becomes especially useful when the current element’s binding context isn’t sufficient or when we need to link a property to an element outside of its immediate scope.
在许多 UI 场景中,某种状态是由多个属性的组合定义的。虽然当然可以在 ViewModel 中创建一个额外的属性来聚合这些属性以进行绑定,但有一种更好、更优雅的方式来处理这种情况。让我们看看多绑定。
Multi-bindings
多绑定是 XAML 数据绑定中的一个强大功能,它允许您将单个目标属性绑定到多个源属性,然后应用逻辑以产生一个单一值。这种技术在目标属性值依赖于多个源属性时特别有用。这个最简单的例子就是使用StringFormat。
Multi-binding StringFormat
一个典型的多绑定场景是您希望在单个标签中显示多个值。我们当然可以在 ViewModel 上创建一个属性来连接这些值,或者我们可以定义一个使用StringFormat的多绑定。
例如,我们想在页面上已经有的最后更新 时间戳旁边显示食谱的作者(图 4**.9 ):
图 4.9:显示作者与最后更新时间戳并排
下面是如何实现的:
首先,让我们在我们的 ViewModel 中添加一个Author属性:
public string Author { get; set; } = "Sally Burton";
接下来,将显示LastModified时间戳的标签替换为以下内容:
<Label FontSize="8" HorizontalOptions="End">
<Label.Text>
<MultiBinding StringFormat="Last updated:
{0:D} | {1}">
<Binding Path="LastUpdated" />
<Binding Path="Author" />
</MultiBinding>
</Label.Text>
</Label>
MultiBinding 类允许我们设置多个绑定。MultiBinding 的 StringFormat 属性允许我们从多个绑定中构建一个单一的字符串值。这类似于 string.Format 方法,使用不同的占位符({0}、{1}、{2} 以及如此等等)对应于每个绑定。这使得从多个数据源构建复杂的字符串值变得更加容易。
绑定属性
在 MultiBinding 中定义的 Binding 与我们之前在数据绑定中使用的 Binding Markup Extension 是同一回事。它具有 Converter、ConverterParameter、StringFormat、TargetNullValue、FallbackValue 等属性,可以为 MultiBinding 中的每个 Binding 单独配置,从而实现对 MultiBinding 每个组件的精细控制。
多绑定不仅限于以特定格式连接字符串。让我们看看 MultiBinding 的 Converter 属性。
IMultiValueConverter
MultiBinding 类有一个名为 Converter 的属性,其类型为 Microsoft.Maui.Controls.IMultiValueConverter。此接口类似于 IValueConverter,但有一个显著的区别。IMultiValueConverter 中的 Convert 方法接受一个对象数组,代表所有单独绑定的值,而不是像 IValueConverter 中的单个对象。同样,IMultiValueConverter 的 ConvertBack 方法返回一个对象数组,而不是单个对象。
让我们更新 RecipeDetailView 中的 RatingIndicator。星星的颜色不仅应取决于平均评分,还应取决于评论总数。如果食谱的评论少于 15 条,我们将显示一个通用的默认颜色。只有当食谱有更多评论时,我们才会使用之前使用的颜色刻度。为了实现这一点,我们将使用 MultiBinding 来绑定 RecipeRatingsSummaryViewModel 中的 AverageRating 和 TotalReviews,并使用 IMultiValueConverter 来决定星星的颜色:
首先,我们需要向我们的 RecipeRatingsSummaryViewModel 添加一个额外的属性,名为 TotalReviews:
public class RecipeRatingsSummaryiewModel
{
public int TotalReviews { get; } = 15;
public double MaxRating { get; } = 4d;
public double? AverageRating { get; set; } =
3.6d;}
接下来,我们可以创建一个名为 RatingAndReviewsToColorConverter 的类,该类实现了 IMultiValueConverter 接口。为此,我们需要在 Converter 文件夹上右键点击,选择 添加 | 类… ,并输入转换器的名称。
使类实现 IMultiValueConverter 并将以下代码添加到 Convert 方法中:
bool isBackground = parameter is string param
&& param.ToLower() == "background";
var hex = isBackground ? "#F2F2F2" : "#EBEBEB";
if (values.Count() == 2
&& values[0] is int reviewCount
&& values[1] is double rating)
{
if (reviewCount >= 15)
{
hex = rating switch
{
…
};
}
}
return hex is null ? null : Color.FromArgb(hex);
在这个 Convert 方法中,我们可以访问绑定的值数组。这允许我们通过考虑每个给定的值来编写逻辑。在这种情况下,我们期望总评论数是第一个值,而评分是第二个值。
现在,我们可以将此转换器作为资源添加到我们的 RecipeDetailPage 中,就像我们之前对其他 ValueConverters 所做的那样。
<conv:RatingAndReviewsToColorConverter
x:Key="ratingAndReviewsToColorConverter" />
最后,我们可以在 Label 类的 TextColor 属性上的 MultiBinding 类中使用 RatingAndReviewsToColorConverter,就像我们之前在其他 ValueConverters 上所做的那样。
<Label.TextColor>
<MultiBinding
Converter="{StaticResource
ratingAndReviewsToColorConverter}"
ConverterParameter="background"
TargetNullValue="{x:Static Colors.HotPink}">
<Binding Path="RatingDetail.TotalReviews" />
<Binding Path="RatingDetail.AverageRating" />
</MultiBinding>
</Label.TextColor>
IMultiValueConverter与MultiBinding结合使用,提供了一种动态且灵活的方法来处理复杂的绑定场景。通过接受一个输入数组并将它们处理成一个单一输出,它允许我们处理 UI 中的多源依赖关系。
在数据绑定的背景下,我们需要关注的是编译绑定。尽管这个特性有很多优点,但它似乎并不那么为人所知。
编译绑定
编译绑定是创建绑定的一种更高效的方式,它们在编译时而不是在运行时进行验证。通常,数据绑定引擎使用反射来获取或设置绑定对象上的属性值。这种方法既灵活又强大,因为它允许绑定引擎与任何类型的对象交互。然而,它也有一些性能影响,因为反射比直接属性访问要慢,并且可能导致仅在运行时才能检测到的错误,例如属性名拼写错误或属性不存在。相比之下,编译绑定在编译时进行检查,这意味着它们可以在应用程序运行之前捕获错误。此外,因为绑定被编译到应用程序中,所以运行时性能得到了提高,因为不需要进行传统数据绑定中发生的绑定解析过程。
启用编译绑定相当简单:使用x:DataType属性,我们可以指定 XAML 元素及其子元素将要绑定的对象类型。因此,基本上,在我们的RecipeDetailPage中,我们可以添加以下内容:
<ContentPage
x:Class="Recipes.Mobile.RecipeDetailPage"
…
xmlns:vms="clr-namespace:Recipes.Client.Core
.ViewModels;assembly=Recipes.Client.Core"
x:DataType="vms:RecipeDetailViewModel"
Title="RecipeDetailPage">
这样,我们表明BindingContext的类型将是Recipes.Client.Core.ViewModels.RecipeDetailViewModel,从而使得 XAML 编译器能够在编译时验证绑定。
然而,有一个需要注意的问题,它将阻止我们从批处理文件中编译和运行应用程序。在我们的现有 XAML 代码中,我们已经明确地将HorizontalStackLayout的BindingContext设置为IngredientsList属性。这会让 XAML 编译器产生混淆,因为它假设HorizontalStackLayout内的元素仍然绑定到RecipeDetailViewModel,但实际上并非如此。这种误解导致在 Visual Studio 中出现了错误消息(图 4**.10 )。这个错误消息是证据,表明通过添加x:DataType属性,绑定现在在编译时得到了验证和编译:
图 4.10:绑定错误
很遗憾,修复这个错误可能不像你想象的那么简单:不仅需要在VerticalStackLayout上设置x:DataType属性为IngredientsListViewModel,绑定本身也需要更新:
<HorizontalStackLayout
x:DataType="vms:IngredientsListViewModel"
BindingContext="{Binding IngredientsList,
Source={RelativeSource AncestorType={x:Type
vms:RecipeDetailViewModel}}}">
在这些调整到位后,我们可以像以前一样构建和运行我们的应用。但现在,我们的应用受益于编译绑定的性能改进。需要更多证据证明绑定现在是编译的吗?尝试在绑定语句中拼写一个绑定属性的名称。如图 图 4.11 所示,Visual Studio 将会立即提醒你关于不存在属性的警告:
图 4.11:在指定类型上找不到属性
编译绑定提供了设计时检查,甚至在编写绑定语句时还会提供智能感知。但最重要的是,它们还能导致页面加载时间更快,整体应用性能更好。
摘要
数据绑定是 XAML 中的一个强大概念,它使得将我们的视图逻辑与业务逻辑分离变得更加容易。它是实现 .NET MAUI 中的 MVVM 的一个巨大推动力。这是一个复杂的话题,完全掌握它的各个方面可能会具有挑战性。它涉及到理解各种概念和技术,从简单的数据绑定到多绑定和转换器,以及从元素和相对绑定到高性能编译绑定。
然而,不要感到不知所措。就像任何复杂的主题一样,理解数据绑定需要时间和实践。你越是用它工作,就会越感到舒适,许多方面最终会变得像本能一样。
记住,最终目标是高效地应用 MVVM 模式。在这种情况下,数据绑定在连接 ViewModel 的数据和业务逻辑与 UI 之间发挥着关键作用。你在本章中学到的知识让你离这个目标更近了一步。
在下一章中,我们将探讨可以促进在 .NET MAUI 中实现 MVVM 模式的社区工具包。
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
第五章:社区工具包
在本章中,我们将探讨一些流行的 社区工具包 ,这些工具包是为了帮助开发者更高效、更有效地在 .NET MAUI 中使用数据绑定和 MVVM 模式而开发的。这些工具包提供了有价值的组件和实用工具,可以极大地提升你的开发体验,并帮助你构建健壮和可维护的应用程序。有许多大型和小型框架或工具包可以促进数据绑定和 MVVM 模式在你的项目中的应用。其中一些对你的代码有重大影响,而另一些则更专注于为开发者提供一套辅助器或组件,你通常需要为每个新项目重写这些组件。
每个现有的工具包或框架都是一群投入时间和专业知识的开发者创造的产品,每个都为用户提供价值,无论它是否适合你的特定编码风格。在这本书中,我不想偏袒任何特定的框架或工具包,而是展示那些对开发者有益的社区驱动努力。因此,我们将探讨两个社区工具包,它们提供了一些辅助类和基类,你可以在此基础上构建,但不会对你的代码产生重大影响。
在本章中,我们将讨论以下内容:
MVVM 工具包
.NET MAUI 社区工具包
其他流行框架
贡献社区
这些工具包被选为示例,因为它们提供了可以按需集成到你的项目中的工具和组件,而不强加特定的架构风格或编码范式。这一特性使得这些工具包特别灵活和适应性强,可以适应各种编码风格和项目需求。作为 .NET 社区工具包的一部分,它们受益于一个广泛的贡献者社区,包括微软员工和独立开发者。这种合作确保了工具包始终保持最新、可靠和有效,适用于所有开发者。
这些工具包旨在简化你在构建 .NET MAUI 应用程序时遇到的大量常见任务。它们提供的功能通常是我们在前面的章节中介绍过的,但现在,它们已经方便地为你实现了。重要的是要理解,这些工具包并不是魔法般的存在;它们封装了你可以自己实现的战略和技术。然而,它们的真正价值在于提供现成的、社区认可的解决方案,为你节省大量的时间和精力。通过了解这些工具包中可用的内容,你可以做出明智的决定,关于何时利用它们以及何时定制你的解决方案。所以,无需多言,让我们深入探讨一下它们能提供什么吧!
技术要求
在本章中,我们将更新Recipes! 应用程序的一些代码。更新的代码可以在 GitHub 上找到,用于参考和比较:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter05 。
MVVM Toolkit (CommunityToolkit.Mvvm )是一个综合性的库,旨在简化并简化您应用程序中 MVVM 模式的实现。由.NET 社区开发和维护,这个工具包提供了一套强大的框架无关的工具、组件和实用程序,帮助您使用 MVVM 模式构建应用程序。重要的是要注意,这个工具包不是.NET MAUI 特定的;它是 UI 框架无关的。
下面是 MVVM Toolkit 的一些关键特性:
INotifyPropertyChanged接口简化了引发PropertyChanged事件的过程。它的SetProperty方法简化了属性值的设置,并在需要时自动引发PropertyChanged事件。通过使用这个类,开发者可以减少与检查属性更新和相应触发PropertyChanged事件相关的样板代码。
ObservableValidator扩展了ObservableObject类,并包含内置的验证逻辑。这意味着任何从ObservableValidator继承的对象都将具有可观察和验证的特性。
ICommand接口。RelayCommand处理同步操作,而AsyncRelayCommand是为异步任务设计的。两者都支持可选的CanExecute逻辑。
WeakReferenceMessenger是一个消息系统,它允许松散耦合组件之间的通信。它使用弱引用来管理消息订阅,这有助于您避免应用程序中对象之间的内存泄漏和意外的强依赖。消息将在第七章 中更深入地讨论,依赖注入、服务 和消息 。
ObservablePropertyAttribute简化了触发PropertyChanged事件的属性的实现,而RelayCommandAttribute则自动化了命令的创建。
这些只是 MVVM Toolkit 组件的几个示例。这里的想法是让您了解 MVVM 库能提供什么。现在,让我们看看如何在我们的项目中有效地使用它。
将 MVVM Toolkit 添加到您的项目中非常简单:
右键单击Recipes.Client.Core项目并选择管理 NuGet 包 。
在列表中搜索MVVM Toolkit并选择CommunityToolkit.Mvvm 。图 5.1 显示了我们要搜索的 NuGet 包:
图 5.1:CommunityToolkit.Mvvm NuGet 包
打开Recipes.Client.Core项目。
在下面的示例中,我们将更新RecipeDetailViewModel和IngredientsListViewModel,使它们使用 MVVM Toolkit 中的ObservableObject。
ObservableObject
在每个 ViewModel 中实现INotifyPropertyChanged接口可能会变得繁琐和重复。这就是为什么开发者经常创建一个基类来处理这项任务。MVVM Toolkit 免费提供这样一个基类,形式为ObservableObject。
通过继承自ObservableObject,您的 ViewModel 类可以自动通知视图属性更改,保持 UI 与底层数据同步。ObservableObject还提供了SetProperty方法,允许开发者设置属性值。因此,如果值已更改,它将自动触发PropertyChanged事件以更新 UI。
让我们更新我们的代码,让我们的 ViewModel 继承自ObservableObject而不是每个都实现自己的INotifyPropertyChanged接口:
转到IngredientsListViewModel,让这个类继承自ObservableObject而不是实现INotifyPropertyChanged接口,如下所示:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Recipes.Client.Core.ViewModels;
public class IngredientsListViewModel :
ObservableObject
{
private int _numberOfServings = 4;
public int NumberOfServings
{
...
}
//ToDo: add list of Ingredients
}
通过继承ObservableObject,我们可以移除之前存在的OnPropertyChanged方法和PropertyChanged事件。
接下来,我们可以更新NumberOfServings属性的 setter,使其使用ObservableObject的SetProperty方法:
public int NumberOfServings
{
get => _numberOfServings;
set => SetProperty(ref _numberOfServings, value);
}
SetProperty方法简化了在值更新时调用PropertyChanged事件的实现。此方法将更新提供的后置字段(_numberOfServings),并在值更新时自动触发PropertyChanged事件。
我们也可以为RecipeDetailViewModel做同样的事情:
继承自ObservableObject。
移除OnPropertyChanged方法和PropertyChanged事件。
更新HideAllergenInformation属性的 setter,使其使用SetProperty方法,如下面的代码片段所示:
private bool _hideAllergenInformation = true;
public bool HideAllergenInformation
{
get => _hideAllergenInformation;
set => SetProperty(ref _hideAllergenInformation, value);
}
通过确保我们的 ViewModel 继承自ObservableObject并利用SetProperty方法,我们可以消除过多的样板代码。这将使我们受益,减少大量的仪式感。这有助于保持 ViewModel 的简洁和清晰,使它们能够专注于其核心职责。除非你因代码行数而获得报酬,否则我认为你会同意,这些更新后的类看起来要好得多,样板代码要少得多!
MVVM Toolkit 中最有帮助的特性之一是不同的ICommand实现。让我们看看吧!
RelayCommand
MVVM Toolkit 提供了ICommand接口的几个强大实现。每个实现都有其独特的作用,针对不同场景中 ViewModel 和视图之间通信的不同需求。这些实现有助于简化命令操作:
RelayCommand:这是 ICommand 的简单实现,允许你使用委托指定 Execute 和 CanExecute 方法,定制命令的行为以及是否可以在特定时间执行。这种实现类似于我们之前使用的 Microsoft.Maui.Controls.Command。然而,Command 实现有 ChangeCanExecute 方法,我们可以调用它来触发 CanExecute 方法的重新评估。在 RelayCommand 类中,具有这种行为的函数称为 NotifyCanExecuteChanged。
RelayCommand<T>:这是 RelayCommand 的一个变体,它增加了对可以传递给 Execute 和 CanExecute 方法的参数的支持。参数的类型由泛型类型参数确定。这种实现类似于 Microsoft.Maui.Controls.Command<T>。
AsyncRelayCommand:ICommand 的异步变体,AsyncRelayCommand 返回一个 Task,使其非常适合管理如网络数据获取之类的异步操作。
AsyncRelayCommand<T>:AsyncRelayCommand 的参数支持版本,这允许你向异步的 Execute 和 CanExecute 方法传递参数,为异步操作提供额外的灵活性。
让我们看看我们如何更新我们的命令,以便利用 RelayCommand 和 RelayCommand<T> 类:
下面的代码块展示了我们如何通过将类型从 Microsoft.Maui.Controls.Command 更新为 CommunityToolkit.Mvvm.Input.RelayCommand,以及从 Microsoft.Maui.Controls.Command<T> 更新为 CommunityToolkit.Mvvm.Input.RelayCommand<T> 来在构造函数中初始化命令:
public RecipeDetailViewModel()
{
AddAsFavoriteCommand =
new RelayCommand(AddAsFavorite,
CanAddAsFavorite);
RemoveAsFavoriteCommand =
new RelayCommand(RemoveAsFavorite,
CanRemoveAsFavorite);
SetFavoriteCommand =
new RelayCommand<bool>(SetFavorite,
CanSetFavorite);
}
此更新后的代码将使用新的 RelayCommand 实例化三个命令,传递在调用命令的 Execute 和 CanExecute 方法时应触发的函数。
当我们进行这些操作时,我们还可以更新这个类中命令的类型。虽然 RelayCommand 实现了 ICommand 接口,但它也实现了 IRelayCommand 接口:
public IRelayCommand AddAsFavoriteCommand
{
get;
}
public IRelayCommand RemoveAsFavoriteCommand
{
get;
}
public IRelayCommand SetFavoriteCommand
{
get;
}
通过更新我们命令的类型,如前文代码片段所示,我们可以避免后续的额外类型转换。
IsFavorite 的设置器现在可以更新为以下内容:
public bool? IsFavorite
{
get => _isFavorite;
private set
{
if(SetProperty(ref _isFavorite, value))
{
AddAsFavoriteCommand
.NotifyCanExecuteChanged();
RemoveAsFavoriteCommand
.NotifyCanExecuteChanged();
SetFavoriteCommand
.NotifyCanExecuteChanged();
}
}
}
SetProperty 方法在属性被更改时返回 true。这允许我们在属性更改(或未更改)时执行额外操作。例如,在这种情况下,我们希望在 IsFavorite 属性值更改时重新评估命令的 CanExecute 方法,通过调用每个命令的 NotifyCanExecuteChanged 方法。
遵循 MVVM 的最佳实践
由于我们不再使用 Microsoft.Maui.Control.Command,我们也可以移除这个项目当前所依赖的 MAUI 依赖。点击 Recipes.Client.Core 项目,以便打开项目的 .csproj 文件,并移除 <UseMaui> 标签。现在,ViewModel(以及 Recipes.Client.Core 项目)再次成为平台无关的,遵循 MVVM 的最佳实践。
在第一章 中,很明显,应用 MVVM 模式比在代码后端编写所有内容需要更多的代码。这主要是因为 ViewModel 中的所有仪式。虽然像ObservableObject这样的类已经抽象了一些样板代码,但我们甚至可以更进一步,看看 MVVM Toolkit 中可用的源生成器。
源生成器
源生成器是.NET 中的一个编译器功能,允许开发者在编译过程中生成新代码。这有可能大大减少手动编码和错误的可能性。
MVVM Toolkit 提供了ObservablePropertyAttribute和RelayCommandAttribute,这些属性由源生成器用于创建通知 UI 变化的属性和用于处理用户交互的命令。此外,还可以使用NotifyPropertyChangedForAttribute和NotifyCanExecuteChangedForAttribute等属性与ObservablePropertyAttribute结合使用,通过代码生成添加更多功能。
由于源生成器的工作方式,应用这些属性的课程必须声明为部分类。在 C#中,部分类允许您使用partial关键字将类拆分到多个文件中。在编译时,不同的文件将组合成一个类。此功能对于源生成器至关重要,因为源生成器在编译期间生成额外的源代码。因此,当您希望使用这些属性中的任何一个时,请记住将您的 ViewModel 声明为partial:
public partial class RecipeDetailViewModel :
ObservableObject
现在已经将 ViewModel 声明为部分类,我们可以开始调整我们的代码以利用这些属性。让我们看看如何做到这一点。
使用 ObservableProperty 属性
通常,在实现触发PropertyChanged事件的属性时,我们需要编写一些样板代码。通过使用ObservablePropertyAttribute,我们可以让源生成器为我们生成此代码。
此属性可以应用于字段;在构建时,将生成一个公共属性,注解的字段作为后端字段。
例如,我们可以看看RecipeDetailViewModel。可以将HideExtendedAllergenList属性删除,并将ObservablePropertyAttribute添加到_hideExtendedAllergenList字段:
[ObservableProperty]
private bool _hideExtendedAllergenList = true;
在此ObservableProperty属性生效的情况下,在构建时将生成一个名为HideExtendedAllergenList的完整公共属性。
生成的属性的设置方法将包含检查值是否已更新并相应触发PropertyChanged事件的逻辑。设置器还将调用一些正在生成的部分方法,允许我们在属性值更新时添加一些自定义代码。
注意
非常重要的是要注意,ObservableProperty 属性会为我们生成一个公共属性。当将此值绑定到 UI 时,请确保绑定到这个生成的属性(在这个例子中是 HideExtendedAllergenList),而不是尝试绑定到设置了属性的域。另外,在赋值时,始终将值赋给生成的属性,以确保触发 PropertyChanged 事件!
但 RecipeDetailViewModel 的 IsFavorite 属性怎么办呢?
在这个设置器中,我们调用我们命令的 NotifyCanExecuteChanged 方法。我们能否让这也在生成的属性上工作?是的;对于这些情况,我们有 NotifyCanExecuteChangedFor 属性,它接受当属性值更新时需要触发的命令的 NotifyCanExecuteChanged 方法的名称。这意味着整个 IsFavorite 属性可以重写为如下所示:
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddAsFavoriteCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveAsFavoriteCommand))]
[NotifyCanExecuteChangedFor(nameof(SetFavoriteCommand))]
private bool? _isFavorite = false;
第一个属性 ObservableProperty 将生成一个属性,当其值更新时将触发 PropertyChanged 事件。接下来的三个 NotifyCanExecuteChangedFor 属性将确保当属性更新时,IRelayCommand 类型的三个命令的 NotifyCanExecuteChanged 方法将被调用。
说到命令,还有一个用于生成 RelayCommand 的源生成器。让我们看看。
使用 RelayCommand 属性
您可以将 RelayCommand 属性应用于您打算用作命令执行动作的方法。在构建时,这将生成一个与方法同名的公共 RelayCommand 属性,后缀为 “Command。” 当调用生成的 RelayCommand 的 Execute 方法时,应用了该属性的该方法将被调用。这实际上简化了声明公共 IRelayCommand 命令、创建要调用的方法以及创建与该方法关联的命令实例的过程,以下是一个简化的形式:
[RelayCommand]
private void RemoveAsFavorite() => IsFavorite = false;
这将生成一个公共 IRelayCommand 类型的 RemoveAsFavoriteCommand 属性,当命令执行时将调用 RemoveAsFavorite 方法。
当将此属性应用于接受一个参数的方法时,生成的命令将是一个泛型 RelayCommand<T>,其中 T 对应于参数的类型。
或者,我们也可以传递一个名为 CanExecute 的字符串参数,该参数指向一个方法,当调用命令的 CanExecute 方法时应调用该方法。如果我们想更新 RecipeDetailViewModel 并让 AddAsFavoriteCommand、RemoveAsFavoriteCommand 和 SetFavoriteCommand 由源生成器生成,这是必不可少的。让我们看看我们如何实现这一点:
删除公共 AddAsFavoriteCommand、RemoveAsFavoriteCommand 和 SetFavoriteCommand 属性,这些属性的类型为 IRelayCommand。
在 RecipeDetailViewModel 的构造函数中删除这些命令的实例化。
将RelayCommand属性添加到AddAsFavorite、RemoveAsFavorite和SetFavorite方法中,如下所示:
[RelayCommand(CanExecute = nameof(CanSetFavorite))]
private void SetFavorite(bool isFavorite)
=> IsFavorite = isFavorite;
[RelayCommand(CanExecute = nameof(CanAddAsFavorite))]
private void AddAsFavorite() => IsFavorite = true;
[RelayCommand(CanExecute = nameof
(CanRemoveAsFavorite))]
private void RemoveAsFavorite() => IsFavorite = false;
在构建时,公共命令将通过RelayCommand属性生成。在这个例子中,所有属性都指向一个方法,当调用生成的命令的CanExecute方法时会被调用。
虽然源生成器可以极大地简化代码库并提高生产力,通过自动化样板代码,但我出于几个原因选择不使用它们。
首先,它们可能会掩盖一些底层实现细节。这可能会让新来的开发者或经验较少的开发者难以完全理解代码及其内在模式。
其次,使用 MVVM 工具包的源生成器可能需要调整你的编码风格以匹配工具包内固有的“规定”风格。这可能不会吸引所有喜欢在编码方法上更有灵活性的开发者。
然而,重要的是要注意,这些是个人偏好,许多开发者发现使用源生成器有很大的价值。它们可以显著简化 ViewModel 的开发过程,如果你发现它们对你有益且有利于你的编码风格,我真诚地推荐使用它们!
最后,源生成器是一个工具。像任何工具一样,考虑何时何地它们能带来最大价值是至关重要的。在自动化和简单性与理解、灵活性以及个人代码风格之间取得平衡,始终是软件开发中一个重要的考虑因素。
这涵盖了 MVVM 工具包的大部分基本内容。现在,让我们看看.NET MAUI 社区工具包如何使我们在应用 MVVM 模式时生活变得更轻松。
.NET MAUI 社区工具包
.NET MAUI 社区工具包 是一组有用的组件、控件和实用工具,旨在增强使用.NET MAUI 的开发体验。尽管其主要焦点不是启用或促进 MVVM,但工具包确实提供了可以帮助开发者在其.NET MAUI 项目中实现 MVVM 模式的功能:
.NET MAUI 社区工具包还有很多其他功能,但在 MVVM 的上下文中,这些是相关的部分。请注意,这是一个应该只添加到 MAUI 项目中的工具包,而不是包含你的 ViewModel 的核心项目。为什么?因为.NET MAUI 社区依赖于.NET MAUI,因此它不应该被引用到与 UI 框架无关的项目中。
通过提供这些额外的功能和辅助工具,.NET MAUI Community Toolkit 有助于在 .NET MAUI 中使用 MVVM 时提高开发效率和流程简化。
安装 .NET MAUI Community Toolkit 与安装任何其他 NuGet 包类似:
右键单击 Recipes.Mobile 项目,然后选择 管理 NuGet 包 。
搜索 Maui Community 并从列表中选择 CommunityToolkit.Maui 。图 5 .2 展示了我们应该下载的 NuGet 包:
图 5.2:CommunityToolkit.Maui NuGet 包
打击 Recipes.Mobile 项目。
安装完成后,转到 MauiProgram.cs 并确保在 MauiAppBuilder 上调用 UseMauiCommunityToolkit:
builder.UseMauiCommunityToolkit();
要在 XAML 中使用工具包中的组件,您需要将命名空间添加到您想要使用工具包的 XAML 页面中:
一旦所有这些设置就绪,我们就可以开始使用 .NET MAUI Community Toolkit。
转换器
在 第四章 ,.NET MAUI 中的数据绑定 ,我们讨论了值转换器是什么以及如何创建它们。.NET MAUI Community Toolkit 内置了许多现成的值转换器。其中之一是 ListToStringConverter,它可以非常方便地将项目列表显示为单个字符串。让我们使用它来显示食谱的过敏信息:
首先,让我们向 RecipeDetailViewModel 添加一个名为 Allergens 的字符串数组:
public string[] Allergens { get; }
= new string[]{ "Milk", "Eggs", "Nuts", "Sesame" };
此属性包含食谱中包含的所有过敏原列表。目前,我们在这里硬编码了一些值。
在 RecipeDetailPage 上添加工具包的命名空间:
xmlns:toolkit=http://schemas.microsoft.com/dotnet/2022
/maui/toolkit
这将允许我们使用 toolkit 前缀在 XAML 中访问 Community Toolkit 库。
将 ListToStringConverter 实例添加到页面的 Resources 中:
<ContentPage.Resources>
...
<toolkit:ListToStringConverter
x:Key="listToStringConverter" Separator=", " />
</ContentPage.Resources>
ListToStringConverter 有一个名为 Separator 的属性,我们将其设置为 ","。
最后,我们可以更新标签,以显示过敏信息:
<Label IsVisible="{Binding HideAllergenInformation,
Mode=OneWay, Converter={StaticResource
inverseBoolConverter}}"
Text="{Binding Allergens, Converter={StaticResource
ListToStringConverter, we can bind the Allergens property of the ViewModel to the Text property of the label. This converter will take the items from the Allergens array and concatenate them to show them as one string value.
此值转换器以及工具包中的其他一些转换器并不完全是火箭科学。这是您可以自己编写的。然而,在我看来,为什么要在别人已经为你做了艰苦工作的情况下重新发明轮子呢?利用这些现成的工具无疑可以使您的编码生活更加轻松!
行为
行为允许您在不从头开始构建自定义控件的情况下向 UI 控件添加功能。这极大地帮助构建丰富且直观的用户界面,以满足您的特定需求。通过在视图层封装 UI 特定的操作,行为有助于减少 ViewModels 的复杂性,遵循 MVVM 中核心的关注点分离原则。.NET MAUI Community Toolkit 提供了各种现成的行为,节省了您在增强 UI 时的时间和精力。
.NET MAUI 社区工具包提供的一个行为是 EventToCommand,它允许你将事件映射到命令。这允许你进一步增强你的 UI 和业务逻辑之间的解耦。这个行为在处理不支持直接绑定到命令的事件时特别有用。
作为一个非常简单的例子,让我们想象一下,我们想要在整个应用程序中收集一些用户行为。我们可能感兴趣的一件事是用户是否在食谱页面上滚动,因为这可能表明用户对该特定食谱有一定程度的兴趣。ScrollView 有一个 Scrolled 事件,但没有相应的命令。在这种情况下,EventToCommandBehavior 可以非常有帮助,就像我在接下来的步骤中将要展示的那样:
在 RecipeDetailViewModel 上创建一个 RelayCommand,当用户在页面上滚动时需要调用:
[RelayCommand]
private void UserIsBrowsing()
{
//Do Logging
}
RelayCommand attribute is used to generate UserIsBrowsingCommand, but you could write a RelayCommand yourself as well, of course.
接下来,我们可以在 ScrollView 上添加 EventToCommandBehavior:
<ScrollView>
<ScrollView.Behaviors>
<toolkit:EventToCommandBehavior
Command="{Binding UserIsBrowsingCommand}"
EventName="Scrolled" />
</ScrollView.Behaviors>
...
</ScrollView>
EventToCommandBehavior 的 Command 属性绑定到我们在 ViewModel 上刚刚创建的 UserIsBrowsingCommand 属性。通过将 EventName 属性设置为 "Scrolled",我们定义我们想要在 ScrollView 的 Scrolled 事件上调用此命令。
作为最后一个示例,为了展示通过结合行为和转换器这个工具包如何有用,想象以下情况:一个心形图标应该始终显示在屏幕上,但只有当用户将食谱标记为收藏时,它才应该是红色的。我们可以不添加任何一行 C# 代码,也不与多个图标交互来完成这个任务!让我们看看这是如何实现的:
到目前为止,在我们的 Recipes! 应用程序中,收藏图标放置得有点不合适。让我们先从将收藏图标放置在食谱标题旁边开始。你可以通过替换显示食谱标题的标签来完成此操作,以下是一个 XAML 代码:
<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>
现在,收藏图标将永久地出现在食谱标题旁边。
.NET MAUI 社区工具包提供了 IconTintColorBehavior,我们可以将其应用到 favoriteIcon 上以改变其颜色。我们可以在我们的图片上添加此行为:
<Image
x:Name="favoriteIcon"
...>
<Image.Behaviors>
<toolkit:IconTintColorBehavior
TintColor="#E9E9E9E9" />
</Image.Behaviors>
</Image>
添加此行为后,图标将采用指定的 TintColor。
现在,我们需要根据 RecipeDetailViewModel 的 IsFavorite 属性设置 TintColor 值。方便的是,工具包提供了 BoolToObjectConverter,我们可以利用它将 boolean 值转换为另一个值。我们可以在 RecipeDetailPage 中添加其实例:
<toolkit:BoolToObjectConverter
x:Key="isFavoriteToColorConverter"
x:TypeArguments="Color"
FalseObject="#E9E9E9E9"
TrueObject="#FF0000" />
通过将此实例的 TypeArguments 设置为 "Color",我们指定我们想要将 bool 值转换为 Color 值。FalseObject 和 TrueObject 属性设置了转换器应该为 false 和 true 返回的值。
最后一步是在图片上更新IconTintColorBehavior。挑战在于将行为的TintColor属性绑定到RecipeDetailViewModel的IsFavorite属性。由于行为不会从它们定义的控件继承BindingContext,我们将使用ElementBinding通过favoriteIcon的BindingContext访问RecipeDetailViewModel:
<toolkit:IconTintColorBehavior TintColor="{Binding
Source={x:Reference favoriteIcon},
Path=BindingContext.IsFavorite,
Converter={StaticResource
TintColor is effectively bound to the IsFavorite property on the ViewModel and isFavoriteToColorConverter determines the color to use.
.NET MAUI Community Toolkit 是一个无价的资源,它简化了开发过程。它提供了各种可重用的构建块,减少了重复工作的必要性,并允许您专注于创建独特应用程序功能。通过行为、转换器等组件,该工具包使开发者能够以更少的努力和复杂性构建丰富和交互式的用户体验。尽管该工具包不专注于 MVVM,但它通过提供行为、转换器等实用功能,有助于实现 MVVM 模式。
其他流行的框架
如前所述,有众多 MVVM 框架可供选择,每个框架都有其独特的特性和优势。在本章中,我们探讨了 MVVM Toolkit 和.NET MAUI Community Toolkit。这些工具包由社区驱动且易于访问,提供了各种有用的工具,以简化您的 MVVM 代码。
然而,MVVM 框架的领域非常广泛,因此了解其他框架可以提供什么可能是有价值的。以下是一些与.NET MAUI 兼容的最受欢迎的第三方 MVVM 框架列表:
无论您是在寻找一个简单的工具包来处理基础知识,还是需要一个具有高级功能的全面框架,您很快就会发现一个最适合您特定需求并与您的编码风格相匹配的框架。然而,重要的是要记住,使用 MVVM 框架并不是有效实现 MVVM 的先决条件。完全有可能在没有专用框架的情况下有效地实现 MVVM 模式。最终,是否使用框架——以及如果使用,使用哪个框架——应取决于您项目的需求、您团队对框架的熟悉程度以及您个人的编码偏好。记住,工具是用来帮助您的,而不是规定您如何编码的。
为社区贡献力量
随着本章的结束,重要的是要认识到这些第三方工具包和框架在本质上都是社区贡献的结果。它们是他人辛勤工作、思考和热情的结晶。所有这些代码都可以在 GitHub 等平台上供您访问,并由具有社区意识的个人维护,他们总是乐于接受建议、改进和错误报告。
请记住,为社区做出贡献并不仅限于创建自己的新项目或工具包。它可能只是报告一个 bug、建议一个功能,甚至在开源项目上进行小的代码改进。这种开源精神是.NET 生态系统的主要优势之一。
因此,如果您在使用这些框架时发现可以改进的地方或需要修复的 bug,请不要犹豫,积极贡献。通过这样做,您不仅会为自己改进工具,也会为其他使用它的开发者带来便利。这样,您可以回馈社区,也许在这个过程中还能学到一些东西。
最后,请记住,这些开源 MVVM 框架不仅仅是您使用的工具,它们也是您作为开发者成长和为更广泛的.NET 社区做出贡献的机会。
摘要
总结来说,MVVM 工具包和.NET MAUI 社区工具包都提供了一套全面的组件,有助于在您的应用程序中实现 MVVM 模式。通过使用这些工具包,您可以避免从头开始构建一切或重新发明现有解决方案的需要,从而节省时间和精力,并让您能够专注于构建应用程序。
在本书的其余代码示例中,我们将使用 MVVM 工具包,并使用ObservableObject和RelayCommand等类。这些类非常易于理解。即使您选择不使用此工具包,您也应该发现代码示例清晰易懂,因为底层概念并不复杂。
进一步阅读
要了解更多关于本章所涉及主题的信息,请查看以下资源:
第六章:与集合一起工作
集合几乎是每个应用程序的基本组成部分,使我们能够管理和组织相关对象的组。在本章中,我们将探讨在 MVVM 设计模式背景下集合的力量,为您提供在 .NET MAUI 应用程序中高效处理数据的工具和知识。
到目前为止,我们的主要关注点一直是绑定单个值,例如标题、评分和命令。然而,随着集合的引入,我们可以将我们的应用程序提升到新的水平。集合使我们能够表示项目组,无论是食谱集合、成分列表还是用户评分数组。通过利用集合的能力,我们可以创建动态、数据驱动的 UI,提供增强的用户体验。
本章分为三个关键部分:
使用 BindableLayout
ICollectionChanged 接口
与 CollectionView 一起工作
到本章结束时,您将对在 .NET MAUI 中使用集合有深入的理解,这将显著扩展您构建丰富、以数据为中心的应用程序(如我们的 Recipes! 应用程序)的能力。让我们深入探讨吧!
技术要求
在本章中,我们将增强 Recipes! 应用程序的功能。本章的代码库以及所有资产,包括为有效覆盖本章主题所需的额外类和代码,可以在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter06 。本章的起点在 Start 文件夹中提供,其中包含为本章特别添加的必要类和代码。Start 文件夹中的代码是跟随本章内容的基础,建立在之前章节中建立的基础上。本章完成后的代码,包括我们本章中编写的所有代码,可以在 Finish 文件夹中找到,供参考和比较。
使用 BindableLayout
BindableLayout 类是一个静态类,它为将集合绑定到布局容器提供了 附加属性 。附加属性是一个概念,允许您将额外的属性附加到现有元素或控件上。这些属性不是在元素本身中定义的,而是由外部类提供的。它们使您能够在不修改其底层代码的情况下扩展元素的行为和功能。.NET MAUI 中最常用的附加属性之一可能是 Grid.Row。它可以应用于任何 UI 元素,允许您在 Grid 布局中定义行位置。它不是 UI 元素本身的属性;相反,它是一个增强 Grid 布局灵活性和强大功能的附加属性。因此,BindableLayout 提供了一组附加属性,可用于将数据集合绑定到布局容器,例如 VerticalStackLayout 或 Grid。您可以利用其附加属性轻松绑定并显示动态数据集合。
虽然可以将 BindableLayout 类的附加属性添加到任何继承自 Layout 类的布局中,但它通常与 VerticalStackLayout 和 HorizontalStackLayout 等布局一起使用。
注意
BindableLayout 是显示数据集合的一个轻量级且直接的方法。它非常适合项目数量有限的情况,提供了简单性和易用性。
然而,重要的是要考虑,BindableLayout 在没有内置功能如虚拟化或视图回收的情况下为集合中的每个项目生成 UI 元素。这意味着对于包含许多项目的大型集合,可能会有性能影响和内存使用增加。
让我们看看我们如何使用 BindableLayout 在 VerticalStackLayout 内部显示数据集合。
显示什么以及如何显示
BindableLayout 的两个基本附加属性是 ItemsSource 和 ItemTemplate。这两个属性在定义需要显示哪些数据集合以及如何可视化每个数据项方面起着至关重要的作用。ItemsSource 属性用于绑定数据集合,而 ItemTemplate 属性用于定义集合中每个项目的 DataTemplate。
由于一个食谱如果没有其配料列表就什么都不是,在第一个例子中,我们将在 RecipeDetailPage 上显示配料列表。然而,在我们深入探讨如何有效使用 BindableLayout 之前,让我们首先讨论 RecipeIngredientViewModel,它用于存储配料的信息。
定义配料
RecipeIngredientViewModel 类表示食谱中的一个配料。
它包含成分的名称和关于特定份数所需成分数量的信息。DisplayAmount属性的目的在于动态调整并显示所需份数的适当数量的成分。它允许用户看到与所选份量相匹配的调整后的数量,为他们的食谱准备提供准确的成分测量。让我们看看它的UpdateServings方法:
public void UpdateServings(int servings)
{
var factor = servings / (double)baseServings;
DisplayAmount = factor * baseAmount;
}
此方法根据期望的份量更新DisplayAmount属性,使用户能够看到所选份量中成分的适当数量。由于DisplayAmount属性的 setter 在值变化时调用PropertyChanged事件,我们可以将此属性绑定到视图中的 UI 元素,以根据所选的份量动态显示调整后的成分数量。
我们可以扩展IngredientsListViewModel以添加一个额外的属性:Ingredients。这个属性是一个RecipeIngredientViewModel对象的列表,为了演示目的,它被分配了一个硬编码的成分列表,这些成分是制作凯撒沙拉所需的。
最后,更新了NumberOfServings属性。以下代码块展示了当NumberOfServings属性的值发生变化时,如何调用每个成分上的UpdateServings方法:
public int NumberOfServings
{
get => _numberOfServings;
set
{
if (SetProperty(ref _numberOfServings, value))
{
Ingredients.ForEach(
i => i.UpdateServings(value));
}
}
}
当更新NumberOfServings时,通过调用UpdateServings方法,每个成分的DisplayAmount属性也会更新。
在所有这些准备就绪后,我们最终可以转向 XAML,看看我们如何将这个RecipeIngredientViewModels集合绑定到一个VerticalStackLayout上,同时使用BindableLayout的附加属性。
在屏幕上显示成分
在以下步骤中,我们将通过设置 XAML 代码来以视觉上吸引人的方式显示成分。请注意,我们将在 XAML 中执行的所有操作也可以在代码中完成:
在RecipeDetailPage上,在包含控制份量的Stepper属性的HorizontalStackLayout下方,我们可以添加一个新的VerticalStackLayout。如下面的代码块所示,我们可以使用BindableLayout类的ItemsSource属性来绑定到成分列表:
<VerticalStackLayout
Margin="0,10" Spacing="10"
BindableLayout.ItemsSource="{Binding
IngredientsList.Ingredients}">
</VerticalStackLayout>
现在,我们需要定义需要渲染的 UI 元素,这些元素对应于绑定集合中的每个项目。我们可以使用ItemTemplate属性来定义这一点。这个属性需要一个DataTemplate类的值。它可以这样定义:
<VerticalStackLayout ... >
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType=
"vms:RecipeIngredientViewModel" >
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
注意,在DataTemplate中,如果我们想利用编译绑定,就像我们在第四章 中看到的,在.NET MAUI 中的数据绑定 ,我们可以定义x:DataType。
注意
非常重要的是要认识到,DataTemplate的BindingContext被设置为绑定集合的单独项目。因为模板会为集合中的每个项目重复,所以在DataTemplate中用x:Name命名的 UI 元素在代码后面是不可访问的。它们的名称仅限于该模板的作用域。然而,名称仍然可以用于同一DataTemplate内的元素绑定。
下面的代码片段显示了我们可以如何为配料定义一个DataTemplate。这就是我们定义每个配料需要如何可视化的方式:
<DataTemplate x:DataType=
"vms:RecipeIngredientViewModel">
<HorizontalStackLayout Spacing="5">
<Label
FontAttributes="Bold" FontSize="16"
Text="{Binding IngredientName,
StringFormat='{0}:', Mode=OneTime}"
VerticalOptions="Center" />
<Label Text="{Binding DisplayAmount,
Mode=OneWay}" VerticalOptions="Center" />
<Label
Text="{Binding Measurement, Mode=OneTime}"
VerticalOptions="Center" />
</HorizontalStackLayout>
</DataTemplate>
对于每个配料,我们希望渲染一个包含显示配料名称的标签、显示DisplayAmount的标签,以及最后显示Measurement的标签的HorizontalStackLayout。图 6**.1 显示了它的样子:
图 6.1:配料列表
注意到DisplayAmount是OneWay绑定的,这意味着当此属性的PropertyChanged事件被触发时,标签的Text属性会相应地更新。所有其他属性都可以绑定OneTime,因为它们的值在显示后不会改变。
小贴士
在性能方面,值得注意的是,OneTime绑定模式通常比OneWay数据绑定更高效。这在绑定数据集合时尤为重要!OneTime绑定只建立一次绑定,不会跟踪源属性后续的变化。因此,在可能的情况下使用OneTime绑定来优化性能和减少不必要的 UI 更新是明智的。
DataTemplate也可以在资源字典中定义。这允许DataTemplate被重用,这在在应用的不同部分显示相同类型的数据时特别方便。例如,通过在Application.Resources(在App.xaml中)中一次性定义模板,它可以在整个应用中重用。然后,我们可以使用StaticResource标记扩展,通过在资源字典中定义的键将特定的DataTemplate绑定到BindableLayout的ItemTemplate属性。下面的代码块显示了如何将DataTemplate添加到页面的资源中,并在稍后将其用作VerticalStackLayout显示配料的ItemTemplate:
<ContentPage.Resources>
<DataTemplate x:Key="recipeIngredientTemplate"
x:DataType="vms:RecipeIngredientViewModel">
...
</DataTemplate>
</ContentPage.Resources>
...
<VerticalStackLayout
Margin="0,10"
BindableLayout.ItemsSource="{Binding IngredientsList.Ingredients}"
BindableLayout.ItemTemplate="{StaticResource
recipeIngredientTemplate}"
Spacing="10"/>
在之前的代码片段中,在ContentPage.Resources部分定义了一个DataTemplate,并将其分配给"recipeIngredientTemplate"键。稍后,通过使用StaticResource标记扩展引用该模板,使用"recipeIngredientTemplate"键,在VerticalStackLayout中使用了此模板。
注意
在.NET MAUI 中,如果你绑定一个集合而没有分配特定的DataTemplate,框架会自动调用集合中每个对象的ToString方法。返回的字符串值就是将在屏幕上显示的内容。
在所有这些准备就绪之后,我们的应用现在在菜谱详情页上显示了一份成分列表,其中每个成分都使用定义好的ItemTemplate进行渲染。
但如果某个特定集合中的所有项目都不应该以相同的方式渲染呢?让我们看看如何在运行时动态选择DataTemplate。
在运行时动态选择 DataTemplate
虽然成分对于菜谱应用至关重要,但没有一套全面的烹饪说明,对成分的处理就非常有限。因此,让我们在我们的应用中让这些说明变得生动起来!除了基本步骤外,说明列表还可能包括有价值的烹饪技巧和额外信息,以增强烹饪体验。让我们探讨如何将这些烹饪说明以及任何相关的备注整合到我们的Recipes! 应用中。
定义烹饪说明和备注
在我们的应用中,我们使用InstructionViewModel和NoteViewModel分别表示烹饪说明和备注。InstructionViewModel有Index和Description属性,而NoteViewModel只有一个Note属性。它们都归组在共同的父类InstructionBaseViewModel下,并存储在RecipeDetailViewModel中的Instructions列表中。
目前,这个列表初始化了一些烹饪说明和制作凯撒沙拉的技巧。
如果我们想在屏幕上显示包含说明和备注的列表,我们需要一个机制,允许我们根据类型使用不同的ItemTemplate。让我们看看DataTemplateSelector如何实现这一点!
创建一个 DataTemplateSelector
使用DataTemplateSelector,我们可以编写在运行时确定使用哪个DataTemplate的代码。编写DataTemplateSelector相当直接。让我们看看我们如何构建一个:
在TemplateSelectors中,通过右键单击Recipes.Mobile 项目并选择添加 | 新建文件夹 。
接下来,右键单击这个新添加的文件夹,将其命名为InstructionsDataTemplateSelector。
为了使我们的类作为DataTemplateSelector工作,它需要从Microsoft.Maui.Controls.DataTemplateSelector继承,如下面的代码片段所示:
public class InstructionsDataTemplateSelector :
DataTemplateSelector
{
protected override DataTemplate
OnSelectTemplate(object item, BindableObject
container) { }
}
从DataTemplateSelector类继承需要重写抽象的OnSelectTemplate方法。该方法在运行时被调用以选择适当的DataTemplate,并接受两个参数:
使用这些参数,DataTemplateSelector中的OnSelectTemplate方法可以帮助你为给定项目选择适当的DataTemplate。在我们的特定场景中,该方法将根据传入项目的类型来确定模板。
让我们在InstructionsDataTemplateSelector中引入两个属性。这两个属性,NoteTemplate和InstructionTemplate,决定了DataTemplateSelector应该根据传入的项目参数类型返回哪个DataTemplate。具体来说,如果它是一个NoteViewModel,则OnSelectTemplate方法应该返回NoteTemplate。相反,如果是InstructionViewModel,则返回InstructionTemplate。让我们探索如何实现这一点:
将以下属性添加到InstructionsDataTemplateSelector:
public DataTemplate NoteTemplate { get; set; }
public DataTemplate InstructionTemplate { get; set; }
以下代码块显示了如何实现OnSelectTemplate方法,以便它检查给定项的类型并返回适当的DataTemplate:
protected override DataTemplate OnSelectTemplate
(object item, BindableObject container)
{
if (item is InstructionViewModel)
return InstructionTemplate;
else if(item is NoteViewModel)
return NoteTemplate;
return null;
}
如果给定的项既不是InstructionViewModel也不是NoteViewModel项,则返回null。因此,对象的ToString方法返回的值将被渲染,这与不提供DataTemplateSelector时的行为相同。
这就是InstructionsDataTemplateSelector的内容。让我们看看我们如何使用这个DataTemplateSelector在应用中显示说明和笔记。
在屏幕上显示说明和笔记
现在数据已经就绪,我们也确定了想要使用的DataTemplateSelector,我们需要在 XAML 中做一些事情来显示菜谱的说明和笔记:
让我们先思考一下我们如何在应用中表示InstructionViewModel。以下是一个我们可以添加到RecipeDetailPage资源中的模板:
<ContentPage.Resources>
...
<DataTemplate x:Key="instructionTemplate"
x:DataType="vms:InstructionViewModel">
<VerticalStackLayout Spacing="10">
<Label FontSize="20" Text="{Binding Index,
StringFormat='{0:D2}.', Mode=OneTime}" />
<Label Margin="10,0" Text="{Binding
Description, Mode=OneTime}" />
</VerticalStackLayout>
</DataTemplate>
</ContentPage.Resources>
InstructionViewModel的DataTemplate定义了我们想要如何可视化此类项:显示Index属性,其下方是Description。
注意,我们给DataTemplate分配了一个键(instructionTemplate),我们可以稍后使用它来引用这个特定的模板。
让我们添加一个NoteViewModel项的DataTemplate。以下代码块显示了可视化此类项的DataTemplate:
<DataTemplate x:Key="noteTemplate" x:DataType=
"vms:NoteViewModel">
<Grid Margin="20,0" ColumnDefinitions="35,*">
<Label
FontFamily="MaterialIconsRegular"
FontSize="20" Text=""
TextColor="LightSlateGray" />
<Label
Grid.Column="1" FontAttributes="Italic"
Text="{Binding Note, Mode=OneTime}"
TextColor="LightSlateGray" />
</Grid>
</DataTemplate>
通过使用这个DataTemplate,我们可以通过显示一个图标(我们使用MaterialIconsRegular字体)来可视化笔记,然后是笔记本身。两者都使用特定的颜色,以便在笔记和说明之间有清晰的区分。和之前一样,我们给DataTemplate分配了一个特定的键(noteTemplate),这样我们就可以稍后引用它。
接下来,让我们将InstructionsDataTemplateSelector添加到RecipeDetailPage。首先,将所述DataTemplateSelector的命名空间作为 XML 命名空间添加到页面中,如下所示:
xmlns:selectors="clr-namespace:Recipes
.Mobile.TemplateSelectors"
一旦设置好,我们就可以将InstructionsDataTemplateSelector类的一个实例添加到页面的Resources中,如下面的代码片段所示:
<selectors:InstructionsDataTemplateSelector
x:Key="instructionDataTemplateSelector"
InstructionTemplate="{StaticResource
instructionTemplate}"
NoteTemplate="{StaticResource noteTemplate}" />
使用 StaticResource 标记扩展来引用我们之前创建的两个 DataTemplate,并将它们分配给 InstructionsDataTemplateSelector 的相应属性。就像我们对单个 DataTemplate 所做的那样,我们给这个 InstructionsDataTemplateSelector 实例提供了一个键(instructionDataTemplateSelector),我们可以在以后使用它来引用。
要显示指令列表,我们可以在 RecipeDetailPage 的底部附近添加一个 VerticalStackLayout。以下代码片段演示了此设置:
<VerticalStackLayout Padding="10">
<Label FontAttributes="Italic,Bold"
FontSize="16" Text="Instructions" />
<VerticalStackLayout
Margin="0,10" Spacing="10"
BindableLayout.ItemsSource="{Binding
Instructions}"
BindableLayout.ItemTemplateSelector=
"{StaticResource instruction
DataTemplateSelector}"/>
</VerticalStackLayout>
Instructions property of our ViewModel to the BindableLayout ItemsSource property. Additionally, by using the StaticResource markup extension and the key we used in the resource dictionary for our DataTemplateSelector, we set the ItemTemplateSelector property. The result is shown in *Figure 6**.2*:
Figure 6.2: Showing instructions and notes
通过这样,我们已经探讨了如何在 Recipes! 应用程序中利用 DataTemplate 和 DataTemplateSelector 来可视化指令和注释。通过定义单独的 DataTemplate 并使用 DataTemplateSelector,我们可以动态地为集合中的每个项目选择适当的模板,提供定制且直观的烹饪指令和附加注释的显示。
现在我们已经成功实现了指令和注释的可视化,让我们继续处理空集合。
处理空集合
除了 ItemsSource 和 ItemTemplate 属性外,BindableLayout 还具有 EmptyView 和 EmptyViewTemplate 属性。这些属性允许我们定义如果提供的 ItemsSource 为空或为 null 时显示的内容。
EmptyView 属性可以是字符串值或 View。因此,在其最简单的形式中,我们可以在 VerticalStackLayout 中添加以下内容来显示购物清单:
<VerticalStackLayout
BindableLayout.EmptyView="Nothing to see here"
... >
当绑定的 ItemSource 不包含任何项目时,屏幕上会显示 "Nothing to see here"。
或者,如果我们想要对集合为空时显示的内容的外观有更多控制,我们也可以这样做:
<VerticalStackLayout
...>
<BindableLayout.EmptyView>
<Label Text="Nothing to see here"
FontAttributes="Bold" />
EmptyViewTemplate, we can specify a DataTemplate that needs to be shown when the bound collection is empty or null. This means that in this template, you can bind to values on the parent UI element or any other accessible context within the UI hierarchy. This flexibility enables you to create dynamic and context-aware empty views that can display relevant information or provide interactive elements based on the available data context.
As we saw earlier, data binding and the `INotifyPropertyChanged` interface allow the UI to stay in sync with the data on ViewModels, ensuring automatic updates. However, when it comes to dynamically adding or removing items from collections, the binding engine alone will not automatically reflect these changes in the UI. To achieve this kind of behavior, we need to explore the `ICollectionChanged` interface.
The ICollectionChanged interface
The `ICollectionChanged` interface provides a powerful mechanism for notifying the UI about changes in a collection itself, rather than on individual items within the collection. By implementing this interface, a collection can raise events that inform the binding engine and UI elements about structural changes, such as additions, removals, or modifications to the collection itself.
While it is possible to assign an updated list of items to a property on your ViewModel and trigger the `PropertyChanged` event, dynamically changing a collection requires a more optimal approach. By utilizing a collection that implements the `INotifyCollectionChanged` interface, we can achieve more efficient rendering of the UI. Instead of needing to re-render the entire collection on the UI, the binding engine can perform updates in a more optimized manner, resulting in improved performance and responsiveness.
The `ICollectionChanged` interface defines the `CollectionChanged` event, which is raised whenever the collection undergoes a structural change. This event provides detailed information about the type of change that occurred, such as whether an item was added, removed, or modified, and the position at which the change occurred. Let’s see what this means in terms of binding modes.
The ICollectionChanged interface and binding modes
To use this interface as efficiently as possible, it’s very important to understand how different binding modes affect this behavior.
OneTime binding
When using `OneTime` binding, the UI will perfectly update when items inside the collection change. However, there’s a caveat: if a new instance is assigned to the property holding the collection, this change won’t reflect in the UI. In such cases, instead of assigning a new instance, we need to clear the existing collection and add the new items to it. Importantly, the property setter should not trigger the `NotifyPropertyChanged` event as it’s unnecessary for `OneTime` binding.
OneWay binding
`OneWay` binding might offer more flexibility, allowing you to replace the collection with a new instance and reflect this in the UI. In this mode, make sure the property setter calls the `NotifyPropertyChanged` event to update the UI. While `OneWay` binding allows for greater flexibility, replacing an entire collection can be resource-intensive, requiring the UI to re-render the collection. This is especially important to consider when dealing with large datasets. If only a few items change, modifying the existing collection is often more efficient than replacing it.
By understanding these subtleties, you can make more informed decisions on what data binding mode to use.
Let’s put this into action and add some functionality to the *Recipes!* app by leveraging the `ObservableCollection` class.
Using the ObservableCollection
The `ObservableCollection` class is a specialized collection class provided by .NET that implements the `ICollectionChanged` interface out of the box.
Let’s enhance the functionality of our *Recipes!* app by introducing a `Shopping List` feature. We want to provide users with the ability to add ingredients from the list of recipe ingredients to a separate `Shopping List`. To achieve this, we will associate a button with each ingredient in the list. When the user taps the button, the corresponding ingredient will be added to an `ObservableCollection` named `ShoppingList`. As a result, the UI will be automatically updated each time an ingredient is added or removed from the list:
1. Let’s start by adding an additional property, `ShoppingList`, of type `Observable` **Collection<RecipeIngredientViewModel>** to `RecipeDetailViewModel`:
```
public ObservableCollection<RecipeIngredientViewModel>
ShoppingList { get; } = new();
```cs
We are automatically assigning a new instance to this property, which makes perfect sense: the instance of this property will not change as we will be adding and removing items from the collection. As `ObservableCollection` implements the `IObservableCollection` interface, the UI will remain in sync as the `CollectionChanged` event will be triggered when we manipulate the collection.
2. Currently, we don’t have functionality for managing items in the `ShoppingList` collection. So, let’s add the following to `RecipeDetailViewModel`:
```
public IRelayCommand AddToShoppingListCommand { get; }
public IRelayCommand RemoveFromShoppingListCommand
{ get; }
private void AddToShoppingList(
RecipeIngredientViewModel viewModel)
{
if (ShoppingList.Contains(viewModel))
return;
ShoppingList.Add(viewModel);
}
private void RemoveFromShoppingList
(RecipeIngredientViewModel viewModel)
{
if (ShoppingList.Contains(viewModel))
ShoppingList.Remove(viewModel);
}
```cs
The `AddToShoppingList` method will be responsible for adding an instance of `RecipeIngredientViewModel` to the `ShoppingList` collection if the given ViewModel isn’t already in there. The `RemoveFromShoppingList` method, on the other hand, will remove the item from `ShoppingList`.
For both methods, we’ve also created two corresponding commands, which we need to instantiate in the constructor of `RecipeDetailViewModel`, as shown here:
```
public RecipeDetailViewModel()
{
...
AddToShoppingListCommand = new RelayCommand
<RecipeIngredientViewModel>(AddToShoppingList);
RemoveFromShoppingListCommand = new RelayCommand
<RecipeIngredientViewModel>
(RemoveFromShoppingList);
}
```cs
3. Next, add the following XAML to the `VerticalStackLayout` that shows the ingredients of the recipe:
```
<VerticalStackLayout Padding="10">
<Label ...
Text="Ingredients list" />
...
<VerticalStackLayout Margin="10,0" Padding="10">
<Label
FontAttributes="Italic,Bold"
FontSize="16" Text="Shopping list" />
<VerticalStackLayout
Margin="0,10" Spacing="10"
BindableLayout.ItemsSource="{Binding
ShoppingList, Mode=OneTime}"
BindableLayout.EmptyView="Nothing added">
</VerticalStackLayout>
</VerticalStackLayout>
</VerticalStackLayout>
```cs
Below the list of ingredients, we’ve added a label with the text `"Shopping list"`, followed by another `VerticalStackLayout`. The `ShoppingList` property is bound to the `BindableLayout.ItemsSource` property. We’ve added an `EmptyView` property that will be shown when no items are on the list.
The `ItemTemplate`, which will be rendered for each item in `ShoppingList`, will be added in a few steps.
4. Let’s add a `Button` to the `ItemTemplate` of the `VerticalStackLayout` showing the ingredients. The `Button`’s `Command` should be bound to the `AddToShoppingListCommand` on the `RecipeDetailViewModel` as shown here:
```
<HorizontalStackLayout Spacing="5">
`<Button
Command="{Binding AddToShoppingListCommand,
Source={RelativeSource AncestorType={x:Type
vms:RecipeDetailViewModel}}}"
CommandParameter="{Binding}"
FontFamily="MaterialIconsRegular"
Text="" />
<Label
FontAttributes="Bold"
FontSize="16" VerticalOptions="Center"
Text="{Binding IngredientName,
StringFormat='{0}:'}" />
...
</HorizontalStackLayout>
```cs
The `Button`’s `Command` property is bound to the `AddToShoppingListCommand` on the `RecipeDetailViewModel`. As the `Button`’s `BindingContext` is the current `RecipeIngredientViewModel`, we need to use relative binding to point to the `RecipeDetailViewModel`. `CommandParameter` is data bound by just defining `{ Binding }`. This will bind it to the binding context of the UI element itself, which is the current `RecipeIngredientViewModel`. As a result, the `RecipeIngredientViewModel` instance is passed to the `AddShoppingList` method, allowing us to add it to the `ShoppingList` collection.
5. Finally, we can define the `ItemTemplate` of the `ShoppingList` items. We can copy the `DataTemplate` of the `"Ingredients list"`. However, we need to update the `Button` to this:
```
<Button
Command="{Binding RemoveFromShoppingListCommand,
Source={RelativeSource AncestorType={x:Type
vms:RecipeDetailViewModel}}}"
CommandParameter="{Binding}"
FontFamily="MaterialIconsRegular"
Button has a different icon and has its Command bound to RemoveFromShoppingListCommand, allowing the user to remove an ingredient again from the list.
```cs
With everything in place, users can now add ingredients from `"Ingredients list"` to `"Shopping list"`, from which items can also be removed again. Here’s what it looks like:

Figure 6.3: Shopping list
By using `ObservableCollection` – or any collection that implements `IObservableCollection` – it becomes very easy and efficient to keep a list of objects in sync with the UI.
Don’t overuse ObservableCollection
It is important to use `ObservableCollection` judiciously in your application. This specialized collection should be utilized when the collection itself dynamically changes, such as when items are added or removed, and the UI needs to reflect those changes. However, if the collection is fixed or assigned to a property in its entirety, there is no need to use `ObservableCollection`.
As we mentioned earlier, using `BindableLayout` is very easy to use and is perfect for showing small collections. For more advanced scenarios, there is `CollectionView`. Let’s have a look at it!
Working with CollectionView
`CollectionView` is an advanced control specifically designed for efficiently displaying large amounts of data. It offers all the properties available in `BindableLayout`, such as `ItemsSource`, `ItemTemplate`, `ItemTemplateSelector`, `EmptyView`, and `EmptyViewTemplate`. Additionally, `CollectionView` provides a wealth of powerful features, including item grouping, header and footer support, item selection and highlighting, item virtualization, and incremental loading of data as the user scrolls. These features enable you to create highly interactive and engaging user interfaces while efficiently managing and presenting your data. Item virtualization ensures that only the visible items are rendered, optimizing performance and memory usage, especially for large collections.
Other specialized controls
Aside from `CollectionView`, there are other specialized controls, such as `CarouselView` and `ListView`, for displaying collections in .NET MAUI. These controls also support `ItemsSource` binding and allow you to define an `ItemTemplate` or `DataTemplateSelector`. Each comes with a unique set of features and use cases, but the basic principles of data binding remain similar.
Now, let’s explore a simple example of using `CollectionView`. In our `RecipesOverviewViewModel`, we expose an `ObservableCollection` of `RecipeListItemViewModel`s called `Recipes`. Each `RecipeListItemViewModel` represents a recipe and contains a subset of properties relevant to displaying it on an overview page, such as the recipe’s ID, title, image, and favorite status. While the recipe’s ID may not be necessary for direct display on the screen, it is valuable for identifying the selected item for navigation purposes or implementing features such as “favoriting” an item from the list. For our app to start on `RecipesOverviewPage`, we need to update the `AppShell.xaml` file, as shown in the following snippet:
<ShellContent AppShell.xaml file, as shown earlier, and don’t worry about it.
To display the recipes in RecipesOverviewPage, we can use CollectionView. The ItemsSource property of CollectionView is bound to the Recipes property of the RecipesOverviewViewModel class, which serves as the page’s BindingContext. Similar to BindableLayout, we define the ItemTemplate property to specify how each item in the collection should be rendered. The following code snippet demonstrates this setup:
<CollectionView
ItemsSource="{Binding Recipes}">
<CollectionView.ItemTemplate>
<DataTemplate>
...
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
The usage of CollectionView should feel familiar if you have worked with BindableLayout before. You can copy the DataTemplate class from BindableLayout and it will show up the same.
Now, let’s leverage one of the more powerful features of CollectionView: data grouping. Let’s explore how to effectively display grouped data.
Displaying grouped data
在集合中对数据进行分组是一种强大的方式,可以有意义和结构化地组织和展示信息。通过分组相关项目,您可以提供直观的导航并增强用户体验。CollectionView 允许我们轻松显示分组数据,并提供 GroupHeaderTemplate 和 GroupFooterTemplate 属性。这些模板允许我们定义在项目组上方和下方显示的内容。图 6.4 展示了如何使用分组标题和脚本来渲染分组项目列表:
图 6.4:分组标题和脚注
RecipeRatingDetailPage, which can be accessed by tapping on the rating information on RecipeDetailPage, should show all the ratings of a recipe grouped by the number of stars. Let’s walk through the steps needed to set this up:
While RecipeRatingsDetailViewModel already contains a list of all the reviews of a recipe, it doesn’t group the data yet. First, let’s create a class for holding a group of ratings. In the RatingGroup as its name.
The RatingGroup class inherits from List<UserReviewModel> and has an additional property called Key. Here’s what it looks like:
public class RatingGroup : List<UserReviewViewModel>
{
public string Key { get; private set; }
public RatingGroup(string key,
List<UserReviewViewModel> reviews) :
base(reviews)
{
Key = key;
}
}
这个类作为一个专门用于存储 UserReviewViewModel 对象的列表。它继承自 List<UserReviewViewModel>,这意味着它可以做任何常规列表能做的事情,例如存储多个 UserReviewViewModel 项目。除此之外,该类还包括一个名为 Key 的额外属性。这个属性用于根据某些标准(如评分或类别)对用户评论进行分组。
接下来,向 RecipeRatingsDetailViewModel 添加一个 GroupedReviews 属性。这个属性的类型是 List<RatingGroup>。它包含根据星级数量组织的评论组。列表中的每个 RatingGroup 将包含具有相同星级数量的评论,这由 Key 属性表示。以下代码片段展示了这个新添加的属性以及如何在构造函数中初始化它:
List<RatingGroup> _groupedReviews = new();
public List<RatingGroup> GroupedReviews
{
get => _groupedReviews;
private set => SetProperty(ref _groupedReviews,
value);
}
public RecipeRatingsDetailViewModel(...)
{
...
Reviews = new() { ... };
GroupedReviews = Reviews.GroupBy(r =>
Math.Round(r.Rating / .5) * .5)
.OrderByDescending(g => g.Key)
.Select(g => new RatingGroup(g.Key.ToString(),
g.ToList()))
.ToList();
}
在构造函数中,我们已经根据评分(四舍五入到 0.5)对所有的评论进行了分组。我们使用这个分组来创建一个 RatingGroups 项目的列表,并将其分配给 GroupedReviews 属性。
现在,我们可以将 GroupedReviews 属性绑定到 RecipeRatingDetailPage 上的 CollectionView。在绑定分组集合时,我们还需要确保 CollectionView 的 IsGrouped 属性设置为 true,如下代码片段所示:
<CollectionView
IsGrouped="True"
ItemsSource="{Binding GroupedReviews}">
...
</CollectionView>
正如我们之前所做的那样,我们应该定义一个 ItemTemplate 来声明每个项目应该如何渲染。一旦设置好,CollectionView 就会渲染所有项目,但不同组之间还没有明显的区分。
因此,让我们添加 GroupHeaderTemplate 和 GroupFooterTemplate 以清楚地区分不同的组。以下代码块演示了如何实现这一点:
<CollectionView.GroupHeaderTemplate>
<DataTemplate x:DataType="{x:Type
vms:RatingGroup}">
<Label
Margin="0,25,0,0" FontSize="16"
Text="{Binding Key, StringFormat='{0}
stars Reviews'}" />
</DataTemplate>
</CollectionView.GroupHeaderTemplate>
<CollectionView.GroupFooterTemplate>
<DataTemplate x:DataType="{x:Type
vms:RatingGroup}">
<Label FontSize="12" Text="{Binding Count,
StringFormat='{0} reviews'}" />
</DataTemplate>
</CollectionView.GroupFooterTemplate>
这些模板的 BindingContext 是 RatingGroup 的一个实例,允许我们绑定到其属性,如 Key 和 Count。有了这个,我们可以使分组对用户来说在视觉上更加清晰。
在 CollectionView 中对数据进行分组允许以更有组织和结构化的方式展示信息,如图 6.5 所示。5:
图 6.5:分组数据
通过利用 GroupHeaderTemplate 和 GroupFooterTemplate 属性,您可以增强用户体验,并在您的应用中提供直观的导航。
不仅 CollectionView 可以渲染您的数据,它还提供了各种交互功能,以吸引用户参与您的应用。从选择项目到增量加载数据和其他常见功能,让我们在保持 MVVM 原则的同时,发现如何充分利用这个强大的控件。
选择项目
当使用 CollectionView 时,您有多种选择项目和管理选择状态的方法。通过绑定 SelectedItem 属性,您可以在 ViewModel 中轻松跟踪当前选中的项目。此外,您可以将 SelectedItems 属性绑定到 ViewModel 中的集合以跟踪多个选中的项目。SelectionMode 属性允许您定义是否可以选择单个或多个项目,或者是否禁用项目选择。您可以使用 SelectionChangedCommand 属性绑定 ViewModel 中的命令,该命令将在选择更改时执行,或者处理 SelectionChanged 事件,从而在您的应用程序中实现灵活和交互式的项目选择行为。
让我们看看如何允许用户选择一个或多个评论,例如允许用户报告不适当的评论:
将以下属性添加到 RecipeRatingsDetailViewModel:
public ObservableCollection<object> SelectedReviews
{ get; } = new();
此属性将保存用户在 CollectionView 上选择的项目。请注意,此 ObservableCollection 使用 object 作为其类型参数,而不是更具体的类型。尽管没有明确记录,但使用 object 似乎是在 CollectionView 中成功绑定多选的唯一方法。
现在,让我们添加一个名为 ReportReviewsCommand 的命令,该命令只能在选择一个或多个评论时执行,如下所示:
public RelayCommand ReportReviewsCommand { get; }
public RecipeRatingsDetailViewModel()
{
...
ReportReviewsCommand = new
RelayCommand(ReportReviews,
() => SelectedReviews.Any());
}
private void ReportReviews()
{
var selectedReviews = SelectedReviews
.Cast<UserReviewViewModel>().ToList();
//do reporting
SelectedReviews.Clear();
}
由于 SelectedReviews 属性是 ObservableCollection,我们可以监听 CollectionChangedEvent 并调用 ReportReviewsCommand 的 NotifyCanExecuteChanged 方法。这样,ReportReviewsCommand 的 CanExecute 方法将重新评估。以下代码片段显示了如何实现这一点:
public RecipeRatingsDetailViewModel()
{
. ...
SelectedReviews.CollectionChanged +=
SelectedReviews_CollectionChanged;
}
private void SelectedReviews_CollectionChanged(object?
sender, NotifyCollectionChangedEventArgs e)
=> ReportReviewsCommand.NotifyCanExecuteChanged();
更新 RecipeRatingDetailPage 上的 CollectionView,使其 SelectedItems 属性绑定到 SelectedReviews 属性。我们还需要设置适当的 SelectionMode,如下所示:
<CollectionView
IsGrouped="True"
ItemsSource="{Binding GroupedReviews}"
SelectedItems="{Binding SelectedReviews}"
SelectionMode="Multiple">
通过添加此代码,用户在 UI 上选择的项目将被添加到 SelectedReviews 列表中,而用户取消选择的项目将从其中移除。通过 ReportReviews 方法,我们可以轻松访问 SelectedReviews 属性以查看已选择的项目。
作为最后的例子,让我们看看如何将 SelectedItem 和 SelectionChangedCommand 属性绑定以触发导航等操作。让我们看看如何在 RecipesOverviewPage 上实现这一点:
在 RecipesOverviewPage 上,更新 CollectionView 并绑定其 SelectedItem 和 SelectionChangedCommand 属性,如下所示:
<CollectionView
...
SelectedItem="{Binding SelectedRecipe,
Mode=TwoWay}"
SelectionChangedCommand="{Binding
NavigateToSelectedDetailCommand}"
SelectionMode="Single">
SelectedItem 属性与 SelectedRecipe 属性双向绑定,并且 SelectionChangedCommand 绑定到 NavigateToSelectedDetailCommand。这两个属性将很快被添加。此外,SelectionMode 属性设置为 "Single",允许用户在列表中选择一个项目。
现在,让我们将前面提到的两个属性添加到 RecipeOverviewViewModel 中。以下是它们的形状:
RecipeListItemViewModel? _selectedRecipe;
public RecipeListItemViewModel? SelectedRecipe
{
get => _selectedRecipe;
set => SetProperty(ref _selectedRecipe, value);
}
public AsyncRelayCommand NavigateTo
SelectedDetailCommand { get; }
以下代码片段展示了如何在 ViewModel 的构造函数中实例化该命令:
public RecipesOverviewViewModel()
{
...
NavigateToSelectedDetailCommand = new
AsyncRelayCommand(NavigateToSelectedDetail);
}
因为此命令绑定到 CollectionView 的 SelectionChangedCommand,所以当选择或取消选择项目时,它将被触发。
以下代码块显示了 NavigateToSelectedDetail 方法:
private Task NavigateToSelectedDetail()
{
if (SelectedRecipe is not null)
{
//ToDo navigate to selected item
SelectedRecipe = null;
}
return Task.CompletedTask;
}
当用户在 CollectionView 中选择一个项目时,ViewModel 上的 SelectedRecipe 属性将被更新。接下来,NavigateToSelectedDetailCommand 将被执行,这将调用 NavigateToSelectedDetail 方法。在这个方法中,我们可以访问 SelectedRecipe 属性并对其采取行动,例如导航到其详细页面。最后,我们将 SelectedRecipe 属性设置为 null。由于此属性是双向绑定的,项目将在 CollectionView 中取消选中。因此,如果我们从 RecipeDetailPage 返回,概述中不会选择任何项目,然后我们可以立即选择另一个项目。
现在,让我们看看如何在用户滚动大型数据集时增量加载数据。
增量加载数据
当用户在 CollectionView 中滚动大型数据集时,提供无缝和交互式的用户体验至关重要。RemainingItemsThreshold 和 RemainingItemsThresholdReachedCommand 属性允许您轻松地加载更多项目。通过指定剩余项目的阈值,新数据将动态获取并无缝加载,确保流畅和连续的体验。让我们通过几个简单的步骤来探索如何实现这种交互功能:
将以下方法添加到 RecipesOverviewViewModel:
private async Task TryLoadMoreItems()
{
//Dummy implementation
if (Recipes.Count < TotalNumberOfRecipes)
{
await Task.Delay(250);
foreach (var item in items)
{
Recipes.Add(item);
}
}
}
此方法将项目添加到 Recipes ObservableCollection 类中,因为它比 TotalNumberOfRecipes 属性中定义的项目要少。这是一个相当愚蠢的实现,但它应该能说明问题。在现实场景中,我们会从 API 或其他地方获取这些数据。我们将在 第十章 ,与远程数据工作 中查看这一点。
接下来,让我们添加一个名为 TryLoadMoreItemsCommand 的命令。这是我们想要加载数据时应该调用的命令。我们希望将其实例化在 RecipesOverviewViewModel 的构造函数中,如下所示:
public AsyncRelayCommand TryLoadMoreItemsCommand
{ get; }
public RecipesOverviewViewModel()
{
Recipes = new ObservableCollection
<RecipeListItemViewModel>(items);
TryLoadMoreItemsCommand = new AsyncRelayCommand
(TryLoadMoreItems);
}
现在,我们可以更新 CollectionView 并添加 RemainingItemsThreshold 和 RemainingItemsThresholdReachedCommand,如下面的代码块所示:
<CollectionView
ItemsSource="{Binding Recipes}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding
TryLoadMoreItemsCommand}"
...>
在此代码设置后,当用户滚动并且剩余项目数量达到指定的五个或更少时,TryLoadMoreItemsCommand 将被调用。然后,TryLoadMoreItems 方法将向 Recipes ObservableCollection 添加项目。因为此方法实现了 ICollectionChanged 接口,所以添加的项目将被自动添加到 CollectionView。
在处理非常大的数据集时,增量加载数据是一个好主意,而 CollectionView 通过使用 RemainingItemsThreshold 和 RemainingItemsThresholdReachedCommand 属性,使得实现这一点变得极其方便。
其他常见交互
除了我们讨论的功能之外,还值得提一下,常见的交互,如下拉刷新和项目上下文菜单,也在 .NET MAUI 中提供。这些交互允许用户刷新集合中显示的数据,并访问针对每个项目的附加操作或信息。虽然它们在集合上使用得非常频繁,但重要的是要注意,这些功能并不局限于 CollectionView,可以在应用程序的任何地方实现。
SwipeView 是一个多才多艺的控件,允许您向集合中的单个项目添加滑动手势。它使用户能够通过在项目上水平或垂直滑动来执行操作,例如删除项目或显示附加选项。
另一方面,RefreshView 是一个提供标准下拉刷新功能的控件。它允许用户通过在屏幕上向下拉来刷新集合中显示的数据。当被触发时,RefreshView 执行一个命令以使用新鲜数据更新集合。
摘要
在本章中,我们探讨了 .NET MAUI 中集合的强大功能和能力。我们学习了如何使用 BindableLayout 和 CollectionView 有效地将集合绑定到 UI 元素,从而实现数据的动态和高效渲染。我们涵盖了数据模板、项目选择、分组和增量加载等主题。CollectionView 证明是一个多才多艺的控件,提供了如项目虚拟化和无缝数据加载的高级功能。
随着我们继续构建健壮和可扩展的应用程序之旅,在下一章中,我们将深入探讨依赖注入、服务和消息传递等重要概念。这些应用程序开发的基本方面将使我们能够创建模块化和可维护的代码,提高代码的可重用性,并使应用程序不同部分之间的有效通信成为可能。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
第二部分:使用 MVVM 构建.NET MAUI 应用程序
在本部分中,我们专注于为您提供构建真正的.NET MAUI 应用程序所需的必要工具和技术,该应用程序利用 MVVM 模式。我们将深入研究核心机制,如依赖注入、服务和消息传递,这些是任何强大应用程序的骨架。我们将帮助您掌握.NET MAUI 中的基于 MVVM 的导航,无论是否有 Shell,以及通过精确的输入和验证来细化用户交互,并与远程数据源无缝连接。
本部分包含以下章节:
第七章 , 依赖注入、服务和消息传递
第八章 , .NET MAUI Shell 和导航
第九章 , 处理用户输入和验证
第十章 , 处理远程数据
第七章:依赖注入、服务和消息传递
随着我们继续使用.NET MAUI 构建我们的Recipes! 应用程序,我们希望充分利用 MVVM 设计模式。MVVM 非常适合保持我们的代码整洁,并促进行业标准实践,使我们的代码库更易于维护和测试。在本章中,我们将重点关注两个对坚实的 MVVM 架构至关重要的关键概念:依赖注入 (DI )和消息传递 。DI 促进了关注点的分离,并使我们的代码更容易进行测试。消息传递帮助我们保持代码的不同部分不会相互纠缠。它允许应用程序的不同区域以松耦合的方式相互通信。这两个概念对于确保我们的 MVVM 架构真正突出至关重要。
让我们看看本章涵盖了哪些内容:
通过依赖注入实现控制反转
注册、解析和注入服务
消息传递
到本章结束时,您将对我们如何在Recipes! 应用程序中实现这些概念有一个很好的理解。那么,让我们继续深入探讨。
技术要求
在本章的整个过程中,我们将增强Recipes! 应用程序的功能。包括本章涵盖的主题所需的所有资源,包括额外的类和代码,都可以在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter07 。要跟随本章的内容,您可以从提供的Start文件夹开始。它包含本章的初始代码和必要的特定类。此代码作为基础,建立在之前章节学到的内容之上。如果您想参考或比较完成的代码,包括本章编写的所有代码,可以在Finish文件夹中找到。
通过依赖注入实现控制反转
控制反转 (IoC )是一种编程原则,其中程序流程的某些方面的控制权从主代码转移到框架或容器。简单来说,组件不再负责管理其依赖项和生命周期,这些责任被反转或委托给外部控制器。这种方法对于创建模块化和灵活的系统特别有用。
在典型的无 IoC 的软件设计中,一个需要从其他类获取特定功能的类会在其内部创建或管理这些依赖对象。使用 IoC 时,这种创建和管理由外部组件处理,因此实现了控制反转。
IoC 可以通过多种方法实现,例如 DI、工厂模式、服务定位器等。在这些方法中,DI 是 MVVM 上下文中最常用的方法。
依赖注入
依赖注入(DI)是 IoC 的一种特定形式,它涉及通过外部实体将对象的依赖项(如服务或组件)注入到对象中,而不是由对象创建它们。这通常是通过构造函数完成的。
使用 DI 带来了许多优势。让我们看看:
关注点分离 :每个组件或类都专注于其核心责任。其依赖项的创建和生命周期管理由外部处理。
可测试性 :DI 通过允许注入模拟依赖项,使得测试组件变得更加容易。这对于单元测试至关重要,因为在单元测试中,你希望隔离正在测试的组件,并且不必担心依赖项。例如,如果 ViewModel 依赖于一个获取数据的服务,你可以注入一个模拟数据服务来模拟数据检索,而不必实际击中数据库或 API。这使得测试更快、可重复且更可靠。
可重用性和可维护性 :组件因为不与其依赖项紧密耦合,所以变得更加可重用和可维护。
灵活性 :在不改变依赖类的情况下,更容易更改或交换依赖项的实现。
通过允许从外部注入依赖项,依赖注入(DI)支持创建更松散耦合的代码。这不仅导致系统更易于维护和扩展,而且对于测试也非常有利。通过在测试期间注入模拟或存根实现,你可以专注于单独测试单个组件的功能,而不必担心整个系统的复杂性和不可预测性。
注入不同实现的能力是 DI 的一个强大方面,并且对于创建健壮和灵活的架构至关重要。
记得,在第一章 ,“什么是 MVVM 设计模式?”中,我们有一个MainPageViewModel,其构造函数接受一个名为IQuoteService的接口?让我们看看:
public class MainPageViewModel : INotifyPropertyChanged
{
private readonly IQuoteService quoteService;
public MainPageViewModel(IQuoteService quoteService)
{
this.quoteService = quoteService;
}
...
}
这是一个依赖注入的典型示例。MainPageViewModel不应该负责检索“每日名言”。相反,这应该是另一个类的责任。MainPageViewModel依赖于一个实现IQuoteService接口的类的实例以实现该功能。然而,而不是直接创建或管理该服务的实例,它通过其构造函数接收该实例。这就是所谓的通过构造函数的IQuoteService依赖,MainPageViewModel遵循的原则是MainPageViewModel类的责任是提供视图数据,而获取实际数据的责任则委托给IQuoteService。
在这里,MainPageViewModel对实现IQuoteService接口的类的来源、实例化方式或生命周期管理一无所知。它只是接收一个实例并使用它。这使得 ViewModel 独立于IQuoteService接口的具体实现,并且可以是实现此接口的任何类。
对于这个IQuoteService接口,我们只需创建一个新的类来实现它,并从 API 获取数据。然后我们可以将这个新类注入到我们的 ViewModel 中,而无需更改MainPageViewModel本身的任何代码。ViewModel 只关心它有一个实现IQuoteService接口的类来与之交互,而不关心它完成任务的具体细节。
注意
虽然在依赖注入(DI)中使用接口很常见,但这并不是一个严格的要求。接口在 DI 中很受欢迎,因为它们促进了松耦合。然而,抽象类甚至具体类也可以被注入。选择取决于您应用程序的具体需求和设计目标。
当涉及到测试时,这种解耦的优势变得更加明显。假设我们想要为MainPageViewModel编写单元测试。为了使这些测试可靠,我们需要确保它们不受外部依赖不可预测性的影响。使用依赖注入(DI),我们可以通过创建一个返回受控数据的IQuoteService的模拟实现来轻松实现这一点,这对于测试场景来说非常完美。这样,我们就可以在隔离的情况下测试MainPageViewModel的所有方面,而不会受到外部依赖不可预测行为的影响。
让我们看看 DI 的实际应用,并看看我们如何注册、解析和注入依赖项。
注册、解析和注入服务
.NET MAUI 自带对 DI 的支持。它已经考虑到了 DI,这使得配置和管理应用程序所依赖的服务变得更加容易。通过提供开箱即用的 DI 支持,.NET MAUI 使开发者能够利用 DI 和 IoC 的概念来使他们的代码更易于维护和更松耦合。由于 MVVM 模式从 DI 和 IoC 中获得了巨大的好处,这再次表明 MVVM 和.NET MAUI 是完美匹配的!
Microsoft.Extensions.DependencyInjection命名空间是.NET MAUI 获取其默认 DI 实现的来源。然而,需要注意的是,.NET MAUI 对 DI 容器是无关的,这意味着您不受默认容器的限制。如果您更喜欢第三方 DI 容器,您可以自由地将默认容器替换为您首选的选择。让我们看看如何使用.NET MAUI 的默认 DI 实现来注册服务。
注册服务
.NET MAUI 托管一个依赖注入容器,并使其在整个应用程序中可用。当应用程序启动时,你有机会配置将可用于注入的服务。这是通过MauiAppBuilder实例的Services属性完成的。如果你之前使用过 ASP.NET,那么你已经知道这是如何工作的。通过Services属性,我们可以在整个应用程序中设置服务。
MauiAppBuilder上的Services属性是IServiceCollection类型,这是一个框架提供的用于服务描述符集合的接口。它提供了在容器中注册服务的方法。在下面的示例中,使用AddSingleton方法将QuoteService注册为IQuoteService接口的单例服务:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
...
builder.Services.AddTransient<IQuoteService,
QuoteService>();
...
return builder.Build();
}
前面的代码展示了我们如何将一个具体的实现(QuoteService)与一个接口(IQuoteService)关联起来。因此,当应用的IServiceProvider需要解析实现IQuoteService的类的实例时,它将创建(或重用)QuoteService类的实例。IServiceProvider代表一个服务容器,它是一组服务注册的集合。它本质上是一个负责在需要时解析和提供通过关联的IServiceCollection注册的服务实例的对象。为特定接口注册具体的实现使我们能够将实现与代码中的实际使用解耦。面向接口编程而不是面向实现编程是一种良好的编程实践,这正是我们在这里能够实现的目标。
然而,对于 ViewModel 或其他不一定有相关接口的类,我们可以直接注册它们,如下面的代码片段所示:
builder.Services.AddTransient<MainPageViewModel>();
当我们这样做时,每次从依赖注入容器请求MainPageViewModel的实例时,它将提供一个类的实例并管理其生命周期。
根据预期的生命周期,服务可以以不同的方式注册:
瞬态(Transient) :瞬态服务每次从容器请求时都会创建。这种生命周期最适合轻量级、无状态的服务。从理论上讲,瞬态服务因为每次请求服务时都会创建一个新的实例,所以会占用更多的内存。然而,因为每个实例一旦不再使用就可以被垃圾回收,所以这些内存可以快速回收。
单例(Singleton) : 单例服务只创建一次,并在整个应用程序的生命周期内使用相同的实例。这对于需要在整个应用程序中保持一致性的有状态服务,或者对于创建多次成本较高的重服务来说,效果最好。单例服务占用的内存最少,因为只创建了一个实例。然而,由于整个应用程序都使用相同的实例,它将保留在内存中,直到应用程序运行结束。
作用域(Scoped) : 作用域服务在每个作用域内创建一次。然而,在.NET MAUI 应用程序中,这类似于单例,因为通常只有一个作用域。
选择这些生命周期完全取决于服务的具体需求。如果你的服务是无状态的且轻量级,那么瞬态(Transient)生命周期可能很合适。如果你的服务需要在整个应用程序中维护状态,或者创建成本较高,那么单例(Singleton)生命周期可能是最佳选择。为你的服务选择正确的生命周期非常重要。它可能会影响你的应用程序的行为和性能,所以请仔细考虑。根据我的经验,我通常尽可能选择瞬态(Transient)作为我的服务。我这样做是因为我旨在保持我的服务简单且无状态。这样,它们更有效地使用内存,因为一旦不再需要,它们就会被移除。此外,这有助于避免在不同地方更改共享状态时可能出现的问题,尤其是在多线程情况下。但请记住,这里没有一刀切的方法。每个服务都是独特的,你的应用程序也是如此。根据你的服务做什么以及你的应用程序需要什么,你可能需要选择不同的生命周期。这就是为什么理解这些概念并为你的特定应用程序做出明智的选择至关重要。
让我们看看我们如何解决和注入这些已注册的服务。
解决和注入服务
DI 容器,如.NET MAUI 中内置的容器,能够解决不仅包括直接依赖项,还包括嵌套依赖项。
从本质上讲,当容器解决类或服务的实例时,所有其依赖项以及这些依赖项的依赖项都会自动解决并注入。这形成了一个完整的对象图,其中每个类都有其依赖项得到满足。
如果一个特定的类有一个需要注入的依赖项,只需将其定义为类构造函数的参数即可。这正是我们对MainPageViewModel所做的那样:它有一个需要实现IQuoteService接口的类的实例的构造函数。
DI 容器使用哪个构造函数?
由于 DI 容器可以为我们实例化类,你可能会想知道当类有多个构造函数时,它使用哪个构造函数。答案是相当简单的:它使用可以解析的参数最多的构造函数。如果有两个或更多具有相同参数数量的构造函数可以被 DI 容器解析,或者当找不到所有依赖项都可以解析的构造函数时,将抛出异常。
我们还能够动态解析服务,只要我们能够获取到应用的IServiceProvider容器。此接口公开了一个GetService<T>方法,我们可以调用它来获取与提供的泛型类型参数关联的类的实例。以下代码块显示了如何创建一个静态的ServiceProvider类,我们可以用它从应用的任何地方访问服务容器:
public static class ServiceProvider
{
public static TService GetService<TService>()
=> Current.GetService<TService>();
public static IServiceProvider Current
=>
#if WINDOWS10_0_17763_0_OR_GREATER
MauiWinUIApplication.Current.Services;
#elif ANDROID
MauiApplication.Current.Services;
#elif IOS || MACCATALYST
MauiUIApplicationDelegate.Current.Services;
#else
null;
#endif
}
因此,我们不需要在MainPage_MVVM类的代码背后实例化MainPageViewModel,也不需要手动提供实现 IQuoteService 接口的类的实例,我们可以这样做:
public MainPage_MVVM()
{
InitializeComponent();
BindingContext = ServiceProvider
.GetService<MainPageViewModel>();
}
由于我们已经将MainPageViewModel注册为服务,并将QuoteService与之关联到IQuoteService接口,GetService方法将返回一个MainPageViewModel的实例,该实例通过其构造函数注入了QuoteService类的实例。这个ServiceProvider类可以在动态地从 DI 容器中解析类实例时非常有帮助。然而,对于这个特定的例子,我们可以更进一步,避免手动解析MainPageViewModel实例的需求。我们可以通过将MainPageViewModel的实例作为依赖项添加到MainPage_MVVM类中来实现这一点,如下所示:
public MainPage_MVVM(MainPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
以下代码片段显示了如何注册MainPage_MVVM类:
builder.Services.AddTransient<MainPage_MVVM>();
在这个示例应用中,我们使用.NET MAUI Shell 进行导航。这允许在导航过程中动态解析页面的实例。因此,当我们导航到MainPage_MVVM页面时,DI 容器会立即行动。它解析MainPage_MVVM页面的实例及其所有依赖项。
什么是.NET MAUI Shell?
在第八章“MVVM 中的导航”中,我们将更深入地探讨导航和.NET MAUI Shell 的各个方面。
现在我们已经通过“每日名言”应用体验了依赖注入(DI),让我们将所学应用到功能丰富的“食谱”应用中,进一步提升我们的技能。这将使我们能够更深入地了解 DI,并看到它如何在更复杂的项目中巧妙地被利用。所以,卷起袖子,让我们在“食谱”应用中使用 DI 大显身手吧。
应用依赖注入
到目前为止,在我们的 Recipes! 应用中的 ViewModels 我们一直在使用硬编码的数据。现在,是时候通过引入可以动态获取和管理数据的服务来给我们的应用程序注入更多活力了。GitHub 仓库中本章的 Begin 目录展示了某些更新和额外的代码,包括新的服务接口,如 IRecipeService、IFavoritesService 和 IRatingsService,以及它们各自的实现。这些服务将在我们的应用程序中扮演关键角色:IRecipeService 接口定义了一个将加载和管理食谱数据的服务合同。同样,IFavoritesService 概述了处理用户喜欢的食谱的服务规则,而 IRatingsService 接口对管理食谱评分的服务做了同样的事情。随着我们继续前进,我们将探讨如何在 MVVM 架构中使用这些服务,以及 DI 如何以干净、可管理的方式将它们全部整合在一起。
为了在我们的 Recipes! 应用中引入依赖注入(DI),我们需要确保,与我们现在所做的不一样,我们不再在代码背后初始化 ViewModels。相反,这些 ViewModels 需要被注入并分配给页面的 BindingContext。在我们更新 ViewModels 之前,让我们先看看这个。
向页面添加依赖项
要将特定 ViewModel 的依赖项添加到页面中,我们只需将 ViewModel 的类型作为参数添加到构造函数中。此外,我们还需要确保页面和 ViewModel 都已在 DI 容器中注册:
前往 RecipesOverviewPage 的代码背后,并将 RecipeOverviewViewModel 类型的参数添加到页面的构造函数中,如下面的代码片段所示:
public RecipesOverviewPage(RecipesOverviewViewModel
viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
接下来,我们需要确保 RecipesOverviewPage 和 RecipeOverviewViewModel 类已在 DI 容器中注册。只有这样,DI 容器才能解析 RecipesOverviewPage 并解析其依赖项,即 RecipesOverviewViewModel 的一个实例。前往 MauiProgram 并添加以下代码行:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
...
builder.Services
.AddTransient<RecipesOverviewPage>();
builder.Services
.AddTransient<RecipesOverviewViewModel>();
...
}
同样,我们还需要对 RecipeDetailPage 和 RecipeRatingDetailPage 做同样的处理:通过将它们作为参数包含进来,将它们各自的 ViewModels 作为依赖项添加。以下是 RecipesOverviewPage 的样子:
public RecipesOverviewPage(
RecipesOverviewViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
同样,对于 RecipeRatingDetailPage,我们必须做以下操作,其中我们想要注入 RecipeRatingsDetailViewModel:
public RecipeRatingDetailPage(
RecipeRatingsDetailViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
现在,就像我们之前做的那样,让我们在 MauiProgram 类中注册这些额外的页面和它们的 ViewModels:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
...
builder.Services
.AddTransient<RecipesOverviewPage>();
builder.Services
.AddTransient<RecipesOverviewViewModel>();
builder.Services
.AddTransient<RecipeDetailPage>();
builder.Services
.AddTransient<RecipeDetailViewModel>();
builder.Services
.AddTransient<RecipeRatingDetailPage>();
builder.Services
.AddTransient<RecipeRatingsDetailViewModel>();
...
}
通过这些修改,我们已经在我们的Recipes! 应用中成功实现了 DI 的基础元素。通过将页面及其对应的 ViewModel 注册到 DI 容器中,我们确保了每当需要这些组件时,它们可以很容易地由 DI 容器解决并提供。此外,通过通过构造函数将 ViewModel 注入到我们的页面中,我们将创建和管理 ViewModel 实例的责任从页面本身转移到 DI 容器。这为我们应用程序设置了一个更灵活、更易于维护的结构,为我们进一步通过额外的服务和功能增强它铺平了道路。
现在,让我们更仔细地看看我们需要对 ViewModel 进行的具体更改,以完全实现 DI。
向 ViewModel 添加依赖项
我们不再希望我们的 ViewModel 包含硬编码的数据,也不希望它们负责检索数据。因此,让我们向我们的 ViewModel 中引入一些依赖项:
在RecipesOverviewViewModel类的顶部,我们可以先移除items字段。我们正在远离硬编码的数据,并将使用服务来获取数据。
以下代码片段展示了我们如何向这个类中引入两个字段:recipeService,其类型为IRecipeService,以及favoritesService,其类型为IFavoritesService:
private readonly IRecipeService recipeService;
private readonly IFavoritesService favoritesService;
这些服务将负责加载此页面上的食谱,并显示用户是否收藏了它们。这两个服务都是将被注入到 ViewModel 中的依赖项。
此代码块展示了这些依赖项如何通过 ViewModel 的构造函数进行注入:
public RecipesOverviewViewModel(
IRecipeService recipeService,
IFavoritesService favoritesService)
{
this.recipeService = recipeService;
this.favoritesService = favoritesService;
Recipes = new ();
TryLoadMoreItemsCommand =
new AsyncRelayCommand(TryLoadMoreItems);
NavigateToSelectedDetailCommand =
new AsyncRelayCommand
(NavigateToSelectedDetail);
LoadRecipes(7, 0);
}
通过在RecipesOverviewViewModel的构造函数中定义这两个参数,DI 容器将尝试在创建RecipesOverviewViewModel时解决这两个实例。然后,解决的实例作为参数传递给构造函数,在那里我们可以将它们分配给之前创建的字段。
当我们检查LoadRecipes方法时,我们可以看到我们如何利用这些服务来加载数据:
private async Task LoadRecipes(int pageSize, int page)
{
var loadRecipesTask =
recipeService.LoadRecipes(pageSize, page);
var loadFavoritesTask =
favoritesService.LoadFavorites();
...
}
ViewModel 不关心这些食谱或收藏来自哪里。它只信任注入的服务——recipeService和favoritesService——遵循指定的接口并交付所需的功能。具体的实现被抽象化,从 ViewModel 中突出显示了 DI 的主要好处之一。
转到 RecipeDetailViewModel。在这个类中,我们同样希望移除所有硬编码的数据:Title、Allergens、Calories 等等。并且在我们做这件事的同时,我们应该更新属性为“完整”属性,这些属性会触发 PropertyChanged 事件。这是必要的,因为 ViewModel 上的数据将异步加载,因此在页面渲染时可能不存在。因此,每个属性的 PropertyChanged 事件需要在数据加载时触发,以便在 UI 上反映加载的值。以下代码片段显示了部分更新的属性:
string _title;
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
string[] _allergens = new string[0];
public string[] Allergens
{
get => _allergens;
set => SetProperty(ref _allergens, value);
}
int? _calories;
public int? Calories
{
get => _calories;
set => SetProperty(ref _calories, value);
}
现在也是时候更新标签的绑定模式了,该标签显示菜谱的标题。到目前为止,这被定义为 OneTime 绑定,但因为我们现在在将 ViewModel 设置为 RecipeDetailPage 的 BindingContext 之后加载菜谱的数据,我们需要确保更新的值也显示在屏幕上。让我们将绑定模式更新为 OneWay,这样当 Title 属性的值设置后触发 PropertyChanged 事件时,绑定引擎会更新 UI 上的值。以下代码片段显示了更新的标签:
<Label
FontAttributes="Bold"
FontSize="22"
Text="{Binding Path=Title, Mode=OneWay}"
VerticalOptions="Center" />
让我们更新 RecipeDetailViewModel 的构造函数,使其接受 IRecipeService、IFavoritesService 和 IRatingsService 的实例,如下所示:
public RecipeDetailViewModel(
IRecipeService recipeService,
IFavoritesService favoritesService,
IRatingsService ratingsService)
{
this.recipeService = recipeService;
this.favoritesService = favoritesService;
this.ratingsService = ratingsService;
...
}
这些服务(recipeService、favoritesService 和 ratingsService)是 ViewModel 中的 readonly 字段,如以下代码片段所示:
private readonly IRecipeService recipeService;
private readonly IFavoritesService favoritesService;
private readonly IRatingsService ratingsService;
以下代码块显示了 LoadRecipe 方法,该方法接受要加载的菜谱 ID 作为参数。此方法使用注入的服务来加载此 ViewModel 所需的所有相关数据:
private async Task LoadRecipe(string recipeId)
{
var loadRecipeTask =
recipeService.LoadRecipe(recipeId);
var loadIsFavoriteTask =
favoritesService.IsFavorite(recipeId);
var loadRatingsTask =
ratingsService.LoadRatingsSummary(recipeId);
await Task.WhenAll(loadRecipeTask,
loadRecipeTask, loadRatingsTask);
if(loadRecipeTask.Result is not null)
MapRecipeData(
loadRecipeTask.Result,
loadRatingsTask.Result,
loadIsFavoriteTask.Result);
}
获取 RecipeDetailDto、RatingsSummaryDto 和表示菜谱是否为收藏夹的 bool 值的三个异步任务并行启动。通过 Task.WhenAll 方法,我们等待所有三个任务完成。在此点之后,任务的 Result 属性包含检索到的数据。然后,通过 MapRecipeData 方法将这些数据映射到 ViewModel。
在下一章,第八章 ,MVVM 中的导航 ,我们将探讨如何将选中菜谱的 ID 从 RecipesOverviewViewModel 传递到 RecipeDetailViewModel 以加载选中菜谱的详细信息。现在,让我们在 ViewModel 的构造函数末尾添加以下代码片段以加载 ID 为 3 的菜谱的详细信息:
LoadRecipe("3");
最后,我们还需要更新 RecipeRatingsDetailViewModel。和之前一样,我们希望移除所有硬编码的数据,并更新构造函数,使其接受一个实现了 IRecipeService 接口和实现了 IRatingsService 接口类的实例。以下代码片段显示了更新的构造函数,其中我们还删除了 Reviews 和 GroupedReviews 属性的初始化:
public RecipeRatingsDetailViewModel(
IRecipeService recipeService,
IRatingsService ratingsService)
{
this.recipeService = recipeService;
this.ratingsService = ratingsService;
...
}
可以移除Reviews属性,并确保当RecipeTitle属性被更新时调用PropertyChanged事件。同样,我们必须这样做,因为数据是异步加载的,我们必须通知 UI 关于更新值的消息。下面的代码块显示了更新的RecipeTitle属性,它使用ObservableObject类的SetProperty方法来分配值并触发PropertyChanged事件。它还显示了我们在构造函数中分配注入依赖关系的字段:
public class RecipeRatingsDetailViewModel :
ObservableObject
{
private readonly IRatingsService ratingsService;
private readonly IRecipeService recipeService;
string _recipeTitle = string.Empty;
public string RecipeTitle
{
get => _recipeTitle;
set => SetProperty(ref _recipeTitle, value);
}
…
}
让我们再添加一个LoadData方法,该方法接受我们想要加载评分的食谱 ID。它使用注入的服务动态加载 ViewModel 中所需的数据。让我们看看:
private async Task LoadData(string recipeId)
{
var recipeTask =
recipeService.LoadRecipe(recipeId);
var ratingsTask =
ratingsService.LoadRatings(recipeId);
await Task.WhenAll(recipeTask, ratingsTask);
RecipeTitle =
recipeTask.Result?.Name ?? string.Empty;
GroupedReviews = ratingsTask.Result
...
.ToList();
}
现在,让我们在构造函数中调用LoadData方法,以便在初始化 ViewModel 时加载一些数据:
LoadData("3");
在对 ViewModel 的所有更新完成后,让我们通过注册我们的更新后的 ViewModel 现在作为依赖项的服务来完成。
注册服务
现在我们 ViewModel 有一些依赖项,我们必须确保这些依赖项被注册,以便 DI 容器可以解析它们。
在MauiProgram类中再次执行注册所需依赖项的操作。以下代码片段显示了如何注册FavoritesService,这非常直接:
builder.Services.AddSingleton<IFavoritesService,
FavoritesService>();
我们故意将FavoritesService注册为 Singleton,因为这种特定的实现将用户的收藏存储在内存中。如果我们将其注册为 Transient,每次将其作为依赖项注入时都会创建一个新的实例,这将导致收藏在页面导航之间不会持久化。然而,值得注意的是,将收藏保留在内存中并不是理想的,但为了这个示例,它将满足我们的目的。在现实场景中,我们希望收藏能够持久化存储在(在线)数据存储中。
注册RecipeService涉及一个稍微复杂的过程。原因在于RecipeService的构造函数需要一个Task属性,该属性返回一个指向包含所有食谱信息的 JSON 文件的流。这可以在RecipeService的构造函数中看到:
public RecipeService(Task<Stream> recipesJsonStreamTask)
{
this.recipesJsonStreamTask = recipesJsonStreamTask;
}
我们不能像之前在FavoritesService或其他服务中注册那样注册RecipeService。这是因为 DI 容器需要知道传递给构造函数的参数是什么。在之前的示例中,这很简单:我们只需指定我们想要与接口或基类或类型本身关联的具体类型。然后容器可以通过调用其默认构造函数或注入其他已解析的依赖关系来创建具体类的实例。
然而,对于 RecipeService 来说,创建实例所需的参数并不是我们计划注册的,这意味着它不能由依赖注入容器解析。为了应对此类场景,AddTransient、AddSingleton 和 AddScoped 方法提供了重载功能。这个重载功能允许我们传递一个函数,该函数返回我们想要与给定基类型关联的类型实例。每当关联类型需要被解析时,这个函数就会被调用。更重要的是,该函数的参数是 IServiceProvider 本身,这允许我们在必要时解析任何额外的依赖项。以下代码块展示了我们如何使用重载函数注册 RecipeService,同时传递一个创建此类实例的函数:
builder.Services.AddTransient<IRecipeService>(
serviceProvider => new RecipeService( FileSystem.
OpenAppPackageFileAsync("recipedetails.json")));
每当需要通过依赖注入容器解析 IRecipeService 对象时,传入的函数都会被调用。然而,由于使用了 AddSingleton 方法,该函数只会被调用一次。这意味着在这个特定用例中,将服务注册为 Singleton 可能是一个明智的决定,因为它将确保 JSON 文件只被读取一次,从而保持食谱在内存中,并优化应用程序的性能。
对于 RatingsService 的注册也是如此。就像 RecipeService 一样,这个类也将从本地文件中读取评分。因此,就像之前一样,我们希望使用重载的 AddTransient 方法来注册此服务,如下所示:
builder.Services.AddSingleton<IRatingsService>(
serviceProvider => new RatingsService( FileSystem.
OpenAppPackageFileAsync("ratings.json")));
一旦所有这些服务都已注册,我们就可以继续运行 Recipes! 应用程序。我们的代码现在利用了依赖注入,这是一种极大地增强了我们应用程序模块化和可测试性的实践。通过注入依赖项,我们解耦了具体类与接口或基类,允许我们更改或替换底层实现,而不会影响依赖类。在 MVVM 模式下,依赖注入允许我们为 ViewModels 提供处理其任务所需的服务,例如数据获取或业务逻辑,而不需要硬编码这些依赖项,从而促进关注点的清晰分离。此外,我们甚至通过将 ViewModels 直接注入到视图中,进一步强调了这种实践在我们应用程序开发过程中的灵活性和多功能性。
注意
尽管我们主要讨论了在 .NET MAUI 中基于构造函数的依赖注入,但值得提及的是,在更广泛的环境中,依赖也可以通过属性或方法注入。然而,这些方法在 .NET MAUI 中并不是原生支持的。依赖注入的本质是为类提供其依赖项,无论使用何种方法。构造函数注入通常因为其清晰性而被优先考虑,但所使用的具体技术可能会根据平台和设计目标的不同而有所变化。
DI 在保持我们应用程序组件解耦方面发挥着至关重要的作用。接下来,我们将深入了解另一种促进应用程序解耦的机制。
消息传递
消息传递是一种软件架构模式,它促进了应用程序不同部分之间的通信。在.NET MAUI 和 MVVM 架构的背景下,消息传递通常用于在松散耦合的组件之间发送通知,例如在 ViewModel 之间,或者从模型到 ViewModel。这解耦了组件,并促进了更模块化和可维护的代码库。
当需要在不同部分之间传递数据或通信事件,而这些部分之间没有直接关系时,消息传递的概念特别有用。而不是通过直接调用对方来紧密耦合这些部分,你可以使用一个消息系统,其中一个部分发送的消息可以被应用程序中任何感兴趣的任何部分接收并做出反应。
这种模式是观察者模式 的一种形式,其中名为主题 的对象维护其依赖者(称为观察者 )的列表,并自动通知它们任何状态变化,通常是通过调用它们的方法之一。同样,在 MVVM 中,消息传递用于在应用程序的解耦组件之间进行通信:应用程序中的任何对象,包括 ViewModel、服务、模型类或服务都可以发送消息,任何订阅了该特定类型消息的任何其他类都将被通知并可以相应地做出反应。
关于 MessagingCenter
MessagingCenter最初在 Xamarin.Forms 中作为组件之间松散耦合通信的机制被引入,但在.NET MAUI 中被标记为已弃用。虽然它在.NET 8 中保留以用于过渡场景,但其使用是不被推荐的!
通常,在 MVVM 的背景下,如以下图所示,消息系统本身维护一个观察者列表,并处理从发送者到适当接收者的消息传递:
图 7.1:消息概述
消息模式的一个显著挑战是其固有的不透明性:很难确定应用程序的哪些部分订阅了特定的消息。这种缺乏透明度在修改代码时可能导致不可预见的结果,使得代码库更难以导航和调试。当我确实使用消息时,我会非常谨慎。保持消息最小化和专注于特定任务可以帮助减轻这一挑战。使用消息的另一个潜在风险是无意中造成内存泄漏。这可能会发生在对象订阅了消息但从未取消订阅的情况下。如果发生这种情况,消息系统会继续持有订阅者对象的引用,即使应用程序中没有其他引用,也会阻止它被垃圾回收。随着时间的推移,这可能会导致内存使用量增加,并最终降低应用程序的性能。
在 MVVM 的上下文中,这个问题尤为重要,因为 ViewModel 可能会在初始化期间订阅消息,然后在用户在应用程序中导航时被新的 ViewModel 所取代。如果这些 ViewModel 在不再使用时没有取消订阅消息,它们将无限期地留在内存中。
正是这里,WeakReferenceMessenger 发挥了作用。
WeakReferenceMessenger
我们之前讨论过的 MVVM Toolkit 为我们提供了一个强大的消息传递实现,名为WeakReferenceMessenger。考虑到 MVVM 应用程序的需求,这个消息传递者确保我们可以在不担心潜在内存泄漏的情况下享受消息传递的好处。
与传统的消息传递者不同,后者对其订阅者持有强引用,WeakReferenceMessenger 则持有弱引用。这意味着它不会阻止其订阅者被垃圾回收。因此,即使你忘记取消订阅,垃圾回收器仍然可以在不再使用时清理你的 ViewModel,从而防止内存泄漏。
注意
在本节中,我们将使用 MVVM Toolkit 中的WeakReferenceMessenger作为我们的消息系统。然而,重要的是要注意,还有其他消息系统可供选择。虽然我们专注于WeakReferenceMessenger,但我们在这里讨论的核心概念——例如发送和接收消息——适用于大多数消息系统。始终记得研究和理解你正在使用的特定消息系统,以充分利用其功能并避免潜在的风险。
WeakReferenceMessenger使用基于类型的消息系统。这意味着当你发送消息时,你指定一个消息类型,只有订阅了该特定类型的接收者才会收到消息。消息类型通常定义为类,而消息数据则存储为该类的属性。
要发送消息,你必须使用 Send 方法,传入消息对象,可选地传入发送者和目标。然后,信使会将消息传递给所有已注册的指定消息类型的接收者。要接收消息,一个类需要通过调用 Register 方法与信使注册,指定它希望接收的消息类型,并提供一个回调方法,当发送该类型消息时将被调用。
让我们看看我们如何通过使用 WeakReferenceMessenger 更新我们的代码,并使其更加松耦合。
更新份数数量
通过消息传递,ViewModels 可以以松耦合的方式相互通信。例如,让我们看看 IngredientsListViewModel。当更新 NumberOfServings 属性的值时,我们会遍历 Ingredients 集合中的所有元素(它们是 RecipeIngredientViewModel 对象),并调用它们的 UpdateServings 方法,传入更新的值:
public int NumberOfServings
{
get => _numberOfServings;
set
{
if (SetProperty(ref _numberOfServings, value))
{
Ingredients
.ForEach(i => i.UpdateServings(value));
}
}
}
这种方法紧密耦合,因为属性了解其他对象的实现细节,特别是 RecipeIngredientViewModel。此外,它并不很好地遵循 单一职责原则 :属性不仅关注自身;它还负责更新其他属性的值。
因此,让我们在这里引入消息传递!
虽然 WeakReferenceMessenger 可以发送任何类型的消息,但你可能希望从某些基消息类继承。在这种情况下,我们可以从通用的 CommunityToolkit.Mvvm.Messaging.Messages.ValueChanged 类继承,因为 ServingsChangedMessage 正是为了这个目的。
下面的代码块显示了该类的实现:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Recipes.Client.Core.Messages;
public class ServingsChangedMessage :
ValueChangedMessage<int>
{
public ServingsChangedMessage(int value)
: base(value)
{ }
}
现在,我们可以更新 IngredientsListViewModel 类中的 NumberOfServings 方法。我们不再需要遍历配料列表中的每个项目并调用其 UpdateServings 方法,现在我们可以发送 ServingsChangedMessage,如下所示:
public int NumberOfServings
{
get => _numberOfServings;
set
{
if (SetProperty(ref _numberOfServings, value))
{
WeakReferenceMessenger.Default.Send(
new ServingsChangedMessage(value));
}
}
}
向 WeakReferenceMessenger 的实例调用 Send 方法发送消息就像发送一个你想发送的消息一样简单。
最后,我们需要更新 RecipeIngredientViewModel。这个类需要订阅 ServingsChangedMessage 以便能够对其做出反应。通过在 WeakReferenceMessenger 上调用通用的 Register 方法来注册消息类型。作为类型参数,你需要传入你想要监听的消息类型。以下是如何注册 ServingsChangedMessage 的一种方法:
public RecipeIngredientViewModel(...)
{
...
WeakReferenceMessenger.Default
.Register<ServingsChangedMessage>(this, (r, m) =>
((RecipeIngredientViewModel)r)
.UpdateServings(m.Value));
}
Register方法的第一参数是消息的接收者,在我们的情况下将是这个类本身。第二个参数是当接收到消息时被调用的处理程序。处理程序的第一个参数是接收者,第二个参数是消息本身。传入的接收者允许 Lambda 表达式不捕获this,这可以提高性能。也许将UpdateServings方法的访问修饰符更新为private也是一个好主意,因为不再需要公开访问这个方法。
使用这个更新后的实现,IngredientsListViewModel中的NumberOfServings属性不再需要了解RecipeIngredientViewModel对象。相反,当其值发生变化时,它只需发送一条消息。订阅这些消息的RecipeIngredientViewModel对象可以相应地更新其状态。这种方式解耦了这两个类,并确保每个类只负责管理自己的状态,遵循单一职责原则和关注点分离。
在以下示例中,我们将探讨消息传递不仅对 ViewModel 之间有价值。服务也可能发送 ViewModel 可以响应的消息。
保持收藏同步
在Recipes! 应用程序中,RecipesOverviewPage显示所有食谱,用户可以在RecipeDetailPage上标记收藏。然而,如果没有重新加载RecipesOverviewPage,新收藏的食谱不会被突出显示。鉴于食谱数据库不经常更新,频繁的页面重新加载将是过度行为,可能会影响用户体验。
一种更有效的策略是使用消息传递。当一个食谱被收藏时,会发送一条消息。RecipesOverviewViewModel中包含的RecipeListItemViewModel个体订阅了这个消息,并在接收到消息后实时更新其收藏状态。这种方法防止了不必要的请求数据,从而提高了应用程序的性能和响应速度。让我们看看我们需要做什么来实现这一点:
首先,让我们添加一个新的消息类型。右键单击FavoriteUpdateMessage作为类名。
将以下代码添加到FavoriteUpdateMessage类中:
public class FavoriteUpdateMessage
{
public string RecipeId { get; }
public bool IsFavorite { get; }
public FavoriteUpdateMessage(string recipeId,
bool isFavorite)
{
RecipeId = recipeId;
IsFavorite = isFavorite;
}
}
这个类包含两个属性,RecipeId和IsFavorite,这样我们就可以通过这条消息来指示哪个食谱被标记或移除收藏。
以下代码块展示了我们如何从FavoritesService发送FavoriteUpdateMessage,每当一个食谱被添加为收藏时:
public Task Add(string id)
{
if(!favorites.Contains(id))
{
favorites.Add(id);
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(id, true));
}
return Task.CompletedTask;
}
同样,当一个食谱被移除收藏时,也可以发送FavoriteUpdateMessage,如下所示:
public Task Remove(string id)
{
if (favorites.Contains(id))
{
favorites.Remove(id);
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(id, false));
}
return Task.CompletedTask;
}
最后一步是在RecipeListItemViewModel中订阅此消息。这确保了当FavoriteUpdateMessage到达时,IsFavorite属性可以相应地更新。与之前的示例不同,当时我们使用Register方法定义了一个消息处理器,这次我们将采用不同的方法,通过实现IRecipient接口。下面是如何做到这一点的示例:
public class RecipeListItemViewModel :
ObservableObject,
IRecipient<FavoriteUpdateMessage>
{
...
public RecipeListItemViewModel(...)
{
...
WeakReferenceMessenger.Default.Register(this);
}
void IRecipient<FavoriteUpdateMessage>
.Receive(FavoriteUpdateMessage message)
{
if (message.RecipeId == Id)
{
IsFavorite = message.IsFavorite;
}
}
}
通过实现CommunityToolkit.Mvvm.Messaging.IRecipient<TMessage>接口,其中TMessage在这种情况下是FavoriteUpdateMessage,我们指定了我们想要处理的消息类型。实现此接口允许我们调用WeakReferenceMessenger的Register方法,并将类本身作为唯一参数传递。该接口要求我们实现Receive方法,当接收到指定类型的消息时,该方法会被调用。
通过更新的代码,每当用户将食谱添加或删除为收藏时,都会发送一条消息。RecipeListItemViewModel的实例被设置为监听此消息并相应地更新它们的IsFavorite属性。因此,当用户从更新收藏状态的详细页面返回时,刷新的状态会立即在概览页上可见——而无需重新加载数据。
注意
虽然WeakReferenceMessenger为许多消息传递场景提供了强大的解决方案,但在处理大量监听者时,使用它时需要谨慎。始终监控应用程序的性能和行为,尤其是在向数千个监听者发送消息时,如果需要,考虑优化或重新评估你的设计。
摘要
在本章中,我们探讨了现代应用程序架构中的两个关键主题:依赖注入(DI)和消息传递。首先,我们探讨了依赖注入,这是一种在对象及其依赖项之间实现松耦合的技术。在 MVVM 模式中,我们利用这项技术将服务和其它依赖项注入到我们的 ViewModel 中,从而增强了它们的可测试性和可维护性。
本章的后半部分专注于消息传递,这是 MVVM 应用程序中另一个重要的组件,用于在组件之间促进解耦通信。我们考察了 MVVM Toolkit 提供的WeakReferenceMessenger,它有助于在应用程序中实现松耦合。
本质上,本章旨在强调软件设计中松耦合的重要性,展示了依赖注入和消息传递如何对创建可维护和可测试的应用程序做出重大贡献。
在接下来的章节中,我们将深入探讨.NET MAUI 中导航的复杂性以及如何将导航集成到我们的 MVVM 架构中。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
第八章:MVVM 中的导航
到目前为止,在我们的 Recipes! 应用程序构建之旅中,我们已经使用 MVVM 设计模式建立了坚实的基础。现在,还缺少一个重要部分:导航——即在不同页面之间移动。本章将专注于 .NET MAUI 内部导航的实际方面。我们将把讨论分为四个关键领域:
MVVM 主要关注关注点的分离,将逻辑与表示层解耦。当我们将导航集成到 MVVM 架构中时,我们实际上是在将“关注点分离”的原则扩展到导航逻辑。为了有效地实现这一点,掌握导航的关键原则是至关重要的。
到本章结束时,你将牢固掌握 .NET MAUI 的导航功能。无论你选择使用 .NET MAUI Shell 还是坚持使用传统的导航方法,你都将能够使你的应用程序导航流畅且用户友好。是时候深入研究了!
技术要求
我们将在本章中继续向 Recipes! 应用程序添加功能。像往常一样,所有内容都可以在 GitHub 上找到,网址为 github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter08 。你可以从 Start 文件夹中提供的代码开始,跟随本章内容。Finish 文件夹包含完成的代码,你可以参考它。
关于 .NET MAUI Shell
.NET MAUI Shell 是一种有观点的方式来创建 .NET MAUI 应用程序的结构。它引入了一种更简化的方法来构建移动应用程序,这在结构化和导航方面可能会相当复杂。Shell 通过提供统一的声明性语法来表达应用程序的结构和导航模式,从而简化了这些方面。
由于有观点,.NET MAUI Shell 有特定的指南和约定,它期望开发者遵循,以减少样板代码和努力。它带来了各种功能,旨在降低移动应用程序开发的复杂性:
基于 URI 的导航 :Shell 支持基于 URI 的导航方案,类似于网络开发模型。开发者可以定义一个指向特定页面的 路由 。这些路由允许在应用程序周围进行简单且松散耦合的导航,使导航代码更直接且更少出错。
简化复杂的应用程序结构 :Shell 提供了开箱即用的对常见 UI 元素(如飞出菜单、标签页和导航栏)的支持,以及所有这些元素的组合。开发者可以轻松地将这些结构添加到他们的应用程序中,并由 Shell 在不同平台上管理渲染。
性能 :Shell 还旨在通过更有效地处理其组件的生命周期来提高性能,提供更快的渲染时间。
然而,Shell 的固执己见性质意味着它可能不适合每个应用程序场景或开发者。它提供了一个预定义的结构,并期望开发者遵守它,这对简单的应用程序可能有益,但可能会限制更复杂场景的灵活性。
.NET MAUI Shell
.NET MAUI Shell 是任何移动开发者工具箱中的强大工具。它提供了一个高级别的抽象,以简化应用程序开发,但开发者应该评估其固执己见的方法是否与项目需求和限制相一致。
让我们看看如何在.NET MAUI 应用程序中利用 Shell。
设置 Shell
默认情况下,当创建一个新的.NET MAUI 应用程序时,Shell已经自动连接。一个继承自Microsoft.Maui.Controls.Shell的AppShell类被生成,并且这个AppShell类的实例被分配给App类的MainPage属性:
public App()
{
InitializeComponent();
MainPage = new AppShell();
}
MainPage属性的值指定了当应用程序启动时将显示的第一个页面。换句话说,它是进入应用程序 UI 的入口点。这可以是一个单独的内容页面、一个导航页面、一个标签页,甚至是一个主详情页面。或者,如这里所示,当利用Shell时,它也可以是一个Shell对象。Shell充当应用程序结构和导航的容器,定义了应用程序的初始布局和流程。
虽然MainPage是显示的初始页面,但从技术上讲,在应用程序的生命周期中的任何时刻都可以更改它,以适应应用程序的需求。例如,你最初可以将MainPage设置为LoginPage,一旦用户成功登录,你就可以将MainPage更改为你的AppShell。
在AppShell类中,你定义了应用程序的主要结构元素。例如,如果你的应用程序包含一个飞出菜单和几个标签页,你可以在你的AppShell中定义这些元素。以下是一个基本示例,展示了这可能看起来像什么:
<Shell.FlyoutHeader>
...
</Shell.FlyoutHeader>
<FlyoutItem Title="Quotes" Icon="badge.png">
<Tab Title="MVVM">
<ShellContent
Title="Quote of the Day"
ContentTemplate="{DataTemplate
local:MainPage_MVVM}"
Icon="badge.png" />
</Tab>
<Tab Title="Not MVVM">
<ShellContent
Title="Quote of the Day"
ContentTemplate="{DataTemplate local:MainPage}"
Icon="badge.png" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Settings" Icon="settings.png">
<ShellContent Title="Settings"
ContentTemplate="{DataTemplate
local:SettingsPage}" />
</FlyoutItem>
这里显示的代码将渲染一个包含飞出菜单的两个项目的 Shell:FlyoutItem 代表应用的独立部分。Quotes 部分被定义为包含两个标签页,每个标签页都有自己的标题。每个标签页的内容由一个 ShellContent 对象定义,该对象引用在标签页被选中时显示的页面。Settings 部分只包含一个 ShellContent 项目,它引用 SettingsPage。没有 .NET MAUI Shell,创建一个包含飞出菜单、标签页和独立部分的复杂布局,同时管理它们之间的导航,可能相当复杂且需要大量的样板代码。但有了 Shell,你可以在 AppShell 中以简单、声明性的方式定义这种结构,使其更容易管理和更新。图 8.1 显示了这里定义的布局,使用 Shell 在 XAML 中定义的外观:
图 8.1:使用 Shell 时的飞出项和标签页
Shell 不仅允许我们定义应用的主要结构,还允许我们定义路由。让我们看看这一点。
路由
.NET MAUI Shell 的路由系统基于命名路由的概念,这实际上是应用内页面的唯一标识符或路由。这简化了在页面之间导航的过程,并引入了一种将导航逻辑与页面类型松散耦合的方法。而不是直接引用页面类型,你导航到已注册的路由。这在你导航到特定名称注册的“页面”时提供了一种抽象级别,而不是直接导航到特定的页面。这允许与特定路由关联的实体页面发生变化,而无需修改你的导航逻辑。
我们可以使用静态 Microsoft.Maui.Controls.Routing 类上的 RegisterRoute 方法来注册路由。通常,路由在 AppShell 类的构造函数中注册,但可以在应用的任何位置进行,只要它在应用的生命周期早期完成。因此,MauiProgram 类中的 CreateMauiApp 方法也是一个不错的选择。在 每日名言 应用中,路由的注册是在后者中完成的。无论你选择在哪个位置注册你的应用路由,以下是完成方式:
Routing.RegisterRoute("about", typeof(AboutPage));
注册后,你可以像这样导航到路由:
await Shell.Current.GoToAsync("about");
此外,Shell 支持绝对和相对导航。一个以斜杠(/)开头的绝对 URI 在导航之前重置导航堆栈,而一个不以斜杠开头的相对 URI 将导航操作推送到导航堆栈上。为了清晰起见,导航堆栈实际上是用户已导航过的页面历史记录,允许在应用中进行前后导航。
这种基于命名路由的导航还支持在页面之间传递参数:
await Shell.Current.GoToAsync("aboutIDictionary<string, object>:
await Shell.Current.GoToAsync("about",
new Dictionary<string, object>()
{
{"foo", "bar" }
});
The reason I prefer this approach is the fact that this allows for passing complex objects, whereas the query string approach only allows for primitive types. And because I like consistency, I prefer to always use the dictionary.
These parameters, which we pass from one page to another, can be retrieved in different ways. One of them is to let the target page inherit the `Microsoft.Maui.Controls.IQueryAttributable` interface. As shown here, this interface defines just one method, `ApplyQueryAttributes`, that needs to be implemented:
public partial class AboutPage : ContentPage,
IQueryAttributable
{
...
public void ApplyQueryAttributes(
IDictionary<string, object> query)
{
lblParameter.Text = $"Parameter {query
.First().Key}: {query.First().Value}";
}
}
Moreover, if an instance of a class that implements the `IQueryAttributable` interface is assigned as the target page’s `BindingContext` (such as a ViewModel), that `ApplyQueryAttributes` method would also be invoked.
Adhering to MVVM best practices
I’ve often seen ViewModels inheriting the `IQueryAttributable` interface to receive navigation parameters. Although that works perfectly well, it goes against one of MVVM’s best practices, which says that ViewModels should be framework agnostic. This interface is .NET MAUI and Shell-specific, so it requires a dependency on these frameworks. Later in this chapter, I’ll show you how to not rely on the `IQueryAttributable` interface and still be able to receive navigation parameters.
As we mentioned previously, there are also other ways to receive parameters on the navigation target. I’m not going to dive deeper into that as we won’t be relying on that when we implement navigation in MVVM.
Let’s have a look at one final aspect I want to highlight about Shell before we go further: Shell’s support for DI.
Supporting Dependency Injection
In the previous chapter, we discussed DI and briefly touched on the fact that Shell allows pages to be resolved dynamically. This allowed us to define the ViewModel as a dependency of a page, which gets injected through the page’s constructor, as shown here:
public AboutPage(AboutPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
The only caveat here is that the page itself needs to be registered in the `IServiceCollection`, alongside its dependencies of course. With the following in place, we can navigate to `"about"` and Shell will resolve `AboutPage` and its dependencies – in this case, `AboutPageViewModel` – and inject them:
Routing.RegisterRoute("about", typeof(AboutPage));
builder.Services.AddTransient();
builder.Services.AddTransient();
There is even a convenient extension method in the .NET MAUI Community Toolkit that does all of this at once. Take a look:
builder.Services.AddTransientWithShellRoute<AboutPage,
AboutPageViewModel>("about");
The same method also exists for adding the types as scoped or singleton, of course.
In this section, we merely scratched the surface of .NET MAUI Shell, exploring its routing system and support for DI. We saw how it provides a robust, flexible, and intuitive approach to structuring your application and managing navigation.
However, remember that .NET MAUI Shell has a lot more to offer, including advanced features such as flyout customization, search handling, and life cycle events, among others. For deeper insights into these aspects of .NET MAUI Shell, be sure to visit the *Further reading* section at the end of this chapter.
Now that we’ve established a solid understanding of .NET MAUI Shell, it’s time to see how we can leverage it within MVVM. In the next section, we’ll focus on setting up navigation in our *Recipes!* app while adhering to the MVVM pattern. So, let’s dive right in!
Setting up navigation in a .NET MAUI Shell app
Effective navigation within the MVVM pattern begins with an integral component: a `NavigationService`. This service is the driving force behind MVVM navigation. In essence, a `NavigationService` is a class that implements an `INavigationService` interface. The `INavigationService` interface provides the contract for navigating between pages, defining the various methods needed for such operations. These methods could include operations such as `GoToDetailPage()`, `GoBack()`, and others, depending on your specific requirements.
Here’s the beauty of this setup: during the app’s startup, we register a framework-specific implementation of the `INavigationService` interface with the DI container. It’s a perfect illustration of the power of DI, where we program to an interface, not an implementation. This allows our ViewModels to be completely platform-agnostic. This not only promotes code flexibility and testability but also allows us to replace or modify our `NavigationService` implementation without affecting the rest of our app.
Before we dive deeper into the setup of an `INavigationService` and explore how it operates within our *Recipes!* app, notice that the UI of the app has changed a little bit. The main UI now shows two tabs: `RecipesOverviewPage`, and `SettingsPage`. Let’s have a look at implementing a `NavigationService` interface that leverages .NET MAUI Shell.
Creating an INavigationService interface
It all starts with this interface, which will get injected into the ViewModels that want to navigate. And this is where developers tend to have different opinions. Some developers prefer to have a very slimmed-down interface only containing methods such as `GoTo(string name)`, `GoTo(string name, IDictionary<string, object> parameters)`, and `GoBack()`. This allows for a very generic interface and implementation that can easily be reused. I prefer a more per-app approach where I have methods such as `GoToOverview()`, `GoToDetail(string id)`, and others. The big advantage I find in this approach is the fact that when I want to navigate to a certain page, I know exactly what parameters are required to navigate to that page. I also find it easier to unit test and it makes it easier to implement app-specific edge cases. I’ve also seen and used generic implementations over the years, containing methods such as `GoTo<TViewModel>()` for example. It pretty much comes down to personal preference, use case requirements, and the specific needs of the project. I’ll be demonstrating the approach I typically use and have used successfully over the years. Once you understand the main concept of a `NavigationService`, please use whatever approach you prefer! Let’s create the `INavigationService` interface for our *Recipes!* app:
1. In the `Recipes.Client.Core` project, add a new folder by right-clicking the project in the `Navigation`.
2. Next, right-click the newly created folder and select `INavigationService` as the name for the new interface.
3. As the *Recipes!* app doesn’t have a lot of navigation going on, we can keep the interface pretty simple, as shown here:
```
public interface INavigationService
{
Task GoToRecipeDetail(string recipeId);
Task GoToRecipeRatingDetail(RecipeDetailDto
recipe);
Task GoBack();
}
```cs
The interface currently holds three methods. The `GoToRecipeDetail` method should navigate to the detail page. It accepts a string parameter representing the ID of the recipe we want to load on that page. The `GoToRecipeRatingDetail` method should load the ratings overview page of the given `RecipeDetailDto` object. Finally, there is the `GoBack` method, which should allow us to programmatically navigate back into the app.
Note
This `INavigationService` interface holds no reference to .NET MAUI or Shell. It’s just a contract for triggering navigations. The fact the interface is part of the `Recipes.Client.Core` project already gives away that it is framework agnostic. So, whether you want to leverage Shell or not, this interface probably won’t change.
Now that we have this interface in place, let’s see how we can implement a `NavigationService` interface that leverages Shell.
Creating and using a NavigationService
Because the implementation of `NavigationService` is specific to a framework, we are going to add it to the `Recipes.Mobile` project:
1. Let’s add a `Navigation` folder to the **Recipes.Mobile** project by right-clicking the project in the **Solution Explorer** and selecting **Add** | **New Folder**.
2. Now, right-click the `NavigationService`.
3. This class needs to implement `INavigationService`, as shown here:
```
public class NavigationService : INavigationService
{
...
}
```cs
4. Now, this is where Shell comes in! Earlier in this chapter, we saw how easy it is to use Shell for navigation: just call `Shell.Current.GoToAsync` and pass in the name of the page you want to navigate to. Let’s add the following method, which wraps around this `GoToAsync` method, to our new `NavigationService` class:
```
private async Task Navigate(string pageName,
Dictionary<string, object> parameters)
{
await Shell.Current.GoToAsync(pageName);
}
```cs
This `Navigate` method just calls the `GoToAsync` method of `Shell`, passing in the given `pageName` parameter. We’ll look at the `parameters` parameter later.
5. What remains for `NavigationService` is implementing the `INavigationService` interface’s methods, which is now pretty easy to do, as shown here:
```
public Task GoToRecipeDetail(string recipeId)
=> Navigate("RecipeDetail",
new () { { "id", recipeId } });
public Task GoToRecipeRatingDetail(RecipeDetailDto
recipe)
=> Navigate("RecipeRating",
new () { { "recipe", recipe } });
public Task GoBack()
=> Shell.Current.GoToAsync("..");
```cs
The first two methods call the `Navigate` method we created earlier, passing in the name of the page that needs to be loaded, as well as a dictionary containing the `recipeId` parameter. The `GoBack` method calls the `GoToAsync` method of `Shell`, passing in "..", signaling we want to navigate up the navigation stack.
6. Next, we can go ahead and register this `NavigationService` in the DI container. Open `MauiProgram.cs` and add the following:
```
builder.Services.AddSingleton<INavigationService,
NavigationService>();
```cs
And with that in place, it’s time to update our ViewModels and add `INavigationService` as a dependency:
1. Head over to `RecipesOverviewViewModel` and update its constructor so that it accepts an `INavigationService`. As before, we should also create a `readonly` field to hold the injected value:
```
public class RecipesOverviewViewModel :
ObservableObject
{
private readonly INavigationService
navigationService;
...
public RecipesOverviewViewModel(
IRecipeService recipeService,
IFavoritesService favoritesService,
INavigationService navigationService)
{
this.navigationService = navigationService;
...
}
}
```cs
2. Further down this class, we can now update the `NavigateToSelectedDetail` method to the following:
```
private async Task NavigateToSelectedDetail()
{
if (SelectedRecipe is not null)
{
await navigationService.GoToRecipeDetail
(SelectedRecipe.Id);
SelectedRecipe = null;
}
}
```cs
3. In `RecipesOverviewPage.xaml`, the following can be removed:
```
SelectionChanged="CollectionView_SelectionChanged"
```cs
In the `RecipesOverviewPage.xaml.cs` file, the `CollectionView_SelectionChanged` method can be removed as well. Up until now, this is what triggered the navigation from the overview page to the detail page.
Now, we need to give `RecipeDetailViewModel` the same treatment: inject `INavigationService` and use it to execute navigation to the `RecipeRatingsDetailPage`:
1. As before, add an additional parameter to the class’s constructor and keep a reference to it in a `readonly` field:
```
public partial class RecipeDetailViewModel :
ObservableObject
{
private readonly INavigationService
navigationService;
...
public RecipeDetailViewModel(
IRecipeService recipeService,
IFavoritesService favoritesService,
IRatingsService ratingsService,
INavigationService navigationService)
{
this.navigationService = navigationService;
...
}
...
}
```cs
2. The following snippet shows how we can update the `NavigateToRatings` method:
```
private Task NavigateToRatings()
=> navigationService
.GoToRecipeRatingDetail(recipeDto);
}
```cs
This method gets called when `NavigateToRatingsCommand` is invoked.
3. Finally, we need to head over to the `RecipeDetailPage.xaml` file and update the `TapGestureRecognizer` on the `HorizontalStackLayout`, which shows the rating, to the following:
```
<HorizontalStackLayout.GestureRecognizers>
<TapGestureRecognizer Command="{Binding
NavigateToRatingsCommand}" />
</HorizontalStackLayout.GestureRecognizers>
```cs
Tapping this control will now trigger `NavigateToRatingsCommand`, which we just created, which will call the `NavigationService` to initiate navigation to the `RatingsDetailPage`.
4. The `Ratings_Tapped` method in the code-behind of `RecipeDetailPage` can be deleted as it is no longer of any use.
The pages, their ViewModels, and their routes, are already registered in `MauiProgram`’s `CreateMauiApp` method, as shown here:
...
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient
();
builder.Services.AddTransient();
builder.Services.AddTransient();
Routing.RegisterRoute("MainPage",
typeof (RecipesOverviewPage));
路由注册("RecipeDetail",
typeof (RecipeDetailPage));
Routing.RegisterRoute("RecipeRating",
typeof (RecipeRatingsDetailPage));
With all of these changes in place, we can now effectively navigate from one page to another. The injected instance of `NavigationService` in the ViewModels leverages Shell to navigate between pages. But there is still one thing missing: passing parameters from one page to another. Let’s see how to add this!
Passing parameters
As mentioned earlier, although .NET MAUI Shell has a baked-in way of passing parameters, I’m not fond of using that in my ViewModels as it would require my ViewModels to depend on MAUI and Shell. Luckily, a solution to that is not that complex. Moreover, it fits nicely in the broader setup of our `NavigationService`, as we will discuss later in this chapter.
Let’s introduce a new interface: `INavigationParameterReceiver`. This interface exposes one method called `OnNavigatedTo` that receives a dictionary of type `Dictionary<string, object>` as a single parameter. This interface can be implemented by ViewModels that want to accept navigation parameters. When navigated to a page, the `NavigationService` can check whether the `BindingContext` of the new page implements this interface and then call the `OnNavigatedTo` method, passing in the parameters. Let’s see how we can implement this:
1. First, let’s add the `INavigationParameterReceiver` interface. Right-click the `Navigation` folder in the `Recipes.Client.Core` project and select `INavigationParameterReceiver` as the name of the interface.
2. As we mentioned previously, this interface should expose the `OnNavigatedTo` method. Let’s add this:
```
public interface INavigationParameterReceiver
{
Task OnNavigatedTo(Dictionary<string, object>
parameters);
}
```cs
3. Head over to the `Navigate` method of the `NavigationService` class and update it to the following:
```
private async Task Navigate(string pageName,
Dictionary<string, object> parameters)
{
await Shell.Current.GoToAsync(pageName);
if (Shell.Current.CurrentPage.BindingContext
is INavigationParameterReceiver receiver)
{
await receiver.OnNavigatedTo(parameters);
}
}
```cs
The preceding code will pass the provided parameters to the ViewModel once Shell has navigated to the new page. We can retrieve the current page by calling `Shell.Current.CurrentPage`. Once we have the current page, we can check whether the page’s `BindingContext` implements the `INavigationParameterReceiver` interface. If it does, we can call the `OnNavigatedTo` method to pass the provided parameters.
4. `RecipeDetailViewModel` can now implement the `INavigationParameterReceiver` interface, as shown here:
```
public partial class RecipeDetailViewModel :
ObservableObject, INavigationParameterReceiver
{
...
public Task OnNavigatedTo(
Dictionary<string, object> parameters)
=> LoadRecipe(parameters["id"].ToString());
当调用 OnNavigatedTo 方法时,从字典中检索 id 参数并将其传递给 LoadRecipe 方法。
```cs
5. Up until now, the constructor of `RecipeDetailViewModel` called the `LoadRecipe` method with a hard-coded ID. This call can now be removed.
6. `RecipeRatingsDetailViewModel` also needs to be updated for it to be able to receive parameters. The following code block shows how it can implement `INavigationParameterReceiver`:
```
public class RecipeRatingsDetailViewModel :
ObservableObject, INavigationParameterReceiver
{
...
public Task OnNavigatedTo(
Dictionary<string, object> parameters)
=> LoadData(parameters["recipe"]
as RecipeDetailDto);
}
```cs
Please note that there has been a slight change in the `LoadData` method signature since the previous chapter. Rather than accepting a string, it now takes a `RecipeDetailDto` object as its parameter. This is an optimization strategy that aims to avoid over-fetching. When we navigate from `RecipeDetailPage`, we have already loaded the details of a recipe. Therefore, it’s not needed to reload these same details when we move to `RecipeRatingDetailPage`. By passing in a `RecipeDetailDto`, we effectively utilize the data we’ve already fetched.
With `INavigationParameterReceiver` in place, we can effectively pass navigation parameters from one page to another. Let’s see how we can extend our navigation infrastructure even more to allow us to hook into and manage important parts of the ViewModel’s life cycle: specifically, the moments of navigation to and from a ViewModel.
Avoid “magic strings”
The code samples in this chapter use a lot of “magic strings": specific routes are registered for pages, parameters are passed using exact keys, and navigation requires correct input of these routes and keys. While this makes the code samples simple and clear, it’s risky in practice. A single spelling error can lead to runtime errors that aren’t immediately apparent. To prevent such issues, it’s advisable to use string constants for route names and parameters stored in designated classes. We didn’t follow this best practice here for the sake of simplicity, but keep it in mind when you’re writing your code.
Hooking into navigation
Often, we want to hook into the navigation process to effectively manage ViewModel states during transitions. This allows us to handle setup and cleanup operations, as well as manage state changes when a ViewModel becomes active or inactive in the application. This strategy is particularly useful for tasks such as subscribing to or unsubscribing from services, loading or saving states, and initiating or canceling network requests.
For that purpose, let’s introduce two new interfaces: `INavigatedFrom` and `INavigatedTo`. Just like `INavigationParameterReceiver`, ViewModels can choose to implement these interfaces if they want to react to certain navigation events. Let’s see what they look like:
public interface INavigatedFrom
{
Task OnNavigatedFrom(NavigationType navigationType);
}
public interface INavigatedTo
{
Task OnNavigatedTo(NavigationType navigationType);
}
Both the `OnNavigatedFrom` and `OnNavigatedTo` methods accept one parameter of type `NavigationType`, which is an enum. This enum has the following implementation:
public enum NavigationType
{
Unknown,
Forward,
Back,
SectionChange,
}
With this `enum` type, we want to give context to the type of navigation that occurred. The `SectionChanged` value can be used when the user opens another tab for example or selects another item from `FlyoutMenu`. As you might expect, the `Forward` and `Back` values are used when navigating hierarchically from one page to another.
These methods and values of the `NavigationType` enum enable nuanced reactions to a wide variety of navigation scenarios. Let’s consider some examples:
* When the `OnNavigatedFrom` method of a ViewModel is called with `Back` as the parameter, we can infer that we’re navigating backward away from this ViewModel. In this context, you should consider stopping any ongoing tasks or network requests related to that ViewModel. The page no longer exists on the `NavigationStack`, so unsubscribing from messages or events may be wise, allowing any unneeded resources to be reclaimed via garbage collection.
* If the `OnNavigatedFrom` method is invoked with `Forward` as the parameter, we know the page and its ViewModel remain on the `NavigationStack`. Therefore, the user can easily navigate back to this page. In this situation, we may also want to clean up specific processes or running tasks, but it’s important to ensure they can be quickly reinstated. When the user navigates back to the ViewModel, the `OnNavigatedTo` method will be triggered with `Back` as the parameter, signaling a need to restart previously paused processes.
Let’s see how we can add these additional interfaces to our ViewModels and hook up the necessary code to call the methods these interfaces expose:
1. Add the interfaces (`INavigatedFrom` and `INavigatedTo`) and the `NavigationType` enum, as shown in the earlier code blocks, to the `Navigation` folder of the `Recipes.Client.Core` project.
2. When inheriting from the `AppShell` class, we can override the `OnNavigated` method. This method is invoked when a navigation is executed by the Shell framework. This is the ideal place to call into the methods of the interfaces that we’ve introduced. Go ahead to the `AppShell` class and override this method, as shown here:
```
protected override async void OnNavigated
(ShellNavigatedEventArgs args)
{
var navigationType =
GetNavigationType(args.Source);
base.OnNavigated(args);
}
```cs
The `ShellNavigatedEventArgs` parameter that’s passed into this method has a `Source` property. This `Source` property is of type `ShellNavigationSource` and indicates how the navigation occurred: `Push`, `Pop`, `PopToRoot`, and so on. We want to translate this to the `NavigationType` enum we introduced earlier, allowing it to be framework-independent from here on.
3. To translate `ShellNavigationSource` to `NavigationType`, create the following `GetNavigationType` method in the `AppShell` class:
```
private NavigationType GetNavigationType
(ShellNavigationSource source) =>
source switch
{
ShellNavigationSource.Push or
ShellNavigationSource.Insert
=> NavigationType.Forward,
ShellNavigationSource.Pop or
ShellNavigationSource.PopToRoot 或
ShellNavigationSource.Remove
=> NavigationType.Back,
ShellNavigationSource.ShellItemChanged 或
ShellNavigationSource.ShellSectionChanged or
ShellNavigationSource.ShellContentChanged
=> NavigationType.SectionChange,
_ => NavigationType.Unknown
};
```cs
4. Now, we need to find a way to access the ViewModels of the current and previous page, from within the overridden `OnNavigated` method. Once we get a hold of them, we can call `OnNavigatedFrom` and `OnNavigatedTo` when the ViewModel implements the corresponding interfaces. For that purpose, let’s introduce a new interface: `INavigationInterceptor`. Here’s what it looks like:
```
public interface INavigationInterceptor
{
Task OnNavigatedTo(object bindingContext,
NavigationType navigationType);
}
```cs
For now, this interface only exposes one method: `OnNavigatedTo`. It accepts a parameter of the `object` type that represents the `BindingContext` of the current page. It also accepts a parameter of type `NavigationType`.
5. Add this interface as a constructor parameter to `AppShell` and call its `OnNavigatedTo` method from the `OnNavigated` method of Shell, which we’ve just overridden. The following code block shows how to add this:
```
public partial class AppShell : Shell
{
readonly INavigationInterceptor interceptor;
public AppShell(INavigationInterceptor
interceptor)
{
this.interceptor = interceptor;
InitializeComponent();
}
protected override async void OnNavigated
(ShellNavigatedEventArgs args)
{
var navigationType =
GetNavigationType(args.Source);
base.OnNavigated(args);
await interceptor.OnNavigatedTo(
CurrentPage?.BindingContext,
navigationType);
}
...
}
```cs
6. Because the `AppShell` class now lacks its default constructor and requires a parameter of type `INavigationInterceptor`, we need to update our code in the `App.xaml.cs` file, as shown here:
```
public App(INavigationInterceptor interceptor)
{
...
MainPage = new AppShell(interceptor);
}
```cs
The constructor of the `App` class has been updated so that it accepts a parameter of the `INavigationInterceptor` type.
7. What remains is implementing `INavigationInterceptor` and registering it in the DI container. This interface can be implemented by our existing `NavigationService`. Let’s see how:
```
Public class NavigationService : INavigationService,
INavigationInterceptor
{
...
WeakReference<INavigatedFrom> previousFrom;
public async Task OnNavigatedTo(object
bindingContext, NavigationType navigationType)
{
if(previousFrom is not null && previousFrom
.TryGetTarget(out INavigatedFrom from))
{
await from.OnNavigatedFrom
(navigationType);
}
if (bindingContext
is INavigatedTo to)
{
await to.OnNavigatedTo(navigationType);
}
if(bindingContext is INavigatedFrom
navigatedFrom)
previousFrom = new (navigatedFrom);
else
previousFrom = null;
}
```cs
A lot is going on here, so let’s discuss what happens. Remember that the `OnNavigatedTo` method is called when we have already navigated. So, we must keep a reference to the previous page’s `BindingContext` if we want to call a method on that later on. This reference is kept as `WeakReference` because we don’t want this reference to cause the object not to be garbage collected and causing memory leaks. First, we check whether the `previousFrom` field is not null and whether it still holds a reference to a value that implements the `INavigatedFrom` interface. If we get back a value, the `OnNavigatedFrom` method is called on the `BindingContext` of the page we’ve navigated from.
Next, we check whether the passed-in `bindingContext` parameter implements the `INavigatedTo` interface. If that’s the case, the `OnNavigatedTo` method is called.
In the end, we check whether the given `bindingContext` implements the `INavigatedFrom` interface. If so, we store it in the `previousFrom` field. If not, the `previousFrom` field is assigned null.
8. It’s important to notice that we’ve introduced state to our `NavigationService` by keeping track of the `BindingContext` of the previous page through the `previousFrom` field. As a result, `NavigationService` should be registered as Singleton so that throughout the app, the same instance of `NavigationService` is being used. Moreover, the `NavigationService` should be resolvable as `INavigationService` in the ViewModels and as `INavigationInterceptor` for instantiating the `AppShell` class. To accommodate this, we can update the registration, as follows:
```
builder.Services.AddSingleton<NavigationService>();
builder.Services.AddSingleton<INavigationService>(
c => c.GetRequiredService<NavigationService>());
builder.Services.AddSingleton<INavigationInterceptor>(
c => c.GetRequiredService<NavigationService>());
```cs
`NavigationService` itself is registered as a singleton. We also added singleton registrations for `INavigationService` and `INavigationIntercepter`, both returning the registration of `NavigationService`. This allows us to register one type for multiple interfaces, all pointing to the same instance.
Go ahead and implement the `INavigatedTo` and `INavigatedFrom` interfaces in some ViewModels. Add a breakpoint to the implemented methods, run the app, and see what happens by inspecting the parameter values. For our little *Recipes!* app, there is no need to add an implementation to said methods, but as managing ViewModel states during transitions is something developers tend to struggle with in larger apps, I wanted to share how I handle these kinds of scenarios.
Setting up navigation without .NET MAUI Shell
As I mentioned earlier, Shell is an opinionated way to create the structure of a .NET MAUI app. This might not work for you or your particular project. Not using Shell complicates the implementation of a `NavigationService` a lot, especially when your app has a complex structure such as tabs or a flyout menu. Let’s focus on a simple hierarchical navigation and see what is needed to implement a `NavigationService` without relying on another framework.
Luckily, the interfaces we created earlier (`INavigationService`, `INavigatedTo`, `INavigatedFrom`, and `INavigationParameterReceiver`) are framework-independent and can still be used as the backbone of this implementation:
1. No Shell means no routing. However, I do like the concept of having keys associated with a particular view as it allows for a loosely coupled way of navigating. That’s why we’re creating a static `Routes` class in the `Navigation` folder of the `Recipes.Mobile` project, as shown here:
```
public static class Routes
{
static Dictionary<string, Type> routes
= new Dictionary<string, Type>();
public static void Register<T>(string key)
where T : Page
=> routes.Add(key, typeof(T));
public static Type GetType(string key)
=> routes[key];
}
```cs
This class allows us to map keys to types that inherit from `Page`.
2. Next, instead of using the static `Routing.RegisterRoute` method to register routes in the `MauiProgram` class, we can now use our own `Routes` class, like this:
```
Routes.Register<RecipesOverviewPage>("MainPage");
Routes.Register<RecipeDetailPage>("RecipeDetail");
Routes.Register<RecipeRatingsDetailPage>
("RecipeRating");
```cs
The `Routes.GetType` method will allow us to retrieve a key’s `Type` later.
3. Before diving into the implementation of the non-Shell `NavigationService`, let’s add the following code to the `INavigationService` interface:
```
Task GoToOverview();
```cs
4. In `App.xaml.cs`, update the `App`’s constructor as shown here:
```
public App(INavigationService navigationService)
{
Application.Current.UserAppTheme = AppTheme.Light;
InitializeComponent();
MainPage = new NavigationPage();
navigationService.GoToOverview();
}
```cs
With the updated code, a class implementing the `INavigationService` interface will be injected. After assigning a new `NavigationPage` to the `MainPage` property, we can call the newly added `GoToOverview` method on the injected `INavigationService` for it to navigate to the `OverviewPage`.
5. Now, we can go and start implementing the non-Shell `NavigationService`. Create a new class called `NonShellNavigationService` in the `Navigation` folder of the `Recipes.Mobile` project. As you might expect, this class needs to implement the `INavigationService` interface, as shown here:
```
public class NonShellNavigationService :
INavigationService
```cs
6. The first thing we want to add is the `Navigation` property, which is of type `Microsoft.Maui.Controls.INavigation`. Through this property, we want to effectively route our navigation. The `INavigation` interface defines navigation-related methods and properties. Let’s see what that property looks like:
```
protected INavigation Navigation
{
get
{
INavigation? navigation =
Application.Current?.MainPage?.Navigation;
if (navigation is not null)
return navigation;
else
{
throw new Exception();
}
}
}
```cs
Through the static `Current` property of the `Application` class, we can get to the instance of the application, allowing us to access its `MainPage` property. The `MainPage` property, which is of type `Page`, has a `Navigation` property of type `INavigation`, which is exactly what we need.
7. Like on `NavigationService`, which used `Shell`, we also want to add a private `Navigate` method that other methods in this class can use. Here’s what this looks like:
```
private async Task Navigate(string key,
Dictionary<string, object> parameters)
{
var type = Routes.GetType(key);
var page = ServiceProvider.Current
.GetService(type) as Page;
page.NavigatedFrom += Page_NavigatedFrom;
await Navigation.PushAsync(page);
if (page.BindingContext
is INavigationParameterReceiver receiver)
{
await receiver.OnNavigatedTo(parameters);
}
if (Navigation.NavigationStack.Count == 1)
{
if (page.BindingContext
is INavigatedTo to)
await to.OnNavigatedTo(NavigationType
.SectionChange);
}
}
```cs
With the given key, we can resolve the type we want to navigate to. Using the `ServiceProvider`, we can retrieve an instance of the given type, satisfying all of its dependencies. Next, an event handler for the resolved page’s `NavigatedFrom` event is added before we access our `Navigation` property and push this page onto the navigation stack with the `PushAsync` method. This is what executes the effective navigation to the requested page. After the page is pushed, its `BindingContext` is checked to see whether it implements the `INavigationParameterReceiver` interface. If that’s the case, its `OnNavigatedTo` method is called, passing in the navigation parameters. The final thing that happens in this method is that it checks whether the size of `NavigationStack` is `1`. This means that we navigated to a page and that there’s only one item on the stack, or in other words, this is the first page we’re navigating to. If that is the case, we want to call the `OnNavigatedTo` method on the page’s `BindingContext` if said `BindingContext` implements the `INavigatedTo` interface and pass in `NavigationType.SectionChange`. With this in place, the `OnNavigatedTo` method is called on initial navigation.
8. In the previous method, we added a handler to the page’s `NavigatedFrom` event. The following code block shows its implementation:
```
private async void Page_NavigatedFrom(object sender,
NavigatedFromEventArgs e)
{
bool isForwardNavigation =
Navigation.NavigationStack.Count > 1
&& Navigation.NavigationStack[²] == sender;
if (sender is Page page)
{
if (!isForwardNavigation)
{
page.NavigatedFrom -= Page_NavigatedFrom;
}
await OnNavigatedTo(Navigation.NavigationStack
.Last().BindingContext,
isForwardNavigation ? NavigationType
.Forward : NavigationType.Back);
}
}
```cs
As we are focusing on hierarchical navigation, navigation from a page can happen for two reasons: we’re navigating forward to another page or we’re navigating back to the previous page. This is what is determined at the beginning of this method. As this event is handled after the navigation occurred, we can determine forward navigation by looking at the second-to-last item on the `NavigationStack`: if that entry equals the sender, it means we navigated forward from the sender to another page. If it wasn’t forward navigation, meaning we’ve navigated back from the page to the previous page, we need to remove the event handler from the page’s `NavigateFrom` event. We need to do this so that the page has no references and can be garbage collected, avoiding potential memory leaks. Finally, we call the `OnNavigated` method, passing in the `BindingContext` of the current page (which is the last item in `NavigationStack`). Depending on whether it is forward navigation or not, we pass in `NavigationType.Forward` or `NavigationType.Backward`.
9. The `OnNavigatedTo` method that is being called in the previous code block might look familiar. That’s because it is completely identical to the `OnNavigatedTo` method we had in our previous implementation of the `NavigationService`:
```
WeakReference<INavigatedFrom> previousFrom;
private async Task OnNavigatedTo(object
bindingContext,
NavigationType navigationType)
{
if (previousFrom is not null && previousFrom
.TryGetTarget(out INavigatedFrom from))
{
await from.OnNavigatedFrom(navigationType);
}
if (bindingContext
is INavigatedTo to)
{
await to.OnNavigatedTo(navigationType);
}
if (bindingContext is INavigatedFrom
navigatedFrom)
previousFrom = new(navigatedFrom);
else
previousFrom = null;
}
```cs
10. Finally, let’s have a look at the implemented methods of the `INavigationService` interface:
```
public Task GoBack()
=> Navigation.PopAsync();
public Task GoToRecipeDetail(string recipeId)
=> Navigate("RecipeDetail",
new() { { "id", recipeId } });
public Task GoToRecipeRatingDetail(RecipeDetailDto
recipe)
=> Navigate("RecipeRating",
new() { { "recipe", recipe } });
public Task GoToOverview()
=> Navigate("Overview", null);
```cs
They also look very much like they did in the previous implementation because the `Navigate` method accepts the same parameters as in the previous sample.
11. The only thing that’s left to do is register this `NonShellNavigationService`:
```
builder.Services.AddSingleton<INavigationService,
NonShellNavigationService>();
```cs
In this setup, we’re not using `INavigationInterceptor`, so there’s no need to register that.
With that in place, we’ve successfully created a simple `NavigationService` that does not leverage .NET MAUI Shell. Many of the core concepts were reused in this example, demonstrating that they are a good level of abstraction. That said, this implementation is very simple and naïve. It lacks the support for modal navigation and navigation inside tabs and doesn’t have anything for handling a flyout menu. This example might give you some ideas and insights, but building a `NavigationService` from scratch, without leveraging Shell, is quite daunting. In many cases, when Shell is not an option for you or your specific project, I think relying on other third-party frameworks might be the way to go. Libraries such as *FreshMvvm* and especially *Prism Library* are worth checking out!
Before concluding this chapter, it’s important to address a potentially unclear aspect: how can we effectively return a result from a child page to its parent?
Passing results back
In this chapter, we’ve explored passing parameters from one page to another during forward navigation. But what if we need to take an object, use it as a parameter to navigate to another page, manipulate it there, and then retrieve the updated result?
There are various approaches to achieve this, but the most straightforward method is to add a little extension to our navigation framework and allow parameters to be passed when navigating back. For example, on the `SettingsPage` of the *Recipes!* app, we show the user’s current language. There’s a button that navigates to the `PickLanguagePage`, where the user can select a different language. The current language needs to be passed from the `SettingsPage` to the `PickLanguage` page so that the latter can show the current value. When the user selects a new language, the `PickLanguagePage` should navigate back to the `SettingsPage` and pass the selected language as a parameter. *Figure 8**.2* shows how this looks schematically:

Figure 8.2: Passing values back
Let’s explore how to implement this scenario:
1. First, add the `GoBackAndReturn` method to `INavigationService`:
```
Task GoBackAndReturn(Dictionary<string, object>
parameters);
```cs
By introducing this method, we want to allow a ViewModel to trigger back navigation and pass parameters to the ViewModel of the previous page.
2. This method is very easy to implement in both `NavigationService` and `NonShellNavigationService`. First, let’s take a look at the implementation in `NavigationService`:
```
public async Task GoBackAndReturn(
Dictionary<string, object> parameters)
{
await GoBack();
if (Shell.Current.CurrentPage.BindingContext
is INavigationParameterReceiver receiver)
{
await receiver.OnNavigatedTo(parameters);
}
}
```cs
In this method, we first call the `GoBack` method. Once the back navigation is executed, we check whether `BindingContext` of the current page implements the `INavigationParameterReceiver` interface. If that’s the case, we call its `OnNavigatedTo` method, passing in the parameters.
3. On `NonShellNavigationService`, this method looks very similar:
```
public async Task GoBackAndReturn(
Dictionary<string, object> parameters)
{
await GoBack();
if(Navigation.NavigationStack.Last()
.BindingContext
is INavigationParameterReceiver receiver)
{
await receiver.OnNavigatedTo(parameters);
}
}
```cs
We are doing the same thing here as in the `NavigationService`, except we’re not using the Shell APIs to retrieve the current page. Instead, we’re getting the current page from `NavigationStack`.
4. Next, let’s add the method that should navigate to `PickLanguagePage`. Add the `GoToChooseLanguage` method to the `INavigationService` interface:
```
Task GoToChooseLanguage(string currentLanguage);
```cs
5. In both `ShellNavigationService` and `NonShellNavigationService`, implement the `GoToChooseLanguage` method, like this:
```
public Task GoToChooseLanguage(string currentLanguage)
=> Navigate("PickLanguagePage",
new() { { "language", currentLanguage } });
```cs
The registration of `PickLanguagePage`, its route, and its ViewModel is already done in the `CreateMauiApp` method of the `MauiProgram` class, as shown here:
```
...
builder.Services.AddTransient<PickLanguagePage>();
builder.Services.AddTransient<PickLanguageViewModel>
();
...
Routing.RegisterRoute("PickLanguagePage",
typeof (PickLanguagePage));
...
//非 Shell
//Routes.Register<PickLanguagePage>
//("PickLanguagePage");
//非 Shell
...
```cs
6. Update `PickLanguageViewModel` as it needs to implement the `INavigationParameterReceiver` interface and needs to get a dependency on the `INavigationService` interface. Here’s how it looks:
```
public class PickLanguageViewModel : ObservableObject,
INavigationParameterReceiver
{
readonly INavigationService _navigationService;
...
public PickLanguageViewModel(I
NavigationService navigationService)
{
_navigationService = navigationService;
}
public async Task OnNavigatedTo(
Dictionary<string, object> parameters)
{
_selectedLanguage =
parameters["language"] as string;
OnPropertyChanged(nameof(SelectedLanguage));
}
}
```cs
Note that in the `OnNavigatedTo` method, we’re assigning the _`selectedLanguage` field rather than the `SelectedLanguage` property. This is intentional because updating the property will immediately invoke the `LanguagePicked` method. We don’t want to trigger this when we set the initial value of this property. Because of that, we need to call `OnPropertyChanged` manually, passing the name of the `SelectedLanguage` property.
7. The `LanguagePicked` method is called when the user selects a new language from the dropdown. This should be where we utilize our new `GoBackAndReturn` method to navigate back and return the selected language. Let’s take a look:
```
private Task LanguagePicked()
{
return _navigationService.GoBackAndReturn(
new Dictionary<string, object> {
{ "SelectedLanguage", SelectedLanguage }
});
}
```cs
8. Go to `SettingsViewModel` and make it implement the `INavigationParameterReceiver` interface:
```
public class SettingsViewModel :
ObservableObject, INavigationParameterReceiver
```cs
Here’s what the implemented `OnNavigatedTo` method looks like:
```
public Task OnNavigatedTo(
Dictionary<string, object> parameters)
{
if(parameters is not null &&
parameters.ContainsKey("SelectedLanguage"))
{
CurrentLanguage =
parameters["SelectedLanguage"] as string;
}
return Task.CompletedTask;
}
```cs
This `OnNavigatedTo` method will be called both when navigating “forward” to this ViewModel as well as when navigating “back” to it. The `SelectedLanguage` parameter that’s sent by `PickLanguageViewModel` can be picked up here.
9. `SettingsPageViewModel` also needs to get the `INavigationService` interface injected. Here’s how:
```
Readonly INavigationService _navigationService;
...
public SettingsViewModel(INavigationService service)
{
_navigationService = service;
...
}
```cs
10. Finally, the `ChooseLanguage` method, which gets triggered when the user taps `PickLanguagePage`, as shown here:
```
private async Task ChooseLanguage()
{
await _navigationService
.GoToChooseLanguage(CurrentLanguage);
}
```cs
With this update, moving data between pages is easier and more flexible. Our app now offers smoother user experiences, all thanks to our navigation framework.
Summary
The fundamental picture of navigation is quite straightforward: a navigation service, which is injected into ViewModels, is utilized to handle navigation. ViewModels can implement specific interfaces, enabling them to receive parameters or be notified about navigation activities, be it from or to them.
While the overall idea seems simple, the implementation can be complex, and this is where developers often become puzzled. Fortunately, .NET MAUI Shell streamlines the navigation process in complex UIs, providing a level of ease in the implementation. But as with anything, Shell’s opinionated nature may not suit every application or developer’s preferences. Therefore, we didn’t stop at exploring Shell navigation but also dove into building a navigation service that is not reliant on Shell.
Toward the end of this chapter, we looked a bit deeper into passing parameters and results between pages. We demonstrated that by efficiently combining navigation services and ViewModel coordination, we can create a seamless user experience.
Navigating through the complexities of .NET MAUI navigation can be a challenge, but with a good understanding of the underlying principles and implementation details, we’re better equipped to handle it. As we move forward, we’ll explore handling user input and validation, diving into how to make our applications more interactive.
Further reading
To learn more about the topics that were covered in this chapter, take a look at the following resources:
* *.NET MAUI* *Shell*: [`learn.microsoft.com/dotnet/maui/fundamentals/shell/`](https://learn.microsoft.com/dotnet/maui/fundamentals/shell/)
* *Prism* *Library*: [`prismlibrary.com/`](https://prismlibrary.com/)
* *FreshMvvm*: [`github.com/XAM-Consulting/FreshMvvm.Maui`](https://github.com/XAM-Consulting/FreshMvvm.Maui)
第九章:处理用户输入和验证
用户输入是任何交互式应用程序的核心。我们管理、验证和响应用户输入的方式直接影响了我们应用程序的用户体验。虽然后端验证用户输入对于维护数据完整性是必不可少的,但在前端提供即时且有用的反馈对于良好的用户体验同样重要。在本章中,我们将深入探讨在.NET MAUI 应用程序中利用 MVVM 设计模式管理用户输入和验证的关键主题。
本章分为以下几部分:
在 ViewModel 中实现输入验证
使用触发器可视化验证错误
提示和警报
确认或取消导航
为了使我们的应用程序更加动态和交互式,本章将专注于有效地处理用户输入——确保流畅且无缝的用户体验。让我们开始吧!
技术要求
在本章中,我们将进一步丰富 Recipes! 应用程序的功能。为了保持同步,所有资源和代码片段都可在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter09 。如果您想与本章一起积极编码,最好从 Start 文件夹开始。最终版本可在 Finish 文件夹中找到。
在 ViewModel 中实现输入验证
输入验证可以在用户交互生命周期的不同阶段以各种方式实现。它可能发生在用户更改属性时,立即提供关于输入数据有效性的反馈。另一方面,它可能仅在用户启动某些操作时执行,例如点击按钮,从而提供更累积的验证体验。决定何时以及如何实现验证通常取决于您项目的具体要求。这是在即时反馈和保持流畅、不间断的用户流程之间的平衡。没有一种适合所有情况的解决方案,策略可以根据数据的复杂性、用户交互的形式以及您应用程序的整体设计而有所不同。
在前端实现验证具有几个优点:它为用户提供即时反馈,确保更响应和直观的体验。此外,通过在源头捕捉和纠正问题,我们可以降低将错误数据发送到后端的风险。然而,需要注意的是,前端验证应补充而非取代后端验证。ViewModel 是前端验证的理想位置,因为它作为我们防止潜在数据不一致或错误在到达后端系统之前的第一道防线。
在本节中,我们将专注于 ViewModel 中输入验证的实现。我们将特别深入探讨ObservableValidator的使用,它是 MVVM 社区工具包的一部分,是一个强大的工具。这个特性使得处理验证响应并向用户显示有意义的反馈变得轻而易举。让我们继续详细探索ObservableValidator,以及它是如何增强我们对输入验证方法的。
使用ObservableValidator
验证用户输入是构建健壮应用程序的基础。虽然我们可以完全编写自己的验证逻辑——在属性更新或命令调用时检查属性,然后填充可以绑定到 UI 的验证属性——但这通常涉及重新发明常见的模式。为什么不利用为这一特定目的设计的现有工具呢?在 MVVM 模式中,用户输入验证的最佳实践之一是使用ObservableValidator。这个类是 MVVM 社区工具包的一部分,它继承自ObservableObject类并实现了System.ComponentModel.INotifyDataErrorInfo接口。这个接口并不特定于.NET MAUI,它提供了一个强大的系统来报告和管理对象中的错误。它包括一个ErrorsChanged事件,你可以订阅它以接收验证状态变化的通知,以及一个GetErrors方法,用于检索指定属性或整个对象的验证错误。
当属性值更新时,ObservableValidator能够使用其SetProperty方法的重载自动验证它。它还提供了TrySetProperty的重载,只有在验证成功时才会更新属性值,并且可以返回任何生成的错误。对于需要手动控制验证的场景,ObservableValidator提供了ValidateProperty和ValidateAllProperties方法,可以用来手动触发特定属性或所有属性的验证。此外,它还提供了一个ClearAllErrors方法,非常适合用于重置表单以便再次使用。
ObservableValidator 的灵活性扩展到与 System.ComponentModel.DataAnnotations 命名空间提供的广泛验证属性兼容。这意味着您可以使用一组在许多场景中必不可少的常见验证规则,例如 [Required]、[StringLength]、[Range]、[Compare]、[RegularExpression] 以及更多。这些属性允许以表达的方式直接在 ViewModel 的属性上定义验证规则,从而实现高度可读性和可维护的代码库。当验证属性时,ObservableValidator 会拾取这些属性,使验证过程无缝且直接。您还可以通过将复杂的验证逻辑封装到专门的验证方法中,甚至是在自定义 ValidationAttribute 类中定义自己的特定验证规则,这些类可以在您的应用程序中重用。
让我们看看如何使用 ObservableValidator 为 AddRatingViewModel 添加验证。
预配置的验证规则
如前所述,ObservableValidator 类利用了 System.ComponentModel.DataAnnotations 命名空间中找到的验证属性的力量。这些属性可以轻松地应用于您的属性,而 ObservableValidator 类将使用它们来验证相应的属性值。ObservableValidator 与 DataAnnotations 命名空间之间的关系提供了一系列预配置的验证规则,简化了在 ViewModel 中实现输入验证的过程。让我们首先将一些属性标记为必填项:
转到 AddRatingViewModel,而不是继承 ObservableObject 类,让它继承 ObservableValidator 类,如下所示:
public class AddRatingViewModel : Required attribute to the EmailAddress, DisplayName, and RatingInput properties:
[Required]
public string EmailAddress { … }
[Required]
public string DisplayName { … }
[Required]
public string RatingInput { ... }
AddRatingViewModel 类包含一个 SubmitCommand,它调用 OnSubmit 方法。让我们更新这个方法,以便它验证所有属性并将验证消息写入调试窗口:
private Task OnSubmit()
{
ValidateAllProperties();
if(HasErrors)
{
var errors = GetErrors();
Debug.WriteLine( string.Join("\n",
errors.Select(e => e.ErrorMessage)));
}
else
{
Debug.WriteLine("All OK");
}
return Task.CompletedTask;
}
使用 ValidateAllProperties 方法,验证当前类的所有属性。调用此方法后,我们可以检查 HasErrors 属性以查看是否存在违规行为。如果有,GetErrors 方法允许我们检索 ValidationResult 对象的列表。ValidationResult 对象包含受此结果影响的成员名称列表和 ErrorMessage。
继续调试应用程序!如果您转到 AddRatingPage,留空所有内容并点击 提交 按钮。您将在 Visual Studio 的 输出 窗口中看到错误消息。
验证和错误
ValidateAllProperties方法验证 ViewModel 中的所有属性。ValidateProperty方法接受一个值和属性名,将检查给定值是否适用于给定属性。这是一种验证单个属性简单的方法。HasErrors属性将根据已验证的属性设置。对于GetErrors方法也是如此:它返回已验证属性的ValidationResult对象。此方法还有一个重载版本,您可以传递一个属性名以获取该特定属性的错误。GetErrors方法本身不会进行任何验证,尝试获取未验证属性的错误将不会产生任何结果。ClearErrors方法允许在提供属性名时移除所有错误或特定属性的错误。
让我们通过添加更多的验证规则并禁用提交 按钮直到所有输入有效来增强我们的验证。
在EmailAddress属性上,我们应该添加一个额外的验证属性。我们可以使用RegularExpressionAttribute来检查一个值是否与给定的正则表达式匹配:
以下代码片段显示了如何使用RegularExpressionAttribute将额外的验证属性添加到EmailAddress属性,以确保值匹配指定的正则表达式模式。此模式将用于验证给定值是否为电子邮件地址:
public const string EmailValidationRegex =
"^[aA-zZ0-9]+@[aA-zZ]+\.[aA-zZ]{2, 3}$";
...
[Required]
[RegularExpression(EmailValidationRegex)]
public string EmailAddress { ... }
为了确保DisplayName属性具有最小和最大长度,我们可以使用MinLength和MaxLength属性。让我们添加它们:
public const int DisplayNameMinLength = 5;
public const int DisplayNameMaxLength = 25;
...
[Required]
[MinLength(DisplayNameMinLength)]
[MaxLength(DisplayNameMaxLength)]
public string DisplayName { ... }
为了将RatingInput属性约束在 0 到 4 之间,带有零或一个十进制点,我们可以使用RangeAttribute进行范围约束和RegularExpressionAttribute进行十进制限制:
public const string RangeDecimalRegex = @"^\d+(\.\d{1,1})?$";
public const double RatingMinVal = 0d;
public const double RatingMaxVal = 4d;
...
[Required]
[RegularExpression(RangeDecimalRegex)]
[Range(RatingMinVal, RatingMaxVal)]
public string RatingInput { ... }
最后,我们希望在属性值更新时验证每个属性。ObservableValidator类有一个重载的SetProperty方法,它接受一个bool值,指示提供的值是否需要验证。这是EmailAddress属性的示例:
SetProperty(ref _emailAddress, value, DisplayName, RatingInput, and Review properties on this ViewModel as well, to use this overloaded SetProperty method, passing in true in order to trigger validation when the value is set. If we were to pass in false as the last parameter, the validation would not be triggered.
SetProperty和TrySetProperty
注意,这个重载的SetProperty方法无论提供的值是否有效,都会设置后备字段的值并触发PropertyChanged事件。在ObservableValidator类中还有一个TrySetProperty方法,当属性值无效时,它不会设置属性值。它返回一个bool值,指示值是否已设置,并且有一个out参数,返回错误集合。
AddRatingViewModel类包含一个命令SubmitCommand。这个命令应该在提供的属性值被认为是有效的情况下才可执行。为此,我们可以将SubmitCommand的canExecute函数指向HasErrors属性,如下所示:
SubmitCommand =
new AsyncRelayCommand(OnSubmit, NotifyCanExecuteChanged method of the SubmitCommand, so the canExecute function can be re-evaluated. The ObservableValidator class exposes an event called ErrorsChanged, which gets triggered whenever there is a change in validation errors. That’s the ideal moment to call the NotifyCanExecuteChanged method of the SubmitCommand. Let’s subscribe to this event and implement this:
public AddRatingViewModel(
INavigationService navigationService)
{
...
ErrorsChanged += AddRatingViewModel_ErrorsChanged;
}
private void AddRatingViewModel_ErrorsChanged(
object? sender, DataErrorsChangedEventArgs e)
{
SubmitCommand.NotifyCanExecuteChanged();
}
现在,如果你运行应用程序并导航到AddRatingPage,你会发现SubmitCommand的CanExecute方法基于验证错误的缺失。图 9.1 展示了这看起来是什么样子:
图 9.1:没有错误时启用提交按钮(右)
我们之前讨论的预配置验证属性可以简化向 ViewModel 添加验证的过程。然而,在某些情况下,这些内置规则可能无法满足你的特定要求,你需要创建自己的自定义验证逻辑。让我们接下来探讨如何实现这一点。
创建自定义验证规则
通常,预配置的验证规则可能不足以满足需求,你需要添加自己的自定义验证逻辑。当与ObservableValidator类一起工作时,实现自定义验证规则有两种选择:
第一种方法涉及通过扩展基类ValidationAttribute创建一个全新的验证属性。这允许你在可重用的组件中封装自己的验证逻辑,使你的 ViewModel 更干净,更专注于其主要职责。然后,你可以像应用内置属性一样将此自定义属性应用于任何属性。
第二种选择涉及使用CustomValidation属性,这允许你在声明点直接指定一个静态方法来处理验证。这种方法允许实现更本地化、上下文特定的验证场景,在这种情况下,创建一个单独的属性类可能就过于冗余了。
让我们更详细地检查这两种方法,看看它们如何被用来解决自定义验证需求。
创建一个自定义属性
让我们添加一个我们想在Review属性上使用的验证规则。尽管评论是可选的,但如果提供了,它必须在一个特定的长度范围内。由于这样的规则可能在不同场景中被重复使用,因此将此验证逻辑放入自定义属性中是有意义的。下面是如何操作的:
在Validation文件夹中,右键单击Recipes.Client.Core项目,将其命名为新的文件夹。
右键单击Validation文件夹,选择EmptyOrWithinRangeAttribute作为类名。
让这个类继承ValidationAttribute类,如下所示:
public class EmptyOrWithinRangeAttribute : ValidationAttribute
接下来,添加两个属性,MinLength和MaxLength,类型为int:
public int MinLength { get; set; }
public int MaxLength { get; set; }
这些属性允许更广泛地使用此属性。它们允许开发者在将此属性应用于属性时自定义长度约束。这样,可以针对每个单独的属性定制所需的精确最小和最大长度,因为开发者可以声明所需的最低和最高长度。
下一步是重写IsValid方法。当对应用了属性的属性开始验证过程时,该方法会被调用。让我们看看我们如何实现它:
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
if (value is string valueAsString && (
string.IsNullOrEmpty(valueAsString) ||
(valueAsString.Length >= MinLength
&& valueAsString.Length <= MaxLength)))
{
return ValidationResult.Success;
}
else
{
return new ValidationResult($"The value should be between {MinLength} and {MaxLength} characters long, or empty.");
}
}
值参数,类型为object,表示需要验证的属性值。第二个参数,类型为ValidationContext,提供了更多的上下文,包括正在验证的对象的实例和属性。返回ValidationResult.Success表示给定的值是有效的,否则我们返回一个描述性的错误消息。
最后,我们可以在AddRatingViewModel的Review属性上使用这个属性,如下所示:
[EmptyOrWithinRange(MinLength = 10, MaxLength = 250)]
public string Review
{
get => _review;
set => SetProperty(ref _review, value, true);
}
在我们的自定义验证属性就绪后,Review属性可以被验证,使其为空或位于给定的长度范围内。此属性可以轻松地在整个应用程序中重用。
使用 CustomValidation 属性
ValidationResult对象,并接受一个与正在验证的属性类型匹配的参数,以及一个ValidationContext对象。这与我们在实现自己的ValidationAttribute时重写的IsValid方法类似。让我们给Review属性添加一个额外的验证,以便在给定的评分小于或等于 2 时是必需的:
在AddRatingViewModel中,添加一个名为ValidateReview的新静态方法,其实现如下:
public static ValidationResult ValidateReview(string review, ValidationContext context)
{
AddRatingViewModel instance =
(AddRatingViewModel)context.ObjectInstance;
if (double.TryParse(instance.RatingInput,
out var rating))
{
if (rating <= 2 &&
string.IsNullOrEmpty(review))
{
return new("A review is mandatory when rating the recipe 2 or less.");
}
}
return ValidationResult.Success;
}
此方法接受一个类型为string的参数,与我们要验证的属性类型匹配。第二个参数是类型为ValidationContext,我们可以使用它来访问属性定义的对象的实例。这允许我们访问正在验证的对象上的其他属性,例如我们案例中的RatingInput属性。当我们可以将RatingInput值解析为double时,我们可以检查它是否小于或等于 2。如果是这种情况,并且给定的评论值为空,我们返回一个验证错误。否则,返回Success。
现在,我们需要在Review属性上添加一个CustomValidation属性,并将其指向我们刚刚创建的静态ValidateReview方法。让我们看看这是如何完成的:
[CustomValidation(
typeof(AddRatingViewModel),
nameof(ValidateReview))]
[EmptyOrWithinRange(MinLength = 2, MaxLength = 250)]
public string Review
{
get => _review;
set => SetProperty(ref _review, value, true);
}
CustomValidation 属性需要的第一参数是静态验证方法定义的类型。在我们的例子中,我们是在 AddRatingViewModel 本身上定义的,所以我们将其作为类型传递。这意味着你可以在其他地方定义你的验证方法,例如将它们捆绑在单独的类中。第二个参数是验证方法的名称。我们使用 nameof 关键字来避免使用魔法字符串,并添加编译时错误检查。
由于 Review 属性的验证也依赖于输入的 RatingInput 属性,我们必须确保在 RatingInput 属性更改时也验证 Review 属性。如下面的代码片段所示,我们可以通过在 RatingInput 属性的值更新时调用 Review 属性的 ValidateProperty 方法来轻松地做到这一点:
[Required]
[RegularExpression(RangeDecimalRegex)]
[Range(RatingMinVal, RatingMaxVal)]
public string RatingInput
{
get => _ratingInput;
set
{
SetProperty(ref _ratingInput, value, true);
ValidateProperty(Review, nameof(Review));
}
}
就这些了!这就是添加自定义验证、利用 CustomValidation 属性所需的所有内容。这种方法通常用于在较少的不同对象上重复使用的验证检查。在这种情况下,我们访问 ValidationContext 的 ObjectInstance 并将其转换为特定类型,这自然使其在不同类型上不可用。
运行你的应用程序,注意当给出的评分低于 3 且评论为空时,提交 按钮会被禁用。我们的验证逻辑正在按预期运行!然而,当前的实现缺乏用户友好性,因为它没有提供关于无效输入的任何反馈。让我们看看如何向用户展示这些验证错误。
在屏幕上显示错误
向用户展示验证错误基本上有两种方法。第一种方法通常一次概述所有问题,通常在表单的顶部或底部。第二种方法在发生错误的输入字段上直接提供反馈。这通常可以帮助用户更直接、更快速地纠正错误。这两种方法都有其用途,并且通常在应用程序中结合使用以获得最佳用户体验。
显示所有错误
虽然 ObservableValidator 没有提供直接列出所有验证错误的属性,但它确实提供了一个 GetErrors 方法来获取它们。遗憾的是,数据绑定到方法是不可能的。为了更好地与 MVVM 实践相一致并促进数据绑定,引入一个类型为 ObservableCollection<ValidationResult> 的 Errors 属性将会很有益。这样,我们就可以在 UI 中绑定到我们的验证错误。
让我们看看我们如何实现这一点:
将一个类型为 ObservableCollection<ValidationResult> 的 Errors 属性添加到 AddRatingViewModel 中:
public ObservableCollection<ValidationResult> Errors { get; } = new();
当ObservableValidator的ErrorsChanged事件被触发时,AddRatingViewModel上的AddRatingViewModel_ErrorsChanged方法会被调用。目前,这调用SubmitCommand上的NotifyCanExecuteChanged方法,但让我们更新它,使其也(重新)填充我们刚刚定义的Errors集合。下面的代码块显示了我们可以如何做到这一点:
private void AddRatingViewModel_ErrorsChanged(object? sender, DataErrorsChangedEventArgs e)
{
Errors.Clear();
GetErrors().ToList().ForEach(Errors.Add);
SubmitCommand.NotifyCanExecuteChanged();
}
前面的代码首先清除了Errors集合。接下来,它调用ObservableValidator的GetErrors方法来获取所有错误。使用ForEach方法,我们可以遍历所有项目并调用Errors.Add方法将当前项目添加到Errors集合中。
前面的代码应该将所有当前验证错误添加到Errors属性。剩下要做的只是将这个集合绑定到我们的视图中,如下所示:
<VerticalStackLayout BindableLayout.ItemsSource="{Binding Errors}">
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="annotations:ValidationResult">
<Label Text="{Binding ErrorMessage}"
FontSize="12" TextColor="Red"/>
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
到目前为止,你应该会发现 XAML 代码熟悉且简单:Errors集合绑定到VerticalStackLayout上的BindableLayout ItemsSource属性。ItemTemplate规定对于每个项目,我们想要渲染一个显示其ErrorMessage属性的Label。DataTemplate的DataType是ValidationResult。annotations XML 命名空间在页面顶部定义:.
前面的代码将导致在屏幕上显示所有验证错误的列表。运行应用程序,你应该会看到随着你输入值,验证错误的出现和消失,如图 9**.2 所示。
图 9.2:显示最新的验证错误
虽然这已经可以大大改善用户体验,但我们还可以更进一步。让我们通过显示内联错误消息来增强 UX,直接相邻于相关的输入字段。
显示内联错误
显示单个错误时的主要挑战是开发一种机制,使我们能够检索和显示与单个属性相关的验证错误,而不是一次性显示所有验证错误。对此有一个非常简单的方法:对于每个属性,公开一个包含该属性错误集合的附加属性。看看以下示例:
public List<ValidationResult> EmailValidationErrors
{
get => GetErrors(nameof(EmailAddress)).ToList();
}
这个EmailValidationErrors属性只提供与EmailAddress属性相关的验证错误列表。这个EmailValidationErrors属性可以绑定到 UI 上,这样我们就可以在屏幕上只显示与EmailAddress属性相关的错误。为了保持这个绑定列表的更新,我们需要确保每次EmailAddress属性更新时,都触发EmailValidationErrors属性的PropertyChanged事件,如下所示:
public string EmailAddress
{
get => _emailAddress;
set
{
SetProperty(ref _emailAddress, value, true);
OnPropertyChanged(nameof(EmailValidationErrors));
}
}
通过为每个输入的验证错误创建一个专用属性并确保在输入值更改时更新它,我们可以有效地隔离和显示单个字段的验证错误。然而,这也带来了一些副作用:对于具有许多输入字段的表单,这种方法可能有些费时且重复。让我向您展示一个自动化此过程并节省我们一些手动工作的替代方案。最终结果是我们将绑定到一个特定的属性,称为 ErrorExposer。然后,使用它的 索引器 ,我们将指定要检索和显示哪个属性的验证错误,类似于这样:
<VerticalStackLayout BindableLayout.ItemsSource="{Binding ErrorExposer[EmailAddress]}">
让我们看看我们如何实现这个机制:
在我们之前创建的 Validation 文件夹中,选择 ValidationErrorExposer 作为类名。
ValidationErrorExposer 类应该实现两个接口:INotifyPropertyChanged 和 IDisposable,如下面的代码片段所示:
public class ValidationErrorExposer : INotifyPropertyChanged, IDisposable
{
public event PropertyChangedEventHandler? PropertyChanged;
public void Dispose()
{
}
}
接下来,让我们介绍一个 readonly 字段 validator,其类型为 ObservableValidator。这个字段的值应该通过类构造函数传入,如下所示:
readonly ObservableValidator validator;
public ValidationErrorExposer(
ObservableValidator observableValidator)
{
validator = observableValidator;
}
传入的 observableValidator 是我们想要自动暴露每个属性验证错误的实例。
是时候添加 ValidationErrorExposer 索引器了。在 .NET 中,索引器允许使用索引访问类的实例,类似于数组或字典。这个索引可以是任何类型,例如字符串或整数,它允许你检索或设置值,而无需显式调用方法或属性。在这种情况下,我们将索引设置为字符串,因为它代表我们想要获取验证错误的属性的名称。这就是我们如何做到这一点的:
public List<ValidationResult> this[string property]
=> validator.GetErrors(ValidationErrorExposer accepts a string value as the index and returns a list of ValidationResult objects. This value, which represents the name of the property we want to get the errors of, is passed into the ObservableValidator GetErrors method. The result is returned as a List.
在 ValidationErrorExposer 类的构造函数中,我们还应该订阅传入的 ObservableValidator 的 ErrorsChanged 事件,如下所示:
public ValidationErrorExposer(ObservableValidator observableValidator)
{
validator = observableValidator;
validator.ErrorsChanged += ObservableValidator_ErrorsChanged;
}
private void ObservableValidator_ErrorsChanged(object? sender, DataErrorsChangedEventArgs e)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"Item[{e.PropertyName}]"));
虽然这看起来可能像是有某种魔法在起作用,但这个概念相当直接。在 .NET MAUI 中,如果一个对象有一个索引器,你可以绑定到那个索引器属性,就像示例 {Binding ErrorExposer[EmailAddress]} 所展示的那样,其中 ErrorExposer 是一个包含索引器的类型的实例。为了通知 UI 更新后的值,我们可以从 ErrorExposer 中调用 PropertyChanged 事件,并传递 Item[EmailAddress] 作为属性名。这样做将促使所有绑定到 ErrorExposer[EmailAddress] 的绑定重新评估。或者,使用具有 Item 作为属性名的 PropertyChangedEventArgs 调用 PropertyChanged 事件将触发与索引器相关联的所有绑定的重新评估。
最后,在 ValidationErrorExposer 中,我们应该更新 Dispose 方法如下:
public void Dispose()
=> _validator.ErrorsChanged -= ObservableValidator_ErrorsChanged;
由于我们已订阅了 ObservableValidator 的 ErrorsChanged 方法,我们需要提供一个机制来取消订阅此事件,以防止内存泄漏。为此,我们可以使用 Dispose 方法。
让我们回到AddRatingViewModel并添加一个类型为ValidationErrorExposer的属性,如下所示:
public ValidationErrorExposer ErrorExposer { get; }
在AddRatingViewModel的构造函数中,将新实例分配给此属性:
public AddRatingViewModel(INavigationService navigationService)
{
...
ErrorExposer = new (this);
...
}
我们将this传递给ValidationErrorExposer类的构造函数,因为它本身就是继承自ObservableValidator的AddRatingViewModel实例,因此包含所有验证错误。
在 XAML 中,我们现在可以通过ValidationErrorExposer类的索引器绑定到特定属性的验证错误列表。这允许我们将这些错误显示在相关的输入字段附近,如下面的代码片段所示:
<Editor Text="{Binding Review, Mode=TwoWay}" />
<VerticalStackLayout BindableLayout.ItemsSource="{Binding ErrorExposer[Review]}">
<BindableLayout.ItemTemplate>
...
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
ValidationErrorExposer允许我们轻松地从特定属性获取验证错误,而无需进行任何手动工作:一旦设置好,我们就可以使用其索引属性绑定任何特定属性的错误。
在用户输入附近显示验证错误可以进一步提高用户体验。让我们探讨如何通过视觉指示输入值的有效性,从而进一步提升体验。
使用触发器可视化验证错误
触发器 帮助我们自定义 UI 元素的工作方式和外观,而无需从头开始构建新的控件。
尽管存在不同类型的触发器,但我们将重点关注数据触发器 。当 ViewModel 上的属性发生变化时,这些触发器会启动,使我们能够根据用户的行为动态调整 UI 控件元素。
触发器类型
.NET MAUI 中有不同类型的触发器:属性触发器、数据触发器、事件触发器等等……它们都允许你在 XAML 中声明性地更改 UI 控件的外观,基于触发器。它们之间的区别在于触发变化的是什么:属性值、绑定值、事件。你可以在learn.microsoft.com/dotnet/maui/fundamentals/triggers 了解更多关于它们的信息。
从本质上讲,数据触发器提供了一种在 XAML 中声明性地设置 UI 变化以响应数据变化的方法,无需在代码后编写过程式代码、自定义值转换器或 ViewModel。
数据触发器是一个相对容易理解的概念。所以,让我们直接深入并添加一个视觉指示器,紧邻不同的Entry控件旁边,用符号和特定的颜色指示输入的值是否有效,如图9**.3 所示:
图 9.3:指示输入有效(右)或无效(左)
那么,值转换器呢?
在 第四章 中,我们讨论了 .NET MAUI 中的数据绑定使用。你可以使用 DataTriggers 实现的事情也可以用值转换器实现,反之亦然。但是,使用 DataTrigger,可以非常容易地声明式地实现这些视觉效果,而不需要一行 C# 代码,这也使得 XAML 更易于阅读。
让我们看看如何使用 DataTriggers 来实现这些视觉提示:
首先,为 EmailAddress 属性的 Entry 添加一个 Grid:
<Grid ColumnDefinitions="*, Auto" HeightRequest="45">
<Entry
Keyboard="Email"
Text="{Binding EmailAddress, Mode=TwoWay}"
VerticalOptions="End" />
</Grid>
这个 Grid 有两列,第一列是 Entry,我们将在第二列中添加有效性指示器。
在这个 Grid 中,在 Entry 下方添加以下 Label,它将作为有效性指示器:
<Label
Grid.Column="1" FontFamily="MaterialIconsRegular"
FontSize="20" Text="" TextColor="Red"
VerticalOptions="Center">
</Label>
默认情况下,这个 Label 显示无效状态:红色文本和感叹号图标。
现在,我们可以添加 DataTrigger 并定义当提供的电子邮件地址有效时,Label 需要显示蓝色勾选标记。以下代码片段展示了我们如何做到这一点:
<Label ... >
<Label.Triggers>
<DataTrigger
TargetType="Label"
Binding="{Binding ErrorExposer[EmailAddress].Count}"
Value="0">
<Setter Property="Text"
Value="" />
<Setter Property="TextColor"
Value="Blue" />
</DataTrigger>
</Label.Triggers>
</Label>
DataTrigger 有一个 Binding 属性,允许我们绑定到某个值。在这种情况下,我们绑定到与 EmailAddress 属性相关的验证错误列表的 Count 属性。通过 Value 属性,我们可以为这个绑定属性设置一个条件。我们将值设置为 "0",这意味着没有与 EmailAddress 属性相关的验证错误。当属性的值满足这个条件时,触发器被激活。一旦激活,触发器可以改变 UI 控件的属性之一或多个。在这个特定的情况下,我们通过指定这些属性的 Setter 并提供一定的 Value 来更新 Text 和 TextColor 属性。
如前所述,这也可以通过自定义 ValueConverters 实现,但这种方式在 XAML 中声明式地定义它非常易于阅读、维护和使用。
Behaviors
另一种给用户提供关于输入值有效性的视觉提示的方法是使用 Behaviors 。Behaviors 就像你可以添加到你的 UI 元素中的小插件,增强它们的默认行为,而无需对它们进行子类化。它们特别有用,因为它们封装了可重用的逻辑片段,允许开发者将相同的功能应用于不同的控件。例如,一个 Behavior 可能允许文本输入字段只接受数值输入或在满足某些条件时改变其颜色。.NET MAUI 社区工具包附带了一套现成的 Behaviors!你可以在 learn.microsoft.com/dotnet/maui/fundamentals/behaviors 上了解更多信息。
在下一节中,我们将探讨如何显示提示和警报,这是提供用户反馈和收集输入的基本部分。
提示和警报
直接反馈和清晰的沟通对于良好的用户体验至关重要。当用户在应用程序中导航并与各种输入交互时,会有一些时刻,微妙的提示或直接的提示可以产生巨大的差异。提示和警报是这些基本工具,引导用户完成旅程,确保他们获得信息并做出有意的决策。
显示提示和警报是特定于平台的。幸运的是,.NET MAUI 为我们提供了覆盖,它们提供了简单直观的 API 来实现这一点。另一方面,在 MVVM 场景中,显示提示或警报通常是从 ViewModel 触发的,这应该是框架无关的。解决方案当然是创建一个接口来实现这个功能,ViewModel 可以与之通信。让我们继续设置这个接口!
首先,让我们添加一个定义显示提示和警报方法的接口。在 Services 中右键点击 Recipes.Client.Core 项目。
在此文件夹中添加一个新的接口,命名为 IDialogService。
在 IDialogService 中定义的方法可能因项目而异。以下代码块显示了在 IDialogService 接口中常见的某些方法声明:
public interface IDialogService
{
Task Notify(string title, string message,
string buttonText = "OK");
Task<bool> AskYesNo(string title, string message,
string trueButtonText = "Yes",
string falseButtonText = "No");
Task<string?> Ask(string title, string message,
string acceptButtonText = "OK",
string cancelButtonText = "Cancel");
}
正如所说,根据您的具体用例,这个接口可能会扩展一些其他具体的方法定义。
接下来,我们需要为这个接口添加一个实现。在 Recipes.Mobile 项目中添加一个名为 Services 的新文件夹。
右键点击新的 Services 文件夹,选择 DialogService 作为新类的名称,并添加以下代码:
public class DialogService : IDialogService
{
public Task Notify(string title, string message,
string buttonText = "OK")
=> Application.Current.MainPage
.DisplayAlert(title, message, buttonText);
public Task<bool> AskYesNo(string title,
string message,
string trueButtonText = "Yes",
string falseButtonText = "No")
=> Application.Current.MainPage
.DisplayAlert(title, message,
trueButtonText, falseButtonText);
public Task<string?> Ask(string title,
string message,
string acceptButtonText = "OK",
string cancelButtonText = "Cancel")
=> Application.Current.MainPage
.DisplayPromptAsync(title, message,
acceptButtonText, cancelButtonText);
}
在 .NET MAUI 中,Page 类提供了显示警报、提示和操作表的各种方法。在 DialogService 类中,我们可以通过引用 Application 类的静态 Current 属性来访问这些方法。
接下来,我们需要在我们的依赖注入容器中注册 DialogService。转到 MauiProgram.cs 并添加以下内容:
builder.Services.AddSingleton<IDialogService, DialogService>();
最后,我们可以将 IDialogService 作为依赖项添加到我们的 ViewModels 中。让我们将其添加到 AddRatingViewModel 中,如下所示:
readonly IDialogService dialogService;
...
public AddRatingViewModel(INavigationService navigationSerivce, IDialogService dialogService)
{
...
this.dialogService = dialogService;
...
}
转到 OnSubmit 方法并更新如下所示:
private async Task OnSubmit()
{
var result = await _dialogService.AskYesNo(
"Are you sure?",
"Are you sure you want to add this rating?");
if (result)
{
//ToDo: Submit data
await _dialogService.Notify("Rating sent",
"Thank you for your feedback!");
GoBackCommand.Execute(null);
}
}
在更新的 OnSubmit 方法中,我们首先请求确认。如果用户确认,将显示一个警报,说明评分已发送(图 9**.3 )。在用户关闭警报后,将调用 GoBackCommand,关闭 AddRatingPage。
图 9.3:显示警报和提示
在 IDialogService 和其 DialogService 实现到位后,我们已经为我们的应用程序中的基本弹出交互奠定了基础。接下来,我们将探讨如何利用这一点在用户尝试离开特定页面时提示用户确认。
确认或取消导航
当用户与我们的应用程序交互时,可能会有他们即将从包含未保存更改或重要输入的页面导航离开的时刻。为了防止潜在的数据丢失,在允许此类导航之前提示确认是至关重要的。让我们看看我们如何通过利用我们在上一章中构建的 NavigationService 来构建它:
让我们从向 Recipes.Client.Core 项目的 Navigation 文件夹中添加以下名为 INavigatable 的接口开始:
public interface INavigatable
{
Task<bool> CanNavigateFrom(NavigationType navigationType);
}
想要控制用户是否能够导航的 ViewModels 可以实现此接口。这与我们在导航上下文中引入的其他接口类似,例如 INavigatedFrom、INavigatedTo 和 INavigationParameterReceiver 接口。
使用以下方法定义扩展 INavigationInterceptor 接口:
Task<bool> CanNavigate(object bindingContext, NavigationType type);
实现 INavigationInterceptor 接口的 NavigationService 类现在需要实现此方法。以下是它的样子:
public Task<bool> CanNavigate(object bindingContext, NavigationType type)
{
if(bindingContext is INavigatable navigatable)
return navigatable.CanNavigateFrom(type);
return Task.FromResult(true);
}
此方法检查给定的 bindingContext 参数是否实现了 INavigatable 接口。如果是这样,它将返回其 CanNavigateFrom 方法的结果,传递 NavigationType。如果 bindingContext 没有实现 INavigatable 接口,则返回 true,表示导航可以执行。
在 AppShell 类中,我们现在必须重写 OnNavigating 方法。在这个方法中,我们可以从传入的 ShellNavigatingEventArgs 中检索一个 ShellNavigatingDeferral。这个延迟令牌可以用来完成导航。或者,如果导航应该被取消,可以在 ShellNavigatingEventArgs 上调用 Cancel 方法。下一个代码块显示了重写的方法:
protected override async void OnNavigating(ShellNavigatingEventArgs args)
{
base.OnNavigating(args);
var token = args.GetDeferral();
if(token is not null)
{
var canNavigate = await interceptor
.CanNavigate(CurrentPage?.BindingContext, GetNavigationType(args.Source));
if (canNavigate)
token.Complete();
else
args.Cancel();
}
}
通过调用拦截器的 CanNavigate 方法,我们可以确定是否必须完成导航。根据结果,我们可以调用延迟令牌上的 Complete 方法来完成导航,或者调用传入的 args 上的 Cancel 方法来取消它。
最后,我们可以进入 AddRatingViewModel,使其实现 INavigatable 接口,并添加以下内容:
public class AddRatingViewModel : ObservableValidator, INavigationParameterReceiver, INavigatedFrom, INavigatable
{
...
public Task<bool> CanNavigateFrom(
NavigationType navigationType) =>
_dialogService.AskYesNo(
"Leaving this page...",
"Are you sure you want to leave this page?");
}
因此,当从 AddRatingView 导航时,Shell 类上的 OnNavigating 方法将被调用,通过 NavigationService 将调用 AddRatingViewModel 上的 CanNavigateFrom 方法。ViewModel 将向用户显示对话框并返回响应。根据用户给出的响应,导航将被完成或取消。INavigatable 接口可以由任何 ViewModel 实现,并可以包含任何业务逻辑以确定是否允许导航。
摘要
在本章中,我们深入探讨了通过有效的验证、提示和警报来增强用户体验。我们探索了ObservableValidator在验证逻辑方面的强大功能,并学习了如何以集体列表和内联方式显示错误,即紧挨着输入字段。通过触发器,我们学习了如何在不重新发明轮子的前提下自定义 UI 元素。我们还探讨了使用IDialogService利用警报和提示,这在需要用户反馈或确认的上下文中至关重要,例如在关键操作或导航期间。随着我们继续前进,我们将转向许多现代应用的一个关键方面:进行远程 API 调用。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
第十章:处理远程数据
到目前为止,我们已经深入探讨了 MVVM 和 .NET MAUI,涵盖了从 MVVM 设计的基础,到数据绑定和依赖注入,再到导航和处理用户输入的各个方面。但是,还有一块重要的拼图我们没有解决:从互联网获取数据。
现在很难想象一个应用程序不与在线服务通信来获取新鲜数据。添加后端通信也意味着我们需要解决一些架构挑战,例如保持关注点的分离、以可维护性为前提构建应用程序等。
在本章中,我们将深入探讨以下内容:
重新审视模型架构
使用 Refit 进行 API 通信
ViewModels 的 API 通信
到本章结束时,我们的 Recipes! 应用程序将不仅仅是一个独立的东西。它将与后端服务通信以获取新鲜数据并推送更新。我们将为其配备构建坚实的 MVVM 应用程序所需的必要工具和技能。
技术要求
在本章中,我们更新了 Recipes! 应用程序的一般架构,以更好地促进与远程 API 的通信。为了确保你与我们在同一页面上,所有资源和代码片段都可在 GitHub 上找到:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter10 。如果你希望一起编码,请从 Start 文件夹中的代码开始,该代码已被重构,作为本章的基础。完成之后,你可以将你的工作与 Finish 文件夹中的最终版本进行比较。
重新审视模型架构
在我们迄今为止的旅程中,我们的模型很简单。我们只是使用了读取本地 JSON 文件的服务,并将 数据传输对象 (DTOs) 直接提供给我们的 ViewModels。但是,随着我们引入远程数据,这种简单的模型将不再足够。
一种直接的方法是在我们的服务中直接进行 API 调用,并将结果 DTOs 传递给我们的 ViewModels。然而,根据 SoC 原则,我相信服务不应该进行 API 调用。此外,直接在 ViewModels 中使用特定于 API 的 DTOs 是一条滑梯。这会紧密地将我们的应用程序与外部 API 相关联,可能导致维护噩梦,尤其是如果 API 经常更改或不受我们控制的话。
相反,我主张将这些 DTOs 映射到 Plain Old CLR Objects (POCOs) ,或者实体或领域模型——无论你更喜欢叫它们什么。核心思想?与我们所拥有和控制的数据类型一起工作。
小贴士
通过将我们的应用程序和 API 之间的交互点保持在最低限度,我们的代码将受到潜在 API 变化的影响较小,从而提高可维护性。
为了实现这一点,我们将在我们的架构中引入存储库 的概念。这些存储库将与 API(或任何数据源)接口,获取 DTOs,将它们映射到我们的领域模型,然后提供给我们的服务和 ViewModel。以下图表展示了这一设想架构:
图 10.1:架构概述
现在,有人可能会问:这真的有必要吗?我们难道不能直接从服务中调用 API,同时在 ViewModel 中使用 DTOs 吗?答案是:不需要;如果你想,完全可以从服务中调用 API。这绝对适用于所有业务逻辑都在服务器上完成的较小“傻瓜”应用程序。但随着我们规模的扩大或处理更复杂的场景,深思熟虑的架构对于几个原因来说变得至关重要:
关注点分离 : 通过引入存储库,我们明确地分离了系统中的角色。存储库主要关注从数据源(无论是 API 还是另一个数据存储)获取数据,并将其转换为服务可以轻松使用的格式。将存储库层放置在核心项目之外不仅强调了其独特的责任,还确保了数据源的变化或扩展不会干扰核心业务逻辑。这种分离增强了系统的适应性和可维护性。很明显,存储库的唯一目的是数据检索,它作为一个薄层从各种来源获取数据,并将其提供给核心服务。作为中介的存储库,数据源的变化或数据结构的更改都集中管理,简化了修改过程。
提高可测试性 : 通过引入存储库层,我们增强了应用程序的可测试性。有了存储库,我们可以在测试中轻松地模拟数据层。这种抽象确保我们的测试专注于服务内部的逻辑,不受外部数据源的依赖。在第十三章 单元测试 中,我们将更详细地探讨这一点。
增强服务 : 服务层可以自由地引入额外的功能,例如缓存、业务逻辑或从多个存储库中进行数据聚合。这种解耦意味着服务不会直接绑定到特定的数据源,并且可以独立演进。
在深入探讨存储库和其他细节的概念之前,让我们先探索本章配套代码库的Start文件夹中找到的解决方案,以了解所进行的更改。
代码库的更新
随着我们深入创建一个健壮的 MVVM 架构,熟悉代码库中已进行的更改和新增内容至关重要。如果你查看本章配套存储库的Start文件夹,你会注意到一些变化。以下是一些显著的更新:
已添加了一个Recipes.Web.Api API 项目。在核心上,它仍然从本地 JSON 文件中读取,类似于我们早期的服务。API 的实现相当简单,仅用于演示目的。
新的Recipes.Shared项目包含了 API 返回并接受的 DTOs。
在Recipes.Client.Core项目的Features文件夹中,我们添加了新的 POCO 或领域实体。这些实体是 DTOs 的反映,但设计上完全受我们控制,确保与我们的应用程序基础设施的集成更加顺畅。
已创建一个新的Recipes.Client.Repositories项目。该项目将包含我们将要创建的存储库的实现。我们的想法是将它们与Recipes.Client.Core项目分开,以便核心项目完全独立于 API 及其 DTOs。该项目还包含将 API 返回的 DTOs 映射到我们将在整个应用程序中使用的 POCO 实体的映射器。
服务和 ViewModels 不再依赖于 DTOs。现在,它们仅与我们的 POCO 交互,确保架构干净且易于维护。
由于我们将从 API 获取数据,因此已从Recipes.Mobile项目中删除了ratings.json和recipedetails.json文件。
MauiProgram类中IRecipeService和IRatingsService的注册已更新为以下内容:
builder.Services.AddTransient<IRatingsService,
RatingsService>();
builder.Services.AddTransient<IRecipeService,
RecipeService>();
这些更改为引入存储库和与 API 的交互奠定了基础。
总是返回一个结果
在传统的编码中,异常通常用于指示失败。虽然它们对于异常 情况很有用,但它们可能不是处理常规、预期错误场景的最佳选择。使用异常处理预期错误可能会使代码变得混乱,难以理解。
因此,我倾向于使用Result<TSuccess>对象来处理此类情况。该对象作为成功时预期的数据(TSuccess)的包装器,并提供错误代码、错误数据和失败情况下的Exception错误字段。它是一个非常简单且方便的包装器,如下所示:
public sealed class Result<TSuccess>
{
...
public bool IsSuccess { get; }
public TSuccess? Data { get; }
public string? ErrorCode { get; }
public string? ErrorData { get; }
public Exception? Exception { get; }
private Result(TSuccess? data,
string? errorCode, string? errorData,
Exception? exception, bool isSuccess)
{
Data = data;
ErrorCode = errorCode;
ErrorData = errorData;
Exception = exception;
IsSuccess = isSuccess;
}
}
如以下代码块所示,该类还包含一些静态方法,用于实例化Success或Fail Result对象,从而简化该对象的使用:
public static Result<TSuccess> Success(TSuccess data)
=> new Result<TSuccess>(data, null, null, null, true);
public static Result<TSuccess> Success()
=> new Result<TSuccess>(default, null, null,
null, true);
public static Result<TSuccess> Fail(string errorCode,
string? errorData = null, Exception? exception = null)
=> new Result<TSuccess>(default, errorCode,
errorData, exception, false);
public static Result<TSuccess> Fail(Exception exception)
=> new Result<TSuccess>(default, nameof(exception),
exception.Message, exception, false);
通过使用Result对象,我们可以轻松地区分两种类型的错误:
例如,一个无法检索数据的移动应用程序不是一个异常情况;这是我们应计划的情况。Result对象允许我们优雅地处理此类情况,而无需求助于异常。它提供的上下文比简单地返回null或false更丰富,使我们能够了解操作失败的原因。即使当我们与不控制的 API 通信时,这也同样有效:这只是一个围绕对象的简单包装器。
Result 对象使我们的代码更加清晰和一致。它消除了诸如“这个方法会抛出异常吗?如果是,是什么类型的异常?它在 类似场景中抛出的异常类型是否相同? ”这样的不确定性。
错误与异常
通过将预期错误与真正的异常分开,我们使代码更易于阅读和维护。它让异常成为它们应该成为的东西:指示关键、意外的失败。
随着我们将 Recipes! 应用程序扩展到与后端 API 通信,各种类型错误的可能性显著增加。为了应对这种复杂性,我们将把服务和仓库的返回值包裹在一个 Result 对象中。这种方法不仅帮助我们有效地处理预期错误,还为我们错误处理策略带来了标准化和清晰度。让我们看看这个 Result 对象如何为我们的 ViewModels 带来优雅和健壮性。
将 Result 对象投入使用
以下代码片段展示了 Result 对象如何在 RecipeRatingsDetailViewModel 中优雅地处理成功和失败的结果:
private async Task LoadData(RecipeDetail recipe)
{
...
var loadRatings = await
ratingsService.LoadRatings(recipe.Id);
if(loadRatings.IsSuccess)
{
GroupedReviews = loadRatings.Data
...
.ToList();
}
else
{
var shouldRetry = await dialogService.AskYesNo(
"Failed to load", "Retry?");
if (shouldRetry)
await LoadData(recipe);
else
await navigationService.GoBack();
}
}
这个例子强调了 Result 对象为我们的 ViewModels 带来的优雅和健壮性。通过使用 IsSuccess 属性,我们可以立即确定操作的成功与否。如果成功,我们继续处理我们接收到的数据。如果不成功,我们给用户一个重试或返回的机会。此外,Result 对象还包含在 ErrorMessage、ErrorCode 和 Exception 中的有价值错误信息,使我们能够定制我们的错误处理策略,例如向用户显示特定的错误消息。这种方法消除了在 ViewModels 中添加异常处理部分的必要性,从而使得代码结构更清晰、易于阅读和维护。
如果我们想利用 C# 的 模式匹配 能力,我们可以使前面的代码更加优雅,如下所示:
if (loadRatings is { IsSuccess: true, Data: var ratings })
{
GroupedReviews = ratings
...
.ToList();
}
else
{
...
}
通过使用模式匹配,我们可以检查 Result 对象的 IsSuccess 属性是否为 true,并在同一语句中,将 Result 对象的 Data 属性赋值给 ratings 变量。这使得我们能够通过 if 块内部的 ratings 变量更轻松地访问 Data 属性。现在我们已经很好地理解了 Result 对象,我们可以开始向我们的架构中添加仓库。
添加仓库接口
让我们开始添加仓库,这些代码片段将直接与我们的 API 交互:
在 Recipes.Client.Core 项目中,将一个名为 IRecipeRepository 的接口添加到 Features/Recipes 文件夹中。这个接口看起来是这样的:
public interface IRecipeRepository
{
Task<Result<LoadRecipesResponse>> LoadRecipes(
int pageSize = 7, int page = 0);
Task<Result<RecipeDetail>> LoadRecipe(string id);
}
此接口定义了任何类需要实现以从数据源获取菜谱的合同。在此接口中定义了两种方法:LoadRecipes和LoadRecipe。第一种方法返回一个LoadRecipesResponse对象,它包含一个分页的菜谱集合。第二种方法返回一个 ID 标识的RecipeDetail对象。这两种方法的返回值都包裹在Result对象中,使我们能够处理请求的数据(暂时)无法检索的情况。
前往RecipeService类,并在其构造函数中添加一个类型为IRecipeRepository的参数。还添加了一个字段来保持对这个实例的引用,如下面的代码片段所示:
public class RecipeService : IRecipeService
{
readonly IRecipeRepository _recipeRepository;
...
public RecipeService(
IRecipeRepository recipeRepository)
{
_recipeRepository = recipeRepository;
}
}
由于RecipeService类中几乎没有“业务逻辑”,其方法应该只是调用注入的仓库的方法并返回其结果。请看以下代码:
public Task<Result<RecipeDetail>> LoadRecipe(
string id) => _recipeRepository.LoadRecipe(id);
public Task<Result<LoadRecipesResponse>> LoadRecipes(
int pageSize = 7, int page = 0)
=> _recipeRepository.LoadRecipes(pageSize, page);
我们可以将相同的处理方式应用到RatingsService类中:创建一个仓库接口,将其作为依赖项添加到服务中,并在RatingsService类的方法中调用该接口的方法。我们将按以下步骤进行:
让我们在Features/Ratings文件夹中创建一个IRatingsRepository接口,并将以下定义添加到新创建的接口中:
Task<Result<IReadOnlyCollection<Rating>>>
GetRatings(string recipeId);
Task<Result<RatingsSummary>> GetRatingsSummary(
string recipeId);
在此接口中定义了两种方法:GetRatings和GetRatingsSummary。第一种方法返回与指定菜谱 ID 关联的Rating对象集合。第二种方法返回一个 ID 标识的菜谱的RatingsSummary。如前所述,返回值都包裹在Result对象中。
接下来,我们将IRatingsRepository作为RatingsService类的依赖项添加,通过将其定义为构造函数参数:
public class RatingsService : IRatingsService
{
readonly IRatingsRepository _ratingsRepository;
...
public RatingsService(
IRatingsRepository ratingsRepository)
{
_ratingsRepository = ratingsRepository;
}
}
最后,由于RatingsService类不包含任何额外的逻辑,这个类将只调用仓库的方法,正如您在这里看到的:
public Task<Result<RatingsSummary>>
LoadRatingsSummary(string recipeId)
=> _ratingsRepository.GetRatingsSummary(recipeId);
public Task<<Result<IReadOnlyCollection<Rating>>>
LoadRatings(string recipeId)
=> _ratingsRepository.GetRatings(recipeId);
这就剩下最后一个需要更新的服务:FavoritesService。与之前我们一直在更新的服务不同,FavoritesService确实包含一些额外的逻辑。但让我们首先看看IFavoritesRepository接口的样子:
public interface IFavoritesRepository
{
Task<Result<IReadonlyCollection<string>>>
LoadFavorites(string userId);
Task<Result<Nothing>> Add(string userId, string id);
Task<Result<Nothing>> Remove(string userId, string id);
}
此接口定义了三个方法:LoadFavorites、Add和Remove。由于我们的收藏夹存储在集中式服务器上,因此将用户的标识符(或userId)传递给 API 是必不可少的。这确保了获取、添加或删除的收藏夹是针对该用户的。Add方法或Remove方法没有固有的返回值。为了与我们的其他 API 保持一致,我们希望返回一个包裹在Result对象中的值。这就是为什么返回一个自定义的Nothing类型的原因。正如您在这里看到的,这只是一个空的 struct:
public struct Nothing
{
}
让我们更新FavoritesService,使其利用IFavoritesRepository接口:
将IFavoritesRepository接口添加到Features/Favorites文件夹。
更新 FavoritesService,使其构造函数接受一个类型为 IFavoritesRepository 的参数:
public class FavoritesService : IFavoritesService
{
readonly IFavoritesRepository
_favoritesRepository;
...
public FavoritesService(
IFavoritesRepository favoritesRepository)
{
_favoritesRepository = favoritesRepository;
}
}
FavoritesService 在内存中保存用户喜欢的列表。这个内存中的列表可以很容易地在 IsFavorite 方法中使用,以快速检查给定的 recipeId 是否存在于列表中。以下是我们在内存中加载此列表的方法:
List<string> favorites = null;
private async ValueTask LoadList()
{
if (favorites is null)
{
var loadResult = await _favoritesRepository
.LoadFavorites(GetCurrentUserId());
if (loadResult.IsSuccess)
{
favorites = loadResult.Data.ToList();
}
}
}
//Dummy implementation,
//could be retrieved via injected service
private string GetCurrentUserId()
=> "3";
当喜欢列表为 null 时,LoadList 方法会在 IFavoritesRepository 上调用 LoadFavorites 方法。这个“虚拟”的 GetCurrentUserId 方法为应用中给定的用户提供一个假的标识符。在实际场景中,这可以从注入的服务中检索。
如前所述,这个内存中的列表有助于实现 IsFavorite 方法,如下一个代码块所示:
public async Task<bool> IsFavorite(string id)
{
await LoadList();
return favorites is not null
&& favorites.Contains(id);
}
此方法调用 LoadList 方法,如果内存中的列表为 null,则从 API 获取喜欢的项目。当喜欢的项目被加载后,我们可以检查列表是否包含给定的 ID。
与我们之前讨论的早期服务不同,其中每个方法只是调用了注入的仓库中相应的方法,这里由于内存中列表的存在,事情稍微复杂一些。此外,由于 Add 和 Remove 方法都发送一个 FavoriteUpdateMessage 实例,它们在实现上需要一些额外的逻辑。以下是它是如何完成的:
在 FavoritesService 的 Add 方法中要做的第一件事是调用仓库的 Add 方法,传入(假的)userId 值,如下所示:
public async Task<Result<Nothing>> Add(string id)
{
var result = await _favoritesRepository
.Add(GetCurrentUserId(), id);
}
IFavoritesRepository 的 Add 方法返回一个包裹在 Result 对象中的 Nothing 对象。多亏了 Result 对象,我们可以检查 API 调用是否成功。如果是这样,我们将喜欢的菜谱的 ID 添加到内存中的喜欢列表中,并发送 FavoriteUpdateMessage,如下所示:
if (result.IsSuccess)
{
if (favorites is not null
&& !favorites.Contains(id))
favorites.Add(id);
WeakReferenceMessenger.Default
.Send(new FavoriteUpdateMessage(id, true));
}
return result;
Remove 方法非常相似:
public async Task<Result<Nothing>> Remove(string id)
{
var result = await _favoritesRepository
.Remove(GetCurrentUserId(), id);
if (result.IsSuccess)
{
if (favorites is not null
&& favorites.Contains(id))
favorites.Remove(id);
WeakReferenceMessenger.Default
.Send(
new FavoriteUpdateMessage(id, false));
}
return result;
}
在所有代码就绪后,是时候为这些仓库添加实现,并确保它们在 DI 容器中注册。
添加和注册仓库实现
我们可以放置仓库接口实现的专用项目中。由于这些仓库将与我们自己的 API 进行通信,我倾向于使用 ApiGateway 作为命名。我个人认为这个名字完美地说明了其功能。在 Recipes.Client.Repositories 项目中,我们可以添加三个类:FavoritesApiGateway、RatingsApiGateway 和 RecipeApiGateway。这些类应分别实现 IFavoritesRepository、IRatingsRepository 和 IRecipeRepository 接口。在下一节中,我们将讨论如何使用 Refit 有效地激活 API 通信。
现在,让我们将注意力转向将这些仓库注册到 DI 容器中。我们不会在 MauiProgram 类中处理每个注册,而是将这项任务完全委托给 Recipes.Client.Repositories 项目的代码:
将 Microsoft.Extensions.DependencyInjection.Abstractions NuGet 包添加到 Recipes.Client.Repositories 项目中。
在 Recipes.Client.Repositories 项目中,添加一个 ServiceCollectionExtension 类。以下是这个静态类的样子:
public static class ServiceCollectionExtension
{
public static IServiceCollection
RegisterRepositories(
this IServiceCollection services)
{
services.AddTransient<IRatingsRepository,
RatingsApiGateway>();
services.AddTransient<IRecipeRepository,
RecipeApiGateway>();
services.AddTransient<IFavoritesRepository,
FavoritesApiGateway>();
return services;
}
}
这个类包含一个方法:RegisterRepositories。这是一个扩展方法,它扩展了 IServiceCollection 接口。要使用 IServiceCollection,请确保你已经包含了 Microsoft.Extensions.DependencyInjection 命名空间,这是我们在第一步中添加的 NuGet 包的一部分。这个方法完全是关于注册仓库的。通过在方法结束时返回 services 实例,我们可以采用构建器模式,从而允许链式调用额外的扩展方法。
现在,我们可以转到 MauiProgram.cs 文件,并在 CreateMauiApp 方法中添加以下内容:
builder.Services.RegisterRepositories();
RegisterRepositories 扩展方法只能在添加了 Recipes.Client.Repositories 命名空间时才能解析。
在所有这些准备就绪之后,我们应用程序的服务现在依赖于最终将与应用程序 API 通信的仓库。这些仓库接口的实现及其注册是在专门的 Recipes.Client.Repositories 项目中完成的。这保持了所有内容的组织和模块化,并确保了关注点的清晰分离,使我们的代码库更容易维护。
尽管我们现在已经注册了仓库,但它们仍然缺乏与我们的 API 的通信。此外,我们还注册了 RatingsApiGateway、RecipeApiGateway 和 FavoritesApiGateway 类,这些类目前还不存在。让我们看看我们如何添加这些类,并利用 Refit 来进行 API 请求并接收强类型响应,这使得处理错误和解析数据变得更加容易。
使用 Refit 进行 API 通信
到目前为止,我们已经为我们的仓库设置了一个整洁的架构,但它们仍然缺少与我们的 API 通信的能力。为了添加这个功能,我们可以手动使用 HttpClient 来进行 API 调用并反序列化响应。虽然这是完全可能的,但它也很繁琐且容易出错,更不用说它需要大量的样板代码才能正确实现。
这就是 Refit 发挥作用的地方。Refit 是一个强大的库,通过提供更声明式和更少错误的方法来简化 API 调用。你不需要编写繁琐的 HTTP 请求和响应,只需定义一个映射到 API 端点的 C# 接口。Refit 会为你处理底层的 HttpClient 调用、序列化和反序列化,让你专注于最重要的部分——你应用程序的逻辑。
在本节中,我们将看到 Refit 如何通过减少代码复杂性和提高可读性来简化我们的工作,同时仍然为更复杂的场景提供定制选项。因此,让我们以智能的方式让我们的仓库与 API 进行通信。
开始使用 Refit
Refit 是一个类型安全的 .NET REST 客户端,允许您通过定义接口轻松地执行 API 调用。您使用 HTTP 属性(如 [Get]、[Post] 等)注释接口方法,指定 API 端点。然后 Refit 能够为您生成实现,将这些接口方法转换为 API 调用。让我们看看一个例子:
首先,我们需要通过声明一个接口来定义我们将与之交互的 API 端点:
public interface IRecipeApi
{
[Get("/recipe/{recipeId}")]
Task<ApiResponse<RecipeDto>>
GetRecipe(string recipeId);
}
在这里,IRecipeApi 接口定义了一个通过其 ID 获取单个菜谱的 API 调用。使用 Refit.Get 属性来定义一个针对特定端点的 HTTP GET 方法。端点中的 {recipeId} 部分指定了一个路径参数,用于将 recipeId 参数传递给方法。当使用菜谱 ID 调用 GetRecipe 方法时,我们希望 Refit 库向指定的端点发送一个 HTTP GET 请求,并将端点的 {recipeId} 部分替换为指定的 ID。
其次,我们使用 RestService.For 生成接口的实现,如下所示:
var api = RestService.For<IRecipeApi>(
"https://api.yourservice.com");
这一行代码创建了一个知道如何调用 IRecipeApi 中定义的 API 调用的对象。RestService.For 方法接受一个字符串参数,用于定义 API 的基本 URL。或者,也可以传递 HttpClient 的实例作为参数,而不是字符串值。Refit 将使用提供的 HttpClient 与 API 进行通信。在章节的后面部分,我们将看到为什么传递 HttpClient 可能是有用的。
最后,我们可以使用生成的对象来执行 API 调用并处理响应,如下面的代码片段所示:
var recipeResponse = await api.GetRecipe("1");
if (recipeResponse.IsSuccessStatusCode)
{
RecipeDto recipe = recipeResponse.Content;
}
Task<RecipeDto> GetRecipe(string recipeId);
这将只返回反序列化的对象。我更喜欢返回 ApiResponse<T> 的方法,因为它提供了关于 API 交互过程中发生情况的更全面的信息,这对于健壮的错误处理和有洞察力的日志记录至关重要。
如此一来,我们就完成了三个简单的步骤,替换了原本可能需要更多样板代码的情况。现在,让我们回到我们的 Recipes! 应用程序,并将这些应用到实践中。
创建 API 接口
让我们在 Recipes.Client.Repositories 项目中添加 API 接口。稍后,我们将与 Refit 一起使用它们来生成与 API 通信所需的代码:
在 Recipes.Client.Repositories 项目中选择 Api。
在新创建的文件夹中添加一个名为 IFavoritesApi 的接口。下面的代码片段显示了该接口的外观:
public interface IFavoritesApi
{
[Get("/users/{userId}/favorites")]
Task<string[]> GetFavorites(string userId);
[Post("/users/{userId}/favorites")]
Task AddFavorite(string userId,
FavoriteDto favorite);
[Delete("/users/{userId}/favorites/{recipeId}")]
Task DeleteFavorite(string userId,
string recipeId);
}
这个接口直接映射到负责管理用户收藏的 API 端点。Get、Post 和 Delete 属性指定了每个 API 调用应使用的 HTTP 方法。出现在 URL 中的参数,如 userId,将自动从方法参数中填充。注意 AddFavorite 方法中的 favorite 参数。这个参数不是定义的端点 URL 的一部分;相反,它被序列化并发送为请求体。或者,也可以通过使用 Body 属性显式地指出需要将收藏参数发送到消息体中。这看起来是这样的:
[Post("/users/{userId}/favorites")]
Task AddFavorite(string userId,
IRatingsApi interface, which looks like this:
public interface IRatingsApi
{
[Get("/recipe/{recipeId}/ratings")]
Task<ApiResponse<RatingDto[]>> GetRatings(
string recipeId);
[Get("/recipe/{recipeId}/ratingssummary")]
Task<ApiResponse>
GetRatingsSummary(string recipeId);
}
Again, these methods and their attributes correspond with the endpoints that allow us to retrieve ratings and a ratings summary for a given recipe ID.
最后,让我们定义 IRecipeApi 接口:
public interface IRecipeApi
{
[Get("/recipe/{recipeId}")]
Task<ApiResponse<RecipeDetailDto>>
GetRecipe(string recipeId);
[Get("/recipes")]
Task<ApiResponse<RecipeOverviewItemsDto>>
GetRecipes(int pageSize = 7, int pageIndex = 0);
}
如您现在可能已经知道的那样,与之前的接口一样,这个接口也映射到某些 API 端点。GetRecipes 方法的 pageSize 和 pageIndex 参数没有出现在 Get 属性的端点中。因此,在执行请求时,它们将作为查询字符串参数添加。
在定义了我们的 API 接口之后,现在是时候弥合我们的存储库和实际 API 调用之间的差距了。
集成 Refit
让我们将 Refit 集成到我们的存储库中,使 API 调用变得轻松。一切从向 Recipes.Client.Repositories 项目添加 Refit NuGet 包开始。为了使集成 Refit 更加容易并避免以后重复代码,让我们首先在 Recipes.Client.Repositories 项目中添加一个新的 ApiGateway 抽象类。这个类的作用是帮助我们执行调用并将 ApiResponse 结果映射到另一个类型。InvokeAndMap 方法的签名如下所示:
protected async Task<Result<TResult>>
InvokeAndMap<TResult, TDtoResult>(
Task<ApiResponse<TDtoResult>> call,
Func<TDtoResult, TResult> mapper)
{
}
这个方法返回一个 Task<TResult> 对象并接受两个参数:
实现相当直接:方法必须执行提供的 call 参数。如果 resulting ApiResponse 实例表示成功,则将使用传入的 mapper 参数将结果从 TDtoResult 映射到 TResult,并将其包装在一个表示成功的 Result 对象中。如果响应不表示成功,则返回一个失败的结果,其中包含响应的状态码。下面的代码块显示了如何实现:
try
{
var response = await call;
if (response.IsSuccessStatusCode)
{
return Result<TResult>
.Success(mapper(response.Content));
}
else
{
return Result<TResult>.Fail("FAILED_REQUEST",
response.Error.StatusCode.ToString());
}
}
...
此外,我们还需要警惕可能抛出的潜在异常,我们可以像下面这样处理:
try
{
...
}
catch (ApiException aex)
{
return Result<TResult>
.Fail("ApiException",
aex.StatusCode.ToString(), aex);
}
catch (Exception ex)
{
return Result<TResult>.Fail(ex);
}
当发生异常时,我们应该返回一个 Result 对象,该对象表示失败,并包含有关异常的相关数据。
关于鲁棒性的说明
在开发移动应用程序时,重要的是要记住网络条件可能是不可预测的。移动设备可能会在不同的网络区域之间移动,导致连接不稳定。作为一个最佳实践,始终考虑实现弹性模式,例如,在ApiGateway类中添加这种重试逻辑而不是直接返回一个Fail结果是完美的位置。更多关于 Polly 的信息请在这里了解:github.com/App-vNext/Polly 。
在 API 接口返回的数据类型与我们希望封装在Result对象中的类型相同的情况下,我们可以提供一个重载的InvokeAndMap方法,从而消除对类型映射器的需求。这在处理原始类型时特别有用。以下代码片段显示了这种重载:
protected Task<Result<T>>
InvokeAndMap<T>(<ApiResponse<T>> call)
=> InvokeAndMap(call, e => e);
这个基类将极大地简化将 API 返回的 DTO 映射到封装在Result对象中的域实体的过程。现在,让我们看看我们如何在我们的仓库中利用这个InvokeAndMap方法:
首先,确保所有我们的仓库(FavoritesApiGateway、RatingsApiGateway和RecipeApiGateway)通过添加以下代码继承这个抽象的ApiGateway类:
internal class FavoritesApiGateway : ApiGateway,
IFavoritesRepository { ... }
internal class RatingsApiGateway : ApiGateway,
IRatingsRepository { ... }
internal class RecipeApiGateway : ApiGateway,
IRecipeRepository { ... }
接下来,这些仓库中的每一个都应该通过其构造函数注入相应的 API 接口。让我们看看RatingsApiGateway的例子:
internal class RatingsApiGateway : ApiGateway,
IRatingsRepository
{
readonly IRatingsApi _api;
...
public RatingsApiGateway(IRatingsApi api)
{
_api = api;
}
}
注入的IRatingsApi接口现在可以用来进行网络调用并从 API 检索所需的数据。以下代码块显示了实现的GetRatings方法:
public Task<Result<IReadOnlyCollection<Rating>>>
GetRatings(string recipeId)
=> InvokeAndMap(
_api.GetRatings(recipeId), MapRatings);
GetRatings方法调用基类的InvokeAndMap方法。将GetRatings API 调用作为参数传递,以及MapRatings方法,它将 API 返回的RatingDto数组映射到Rating对象的IReadOnlyCollection。MapRatings方法是在静态RatingsMapper类上的静态方法。我们可以在类内部直接访问RatingsMapper的静态映射方法,而无需显式地在其名称前添加,因为以下using语句被添加到了类中:
using static Recipes.Client.Repositories.Mappers
.RatingsMapper;
我们现在可以为这个类的GetRatingsSummary方法做完全相同的事情:
public Task<Result<RatingsSummary>>
GetRatingsSummary(string recipeId)
=> InvokeAndMap(_api.GetRatingsSummary(recipeId),
MapRatingSummary);
与前面的示例类似,FavoritesApiGateway类的实现可以遵循相同的模式:注入IFavoritesApi接口并使用其方法从 API 检索数据,利用ApiGateway基类的InvokeAndMap方法:
internal class FavoritesApiGateway : ApiGateway,
IFavoritesRepository
{
readonly IFavoritesApi _api;
public Task<Result<Nothing>> Add(
string userId, string id)
=> InvokeAndMap(_api.AddFavorite(userId,
new FavoriteDto(id)));
public Task<Result<string[]>> LoadFavorites(
string userId)
=> InvokeAndMap(_api.GetFavorites(userId));
public Task<Result<Nothing>> Remove(string userId,
string recipeId)
=> InvokeAndMap(_api.DeleteFavorite(userId,
recipeId));
public FavoritesApiGateway(IFavoritesApi api)
{
_api = api;
}
}
在FavoritesApiGateway中的所有这些方法都使用了重载的InvokeAndMap方法,该方法不执行额外的映射:API 接口返回的数据类型与仓库返回的类型相同,但它被封装在一个Result对象中。
RecipeApiGateway类的实现不应该有任何惊喜;它只包含更多的映射。但就其核心而言,它遵循与前面两个类完全相同的模式。首先,需要添加一个类型为IRecipeApi的字段作为成员和构造函数参数,如下面的代码片段所示:
internal class RecipeApiGateway : ApiGateway,
IRecipeRepository
{
readonly IRecipeApi _api;
...
public RecipeApiGateway(IRecipeApi api)
{
_api = api;
}
}
LoadRecipes方法和它使用的映射看起来是这样的:
public Task<Result<LoadRecipesResponse>>
LoadRecipes(int pageSize, int page)
=> InvokeAndMap(_api.GetRecipes(pageSize, page),
MapRecipesOverview);
LoadRecipes调用InvokeAndMap方法,传入 API 接口的GetRecipes方法。MapRecipesOverview方法用于将类型为RecipesOverviewItemsDto的结果对象映射到LoadRecipesResponse对象。
最后,我们可以实现LoadRecipe方法。它可以使用IRecipeApi的GetRecipe方法来获取数据。结果将使用静态RecipeMapper类的静态MapRecipe方法进行映射。看看这个:
public Task<Result<RecipeDetail>> LoadRecipe(
string id)
=> InvokeAndMap(_api.GetRecipe(id), MapRecipe);
我们还需要更新我们的ServiceCollectionExtension类。因为每个仓库现在都依赖于特定的 API 接口,我们需要确保这些依赖也被注册。但首先,我们可能想要在Recipes.Client.Repositories项目中添加一个新的RepositorySettings类。这个类应该是一种将设置从应用传递到仓库的方式,例如,需要使用特定HttpClient实例的情况。如下面的代码块所示,对于这个特定的演示项目,这个类并没有多少内容。但在更复杂的应用中,可以在这里添加诸如特定的序列化或认证设置等。
public class RepositorySettings
{
public HttpClient HttpClient { get; }
public RepositorySettings(HttpClient httpClient)
{
HttpClient = httpClient;
}
}
让我们把这些拼图的最后几块拼在一起,更新ServiceCollectionExtension,并在我们的应用中调用它。以下是我们需要做的:
将前面提到的RepositorySettings类添加到Recipes.Client.Repositories项目中。
在ServiceCollectionExtension类的RegisterRepositories扩展方法中添加一个类型为RepositorySettings的参数,如下所示:
public static class ServiceCollectionExtension
{
public static IServiceCollection
RegisterRepositories (
this IServiceCollection services,
RepositorySettings settings)
{
...
return services;
}
}
正如我们之前看到的,Refit 的RestService.For方法可以用来生成 API 接口的实现。以下代码片段展示了我们如何将生成的实现注册到 DI 容器中:
services.AddSingleton((s) =>
RestService.For<IRatingsApi>(settings.HttpClient));
services.AddSingleton((s) =>
RestService.For<IRecipeApi>(settings.HttpClient));
services.AddSingleton((s) =>
RestService.For<IFavoritesApi>(settings.HttpClient));
RestService.For方法接受一个HttpClient实例,该实例将由 Refit 用于 API 通信。HttpClient应该在应用中进行配置并通过RepositorySettings传入。
最后,在MauiProgram类中,我们可以调用更新的RegisterRepositories并传入一个配置好的HttpClient,如下所示:
var baseAddress = DeviceInfo.Platform ==
DevicePlatform.Android
? "https://10.0.2.2:7220"
: "https://localhost:7220";
var httpClient = HttpClientHelper
.GetPlatformHttpClient(baseAddress);
builder.Services.RegisterRepositories(
new RepositorySettings(httpClient));
由于 Android 模拟器在虚拟路由器后的隔离网络环境中运行,它不能直接使用localhost访问开发机器。相反,模拟器提供了一个特殊的10.0.2.2别名,该别名路由到开发机器的环回接口,使您能够访问本地网络服务。
Recipes.Mobile 项目包含一个 HttpClientHelper 类,该类配置一个 HttpClient 实例以用于连接到本地网络服务。这是必要的,因为需要对每个平台进行一些特定的配置,以便有效地与本地托管的服务进行通信。
连接到本地网络服务
在开发软件时,通常需要在 Android 模拟器或 iOS 模拟器中运行本地网络服务,并使用应用访问它。为了使应用能够与你的本地网络服务通信,需要一些额外的配置。有关连接到本地网络服务以及如何配置每个平台的更多信息,请参阅此处:learn.microsoft.com/dotnet/maui/data-cloud/local-web-services 。
如果你想要调试 Recipes! 应用,我们需要告诉 Visual Studio 同时运行移动应用和 API。为此,在 Solution Explorer 中右键单击 Solution ‘Recipes App’ 并选择 Properties 。在弹出的窗口中,选择 Multiple startup projects 并将 Recipes.Mobile 和 Recipes.Web.Api 项目的 Action 设置为 Start 。
在我们结束本章之前,让我们简要地关注一下我们如何从 ViewModels 调用 API、处理加载指示器以及处理潜在的错误,同时提供无缝的用户体验。
从 ViewModels 发送的 API 通信
当导航到 RecipeDetailPage 时,你会看到屏幕上显示一些数据,同时正在加载数据。显示的数据是绑定语句中定义的 FallbackValue 或 TargetNullValue 的值,这是由于 RecipeDetailViewModel 中的数据尚未加载。虽然有效,但我认为它看起来不太美观。让我们看看我们如何在数据加载时显示加载指示器来改进这一点。
显示加载指示器
提高用户体验的最简单有效的方法之一是在 API 调用期间提供视觉反馈。考虑以下代码片段:
private bool _isLoading = true;
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
private async Task LoadRecipe(string recipeId)
{
IsLoading = true;
...
await Task.WhenAll(loadRecipeTask,
loadIsFavoriteTask, loadRatingsTask);
...
IsLoading = false;
}
在这里,我们在开始加载数据之前将 RecipeDetailViewModel 的 IsLoading 属性设置为 true,并在之后将其还原为 false。多亏了 async/await 的使用,在此操作期间 UI 线程不会被阻塞,允许更新和渲染 UI 元素,如加载指示器。
你可以将此属性绑定到 XAML 中的加载旋转器,如下所示:
<Grid>
<ScrollView>
...
</ScrollView>
<Grid
BackgroundColor="GhostWhite"
IsVisible="{Binding IsLoading}">
<ActivityIndicator
HorizontalOptions="Center"
IsRunning="{Binding IsLoading}"
VerticalOptions="Center" />
</Grid>
</Grid>
由于这种设置,每当 API 调用——或者任何长时间运行的过程——正在进行时,用户会收到一个视觉提示,表明应用当前正忙。在上一个示例中,我们使用了 Grid 属性,它覆盖了整个页面并包含一个 ActivityIndicator。然而,你并不局限于这种方法;你也可以使用消息、动画或任何其他最适合你应用设计和用户体验的 UI 元素。
使用一个属性来指示任务正在进行中,并将此属性绑定到 UI,是管理长时间运行操作的一种常见的 MVVM 模式。响应性是通过async/await构造实现的,它保持 UI 线程不被阻塞,从而提供更流畅的用户体验。
在本章的早期部分,我们已经看到了Result对象如何帮助我们优雅地处理成功和失败的结果。现在,让我们更进一步,通过处理表示失败的Result对象来增强用户体验。
处理失败
在本章的早期部分,我们介绍了Result对象作为优雅处理失败的一种方式。对象的IsSuccess属性指示操作是否成功完成。我们已经看到,在成功的情况下,Data属性为我们提供了访问结果的方式。然而,当IsSuccess为false时,至关重要的是不要让用户猜测。相反,我们应该提供清晰的反馈并提供解决问题的方法。让我们看看这是如何在RecipesOverviewViewModel的LoadRecipes方法中解决的:
private async Task LoadRecipes(int pageSize, int page)
{
LoadFailed = false;
var loadRecipesTask =
recipeService.LoadRecipes(pageSize, page);
...
if(recipesResult.IsSuccess)
{
//Set TotalNumberOfRecipes property
//Fill Recipes collection
...
}
else
{
LoadFailed = true;
ReloadCommand =
new AsyncRelayCommand(
() => LoadRecipes(pageSize, page));
}
}
如您所见,当任务未成功时,LoadFailed属性被设置为true。此外,ReloadCommand属性被初始化。下面的代码块展示了这两个属性如何与 UI 数据绑定,并将为用户提供一些信息和重新加载食谱列表的能力:
<Grid>
<CollectionView>
...
</CollectionView>
<Grid
BackgroundColor="{StaticResource Primary}"
HorizontalOptions="Fill"
IsVisible="{Binding LoadFailed}"
VerticalOptions="Fill">
<VerticalStackLayout
HorizontalOptions="Center"
VerticalOptions="Center">
<Label Text="Unable to load recipes" />
<Button Command="{Binding ReloadCommand}"
Text="Retry" />
</VerticalStackLayout>
</Grid>
</Grid>
此外,当发生特定失败时,我们还可以向用户显示自定义错误消息。Result对象的ErrorMessage、ErrorCode和Exception属性可以用于此目的。这种方法消除了在 ViewModel 中散布各种异常处理块的需要,从而创建了一个更流畅、可读性和可维护性更强的代码库。
摘要
我们通过回顾我们的Recipes! 应用架构,并包含存储库来开启本章。这次增加是为了遵循 SoC 原则并最小化我们的应用对 API 本身的依赖。我们还引入了Result对象,这是我们错误处理策略中的一个变革性组件。这个单一的对象封装了成功和失败状态,使我们的 ViewModel 更加健壮和易于理解。通过使用Result对象,我们使优雅地处理预期错误变得更加容易,同时仍然保留异常用于关键性故障。
在建立这个架构基础之后,我们继续探索 Refit 的强大和简单性,Refit 是一个类型安全的 REST 客户端。Refit 极大地简化了我们与 API 交互的方式,使代码更易于阅读和维护。
我们还探讨了 Result 对象如何优雅地融入我们的 ViewModels,这使得处理预期错误变得更容易,并提供了统一的方法来管理错误。与此同时,我们讨论了实用的 UI 考虑因素,包括加载指示器和错误覆盖层,以提供吸引用户并富有信息性的体验。
你现在应该已经对如何进行 API 调用、管理响应以及以弹性和易于维护的方式提供实时用户反馈有了稳固的理解。
在下一章中,我们将探讨如何创建适合 MVVM 的控件。
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
第三部分:掌握 MVVM 开发
本部分主要介绍如何精炼和加固你的 .NET MAUI 应用。我们将深入探讨如何制作与 MVVM 无缝集成的控件。你将学习如何通过本地化技术扩展你应用的范围,并通过强大的单元测试加固你的代码库,确保稳定性和可靠性。当事情没有按计划进行时,你将能够依赖有价值的故障排除和调试技巧来重新回到正确的轨道。
本部分包含以下章节:
第十一章 ,创建适合 MVVM 的控件
第十二章 ,使用 MVVM 进行本地化
第十三章 ,单元测试
第十四章 ,故障排除和调试技巧
第十一章:创建 MVVM 友好的控件
到目前为止,我们已经浏览了 MVVM 和.NET MAUI 的广泛主题——从基本设计模式到数据绑定、导航,甚至处理远程数据。现在,让我们深入探讨另一个重要主题——自定义控件 。
有时,内置控件可能无法满足特定的用户界面要求或独特的设计元素。因此,你需要通过将单个 UI 元素组合成一个更有效的单元来创建自己的控件。使这些元素在应用程序中成为 MVVM 友好和可重复使用的是我们的目标。
在本章中,我们将关注以下内容:
使用可绑定属性构建自定义控件
在自定义控件上启用交互
与控件模板一起工作
到本章结束时,你将知道如何创建既美观又易于在 MVVM 设置中管理的自定义控件。准备好深入研究了?
技术要求
我们将继续通过深入研究自定义控件和控制模板来增强食谱 应用,所有这些都是在考虑 MVVM 架构的情况下设计的。为了跟随代码,请确保访问 GitHub 仓库github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter11 。Start文件夹包含开始本章所需的初始代码设置,而Finish文件夹包含供您参考的完成代码。
使用可绑定属性构建自定义控件
构建功能丰富的应用程序通常需要不仅仅是标准的 UI 控件集。当你有多个部分中一起出现的控件组合——例如,一个带有验证错误列表的输入字段或总是与活动指示器一起使用的按钮——将这些组合成自定义控件是有意义的。这些自定义的可重复使用元素不仅使代码库更易于维护,而且当它们可绑定时,与 MVVM 架构完美契合。
本节的重点不仅是创建自定义控件,还要使它们“可绑定”,无缝地集成到我们的 MVVM 架构中。通过制作可绑定的控件,你可以实现与 ViewModel 的简单通信。这是确保 UI 始终与应用程序的数据和逻辑保持同步的关键。
那么,我们如何实现这一点?
在接下来的几页中,我们将看到如何将现有控件组合成自定义的可绑定控件。我们还将讨论如何向控件添加自定义属性和行为,确保它们可以无缝地融入基于 MVVM 的应用程序架构。
注意
虽然我们将在本章的整个过程中使用 XAML 创建自定义控件,但重要的是要注意,我们所做的所有事情也可以完全通过代码完成,如果需要的话。
当我们深入创建具有数据绑定功能的自定义控件时,重要的是要回忆起我们在 第三章 ,在 .NET MAUI 中的数据绑定构建块 中讨论的绑定目标的概念。在 .NET MAUI 中,绑定目标通常是 UI 元素上的 BindableProperty 或另一个 BindableObject。为了使我们的自定义控件作为有效的绑定目标,它们需要从 BindableObject 继承。此外,我们打算绑定的任何属性都必须是 BindableProperty 类型。这确保了我们的自定义控件将无缝集成到 MVVM 数据绑定架构中。
实现 FavoriteControl
在我们的 Recipes! 应用中,显示一个菜谱是否被标记为收藏是一个常见的主题。该“收藏”图标出现在我们应用的各个部分。每次这个图标出现时,它的行为都是相同的——当菜谱被标记为收藏时改变颜色。为了避免在各个地方重复相同的代码,我们可以将这种模式封装成一个可重用的 FavoriteControl。通过这样做,我们使代码更易于维护,并为未来的改进——例如添加更多手势或动画——铺平了道路,而无需修改代码库的多个部分。
让我们继续看看创建我们的 FavoriteControl 所需的步骤:
在 Recipes.Mobile 项目的 Controls 文件夹中,选择 添加 | 新建项… 。
将新项目的名称选为 FavoriteControl.xaml。点击 添加 。
图 11.1:从对话框中选择 ContentView
按照这些步骤,将创建两个新的文件——FavoriteControl.xaml 和其代码后文件 FavoriteControl.xaml.cs,如图 11.2 所示:
图 11.2:组成 FavoriteControl 的文件
生成的 FavoriteControl 继承自 ContentView 类,而 ContentView 本身是 BindableObject 的子类。这使得 FavoriteControl 能够定义 BindableProperties,这对于使控件可绑定至关重要。说到这里,FavoriteControl 应该包含一个名为 IsFavoriteProperty 的 BindableProperty 和一个类型为 bool 的 IsFavorite 属性。以下代码片段显示了它的样子:
public static readonly BindableProperty IsFavoriteProperty
=
BindableProperty.Create(nameof(IsFavorite),
typeof(bool), typeof(FavoriteControl));
public bool IsFavorite
{
get { return (bool)GetValue(IsFavoriteProperty); }
set { SetValue(IsFavoriteProperty, value); }
}
定义 BindableProperty 可能一开始会让人感到困惑或不清楚。如果这个概念仍然模糊,请参阅 第三章 ,在 .NET MAUI 中的数据绑定构建块 ,它对此进行了更深入的介绍。
在此基础上,我们可以深入 XAML 并开始工作于这个 FavoriteControl 的视觉层。
自定义控件中的数据绑定
在构建自定义控件时,建议设计它,使其不依赖于其BindingContext,这是从其父页面或控件继承而来的。自定义控件不应依赖于其BindingContext,而应该是自包含的,并直接与其自己的可绑定属性交互。这种方法使您的控件更加模块化和可重用,使其摆脱对任何特定 ViewModel 或数据源的依赖。元素绑定或相对绑定是实现这种独立性的关键。然而,值得注意的是,在控件中使用数据绑定不是强制性的。在控件的后台代码中程序化处理所有逻辑和值赋值也是一个可靠的方法。这主要取决于个人偏好。
让我们看看如何定义控件的外观,以及我们如何有效地绑定到其IsFavorite属性:
打开FavoriteControl.xaml文件,并添加以下local和toolkit XML 命名空间以及x:Name属性:
<ContentView
x:Class="Recipes.Mobile.Controls.FavoriteControl"
xmlns="http://schemas.microsoft.com/dotnet/
2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/
2009/xaml"
xmlns:local="clr-namespace:
Recipes.Mobile.Controls"
xmlns:toolkit="http://schemas.microsoft.com/
dotnet/2022/maui/toolkit"
toolkit namespace in place, which refers to the .NET MAUI Community Toolkit, we can add an instance of BoolToObjectConverter to the control’s Resources. This converter can be copied over from RecipesOverviewPage or RecipeDetailPage. The next code block shows what it looks like:
<ContentView.Resources>
<toolkit:BoolToObjectConverter
x:Key="isFavoriteToColorConverter"
x:TypeArguments="Color"
FalseObject="#E9E9E9E9"
TrueObject="#FF0000" />
</ContentView.Resources>
As a reminder, this converter (as it is configured here) will convert a `bool` value into a color – `false` will become a color with the hex value of `"#E9E9E9E9"` while `true` will be converted to `"#FF0000"`.
接下来,我们可以添加Image控件来显示"favorite.png"图标。我们还想让这个 Image 控件与为FavoriteControl指定的尺寸相匹配。以下是设置此控件的步骤:
<Image
x:Name="icon"
HeightRequest="{Binding HeightRequest,
Source={RelativeSource AncestorType={x:Type
local:FavoriteControl}}}"
Source="favorite.png"
WidthRequest="{Binding WidthRequest, Source=
{RelativeSource AncestorType={x:Type
local:FavoriteControl}}}">
HeightRequest and WidthRequest properties of the Image control to those of FavoriteControl. By doing so, we ensure that the image scales according to the dimensions defined for FavoriteControl.
最后,为了根据收藏状态控制Image的色调,我们使用IconTintColorBehavior,如下所示:
<Image.Behaviors>
<toolkit:IconTintColorBehavior
TintColor="{Binding IsFavorite,
Source={x:Reference root},
Converter={StaticResource
isFavoriteToColorConverter}}" />
</Image.Behaviors>
这个设置与我们在RecipesOverviewPage和RecipeDetailPage上使用的设置非常相似。关键区别在于绑定的来源。在这里,我们直接绑定到我们刚刚创建的IsFavorite属性。我们通过元素绑定(通过x:Reference root)实现这一点,它引用回FavoriteControl本身。这是必要的,因为相对绑定在这里不适用。与其它 UI 元素不同,行为不是视觉树的一部分,因此它们不能像其他元素那样执行相对绑定或查找祖先。
完成这些步骤后,我们已经成功创建了第一个自定义控件FavoriteControl。现在,我们可以继续用这个自定义控件替换现有的图像和IconTintColorBehavior设置,这些设置用于在RecipesOverviewPage和RecipeDetailPage上指示收藏的菜谱。让我们按以下步骤进行:
前往RecipesOverviewPage并添加一个xml命名空间,引用包含新创建的FavoriteControl的命名空间,如下所示:
指向.NET MAUI 社区工具包的 XML 命名空间(xmlns:toolkit)可以删除,因为我们不会在这个页面上再使用其任何功能。现在,这些功能都封装在我们的自定义控件中。
删除Image UI 元素及其相关行为,这些行为直到现在一直作为菜谱的收藏指示器。
用我们刚刚创建的FavoriteControl替换删除的Image。以下是这样做的方法:
<controls:FavoriteControl
Margin="5"
HeightRequest="45"
HorizontalOptions="End"
IsFavorite="{Binding IsFavorite}"
IsVisible="{Binding IsFavorite}"
VerticalOptions="Start"
WidthRequest="45" />
布局属性,如边距、大小、可见性和对齐选项,与删除的Image保持不变。同时,观察我们如何轻松地将FavoriteControl的IsFavorite属性绑定到页面BindingContext上的相应IsFavorite属性。
对于RecipeDetailPage,我们可以采取类似的方法。包含一个指向Recipes.Mobile.Controls的 XML 命名空间,并用新创建的FavoriteControl替换之前表示是否为收藏的Image。这就是结果的样子:
<ContentPage
x:Class="Recipes.Mobile.RecipeDetailPage"
...>
...
<Grid ColumnDefinitions="*, Auto">
<Label FontAttributes="Bold" FontSize="22"
Text="{Binding Path=Title, Mode=OneWay}"
VerticalOptions="Center" />
**<controls:FavoriteControl Grid.Column="1"**
** Margin="5" HeightRequest="35"**
** IsFavorite="{Binding IsFavorite}"**
** VerticalOptions="Center" WidthRequest="35" />**
</Grid>
...
</ContentPage>
通过将收藏指示器合并成一个单一的可重用FavoriteControl,我们实现了多个目标。首先,我们集中了代码,使其更容易管理和更新。其次,这个控件现在可以一致地应用于应用的不同页面,确保了统一的用户体验。最后,通过这样做,我们还提高了代码库的可读性和可维护性 。图 11.3*展示了应用不同页面上的FavoriteControl。尽管对用户来说没有明显的变化,但代码及其可维护性从这种可重用控件中获得了巨大的收益。
图 11.3:不同页面上的 FavoriteControl
让我们看看我们如何进一步改进这个控件,使应用对用户更具吸引力。
状态变化动画
作为对状态变化做出快速反应的一个例子,让我们来看看如何在IsFavorite属性变化时给FavoriteControl添加一个微妙的动画。我们将使用IsFavoriteProperty的propertyChanged委托方法来触发这个动画。让我们深入探讨吧!
通过添加一个propertyChanged委托,修改IsFavoriteProperty,如下所示:
public static readonly BindableProperty
IsFavoriteProperty =
BindableProperty.Create(nameof(IsFavorite),
typeof(bool),
typeof(FavoriteControl),
propertyChanged: OnIsFavoriteChanged);
private static void OnIsFavoriteChanged(BindableObject
bindable, object oldValue, object newValue)
{
}
经过这次修改,每当IsFavorite属性的值发生变化时,静态的OnIsFavoriteChanged方法将被调用。传入的BindableObject是设置BindableProperty的实例。在这种情况下,它将是一个FavoriteControl的实例。oldValue和newValue参数是自解释的,因为它们分别提供了属性的旧值和新值。
接下来,让我们添加当控件状态变化时要播放的动画。以下是添加动画的方法:
private async Task AnimateChange()
{
await icon.ScaleTo(1.5, 100);
await icon.ScaleTo(1, 100);
}
AnimateChange方法将在 100 毫秒内将持有图标的Image缩放到 1.5 倍大小。之后,它将在相同的时间内再次缩放到原始大小。
最后,我们需要从静态的OnIsFavoriteChanged方法中调用这个方法。以下代码块展示了如何实现这一点:
private static void OnIsFavoriteChanged(
BindableObject bindable,
object oldValue, object newValue)
=> (bindable as FavoriteControl).AnimateChange();
可绑定参数可以安全地转换为FavoriteControl,这样我们就可以调用AnimateChange方法,触发动画。
运行应用程序以查看实际变化!每次将食谱切换为收藏时,你会在RecipeDetailPage上观察到细微的动画。引入这个自定义控件带来了应用程序中统一动画的便利。对自定义控件内动画的任何修改都会自动反映到其使用的每个地方。如果没有这样的控件,我们就需要手动更新应用程序中每个实例的动画。忽略任何一个实例都可能导致行为不一致。因此,自定义控件确保了一致性和维护的简化。
注意
这个例子强调了重要的一点——即使你完全致力于使用 MVVM 模式,也仍然会有一些情况下在代码后编写代码不仅是可以接受的,而且是必要的。这在创建自定义控件时尤其如此,使用代码后不仅不可避免,而且非常合适。
接下来,让我们探讨如何通过利用命令来添加自定义控件的交互,从而进一步与 MVVM 概念保持一致。
在自定义控件上启用交互
在现实世界的应用中,控件通常扮演双重角色——它们既显示数据,也允许用户与之交互。在本节中,我们将进一步增强我们的FavoriteControl,使其不仅支持通过IsFavoriteChangedCommand进行用户交互,而且还要便于双向数据绑定。这些特性将使控件更具交互性,并进一步与 MVVM 概念保持一致。我们希望允许用户点击FavoriteControl上的Image。当FavoriteControl的IsEnabled属性设置为true时,IsFavorite属性将被更新,并且IsFavoriteChangedCommand将被执行。
让我们来看看这个功能的第一个部分——当用户点击图片时更新IsFavorite属性,并确保绑定到这个属性的值也得到更新。
用户操作和状态反映
首先,让我们添加用户通过点击控件来切换收藏状态的能力。然后,这个用户操作将更新控件的IsFavorite属性,反过来,它将反映回更新绑定的属性。以下步骤展示了如何实现这一点:
如下所示,将GestureRecognizer添加到FavoriteControl的ContentView中:
<ContentView.GestureRecognizers>
<TapGestureRecognizer
Tapped="TapGestureRecognizer_Tapped" />
</ContentView.GestureRecognizers>
GestureRecognizer允许你在 UI 元素上处理用户交互事件,例如点击、捏合和滑动。通过向ContentView添加TapGestureRecognizer,我们有效地指示应用程序监听此控件上的点击事件。
下面是代码后部的TapGestureRecognizer_Tapped事件处理程序:
private void TapGestureRecognizer_Tapped(
object sender, TappedEventArgs e)
{
}
当用户点击FavoriteControl时,将触发此方法。
现在,让我们在TapGestureRecognizer_Tapped方法中实现更新IsFavorite属性的逻辑:
private void TapGestureRecognizer_Tapped(
object sender, TappedEventArgs e)
{
if (IsEnabled)
{
IsFavorite = !IsFavorite;
}
}
注意我们首先检查 IsEnabled 属性,然后再更新 IsFavorite。对于自定义控件来说,与开发者的期望保持一致的行为至关重要。在这种情况下,将 VisualElement 的 IsEnabled 属性设置为 false 应该会禁用控件。因此,我们在切换 IsFavorite 的值之前会检查 IsEnabled 属性。
如果我们现在运行应用程序并导航到 RecipeDetailPage,触摸 FavoriteControl 应该会更新其状态。我们之前定义的动画也会作为对 IsFavorite 属性更新的反应播放。然而,状态变化并没有反映在 ViewModel 上。你可能想知道为什么会出现这种情况。这是因为 IsFavoriteProperty 具有默认的绑定模式 OneWay。这就是为什么更新后的值不会从控件流向 ViewModel。这很容易调整——将 IsFavoriteProperty 的默认绑定模式更改为 TwoWay,或者更新 RecipeDetailPage 上的绑定语句并显式将其设置为 TwoWay。以下是第一种方法——更新默认绑定模式——的示例:
public static readonly BindableProperty
IsFavoriteProperty =
BindableProperty.Create(nameof(IsFavorite),
typeof(bool),
typeof(FavoriteControl),
defaultBindingMode: BindingMode.TwoWay,
propertyChanged: OnIsFavoriteChanged);
或者,我们可以保留默认的绑定模式为 OneWay,并在 RecipeDetailPage 上更新绑定语句,如下所示:
<controls:FavoriteControl
Grid.Column="1"
Margin="5"
HeightRequest="35"
IsFavorite="{Binding IsFavorite, Mode=TwoWay}"
VerticalOptions="Center"
WidthRequest="35" />
无论哪种方式,FavoriteControl 上的 IsFavorite 属性现在将反映其在 ViewModel 中的状态。我们如何验证这一点呢?当然是通过在代码中添加断点,但也可以通过简单地触摸控件——注意 ViewModel 中 IsFavorite 属性的可见性。
注意
在定义可绑定属性时,选择与控件的主要预期行为或最常用的绑定模式最一致的 defaultBindingMode 是至关重要的。在需要不同行为的不常见情况下,开发者可以通过在绑定语句中指定不同的绑定模式来覆盖默认设置。
IsFavorite 属性不会导致底层模型更新。让我们深入了解如何将命令集成到我们的 FavoriteControl 中,使其更加灵活。
添加基于命令的交互
让我们进一步增强我们的 FavoriteControl,通过公开一个命令属性——ToggledCommand。当通过在控件上轻触手势切换 IsFavorite 属性时,将调用此命令。为了使其更加健壮,该命令将发送更新的 IsFavorite 布尔值作为参数。
将基于命令的交互添加到自定义控件中非常简单,以下步骤展示了如何操作:
所有操作都从添加一个 BindableProperty 和一个类型为 ICommand 的属性开始。此代码块展示了如何将 ToggledCommand 属性及其对应的 BindableProperty 添加到我们的 FavoriteControl 中:
public static readonly BindableProperty
ToggledCommandProperty =
BindableProperty.Create(
nameof(ToggledCommand),
typeof(ICommand), typeof(FavoriteControl));
public ICommand ToggledCommand
{
get => (ICommand)
GetValue(ToggledCommandProperty);
set => SetValue(ToggledCommandProperty, value);
}
接下来,我们可以在用户触摸控件并更新 IsFavorite 属性时调用命令的 Execute 方法。以下是更新后的 TapGestureRecognizer_Tapped 方法:
private void TapGestureRecognizer_Tapped(
object sender, TappedEventArgs e)
{
if (IsEnabled)
{
IsFavorite = !IsFavorite;
ToggledCommand?.Execute(IsFavorite);
}
}
注意,我们使用空条件运算符(?)来防止在ToggledCommand为 null 时出现NullReferenceException。
下面的代码块显示了我们可以添加到RecipeDetailViewModel中的FavoriteToggledCommand,并将其绑定到FavoriteControl的ToggledCommand:
...
public IRelayCommand FavoriteToggledCommand { get; }
...
public RecipeDetailViewModel(...)
{
...
FavoriteToggledCommand =
new AsyncRelayCommand<bool>(FavoriteToggled);
...
}
...
接下来,让我们添加FavoriteToggled方法,这个方法由FavoriteToggledCommand调用。下面是这个方法的示例:
private async Task FavoriteToggled(bool isFavorite)
{
if(isFavorite)
{
await favoritesService.Add(recipeDto.Id);
}
else
{
await favoritesService.Remove(recipeDto.Id);
}
}
FavoriteControl的ToggledCommand将更新的isFavorite值作为参数发送。我们可以使用这个参数来决定在favoritesService上调用哪个方法。
在此基础上,我们还可以快速重构现有的AddAsFavorite和RemoveAsFavorite方法,以避免代码重复,如下所示:
private Task AddAsFavorite()
=> UpdateIsFavorite(true);
private Task RemoveAsFavorite()
=> UpdateIsFavorite(false);
private Task UpdateIsFavorite(bool newValue)
{
IsFavorite = newValue;
return FavoriteToggled(newValue);
}
添加了UpdateIsFavorite方法。这个方法接受IsFavorite属性应该获取的新值。它将这个值赋给属性,并调用我们刚刚引入的FavoriteToggled方法。从AddAsFavorite和RemoveAsFavorite方法中,现在可以调用这个新的UpdateIsFavorite方法,只需传递一个bool值。
剩下的唯一事情是将RecipeDetailViewModel的FavoriteToggledCommand绑定到RecipeDetailPage上FavoriteControl的ToggledCommand属性。下面是如何做的:
<controls:FavoriteControl
Grid.Column="1"
Margin="5"
HeightRequest="35"
IsFavorite="{Binding IsFavorite, Mode=TwoWay}"
ToggledCommand="{Binding FavoriteToggledCommand}"
VerticalOptions="Center"
WidthRequest="150" />
如果我们现在运行应用程序,我们会看到当点击FavoriteControl时,不仅IsFavorite属性会更新,而且绑定的FavoriteToggledCommand也会被调用,这使得我们的应用程序中的交互和数据更新更加无缝。这完成了将基于命令的基本交互集成到我们的自定义控件中。让我们继续,使控件对应用程序的用户和其他开发者来说都更好、更直观。
提升开发者和用户体验
虽然前面的例子提供了如何将基于命令的交互集成到我们的自定义控件中的基础理解,但它只是触及了表面。旨在将此控件集成到各种用例中的开发者和将与之交互的最终用户都期待一个更加精致和适应性强的解决方案。例如,我们可以通过提供指示控件可点击的视觉提示来增强用户体验。在开发者方面,我们希望我们的控件是可预测的;开发者期望事情以某种方式工作。理想情况下,我们的控件应该遵循绑定命令的CanExecute方法并根据其行为进行调整。根据我的经验,设计不佳或思考不周全的控件经常给我和我的同事带来了麻烦。目标是使自定义控件足够直观,以至于对于简单任务不需要详细的解释。让我们看看我们如何将FavoriteControl提升到另一个层次!
让我们先向FavoriteControl引入一个新的属性IsInteractive。当控制器的IsEnabled属性设置为true,ToggleCommand被设置,并且其CanExecute方法返回true时,此属性将被设置为true。在所有其他情况下,IsInteractive属性的值需要为false。然后可以在执行动作之前在控件内部检查此属性。让我们一步一步来:
将IsInteractive属性添加到FavoriteControl中,如下所示:
public bool IsInteractive { get; private set; }
以下代码片段显示了UpdateIsInteractive方法。此方法负责设置IsInteractive属性:
private void UpdateIsInteractive()
=> IsInteractive = IsEnabled
&& (ToggledCommand?.CanExecute(IsFavorite)
?? false);
更新TapGestureRecognizer_Tapped方法,如下代码块所示:
private void TapGestureRecognizer_Tapped(
object sender, TappedEventArgs e)
{
if (IsInteractive)
{
IsFavorite = !IsFavorite;
ToggledCommand?.Execute(IsFavorite);
}
}
通过更新此处显示的代码,我们只允许在IsInteractive属性设置为true时切换IsFavorite属性。这意味着控件已启用,其ToggleCommand已设置,并且ToggleCommand的CanExecute方法返回true。
接下来,我们需要确保在需要更新其值时调用UpdateIsInteractive方法。以下三种情况就是这种情况:
在以下步骤中,我们将确保在上述情况下调用UpdateIsInteractive方法:
在FavoriteControl类中,重写OnPropertyChanged方法。此方法由控件属性调用,以触发PropertyChanged事件,就像我们在 ViewModel 中通常做的那样。让我们看看我们能用它做什么:
protected override void OnPropertyChanged(
[CallerMemberName] string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if(propertyName == nameof(IsEnabled))
{
UpdateIsInteractive();
}
}
每当传入的propertyName与IsEnabled属性匹配时,我们希望触发UpdateIsInteractive方法。这确保了每当IsEnabled属性被更新时,IsInteractive属性会重新评估。
接下来,我们继续确保当ToggledCommand被更新时,IsInteractive属性也会被更新。让我们首先向ToggledCommandProperty添加一个propertyChanged委托,如下所示:
public static readonly BindableProperty
ToggledCommandProperty =
BindableProperty.Create(nameof(ToggledCommand),
typeof(ICommand), typeof(FavoriteControl),
propertyChanged: ToggledCommandChanged);
private static void ToggledCommandChanged(
BindableObject bindable,
object oldValue, object newValue)
{
var control = bindable as FavoriteControl;
control.UpdateIsInteractive();
}
当命令的值被更新时,会调用ToggledCommandChanged方法。这是调用UpdateIsInteractive方法的理想位置,以便根据新的ToggledCommand更新IsInteractive属性。
最后,我们的IsInteractive属性不仅依赖于IsEnabled属性和ToggledCommand的存在,还考虑了定义的命令的CanExecute方法。为了实现这一点,我们需要通过订阅其CanExecuteChanged事件来监听命令CanExecute状态的变化。以下是我们可以如何更新ToggleCommandChanged方法:
private static void ToggledCommandChanged(
BindableObject bindable,
object oldValue, object newValue)
{
var control = bindable as FavoriteControl;
if (oldValue is ICommand oldCommand)
{
oldCommand.CanExecuteChanged -=
control.CanExecuteChanged;
}
if (newValue is ICommand newCommand)
{
newCommand.CanExecuteChanged +=
control.CanExecuteChanged;
}
control.UpdateIsInteractive();
}
在设置新命令后,我们不仅订阅了其CanExecuteChanged事件;我们还确保从上一个命令的事件中取消订阅。这对于确保我们的控件只对当前命令的CanExecute状态做出反应至关重要。CanExecuteChanged事件处理程序仅调用UpdateIsInteractive方法,如下所示:
private void CanExecuteChanged(
object sender, EventArgs e)
=> UpdateIsInteractive();
总结来说,IsInteractive属性是用户与控件交互的门户。其状态由多种因素决定——控件的IsEnabled属性、命令的存在以及该命令的CanExecute方法。重要的是,每当这些影响因素中的任何一个发生变化时,IsInteractive状态都会动态重新评估。
让我们看看这个效果!为了演示目的,让我们添加一个最大次数,通过FavoriteControl可以切换菜谱的收藏状态。只要这个数字没有超过,RecipeDetailViewModel上的FavoriteToggledCommand的canExecute委托应返回true。让我们看看我们如何实现这一点:
让我们从向RecipeDetailViewModel添加以下两个字段开始:
int updateCount = 0;
int maxUpdatedAllowed = 5;
接下来,更新FavoriteToggledCommand,使其包括canExecute谓词,如下所示:
FavoriteToggledCommand = new AsyncRelayCommand<bool>(
FavoriteToggled,
FavoriteToggled method so that it keeps track of the number of times it was invoked, as shown here:
private async Task FavoriteToggled(bool isFavorite)
{
...
updateCount++;
FavoriteToggledCommand.NotifyCanExecuteChanged();
}
Not only does this method now keep track of the number of times it was invoked, but it also triggers the `NotifyCanExecuteChanged` event of the `FavoriteToggledCommand`. As a result, the `CanExecuteChanged` method on `FavoriteControl` will get called, which will eventually call the command’s `CanExecute` method to see whether it still can be executed.
在此设置完成后,我们可以运行应用程序并查看FavoriteControl如何对ToggleCommand的CanExecute方法做出反应。转到菜谱的详细页面,并按下FavoriteControl几次。你会注意到收藏状态将被更新,直到你点击五次。之后,ToggleCommand的CanExecute方法返回false,导致IsInteractive也被设置为false。由于这个原因,任何后续与控件的交互都将被忽略。此外,在RecipesOverviewPage上,你会发现FavoriteControl不可触摸,因为没有定义ToggleCommand。这两个场景都说明了控件的行为符合预期!
现在我们已经了解了IsInteractive属性的工作原理,让我们将注意力转向如何利用它来提供更直观的用户体验。具体来说,我们将探讨如何使用这个属性来提供视觉提示,指示控件是否可触摸。关于我们的FavoriteControl,我们无法做太多来使其非常清楚地表明它是可触摸的。为了演示的目的,我们将在可触摸时在心形图标周围添加一个简单的指示器——一个黑色边框。以下是我们可以如何做到这一点:
打开FavoriteControl.xaml文件,并将现有的Image控件用Grid包围,如下面的代码片段所示:
<Grid>
<Image x:Name="icon" ...>
...
</Image>
Image, prior to the existing one:
<Image
HeightRequest="{Binding HeightRequest,
Source={x:Reference icon}}"
WidthRequest="{Binding WidthRequest,
Source={x:Reference icon}}"
IsVisible="{Binding IsInteractive,
Source={RelativeSource
AncestorType={x:Type
local:FavoriteControl}}}"
Scale="1.2"
Source="{Binding Source,
Source={x:Reference icon}}" />
<Image x:Name="icon" ...>
...
`Grid` allows controls to be placed on top of each other. The added `Image` will be rendered below the existing favorite icon. Its `HeightRequest`, `WidthRequest`, and `Source` properties are bound to those of the existing Image. Do note its `Scale` property – it’s set to `1.2`. As a result, this new `Image` will be a bit bigger than the `Image` on top. This creates the visual effect of a border surrounding the icon. Also, take a look at the `IsVisible` property – it’s bound to the `IsInteractive` property we introduced earlier. Because of this, the underlying `Image` will only be rendered when the control is tappable, giving a user a visual cue.
最后,不要忘记在IsInteractive属性更新时触发PropertyChanged事件。否则,绑定引擎不会通知更新后的值。以下代码片段显示了如何更新UpdateIsInteractive方法来实现这一点:
private void UpdateIsInteractive()
{
IsInteractive = IsEnabled
&& (ToggledCommand?.CanExecute(IsFavorite)
?? false);
OnPropertyChanged(nameof(IsInteractive));
}
当现在运行应用程序并导航到RecipeDetailPage时,你应该看到收藏图标周围有一个黑色边框,这表明控制是交互式的。由于缺少ToggledCommand,这个边框在RecipeOverviewPage上不可见。此外,在RecipeDetailPage上连续几次轻触FavoriteControl后,第五次触摸后边框将消失,因为FavoriteToggledCommand的CanExecute方法返回false。这个视觉提示告知用户控制不再交互。图 11.4 显示了RecipeDetailPage上FavoriteControl的所有不同状态:
图 11.4:FavoriteControl 在不同状态
现在我们已经优化了用户和开发者的体验,让我们更进一步,使我们的控制设计与其功能一样适应性强。
与控制模板一起工作
到目前为止,我们一直在处理硬编码的外观和感觉,但如果我们想提供更多的灵活性,而不强迫开发者重写或扩展我们的控制,那会怎么样呢?通过支持控制模板,我们可以暴露我们控制视觉树的结构,允许进行样式和结构上的更改,同时保留其核心功能。这是确保我们的自定义控制能够无缝地融入各种用户界面,提供更高程度的定制化的绝佳方式。
你可能会想知道这与 MVVM 有什么关系。考虑以下情况——一个控制模板的作用是将控制逻辑和行为与其视觉表示分离,就像 MVVM 如何实现 ViewModel 和 View 之间的松耦合。从这个意义上讲,控制实际上充当了控制模板的 ViewModel。它暴露了模板绑定到的属性。反过来,控制模板可以被视为 View。因此,就像我们习惯的那样,我们可以在控制模板中使用数据绑定,并使用设置为TemplatedParent的相对绑定源来绑定到模板应用到的控制属性。
模板绑定和模板父级
在接下来的示例中,我们将使用绑定语句,并将它们的 RelativeSource 设置为 TemplatedParent。值得注意的是,这种方法与现在已废弃的 TemplateBinding 标记扩展执行相同的功能。本质上,手动将 RelativeSource 设置为 TemplatedParent 完成了 TemplateBinding 以前自动完成的事情,创建了一个源为应用模板的控件的绑定。然而,从 .NET 7 开始,TemplateBinding 标记扩展已被标记为“已废弃”。
我想快速讨论一下控件模板,因为它们反映了 MVVM 哲学。无论你是扩展现有控件的外观还是从头创建新的控件,了解如何正确构建和利用控件模板都将使你的开发过程更高效,并使你的应用程序更易于维护。我还认为,精通 XAML 和数据绑定的方面直接有助于在项目中有效地应用 MVVM 模式。无需多言,让我们深入探讨!
下一步将展示如何定义一个控件模板并将其应用到 FavoriteControl 上:
在 RecipeDetailPage 中,我们可以向页面的 Resources 添加一个 ControlTemplate,如下面的代码片段所示:
<ControlTemplate x:Key="FavoriteTemplate">
<VerticalStackLayout>
<Label
FontSize="10" HorizontalOptions="Center"
Text="Favorite?" />
<Switch
HorizontalOptions="Center"
InputTransparent="True"
IsEnabled="{Binding IsInteractive,
Source={RelativeSource TemplatedParent}}"
IsToggled="{Binding IsFavorite, Source=
{RelativeSource TemplatedParent},
Mode=OneWay}"
/>
</VerticalStackLayout>
</ControlTemplate>
注意这个模板在其绑定语句中使用 TemplatedParent 作为 RelativeSource。通过这样做,我们绑定到控制公开的属性,这些属性将在应用此模板的控件中暴露,正如我们稍后将要看到的。
以下代码片段展示了我们如何使用与 ControlTemplate 关联的键(FavoriteTemplate),结合 StaticResource 标记扩展,将其分配为我们的 FavoriteControl 的模板:
<controls:FavoriteControl
...
ControlTemplate="{StaticResource
FavoriteTemplate}"
... />
在保持 FavoriteControl 功能的同时,这个模板给控件带来了完全不同的外观,正如你在 图 11.5 中可以看到的那样:
图 11.5:使用替代模板的 FavoriteControl
然而,这里缺少了一样东西。还记得原始控件上的动画吗?动画是由以下代码触发的:
private async Task AnimateChange()
{
await icon.ScaleTo(1.5, 100);
await icon.ScaleTo(1, 100);
}
控件的原生视觉树被 ControlTemplate 中定义的新视觉树所替换。因此,原始元素不再可见,并且不是当前视觉树的一部分。然而,如果你在代码后端有对这些原始元素的引用,它们仍然存在于内存中。尽管它们已经从视觉树中分离出来且不可见,但你仍然可以使用代码与它们交互。它们只是不会对用户看到的内容产生影响,因为它们不再在视觉树中。因此,负责动画的代码不会崩溃,但屏幕上不会有任何视觉效果。让我们看看我们如何访问在 ControlTemplate 上定义的视觉元素。
从模板中访问元素
传统上,当与自定义控件一起工作时,UI 元素直接在控件内部定义。这些控件在赋予x:Name属性后,可以从控件的代码隐藏文件中访问和操作,这是一种定义和使用自定义控件的完全有效的方法。然而,当完全拥抱控件模板的力量时,直接定义 UI 元素的需求减少。相反,控件的理想默认外观应封装在ControlTemplate中。
为了说明,让我们考虑FavoriteControlTemplated,它是FavoriteControl的一个变体。与原始版本不同,这个版本没有在内部直接定义 UI 元素。相反,它的默认外观在ControlTemplate中声明,定义在控件的Resources中。以下是它在 XAML 中的样子:
<ContentView
...>
<ContentView.Resources>
<toolkit:BoolToObjectConverter
... />
<ControlTemplate x:Key="DefaultTemplate">
<Grid>
<Image
HeightRequest="{Binding HeightRequest,
Source={x:Reference TemplatedParent}}"
IsVisible="{Binding IsInteractive,
Source={RelativeSource
TemplatedParent}}"
Scale="1.2"
Source="{Binding Source, Source=
{x:Reference scalableContent}}"
WidthRequest="{Binding WidthRequest,
Source={x:Reference scalableContent}}"
/>
<Image
x:Name="scalableContent"
HeightRequest="{Binding HeightRequest,
Source={RelativeSource
TemplatedParent}}"
Source="favorite.png"
WidthRequest="{Binding WidthRequest,
Source={RelativeSource
TemplatedParent}}">
<Image.Behaviors>
...
</Image.Behaviors>
</Image>
</Grid>
</ControlTemplate>
</ContentView.Resources>
<ContentView.GestureRecognizers>
...
</ContentView.GestureRecognizers>
</ContentView>
如您在下一个代码块中可以看到的,在FavoriteControlTemplated类的构造函数中,如果没有指定其他模板,则会分配一个默认模板:
public FavoriteControlTemplated()
{
InitializeComponent();
if(ControlTemplate == null)
{
var template = Resources["DefaultTemplate"];
ControlTemplate = template as ControlTemplate;
}
}
如果ControlTemplate属性为 null,这意味着开发者没有指定不同的模板。在这种情况下,会检索并分配控件Resources中的默认模板。
当控件模板完全加载时,会调用OnApplyTemplate方法。这是使用GetTemplateChild方法访问模板中特定元素的地方,例如我们模板中的Image,我们将其命名为scalableContent。以下代码片段显示了如何使用GetTemplateChild方法获取名为scalableContent的VisualElement:
VisualElement scalableContent;
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
scalableContent =
GetTemplateChild("scalableContent")
as VisualElement;
}
一旦检索到名为scalableContent的VisualElement,就可以对其进行程序化操作。例如,在AnimateChange方法中,可以对其应用缩放动画:
private async Task AnimateChange()
{
if (scalableContent is not null)
{
await scalableContent.ScaleTo(1.5, 100);
await scalableContent.ScaleTo(1, 100);
}
}
通过采用控件模板并利用GetTemplateChild等方法,我们可以保持控件逻辑和视觉表示之间的清晰分离,从而允许更灵活和可重用的组件。
让我们更新在RecipeDetailPage上创建的FavoriteTemplate,通过给某个 UI 元素命名为scalableContent,使用FavoriteControlTemplated而不是FavoriteControl。以下是操作步骤:
在RecipeDetailPage上更新FavoriteTemplate,如下所示:
<ControlTemplate x:Key="FavoriteTemplate">
...
<Switch
x:Name="scalebleContent"
… />
</VerticalStackLayout>
</ControlTemplate>
通过将scalebleContent作为Switch控件的名称,它可以在以后被FavoriteControlTemplated控件拾取,并对其应用动画。
如下一个代码块所示,让我们使用FavoriteControlTemplated自定义控件而不是之前的FavoriteControl:
<controls:FavoriteControlTemplated
Grid.Column="1"
Margin="5"
ControlTemplate="{StaticResource
FavoriteTemplate}"
IsFavorite="{Binding IsFavorite, Mode=TwoWay}"
ToggledCommand="{Binding FavoriteToggledCommand}"
VerticalOptions="Center" />
如果你在这个阶段运行应用程序,你会注意到更新的收藏指示器。当我们点击控件时,你会注意到Switch被缩放了。这是因为它在模板中命名为scalableContent。如果你更新RecipeDetailPage并省略对ControlTemplate属性的赋值(ControlTemplate="{StaticResource FavoriteTemplate}")然后再次运行应用程序,你会看到我们之前使用的心形可视化。那是因为当没有明确分配ControlTemplate时,控件将加载默认的控件模板。
值得注意的是,任何包含名为scalableContent的元素的控件模板都将与此代码兼容。换句话说,只要模板中有一个名为scalableContent的 UI 元素,我们的FavoriteControlTemplated类就能检索到它并应用缩放动画。然而,如果模板中没有scalableContent元素呢?不用担心——控件被设计成能够优雅降级。如果找不到该元素,控件的所有功能都将保持完整;只是动画将不存在。这种灵活性允许开发者创建各种视觉风格,同时保持控件的行为不变。
关注点分离原则是 MVVM 的核心,对于创建可维护的软件至关重要。当你正在创建自定义控件时,这个原则可以通过使用控件模板来扩展。然而,值得注意的是,并非总是需要完全实现控件模板。对于你应用程序中独特且保持一致外观的简单控件,直接在控件内部定义 UI 元素既实用又有效。这允许你平衡复杂性与灵活性,使你能够选择最适合你项目特定需求的方法。
摘要
在本章中,我们探讨了.NET MAUI 中自定义控件和控制模板提供的强大可能性,所有这些都是在 MVVM 架构的背景下进行的。从理解自定义控件的基础到创建可绑定属性,我们深入研究了自定义控件上的数据绑定复杂性。我们还探讨了如何为我们的控件添加交互性以及如何公开和处理命令。此外,我们还深入研究了控制模板提供的灵活性和可扩展性。这些技术的综合运用提高了可重用性,并遵循了关注点分离的原则。掌握创建 UI 的艺术——无论是通过 XAML 还是代码后置——以及了解如何使用可绑定属性和命令创建 MVVM 友好的控件,对精通 MVVM 贡献巨大。无论是具有硬编码外观的简单控件还是复杂、模板驱动的控件,关键是要理解何时以及如何恰当地应用这些方法来创建可维护、可适应且高度可定制的 UI 组件。
在下一章中,我们将探讨在 MVVM 的背景下进行本地化,使我们的 UI 组件不仅灵活且易于维护,而且能够全球适应。
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
第十二章:使用 MVVM 进行本地化
因此,我们已经构建了这个出色的食谱 应用,我们对它的设计和功能感到非常满意。然而,目前这个应用完全是英文的。那么,来自世界各地的烹饪爱好者怎么办呢?答案是本地化,具体来说,就是将我们应用的所有方面都翻译出来,使其对全球用户更加易于访问和友好。
在本章中,我们将从 MVVM 的角度探讨本地化。我们将探讨如何将硬编码的copy标签从应用中翻译出来,同时也会探讨如何有效地从 API 中获取特定语言的数据。为了实现这一点,我们将深入研究以下主题:
与文化、资源文件和本地化一起工作
查看 MVVM 的本地化解决方案
使用自定义的Translate标记扩展
从 API 中获取本地化数据
在我们深入本章内容时,请记住,我们对本地化的探索也展示了具有 MVVM 原则的深思熟虑的应用设计。到结束时,我们不仅将拥有工具和知识来通过 UI 将我们应用的触角扩展到多元化的全球受众,我们还将看到正确的设计选择如何确保我们应用的每一层,包括我们展示的数据,都是协调和有序的。
技术要求
像往常一样,为了获得实际经验并跟上内容,请访问我们的 GitHub 仓库github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter12 。从Start文件夹中的代码开始,如果您需要一个全面的视角,您始终可以参考包含完善、章节末尾代码的Finish文件夹。
与文化、资源文件和本地化一起工作
在我们深入实际编码并探讨如何在 MVVM 中集成本地化之前,让我们确保我们对.NET MAUI 上下文中的“文化”一词有相同的理解。在这种情况下,“文化”指的是确定要使用的语言以及日期、时间、货币等项目显示格式的设置。
让我们从如何在.NET MAUI 中检索用户的“文化”开始。
获取用户的“文化”
CultureInfo类是.NET 中System.Globalization命名空间的一部分,它作为获取特定文化信息(如语言、国家、日期格式、数字格式等)的中心点。它还包含CurrentCulture和CurrentUICulture属性,可以用来获取或设置用户的当前文化以及“UI 文化。”
CurrentCulture 与 CurrentUICulture
CurrentCulture定义了数据类型(如日期、数字和货币)在应用程序中的格式化方式。这确保了数据的视觉和感觉与用户的文化背景相一致。另一方面,CurrentUICulture决定了 UI 元素和文本资源使用的语言。CurrentCulture决定事物看起来如何 (格式化),而CurrentUICulture控制使用哪种语言 (本地化)。
用户当前的文化设置可以通过以下代码行轻松获取:
var currentCulture = CultureInfo.CurrentCulture;
var currentUICulture = CultureInfo.CurrentUICulture;
这将获取设备的文化设置,这是用户在其系统设置中设置的。让我们看看我们如何设置不同的文化。
设置不同的文化
设置特定的文化就像将其分配给CultureInfo类上的相关属性一样简单:
var cultureInfo = new CultureInfo("fr-FR");
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
DefaultThreadCurrentCulture和DefaultThreadCurrentUICulture属性用于设置.NET 应用程序中所有线程的默认文化设置,包括你可能为各种任务启动的后台线程。
注意
为了简化并保持我们对本地化整体过程的关注,在本章中,除非明确提及,否则我们不会在CurrentCulture和CurrentUICulture之间做出区分。当我们谈论更新文化设置时,我们将更新CurrentCulture和CurrentUICulture,以及它们的DefaultThread对应物。
如果我们想,我们可以允许用户从支持的文化的列表中选择一个文化,并将其保存以供未来的会话使用。以下是一个使用Microsoft.Maui.Storage.Preferences存储所选文化的简化示例:
Preferences.Set("SelectedCulture", "fr-FR");
获取并分配之前存储的选定文化可以这样做:
var storedCulture = Preferences.Get("SelectedCulture",
"en-US");
Preferences的Get方法接受第二个参数,用作当给定键不存在现有值时返回的默认值。我们现在可以使用的字符串值来实例化一个新的CultureInfo对象,然后我们可以使用它来设置应用程序的文化。看看这个例子:
var cultureInfo = new CultureInfo(storedCulture);
CultureInfo.CurrentCulture = cultureInfo;
如前所述,CurrentCulture属性定义了特定数据类型是如何显示的。那么,让我们快速看看我们指的是什么。
显示格式化数据
文化设置对数据类型(如日期和数字)在 UI 中显示时的格式化有直接影响。当你设置CultureInfo.CurrentCulture时,它也会影响数据绑定场景中的格式化。这意味着例如DateTime值将根据设置文化的格式规则显示。
假设我们在 ViewModel 中有一个DateTime属性:
public DateTime LastUpdated { get; set; } = new
DateTime(2020, 7, 3);
我们将此属性绑定到 XAML 中的Label:
<Label Text="{Binding LastUpdated}" />
由于我们没有指定数据应该如何格式化,ToString 方法将被调用在 DateTime 对象上,并且该结果将显示在屏幕上。ToString 方法将考虑当前的文化设置。因此,如果文化设置为美国英语(en-US),日期将格式化为 7/3/2020 12:00:00 AM,而如果文化设置为法语(fr-FR),日期将显示为 03/07/2020 00:00:00。
XAML 绑定表达式中的 StringFormat 属性允许我们为 DateTime 等数据类型定义自定义格式化。最棒的是,StringFormat 也会尊重当前的文化设置,因此这是一种将定制与本地化结合的绝佳方式。例如,我们可以在 XAML 中使用 StringFormat 来指定日期的显示方式:
<Label Text="{Binding LastUpdated, StringFormat='{0:MMMM d,
yyyy}'}" />
在这个例子中,StringFormat 被设置为显示完整的月份名称、日期和完整的年份。在美国英语中,它将显示为 July 3, 2020,而当应用程序的文化设置为法语时,日期将自动调整为 juillet 3, 2020。StringFormat 中指定的格式保持一致,但日期和月份的实际字符串值将适应设置的文化。
同样的原则适用于数字格式化。不同的文化有不同的数字表示方式,尤其是在分隔千位和小数点时。通过注意文化设置,我们确保我们的应用程序以用户熟悉且易于理解的方式显示数字。
与 CurrentCulture 属性相反,CurrentUICulture 属性确定应用程序中使用的语言。因此,让我们看看如何通过资源文件来管理这一点。
资源文件是什么?
资源文件是 .NET 生态系统的一个核心功能,用于在广泛的应用程序类型之间促进本地化。通常以 .resx 扩展名命名,这些基于 XML 的文件允许你定义键值对,其中键代表应用程序中的特定文本或资产,值代表其本地化等效项。Visual Studio 还提供了 .resx 文件的编辑器,允许开发者轻松定义键及其值(图 12**.1 ):
图 12.1:Visual Studio .resx 设计器
特别方便的是,当你创建 .resx 文件时,会自动生成一个强类型类。这个类允许我们以类型安全的方式以编程方式访问本地化资源,从而消除了在代码中手动查找资源键的需要。这个自动生成的类的名称是从 resx 文件本身的名称派生出来的。例如,如果资源文件命名为 AppResources.resx,则生成的类将命名为 AppResources。
通过给文件名添加后缀,我们可以指定特定资源文件属于哪种文化——例如,AppResources.resx是应用程序的默认文化(比如说英语),AppResources.fr-FR.resx是法语,AppResources.es-ES.resx是西班牙语,等等。
.NET MAUI 将查找与请求的值对应的设置文化对应的.resx文件。获取本地化字符串可以像这里所示那样进行:
var s1 = AppResources.
ResourceManager.GetString("AddAsFavorite");
如果当前 UI 文化设置为法语(fr-FR),.NET MAUI 将自动在AppResources.fr-FR.resx文件中查找AddAsFavorite。如果不存在与当前 UI 文化匹配的资源文件,将使用默认文件(不带后缀的文件)。
或者,检索AddAsFavorite键的值也可以这样做:
var s2 = AppResources.AddAsFavorite;
对于.resx文件中的每个键,AppResources类将生成一个静态属性。这些属性使用ResourceManager的GetString方法检索相应的值。
注意
重要的是要注意,生成的类将仅基于默认资源文件。这意味着只为默认文件中的键生成属性。因此,在各个不同文化特定的文件中保持相同的键非常重要!如果当前 UI 文化的资源文件中不存在键的值,将导致异常!
让我们在我们的解决方案中添加一些资源文件:
右键单击Recipes.Mobile项目中的Resources文件夹,然后选择Strings。
右键单击此文件夹,然后选择添加 | 新建项… 。
在resources中,选择AppResources.resx作为名称(图 12**.2 ):
图 12.2:创建新的资源文件
在属性 窗口中双检查新创建的文件属性。构建操作 属性应设置为嵌入资源 ,自定义工具 属性应设置为ResXFileCodeGenerator ,如图12**.3 所示:
图 12.3:AppResources.resx 文件的属性
添加第二个AppResources.fr-FR.resx。请注意,只有默认资源文件的自定义工具 属性设置为ResXFileCodeGenerator 。其他资源文件将不会设置此属性,但它们的构建操作 属性也必须设置为嵌入资源 。
在Chapter 12/Resources文件夹中,你会找到一个AppResources.resx文件和一个AppResources.fr-FR.resx文件,分别包含英语和法语资源。将它们复制到 Visual Studio 中的Strings文件夹,并覆盖你刚刚创建的文件。
在此基础上,让我们看看如何将这些值显示在屏幕上。
在屏幕上获取本地化资源
在其最简单形式中,我们可以使用 x:Static 标记扩展。此标记扩展用于从指定的类中引用静态字段或属性。如前所述,生成的 AppResources 类为 x:Static 标记扩展中的每个键都有一个静态属性来引用本地化值。让我们看看我们如何添加它:
将以下代码添加到 MauiProgram 类的 CreateMauiApp 方法中:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
...
var french = new CultureInfo("fr-FR");
CultureInfo.CurrentCulture = french;
CultureInfo.CurrentUICulture = french;
...
}
这将强制应用的文化设置为法语。
前往 AppShell.xaml 并添加一个指向 AppResources 类命名空间的 XML 命名空间:
接下来,更新第一个 Tab 元素的 Title 属性为以下内容:
Title="{x:Static strings:AppResources.Tab1Title}"
通过利用 x:Static 标记扩展,我们正在指向 AppResources 类的静态 Tab1Title 属性。
第二个 Tab 元素的 Title 属性应指向 Tab2Title 属性,如下所示:
Title="{x:Static strings:AppResources.Tab2Title}"
最后,对于这个第一个例子,前往 RecipeDetailPage 并找到显示 LastUpdated 和 Author 属性的标签上的 MultiBinding。x:Static 标记扩展也可以用于绑定语句上的 StringFormat 属性,如下所示:
<MultiBinding StringFormat="{x:Static
strings:AppResources.ModifiedDateAuthorFormat}">
<Binding Path="LastUpdated" />
<Binding Path="Author" />
</MultiBinding>
值得注意的是,ModifiedDateAuthorFormat 包含一个表示长日期模式的 D 格式说明符。因此,日期的表示将根据所选文化而有所不同。例如,当 CurrentCulture 设置为法语 (fr-FR) 时,2020 年 7 月 3 日将显示为 vendredi 3 juillet 2020。
如果我们现在运行应用,我们应该立即看到两个主要标签的标题现在已变为法语,如图 12**.4 所示:
图 12.4:本地化标签标题
当导航到 RecipeDetailPage 时,显示 LastUpdated 和 Author 属性的标签现在也已翻译。现在,前往 CreateMauiApp 方法并更改文化为 nl-BE。运行应用,你应该会看到英文的本地化标签。这是因为没有为这种文化提供资源文件,所以使用默认的。请注意,日期是根据荷兰(比利时)的格式进行格式化的:vrijdag 3 juli 2020。
如前所述,这种方法是获取屏幕上本地化资源的最简单形式。这是一个完全有效的方法,但它不是非常 MVVM 友好,并且有其局限性:
最大的限制是 x:Static 无法响应变化。如果在运行时更改静态属性的值,UI 不会更新以反映新值。这使得它不适合本地化文本可能动态变化的情况,例如当用户在应用内切换语言时。只有当导航到新页面时,x:Static 标记扩展才会加载新选择的文化所需值。当导航(返回)到内存中的页面时,前一个文化的值仍然存在。
无法从我们的 ViewModel 中访问这些资源。如果我们想从 ViewModel 中显示对话框或警报,这可能会成为一个问题。
到目前为止,我们已经为理解.NET MAUI 中的本地化奠定了基本的基础,包括资源文件的作用、CultureInfo类和x:Static标记扩展。虽然我们迄今为止讨论的方法和概念构成了.NET MAUI 本地化的基本骨架,但我们的下一节将探讨如何无缝地将它们集成到 MVVM 中。最终,我们的目标是利用数据绑定将我们的 UI 连接到本地化值。这实现了动态更新:如果用户在运行时切换语言或文化,显示的文本将自动反映这些更改。
注意
在绑定语句上的属性,如StringFormat、FallbackValue和TargetNullValue,不是可绑定属性。这意味着将这些属性从资源文件中赋值的唯一方法是通过利用x:Static标记扩展,尽管它有其局限性。
查看 MVVM 的本地化解决方案
在本节中,我们将探讨一个不仅功能性强,而且与 MVVM 架构模式相得益彰的解决方案。无论您需要在本地的 ViewModel 中本地化文本,还是动态更新 UI 中的语言,这种方法都能满足您的需求。这是我多年来在众多项目中亲自实施过的解决方案。虽然我在过程中做了一些改进,但核心概念经受住了时间的考验,并在实际应用中证明了其有效性。
警告 – 文化设置是线程特定的
当允许用户在应用程序内切换文化时,我们应该警惕这样一个事实:在async操作中更新CultureInfo时,这些更改不会自动传播到父线程。需要设计一个考虑到这一点的本地化策略,以避免不一致性。
在本章的代码中,新增了两个项目:Localization和Localization.Maui。Localization项目的主要目标是提供一个与框架无关的方法来管理和访问本地化设置和资源。通过将本地化逻辑封装在其自己的项目中,我们促进了不同项目之间的代码共享,为在其他项目中打包和重用铺平了道路。
另一方面,Localization.Maui项目专门针对.NET MAUI 进行定制。它包含了存储和检索本地化信息的代码。将此逻辑与其他Recipes! 应用程序代码分开,使我们能够确保它保持模块化和可重用性,适用于其他.NET MAUI 应用程序。让我们首先讨论Localization项目。
本地化项目
让我们来看看Localization项目中的ILocalizationManager接口:
public interface ILocalizationManager
{
void RestorePreviousCulture(
CultureInfo defaultCulture = null);
void UpdateUserCulture(CultureInfo cultureInfo);
CultureInfo GetUserCulture(
CultureInfo defaultCulture = null);
}
ILocalizationManager接口定义了三种用于管理本地化设置的方法:
RestorePreviousCulture是一个可以用来恢复用户之前使用的文化的函数。可以使用defaultCulture参数指定一个回退文化,以防没有设置之前的文化。
UpdateUserCulture更新并存储当前用户的语言设置。cultureInfo参数指定了要使用的新文化。
GetUserCulture方法检索存储的文化设置。可以使用defaultCulture参数指定一个回退文化,以防没有设置文化。
此接口的主要目的是抽象与用户指定文化的持久化和检索相关的任务。在.NET MAUI 特定实现的背景下,例如,我们可能会选择将最近选择的CultureInfo保存在Preferences中,以确保在下次应用启动时可用,以便可以恢复。我们将在稍后深入了解这一特定方法。
Localization项目中的第二个接口是ILocalizedResourcesProvider接口。让我们看看它是什么样子:
public interface ILocalizedResourcesProvider
{
string this[string key]
{
get;
}
void UpdateCulture(CultureInfo cultureInfo);
}
在其核心,此接口促进了与用户选择的CultureInfo相匹配的本地化字符串值的检索。它提供了一个只读索引器来获取与指定键对应的本地化字符串。此外,UpdateCulture方法允许您修改当前的CultureInfo,确保后续的本地化字符串检索反映了更新的文化上下文。
接下来,我们将探索Localization项目中ILocalizedResourceProvider接口的具体实现——即LocalizedResourcesProvider。让我们首先看看它的构造函数:
ResourceManager resourceManager;
CultureInfo currentCulture;
public static LocalizedResourcesProvider Instance
{
get;
private set;
}
public LocalizedResourcesProvider(
ResourceManager resourceManager)
{
this.resourceManager = resourceManager;
currentCulture = CultureInfo.CurrentUICulture;
Instance = this;
}
构造函数接受一个参数,resourceManager。这是需要用来检索本地化值的ResourceManager。currentCulture字段使用默认值初始化,即当前的 UI 文化。在构造函数中,当前实例被分配给静态的Instance属性。这允许我们通过静态属性访问此LocalizedResourcesProvider实现,这在数据绑定场景中将会很有用,正如我们稍后将会看到的。
注意
将当前LocalizedResourcesProvider实例分配给静态的Instance属性意味着在整个应用中只能有一个LocalizedResourcesProvider实现。虽然这是一个需要注意的限制,但它不应造成重大问题。
此类的索引器允许我们获取给定键的本地化字符串值。以下是其实施方式:
public string this[string key]
=> resourceManager.GetString(key, currentCulture)
?? key;
当键存在于我们的资源中时,该方法使用 currentCulture 获取相应的本地化字符串。然而,如果提供的键不匹配任何资源键,则键本身将作为后备返回。重要的是要注意,我们在调用 GetString 时明确传递了 currentCulture。这确保了 resourceManager 获取特定于提供的 currentCulture 的值,而不是默认使用当前执行线程的 CultureInfo。这种设计选择解决了由文化设置线程特定性引起的问题。
现在,让我们看看 LocalizedResourcesProvider 中的 UpdateCulture 方法:
public void UpdateCulture(CultureInfo cultureInfo)
{
currentCulture = cultureInfo;
OnPropertyChanged("Item");
}
此方法更新 currentCulture 字段。因此,任何后续对该类索引器的调用都将根据更新的 currentCulture 获取给定键的本地化字符串值。此外,该方法调用 OnPropertyChanged 方法,将 Item 作为其参数。在涉及数据绑定的场景中,这会提示重新评估所有与索引器链接的绑定。因此,任何绑定到此索引器的值都将刷新,返回更新后的 CultureInfo 的本地化字符串。让我们将注意力转向 Localization.Maui 项目。
The Localization.Maui project
Localization.Maui 项目中的 LocalizationManager 类是 ILocaliazationManager 的一个实现。它依赖于 ILocalizedResourcesProvider,正如你在这里看到的:
readonly ILocalizedResourcesProvider _resourceProvider;
public LocalizationManager(
ILocalizedResourcesProvider resoureProvider)
{
_resourceProvider = resoureProvider;
}
通过拥有这个引用,我们可以在以后调用它的 UpdateCulture 方法,保持更新后的文化与 ILocalizedResourcesProvider 保持同步。
LocalizationManager 类也应该存储选定的 CultureInfo,以便在后续的应用程序启动时检索并恢复之前的 CultureInfo。以下代码块展示了 UpdateUserCulture 如何使用 Preferences API 存储给定的 CultureInfo,将更新后的值分配给 currentCulture 字段,更新 CultureInfo 对象上的静态属性,并调用 ILocalizedResourcesProvider 的 UpdateCulture 方法:
public void UpdateUserCulture(CultureInfo cultureInfo)
{
Preferences.Default.Set("UserCulture",
cultureInfo.Name);
SetCulture(cultureInfo);
}
private void SetCulture(CultureInfo cultureInfo)
{
currentCulture = cultureInfo;
Application.Current.Dispatcher.Dispatch(() =>
{
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
CultureInfo.DefaultThreadCurrentCulture =
cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture =
cultureInfo;
});
_resourceProvider.UpdateCulture(cultureInfo);
}
注意到对 CultureInfo 对象的更新是如何派发到主线程的。还记得关于 CultureInfo 与当前线程绑定的说明吗?我们想要确保主线程的 CultureInfo 被更新以保持一致性。未能将这些更改应用到主线程可能会导致使用过时或之前的 CultureInfo 进行数据绑定。这种不匹配可能导致在格式化数据时出现不准确,例如显示 DateTime 对象。
GetUserCulture 方法首先检查其 currentCulture 字段是否已设置。如果是,则直接返回该值。如果不是,该方法尝试根据 Preferences 中的 UserCulture 键检索文化。如果没有找到值,它将回退到提供的 defaultCulture 参数,或者在没有提供的情况下,使用当前系统文化。一旦确定,此文化将被分配给 currentCulture 字段,然后返回。请看以下内容:
public CultureInfo GetUserCulture(
CultureInfo defaultCulture = null)
{
if (currentCulture is null)
{
var culture = Preferences.Default.Get(
"UserCulture", string.Empty);
if (string.IsNullOrEmpty(culture))
{
currentCulture = defaultCulture
?? CultureInfo.CurrentCulture;
}
else
{
currentCulture = new CultureInfo(culture);
}
}
return currentCulture;
}
最后,RestorePreviousCulture 方法使用 GetUserCulture 方法检索之前使用的文化,并将其传递给 SetCulture 方法,正如您在这里所看到的:
public void RestorePreviousCulture(
CultureInfo defaultCulture = null)
=> SetCulture(GetUserCulture(defaultCulture));
当应用程序启动时,可以使用此方法将当前文化设置为用户之前选择的文化。
现在我们已经熟悉了这些类的角色和工作原理,让我们将它们集成到 Recipes! 应用程序中。这将使我们能够以补充 MVVM 架构模式的方式处理本地化。以下是一个分步指南:
打开 MauiProgram.cs 并在 CreateMauiApp 方法中添加以下代码行:
builder.Services.AddSingleton<ILocalizationManager,
LocalizationManager>();
此操作将 LocalizationManager 的一个实例注册到依赖注入容器中。
创建 LocalizedResourcesProvider 的实例。将 AppResources 的 ResourceManager 传递给其构造函数,允许 LocalizedResourceProvider 访问在 AppResources 文件中定义的资源:
var resources = new LocalizedResourcesProvider(
AppResources.ResourceManager);
让我们将此 LocalizedResourcesProvider 注册为 Singleton 到依赖注入容器中,如下所示:
builder.Services
.AddSingleton<ILocalizedResourcesProvider>(
resources);
通过这样做,我们确保在整个应用程序中只使用 LocalizedResourcesProvider 类的单个实例。每次通过依赖注入解析此实例时,它都保证与 LocalizedResourcesProvider 的静态 Instance 属性所引用的是同一个实例。这对于在应用程序中一致地访问本地化资源至关重要。
这为构建一个稳固的本地化解决方案奠定了基础,该解决方案可以用于我们的应用程序。让我们看看我们如何在应用程序中集成 LocalizedResourcesProvider 和 LocalizationManager。
使用 LocalizedResourcesProvider 和 LocalizationManager
让我们看看我们如何使用 LocalizedResourcesProvider 和 LocalizationManager 从 ViewModel 内部访问本地化资源。在 SettingsPage 上,用户可以通过 PickLanguagePage 选择新的语言。在选择语言时,应提示用户是否想要切换,当前语言下的提示。切换后,应以更新后的语言显示一个确认更新的警报。以下是我们可以这样做的方法:
首先,让我们向 SettingsViewModel 添加以下两个字段:
readonly ILocalizedResourcesProvider _resources;
readonly ILocalizationManager _localizationManager;
readonly IDialogService _dialogService
...
public PickLanguageViewModel(
INavigationService navigationService
IDialogService dialogService,
ILocalizedResourcesProvider resourcesProvider,
ILocalizationManager localizationManager)
{
_dialogService = dialogService;
_resources = resourcesProvider;
_localizationManager = localizationManager;
...
}
...
依赖注入容器将自动将这些额外的依赖项注入到构造函数中。
接下来,我们可以更新 ConfirmSwitchLanguage 方法如下:
private Task<bool> ConfirmSwitchLanguage()
=> _dialogService.AskYesNo(
_resources["SwitchLanguageDialogTitle"],
_resources["SwitchLanguageDialogText"],
_resources["YesDialogButton"],
_resources["NoDialogButton"]);
看看我们如何使用 _resources 字段来检索本地化字符串,然后将其传递给 IDialogService 的 AskYesNo 方法。键与 AppResources 文件中存在的键完全匹配。
同样,我们也可以更新 NotifySwitch 方法,如下所示:
private Task NotifySwitch()
=> _dialogService.Notify(
_resources["LanguageSwitchedTitle"],
_resources["LanguageSwitchedText"],
_resources["OKDialogButton"]);
最后,我们需要在 SwitchLanguage 方法中添加以下代码行:
private void SwitchLanguage()
{
CurrentLanguage = newLanguage;
_localizationManager
.UpdateUserCulture(
new CultureInfo(SelectedLanguage));
}
当用户确认切换到新语言时,将调用此方法。通过调用 _localizationManager 的 UpdateUserCulture,我们将持久化所选文化,更新 CultureInfo 属性,并通知 ILocalizedResourcesProvider 关于更新的文化。因此,后续对 _resources 字段的调用将检索更新后的 CultureInfo 的本地化值。
我们现在可以运行应用程序,转到 SettingsPage,然后点击到 PickLanguagePage,在那里我们可以更改应用程序的语言。一旦选择了新语言,我们将自动导航回 SettingsPage,在那里我们将以应用程序的当前语言收到提示(图 12.5 ):
图 12.5:英语提示
确认后,语言将更新,我们将看到新语言中的警报(图 12.6 ):
图 12.6:法语警报
我们可以在我们的 ViewModels 中切换文化,并检索当前设置文化的本地化字符串值,这已经非常令人兴奋了!但如果我们想让它们也更新到新选定的文化,我们如何在视图中处理本地化?让我们看看!
转到 SettingsPage.xml 并添加以下 XML 命名空间:
xmlns:localization="clr-namespace:
Localization;assembly=Localization"
此命名空间指向 Localization 项目的 Localization 命名空间。
将 选择语言 按钮的 Text 属性更新为以下内容:
Text="{Binding Path=[ChooseLanguage], Source={x:Static
localization:LocalizedResourcesProvider.Instance}}"
LocalizedResourcesProvider.Instance["ChooseLanguage"]
正如我们在 LocalizedResourcesProvider 的实现中看到的,索引器接受提供的键,并使用配置的 ResourceManager 获取相应的本地化字符串。
让我们同样更新 LocalizedResourcesProvider 的文本,如下所示:
{Binding Path=[About], Source={x:Static
localization:LocalizedResourcesProvider.Instance}}"
同时,让我们也更新页面的标题:
Title="{Binding Path=[SettingsTitle], Source={x:Static
localization:LocalizedResourcesProvider.Instance}}"
看一下显示当前选中语言的标签的 Text 属性:
Text="{Binding CurrentLanguage, StringFormat=
'Language: {0}'}"
We’ve extracted the hardcoded `Language` part from the `StringFormat` property and defined two placeholders. By leveraging multi-binding, we can indicate the first placeholder should get the value associated with `Language` in the `AppResources`. The second placeholder is bound to the `CurrentLanguage` property on the ViewModel.Note that the `StringFormat` property starts with `{}`. Putting just `{0}: {1}` in there is not allowed, because the `{` curly brace is a special character in XAML, often signaling the start of a markup extension. `{}` is an escape sequence to handle this.
实施这些更改后,再次运行应用程序。转到 SettingsPage 并选择不同的语言。在确认切换应用程序的语言后,您不仅应该看到更新的语言中的警报出现,而且应该看到 SettingsPage 上的标签立即更新(图 12.7 )!
图 12.7:切换到法语之前(左)和之后(右)的设置页面
奇迹发生是因为我们的绑定针对的是LocalizedResourcesProvider类的索引器。因此,任何触发PropertyChanged事件(Item作为属性名)都会促使这些绑定重新评估。触发PropertyChanged事件是因为LocalizationManager在其_resourceProvider字段上调用UpdateCulture方法。
目前,所选的文化没有在应用程序启动之间持久化。更准确地说:所选的文化确实被存储了,但在应用程序启动时并没有被恢复。我们可以很容易地通过更新App类的构造函数来添加这个功能:
public App(INavigationInterceptor navigationInterceptor,
ILocalizationManager manager)
{
manager.RestorePreviousCulture();
...
}
通过在注入的ILocalizationManager接口上调用RestorePreviousCulture,正在恢复之前设置的 culture。这是在App类的构造函数中完成的,以便在应用程序的生命周期早期应用。
个人认为,这是一个美丽且非常 MVVM 友好的本地化解决方案;好吧,除了我们必须放置的相当复杂的数据绑定语句。在下一节中,我们将看到如何改进这一点。
使用自定义的 Translate 标记扩展
我们之前为我们的应用程序设置了一个本地化解决方案。虽然它非常有效,但数据绑定语句有点冗长,并且必须为每个字符串重复。在这个基础上,在本节中,我们将介绍一种简化的方法:一个专门为翻译设计的自定义标记扩展。
在我们继续之前,让我们简要回顾一下标记扩展是什么。标记扩展 提供了一种在运行时计算或检索属性值的方法,而不是仅仅将它们设置为静态值。这种功能使它们在诸如资源查找、数据绑定或在我们的情况下简化翻译检索等任务中特别有用。
重要的是要注意,我们并没有改变我们利用数据绑定绑定到资源的方式。相反,我们只是使 XAML 代码更容易编写和阅读。实际的数据绑定过程保持不变。这就是Translate标记扩展在 XAML 中的基本样子:
Title="{mauiloc:Translate SettingsTitle}"
这与以下内容同义:
Title="{Binding Path=[SettingsTitle], Source={x:Static
localization:LocalizedResourcesProvider.Instance},
Mode=OneWay}"
Translate标记扩展是一个常规数据绑定语句的包装器。让我们看看Localization.Maui项目中的TranslateExtension类。这个类实现了泛型的IMarkupExtension接口,因此它需要实现以下方法:
object IMarkupExtension.ProvideValue(IServiceProvider
serviceProvider);
T ProvideValue(IServiceProvider serviceProvider);
非泛型的ProvideValue方法将在运行时被调用,并且必须返回我们想要在 XAML 中使用的值。在这种情况下,我们想要返回一个Binding。以下是它的实现方式:
[ContentProperty(nameof(Key))]
public class TranslateExtension :
IMarkupExtension<Binding>
{
public string Key { get; set; }
object IMarkupExtension.ProvideValue(
IServiceProvider serviceProvider)
=> ProvideValue(serviceProvider);
public Binding ProvideValue(
IServiceProvider serviceProvider)
=> ...
}
非泛型的ProvideValue方法调用其泛型版本,该版本返回一个Binding。该类有一个名为Key的属性,它表示用于获取本地化值的键。这个Key属性可以在标记扩展中分配,如下所示:
Title="{mauiloc:Translate ContentProperty attribute applied to this class, we can omit the Key= segment, simplifying our markup extension’s use even more. ContentProperty specifies the default property that is to be used when no identifier is specified in XAML. Remember how we don’t need to explicitly state Path= in a data-binding statement and can simply write the path? This ContentProperty attribute is exactly what drives that! Let’s finally see what the generic ProvideValue method returns:
public Binding ProvideValue(
IServiceProvider serviceProvider)
=> new Binding
{
Mode = BindingMode.OneWay,
Path = $"[{Key}]",
Source = LocalizedResourcesProvider.Instance
};
The `ProvideValue` method returns a `Binding` object. Its `Source` value is set to the static `Instance` property of `LocalizedResourcesProvider`, equivalent to this:
{x:Static localization:LocalizedResourcesProvider.Instance}
For the `Path` property, when the `Key` value is `SettingsTitle`, it translates to `Path=[SettingsTitle]`. The `Binding`’s `Mode` is set to `OneWay` to ensure it listens for the `PropertyChanged` event and updates the bound value when needed.
The `Translate` markup extension provides a convenient way to define the exact same binding statements we had earlier. Let’s go ahead and update some of our Views so that they leverage this `Translate` markup extension:
1. Go to `SettingsPage` and add the following XML namespace, which points to the `Localization.Maui` namespace of the `Localization.Maui` project:
```
xmlns:mauiloc="clr-namespace:Localization.Maui;
assembly=Localization.Maui"
```cs
2. Update the binding statements on the `SettingsPage`, as shown here:
```
...
Title="{mauiloc:Translate SettingsTitle}"
...
Text="{mauiloc:Translate ChooseLanguage}"
...
Text="{mauiloc:Translate About}"
...
```cs
3. In `AppShell.xaml`, add the same XML namespace that we previously added to `SettingsPage` in *step 1*.
4. Now, update the binding statements on `AppShell.xaml`, as shown in the following snippet:
```
<Tab Title="{mauiloc:Translate Tab1Title}">
...
</Tab>
<Tab Title="{mauiloc:Translate Tab2Title}">
...
</Tab>
```cs
The `Translate` markup extension offers an elegant approach to what is typically considered a complex task. Everywhere hardcoded text is used, we can very simply replace it with this `TranslateExtension`. Note that the `TranslateExtension` can still be improved: all the typical data-binding properties such as `Converter`, `ConverterParameter`, and so on can be added to this class as well and be used in the `Binding` object that is being returned.
Now that we know how to localize hardcoded `copy` labels, let’s have a look at how we can fetch localized data from APIs.
Fetching localized data from APIs
Before we wrap up this chapter, let’s have a quick look at how we could pass the user’s language to the API so that it can return localized data. One approach is to include a language parameter in every service and repository method, allowing the ViewModel to pass the user’s current language. However, I believe adding such parameters can clutter the code. A cleaner alternative is to handle this within the repositories. Let’s see how:
1. First, let’s update the `IRecipeAPI` interface by adding a `language` parameter of type `string` to the `GetRecipes` method. The following snippet shows how we can configure Refit to pass this additional parameter as an `Accept-Language` request header when executing the API call:
```
Task<ApiResponse<RecipeOverviewItemsDto>>
GetRecipes([Header("Accept-Language")] string
Header 属性,但我认为这种方法更简洁。
```cs
2. It’s the `RecipeApiGateway` class that invokes the updated `GetRecipes` method. In order for it to access the current culture, let’s inject an instance of the `ILocalizationManager`, as shown here:
```
readonly ILocalizationManager _localizationManager;
...
public RecipeApiGateway(IRecipeApi api,
ILocalizationManager localizationManager)
{
_api = api;
_localizationManager = localizationManager;
}
```cs
3. The next code block shows how we can now use the `_localizationManager` field to access the current user culture and pass its `Name` property to the `GetRecipes` method:
```
public Task<Result<LoadRecipesResponse>>
LoadRecipes(int pageSize, int page)
=> InvokeAndMap(_api.GetRecipes(
_localizationManager.GetUserCulture().Name,
pageSize, page), MapRecipesOverview);
```cs
While directly using `CultureInfo.CurrentCulture.Name` might seem straightforward, accessing the culture via our `ILocalizationManager` ensures greater consistency, as mentioned earlier.
4. Accessing the `Accept-Language` that’s now being sent with this API call can be achieved by updating the `GetRecipes` endpoint in the `Program.cs` file inside the `Recipes.Web.Api` project. Here’s what the updated code looks like:
```
app.MapGet("/recipes", (int pageSize, int pageIndex,
[FromHeader(Name = "Accept-Language")] string
language) =>
{
//使用语言来检索菜谱
return new RecipeService()
.LoadRecipes(pageSize, pageIndex);
})
.WithName("GetRecipes")
.WithOpenApi();
```cs
In contrast to the data-bound values on the screen, changing the app’s language won’t automatically fetch any localized data coming from the API anew. So, how can we tackle this? In *Chapter 7*, *Dependency Injection, Services, and Messaging*, we already discussed thoroughly how ViewModels can communicate with each other in a loosely coupled manner through `Messaging`. `Messaging` offers a solution to this challenge: `SettingsViewModel` can send a message notifying other ViewModels about the updated language. ViewModels can react to this and re-fetch their data. Let’s implement this:
1. Add a new class called `CultureChangedMessage` to the `Messages` folder of the `Recipes.Client.Core` project. Here’s what it looks like:
```
public class CultureChangedMessage :
ValueChangedMessage<CultureInfo>
{
public CultureChangedMessage(CultureInfo value) :
base(value)
{ }
}
```cs
2. Update the `SwitchLanguage` method on `SettingsViewModel`, as shown here:
```
private void SwitchLanguage(string newLanguage)
{
CurrentLanguage = newLanguage;
var newCulture = new CultureInfo(newLanguage);
_localizationManager
.UpdateUserCulture(newCulture);
WeakReferenceMessenger.Default.Send(
new CultureChangedMessage(newCulture));
}
```cs
3. Finally, in the constructor of the `RecipesOverviewViewModel`, we can add the following code that listens for the `CultureChangedMessage` to arrive so that we can reload the list of recipes:
```
WeakReferenceMessenger.Default
.Register<CultureChangedMessage>(this, (r, m) =>
{
Recipes.Clear();
(r as RecipesOverviewViewModel).LoadRecipes(7, 0);
});
```cs
We don’t need to bother with passing the updated culture or language around. Once the message is received, the `LocalizationManager` is already updated to the selected culture and will return the newly selected culture. This ensures that any new recipe fetch will use the updated culture.
When running the app and changing the language on the `SettingsPage`, the `RecipesOverviewViewModel` will reload its recipes. If you debug and set a breakpoint in the API, you’ll observe that the language parameter consistently matches the newly selected language.
Summary
We kicked off this chapter with an introduction to localization, understanding its importance in ensuring our app resonates with users globally. Before diving deep, we explored the basics of how localizable values can be statically bound, offering a foundational approach.
Building on this, we introduced a more dynamic localization framework. This allowed for more flexible updates and interactions. Following this, we delved into simplifying our XAML through the `Translate` markup extension. While it made our data-binding statements sleeker, the underlying mechanism remained unchanged.
Next, we discussed getting localized data from our APIs. We found a neat way to tell the API about the user’s language choice without making our code messy. By using the `ILocalizationManager`, we kept our approach consistent. And, with `Messaging`, our app knows when to fetch new data if a user changes their language.
The big takeaway? All our steps respected the key MVVM idea of “separation of concerns.” Each part of our app has its job, making things organized and easier to manage.
Note
Be aware that what we’ve seen throughout this chapter doesn’t completely cover everything there is to localizing your apps, such as localizing the app’s name or localizing images. Take a look at [`learn.microsoft.com/dotnet/maui/fundamentals/localization`](https://learn.microsoft.com/dotnet/maui/fundamentals/localization) to find out more!
As we wrap up this chapter, we’ve truly come a long way in building the *Recipes!* app, while adhering to the MVVM principles. But, of course, no app development journey is complete without ensuring its robustness. And that’s where the next chapter comes in. We’ll dive into how MVVM isn’t just about structuring our app effectively, but also about setting the stage for thorough and effective unit testing.
Further reading
To learn more about the topics that were covered in this chapter, take a look at the following resources:
* The `ResourceManager` class: [`learn.microsoft.com/dotnet/api/system.resources.resourcemanager?view=net-8.0`](https://learn.microsoft.com/dotnet/api/system.resources.resourcemanager?view=net-8.0)
* *XAML markup* *extensions*: [`learn.microsoft.com/dotnet/maui/xaml/fundamentals/markup-extensions`](https://learn.microsoft.com/dotnet/maui/xaml/fundamentals/markup-extensions)
* *Create XAML markup* *extensions*: [`learn.microsoft.com/dotnet/maui/xaml/markup-extensions/create`](https://learn.microsoft.com/dotnet/maui/xaml/markup-extensions/create)
第十三章:单元测试
让我们深入探讨一个关键点:单元测试 。把它想象成你的安全网。这不仅仅是知道你的应用程序现在运行顺畅,而是确保在每次调整、更新或彻底改造后,你的应用程序都能平稳运行,没有故障或意外惊喜。回归 bug?我们正在关注你!使用 MVVM 和正确的测试实践,我们可以有效地防范这些潜在问题。
在本章中,我们将解决以下问题:
单元测试的重要性
设置单元测试项目
使用 Bogus 生成数据
使用 Moq 模拟依赖
测试 MAUI 特定的代码
虽然我们不会深入细节(毕竟,根据你使用的工具,复杂性可能会有很大差异),但我将使用我熟悉的工具集来引导你:xUnit 、Bogus 、AutoBogus 和Moq 。这些是我的首选构建块,但让我们记住:.NET 生态系统庞大而灵活。还有许多其他出色的框架和库,例如NUnit 、AutoFixture 、NSubstitute 等。我们涵盖的原则将大致保持不变;这只是哪个工具与你的工作流程相协调的问题。最终,一切都关乎个人喜好。
到本章结束时,你应该坚信单元测试带来的巨大价值。此外,你将清楚地了解如何有效地使用工具和技术来编写单元测试。
技术要求
为了确保你与即将到来的内容保持同步,请访问我们的 GitHub 仓库:github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter13 。从Start文件夹中的材料开始。记住——如果你需要综合参考,Finish文件夹在章节结束时包含了最终、精炼的代码。
单元测试的重要性
我通过艰难的方式学到的教训永远改变了我对软件开发的态度:永远不要低估 单元测试。
几年前,我曾是制作一款雄心勃勃的应用程序的专业团队的一员。我们在 C#和平台方面的专业知识无可否认。然而,我们忽视了单元测试,将我们的信任寄托在手动测试和我们的质量保证 (QA )团队上。最终产品得到了高度认可和赞扬,但过程是动荡的。QA 的反馈经常揭示 bug,使得每次代码调整都感觉风险重重。
接近截止日期意味着失眠的夜晚、匆忙的 bug 修复以及即将到来的回归恐惧。项目完成后,我与不同背景的开发者合作,遇到了一位对测试驱动开发 (TDD )投入极大的同事。这真是一个真正的启发,不仅对单元测试的本质,也对我们之前设计选择中的缺陷有了深刻的认识。
我的单元测试之旅
效率 :不再需要在手动验证之前进行长期部署。单元测试迅速验证我的代码,简化了开发过程。
质量和信心 :随着每个测试的进行,软件的质量提高,我的信心也随之增强。它成为了一个安全网,允许探索性编码而不必担心意外的后果。
防止回归 :单元测试确保更改不会无意中破坏现有功能。
随着时间的推移,我也观察到单元测试充当着不断演变的文档。新团队成员可以从这些测试中确定预期的行为和逻辑,从而加快集成并增强代码修改的信心。
我和我的老队友们时不时还会聚在一起。我们谈论过去的日子和共同的经历。在我们的对话中,一个共同的认知脱颖而出:在我们共同度过的时光里,我们每个人都在各自的项目中成熟起来。这往往导致我们进行相互反思:如果我们多年前在那个项目中就采用了单元测试,我们的工作可能会更加顺利。请别误会;我们的客户对我们交付的成果感到满意。但对我们所有人来说,从个人层面来看,从一开始就有测试可能会节省许多压力之夜、健康问题和不确定性。
虽然单元测试在许多开发者圈子中受到赞誉,但我仍然目睹了犹豫不决,尤其是在.NET 世界的某些部分。我的倡导并不是为了达到一个覆盖率指标或 TDD 狂热。它是为了认识到单元测试的好处,从确保代码可靠性到提升团队士气。
单元测试不是关于达到一个抽象的完美概念。它是关于拥有一个安全网,允许进行代码杂技表演而不必担心出错。我是否为每一行代码都编写了测试?坦白说:不是。我的所有代码都容易测试吗?并不总是。但我编写的测试确实很有帮助。它们让我快速知道事情是否正常工作,是否破坏了某些东西,或者是否正确地修复了一个错误。这让我对自己的工作充满信心。当报告错误时,我首先做的事情就是编写一个失败的测试,暴露这个错误。然后我可以继续处理这个错误,一旦测试通过,我就知道我已经修复了它。而且,不仅错误现在已经被修复,而且由于额外的测试(或测试),代码变得更加健壮,并得到了对未来回归的保护。
在我们这个快节奏的世界中,软件持续发展,单元测试不仅是一种最佳实践,更是一条生命线。它带来的安心感是无与伦比的,确保软件不仅能够运行,而且能够抵御不可避免的变化。
对于刚开始开发者旅程或重新评估单元测试的人来说:不要模仿我最初的疏忽。将测试看作是一项任务,而不是一个可靠的伙伴。
话虽如此,是时候从“为什么”转向“如何”了。对于那些仍然和我在一起的人,让我们深入了解细节:为 ViewModel 设置单元测试。相信我,这比看起来简单得多!
设置单元测试项目
在本节中,我们将逐步介绍设置单元测试项目并创建第一个测试的步骤。我们将在本节中使用 xUnit。我们不会深入探讨这个特定库的细节,因为还有许多其他出色的库。无论你选择哪个,重要的收获都应该记住。所以,无需多言,让我们开始设置有效的 ViewModel 测试的舞台!
创建单元测试项目
让我们先创建一个 xUnit 测试项目。在 xUnit 中,我欣赏的一点是它的简单性。测试类和方法只是普通的类和方法,无需特殊基类或复杂的设置。以下是步骤:
在 Visual Studio 的 解决方案资源管理器 中,右键单击 “Recipes App” 解决方案 ,然后选择 添加 | 新建项目… 。
在搜索框中输入 xunit 并从列表中选择 xUnit 测试项目 (图 13.1 ):
图 13.1:创建新的 xUnit 测试项目
将 Recipes.Client.Core.UnitTests 作为项目名称。点击 下一步 。
当提示时,从 框架 列表中选择 .NET 8.0 (长期支持 ),然后点击 创建 按钮。
一旦项目生成,右键单击它,从列表中选择 Recipes.Client.Core 项目。
在项目及其对 Recipes.Client.Core 项目的引用就绪后,我们可以开始编写我们的单元测试。让我们继续编写我们的第一个测试!
创建单元测试
让我们先从测试一些相对简单的事情开始:测试 RecipeListItemViewModel 的初始化。以下是步骤:
删除生成的 UnitTest1.cs 文件。
将一个名为 RecipeListItemViewModelTests 的新 C# 类添加到项目中。
将类设置为公共的,并添加一个名为 ViewModel_Initialized_PropertiesSetCorrectly 的公共方法,如下面的代码片段所示:
[Fact]
public void
ViewModel_Initialized_PropertiesSetCorrectly()
{
}
注意如何将 Fact 属性添加到这个方法中。这个属性表示这是一个测试方法。如果没有它,这个方法就不会被识别为测试方法,因此在测试运行期间也不会作为测试方法执行。
将以下代码添加到这个方法中:
//Arrange
string id = "id1";
string title = "title1";
bool isFavorite = false;
string image = "image1";
//Act
var sut = new RecipeListItemViewModel(id, title,
isFavorite, image);
//Assert
Assert.Equal(id, sut.Id);
Assert.Equal(title, sut.Title);
Assert.Equal(isFavorite, sut.IsFavorite);
Assert.Equal(image, sut.Image);
这将创建并验证一个 RecipeListItemViewModel 实例。此代码块首先使用示例数据创建 RecipeListItemViewModel 类的实例。然后调用一系列断言以确认对象的属性已按预期初始化。
在单元测试术语中,我们实例化的对象被称为 sut,代表 系统测试对象 。这是一个在单元测试中常用的名称。
最后,值得特别注意的是,xUnit 框架固有的 Assert 语句的作用。Assert 方法在验证对象状态以确保它们符合我们的预期中起着关键作用。Assert.True、Assert.Empty、Assert.Contains 以及更多方法都在我们的掌握之中。正如本例所示,Assert.Equal 方法评估预期值是否与对象的实际值匹配。在这种情况下,它确保我们的 sut 对象的属性,如 Id、Title、IsFavorite 和 Image,被初始化为预期值。
在前面的代码片段中,你可能注意到了清晰的架构,这是由注释引导的://Arrange、//Act 和 //Assert。这对应于单元测试中的一个基本模式,称为 Arrange-Act-Assert (AAA )。让我们简要地探讨一下每个阶段的意义:
Arrange: 这个阶段涉及为测试设置任何先决条件。我们确定测试运行的条件。这可能包括初始化变量、创建模拟对象或设置资源。在我们的例子中,这是定义我们的样本数据的地方:id、title、isFavorite 和 image。
Act: 在这里,我们执行我们打算测试的操作。这是 sut 对象被调用的地方,通常是一个单一的操作。在我们的上下文中,这是使用我们安排的样本数据实例化 RecipeListItemViewModel。
Assert: 这个最终阶段是通过检查结果与预期结果是否一致来验证测试是否通过或失败。在我们的例子中,这是通过使用 Assert.Equal 方法来确保我们的 RecipeListItemViewModel 对象的属性与我们初始化它们的值相匹配来完成的。
遵循 AAA 模式可以确保测试是有组织和可读的,这使得你或任何审查你代码的人更容易理解每个测试的目的和行为。
现在运行测试吧!这可以通过在要测试的测试方法内部右键单击并选择 运行测试 来完成。或者,你可以选择 调试测试 ,这将运行你的测试并在你添加的任何断点处中断,允许你逐步执行单元测试。测试方法在 Visual Studio 的 测试资源管理器 中也是可见的。从那里,你可以轻松地运行多个测试。此面板还显示了你的测试的当前状态:哪些成功运行,哪些失败,以及其他相关信息,如图 图 13.2 所示:
图 13.2:Visual Studio 的测试资源管理器
让我们添加一些更多的测试!RecipeListItemViewModel的每个实例都应该监听FavoriteUpdateMessage,当这样的消息到达时,它的IsFavorite属性应该相应更新。所以,首先,让我们编写一个测试来验证新实例化的RecipeListItemViewModel已注册为FavoriteUpdateMessage消息的接收者。其次,在单独的测试中,我们可以检查类是否对这样的消息做出预期的反应:
让我们从向RecipeListItemViewModelTests类添加以下测试开始:
[Fact]
public void
VM_Initialized_SubscribedToFavoriteUpdateMessage()
{
//Arrange, Act
var sut = new RecipeListItemViewModel(
"id", "title", true, "image");
//Assert
Assert.True(WeakReferenceMessenger.Default
.IsRegistered<FavoriteUpdateMessage>(sut));
}
WeakReferenceMessenger.Default.IsRegistered方法允许我们检查特定对象是否已注册接收特定消息。我们期望我们的sut已注册接收FavoriteUpdateMessage,因此我们可以调用此方法,并使用Assert.True验证结果为真。
我们还应该验证,当sut接收到FavoriteUpdateMessage时,IsFavorite属性会相应更新——当然,前提是消息发送的 id 与sut的 id 匹配。看看这里:
[Fact]
public void
FavoriteUpdateMsgReceived_SameId_FavoriteUpdated()
{
//Arrange
var id = "id";
var originalValue = false;
var updateToValue= !originalValue;
var sut = new RecipeListItemViewModel(
"someid", "title", originalValue, "image");
//Act
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(
id, updatedFavorite));
//Assert
Assert.Equal(updatedFavorite, sut.IsFavorite);
}
FavoriteUpdateMessage发送用于实例化sut的originalValue值的逆值。在发送包含与sut相同的RecipeId的消息后,我们可以检查IsFavorite属性的值是否等于我们发送的值。
作为最后的测试,我们可能想要验证RecipeListItemViewModel不会对具有不同RecipeId的FavoriteMessage做出反应。这和之前的测试非常相似,正如你在这里看到的:
[Fact]
public void FavoriteUpdateMsgReceived_DifferentId
_FavoriteNotUpdated()
{
//Arrange
var originalValue = false;
var updateToValue= !originalValue;
var sut = new RecipeListItemViewModel(
"someid", "title", originalValue, "image");
//Act
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(
"otherid", updatedFavorite));
//Assert
Assert.Equal(originalValue, sut.IsFavorite);
}
因为消息发送的RecipeId与RecipeListItemViewModel上的 ID 不同,我们想要验证sut上的IsFavorite属性仍然与最初设置的值相同。
单元测试中的“单元”
当我们谈论单元测试 时,那个单元 部分至关重要。这全部关于保持事物小而专注。看看我们刚刚创建的测试。第一个确保 ViewModel 已注册接收传入的FavoriteUpdateMessage,而另一个则检查 ViewModel 如何响应传入的消息。当然——后者间接验证了消息监听器,但这并不意味着我们可以跳过第一个测试。每个单元 测试都应该专注于检查拼图中的一小部分,以确保每个部分都按预期工作。
在第一个测试通过后,让我们看看如何使它们更具数据驱动性。
查看数据驱动测试
将第三个参数设为false。现在,如果我们从RecipeListItemViewModel类的构造函数中移除IsFavorite = isFavorite;,我们的测试仍然会欺骗性地通过——这是一个测试中的假阳性经典例子。这是因为,出于巧合,IsFavorite的默认值是false,但实际上,它从未被分配我们作为参数传递的值。我们可以创建另一个具有不同值的Theory和InlineData属性的其他测试方法,以利用数据驱动测试。
在 xUnit 中,虽然Fact属性表示一个简单的单元测试,它只运行一次,但还有一个强大的功能:Theory属性。与InlineData属性结合使用,Theory允许我们创建参数化测试。这意味着我们可以用不同的输入值运行相同的测试逻辑,确保我们的代码能够应对各种场景,而无需重复测试方法。让我们深入探讨并重构我们之前的测试,以利用这一功能:
修改ViewModel_Initialized_PropertiesSetCorrectly方法,包括以下参数:
public void
ViewModel_Initialized_PropertiesSetCorrectly(
string id, string title, bool isFavorite,
Arrange phase and use the provided parameters directly to instantiate our ViewModel:
//Arrange, Act
var sut = new RecipeListItemViewModel(id, title,
isFavorite, image);
//Assert
Assert.Equal(id, sut.Id);
Assert.Equal(title, sut.Title);
Assert.Equal(isFavorite, sut.IsFavorite);
Assert.Equal(image, sut.Image);
现在我们从这个方法中移除Fact属性,并添加一个Theory属性和一些InlineData属性:
[Theory]
[InlineData("id1", "title1", false, "image1")]
[InlineData("id2", "title2", true, "image2")]
[InlineData("foo", "bar", true, null)]
[InlineData(null, null, false, null)]
Theory属性用于标记一个测试方法为数据驱动测试。这意味着测试方法将为使用InlineData属性指定的每组数据值执行一次。每个测试运行的测试都将访问由InlineData提供的值。当Theory方法执行时,xUnit 将为InlineData属性中指定的每组数据值创建测试类的新的实例,然后使用这些数据值执行测试方法。InlineData属性中提供的值应与正在测试的方法上的参数数量和类型完全匹配。
这是我们将FavoriteUpdateMsgReceived_SameId_FavoriteUpdated方法更新为数据驱动测试方法的示例:
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
[InlineData(false, false)]
public void
FavoriteUpdateMsgReceived_SameId_FavoriteUpdated(
bool originalValue, bool updateToValue)
{
//Arrange
var id = "id";
var sut = new RecipeListItemViewModel(
id, "title", originalValue, "image");
//Act
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(id, updateToValue));
//Assert
Assert.Equal(updateToValue, sut.IsFavorite);
}
FavoriteUpdateMsgReceived_DifferentId_FavoriteNotUpdated可以像下面这样更新:
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
[InlineData(false, false)]
public void FavoriteUpdateMsgReceived_
DifferentId_FavoriteNotUpdated(
bool originalValue, bool updateToValue)
{
//Arrange
var sut = new RecipeListItemViewModel(
"someid", "title", originalValue, "image");
//Act
WeakReferenceMessenger.Default.Send(
new FavoriteUpdateMessage(
"otherid", updateToValue));
//Assert
Assert.Equal(originalValue, sut.IsFavorite);
}
通过采用Theory和InlineData属性,我们显著增强了我们的测试能力。这些更新的测试方法现在可以验证一系列不同的值,确保我们的 ViewModel 在多种场景下表现一致。这是一种优雅的方式来增加测试覆盖率,而无需添加冗余代码。
有时,让我们自动生成测试数据是有益的,尤其是当我们更关注逻辑而不是具体值时。这正是 Bogus 和 AutoBogus 等工具发挥作用的地方,它们帮助我们无需手动干预就能轻松生成各种测试值。让我们看看吧!
使用 Bogus 生成数据
虽然在单元测试中手动选择数据有其位置,但它往往带有固有的偏见:因为我们已经编写了我们想要测试的功能,所以我们对于使用的值的格式有一定的期望。为了对抗这种偏见,许多测试受益于随机化或生成数据,特别是当具体输入不如结果重要时。Bogus 是一个针对那些需要可靠、无偏见的测试数据而不需要手动劳动的时刻的强大工具。
在本节中,我们将介绍 Bogus 和 AutoBogus,并探索其一些基本功能。然而,值得注意的是,我们在这里只是触及了表面。这些工具提供了许多功能,但为了简洁和专注,我们将保持讨论的高级性,仅涉及一些基本用例。
首先,让我们将 AutoBogus(以及因此也包含 Bogus)添加到我们的项目中:
在 Recipes.Client.Core.UnitTests 项目中,选择 Manage NuGet Packages… 。
在 autobogus 中安装并选择 Manage NuGet Packages… 。
图 13.3:AutoBogus NuGet 包
现在我们已经安装了这些包,让我们通过测试 EmptyOrWithinRangeAttribute 类来将这个库付诸实践:
创建一个名为 EmptyOrWithinRangeAttributeTests 的新类。
将以下成员变量放入新类中:
const int MinValueStart = 5;
const int MinValueEnd = 10;
const int MaxValueStart = 11;
const int MaxValueEnd = 15;
readonly EmptyOrWithinRangeAttribute sut;
在这个类中,我们将把我们的 sut 对象作为类成员。这允许我们在构造函数(或内联)中一次性编写 sut 对象的实例化代码,从而防止在每个测试中设置重复的代码。由于每个测试方法都在 EmptyOrWithinRangeAttributeTests 类的单独实例中运行,这也对其他测试没有副作用。
为类添加一个构造函数,并添加以下代码:
sut = new Faker<EmptyOrWithinRangeAttribute>()
.RuleFor(r => r.MinLength, f =>
f.Random.Int(MinValueStart, MinValueEnd))
.RuleFor(r => r.MaxLength, f =>
f.Random.Int(MaxValueStart, MaxValueEnd))
.Generate();
我们不是手动创建 EmptyOrWithinRangeAttribute 类的实例,而是将此委托给 Bogus 框架。我们通过实例化一个新的 Faker 类,传入我们想要它生成的类型来实现这一点。通过 Faker 类的 RuleFor 方法,我们可以配置单个属性的值。
在 RuleFor 方法中,我们首先需要指向我们想要配置的属性。这个方法的下一个参数是一个带有 Faker 类作为参数的函数,它允许我们定义属性的值。在这种情况下,我们使用 Faker 的 Random.Int 方法来表示我们想要它在两个值之间生成一个生成的 int 值。
通过调用 Generate 方法,Bogus 框架将生成我们想要的类型的实例,并遵循我们定义的规则。这会返回我们想要与之工作的 EmptyOrWithinRangeAttribute 实例。
让我们继续实现一个测试,检查 EmptyOrWithinRangeAttribute 是否正确验证输入:
将 Value_WithinRange_IsValid 添加到这个类中,如下所示:
[Fact]
public void Value_WithinRange_IsValid()
{
//Arrange
var input = new Faker().Random.String2(
sut.MinLength, sut.MaxLength);
//Act
var isValid = sut.IsValid(input);
//Assert
Assert.True(isValid);
}
在 Arrange 步骤中,我们使用 Bogus 的 Faker 类生成一个随机字符串,其长度介于 sut 的 MinLength 和 MaxLength 属性之间。记住——这些 MinLength 和 MaxLength 属性是由 Bogus 生成的值。现在,这个生成的字符串值可以用来检查 EmptyOrWithingRangeAttribute 的 IsValid 方法是否如我们所期望的那样工作。返回的值应该是 true,我们可以很容易地使用 Assert.True 方法来检查。
类似地,我们可以添加一个 Value_TooShort_IsNotValid 方法来检查当提供的值不正确时,IsValid 方法返回 false:
[Fact]
public void Value_TooShort_IsNotValid()
{
//Arrange
var input = new Faker().Random.String2(
1, MinValueStart - 1);
//Act
var isValid = sut.IsValid(input);
//Assert
Assert.False(isValid);
}
当我们进行这项工作时,让我们也添加一个检查,看看是否返回空字符串被视为有效。这将在下面的代码块中展示:
[Fact]
public void Value_Empty_IsValid()
{
//Arrange
var input = string.Empty;
//Act
var isValid = sut.IsValid(input);
//Assert
Assert.True(isValid);
}
注意这些测试方法是多么简短和直接!添加额外的检查以确保输入值过长或为空实际上并不困难。而且因为我们不使用硬编码的数据,我们可以相当有信心 EmptyOrWithingRangeAttributes 类的 IsValid 方法在许多不同场景下都能按预期工作。除此之外,想想我们验证这个 ValidationAttribute 类的速度和效率。我们还没有部署我们的应用程序或点击其界面。尽管如此,我们已经开始确信我们代码的正确性。这极大地提高了开发效率!
记得我们之前在 RecipeListItemViewModelTests 类中编写的 VM_Initialized_SubscribedToFavoriteUpdateMessage 方法吗?这个方法检查 RecipeListItemViewModel 的一个实例是否已注册为 FavoriteUpdateMessage 的接收者。在这个测试方法中,我们必须初始化我们的系统单元(sut)。然而,用于创建它的值在测试中没有任何价值:无论使用什么值,实例都应该监听 FavoriteUpdateMessage。这是一个引入 AutoBogus 的理想场景。只需看看 VM_Initialized_SubscribedToFavoriteUpdateMessage 方法的更新代码:
//Arrange, Act
var sut = AutoFaker.Generate<RecipeListItemViewModel>();
//Assert
Assert.True(WeakReferenceMessenger.Default
.IsRegistered<FavoriteUpdateMessage>(sut));
通过 AutoFaker 类的静态 Generate 方法,我们可以生成 RecipeListItemViewModel 的一个实例。Faker 类通常需要一个默认构造函数来实例化我们想要创建的对象类型。RecipeListItemViewModel 没有默认构造函数,这就是 AutoFaker 发挥作用的地方:它会自动为参数提供假值。我们基本上不关心用于创建这个类的值是什么。在这个测试中,我们感兴趣的唯一事实是实例化的类已注册为 FavoriteUpdateMessage。
在其他测试方法中,例如 RecipeListItemViewModelTests 中的方法,我们依赖于硬编码的虚拟数据。我们也可以使用 AutoBogus 来消除这些硬编码的值。看看更新后的 FavoriteUpdateMsgReceived_SameId_FavoriteUpdated 方法:
public void
FavoriteUpdateMsgReceived_SameId_FavoriteUpdated(
bool originalValue, bool updateToValue)
{
//Arrange
var id = AutoFaker.Generate<string>();
var sut = new RecipeListItemViewModel(id,
AutoFaker.Generate<string>(),
originalValue,
AutoFaker.Generate<string>());
//Act
WeakReferenceMessenger.Default.Send(new
FavoriteUpdateMessage(id, updateToValue));
//Assert
Assert.Equal(updateToValue, sut.IsFavorite);
}
在这个之前的代码块中,我们正在利用 AutoBogus 的 Generate 方法为我们生成随机数据。
Bogus 和 AutoBogus 通过生成无偏、随机的测试数据来自动化 .NET 测试,从而消除手动和可能存在偏见的输入。
除了提供假数据之外,我们通常需要模拟整个组件或行为。这就是专门的模拟框架,如 Moq,变得非常有价值的地方。让我们深入了解 Moq 如何帮助我们有效地模拟依赖项并简化我们的测试过程。
使用 Moq 模拟依赖项
测试往往涉及我们的系统与外部依赖项交互的场景,这些依赖项可能是数据库、API 或其他服务。针对这些真实依赖项运行测试可能导致缓慢、不可预测的结果,并可能产生不希望出现的副作用。模拟通过模拟这些依赖项提供了解决方案,确保我们的测试纯粹关注于当前组件。通过模拟,我们控制了外部交互,确保我们的测试快速、可靠且不受外部影响。
集成测试
在编写单元测试时,我们通常希望尽可能多地模拟外部依赖。然而,测试不同组件的集成以确保它们无缝协作通常也很有价值。这就是集成测试的用武之地。与侧重于模拟以测试独立单元的单元测试不同,集成测试通常涉及较少的模拟。这确保了组件以我们期望的确切方式相互交互,验证它们作为一个统一整体的行为是否正确。
DI 和关注点分离的原则强调了这种方法。当我们设计组件以解耦并注入它们的依赖项时,在测试期间用模拟版本替换真实依赖项变得无缝。将 DI、SoC 和模拟视为相互咬合的拼图碎片,每个都补充了其他,从而形成全面且可维护的测试策略。现在,让我们深入了解 Moq 如何帮助我们实现这一目标。
当我们检查RecipeDetailViewModel的构造函数时,很明显它依赖于几个服务:
public RecipeDetailViewModel(
IRecipeService recipeService,
IFavoritesService favoritesService,
IRatingsService ratingsService,
INavigationService navigationService,
IDialogService dialogService)
{
...
}
为了有效地测试这个 ViewModel,我们需要抽象出其外部依赖。可以通过以下方式模拟recipeService参数:
var recipeServiceMock = new Mock<IRecipeService>();
然后将模拟的值传递给RecipeDetailViewModel如下:
var sut = new RecipeDetailViewModel
(recipeServiceMock with fake method implementations. We can configure our mock object with specific method behaviors. Consider the LoadRecipe method of IRecipeService:
Task<Result> LoadRecipe(string id);
Here’s how to instruct Moq to mimic this method:
recipeServiceMock
.Setup(m => m.LoadRecipe(It.IsAny()))
.ReturnsAsync(
Result.Success(new RecipeDetail(...));
With the `Setup` method, we can tell Moq which method we want to simulate. In our example, we’re targeting the `LoadRecipe` method. `It.IsAny<string>` is a matcher, which signifies that we’re indifferent to the exact value passed into the method. In simpler terms, any string value will trigger the behavior we’re defining here. Speaking of which, `ReturnsAsync` specifies the result our mock method should produce. For example, we’re returning a successful result containing dummy `RecipeDetail` data. In essence, this code configures `recipeServiceMock` to always produce a specific result for any call to `LoadRecipe`, ensuring our tests are predictable and not reliant on real implementations. Let’s see how we can add a set of new tests.
Applying mocking in our ViewModel tests
To start testing `RecipeDetailViewModel`, let’s add a new class named `RecipeDetailViewModelTests` to the test project and follow these steps:
1. Add the following fields to the `RecipeDetailViewModelTests` class:
```
readonly Mock<IRecipeService> _recipeServiceMock;
readonly Mock<IFavoritesService>
_favoritesServiceMock;
readonly Mock<IRatingsService> _ratingsServiceMock;
readonly Mock<INavigationService>
_navigationServiceMock;
readonly Mock<IDialogService> _dialogServiceMock;
readonly RecipeDetailViewModel sut;
```cs
2. Instantiate these fields in the class’s constructor, as shown here:
```
public RecipeDetailViewModelTests()
{
_recipeServiceMock = new();
_favoritesServiceMock = new();
_ratingsServiceMock = new();
_navigationServiceMock = new();
_dialogServiceMock = new();
_ratingsServiceMock
.Setup(m =>
m.LoadRatingsSummary(It.IsAny<string>()))
.ReturnsAsync(Result<RatingsSummary>.Success(
AutoFaker.Generate<RatingsSummary>()));
sut = new RecipeDetailViewModel(
_recipeServiceMock.Object,
_favoritesServiceMock.Object,
_ratingsServiceMock.Object,
_navigationServiceMock.Object,
_dialogServiceMock.Object);
}
```cs
In this previous code block, we’re instantiating all of our mock classes and using them to instantiate the `sut` object. By putting this in the constructor, we don’t have to repeat this code in every unit test. We can even already provide some default mock implementations, as shown with `_ratingServiceMock`. If, for a specific test, we need a different behavior for `_ratingServiceMock`, we can easily override the default behavior set in the constructor in the test itself. When we specify a new behavior in the test method, Moq will use the most recent setup, ensuring flexibility in our tests.
3. In the first test, we want to validate that the parameter that is being used to navigate to the detail page, is effectively being passed to the injected `IRecipeService`’s `LoadRecipe` method to retrieve the detail information. Start by adding the following method to this class:
```
[Fact]
public async Task OnNavigatedTo_Should_Load_Recipe()
{
...
}
```cs
4. Let’s have a look at this test’s `Arrange` step:
```
//Arrange
var recipeId = AutoFaker.Generate<string>();
var parameters = new Dictionary<string, object> {
{ "id", recipeId }
};
_recipeServiceMock
.Setup(m => m.LoadRecipe(It.IsAny<string>()))
.ReturnsAsync(Result<RecipeDetail>
.Success(AutoFaker.Generate<RecipeDetail>()));
```cs
In the `Arrange` step, we’re generating a `recipeId` using `AutoFaker`. This value is put in a Dictionary named parameters which we’ll use in the next step. We’re also configuring the behavior of the `_recipeServiceMock`’s `LoadRecipe` method. As we’re not interested in what exactly is being returned in this test, we’ll leave it to `AutoFaker` to generate a `RecipeDetail` instance.
5. Add the following code below the `Arrange` steps:
```
//Act
await sut.OnNavigatedTo(parameters);
```cs
By calling the ViewModel’s `OnNavigatedTo` method and passing a dictionary, we can mimic navigating to the ViewModel. This should trigger the load of the recipe’s detail information, using the passed-in `"id"` item from the dictionary.
6. Validating whether the `LoadRecipe` method of the injected `IRecipeService` is correctly called can be achieved like this:
```
//Assert
_recipeServiceMock.Verify(
m => m.LoadRecipe(recipeId), Times.Once);
```cs
The `Verify` method on a `Mock` object allows us to check whether a specific method was invoked or not. Note that we’re explicitly specifying that the `LoadRecipe` should have been called with the `recipeId` parameter. Also, with `Times.Once` we define that the method, with the given parameter, should have been called exactly once. If that isn’t the case, an exception will be thrown that will fail the test.
This powerful feature of Moq ensures that certain interactions (method calls) take place as expected. But by using Moq, we can also make our tests predictable, allowing us to check for particular output values. The following test shows how we can validate whether the data returned by the `IRecipeService`’s `LoadRecipe` method is correctly mapped on to the ViewModel:
[Fact]
public async Task OnNavigatedTo_Should_Map_RecipeDetail()
{
//Arrange
var recipeDetail = AutoFaker.Generate();
var parameters = new Dictionary<string, object> {
{ "id", AutoFaker.Generate() }
};
_recipeServiceMock
.Setup(m => m.LoadRecipe(It.IsAny()))
.ReturnsAsync(Result
.Success(recipeDetail));
//Act
await sut.OnNavigatedTo(parameters);
//Assert
Assert.Equal(recipeDetail.Name, sut.Title);
Assert.Equal(recipeDetail.Author, sut.Author);
}
A generated `recipeDetail` is what the `_recipeServiceMock`’s `LoadRecipe` method returns. After navigating to the ViewModel, we can check whether the properties match the value of the returned `recipeDetail` variable, assuring us the values are correctly mapped.
Thin UI, deep tests
One of the pinnacle benefits of the MVVM pattern, especially when coupled with DI, is the depth of our unit testing capability. Let’s look at an example that demonstrates this. Traditionally, interactions such as dialog prompts or navigation might be considered to be UI testing. But here, we’ll see how we can validate these interactions through simple unit tests. The `IDialogService` and `INavigationService`, while seeming intrinsically linked to UI, are injected as platform-independent dependencies. This abstraction ensures that our tests remain agnostic to the final UI layer, whether it’s a mobile app, a web interface, or desktop software. As a result, the UI layer remains incredibly thin, and our confidence in the bulk of our application logic – verified through these tests — remains high. Let’s dive into an example: when the `RecipeDetailViewModel` is unable to load the recipe detail, a prompt should be shown asking the user to retry. If the user selects **No**, the app should automatically navigate back to the previous page. Here’s what this test looks like:
[Fact]
public async Task FailedLoad_Should_ShowDialog()
{
//Arrange
var parameters = new Dictionary<string, object> {
{ "id", AutoFaker.Generate() }
};
_recipeServiceMock
.Setup(m => m.LoadRecipe(It.IsAny()))
.ReturnsAsync(Result
.Fail(AutoFaker.Generate()));
_dialogServiceMock
.Setup(m => m.AskYesNo(It.IsAny(), ...))
.ReturnsAsync(false);
//Act
await sut.OnNavigatedTo(parameters);
//Assert
_dialogServiceMock.Verify(m => m.AskYesNo(It.IsAny(), ...), Times.Once);
_navigationServiceMock.Verify(m => m.GoBack(),
Times.Once);
}
In the previous code block, we’re configuring `_recipeServiceMock` so that it returns a `Fail` result every time the `LoadRecipe` method is called. `_dialogServiceMock` is configured so that when the `AskYesNo` method is invoked, a `false` value is returned. This mimics the user selecting `No` in the presented dialog. With all this in place, we can check that the dialog is being shown and that back navigation is triggered as a result of the user selecting `No` in the retry dialog.
This demonstrates how, with the right architecture and tools, even intricate interactions that touch upon UI elements can be captured, controlled, and tested – all without direct dependency on platform-specific components. This platform-independent unit testing not only ensures that our application remains both maintainable and reliable but also hardens its adaptability across various platforms. It underscores the power of the MVVM pattern!
However, there is still some code to be tested that is platform-specific. Let’s have a look at that before we end this chapter.
Testing MAUI-specific code
As shown in the previous examples, the majority of our code can be tested independently of the platform. But let’s not forget that there is code in our MAUI project as well that could benefit from some unit tests.
Let’s start by adding a new project to hold our tests for the `Recipes.Mobile` project:
1. Add a new `Recipes.Mobile.UnitTests`.
2. Once the project has been created, add a reference to the `Recipes.Mobile` project.
3. Add the **AutoBogus** NuGet package to this project.
The `Recipes.Mobile.UnitTests` project doesn’t target any specific frameworks other than `net8.0`. Because of that, we need to make sure `net8.0` is on the list of target frameworks of the MAUI project as well. Also, we need to make sure that when the `Recipes.Mobile` project targets this additional `net8.0` framework, it doesn’t output an EXE file. Let’s see how to properly configure this:
1. Open the `Recipes.Mobile.csproj` file by clicking on the project name in the **Solution Explorer** in **Visual Studio** or by right-clicking the project and selecting **Edit** **Project File**.
2. Add `net8.0` to the `TargetFrameworks` tag, as shown here:
```
<TargetFrameworks>net8.0;net8.0-android;net8.0-
ios;net8.0-maccatalyst</TargetFrameworks>
```cs
3. Find the `OutputType` tag in the `.csproj` file and update the following:
```
<OutputType Condition="'$(TargetFramework)' !=
Recipes.Mobile 项目并选择卸载项目。卸载后,再次右键单击它并选择重新加载项目。
```cs
Once the `Recipes.Mobile` project is configured, we also need to add one thing to the `Recipes.Mobile.UnitTests` project. In its `.csproj` file, find the first `PropertyGroup` tag and add the following: `<UseMaui>true</UseMaui>`.
With all of this in place, writing tests for functionality in the `Recipes.Mobile` project isn’t any different from the tests we’ve written so far. Let’s have a look at how to test the `RatingToStarsConverter` class:
1. Start by creating a new class called `RatingToStarsConverterTests`.
2. This converter makes for a good data-driven test. We can specify the input and expected output through method parameters, as shown in the following code block:
```
public void Convert_Should_Return_ExpectedOutput(
object input, string expectedOutput)
{
//Arrange
var sut = new Converters.RatingToStarsConverter();
//Act
var result = sut.Convert(input,
null, null, null);
//Assert
Assert.Equal(expectedOutput, result);
}
```cs
The passed-in input value is the value we want our converter to convert. The result is compared to the converted value by using `Assert.Equal`.
3. Add the `Theory` attribute and the following `InlineData` attributes for different – edge-case – scenarios to this method:
```
[InlineData("foo", "")]
[InlineData(-1d, "")]
[InlineData(6d, "")]
[InlineData(1d, "\ue838")]
[InlineData(2d, "\ue838\ue838")]
[InlineData(2.2d, "\ue838\ue838")]
[InlineData(2.5d, "\ue838\ue838\ue839")]
[InlineData(2.9d, "\ue838\ue838\ue839")]
```cs
This test method successfully validates if the `RatingToStarsConverter` converts a given value to a string representing star-icons. Not only are happy paths tested, but also the expected behavior when passing in invalid data.
One other thing we can test is the `InstructionsDataTemplateSelector` class. The following steps show you how this can be done:
1. Add a new class `InstructionsDataTemplateSelectorTests` to the `Recipes.Mobile.UnitTests` project.
2. Here’s what a test for this `TemplateSelector` could look like:
```
[Fact]
public void SelectTemplate_NoteVM_Should_Return
_NoteTemplate()
{
//Arrange
var template = new DataTemplate();
var sut = new InstructionsDataTemplateSelector();
sut.NoteTemplate = template;
sut.InstructionTemplate = new DataTemplate();
//Act
var result = sut.SelectTemplate(
AutoFaker.Generate<NoteViewModel>(), null);
//Assert
Assert.Equal(template, result);
}
```cs
In this test, we’re creating a template that gets assigned to the `NoteTemplate` property of the sut. The `SelectTemplate` method of the sut gets invoked, passing in a generated `NoteViewModel`. We expect the returned `DataTemplate` to be the one we created and assigned to the `NoteTemplate` property.
With simple tests like these, we can easily validate the behavior of a `TemplateSelector` without deploying and running our app once! Testing if the `InstructionsDataTemplateSelector` works as expected for an `InstructionViewModel` or an unsupported ViewModel, should be pretty straightforward.
Summary
In this chapter, we delved into unit testing within the MAUI framework, specifically focusing on testing ViewModels and some MAUI components. It’s worth noting that while we focused on these areas, the tools and techniques discussed are equally effective for testing services, repositories, and other integral parts of your application. Beyond just validating the code’s functionality, unit testing acts as a safety net, ensuring maintainability and robustness by reducing the chances of regression bugs. This powerful approach empowers us to iterate faster, removing the constant need for cumbersome deployments or manual checks. Leveraging mock implementations, we can seamlessly mimic and validate countless scenarios, and this validation remains ingrained in our code base. As we add or modify features, this ensures every intricate use case remains covered. A key takeaway is the significant portion of our app that can be tested independently of platform-specific details. This not only enhances adaptability but solidifies the effectiveness of the MVVM pattern. In conclusion, unit testing in MAUI isn’t just a checkbox; it’s a foundational element that drives us to build robust applications with agility and confidence. In the next and final chapter of this book, we’ll be looking at some troubleshooting and debugging tips that might come in handy when building an MVVM app with .NET MAUI.
Further reading
To learn more about the topics that were covered in this chapter, take a look at the following resources:
* xUnit: [`xunit.net/`](https://xunit.net/)
* Bogus for .NET: [`github.com/bchavez/Bogus`](https://github.com/bchavez/Bogus)
* AutoBogus: [`github.com/nickdodd79/AutoBogus`](https://github.com/nickdodd79/AutoBogus)
* Moq: [`github.com/devlooped/moq`](https://github.com/devlooped/moq)
* *Unit testing C# in .NET Core using dotnet test and* *xUnit*: [`learn.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-test`](https://learn.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-test)
第十四章:故障排除和调试技巧
恭喜你在掌握.NET MAUI 中 MVVM 模式的旅程中走这么远!到目前为止,你已经了解了数据绑定、依赖注入、转换器以及构成你的Recipes! 应用的各个组件的复杂性。然而,正如任何经验丰富的开发者都会告诉你的,即使是经验最丰富的专家有时也会遇到障碍。
尽管 MVVM 拥有所有这些好处,但它有时可能感觉像是在一个复杂的迷宫中导航。当你遇到问题时,并不总是明显知道要找到根本原因或如何修复它。这正是本章的作用所在。在本章这个简短但非常有价值的章节中,我们将揭示你在 MVVM 之旅中可能遇到的常见陷阱和挑战。
我们将探讨三个经常出现问题的领域:
常见的数据绑定问题
服务和依赖注入陷阱
频繁的自定义控件和转换器问题
让我们开始解决这些常见障碍的旅程。到本章结束时,你将更好地装备自己,以解决.NET MAUI 中 MVVM 可能带来的挑战。
技术要求
为了确保你与即将到来的内容保持同步,请前往我们的 GitHub 仓库github.com/PacktPublishing/MVVM-pattern-.NET-MAUI/tree/main/Chapter14 。从Start文件夹中的材料开始。记住,如果你需要综合参考,Finish文件夹在章节结束时包含了最终的、精炼的代码。
常见的数据绑定问题
MVVM 模式的一个基石是数据绑定。它形成了你的 View 和 ViewModel 之间的联系,确保它们之间无缝的通信。虽然数据绑定提供了强大的功能,但它也是开发者经常面临挑战的领域。本节旨在阐明常见的数据绑定和 ViewModel 问题以及如何解决它们:
错别字和名称不匹配 :开发者遇到的最简单但意外常见的问题之一是错别字或属性名称不匹配。你 XAML 标记或 ViewModel 代码中的一个小错误可能会破坏整个数据绑定过程。
OneWay、TwoWay和OneTime,每个都有其自己的用途,正如我们在第四章 中看到的,在.NET MAUI 中的数据绑定 。使用错误的模式可能导致你的应用出现意外的行为。
尽管 XAML 在某些情况下支持隐式类型转换,但将int转换为 UI 控件上的Color属性类型将不起作用。
PropertyChanged事件。当这些事件没有按预期触发时会发生什么?View 将不会更新以反映 ViewModel 数据中的更改。
ObservableCollection。确保你的集合正确更新,并避免无意中分配新的ObservableCollection。
ListView或CollectionView,请注意,集合中的每个项目都会创建自己的数据绑定作用域。这意味着当尝试绑定位于 ViewModel 而不是单个项目中的属性或命令时,您需要使用相对或元素绑定等技术来正确引用所需上下文。
在行为中的数据绑定 :行为存在于视觉树之外,这意味着它们没有与其他 UI 元素相同的定位祖先的能力。
注意
通过利用编译绑定,如第四章 中所述的,在 .NET MAUI 中的数据绑定 ,可以避免一些陷阱,例如属性名中的拼写错误和绑定不兼容的数据类型。
如您所见,与数据绑定相关的问题可能很多。幸运的是,其中一些问题并不难发现和修复,只要您知道该往哪里看。让我们从查看 Visual Studio 中的某些工具开始。
检查输出和 XAML 绑定失败窗口
在 Visual Studio 中,使用输出 窗口和XAML 绑定失败 窗口,可以轻松地发现拼写错误或属性名不匹配。以下是方法:
让我们先在我们的代码中引入两个错误的数据绑定语句。在RecipesOverviewPage中,更新CollectionView的ItemTemplate中的Image和Label元素,如下所示:
<CollectionView.ItemTemplate>
<DataTemplate>
...
<Image
Aspect="AspectFill"
HorizontalOptions="Fill"
Source="{Binding IsFavorite}"
VerticalOptions="Fill" />
...
<Label
Grid.Row="1"
Margin="20,5,20,40"
FontSize="16"
HorizontalOptions="Fill"
HorizontalTextAlignment="Start"
MaxLines="2"
Text="{Binding Titel}"
TextColor="Black"
VerticalOptions="Center" />
...
</DataTemplate>
</CollectionView.ItemTemplate>
运行应用。当应用正在运行时,前往 Visual Studio 并打开输出 窗口。如果尚未打开,您可以通过调试 |窗口 |输出 来打开它。注意以下内容:
[0:] Microsoft.Maui.Controls.Xaml.Diagnostics.Binding
Diagnostics: Warning: 'False' cannot be converted to
type 'Microsoft.Maui.Controls.ImageSource'
[0:] Microsoft.Maui.Controls.Xaml.Diagnostics.Binding
Diagnostics: Warning: 'Titel' property not found on
'Recipes.Client.Core.ViewModels.RecipeListItemView
Model', target property: 'Microsoft.Maui
bool value cannot be converted to an ImageSource type. The second warning signals that a property named Titel cannot be found on the RecipeListItemViewModel. That, of course, is a typo!
或者,让我们看看 Visual Studio 中的XAML 绑定失败 窗口。可以通过调试 |窗口 |XAML 绑定失败 来打开。图 14 .1 展示了此窗口的外观:
图 14.1:XAML 绑定失败窗口
此窗口提供的信息与输出 窗口相同。此外,它还显示了额外信息,例如失败的绑定语句所在位置以及此问题发生次数。这个窗口最好的地方是什么?当点击此列表中的项目时,Visual Studio 将打开包含错误绑定语句的 XAML 文件,并将指针放在包含错误的确切数据绑定语句上。
无论何时您的应用程序中出现绑定失败,Visual Studio 中的XAML 绑定失败 窗口和输出 窗口都会提供有关错误的信息。特别是XAML 绑定失败 窗口,可以立即提供关于拼写错误、缺失属性或数据类型问题的洞察。在开发视图时,始终关注此窗口。
另一种解决或调试数据绑定问题的方法是创建并利用专门的转换器。让我们看看吧!
使用 DoNothingConverter 进行调试
DoNothingConverter 是一个宝贵的调试工具。通过将其放置在绑定管道中,您可以检查绑定过程中传递的值。如果您看到意外的值或根本没有值,这可以帮助确定故障发生的位置。以下是 DoNothingConverter 的实现:
public class DoNothingConverter : IValueConverter
{
public object Convert(object value,
Type targetType, object parameter,
CultureInfo culture)
{
// Break here to inspect value during debugging
return value;
}
public object ConvertBack(object value,
Type targetType, object parameter,
CultureInfo culture)
{
// Break here to inspect value during debugging
return value;
}
}
要添加和使用此转换器到您的绑定语句中,请按照以下步骤操作:
将 DoNothingConverter 添加到您想要调试绑定语句的页面的 Resources 中。以下是将其添加到 RecipesOverviewPage 的方法:
<ContentPage
x:Class="Recipes.Mobile.RecipesOverviewPage"
...
xmlns:conv="clr-namespace:
Recipes.Mobile.Converters"
... >
<ContentPage.Resources>
...
<conv:DoNothingConverter
x:Key="doNothingConverter" />
</ContentPage.Resources>
...
</ContentPage>
将转换器添加到您想要调试的绑定语句中,如下所示:
<Image
...
Source="{Binding IsFavorite,
Converter={StaticResource doNothingConverter}}"
VerticalOptions="Fill" />
...
<Label
...
Text="{Binding Titel,
Converter={StaticResource doNothingConverter}}"
TextColor="Black"
VerticalOptions="Center" />
在 DoNothingConverter 的 Convert 或 ConvertBack 方法中插入断点。
如果在运行时遇到断点,这表明成功绑定到 ViewModel 上的现有属性。您会注意到,在 Convert 方法中的断点不会因为 Titel 绑定而触发,因为这个属性不存在。
如果在属性值后续更新时没有遇到断点,请检查语句的绑定模式,并确保当属性更新时触发 PropertyChanged 方法。
当断点被触发时,您可以轻松检查绑定的值并将其与您的预期进行比较。
您还可以检查 targetType 参数,它表示目标属性的类型。请注意,虽然 XAML 在某些情况下支持隐式类型转换,但了解支持的特定转换是至关重要的。
当 UI 控件上的属性更新且绑定模式设置为 TwoWay 或 OneWayToSource 时,应调用 ConvertBack 方法。如果您期望这能正常工作,但 ConvertBack 方法没有调用,请检查绑定语句的绑定模式。
通过遵循这些步骤并使用 DoNothingConverter 工具,您可以有效地解决 MVVM 应用中的数据绑定问题。
让我们讨论另一个可能导致数据绑定问题的原因:集合。
解决集合问题
当与集合一起工作时,尤其是 ObservableCollection,开发者经常会遇到与更新和绑定相关的挑战。
如果您使用 ObservableCollection 或任何实现 INotifyCollectionChanged 接口的集合,它通常在 ViewModel 的初始化过程中分配一次。这里有一个需要记住的重要细节:这个属性的设置器不会触发 PropertyChanged 事件。相反,当您向集合中添加或删除项目时,它会在集合实例上触发 CollectionChanged 事件。此事件随后更新绑定的控件,假设它支持绑定到 ObservableCollection。要验证特定控件是否与 INotifyCollectionChanged 接口良好协作,请查阅控件文档。
然而,有一个关键点需要注意:如果重新分配 ObservableCollection,绑定将实际上会丢失,除非当然,PropertyChanged 事件被正确触发。这意味着如果您使用 ObservableCollection 的新实例重新分配整个集合,您需要确保 PropertyChanged 事件被正确触发。为了检查此事件是否有效触发,您可以使用 DoNothingConverter。
相比之下,当您处理一个没有实现 INotifyCollectionChanged 的集合(例如标准 List 或类似的集合)时,添加或删除项目将不会被 UI 层自动检测。在这种情况下,当向集合中添加或从集合中删除项目时,必须显式触发 PropertyChanged 事件。因此,当您进行更改时,整个列表将在 UI 中重新渲染。
在处理与集合相关的问题时,请密切关注您是否正在使用 ObservableCollection 或非可观察集合,并确保您触发了适当的事件以保持 ViewModel 和 UI 保持同步。理解这些动态将帮助您更有效地导航 MVVM 应用程序中集合的复杂性,并防止潜在的问题。
当与集合一起工作时,请记住,当集合中某个项目的属性发生变化时,您不需要在集合本身上触发 PropertyChanged 事件。相反,关键在于在经过修改的特定项目的实例上引发 PropertyChanged 事件。这确保了 UI 被通知到项目级别的更改,并准确地反映了更新后的状态。本质上,您正在将更新事件精确地集中在需要的地方,最小化对整个集合的不必要更新。
在 Behaviors 上的数据绑定陷阱
在编写 XAML 时很容易忽略这一点,但相对源绑定在 Behaviors 上不起作用。这是因为 Behaviors 存在于视觉树之外。实际上,一个 Behavior 可以被多个 UI 元素重用,因此相对源绑定将无法检索父对象。当将相对源绑定应用于 Behavior 时,您的应用程序将崩溃,并在崩溃之前出现类型为 System.InvalidOperationException 的异常。该异常指出以下内容:由于对象当前的状态,操作无效 。这个异常以及此消息应该是一个迹象,表明在 Behavior 上定义了一个错误的数据绑定语句。在异常或 输出 窗口中将没有任何进一步的指示。您唯一能做的就是系统地遍历代码中的 Behaviors 并查看它们的绑定语句。
在许多情况下,相对源绑定可以被元素绑定所替代,如下所示:
<!-- RelativeSource binding fails on Behaviors! -->
<toolkit:IconTintColorBehavior
TintColor="{Binding IsFavorite,
Source={RelativeSource AncestorType={x:Type local:
RelativeSource binding in the IcontTintColorBehavior. This can be bypassed by leveraging element binding, as shown in the next code block:
<ContentView
...
x:Name="root">
...
<toolkit:IconTintColorBehavior
TintColor="{Binding IsFavorite,
源={x:Reference root},
转换器=...}" />
...
Next, let’s discuss the things to look out for when working with Dependency Injection.
Services and Dependency Injection pitfalls
In your MVVM journey, DI plays a crucial role in providing essential functionality to your application. However, even in the world of DI, there can be pitfalls waiting to catch you off guard. This section is dedicated to unveiling the most common pitfalls and equipping you with the knowledge to navigate them effectively.
Unable to resolve service for type
A `System.InvalidOperationException` stating `RecipesOverviewViewModel` in the DI container:

Figure 14.2: InvalidOperationException thrown
The exception gives you all the information you need as it clearly states what type is missing while trying to create a particular type.
Registering the missing dependency in the `MauiProgram` class (or anywhere you do your registrations) should fix the issue.
Let’s have a look at another common exception in the context of DI.
No parameterless constructor defined for type
A `System.MissingMethodException` can be thrown in the following scenario:
* Shell is used to perform navigation
* The BindingContext (a ViewModel) of a page is injected through the page’s constructor
* The page isn’t registered in the DI container
As long as a page doesn’t have any dependencies that need to be injected through the constructor, it doesn’t need to be registered in the DI container, as its default constructor is being used by Shell to instantiate the page. However, when the page has one or more dependencies, we need to register it in the DI container. That way, Shell can ask the container to resolve an instance of the needed page.
Registering the page in the DI container solves this issue.
A much more subtle pitfall when it comes to DI is not registering the services appropriately. Let’s have a look.
Incorrect service registration
In the context of DI, one common pitfall stems from improperly registering services, leading to issues that can affect your application’s functionality:
* **Resource intensiveness**: If you register a service as transient when it should be a singleton, you may encounter resource-intensive behavior. This occurs because a new instance of the service is created every time it’s requested. For services that involve resource-intensive operations, such as establishing database connections or managing file handles, this frequent creation can lead to performance bottlenecks and resource exhaustion. Such issues can significantly impact your application’s performance and stability.
* **Unintended shared state**: Conversely, if you mistakenly register a service as a singleton when it should be transient, you may inadvertently introduce an unintended shared state. In this scenario, changes made to the service’s state or properties affect all parts of your application that depend on that service. This shared state can lead to unpredictable behavior and make debugging challenging, as the source of the problem may not be immediately apparent. It’s crucial to align the service’s registration with its intended usage to avoid such pitfalls.
To mitigate these issues, carefully consider the intended scope and usage of each service during registration. Ensure that services requiring a single shared instance across your application are registered as singletons, while services that should have unique instances for each request are registered as transient. By making informed decisions about service registration, you can prevent these common pitfalls and ensure your application functions as intended.
In the final section, let’s have a look at common problems around custom controls and value converters.
Frequent custom control and converter problems
Most of the issues that arise when working with custom controls regularly have to do with bindable properties. Often, a small typo or a little oversight might cause your custom control to not react as expected or to display the wrong data.
Troubleshooting bindable properties
On a custom control, there is a lot of ceremony needed to define bindable properties. It’s very easy to make a mistake that is very hard to spot when troubleshooting. Here are a couple of things to look out for:
* The `propertyName` parameter in the `Create` method: Make sure the `propertyName` parameter matches the exact naming of the property:
```
public static readonly BindableProperty
IsFavoriteProperty =
可绑定属性创建(nameof(IsFavorite), …);
public bool IsFavorite
{
...
}
```cs
As this code sample shows, it is advised to use the `nameof` expression to prevent typos!
* The `returnType` parameter in the `Create` method: The second parameter of the `BindableProperty`’s `Create` method is the `returnType`, which must match the type of the property:
```
public static readonly BindableProperty
IsFavoriteProperty =
可绑定属性创建(nameof(IsFavorite),
typeof(bool), …);
public bool IsFavorite
{
...
}
```cs
* The `declaringType` parameter in the `Create` method: This parameter should be the type of the class where the property is defined:
```
public partial class FavoriteControl : ContentView
{
public static readonly BindableProperty
IsFavoriteProperty =
BindableProperty.Create(...
typeof(FavoriteControl),...);
public bool IsFavorite
{
...
}
```cs
* It’s also important to make sure the getter and setter of the property call the `GetValue` and `SetValue`, passing in the correct `BindableProperty`:
```
public static readonly BindableProperty IsFavoriteProperty = ...
public bool IsFavorite
{
get => (bool)GetValue(IsFavoriteProperty);
set => SetValue(IsFavoriteProperty, value);
}
```cs
Whenever there is a discrepancy between the provided values in the `BindableProperty`’s `Create` method and the values on the control itself, or when the property doesn’t get or set the value correctly on the `BindableProperty`, the bindable property will not work as expected. So, it’s crucial to double-check these values!
Binding to the BindingContext
As already stipulated in *Chapter 11*, *Creating MVVM-Friendly Controls,* it is crucial that custom controls don’t depend on their `BindingContext`! The reason is that you can’t control that, as it is inherited from the parent the custom control is used on. Instead, you should only bind to the (bindable) properties that you’ve defined on the control itself. This can easily be achieved by leveraging relative or element binding, just like we did with the `FavoriteControl`:
<Image
HeightRequest="{Binding HeightRequest,
源={x:Reference icon}}"
IsVisible="{Binding IsInteractive,
源={RelativeSource AncestorType={x:Type
local:FavoriteControl}}}"
... />
Any binding statements on a custom control that don’t have an explicit source set will bind to the `BindingContext` of the parent, which we don’t control. When a custom control works in one place but not in the other, chances are high that there is some binding going on that is not relative to the control itself. So, always double-check the binding statements in your custom control!
Finally, let’s have a quick look at the issues that might arise when working with value converters.
Value converter issues
Converters play a crucial role in data transformation within your app. However, their logic might not always behave as expected. It’s a seemingly trivial issue, but one that is frequently underestimated. The solution? Simple yet powerful: write unit tests! In *Chapter 13*, *Unit Testing,* we’ve highlighted how easy it is to unit test value converters. Paying attention to the logic within converters, testing them rigorously, and handling special cases will ensure that your converters perform reliably.
Summary
Now that we’ve reached the end of this short chapter, I hope you’ve gained valuable insights and tips for effectively troubleshooting issues that can arise in an MVVM context. Remember, the road to mastering MVVM is an ongoing journey, and troubleshooting and debugging are indispensable companions on this path. These challenges, though sometimes frustrating, are valuable teachers that will deepen your understanding and proficiency in MVVM. Embrace them as opportunities to grow, and in doing so, you’ll become a more proficient and confident MVVM developer. Your journey doesn’t end here; it evolves with each issue you resolve.
As we wrap up this final chapter, I want to extend my heartfelt congratulations to you for completing this book’s journey into the world of MVVM in .NET MAUI. Throughout this book, you’ve delved into the intricacies of the MVVM pattern, explored the capabilities of .NET MAUI, and built your very own *Recipes!* app.
Once again, congratulations on your accomplishment, and may your MVVM and .NET MAUI journey continue to be rewarding and filled with exciting projects!
Further reading
To learn more about the topics that were covered in this chapter, take a look at the following resource:
*XAML data binding* *diagnostics*: [`learn.microsoft.com/en-us/visualstudio/xaml-tools/xaml-data-binding-diagnostics?view=vs-2022`](https://learn.microsoft.com/en-us/visualstudio/xaml-tools/xaml-data-binding-diagnostics?view=vs-2022)