使用Xamarin.Forms构建企业应用
主要内容:模型-视图-视图模型 (MVVM) 模式、依赖关系注入、导航、验证和配置管理并同时保持松散耦合的指南。 此外,还提供有关利用 IdentityServer 执行身份验证和授权、通过容器化微服务访问数据以及执行单元测试
来自:Enterprise Application Patterns using Xamarin.Forms eBook
序言
Xamarin.Forms 是一个跨平台的 UI 工具包,使开发人员可轻松创建可以跨平台共享的的本机用户界面布局,这些平台包括 iOS、Android 和通用 Windows 平台 (UWP) 。 它提供面向企业对员工 (B2E)、企业对企业 (B2B) 和企业对消费者 (B2C) 应用的全面解决方案,支持跨所有目标平台间共享代码,并且有助于降低总拥有成本 (TCO) 。
本书提供有关开发自适应、可维护和可测试的 Xamarin.Forms 企业应用程序的体系结构。本指南附带了 eShopOnContainers 移动应用 和 eShopOnContainers 参考应用 的源代码。 eShopOnContainers 移动应用是一款使用 Xamarin.Forms 开发的跨平台企业应用,它连接一系列名为 eShopOnContainers 参考应用的容器化微服务。 但是,对于希望避免部署容器化微服务的用户,可将 eShopOnContainers 移动应用配置为使用模拟服务中的数据。
本指南重点介绍使用 Xamarin.Forms 构建跨平台企业应用。 鉴于此,应完整阅读本指南以对此类应用及其技术注意事项获得基本了解。 本指南及其示例应用还可以充当创建新企业应用的起点或参考。 使用相关示例应用作为新应用的模板,或了解如何组织应用的各个组件。 然后,回头参考本指南来获得架构方面的指导。
本指南可随意转发给团队成员,帮助确保他们对使用 Xamarin.Forms 进行跨平台企业应用开发有大致了解。 通过使所有人在使用一组通用术语和基本原则的基础上开展工作,可帮助团队确保构建具有一致的架构模式和做法的应用程序。
介绍
无论采用何种平台,企业应用程序的开发人员都面临着几个难题:
- 应用的需求可随时间而变化。
- 新的业务机会和挑战。
- 开发过程中会持续出现反馈,可能会显著影响应用的范围和需求。
考虑到这些挑战,需构建可随时间推移而进行修改或扩展的应用。 要实现这种适应性可能很困难,因为所需架构需满足以下条件:允许独立开发应用的各个部分并单独进行测试,同时不会影响应用的其余部分。
许多企业应用非常复杂,需多名开发人员。 决定应用的设计方式是一项重大挑战,因为要允许多名开发人员独立且有效地开发应用的不同部分,同时确保将各部分集成到应用中时,各部分能无缝结合。
采用传统的设计和构建方法会得到所谓的单一应用,其中各组件紧密耦合,不存在明确的界限。 通常情况下,使用这种单一方法会增加应用的维护难度,降低维护效率,因为要在不破坏应用中其他组件的情况下解决 bug 十分困难,并且可能难以添加新功能或替换现有功能。
应对这些挑战的一种有效补救措施是将应用划分为离散、松散耦合的组件,这些组件可轻松集成到应用中。 这样的方法有多个优点:
- 允许由不同个人或团队来开发、测试、扩展和维护单个功能。
- 有助于加强应用横向功能(如身份验证和数据访问)与纵向功能(如应用特定的业务功能)间的重用,并明确分离关注点。 这样便可更轻松地管理应用组件间的依赖关系和交互。
- 允许不同个人或团队根据其专业知识关注特定任务或功能,明确角色分工,尤其是它更加清晰地分离了用户界面和应用业务逻辑。 特别是,它提供用户界面和应用程序的业务逻辑之间的更清晰分隔。
然而,在把应用划分为离散、松散耦合的组件时,有许多必须解决的问题。 这些问题包括:
- 决定如何提供用户界面控件及其逻辑间的明确关注点分离。 创建 Xamarin.Forms 企业应用时,最重要的一点就是确定是否将业务逻辑放到 code-behind 文件中,或是否在用户界面控件及其逻辑之间创建明确的关注点分离,从而提高应用的可维护性和可测试性。 有关详细信息,请参阅ViewModel。
- 决定是否使用依赖关系注入容器。 依赖关系注入容器可提供一个工具来构造类的实例并注入对象的依赖关系,以减少对象间的依赖耦合,并且还可基于容器的配置来管理对象的生命周期。 有关详细信息,请参阅依赖关系注入。
- 选择平台提供的事件或松散耦合的基于消息的通信,后者发生在不便通过对象和类型引用链接的组件间。 有关详细信息,请参阅松散耦合组件间的通信简介。
- 决定如何在页面间导航,包括如何发起导航,以及将导航逻辑保留在哪里。 有关详细信息,请参阅导航。
- 确定如何验证用户输入正确与否。 制定决策时必须确定如何验证用户输入,以及如何通知用户验证错误。 有关详细信息,请参阅验证。
- 决定如何执行身份验证,以及如何保护带有授权的资源。 有关详细信息,请参阅身份验证和授权。
- 确定如何从 Web 服务访问远程数据,包括如何可靠地检索数据,以及如何缓存数据。 有关详细信息,请参阅访问远程数据。
- 确定测试应用程序的方法。 有关详细信息,请参阅单元测试。
本指南针对这些问题提供指导,着重关注使用 Xamarin.Forms 构建跨平台企业应用的核心模式和架构。 本指给出了常见 Xamarin.Forms 企业应用开发方案,并借助模型-视图-视图模型 (MVVM) 模式支持呈现、呈现逻辑和实体间的关注点分离,以此帮助生产出适应性良好、可维护和可测试的代码。
1、示例应用程序介绍
本指南包含的示例应用 eShopOnContainers,这是一个包含以下功能的在线商店:
- 针对后端服务进行身份验证和授权。
- 浏览衣服、咖啡杯和其他商品目录。
- 筛选目录。
- 对目录中的项进行排序。
- 查看用户的订单历史记录。
- 设置配置。
图 1-1: eShopOnContainers 高级架构
示例应用程序附带了三个客户端应用:
- 使用 ASP.NET Core 开发的 MVC 应用程序。
- 使用 Angular 2 和 Typescript 开发的单页应用程序 (SPA)。 采用此方法开发 Web 应用程序时,避免了每执行一次操作都要往返于服务器一次。
- 使用 Xamarin.Forms 开发的移动应用,支持 iOS、Android 和通用 Windows 平台 (UWP)。
后端微服务
这些后端服务作为使用 ASP.NET Core MVC 的微服务实现,并在单个 Docker 主机中部署为唯一容器。 这些后端服务被统称为 eShopOnContainers 参考应用程序。 客户端应用通过具象状态传输 (REST) Web 接口与后端服务进行通信。 有关微服务和 Docker 的详细信息,请参阅容器化微服务。
有关后端服务的实现的信息,请参阅.net 微服务:适用于容器化 .NET 应用程序的体系结构。
移动应用
本指南重点介绍使用 Xamarin.Forms 构建跨平台企业应用,并以 eShopOnContainers 移动应用为例。
该移动应用使用了 eShopOnContainers 参考应用程序提供的后端服务。 但若想避免部署后端服务,则可将此移动应用配置为使用模拟服务中的数据。
运用了以下 Xamarin.Forms 功能:
- XAML
- Controls
- 绑定
- Converters
- 样式
- Animations
- 命令
- Behaviors
- Triggers
- Effects
- 自定义呈现器
- MessagingCenter
- 自定义控件
2、移动应用解决方案介绍
eShopOnContainers 移动应用解决方案将源代码和其他资源整合到项目中。 所有项目使用文件夹对源代码和其他资源进行分类。 下表概述了构成 eShopOnContainers 移动应用的项目:
项目 | 描述 |
---|---|
eShopOnContainers.Core | 此项目是可移植类库(PCL)项目,其中包含共享代码和共享 UI。 |
eShopOnContainers.Droid | 此项目包含特定于 Android 的代码并且是 Android 应用的入口点。 |
eShopOnContainers.iOS | 此项目包含特定于 iOS 的代码并且是 iOS 应用的入口点。 |
eShopOnContainers.UWP | 此项目包含通用 Windows 平台 (UWP) 的特定代码,并且是 Windows 应用程序的入口点。 |
eShopOnContainers.TestRunner.Droid | 此项目是 eShopOnContainers.UnitTests 项目的 Android 测试运行程序。 |
eShopOnContainers.TestRunner.iOS | 此项目是 eShopOnContainers.UnitTests 项目的 iOS 测试运行程序。 |
eShopOnContainers.TestRunner.Windows | 此项目是 eShopOnContainers.UnitTests 项目的通用 Windows 平台测试运行程序。 |
eShopOnContainers.UnitTests | 此项目包含 eShopOnContainers 项目的单元测试。 |
eShopOnContainers 移动应用中的类可再度用于所有 Xamarin.Forms 之中,只需少量修改或完全无需修改。
eShopOnContainers.Core 项目
eShopOnContainers.Core PCL 项目中包含以下文件夹:
文件夹 | 描述 |
---|---|
Animations | 包含可在 XAML 中使用动画的类。 |
Behaviors | 包含公开给视图类的行为。 |
Controls | 包含应用程序使用的自定义控件。 |
Converters | 包含将自定义逻辑应用于绑定的值转换器。 |
Effects | 包含EntryLineColorEffect 类,该类用于更改特定Entry 控件的边框颜色。 |
Exceptions | 包含自定义的ServiceAuthenticationException 类。 |
Extensions | 包含用于VisualElement 和IEnumerable 类的扩展方法。 |
Helpers | 包含应用的帮助程序类。 |
Models | 包含应用的模型类。 |
Properties | 包含 .NET 程序集元数据文件 AssemblyInfo.cs 。 |
Services | 包含用于实现提供给应用的服务的接口和类。 |
Triggers | 包含用于在 XAML 中调用动画的 BeginAnimation 触发器。 |
Validations | 包含验证数据输入时涉及的类。 |
ViewModels | 包含被公开到页面的应用程序逻辑。 |
Views | 包含应用的页面。 |
平台项目
平台项目包含效果实现、自定义呈现器实现以及其他特定于平台的资源。
MVVM
Model-View-ViewModel Pattern,有助于将应用程序的的业务和演示逻辑与其用户界面 (UI) 隔离开来。 始终清晰隔离应用程序逻辑和 UI 有助于解决诸多开发问题,还可使应用程序更加易于测试、维护和改进。 这样做还可以显著改善代码重用机会,并允许开发人员和 UI 设计人员在开发各自的应用部分时能够更轻松地进行协作。
MVVM 模式中有三个核心组件:模型、视图和视图模型。 每个服务都有一个不同的用途。 图2-1 显示了这三个组件之间的关系。
图 2-1:MVVM 模式
除了了解每个组件的职责外,还必须了解这些组件之间的交互方式。 视图 "了解" 视图模型,视图模型 "了解" 模型,但模型不知道视图模型,并且视图模型不知道视图。 因此,视图模型将视图与模型隔离开来,并允许模型独立于视图进行发展。
使用 MVVM 模式的优点如下:
- 如果现有模型实现封装了现有业务逻辑,更改它可能会比较困难或风险。 在此方案中,视图模型充当模型类的适配器,并使您可以避免对模型代码进行任何重大更改。
- 开发人员可以为视图模型和模型创建单元测试,而无需使用视图。 视图模型的单元测试可以执行与视图所使用的功能完全相同的功能。
- 可在不触及代码的情况下重新设计应用 UI,前提是视图完全以 XAML 实现。 因此,新版本的视图应使用现有的视图模型。
- 设计人员和开发人员可以在开发过程中独立、同时在其组件上运行。 设计人员可以专注于视图,而开发人员可以处理视图模型和模型组件。
使用 MVVM 的关键是要了解如何将应用程序代码分解为正确的类,并了解类的交互方式。 以下各节讨论 MVVM 模式中每个类的职责。
View
避免在后台代码启用和禁用UI元素。确保ViewModel负责定义影响视图显示某些方面的逻辑状态更改,例如命令是否可用,或者操作是否挂起。因此,通过绑定到视图模型属性来启用和禁用UI元素,而不是在代码中启用和禁用它们。
有几个选项用于在视图模型上执行代码以响应视图上的交互,例如单击按钮或选择项。
1、如果控件支持命令,则可以将控件的Command属性数据 绑定到视图模型上的ICommand属性。当调用控件的命令时,将执行视图模型中的代码。
2、除了命令之外,还可以将行为附加到视图中的对象,并侦听要调用的命令或要引发的事件。作为响应,该行为可以调用视图模型上的ICommand或视图上的方法。
ViewModel
the class with underlying data is often called a ViewModel.(具有基础数据的类通常称为ViewModel)
视图模型实现视图可以绑定到属性和命令,并通过更改通知事件 将任何状态更改通知给视图。视图模型提供的属性和命令定义了UI提供的功能,但是视图决定了如何显示该功能。
注:保持UI对异步操作的响应。 移动应用程序应保持UI线程畅通,以改善用户对性能的感知。 因此,在视图模型中,对I/O操作使用异步方法,并引发事件以异步通知视图属性更改。
视图模型还负责协调视图与所需的任何模型类的交互。 视图模型和模型类之间通常存在一对多的关系,视图模型可能选择直接将模型类公开给视图,以便视图中的控件可以将数据直接绑定到它们。 在这种情况下,将需要设计模型类以支持数据绑定和更改通知事件。
每个视图模型都以视图易于使用的形式提供来自模型的数据。为此,视图模型有时会执行数据转换,将这种数据转换放在视图模型中是一个好主意,因为它提供了视图可以绑定到的属性。 例如,视图模型可以组合两个属性的值,以使其更易于由视图显示。
为了使ViewModel能够参与视图的双向数据绑定,其属性必须引发PropertyChanged事件,视图模型通过实现INotifyPropertyChanged接口并在属性更改时引发PropertyChanged事件来满足此要求。
对于集合,提供了视图友好的ObservableCollection<T>,此集合实现集合更改通知,从而使开发人员不必在集合上实现INotifyCollectionChanged接口。
Model
模型类是封装应用程序数据的非可视类。 因此,可以将模型视为代表应用程序的域模型,该模型通常包括数据模型以及业务和验证逻辑。 模型对象的示例包括数据传输对象(DTO),普通旧CLR对象(POCO)以及生成的实体和代理对象。
模型类通常与封装数据访问和缓存的服务或存储库结合使用。
1、Connecting View Models to Views
可以使用Xamarin.Forms的数据绑定功能将视图模型连接到视图。 有许多方法可用于构造视图和视图模型,并在运行时将它们关联。 这些方法分为两类,分别称为视图优先组合和视图模型优先组合。 在视图优先组合和视图模型优先组合之间进行选择是偏好和复杂性的问题。 但是,所有方法都具有相同的目的,即为视图分配一个视图模型,使其具有BindingContext属性。
1、视图优先组合,该应用程序是由连接至其所依赖的视图模型的视图组成。 这种方法的主要好处是,由于视图模型不依赖于视图本身,因此可以轻松构建松耦合的可单元测试的应用程序。 通过遵循应用程序的外观结构,也很容易理解应用程序的结构,而不必跟踪代码执行以了解类的创建和关联方式。 此外,视图优先组合与Xamarin.Forms导航系统保持一致,该导航系统负责在导航发生时构造页面。
2、视图模型优先组成,该应用程序是由视图模型组成,其中服务负责为视图模型定位视图。 由于可以将视图创建抽象化,使他们可以专注于应用程序的逻辑非UI结构,因此对于某些开发人员而言,视图模型优先更为自然。 此外,它允许其他视图模型创建视图模型。 但是,这种方法通常很复杂,并且很难理解应用程序的各个部分是如何创建和关联的。
注:保持视图模型和视图独立。 视图与数据源中的属性的绑定应该是视图对其相应视图模型的主要依赖关系。 具体来说,不要从视图模型中引用视图类型,例如Button和ListView。 通过遵循此处概述的原理,可以单独测试视图模型,因此可以通过限制范围来减少软件缺陷的可能性。
以声明方式创建视图模型
对于视图,最简单的方法是在XAML中以声明方式实例化其对应的视图模型。 构造视图时,还将构造相应的视图模型对象:
<ContentPage ... xmlns:local="clr-namespace:eShop"> <ContentPage.BindingContext> <local:LoginViewModel /> </ContentPage.BindingContext> ... </ContentPage>
创建ContentPage时,将自动构造LoginViewModel的实例并将其设置为视图的BindingContext。
视图对视图模型的这种声明式构造和分配具有简单的优点,但缺点是需要在视图模型有默认(无参数)构造函数。
以编程方式创建视图模型
视图可以在代码隐藏文件中包含代码,从而导致将视图模型分配给其BindingContext属性。 这通常是在视图的构造函数中完成的,如以下代码示例所示:
public LoginView() { InitializeComponent(); BindingContext = new LoginViewModel(navigationService); }
在视图的代码隐藏内,以编程方式构造和分配视图模型的优点是很简单。 但是,这种方法的主要缺点是视图需要向视图模型提供任何必需的依赖项。 使用依赖关系注入容器有助于维持视图模型与视图模型之间的松散耦合。 有关详细信息,请参阅依赖关系注入。
使用自定义类(ViewModelLocator)自动创建视图模型
参考eShop示例,ViewModelLocator类它管理视图模型的实例化以及它们与视图的关联。定义可绑定属性:AutoWireViewModel(其默认值为Fasle=default(bool)),用于将视图模型与视图相关联。 在视图的 XAML 中,此附加属性设置为 true 以指示视图模型应自动连接到视图。当AutoWireViewModel值更改时,将调用事件处理程序OnAutoWireViewModelChanged,此方法解析视图为视图模型并绑定。
此方法的优点是,应用只用一个类,该类负责实例化视图模型及其与视图的连接。
此OnAutoWireViewModelChanged
方法尝试使用基于约定的方法解析视图模型,此约定假定:
- 视图模型与视图类型位于同一程序集中。
- 视图在中.View子命名空间(文件夹)。
- 视图模型在.Viewmodels 子命名空间。
- 视图模型名称与视图名称对应,在视图名称后面加"Model" 。
最后,将视图类型的BindingContext设置为解析的视图模型类型。有关解析视图模型类型的更多信息,请参见Resolution。
2、更新视图以响应基础视图模型或模型中的更改
视图可以访问的所有视图模型和模型类都应该实现INotifyPropertyChanged
接口。 当基础属性值更改时,在视图模型或模型类中实现此接口 可使类向视图中的任何数据绑定控件提供更改通知。
应通过满足以下要求来设计应用程序,以正确使用属性更改通知:
- 如果公共属性的值发生更改,则始终引发PropertyChanged事件。
- 对于任何计算后的属性(其值被视图模型或模型中的其他属性使用),始终引发PropertyChanged事件。
public abstract class ExtendedBindableObject : BindableObject { public void RaisePropertyChanged<T>(Expression<Func<T>> property) { var name = GetMemberInfo(property).Name; OnPropertyChanged(name); } private MemberInfo GetMemberInfo(Expression expression) { ... } }
每个视图模型类均派生自ViewModelBase
类,后者又派生ExtendedBindableObject
自类。 因此,每个视图模型类都使用ExtendedBindableObject
类中的RaisePropertyChanged
方法来提供属性更改通知。
ViewModel中属性定义:
public bool IsLogin { get { return _isLogin; } set { _isLogin = value; RaisePropertyChanged(() => IsLogin); } }
lambda vs string,因为OnPropertyChanged(string) 接收的属性是字符串类型,所以可以直接用"IsLogin"。
注:用lambda 表达式涉及一些性能开销,因为必须为每个调用计算 lambda 表达式。 尽管性能开销很小,并且通常不会影响应用程序,但当存在许多更改通知时,成本可能会发生变化。
但是,这种方法的优点是在重命名属性时提供编译时类型安全和重构支持。
3、使用命令和行为的 UI 交互
命令提供了一种方便的方式来表示可以绑定到UI中的控件的动作。它们封装了实现该动作的代码,并有助于使该动作与视图中的视觉表示脱离。 Xamarin.Forms包含可以声明性地连接到命令的控件,并且这些控件将在用户与控件交互时调用命令。
行为还允许将控件以声明方式连接到命令。 但是,行为可用于调用与控件引发的一系列事件相关联的动作。 因此,行为可解决许多与启用命令的控件相同的场景,同时提供更大程度的灵活性和控件。
此外,行为还可用于将命令对象或方法 与未专门设计为与命令交互的控件(没有Command属性的控件)相关联。
1、实现命令
参考:https://www.cnblogs.com/peterYong/p/11576802.html#_label0_4
许多Xamarin.Forms controls提供了Command属性,以绑定到ViewModel中定义的ICommand对象实例。
2、实现行为:
行为允许将功能添加到UI控件,而不必对其进行子类化。功能是在行为类中实现的,并附加到控件,就像它是控件本身的一部分一样。
行为使您能够实现通常必须编写为代码隐藏的代码,因为行为可以直接与控件的API交互,从而可以将其简洁地附加到控件上,并打包以便在多个代码中重复使用,在MVVM的上下文中,行为是将控件连接到命令的有用方法。
通过附加属性附加到控件的行为称为附加行为。 然后,该行为可以使用其所附加元素的公开API向视图的可视树中的该控件或其他控件添加功能。
Xamarin.Forms行为是从Behavior或Behavior <T>类派生的类,其中T是行为应用到的控件的类型。 这些类提供OnAttachedTo和OnDetachingFrom方法,应重写这些方法以提供将行为附加到控件或从控件分离时将执行的逻辑。
实际运用
在eShopOnContainers移动应用程序中,BindableBehavior<T>类派生自Behavior<T>类。 BindableBehavior<T>类的目的是为Xamarin.Forms行为提供一个基类,该行为需要将该行为的BindingContext设置为附加控件。
BindableBehavior<T>类提供一个可重写的OnAttachedTo方法(用于设置行为的BindingContext),以及一个可重写的OnDetachingFrom方法,用于清除BindingContext。 此外,该类在AssociatedObject属性中存储对附加控件的引用。
eShopOnContainers移动应用程序包括EventToCommandBehavior类,该类可响应发生的事件执行命令。 此类派生自BindableBehavior<T>类,以便在使用行为时可以将行为绑定并执行由Command属性指定的ICommand。 下面的代码示例显示EventToCommandBehavior类:
public class EventToCommandBehavior : BindableBehavior<View> { ... protected override void OnAttachedTo(View visualElement) { base.OnAttachedTo(visualElement); var events = AssociatedObject.GetType().GetRuntimeEvents().ToArray(); if (events.Any()) { _eventInfo = events.FirstOrDefault(e => e.Name == EventName); if (_eventInfo == null) throw new ArgumentException(string.Format( "EventToCommand: Can't find any event named '{0}' on attached type", EventName)); AddEventHandler(_eventInfo, AssociatedObject, OnFired); } } protected override void OnDetachingFrom(View view) { if (_handler != null) _eventInfo.RemoveEventHandler(AssociatedObject, _handler); base.OnDetachingFrom(view); } private void AddEventHandler( EventInfo eventInfo, object item, Action<object, EventArgs> action) { ... } private void OnFired(object sender, EventArgs eventArgs) { ... } }
OnAttachedTo和OnDetachingFrom方法用于为EventName属性中定义的事件注册和注销事件处理程序。 然后,在事件触发时,将调用OnFired方法,该方法将执行命令。
在事件触发时使用EventToCommandBehavior执行命令的好处是,命令可以与 未设计为与命令进行交互的控件(没有Comman属性的控件)相关联。 此外,这会将事件处理代码移至视图模型,并在其中进行单元测试。
eg:ListView有ItemTapped事件,但是需在code-behind中处理。为了在viewmodel中处理,采用通过行为,将事件转为命令来处理。。
从视图来调用行为
EventToCommandBehavior对于将命令附加到 不支持命令的控件特别有用。 例如,当ItemTapped事件在列出用户订单的ListView上触发时,ProfileView使用EventToCommandBehavior执行OrderDetailCommand,如以下代码所示:
<ListView> <ListView.Behaviors> <behaviors:EventToCommandBehavior EventName="ItemTapped" Command="{Binding OrderDetailCommand}" EventArgsConverter="{StaticResource ItemTappedEventArgsConverter}" /> </ListView.Behaviors> ... </ListView>
在运行时,EventToCommandBehavior将响应与ListView的交互。 在ListView中选择一个项目时,将触发ItemTapped事件,该事件将在ProfileViewModel中执行OrderDetailCommand。 默认情况下,事件的事件参数 传递给命令。 该数据在EventArgsConverter属性中指定的转换器在源和目标之间传递时进行转换,该属性从ItemTappedEventArgs返回ListView的Item。 因此,当执行OrderDetailCommand时,所选的Order作为参数传递给已注册的Action。
依赖关系注入
通常,在实例化对象时将调用类构造函数,并且该对象需要的任何值都将作为参数传递到构造函数。 这是依赖关系注入的示例,专门称为构造函数注入,将对象所需的依赖项注入构造函数。
通过将依赖项指定为接口类型,依赖关系注入允许从依赖于这些类型的代码分离具体类型。 它通常使用容器来保存接口与抽象类型之间的注册和映射列表,以及实现或扩展这些类型的具体类型。
还有其他类型的依赖项注入,如属性 setter 注入和方法调用注入,但不太常见。 因此,本章重点介绍如何通过依赖关系注入容器来执行构造函数注入。
注:还可以使用工厂手动实现依赖关系注入。 但是,使用容器可提供其他功能(例如生存期管理),并通过程序集扫描进行注册。
public class ProfileViewModel : ViewModelBase { private IOrderService _orderService; public ProfileViewModel(IOrderService orderService) { _orderService = orderService; } ... }
负责实例化IOrderService对象并将其插入ProfileViewModel类的类称为依赖项注入容器。
【有许多依赖关系注入容器可用,eShopOnContainers 移动应用初始使用 Autofac 管理应用中的视图模型和服务类的实例化,后面用TinyIoc(单文件、简单、跨平台的 IoC 容器)替换了Autofac(2018.1.16)。
在使用MVVM的Xamarin.Forms应用程序的上下文中,通常将使用依赖项注入容器来注册和解析ViewModel,以及注册服务并将其注入视图模型。
在运行时,容器必须先实例化IOrderService接口的实现,然后才能实例化ProfileViewModel对象。 这涉及:
- 容器决定如何实例化实现IOrderService接口的对象。 这称为注册。
- 实例化实现IOrderService接口的对象以及ProfileViewModel对象的容器。 这称为解析。
1、注册
需要依赖关系注入的类型的注册应在应用的单个方法中执行,此方法应在应用生命周期中及早调用,以确保应用知道其类之间的依赖关系。 在 eShopOnContainers 移动应用中,这是由ViewModelLocator
类执行的,该类构建对象IContainer,并且是应用程序中唯一拥有对该对象(IContainer)的引用的类(ViewModelLocator)。 以下代码示例显示eShopOnContainers移动应用如何在ViewModelLocator类中声明IContainer对象:
private static readonly TinyIoCContainer _container; static ViewModelLocator() { _container = new TinyIoCContainer(); // View models - by default, TinyIoC will register concrete classes as multi-instance. _container.Register<LoginViewModel>(); _container.Register<SettingsViewModel>(); _container.Register<MainViewModel>(); _container.Register<SkilledViewModel>(); _container.Register<SkilledDetailViewModel>(); _container.Register<SkilledSaveViewModel>(); // Services - by default, TinyIoC will register interface registrations as singletons. _container.Register<INavigationService, NavigationService>(); _container.Register<ISettingsService, SettingsService>(); _container.Register<ISkilledService, SkilledSqliteMockService>(); }
此处Register
显示的方法将接口类型映射为具体类型,它告诉容器实例化需要通过构造函数注入IRequestProvider的对象时,实例化RequestProvider对象。
也可以直接注册具体类型,无需从接口类型进行映射,当解析LoginViewModel
类型时,容器将注入其所需的依赖项(ISettingsService )。
public LoginViewModel(ISettingsService settingsService) { _settingsService = settingsService; }
_container.Register<INavigationService, NavigationService>().AsSingleton();
AsSingleton方法配置注册,以便每个从属对象都接收相同的共享实例。 因此,容器中将仅存在一个NavigationService实例,该实例由需要通过构造函数注入INavigationService的对象共享。
TinyIoC默认将接口映射注册为单例,因此AsSingleton()可以省略。
2、解析
注册类型后,可以将其解析或作为依赖项注入。 在解析类型时,容器需要创建一个新实例,它将所有依赖项注入到实例中。
通常,解析类型时,会发生以下三种情况之一:
- 如果尚未注册类型,则容器将引发异常。
- 如果类型已注册为单例,则容器返回单例实例。 如果是第一次调用该类型,则容器将在需要时创建它,并维护对该类型的引用。
- 如果该类型尚未注册为单例,则容器将返回一个新实例,并且不会维护对该实例的引用。
_container.Resolve<T>();
TinyIoc解析T类型(接口/具体类)的具体类型以及任何依赖项。 通常,需要特定类型的实例时调用Resolve方法。 有关控制已解析对象的生存期的信息,请参阅管理已解析对象的生存期。
松散耦合组件之间的通信
发布-订阅模式是一种消息传递模式,在此模式下,发布者可在无需知道任何接收方(称为订阅方)的情况下发送消息。 同样,订阅方可在不了解任何发布方的情况下侦听特定消息。
.NET中的事件实现了发布-订阅模式,并且如果不需要松散耦合(例如控件和包含它的页面),则是组件之间通信层的最简单直接的方法。 但是,发布者和订阅者的生存期之间通过对象引用相互关联,并且订阅者类型必须具有对发布者类型的引用。 这会产生内存管理问题,尤其是当存在短期对象订阅静态或长期对象的事件时。 如果未删除事件处理程序,则订阅服务器将通过在发布服务器中对其进行引用而保持活动状态,这将防止或延迟订阅服务器的垃圾回收。
1、MessagingCenter 简介
Xamarin.Forms MessagingCenter
类可实现发布-订阅模式,允许在对象和类型引用不便于链接的组件之间进行基于消息的通信。 此机制允许发布者和订阅者在无需相互引用的情况下进行通信,帮助减少组件之间的依赖性,同时还允许这些组件接受独立开发和测试。
MessagingCenter
类提供多播发布-订阅功能。 这意味着可以有多个发布者发布单个消息,并且可以有多个订阅服务器侦听同一消息。 图4-1 说明了此关系:
图4-1: 多播发布-订阅功能
发布方使用 MessagingCenter.Send
方法发送消息,而订阅方使用 MessagingCenter.Subscribe
方法侦听消息。 此外,订阅方还可以使用 MessagingCenter.Unsubscribe
方法取消消息订阅(如果需要)。
在内部,MessagingCenter
类使用弱引用。 这意味着它不会使对象保持活动状态,而是允许对它们进行垃圾回收。 因此,只有当类不再希望接收消息时才需要取消订阅消息。
eg,在EShopOnContainers实例中,
- 将商品添加到购物篮时,CatalogViewModel类将发布AddProduct消息。 作为回报,BasketViewModel类订阅消息并增加购物篮中的商品数量以作为响应。 另外,BasketViewModel类也取消订阅此消息。
- 成功创建和提交新订单后,当CheckoutViewModel导航到MainViewModel时,MainViewModel类将发布ChangeTab消息。 作为回报,MainView类订阅消息并更新UI,以便“My profile”选项卡处于活动状态,以显示用户的订单。
注:尽管MessagingCenter类允许在松散耦合的类之间进行通信,但它不是唯一解决此问题的体系结构解决方案。 例如,视图模型和视图之间的通信也可以通过绑定引擎并通过属性更改通知来实现。 另外,两个视图模型之间的通信也可以通过在导航期间传递数据来实现。
在eShopOnContainers移动应用程序中,MessagingCenter用于在UI中进行更新,以响应另一个类中发生的操作。 因此,消息在UI线程上发布,而订阅者在同一线程上接收消息。
注:执行UI更新时,请编组到UI线程。 如果需要从后台线程发送的消息来更新UI,请通过调用Device.BeginInvokeOnMainThread方法在订阅服务器中的UI线程上处理消息。
2、方法说明
发生消息:
MessagingCenter.Send(this, MessengerKeys.AddProduct, catalogItem);
Send
该方法指定了三个参数:
- 第一个参数指定发送方类。 发送方类必须由想要接收消息的任何订阅服务器指定。
- 第二个参数指定消息(字符串类型)。
- 第三个参数指定要发送到订阅服务器的有效负载数据。 在这种情况下,负载数据是CatalogItem实例。
订阅消息:
MessagingCenter.Subscribe<CatalogViewModel, CatalogItem>( this, MessageKeys.AddProduct, async (sender, arg) => { BadgeCount++; await AddCatalogItemAsync(arg); });
考虑使用不变的有效载荷数据。 不要尝试从回调委托中修改有效负载数据,因为多个线程可能正在同时访问接收到的数据。 在这种情况下,有效载荷数据应该是不变的,以避免并发错误。
导航
Xamarin.Forms包含对页面导航的支持,这通常是由于用户与UI的交互或内部逻辑驱动的状态更改导致的应用本身的结果。 但是,在使用Model-View-ViewModel(MVVM)模式的应用中实现导航可能很复杂,因为必须满足以下挑战:
- 如何使用一种不会在视图之间引入紧密耦合和依赖性的方法来标识要导航到的视图。
- 如何协调要实例化和初始化要导航到的视图的过程。 使用MVVM时,需要实例化视图和视图模型,并通过视图的绑定上下文将它们彼此关联。 当应用程序使用依赖项注入容器时,视图和视图模型的实例化可能需要特定的构造机制。
- 是视图优先导航,还是视图模型优先导航。使用视图优先导航,要导航到的页面引用视图类型的名称,导航期间,将实例化指定的视图及其对应的视图模型和其他相关服务。一种替代方法是使用视图模型优先导航,其中要导航到的页面引用视图模型类型的名称。
- 如何在视图和视图模型之间明确区分应用程序的导航行为。 MVVM模式将应用程序的UI与其表示和业务逻辑分开。但是,应用程序的导航行为通常会跨越该应用程序的UI和演示部分。用户通常会从一个视图启动导航,并且该视图将由于导航而被替换。但是,导航通常也可能需要从视图模型中启动或协调。
- 如何在导航期间传递参数以进行初始化。例如,如果用户导航到视图以更新订单详细信息,则必须将订单数据传递到该视图,以便它可以显示正确的数据。
- 如何协调导航,以确保遵守某些业务规则。例如,在离开视图之前可能会提示用户,以便他们可以更正任何无效数据,或者提示用户提交或放弃在视图内进行的任何数据更改。
本章通过提供一个用来执行ViewModel优先导航的NavigationService类来解决这些挑战。
注:应用程序中使用的NavigationService仅设计用于在ContentPage实例之间执行分层导航,使用该服务在其他页面类型之间导航可能会导致意外行为。
1、在页面之间导航
导航逻辑可以写在视图的代码隐藏中,也可以位于数据绑定视图模型中。 虽然在视图中放置导航逻辑可能是最简单的方法,但不能通过单元测试轻松地对其进行测试,在视图模型类中放置导航逻辑意味着可以通过单元测试执行逻辑。 另外,视图模型然后可以实现控制导航的逻辑,以确保强制执行某些业务规则。 例如,在未首先确保输入的数据有效之前,应用可能不允许用户离开页面。
通常从视图模型中调用NavigationService类,以提高可测试性。 但是,从视图模型导航到视图将需要视图模型引用视图,特别是与活动视图模型不相关的视图,因此不建议这样做。 因此,此处提供的NavigationService将ViewModel类型指定为要导航到的目标(然后从ViewModel解析到View 并导航)。
NavigationService类实现INavigationService
接口:
/// <summary> /// 页面导航服务 /// </summary> public interface INavigationService { /// <summary> /// 返回与导航堆栈中的上一页关联的视图模型类型 /// </summary> ViewModelBase PreviousPageViewModel { get; } /// <summary> /// 启动应用程序时,执行到两个页面(登陆页面or主页面)之一的导航 /// </summary> /// <returns></returns> Task InitializeAsync(); /// <summary> /// 执行到特定页面的分层导航(通过ViewModel导航到Page) /// </summary> /// <typeparam name="TViewModel"></typeparam> /// <returns></returns> Task NavigateToAsync<TViewModel>() where TViewModel : ViewModelBase; /// <summary> /// 通过传递参数执行到指定页面的分层导航。 /// </summary> /// <typeparam name="TViewModel"></typeparam> /// <param name="parameter"></param> /// <returns></returns> Task NavigateToAsync<TViewModel>(object parameter) where TViewModel : ViewModelBase; /// <summary> /// 从导航堆栈中删除上一页 /// </summary> /// <returns></returns> Task RemoveLastFromBackStackAsync(); /// <summary> /// 从导航堆栈中删除所有先前的页面。 /// </summary> /// <returns></returns> Task RemoveBackStackAsync(); }
在依赖注入容器中注册,在ViewModelBase类中解析:
NavigationService = ViewModelLocator.Resolve<INavigationService>();
这将返回对存储在依赖项注入容器中的NavigationService对象的引用,该引用是由App类中的InitNavigation方法创建的。 有关更多信息,请参阅下面的“在启动应用程序时导航”。
ViewModelBase类将NavigationService实例存储在类型为INavigationService的NavigationService属性中。 因此,从ViewModelBase类派生的所有视图模型类都可以使用NavigationService属性来访问INavigationService接口指定的方法。 这避免了将依赖项注入容器中的NavigationService对象注入到每个视图模型类中的开销。
3、处理导航请求
Xamarin 提供NavigationPage
类,该类可实现分层导航体验,用户可在其中根据需要向前或向后导航页面。 有关分层导航的详细信息,请参阅分层导航。
eShopOnContainers应用程序不是直接使用NavigationPage类,而是将NavigationPage类包装在CustomNavigationView类中。
包装的目的是为了轻松为该类在XAML文件中设置NavigationPage实例的样式。eg,在CustomNavigationView.xaml中顶部定义
BarBackgroundColor="{StaticResource GreenColor}"
BarTextColor="{StaticResource WhiteColor}"
BackgroundColor="Transparent">
NavigationService类中提供的2个方法都允许通过调用InternalNavigateToAsync方法从ViewModelBase类派生的任何视图模型类执行分层导航。
private async Task InternalNavigateToAsync(Type viewModelType, object parameter) { Page page = CreatePage(viewModelType, parameter); if (page is LoginView) { Application.Current.MainPage = new CustomNavigationView(page); } else { var navigationPage = Application.Current.MainPage as CustomNavigationView; if (navigationPage != null) { await navigationPage.PushAsync(page); } else { Application.Current.MainPage = new CustomNavigationView(page); } } await (page.BindingContext as ViewModelBase).InitializeAsync(parameter); } private Type GetPageTypeForViewModel(Type viewModelType) { var viewName = viewModelType.FullName.Replace("Model", string.Empty); var viewModelAssemblyName = viewModelType.GetTypeInfo().Assembly.FullName; var viewAssemblyName = string.Format( CultureInfo.InvariantCulture, "{0}, {1}", viewName, viewModelAssemblyName); var viewType = Type.GetType(viewAssemblyName); return viewType; } private Page CreatePage(Type viewModelType, object parameter) { Type pageType = GetPageTypeForViewModel(viewModelType); if (pageType == null) { throw new Exception($"Cannot locate page type for {viewModelType}"); } Page page = Activator.CreateInstance(pageType) as Page; return page; }
如果正在创建的视图是LoginView,则将其包装在CustomNavigationView类的新实例中并分配给Application.Current.MainPage属性。
否则,将检索CustomNavigationView实例,如它不为null,则调用PushAsync方法将正在创建的视图推送到导航堆栈上;如果检索到的CustomNavigationView实例为null,则正在创建的视图将包装在CustomNavigationView类的新实例内,并分配给Application.Current.MainPage属性。 这种机制可确保在导航过程中,无论页面为空还是包含数据,都可以将页面正确添加到导航堆栈中。
注:考虑缓存页面。 页面缓存会导致当前未显示的视图占用内存。 但是,如果没有页面缓存,则确实意味着每次导航到新页面时都会进行XAML解析,页面及其视图模型的构造,这可能会对复杂页面产生性能影响。 对于不使用过多控件的设计良好的页面,性能应该足够。 但是,如果遇到较慢的页面加载时间,则页面缓存可能会有所帮助。
创建视图并导航到该视图后,将执行该视图关联的视图模型的InitializeAsync(初始化)方法。 see Passing Parameters During Navigation.。
4、在APP启动时进行导航
app.xaml.cs中
private Task InitNavigation() { var navigationService = ViewModelLocator.Resolve<INavigationService>(); return navigationService.InitializeAsync(); }
此处回创建NavigationService对象。当ViewModelBase类解析INavigationService接口时,容器将返回对调用InitNavigation方法时创建的NavigationService对象的引用(单例)。
5、在导航过程中传递参数
Task NavigateToAsync<TViewModel>(object parameter) where TViewModel : ViewModelBase;
导航时传递参数:
await NavigationService.NavigateToAsync<SkilledDetailViewModel>(skilledItem).ConfigureAwait(false);
参数在每个ViewModel的InitializeAsync方法中获取并做所需的逻辑处理。
6、使用行为调用导航
7、确认或取消导航
应用可能需要在导航操作期间与用户进行交互,以便用户可以确认或取消导航。 例如,当用户在完全完成数据输入页面之前尝试导航时,这可能是必需的。 在这种情况下,应用程序应提供一个通知,允许用户导航离开页面或在页面出现之前取消导航操作。 这可以在视图模型类中通过使用通知的响应来控制是否调用导航来实现。
验证
任何接受用户输入的应用都应确保输入是有效的。 例如,应用程序可以检查输入中是否仅包含特定范围内的字符、是否为特定长度,或是否匹配特定格式。 如果未进行验证,用户提供的数据可能导致应用故障。 验证可强制实施业务规则,并防止攻击者注入恶意数据。
在 ViewModel (MVVM)模式的上下文中,ViewModel或Model通常需要执行数据验证并向View发出任何验证错误信号,以便用户可以更正这些错误。
eshop中的验证相关的类:
需要验证的视图模型属性的类型为ValidatableObject <T>,并且每个ValidatableObject <T>实例(eg:UserName)都将验证规则添加到其Validations属性中。 通过调用ValidatableObject <T>实例的Validate方法从视图模型中调用验证,该方法检索验证规则并针对ValidatableObject <T>的Value属性执行它们。 将任何验证错误放入ValidatableObject <T>实例的Errors属性中,并更新ValidatableObject <T>实例的IsValid属性以指示验证成功还是失败。
因此Entry控件可以绑定到视图模型类中ValidatableObject <T>实例的IsValid属性,以通知输入的数据是否有效。
1、指定验证规则
自定义一些通用类来处理。
2、触发验证规则
- 手动触发:单击按钮等
- 绑定属性更改自动触发
<Entry Text="{Binding UserName.Value, Mode=TwoWay}"> <Entry.Behaviors> <behaviors:EventToCommandBehavior EventName="TextChanged" Command="{Binding ValidateUserNameCommand}" /> </Entry.Behaviors> ... </Entry>
Entry控件绑定到ValidatableObject <T>实例的UserName.Value属性,并且该控件的Behaviors集合具有一个EventToCommandBehavior实例,此行为将执行ValidateUserNameCommand来响应Entry上触发的[TextChanged]事件,当Entry中的文本更改时会引发该事件。 反过来,ValidateUserNameCommand委托执行ValidateUserName方法,该方法对ValidatableObject <T>实例执行Validate方法。 因此,每次用户在用户名的Entry控件中输入一个字符时,都会对输入的数据进行验证。
3、显示验证错误
eShopOnContainers移动应用通过用红线突出显示包含无效数据的控件,并在包含无效数据的控件下方显示一条错误消息来通知用户为何数据无效,从而将任何验证错误通知用户。 纠正无效数据后,该线将变为黑色,并且错误消息将被删除。 图6-2显示了出现验证错误时eShopOnContainers移动应用程序中的LoginView。
样式:(线的颜色)
<Entry.Style>中有使用EntryStyle(在app.xaml中定义)
<Style x:Key="EntryStyle" TargetType="{x:Type Entry}"> ... <Setter Property="behaviors:LineColorBehavior.ApplyLineColor" Value="True" /> <Setter Property="behaviors:LineColorBehavior.LineColor" Value="{StaticResource BlackColor}" /> ... </Style>
LineColorBehavior附加的行为用于突出显示发生验证错误的Entry控件。
EntryStyle样式在Entry控件上设置LineColorBehavior附加行为的ApplyLineColor和LineColor附加属性,设置或更改ApplyLineColor附加属性的值时,LineColorBehavior附加行为将执行OnApplyLineColorChanged方法,
此方法的参数是:提供行为附加到的控件的实例,以及ApplyLineColor附加属性的旧值和新值。 如果ApplyLineColor附加属性新值为true,则EntryLineColorEffect类将添加到控件的Effects集合中,否则将其从控件的Effects集合中删除。
EntryLineColorEffect是
RoutingEffect的子类,RoutingEffect类表示独立于平台的效果,该效果包装了特定于平台的内部效果。 由于没有编译时访问特定平台特有效果的类型信息,因此简化了效果删除过程。 EntryLineColorEffect调用基类构造函数,并传入一个参数,该参数由分辨率组名称的串联以及在每个特定于平台的效果类上指定的唯一ID组成。【这要求每个平台都需要实现EntryLineColorEffect】
触发器:
Entry控件还向其Triggers集合添加了DataTrigger
<Entry Text="{Binding UserName.Value, Mode=TwoWay}"> ... <Entry.Triggers> <DataTrigger TargetType="Entry" Binding="{Binding UserName.IsValid}" Value="False"> <Setter Property="behaviors:LineColorBehavior.LineColor" Value="{StaticResource ErrorColor}" /> </DataTrigger> </Entry.Triggers> </Entry>
此DataTrigger监视UserName.IsValid属性,如果其值变为false,它将执行Setter,该Setter将LineColorBehavior附加行为的LineColor附加属性更改为红色。
输入的数据无效时,Entry控件中的行将保持红色,否则它将变为黑色以指示输入的数据有效。
4、显示错误信息
<!--绑定源是UserName.Errors,目标是Label的Text属性(String类型),需要Converter--> <Label Text="{Binding UserName.Errors, Converter={StaticResource FirstValidationErrorConverterKey}}" Style="{StaticResource ValidationErrorLabelStyle}" />
每个Label都绑定到正在验证的视图模型对象的Errors属性。 Errors属性由ValidatableObject<T>类提供,类型为List<string>。 由于Errors属性可以包含多个验证错误,因此FirstValidationErrorConverter实例用于从集合中检索第一个错误以进行显示。
配置管理
设置允许从代码中分离出用于配置应用程序行为的数据,从而无需更改应用程序即可更改行为。 设置有两种类型:app settings, and user settings.(应用程序设置和用户设置)。
- 应用程序设置是应用程序创建和管理的数据。 它可以包含诸如固定Web服务终结点,API密钥和运行时状态之类的数据。 应用程序设置与应用程序的存在有关,并且仅对该应用程序有意义。
- 用户设置是应用程序的可自定义设置,会影响应用程序的行为,因此无需经常进行重新调整。 例如,一个应用程序可以让用户指定从何处检索数据以及如何在屏幕上显示数据。
Xamarin.Forms包含一个可用于存储设置数据的持久字典,可以使用Application.Current.Properties属性访问此字典,并且当应用程序进入睡眠状态时,保存在其中的任何数据都会在应用程序恢复或再次启动时恢复。
此外,Application类还具有SavePropertiesAsync方法,该方法允许应用程序在需要时保存其设置。 有关此字典的更多信息,请参见属性字典。使用Xamarin.Forms持久性字典存储数据的不利之处在于,它不容易绑定到数据。
因此,eShopOnContainers移动应用程序使用NuGet的Xam.Plugins.Settings库。 该库提供了一致的,类型安全的跨平台方法,用于在使用每个平台提供的本机设置管理的同时,持久化和检索应用程序和用户设置。
此外,使用数据绑定来访问库公开的设置数据也很简单【最新的代码中并没有使用】
注:现在代码中涉及到的是 自定义类:SettingsService.cs、GlobalSettings.cs
增加设置
每个设置都包含一个key,一个默认值和一个属性。 以下代码示例显示了用户设置的所有三个项,这些设置代表eShopOnContainers移动应用程序连接到的在线服务的基本URL:
public static class Settings { ... private const string IdUrlBase = "url_base"; private static readonly string UrlBaseDefault = GlobalSetting.Instance.BaseEndpoint; ... public static string UrlBase { get { return AppSettings.GetValueOrDefault<string>(IdUrlBase, UrlBaseDefault); } set { AppSettings.AddOrUpdateValue<string>(IdUrlBase, value); } } }
key始终是定义键名称的const字符串,设置的默认值是所需类型的静态只读值,提供默认值可确保在检索未设置的情况下有效值可用。
UrlBase静态属性使用两个方法来读取或写入设置值。 GetValueOrDefault方法用于从平台特定的存储中检索设置的值,如果没有为设置定义任何值,则将检索其默认值。 同样AddOrUpdateValue方法用于将设置的值持久保存到特定于平台的存储中。
单元测试
移动应用程序具有桌面和基于Web的应用程序不必担心的独特问题。 移动用户将因其使用的设备,网络连接性,服务的可用性以及一系列其他因素而有所不同。 因此,应测试移动应用程序,因为它们将在现实世界中使用,以提高其质量,可靠性和性能,应在应用程序上执行多种类型的测试,包括单元测试,集成测试和用户界面测试,其中单元测试是最常见的测试形式。
单元测试占用应用程序的一小部分,通常是一种方法,将其与其余代码隔离,并验证其行为是否符合预期。 其目标是检查每个功能单元是否按预期执行,以免错误不会在整个应用程序中传播。 与在次要故障点间接观察错误的影响相比,检测错误的发生效率更高。
单元测试是软件开发工作流程中不可或缺的一部分,它对代码质量的影响最大。 编写方法后,应立即编写单元测试,以根据输入数据的标准,边界和不正确情况验证方法的行为,并检查代码所做的任何显式或隐式假设。 或者,对于测试驱动的开发,在代码之前编写单元测试。 在这种情况下,单元测试既是设计文档又是功能规范。
注:单元测试对于回归非常有效-也就是说,该功能曾经可以使用,但是由于错误的更新而受到干扰。
单元测试通常使用ranging-act-assert模式:
- 单元测试方法的ranging部分将初始化对象并设置传递给被测方法的数据的值。
- act部分使用必需的参数调用被测方法。
- 断言部分验证被测方法的行为是否符合预期。
遵循此模式可确保单元测试的可读性和一致性。
依赖注入和单元测试
采用松耦合架构的动机之一是它有利于单元测试。
OrderDetailViewModel类对IOrderService类型具有依赖关系,容器在实例化OrderDetailViewModel对象时将解析该IOrderService类型。 但是,与其创建OrderService对象以对OrderDetailViewModel类进行单元测试,不如将其替换为模拟的OrderService对象以进行测试。 图10-1说明了这种关系。
此方法允许在运行时将OrderService对象传递到OrderDetailViewModel类中,并且出于可测试性考虑,它允许在测试时将OrderMockService类传递到OrderDetailViewModel类中。 这种方法的主要优点在于,它可以执行单元测试,而无需使用繁琐的资源(例如Web服务或数据库)。
测试MVVM应用程序
从MVVM应用程序中测试模型和视图模型与测试任何其他类相同,并且可以使用相同的工具和技术(例如单元测试和模拟)。 但是,有一些模式是建模和查看模型类的典型模式,可以从特定的单元测试技术中受益。
在每个单元测试中测试一件事。 不要试图使单元测试练习超出单元行为的一个方面。 这样做会导致难以读取和更新的测试。 在解释故障时,也可能导致混乱。
eShopOnContainers移动应用程序使用xUnit进行单元测试,它支持两种不同类型的单元测试:
- Facts是始终正确的测试,它测试不变的条件。
- Theories是仅适用于特定数据集的测试。
eShopOnContainers移动应用程序附带的单元测试是Fact测试,因此每种单元测试方法都用[Fact]属性修饰。
注:xUnit测试由a test runner执行。 要执行the test runner,请为所需平台运行eShopOnContainers.TestRunner项目。
在VS中创建测试项目:
编写测试方法,以ViewModel为例:
public class SkilledViewModelTests { /// <summary> /// 测试Viewmodel中的命令不为空 /// </summary> [Fact] public void FilterCommandIsNotNullTest() { var mockService = new SkilledSqliteMockService(); var skilledViewModel = new SkilledViewModel(mockService); Assert.NotNull(skilledViewModel.SkillAddCommand); } /// <summary> /// 测试Viewmodel中的Items初始为null /// </summary> [Fact] public void SkilledItemsPropertyIsNullWhenViewModelInstantiatedTest() { var mockService = new SkilledSqliteMockService(); var skilledViewModel = new SkilledViewModel(mockService); Assert.Null(skilledViewModel.SkilledItems); } /// <summary> /// 测试Viewmodel中的Items,在调用初始化后为null /// </summary> /// <returns></returns> [Fact] public async Task SkilledItemsPropertyIsNotNullAfterViewModelInitializationTest() { var mockService = new SkilledSqliteMockService(); var skilledViewModel = new SkilledViewModel(mockService); await skilledViewModel.InitializeAsync(null); Assert.NotNull(skilledViewModel.SkilledItems); } }
直接运行测试即可。
测试INotifyPropertyChanged实现
实现INotifyPropertyChanged接口允许 视图对源自视图模型和模型的更改做出反应。 这些更改不仅限于控件中显示的数据,它们还用于控制视图,例如导致动画启动或控件被禁用的视图模型状态。
可以通过将事件处理程序附加到PropertyChanged事件并检查 为属性设置新值后是否引发该事件来测试 可以通过单元测试直接更新的属性。 以下代码示例显示了这样的测试:
public class SkilledDetailViewModelTests { /// <summary> /// 属性更改 引发事件INotifyPropertyChanged /// </summary> /// <returns></returns> [Fact] public async Task SettingOrderPropertyShouldRaisePropertyChanged() { bool invoked = false; var mockService = new SkilledSqliteMockService(); var skilledDetailViewModel = new SkilledDetailViewModel(mockService); skilledDetailViewModel.PropertyChanged += (sender, e) => { if (e.PropertyName.Equals("SkilledItem")) invoked = true; }; //刚开始SkilledItem=null SkilledItem skilledItem = new SkilledItem() { Id = 1, Name = "C#" }; var order = await mockService.AddSkilledItemAsync(skilledItem); //调用初始化后,SkillItem将赋值,属性发生改变 await skilledDetailViewModel.InitializeAsync(skilledItem); Assert.True(invoked); } }
此单元测试调用ViewModel类的InitializeAsync方法,这将导致其xxx属性被更新。 如果为XXX属性引发了PropertyChanged事件,则单元测试将通过。