Xamarin-Forms-项目-全-

Xamarin.Forms 项目(全)

原文:zh.annas-archive.org/md5/BCF2270FBE70F13E76739867E1CF82CA

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Xamarin.Forms 项目是一本实践性的书,您将从头开始创建七个应用程序。您将获得设置环境所需的基本技能,我们将解释 Xamarin 是什么,然后过渡到 Xamarin.Forms,真正利用真正的本地跨平台代码。

阅读本书后,您将真正了解创建一个可以持续发展并经得起时间考验的应用程序需要什么。

我们将涵盖动画,增强现实,消费 REST 接口,使用 SignalR 进行实时聊天,以及使用设备 GPS 进行位置跟踪等内容。还有机器学习和必备的待办事项清单。

愉快的编码!

这本书适合谁

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

不需要预先了解 Xamarin 的知识,但如果您曾在传统的 Xamarin 中工作并希望迈出向 Xamarin.Forms 的步伐,那将是一个很大的帮助。

本书涵盖内容

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

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

第三章,使用动画创建丰富用户体验的匹配应用程序,让您深入了解如何使用动画和内容放置定义更丰富的用户界面。它还涵盖了自定义控件的概念,将用户界面封装成自包含的组件。

第四章,使用 GPS 和地图构建位置跟踪应用程序,涉及使用设备 GPS 的地理位置数据以及如何在地图上绘制这些数据的图层。它还解释了如何使用后台服务长时间跟踪位置,以创建您花费时间的热图。

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

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

第七章,构建实时聊天应用程序,延续了前一章的内容,涵盖了应用程序的前端,即 Xamarin.Forms 应用程序,它连接到中继消息的后端,实现用户之间的消息传递。本章重点介绍了如何在客户端设置 SignalR,并解释了如何创建一个服务模型,通过消息和事件抽象化这种通信。

第八章,创建增强现实游戏,将两种不同的 AR API 绑定到一个 UrhoSharp 解决方案中。Android 使用 ARCore 处理增强现实,iOS 使用 ARKit 执行相同的操作。我们将通过自定义渲染器下降到特定于平台的 API,并将结果公开为 Xamarin.Forms 应用程序消耗的通用 API。

第九章,使用机器学习识别热狗或非热狗,涵盖了创建一个应用程序,该应用程序使用机器学习来识别图像是否包含热狗。

从本书中获得最大收益

我们建议您阅读第一章,以确保您对 Xamarin 的基本概念有所了解。之后,您可以选择任何您喜欢的章节来学习更多。每一章都是独立的,但章节按复杂性排序;您在书中的位置越深,应用程序就越复杂。

这些应用程序适用于实际应用,但某些部分被省略,例如适当的错误处理和分析,因为它们超出了本书的范围。然而,您应该对如何创建应用程序的基本构建块有所了解。

话虽如此,如果您是 C#和.NET 开发人员已经有一段时间了,那么会有所帮助,因为许多概念实际上并不是特定于应用程序,而是一般的良好实践,例如 Model-View-ViewModel 和控制反转。

但最重要的是,这是一本可以帮助您通过专注于最感兴趣的章节来启动 Xamarin.Forms 开发学习曲线的书籍。

下载示例代码文件

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Xamarin.Forms-Projects。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789537505_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“打开DescriptionGenerator.cs文件,并添加一个构造函数,如下面的代码所示。”

代码块设置如下:

public class DescriptionGenerator
{
  private string[] _adjectives = { "nice", "horrible", "great",
                                   "terribly old", "brand new" };
  private string[] _other = { "picture of grandpa", "car", "photo
                               of a forest", "duck" };
  private static Random random = new Random();
  public string Generate()
{
  var a = _adjectives[random.Next(_adjectives.Count())];
  var b = _other[random.Next(_other.Count())];
  return $"A {a} {b}";
}
}

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

{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(savedInstanceState);

    global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
    Xamarin.Essentials.Platform.Init(this, savedInstanceState);
    LoadApplication(new App());
}

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”

警告或重要说明会以这种形式出现。

提示和技巧会显示在这样的形式下。

第一章:Xamarin 简介

本章主要介绍 Xamarin 是什么以及可以从中期望什么。这是唯一的纯理论章节;其他章节将涵盖实际项目。您不需要在此时编写任何代码,而是简单地阅读本章,以开发对 Xamarin 是什么以及 Xamarin.Forms 与 Xamarin 的关系的高层理解。

我们将首先定义什么是原生应用程序以及.NET 作为一种技术带来了什么。之后,我们将看一下 Xamarin.Forms 如何适应更大的图景和

学习何时适合使用传统的 Xamarin 和 Xamarin.Forms。我们经常使用术语传统的 Xamarin来描述不使用 Xamarin.Forms 的应用程序,尽管 Xamarin.Forms 应用程序是通过传统的 Xamarin 应用程序引导的。

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

  • 原生应用程序

  • Xamarin 和 Mono

  • Xamarin.Forms

  • 设置开发机器

让我们开始吧!

原生应用程序

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

  • 原生用户界面:使用 Xamarin 构建的应用程序使用每个平台的标准控件。这意味着,例如,使用 Xamarin 构建的 iOS 应用程序将看起来和行为与 iOS 用户期望的一样,使用 Xamarin 构建的 Android 应用程序将看起来和行为与 Android 用户期望的一样。

  • 原生性能:使用 Xamarin 构建的应用程序经过本地性能编译,可以使用特定于平台的硬件加速。

  • 原生 API 访问:原生 API 访问意味着使用 Xamarin 构建的应用程序可以使用目标平台和设备为开发人员提供的一切。

Xamarin 和 Mono

Xamarin 是一个开发平台,用于开发 iOS(Xamarin.iOS)、Android(Xamarin.Android)和 macOS(Xamarin.Mac)的原生应用程序。它在这些平台的顶部技术上是一个绑定层。绑定到平台 API 使.NET 开发人员可以使用 C#(和 F#)开发具有每个平台完整功能的原生应用程序。我们在使用 Xamarin 开发应用程序时使用的 C# API 与平台 API 几乎相同,但它们是.NET 化的。例如,API 通常定制以遵循.NET 命名约定,并且 Android 的setget方法通常被属性替换。这样做的原因是 API 应该更容易供.NET 开发人员使用。

Mono(www.mono-project.com)是 Microsoft .NET 框架的开源实现,基于 C#和公共语言运行时(CLR)的欧洲计算机制造商协会ECMA)标准。Mono 的创建是为了将.NET 框架带到 Windows 以外的平台。它是.NET 基金会(www.dotnetfoundation.org)的一部分,这是一个支持涉及.NET 生态系统的开放发展和协作的独立组织。

通过 Xamarin 平台和 Mono 的组合,我们将能够同时使用所有特定于平台的 API 和.NET 的所有平台无关部分,包括例如命名空间、系统、System.LinqSystem.IOSystem.NetSystem.Threading.Tasks

有几个原因可以使用 Xamarin 进行移动应用程序开发,我们将在以下部分中看到。

代码共享

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

除了围绕.NET 平台的大型社区外,还有大量的第三方库和组件可以从 NuGet(nuget.org)下载并在.NET 平台上使用。

跨平台的代码共享将导致更短的开发时间。这也将导致更高质量的应用程序,因为我们只需要编写一次业务逻辑的代码。出现错误的风险会降低,我们还能够保证计算将返回相同的结果,无论用户使用什么平台。

利用现有知识

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

同样,想要构建原生移动应用程序的组织可以利用其现有的具有.NET 知识的开发人员来开发应用程序。因为.NET 开发人员比 Objective-C 和 Swift 开发人员更多,所以更容易找到新的开发人员来进行移动应用程序开发项目。

Xamarin.iOS

Xamarin.iOS 用于使用.NET 构建 iOS 应用程序,并包含了之前提到的 iOS API 的绑定。Xamarin.iOS 使用提前编译AOT)将 C#代码编译为高级精简机器ARM)汇编语言。Mono 运行时与 Objective-C 运行时一起运行。使用.NET 命名空间的代码,如System.LinqSystem.Net,将由 Mono 运行时执行,而使用 iOS 特定命名空间的代码将由 Objective-C 运行时执行。Mono 运行时和 Objective-C 运行时都运行在由苹果开发的类 Unix 内核X is Not UnixXNU)(en.wikipedia.org/wiki/XNU)之上。以下图表显示了 iOS 架构的概述:

Xamarin.Android

Xamarin.Android 用于使用.NET 构建 Android 应用程序,并包含了对 Android API 的绑定。Mono 运行时和 Android 运行时并行运行在 Linux 内核之上。Xamarin.Android 应用程序可以是即时编译JIT)或 AOT 编译的,但要对其进行 AOT 编译,需要使用 Visual Studio Enterprise。

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

Xamarin.Mac

Xamarin.Mac 用于使用.NET 构建 macOS 应用程序,并包含了对 macOS API 的绑定。Xamarin.Mac 与 Xamarin.iOS 具有相同的架构,唯一的区别是 Xamarin.Mac 应用程序是 JIT 编译的,而不像 Xamarin.iOS 应用程序是 AOT 编译的。如下图所示:

Xamarin.Forms

Xamarin.Forms是建立在 Xamarin(用于 iOS 和 Android)和通用 Windows 平台UWP)之上的 UI 框架。Xamarin.Forms使开发人员能够使用一个共享的代码库为 iOS、Android 和 UWP 创建 UI,如下图所示。如果我们正在使用Xamarin.Forms构建应用程序,我们可以使用 XAML、C#或两者的组合来创建 UI:

Xamarin.Forms的架构

Xamarin.Forms基本上只是每个平台上的一个抽象层。Xamarin.Forms有一个共享层,被所有平台使用,以及一个特定于平台的层。特定于平台的层包含渲染器。渲染器是一个将Xamarin.Forms控件映射到特定于平台的本机控件的类。每个Xamarin.Forms控件都有一个特定于平台的渲染器。

以下图示了当在 iOS 应用中使用共享的 Xamarin.Forms 代码时,Xamarin.Forms中的输入控件是如何渲染为UIKit命名空间中的UITextField控件的。在 Android 中相同的代码会渲染为Android.Widget命名空间中的EditText控件:

使用 XAML 定义用户界面

在 Xamarin.Forms 中声明用户界面的最常见方式是在 XAML 文档中定义它。也可以通过 C#创建 GUI,因为 XAML 实际上只是用于实例化对象的标记语言。理论上,您可以使用 XAML 来创建任何类型的对象,只要它具有无参数的构造函数。XAML 文档是具有特定模式的可扩展标记语言(XML)文档。

定义一个标签控件

作为一个简单的例子,让我们来看一下以下 XAML 代码片段:

<Label Text="Hello World!" />

当 XAML 解析器遇到这个代码片段时,它将创建一个Label对象的实例,然后设置与 XAML 中的属性对应的对象的属性。这意味着如果我们在 XAML 中设置了Text属性,它将设置在创建的Label对象的实例上的Text属性。上面例子中的 XAML 将产生与以下相同的效果:

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

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

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

StackLayout是一个容器控件,它将在该容器内垂直或水平地组织子元素。垂直组织是默认值,除非您另行指定。还有许多其他容器,如GridFlexLayout。这些将在接下来的章节中的许多项目中使用。

在 XAML 中创建页面

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

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
     xmlns="http://xamarin.com/schemas/2014/forms"
     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
     x:Class="MyApp.MainPage">

    <StackLayout>
        <Label Text="Hello world!" />
    </StackLayout>
</ContentPage>

在上面的例子中,我们定义了一个ContentPage,它在每个平台上都会被翻译成一个视图。为了使它成为有效的 XAML,您必须指定一个默认命名空间()然后添加x命名空间()。

默认命名空间允许您创建对象而无需为它们加前缀,就像StackLayout对象一样。x命名空间允许您访问属性,如x:Class,它告诉 XAML 解析器在创建ContentPage对象时实例化哪个类来控制页面。

ContentPage只能有一个子元素。在这种情况下,它是一个StackLayout控件。除非您另行指定,默认的布局方向是垂直的。因此,StackLayout可以有多个子元素。稍后,我们将介绍更高级的布局控件,如GridFlexLayout控件。

在这个特定的例子中,我们将创建一个Label控件作为StackLayout的第一个子元素。

在 C#中创建页面

为了清晰起见,以下代码展示了相同的内容在 C#中的写法:

public class MainPage : ContentPage
{
}

page是一个从Xamarin.Forms.ContentPage继承的类。如果你创建一个 XAML 页面,这个类会自动生成,但如果你只用代码,那么你就需要自己定义它。

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

var page = new MainPage();

var stacklayout = new StackLayout();
stacklayout.Children.Add(
    new Label()
    {
        Text = "Welcome to Xamarin.Forms"
    });

page.Content = stacklayout;

第一条语句创建了一个page。理论上,你可以直接创建一个ContentPage类型的新页面,但这会禁止你在其后写任何代码。因此,最好的做法是为你计划创建的每个页面创建一个子类。

紧接着第一条语句的是创建包含添加到Children集合中的Label控件的StackLayout控件的代码块。

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

XAML 还是 C#?

通常使用 XAML 会给你一个更好的概览,因为页面是对象的分层结构,而 XAML 是定义这种结构的一种非常好的方式。在代码中,结构会被颠倒,因为你必须先定义最内部的对象,这样就更难读取页面的结构。这在本章的早些例子中已经展示过了。话虽如此,如何定义 GUI 通常是一种偏好。本书将在以后的项目中使用 XAML 而不是 C#。

Xamarin.Forms 与传统 Xamarin

虽然本书是关于 Xamarin.Forms 的,但我们将强调使用传统 Xamarin 和 Xamarin.Forms 之间的区别。当开发使用 iOS 和 Android SDK 而没有任何抽象手段的应用程序时,使用传统的 Xamarin。例如,我们可以创建一个 iOS 应用程序,在故事板或直接在代码中定义其用户界面。这段代码将无法在其他平台上重用,比如 Android。使用这种方法构建的应用程序仍然可以通过简单引用.NET 标准库来共享非特定于平台的代码。这种关系在下图中显示:

另一方面,Xamarin.Forms 是 GUI 的抽象,它允许我们以一种与平台无关的方式定义用户界面。它仍然建立在 Xamarin.iOS、Xamarin.Android 和所有其他支持的平台之上。Xamarin.Forms 应用程序可以创建为.NET 标准库或共享代码项目,其中源文件被链接为副本,并在当前构建的平台的同一项目中构建。这种关系在下图中显示:

话虽如此,没有传统的 Xamarin,Xamarin.Forms 就无法存在,因为它是通过每个平台的应用程序引导的。这使您能够通过接口将自定义渲染器和特定于平台的代码扩展到每个平台上的 Xamarin.Forms。我们将在本章后面详细讨论这些概念。

何时使用 Xamarin.Forms

我们可以在大多数情况下和大多数类型的应用中使用 Xamarin.Forms。如果我们需要使用 Xamarin.Forms 中没有的控件,我们可以随时使用特定于平台的 API。然而,有一些情况下 Xamarin.Forms 是无法使用的。我们可能希望避免使用 Xamarin.Forms 的最常见情况是,如果我们正在构建一个希望在目标平台上看起来非常不同的应用程序。

设置开发机器

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

  • iOS:要为 iOS 开发应用程序,我们需要一台 Mac。这可以是我们正在开发的机器,也可以是我们网络上的一台机器(如果我们正在使用)。我们需要连接到 Mac 的原因是我们需要 Xcode 来编译和调试应用程序。Xcode 还提供了 iOS 模拟器。

  • Android:Android 应用可以在 macOS 或 Windows 上开发。包括 SDK 和模拟器在内的一切都将与 Visual Studio 一起安装。

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

设置 Mac

在 Mac 上开发使用 Xamarin 开发 iOS 和 Android 应用程序需要两个主要工具。这些工具是 Visual Studio for Mac(如果我们只开发 Android 应用程序,这是我们唯一需要的工具)和 Xcode。在接下来的部分中,我们将看看如何为应用程序开发设置 Mac。

安装 Xcode

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

我们可以从苹果开发者门户(developer.apple.com)或苹果应用商店下载 Xcode。我建议您从应用商店下载,因为这将始终为您提供最新的稳定版本。从开发者门户下载 Xcode 的唯一原因是,如果我们想要使用 Xcode 的预发布版本,例如为 iOS 的预发布版本进行开发。

第一次安装后,以及每次更新 Xcode 后,打开它很重要。Xcode 经常需要在安装或更新后安装额外的组件。您还需要打开 Xcode 以接受与苹果的许可协议。

安装 Visual Studio

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

当我们通过下载的文件启动 Visual Studio 安装程序时,它将开始检查我们的机器上已安装了什么。检查完成后,我们将能够选择要安装的平台和工具。请注意,Xamarin Inspector 需要 Visual Studio 企业许可证。

一旦我们选择了要安装的平台,Visual Studio 将下载并安装我们使用 Xamarin 开始应用程序开发所需的一切,如下图所示:

配置 Android 模拟器

Visual Studio 将使用 Google 提供的 Android 模拟器。如果我们希望模拟器运行速度快,那么我们需要确保它是硬件加速的。要对 Android 模拟器进行硬件加速,我们需要安装Intel Hardware Accelerated Execution ManagerHAXM),可以从software.intel.com/en-us/articles/intel-hardware-accelerated-execution-manager-intel-haxm下载。

下一步是创建一个 Android 模拟器。首先,我们需要确保已安装了 Android 模拟器和 Android 操作系统映像。要做到这一点,请按照以下步骤进行:

  1. 转到工具选项卡安装 Android 模拟器:

  1. 我们还需要安装一个或多个图像以与模拟器一起使用。例如,如果我们想要在不同版本的 Android 上运行我们的应用程序,我们可以安装多个图像。我们将选择具有 Google Play 的模拟器(如下面的屏幕截图所示),以便在模拟器中运行应用程序时可以使用 Google Play 服务。例如,如果我们想要在应用程序中使用 Google 地图,则需要这样做:

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

  1. 如果单击“新设备”按钮,我们可以创建一个具有我们需要的规格的新模拟器。在这里创建新模拟器的最简单方法是选择与我们需求匹配的基础设备。这些基础设备将被预先配置,通常足够。但是,也可以编辑设备的属性,以便获得与我们特定需求匹配的模拟器。

因为我们不会在具有 ARM 处理器的设备上运行模拟器,所以我们必须选择 x86 处理器或 x64 处理器,如下面的屏幕截图所示。如果我们尝试使用 ARM 处理器,模拟器将非常慢:

设置 Windows 机器

我们可以使用虚拟或物理 Windows 机器进行 Xamarin 开发。例如,我们可以在 Mac 上运行虚拟 Windows 机器。我们在 Windows 机器上进行应用程序开发所需的唯一工具是 Visual Studio。

安装 Visual Studio 的 Xamarin

如果我们已经安装了 Visual Studio,我们必须首先打开 Visual Studio 安装程序;否则,我们需要转到visualstudio.microsoft.com下载安装文件。

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

如果我们想要为 Windows 开发应用程序,我们需要选择通用 Windows 平台开发工作负载,如下面的屏幕截图所示:

对于 Xamarin 开发,我们需要安装带有.NET 的移动开发。如果您想要使用 Hyper-V 进行硬件加速,我们可以在左侧的.NET 移动开发工作负载的详细描述中取消选择 Intel HAXM 的复选框,如下面的屏幕截图所示。当我们取消选择 Intel HAXM 时,Android 模拟器也将被取消选择,但我们可以稍后安装它:

当我们首次启动 Visual Studio 时,将询问我们是否要登录。除非我们想要使用 Visual Studio 专业版或企业版,否则我们不需要登录,否则我们必须登录以便验证我们的许可证。

将 Visual Studio 与 Mac 配对

如果我们想要运行,调试和编译我们的 iOS 应用程序,那么我们需要将其连接到 Mac。我们可以手动设置 Mac,如本章前面描述的那样,或者我们可以使用自动 Mac 配置。这将在我们连接的 Mac 上安装 Mono 和 Xamarin.iOS。它不会安装 Visual Studio IDE,但如果您只想将其用作构建机器,则不需要。但是,我们需要手动安装 Xcode。

要能够连接到 Mac(无论是手动安装的 Mac 还是使用自动 Mac 配置),Mac 需要通过我们的网络访问,并且我们需要在 Mac 上启用远程登录。要做到这一点,转到设置 | 共享,并选择远程登录的复选框。在窗口的左侧,我们可以选择允许连接远程登录的用户,如下截图所示:

从 Visual Studio 连接到 Mac,可以在工具栏中使用“连接到 Mac”按钮(如下截图所示),或者在顶部菜单中选择工具 | iOS,最后选择连接到 Mac:

将显示一个对话框,显示可以在网络上找到的所有 Mac。如果 Mac 不出现在可用 Mac 列表中,我们可以使用左下角的“添加 Mac”按钮输入 IP 地址,如下截图所示:

如果 Mac 上安装了您需要的一切,那么 Visual Studio 将连接,我们可以开始构建和调试我们的 iOS 应用程序。如果 Mac 上缺少 Mono,将会出现警告。此警告还将给我们安装它的选项,如下截图所示:

配置 Android 模拟器和硬件加速

如果我们想要一个运行流畅的快速 Android 模拟器,就需要启用硬件加速。这可以使用 Intel HAXM 或 Hyper-V 来实现。Intel HAXM 的缺点是它不能在装有AMD处理器的机器上使用;你必须有一台装有 Intel 处理器的机器。我们不能同时使用 Intel HAXM 和 Hyper-V。

因此,Hyper-V 是在 Windows 机器上硬件加速 Android 模拟器的首选方式。要在 Android 模拟器中使用 Hyper-V,我们需要安装 2018 年 4 月更新(或更高版本)的 Windows 和 Visual Studio 15.8 版本(或更高版本)。要启用 Hyper-V,需要按照以下步骤进行:

  1. 打开开始菜单,键入“打开或关闭 Windows 功能”。单击出现的选项以打开它,如下截图所示:

  1. 要启用 Hyper-V,选择 Hyper-V 复选框。此外,展开 Hyper-V 选项并选中 Hyper-V 平台复选框。我们还需要选择 Windows Hypervisor Platform 复选框,如下截图所示:

  1. 当 Windows 提示时重新启动机器。

因为在安装 Visual Studio 时我们没有安装 Android 模拟器,所以现在需要安装它。转到 Visual Studio 的工具菜单,点击 Android,然后点击 Android SDK Manager。

在 Android SDK Manager 的工具中,我们可以通过选择 Android 模拟器来安装模拟器,如下截图所示。此外,我们应该确保安装了最新版本的 Android SDK 构建工具:

我们建议安装NDKNative Development Kit)。NDK 使得可以导入用 C 或 C++编写的库。如果我们想要 AOT 编译应用程序,也需要 NDK。

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

如果我们想在应用程序中使用谷歌地图,就需要这样做:

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

如果我们点击“新设备”按钮,我们可以创建一个符合我们需求的新模拟器。在这里创建新模拟器的最简单方法是选择符合我们需求的基础设备。这些基础设备将被预先配置,通常已经足够了。但是,我们也可以编辑设备的属性,以便获得符合我们特定需求的模拟器。

我们必须选择 x86 处理器(如下图所示)或 x64 处理器,因为我们不会在 ARM 处理器的设备上运行模拟器。如果我们尝试使用 ARM 处理器,模拟器将非常慢:

配置 UWP 开发者模式

如果我们想开发 UWP 应用程序,我们需要在开发机器上激活开发者模式。要做到这一点,请转到“设置”|“更新和安全”|“开发人员”,然后点击“开发人员模式”,如下图所示。这样我们就可以通过 Visual Studio 侧载和调试应用程序了。

如果我们选择侧载应用程序而不是开发者模式,我们只能安装应用程序,而不需要经过 Microsoft Store。如果我们有一台用于测试而不是调试我们的应用程序的机器,我们可以选择侧载应用程序。

总结

阅读完本章后,您应该对 Xamarin 是什么以及 Xamarin.Forms 与 Xamarin 本身的关系有了一些了解。

在本章中,我们确定了我们对本地应用程序的定义,其中包括以下元素:

  • 本地用户界面

  • 本地性能

  • 本地 API 访问

我们谈到了 Xamarin 是基于 Mono 构建的,Mono 是 .NET 框架的开源实现,并讨论了在其核心,Xamarin 是一组绑定到特定平台 API 的工具。然后我们详细了解了 Xamarin.iOS 和 Xamarin.Android 是如何工作的。

之后,我们开始接触本书的核心主题,即 Xamarin.Forms。我们首先概述了平台无关控件如何渲染为特定于平台的控件,以及如何使用 XAML 定义控件层次结构来组装页面。

然后我们花了一些时间来看 Xamarin.Forms 应用程序和传统 Xamarin 应用程序之间的区别。

传统的 Xamarin 应用程序直接使用特定于平台的 API,除了 .NET 添加的平台之外没有其他抽象。

Xamarin.Forms 是建立在传统 Xamarin API 之上的 API,允许我们在 XAML 或代码中定义平台无关的 GUI,然后渲染为特定于平台的控件。Xamarin.Forms 还有更多功能,但这是它的核心功能。

在本章的最后部分,我们讨论了如何在 Windows 或 macOS 上设置开发机器。

现在是时候将我们新获得的知识付诸实践了!我们将从头开始创建一个待办事项应用程序,这将是下一章的内容。我们将研究诸如 Model-View-ViewModel(MVVM)等概念,以实现业务逻辑和用户界面的清晰分离,以及 SQLite.NET,以将数据持久保存到设备上的本地数据库。我们将同时为三个平台进行开发,敬请期待!

第二章:构建我们的第一个 Xamarin.Forms 应用程序

在本章中,我们将创建一个待办事项列表应用程序,并在此过程中探讨构建应用程序的各个方面。我们将研究创建页面,向这些页面添加内容,导航之间切换,并创建一个令人惊叹的布局。嗯,令人惊叹可能有点牵强,但我们一定会设计应用程序,以便在完成后您可以根据自己的需求进行调整!

本章将涵盖以下主题:

  • 设置项目

  • 在设备上本地持久化数据

  • 使用存储库模式

  • MVVM 是什么以及为什么它非常适合 Xamarin.Forms

  • 使用 Xamarin.Forms 页面(作为视图)并在它们之间导航

  • 在 XAML 中使用 Xamarin.Forms 控件

  • 使用数据绑定

  • 在 Xamarin.Forms 中使用样式

技术要求

为了能够完成这个项目,我们需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参阅 Xamarin 简介。

项目概述

每个人都需要一种跟踪事物的方式。为了启动我们的 Xamarin.Forms 开发学习曲线,我们决定一个待办事项列表应用程序是最好的开始方式,也可以帮助您跟踪事物。一个简单的,经典的,双赢的场景。

我们将首先创建项目,并定义一个存储库,用于存储待办事项列表的项目。我们将以列表形式呈现这些项目,并允许用户使用详细的用户界面对其进行编辑。我们还将看看如何通过SQLite-net在设备上本地存储待办事项,以便在退出应用程序时不会丢失。

此项目的构建时间约为两个小时。

开始项目

是时候开始编码了!然而,在继续之前,请确保您已按照 Xamarin 简介中描述的设置好开发环境。

本章将是一个经典的文件|新建项目章节,将逐步指导您创建您的第一个待办事项列表应用程序的过程。完全不需要下载。

设置项目

Xamarin 应用程序基本上可以使用两种代码共享策略之一来创建:

  • 作为共享项目

  • 作为.NET 标准库

第一个选择,共享项目,将创建一个项目类型,实质上是其中每个文件的链接副本。文件存在于一个共同的位置,并在构建时链接。这意味着我们在编写代码时无法确定运行时,并且只能访问每个目标平台上可用的 API。它确实允许我们使用条件编译,在某些情况下可能有用,但对于以后阅读代码的人来说可能也会令人困惑。选择共享项目选项也可能是一个不好的选择,因为它将我们的代码锁定到特定的平台。

我们将使用第二个选择,.NET 标准库。当然,这是一个选择的问题,两种方式仍然有效。稍加想象力,即使选择了共享项目,您仍然可以遵循本章的内容。

让我们开始吧!

创建新项目

第一步是创建一个新的 Xamarin.Forms 项目。打开 Visual Studio 2017,然后单击文件|新建|项目:

这将打开新项目对话框。展开 Visual C#节点,然后单击跨平台。在列表中选择移动应用程序(Xamarin.Forms)项目。通过命名项目并单击确定来完成表单。确保命名项目为DoToo以避免命名空间问题:

下一步是选择一个项目模板和代码共享策略。选择空白应用程序以创建一个裸的 Xamarin.Forms 应用程序,并将代码共享策略更改为.NET 标准。点击确定完成设置,并等待 Visual Studio 创建必要的项目:

恭喜,我们刚刚创建了我们的第一个 Xamarin.Forms 应用程序!

检查文件

所选模板现在已创建了四个项目:

  • DoToo:这是一个.NET 标准库,目标是.NET 标准 2.0。它可以被支持这个版本的.NET 标准的任何运行时导入。

  • DoToo.Android:这是一个用于在 Android 上引导 Xamarin.Forms 的 Android 应用程序。

  • DoToo.iOS:这是一个用于在 iOS 上引导 Xamarin.Forms 的 iOS 应用程序。

  • DoToo.UWP:这是一个用于在 UWP 上引导 Xamarin.Forms 的Universal Windows PlatformUWP)应用程序。

这三个特定平台的库引用了.NET 标准库。我们的大部分代码将在.NET 标准库中编写,只有一小部分特定平台的代码将被添加到每个目标平台。

项目现在应该如下所示:

我们将重点介绍每个项目中的一些重要文件,以便我们对它们有一个基本的了解。我们将逐个项目进行介绍。

DoToo

这是.NET 标准库,所有特定平台的项目都引用它,大部分我们的代码将被添加到这里。以下截图显示了.NET 标准库的结构:

在依赖项下,我们将找到对外部依赖项(如 Xamarin.Forms)的引用。我们将在更新 Xamarin.Forms 包部分中更新 Xamarin.Forms 包的版本。随着我们在本章中的进展,我们将添加更多依赖项。

App.xaml文件是一个代表应用程序的 XAML 文件。这是放置应用程序范围资源的好地方,我们稍后会这样做。我们还可以看到App.xaml.cs文件,其中包含启动代码和一些生命周期事件,我们可以在其中添加自定义代码,例如OnStartOnSleep

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

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

    protected override void OnStart()
    {
        // Handle when your app starts
    }

    // code omitted for brevity
}

将页面分配给MainPage属性特别重要,因为这决定了用户首先将显示哪个页面。在模板中,这是DoToo.MainPage()类。

最后两个文件是MainPage.xaml文件,其中包含应用程序的第一个页面,以及称为MainPage.xaml.cs的代码后台文件。为了符合Model-View-ViewModelMVVM)命名标准,这些文件将被删除。

DoToo.Android

这是 Android 应用程序。它只有一个文件:

这里的重要文件是MainActivity.cs。如果我们在 Android 设备上运行应用程序,这个文件包含应用程序的入口点方法。Android 应用程序的入口点方法是OnCreate(...)

如果您打开MainActivity.cs并检查OnCreate(...)方法,它应该看起来像这样:

protected override void OnCreate(Bundle bundle)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;
    base.OnCreate(bundle);
    global::Xamarin.Forms.Forms.Init(this, bundle);
    LoadApplication(new App());
}

前两行为TabbarToolbar分配资源。然后我们调用基本方法,接着是 Xamarin.Forms 的强制初始化。最后,我们调用加载我们在.NET 标准库中定义的 Xamarin.Forms 应用程序。

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

DoToo.iOS

这是 iOS 应用程序。它包含的文件比其 Android 对应文件多一些:

AppDelegate.cs文件是 iOS 应用程序的入口点。这个文件包含一个叫做FinishedLaunching(...)的方法,这是我们开始编写代码的地方:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
    global::Xamarin.Forms.Forms.Init();
    LoadApplication(new App());
    return base.FinishedLaunching(app, options);
}

代码从初始化 Xamarin.Forms 开始,然后从.NET 标准库加载应用程序。之后,它将控制返回到 iOS。必须在 17 秒内完成此操作,否则应用程序将被操作系统终止。

info.plist文件是一个 iOS 特定的文件,包含有关应用程序的信息,例如捆绑 ID 及其配置文件。它有一个图形编辑器,但也可以在任何文本编辑器中编辑,因为它是一个标准的 XML 文件。

Entitlements.plist文件也是一个 iOS 特定的文件,用于配置我们希望应用程序利用的权限,例如应用内购买推送通知

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

DoToo.UWP

要检查的最后一个项目是 UWP 应用程序。项目的文件结构如下截图所示:

它有一个App.xaml文件,类似于.NET 标准库中的文件,但特定于 UWP 应用程序。它还有一个名为App.xaml.cs的相关文件。其中包含一个名为OnLaunched(...)的方法,是 UWP 应用程序的入口点。这个文件非常大,所以我们不会在这里打印出来,但是打开它,看看我们是否可以在其中找到 Xamarin.Forms 初始化代码。

更新 Xamarin.Forms 软件包

创建项目后,我们应该始终将 Xamarin.Forms 软件包更新到最新版本。要执行此操作,请按照以下步骤进行:

  1. 在解决方案资源管理器中右键单击我们的解决方案。

  2. 单击“管理解决方案的 NuGet 软件包...”:

  1. 这将在 Visual Studio 中打开 NuGet 软件包管理器:

要将 Xamarin.Forms 更新到最新版本,请执行以下操作:

  1. 单击“更新”选项卡

  2. 检查 Xamarin.Forms 并单击更新

  3. 接受任何许可协议

密切关注输出窗格,并等待所有软件包更新。但是,请确保不要手动更新任何 Android 软件包,因为这可能会破坏您的应用程序。

删除 MainPage 文件

在 Xamarin.Forms 中,我们有页面的概念。然而,对于 MVVM 架构模式来说并非如此,它使用视图的概念。视图与页面是相同的,但它们没有后缀-Page,因此我们将删除模板生成的MainPage。我们将很快详细介绍 MVVM,但目前,我们将从解决方案中删除MainPage.cs类。可以按照以下步骤完成:

  1. DoToo项目(.NET 标准库)中右键单击MainPage.xaml文件

  2. 单击删除并确认删除操作

创建存储库和 TodoItem 模型

任何良好的架构都涉及抽象。在这个应用程序中,我们需要存储和检索待办事项列表中的项目。这些将稍后存储在 SQLite 数据库中,但是直接从负责 GUI 的代码中添加对数据库的引用通常是一个坏主意。

相反,我们需要的是将数据库从 GUI 中抽象出来。对于这个应用程序,我们选择使用简单的存储库模式。这个存储库只是一个简单的类,位于 SQLite 数据库和我们即将到来的ViewModels之间。这是处理与视图的交互的类,而视图又处理 GUI。

存储库将公开用于获取项目、添加项目和更新项目的方法,以及允许应用程序其他部分对存储库中更改做出反应的事件。它将隐藏在接口后面,以便我们稍后可以替换整个实现,而不必修改应用程序初始化中的代码行以外的任何内容。这是由Autofac实现的。

定义待办事项列表项目

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

  1. 在.NET Standard 库项目中,创建一个名为Models的文件夹。

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

public class TodoItem
{
    public int Id { get; set; }
    public string Title { get; set; }
    public bool Completed { get; set; }
    public DateTime Due { get; set; }
}

代码非常简单易懂;这是一个简单的Plain Old CLR ObjectPOCO)类,只包含属性而没有逻辑。我们有一个Title描述我们想要完成的任务,一个标志(Completed)确定待办事项是否已完成,一个Due日期我们期望完成它,以及一个我们以后需要用到的唯一id

创建存储库及其接口

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

  1. 在.NET Standard 库项目中,创建一个名为Repositories的文件夹。

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

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DoToo.Models; 

namespace DoToo.Repositories
{
    public interface ITodoItemRepository
    { 
        event EventHandler<TodoItem> OnItemAdded;
        event EventHandler<TodoItem> OnItemUpdated;

        Task<List<TodoItem>> GetItems();
        Task AddItem(TodoItem item);
        Task UpdateItem(TodoItem item);
        Task AddOrUpdate(TodoItem item);
    }
}

敏锐的读者可能会注意到,我们在这个接口中没有定义Delete方法。这绝对是真实世界应用程序中应该有的内容。虽然我们在本章中创建的应用程序不支持删除项目,但我们相当确定,如果您愿意,您可以自行添加这个功能!

这个接口定义了我们应用程序所需的一切。它用于在存储库的实现和存储库的用户之间创建逻辑隔离。如果应用程序的其他部分需要TodoItemRepository的实例,我们可以传递任何实现ITodoItemRepository的对象,而不管它是如何实现的。

说到这一点,让我们实现ITodoItemRepository

  1. 创建一个名为TodoItemRepository.cs的类。

  2. 输入以下代码:

using DoToo.Models;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;

namespace DoToo.Repositories
{
    public class TodoItemRepository : ITodoItemRepository
    {
        public event EventHandler<TodoItem> OnItemAdded;
        public event EventHandler<TodoItem> OnItemUpdated;

        public async Task<List<TodoItem>> GetItems()
        {
        }

        public async Task AddItem(TodoItem item)
        {
        }

        public async Task UpdateItem(TodoItem item)
        {
        }

        public async Task AddOrUpdate(TodoItem item)
        {
            if (item.Id == 0)
            {
                await AddItem(item);
            }
            else
            {
                await UpdateItem(item);
            }
        }
    }
}

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

在上述代码中还定义了两个事件。这将用于通知任何订阅者项目已更新或已添加。

连接 SQLite 以持久化数据

我们现在有一个接口和一个实现该接口的骨架。完成本节的最后一件事是在存储库的实现中连接 SQLite。

添加 SQLite NuGet 包

要在此项目中访问 SQLite,我们需要向.NET Standard 库项目添加一个名为 sqlite-net-pcl 的 NuGet 包。要做到这一点,请右键单击解决方案的 DoToo 项目节点下的依赖项,然后单击管理 NuGet 包:

您可能会注意到 NuGet 包的后缀为-pcl。这是命名约定出错时发生的情况。这个包实际上支持.NET Standard 1.0,尽管名称中说的是Portable Class LibraryPCL),这是.NET Standard 的前身。

这会弹出 NuGet 包管理器:

  1. 点击浏览并在搜索框中输入 sqlite-net-pcl

  2. 选择 Frank A. Krueger 的包,然后单击安装

等待安装完成。然后我们将向TodoItem类和存储库添加一些代码。

更新 TodoItem 类

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

  1. 打开Models/TodoItem

  2. 在文件的开头下面的现有using语句之后添加一个using SQLite语句,如下面的代码所示:

using System;
using SQLite;
  1. 在 ID 属性之前添加PrimaryKeyAutoIncrement属性,如下面的代码所示:
[PrimaryKey, AutoIncrement]
public int Id { get; set; }

PrimaryKey属性指示 SQLiteId属性是表的主键。AutoIncrement属性将确保Id的值对于添加到表中的每个新的TodoItem类都会增加一。

创建与 SQLite 数据库的连接

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

  1. 打开Repositories/TodoItemRepository文件。

  2. 在文件的开头下面的现有using语句之后添加一个using SQLite语句,如下面的代码所示:

using DoToo.Models;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using SQLite
  1. 在类声明的下面添加以下字段:
private SQLiteAsyncConnection connection;

连接需要初始化。一旦初始化,它就可以在存储库的整个生命周期内重复使用。由于该方法是异步的,不能从构造函数中调用它而不引入锁定策略。为了保持简单,我们将简单地从每个由接口定义的方法中调用它:

  1. 将以下代码添加到TodoItemRepository类中。

  2. 在文件的开头添加一个using System.IO语句,以便我们可以使用Path.Combine(...)

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

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

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

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

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

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

实现获取、添加和更新方法

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

  1. TodoItemRepository类中找到GetItems()方法。

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

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

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

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

SQLite 支持使用语言集成查询LINQ)查询数据。在项目完成后,您可以尝试使用它来更好地了解如何在应用程序内部使用数据库。

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

  1. TodoItemRepository类中找到AddItem()方法。

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

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

CreateConnection()的调用确保我们以与GetItems()方法相同的方式建立连接。之后,我们使用连接对象上的InsertAsync(...)方法在数据库中执行实际的插入操作。在项目被插入到表中后,我们调用OnItemAdded事件通知任何订阅者。

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

  1. TodoItemRepository类中找到UpdateItem()方法。

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

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

在下一节中,我们将开始使用 MVVM。来杯咖啡,让我们开始吧。

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

MVVM 的关键在于关注点的分离。每个部分都有特定的含义:

  • 模型:这与表示数据并可以由ViewModel引用的任何东西有关

  • 视图:这是可视化组件。在 Xamarin.Forms 中,这由一个页面表示

  • ViewModel:这是在模型和视图之间充当中介的类

在我们的应用程序中,我们可以说模型是存储库和它返回的待办事项列表项。ViewModel引用这个存储库并公开属性,供视图绑定。基本规则是任何逻辑都应该驻留在 ViewModel 中,视图中不应该有任何逻辑。视图应该知道如何呈现数据,比如将布尔值转换为“是”或“否”。

MVVM 可以以许多方式实现,有很多框架可以使用。在本章中,我们选择保持简单,以纯净的方式实现 MVVM,而不使用任何框架。

定义一个 ViewModel 基类

ViewModel是视图和模型之间的中介。通过为所有ViewModels创建一个通用的基类,我们可以获得很大的好处。要做到这一点,请按照以下步骤操作:

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

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

  3. 解决对System.ComponentModel和 Xamarin.Forms 的引用,并添加以下代码:

public abstract class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    public void RaisePropertyChanged(params string[] propertyNames)
    {
        foreach (var propertyName in propertyNames)
        {
            PropertyChanged?.Invoke(this, new 
            PropertyChangedEventArgs(propertyName));
        }
    }

    public INavigation Navigation { get; set; }
} 

ViewModel类是所有ViewModels的基类。这不是要单独实例化的,所以我们将其标记为抽象。它实现了INotifyPropertyChanged,这是在.NET 基类库中的System.ComponentModel中定义的一个接口。这个接口只定义了一件事:PropertyChanged事件。我们的ViewModel必须在我们希望 GUI 意识到属性的任何更改时引发此事件。这可以通过手动添加代码到属性的 setter 中来完成,也可以使用中间语言IL)编织器,比如PropertyChanged.Fody。我们将在下一节详细讨论这个问题。

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

介绍 PropertyChanged.Fody

实现ViewModel的传统方式是从基类(比如我们之前定义的ViewModel)继承,然后添加以下代码:

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

我们想要添加到ViewModel的每个属性都会产生六行代码。你可能会认为这并不太糟糕。然而,考虑到一个ViewModel可能潜在地包含 10 到 20 个属性,这很快就会变成大量的代码。我们可以做得更好。

只需几个简单的步骤,我们就可以使用一个名为PropertyChanged.Fody的工具,在构建过程中自动注入几乎所有的代码:

  1. 在.NET Standard 库中,安装PropertyChanged.Fody NuGet 包。

  2. 创建一个名为FodyWeavers.xml的文件,并添加以下 XML 代码:

<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
    <PropertyChanged />
</Weavers>

PropertyChanged.Fody将扫描程序集,查找实现INotifyPropertyChanged接口的任何类,并添加所需的代码来引发PropertyChanged事件。它还会处理属性之间的依赖关系,这意味着如果您有一个属性根据其他两个属性返回值,那么如果这两个值中的任何一个发生变化,它都会被引发。

结果是我们之前的测试类每个属性的代码都被简化为一行。这使得代码更易读,因为一切都是在幕后发生的:

public class MyTestViewModel : ViewModel
{
    public string Name { get; set; }
}

值得注意的是,有许多不同的插件可以用来使 Fody 自动化任务,例如日志记录或方法装饰。查看github.com/Fody/Fody获取更多信息。

创建 MainViewModel

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

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

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

public class MainViewModel : ViewModel
{
    private readonly TodoItemRepository repository;

    public MainViewModel(TodoItemRepository repository)
    {
        this.repository = repository;
        Task.Run(async () => await LoadData());
    }

    private async Task LoadData()
    {
    }
}

这个类中的结构是我们将来会重用的所有ViewModels

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

  • 我们从ViewModel继承以获得共享逻辑,例如INotifyPropertyChanged接口和常见导航代码。

  • 所有对其他类的依赖项,例如存储库和服务,都通过ViewModel的构造函数传递。这将由依赖注入模式处理,更具体地说,由我们使用的依赖注入实现 Autofac 处理。

  • 我们使用异步调用LoadData()作为初始化ViewModel的入口点。不同的 MVVM 库可能以不同的方式执行此操作,但基本功能是相同的。

创建 TodoItemViewModel

TodoItemViewModel是在MainView上表示待办事项列表中每个项目的ViewModel。它不会有自己的整个视图(尽管可能会有),而是将由ListView中的模板呈现。当我们为MainView创建控件时,我们将回到这一点。

这里重要的是,这个ViewModel将代表一个单个项目,无论我们选择在哪里呈现它。

让我们创建TodoItemViewModel

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

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

public class TodoItemViewModel : ViewModel
{
    public TodoItemViewModel(TodoItem item) => Item = item;

    public event EventHandler ItemStatusChanged;
    public TodoItem Item { get; private set; }
    public string StatusText => Item.Completed ? "Reactivate" : 
    "Completed";
}

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

ItemStatusChanged事件处理程序将在以后用于向视图发出信号,表明TodoItem的状态已更改。Item属性允许我们访问传入的项目。

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

创建 ItemViewModel

ItemViewModel表示待办事项列表中的项目,可用于创建新项目和编辑现有项目的视图:

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

  2. 按照以下代码添加代码:

using DoToo.Models;
using DoToo.Repositories;
using System;
using System.Windows.Input;
using Xamarin.Forms;

namespace DoToo.ViewModels
{
    public class ItemViewModel : ViewModel
    {
        private TodoItemRepository repository;

        public ItemViewModel(TodoItemRepository repository)
        {
            this.repository = repository;
        } 
    }
}

模式与前两个ViewModels相同:

  • 我们使用依赖注入将TodoItemRepository传递给ViewModel

  • 我们使用从ViewModel基类继承来添加基类定义的公共功能

创建 MainView

现在我们已经完成了ViewModels,让我们创建视图所需的骨架代码和 XAML。我们要创建的第一个视图是MainView,这是将首先加载的视图:

  1. 在.NET Standard 库中创建一个名为Views的文件夹。

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

  3. 在左侧的 Visual C# Items 节点下选择 Xamarin.Forms。

  4. 选择 Content Page 并将其命名为MainView

  5. 单击添加以创建页面:

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

  1. 打开MainView.xaml

  2. 删除ContentPage根节点下面的所有模板代码,并在以下代码中添加标记为粗体的 XAML 代码:

<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
             xmlns:local="clr-namespace:DoToo"
             x:Class="DoToo.Views.MainView" 
             Title="Do Too!">

 <ContentPage.ToolbarItems>
 <ToolbarItem Text="Add" />
 </ContentPage.ToolbarItems>

 <Grid>
 <Grid.RowDefinitions>
 <RowDefinition Height="auto" />
 <RowDefinition Height="*" />
 </Grid.RowDefinitions>

 <Button Text="Toggle filter" />

 <ListView Grid.Row="1">
 </ListView>
 </Grid>
</ContentPage> 

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

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

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

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

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

如果您对Grid的工作原理感兴趣,您应该在互联网上搜索有关 Xamarin.Forms Grid的更多信息,或者学习官方文档docs.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/layouts/grid

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

  1. 通过展开解决方案资源管理器中的MainView.xaml文件,打开MainView的代码后文件。

  2. 在以下文件的顶部添加using DoToo.ViewModels语句,以及现有的using语句。

  3. 通过添加下面代码中标记为粗体的代码,修改类的构造函数如下:

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

我们通过与ViewModels相同的模式,通过构造函数传递任何依赖项来实现。视图始终依赖于ViewModel。为了简化项目,我们还将页面的Navigation属性直接分配给ViewModel基类中定义的Navigation属性。在较大的项目中,我们可能还希望将此属性抽象化,以确保我们将ViewModels与 Xamarin.Forms 完全分离。但是,对于这个应用程序来说,直接引用它是可以的。

最后,我们将ViewModel分配给页面的BindingContext。这告诉 Xamarin.Forms 绑定引擎使用我们的ViewModel来创建后续的绑定。

创建 ItemView

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

  1. 创建一个新的 Content Page(与我们创建MainView的方式相同),并将其命名为ItemView

  2. 编辑 XAML,并使其看起来像以下代码:

 <?xml version="1.0" encoding="UTF-8"?>
 <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
              xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
              x:Class="DoToo.Views.ItemView"
              Title="New todo item">

 <ContentPage.ToolbarItems>
 <ToolbarItem Text="Save" />
 </ContentPage.ToolbarItems>

 <StackLayout Padding="14">
 <Label Text="Title" />
 <Entry />
 <Label Text="Due" />
 <DatePicker />
 <StackLayout Orientation="Horizontal">
 <Switch />
 <Label Text="Completed" />
 </StackLayout>
 </StackLayout>
 </ContentPage> 

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

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

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

  2. 修改类的构造函数,使其看起来像以下代码。添加粗体标记的代码。

  3. 在现有的using语句下面的文件顶部添加DoToo.ViewModels语句:

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

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

通过 Autofac 进行依赖注入的连接

早些时候,我们讨论了依赖注入模式,该模式规定所有依赖项(例如存储库和视图模型)必须通过类的构造函数传递。这有几个好处:

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

  • 它使依赖注入成为可能

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

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

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

为了设置这一点,我们需要引用一个名为 Autofac 的库。还有其他选择,所以请随意切换到更适合您需求的选项。我们还需要一个入口点来将类型解析为实例。为此,我们将定义一个基本的Resolver类。为了将所有内容包装起来,我们需要一个引导程序,我们将调用它来初始化依赖注入配置。

向 Autofac 添加引用

我们需要引用 Autofac 才能开始。我们将使用 NuGet 来安装所需的软件包:

  1. 通过右键单击解决方案节点并单击“管理解决方案的 NuGet 软件包”来打开 NuGet 管理器。

  2. 单击浏览,然后在搜索框中键入autofac

  3. 在项目下的所有复选框中打勾,然后向下滚动,单击安装:

创建解析器

解析器将负责根据我们请求的类型为我们创建对象。让我们创建解析器:

  1. 在.NET Standard 库项目的根目录中,创建一个名为Resolver.cs的新文件。

  2. 将以下代码添加到文件中:

using Autofac;

namespace DoToo
{
    public static class Resolver
    {
        private static IContainer container;

        public static void Initialize(IContainer container)
        {
            Resolver.container = container;
        }

        public static T Resolve<T>()
        {
            return container.Resolve<T>();
        }
    }
}

IContainer类型的container属性在Autofac中定义,并表示一个保存如何解析类型的配置的容器。Initialize方法接受实现IContainer接口的对象的实例,并将其分配给container属性。Resolve方法使用container将类型解析为对象的实例。虽然一开始可能会觉得奇怪使用这个,但随着经验的增加,它会变得更容易。

创建 bootstrapper

bootstrapper 的责任是初始化 Autofac。它将在应用程序启动时被调用。我们可以按以下方式创建它:

  1. 在.NET Standard 库的根目录中,创建一个名为Bootstrapper.cs的新文件。

  2. 输入以下代码:

using Autofac;
using System.Linq;
using Xamarin.Forms;
using DoToo.Views;
using DoToo.Repositories;
using DoToo.ViewModels;

namespace DoToo
{
    public abstract class Bootstrapper
    {
        protected ContainerBuilder ContainerBuilder { get; private 
        set; }

        public Bootstrapper()
        {
            Initialize();
            FinishInitialization();
        }

        protected virtual void Initialize()
        {
            var currentAssembly = Assembly.GetExecutingAssembly();
            ContainerBuilder = new ContainerBuilder();

            foreach (var type in currentAssembly.DefinedTypes
                      .Where(e => 
                             e.IsSubclassOf(typeof(Page)) ||
                             e.IsSubclassOf(typeof(ViewModel)))) 
            {
                ContainerBuilder.RegisterType(type.AsType());
            }

            ContainerBuilder.RegisterType<TodoItemRepository>().SingleInstance();
        }

        private void FinishInitialization()
        {
            var container = ContainerBuilder.Build();
            Resolver.Initialize(container);
        }
    }
}

Bootstrapper将被每个平台继承,因为这是应用程序执行的起点。这也给了我们添加特定于平台的配置的选项。为了确保我们从该类继承,我们将其定义为抽象的。

ContainerBuilder是在Autofac中定义的一个类,它在我们完成配置后负责为我们创建containercontainer的构建发生在最后定义的FinishInitialization方法中,并且在构造函数调用虚拟的Initialize方法后立即调用。我们可以重写Initialize方法在每个平台上添加自定义注册。

Initialize方法扫描程序集中从PageViewModel继承的任何类型,并将它们添加到container中。它还将TodoItemRepository作为单例添加到container中。这意味着每次我们请求TodoItemRepository时,我们将获得相同的实例。Autofac 的默认行为(这可能在不同的库之间有所不同)是每次解析时获得一个新实例。

在 iOS 上添加 bootstrapper

iOS 的Bootstrapper是.NET Standard 库中通用 bootstrapper 的简单包装器,但增加了一个Init方法,在启动时将被调用:

  1. 在 iOS 项目的根目录中,创建一个名为Bootstrapper.cs的新类。

  2. 向其中添加以下代码:

public class Bootstrapper : DoToo.Bootstrapper 
{
    public static void Init()
    {
        var instance = new Bootstrapper();
    }
} 

Init方法可能看起来很奇怪,因为我们没有保留对我们创建的实例的引用。但请记住,我们确实在Resolver类内部保留对Resolver实例的引用,而Resolver本身是一个单例。

iOS 的最后一步是在正确的位置调用Init方法:

  1. 打开AppDelegate.cs

  2. 找到FinishedLaunching方法并添加粗体代码:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
    global::Xamarin.Forms.Forms.Init();
    Bootstrapper.Init();
    LoadApplication(new App());

    return base.FinishedLaunching(app, options);
}

在 Android 中添加 bootstrapper

与 iOS 一样,Android 的Bootstrapper是.NET Standard 库中通用 bootstrapper 的简单包装器,但增加了一个在启动时将被调用的Init方法:

  1. 在 Android 项目的根目录中,创建一个名为Bootstrapper.cs的新类。

  2. 向其中添加以下代码:

public class Bootstrapper : DoToo.Bootstrapper
{
    public static void Init()
    {
        var instance = new Bootstrapper();
    }
}

然后我们需要调用这个Init方法。在OnCreate中调用LoadApplication之前做这件事是一个好地方:

  1. 打开MainActivity.cs

  2. 找到OnCreate方法并添加粗体代码:

protected override void OnCreate(Bundle bundle)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(bundle);

    global::Xamarin.Forms.Forms.Init(this, bundle);
    Bootstrapper.Init();
    LoadApplication(new App());
}

在 UWP 中添加 bootstrapper

UWP 的 bootstrapper 与其他平台相同:

  1. 在 UWP 项目的根目录中,创建一个名为Bootstrapper.cs的新类。

  2. 向其中添加以下代码:

public class Bootstrapper : DoToo.Bootstrapper
{
    public static void Init()
    {
        var instance = new Bootstrapper();
    }
}

与其他平台一样,我们需要在适当的位置调用Init方法:

  1. 在 UWP 项目中,打开App.xaml.cs文件。

  2. 找到对Xamarin.Forms.Forms.Init()方法的调用,并添加粗体代码:

Xamarin.Forms.Forms.Init(e);
Bootstrapper.Init();

使应用程序运行

我们可以按以下方式首次启动应用程序:

  1. 通过展开.NET Standard 库中的App.xaml节点,打开App.xaml.cs

  2. 找到构造函数。

  3. 添加using语句以使用DoToo.Views,并添加以下粗体代码行:

public App ()
{
    InitializeComponent();
    MainPage = new NavigationPage(Resolver.Resolve<MainView>());
}

添加的行解决了MainView(以及所有依赖项,包括MainViewModelTodoItemRepository)并将其包装成NavigationPageNavigationPage是 Xamarin.Forms 中定义的一个页面,它添加了导航栏并允许用户导航到其他视图。

就是这样!此时,您的项目应该启动。根据您使用的平台不同,它可能看起来像下面的截图:

添加数据绑定

数据绑定是 MVVM 的核心。这是ViewsViewModel相互通信的方式。在 Xamarin.Forms 中,我们需要两样东西来实现数据绑定:

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

  2. 我们需要将页面的BindingContext设置为该对象。我们已经在ItemViewMainView上都这样做了。

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

<Entry Text="{Binding Title} />

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

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

MainView导航到ItemView以添加新项目

MainView中有一个Addtoolbar按钮。当用户点击此按钮时,我们希望导航到ItemView。这样做的 MVVM 方式是定义一个命令,然后将该命令绑定到按钮。让我们添加代码:

  1. 打开ViewModels/MainViewModel.cs

  2. System.Windows.InputDoToo.ViewsXamarin.Forms添加using语句。

  3. 将以下属性添加到类中:

public ICommand AddItem => new Command(async () =>
{
    var itemView = Resolver.Resolve<ItemView>();
    await Navigation.PushAsync(itemView);
}); 

所有命令都应公开为通用的ICommand。这样可以抽象出实际的命令实现,这是一个很好的一般实践。命令必须是一个属性;在我们的情况下,我们正在创建一个新的Command对象,然后将其分配给这个属性。该属性是只读的,对于Command来说通常是可以的。命令的操作(当执行命令时要运行的代码)被传递给Command对象的构造函数。

命令的操作通过Resolver创建一个新的ItemView,并且 Autofac 构建必要的依赖项。一旦创建了新的ItemView,我们只需告诉Navigation服务为我们将其推送到堆栈上。

之后,我们只需将ViewModel中的AddItem命令与视图中的添加按钮连接起来:

  1. 打开Views/MainView.xaml

  2. ToolbarItem添加Command属性:

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

运行应用程序并点击“添加”按钮以导航到新项目视图。请注意,返回按钮会自动出现。

向列表中添加新项目

现在我们已经完成了导航到新项目的添加。现在让我们添加所需的代码来创建一个新项目并将其保存到数据库中:

  1. 打开ViewModels/ItemViewModel.cs

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

  3. 解决对System.Windows.Input的引用:

public class ItemViewModel : ViewModel
{
    private TodoItemRepository repository;

    public TodoItem Item { get; set; }

    public ItemViewModel(TodoItemRepository repository)
    {
        this.repository = repository;
        Item = new TodoItem() { Due = DateTime.Now.AddDays(1) };
    }

 public ICommand Save => new Command(async () => 
 {
 await repository.AddOrUpdate(Item);
 await Navigation.PopAsync();
 });
}

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

由于导航将页面保留在堆栈中,框架声明了反映可以在堆栈上执行的操作的方法。从堆栈中移除顶部项目的操作称为弹出堆栈,因此我们有PopAsync()而不是RemoveAsync()。要将页面添加到导航堆栈中,我们将其推送,因此该方法称为PushAsync()

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

  1. 打开ViewModels/ItemView.xaml

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

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage  

             x:Class="DoToo.Views.ItemView">
    <ContentPage.ToolbarItems>
        <ToolbarItem Text="Save" Command="{Binding Save}" />
    </ContentPage.ToolbarItems>

    <StackLayout Padding="14">
        <Label Text="Title" />
        <Entry Text="{Binding Item.Title}" />
        <Label Text="Due" />
        <DatePicker Date="{Binding Item.Due}" />
        <StackLayout Orientation="Horizontal">
            <Switch IsToggled="{Binding Item.Completed}" />
            <Label Text="Completed" />
        </StackLayout>
    </StackLayout>

</ContentPage> 

ToolbarItems命令属性的绑定会在用户点击Save链接时触发ItemViewModel公开的Save命令。值得再次注意的是,任何名为Command的属性都表示将发生某种操作,我们必须将其绑定到实现ICommand接口的对象的实例。

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

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

在 MainView 中绑定 ListView

没有项目列表的待办事项列表没有多大用处。让我们用项目列表扩展MainViewModel

  1. 打开ViewModels/MainViewModel.cs

  2. 添加System.Collections.ObjectModelSystem.Linqusing语句。

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

public ObservableCollection<TodoItemViewModel> Items { get; set; }

ObservableCollection就像普通集合,但它有一个有用的超能力。它可以通知监听器列表中的更改,例如添加或删除itemsListview将侦听列表中的更改,并根据这些更改自动更新自身。

现在我们需要一些数据:

  1. 打开ViewModels/MainViewModel.cs

  2. 替换(或完成)LoadData方法,并创建CreateTodoItemViewModelItemStatusChanged方法。

  3. 通过添加using语句解析对DoToo.Models的引用:

private async Task LoadData()
{
    var items = await repository.GetItems();
    var itemViewModels = items.Select(i =>  
    CreateTodoItemViewModel(i));
    Items = new ObservableCollection<TodoItemViewModel>  
    (itemViewModels); 
}

private TodoItemViewModel CreateTodoItemViewModel(TodoItem item)
{
    var itemViewModel = new TodoItemViewModel(item);
    itemViewModel.ItemStatusChanged += ItemStatusChanged;
    return itemViewModel;
}

private void ItemStatusChanged(object sender, EventArgs e)
{
}

LoadData方法调用存储库以获取所有项目。然后我们将每个待办事项包装在TodoItemViewModel中。这将包含特定于视图的更多信息,我们不希望将其添加到TodoItem类中。将普通对象包装在ViewModel中是一个很好的做法;这样可以更简单地向其添加操作或额外的属性。ItemStatusChanged是一个存根,当我们将待办事项的状态从活动更改为已完成或反之时将调用它。

我们还需要连接一些来自存储库的事件,以了解数据何时发生变化:

  1. 打开ViewModels/MainViewModel.cs

  2. 添加以下粗体代码:

public MainViewModel(TodoItemRepository repository)
{
   repository.OnItemAdded += (sender, item) => 
 Items.Add(CreateTodoItemViewModel(item));
 repository.OnItemUpdated += (sender, item) => 
 Task.Run(async () => await LoadData());

    this.repository = repository;

    Task.Run(async () => await LoadData());
}   

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

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

  1. 打开MainView.xaml并找到ListView元素。

  2. 修改以反映以下代码:

<ListView Grid.Row="1"
 RowHeight="70"
          ItemsSource="{Binding Items}">
    <ListView.ItemTemplate>    
        <DataTemplate>
            <ViewCell>
                <Grid Padding="15,10">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="10" />
                        <ColumnDefinition Width="*" />
                    </Grid.ColumnDefinitions>

                    <BoxView Grid.RowSpan="2" />
                    <Label Grid.Column="1"
                           Text="{Binding Item.Title}"
                           FontSize="Large" />
                    <Label Grid.Column="1"
                           Grid.Row="1"
                           Text="{Binding Item.Due}"
                           FontSize="Micro" />
                    <Label Grid.Column="1" 
 Grid.Row="1" 
 HorizontalTextAlignment="End" 
 Text="Completed" 
                           IsVisible="{Binding Item.Completed}"
                           FontSize="Micro" />
                </Grid>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

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

DataTemplate定义了每一行的外观。我们使用网格来分割空间,就像我们之前做的那样。

为项目状态创建一个 ValueConverter

有时,我们希望绑定到原始值的表示对象。这可能是基于布尔值的文本片段。例如,我们可能希望写YesNo,或者返回一个颜色,而不是truefalse。这就是ValueConverter派上用场的地方。它可以用于将一个值转换为另一个值。我们将编写一个ValueConverter,将待办事项的状态转换为颜色:

  1. 在.NET Standard 库项目的根目录下,创建一个名为Converters的文件夹。

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

using System;
using System.Globalization;
using Xamarin.Forms;

namespace DoToo.Converters
{
    public class StatusColorConverter : IValueConverter
    {
        public object Convert(object value, Type targetType,
                              object parameter, CultureInfo  
                              culture)
        {
          return (bool)value ?   
          (Color)Application.Current.Resources["CompletedColor"]: 

          (Color)Application.Current.Resources["ActiveColor"];
        }

        public object ConvertBack(object value, Type targetType, 
                                  object parameter, CultureInfo 
                                  culture)
        {
            return null;
        }
    }
}

ValueConverter是实现IValueConverter的类。这只有两个方法被定义。当视图从ViewModel读取数据时,将调用Convert方法,而当ViewModel从视图获取数据时,将使用ConvertBack方法。ConvertBack方法仅用于从纯文本返回数据的控件,例如Entry控件。

如果我们查看Convert方法的实现,我们会注意到传递给该方法的任何值都是对象类型。这是因为我们不知道用户将什么类型绑定到我们正在添加ValueConverter的属性。我们还可能注意到,我们从资源文件中获取颜色。我们本可以在代码中定义颜色,但这是不推荐的,所以我们走了额外的路程,并将它们添加为App.xaml文件中的全局资源。资源是在完成本章后再次查看的好东西:

  1. 在.NET Standard 库项目中打开App.xaml

  2. 添加以下ResourceDictionary

 <Application ...>
     <Application.Resources>
 <ResourceDictionary>
 <Color x:Key="CompletedColor">#1C8859</Color>
 <Color x:Key="ActiveColor">#D3D3D3</Color>
 </ResourceDictionary>
     </Application.Resources>
 </Application> 

ResourceDictionary可以定义各种不同的对象。我们只需要两种颜色,这两种颜色可以从ValueConverter中访问。请注意,这些可以通过给定的键访问,并且还可以使用静态资源绑定从任何其他 XAML 文件中访问。ValueConverter本身将被引用为静态资源,但来自本地范围。

使用 ValueConverter

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

  • 在 XAML 中定义命名空间

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

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

让我们从命名空间开始:

  1. 打开Views/MainView.xaml

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

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:converters="clr-namespace:DoToo.Converters"
             x:Class="DoToo.Views.MainView"
             Title="Do Too!>

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

  1. 打开 Views/MainView.Xaml。

  2. 在 XAML 文件的根元素下添加以下ResourceDictionary,显示为粗体:

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

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

最后一步是添加转换器:

  1. 在 XAML 中找到BoxView节点。

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

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

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

使用命令导航到项目

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

为此,我们需要添加以下代码:

  1. 打开ViewModels/MainViewModel.cs

  2. 在类中添加SelectedItem属性和NavigateToItem方法:

public TodoItemViewModel SelectedItem
{
    get { return null; }
    set 
    {
        Device.BeginInvokeOnMainThread(async () => await 
        NavigateToItem(value));
        RaisePropertyChanged(nameof(SelectedItem));
    }
}

private async Task NavigateToItem(TodoItemViewModel item)
{
    if (item == null)
    {
        return;
    }

    var itemView = Resolver.Resolve<ItemView>();
    var vm = itemView.BindingContext as ItemViewModel;
    vm.Item = item.Item;

    await Navigation.PushAsync(itemView);
}

SelectedItem属性是我们将数据绑定到ListView的属性。当我们在ListView中选择一行时,此属性将设置为表示该行的TodoItemViewModel。由于我们实际上不能在这里使用 Fody 来执行其PropertyChanged魔法,因为需要在 setter 中进行方法调用,所以我们需要老式地手动添加一个 getter 和一个 setter。

然后调用NavigateToItem,它使用Resolver创建一个新的ItemView。我们从新创建的ItemView中提取ViewModel并分配TodoItemViewModel包含的当前TodoItem。困惑吗?请记住,TodoItemViewModel实际上包装了一个TodoItem,我们要传递的就是这个项目到ItemView

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

  1. 打开Views/MainView.xaml

  2. 找到ListView并添加以下属性:

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

SelectedItem属性将ListViewSelectedItem属性绑定到ViewModel属性。当ListView中的项目选择发生变化时,ViewModelsSelectedItem属性将被调用,我们将导航到新的和令人兴奋的视图。

x:Name属性用于命名ListView,因为我们确实需要进行一个小的丑陋的黑客来使其工作。导航完成后,ListView实际上将保持选定状态。当我们导航回来时,除非我们选择另一行,否则无法再次选择它。为了减轻这种情况,我们需要连接到ListViewItemSelected事件,并直接重置ListView上的选定项目。这并不推荐,因为我们实际上不应该在我们的Views中有任何逻辑,但有时我们别无选择:

  1. 打开Views/MainView.xaml.cs

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

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

    ItemsListView.ItemSelected += (s, e) => 
    ItemsListView.SelectedItem = null;
}

现在我们应该能够导航到列表中的项目。

使用命令将项目标记为完成

我们需要添加一个功能,允许我们在完成活动之间切换项目。可以导航到待办事项列表项的详细视图,但这对用户来说太麻烦了。相反,我们将在ListView中添加一个ContextAction。例如,在 iOS 中,可以通过向左滑动一行来访问它:

  1. 打开ViewModel/TodoItemViewModel.cs

  2. 添加using语句以使用System.Windows.InputXamarin.Forms

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

public ICommand ToggleCompleted => new Command((arg) =>
{
    Item.Completed = !Item.Completed;
    ItemStatusChanged?.Invoke(this, new EventArgs());
});

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

  1. 打开Views/MainView.xaml

  2. 找到ListView.ItemTemplate节点并添加以下ViewCell.ContextActions节点:

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

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

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

MainViewModel中进行更改:

  1. 打开ViewModels/MainViewModel.cs并找到ItemStatusChangeMethod

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

private void ItemStatusChanged(object sender, EventArgs e)
{
 if (sender is TodoItemViewModel item)
 {
 if (!ShowAll && item.Item.Completed)
 {
 Items.Remove(item);
 }

 Task.Run(async () => await 
        repository.UpdateItem(item.Item));
 }
} 

public bool ShowAll { get; set; }

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

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

  1. MainViewModel中找到Load方法。

  2. 添加标记为粗体的代码行:

private async Task LoadData()
{
    var items = await repository.GetItems();

    if (!ShowAll)
    {
 items = items.Where(x => x.Completed == false).ToList();
    }

    var itemViewModels = items.Select(i => 
    CreateTodoItemViewModel(i));
    Items = new ObservableCollection<TodoItemViewModel>  
    (itemViewModels);
}

如果ShowAll为假,则我们将列表的内容限制为尚未完成的项目。我们可以通过两种方法来实现这一点,即GetAllItems()GetActiveItems(),或者使用可以传递给GetItems()的筛选参数。花一分钟时间思考一下我们将如何实现这一点。

让我们添加代码来切换筛选器:

  1. 打开ViewModels/MainViewModel.cs

  2. 添加FilterTextToggleFilter属性:

public string FilterText => ShowAll ? "All" : "Active";

public ICommand ToggleFilter => new Command(async () =>
{
    ShowAll = !ShowAll;
    await LoadData();
});

FilterText属性是一个只读属性,用于以人类可读的形式显示状态的字符串。我们本可以使用ValueConverter来实现这一点,但为了节省时间,我们简单地将其公开为一个属性。ToggleFilter命令的逻辑是状态的简单反转,然后调用LoadData。这反过来会导致列表的重新加载。

在我们可以筛选项目之前,我们需要连接筛选按钮:

  1. 打开Views/MainView.xaml

  2. 找到控制筛选的Button(文件中唯一的按钮)。

  3. 调整代码以反映以下代码:

<Button Text="{Binding FilterText, StringFormat='Filter: {0}'}"
        Command="{Binding ToggleFilter}" />

就这个功能而言,应用现在已经完成了!但它并不是很吸引人;我们将在接下来的部分处理这个问题。

布置内容

最后一部分是让应用看起来更加漂亮。我们只是浅尝辄止,但这应该能给你一些关于样式工作原理的想法。

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

样式是将样式应用于元素的一种很好的方法。它们可以应用于类型的所有元素,也可以应用于由键引用的元素,如果您添加了x:Key属性:

  1. 打开.NET Standard 项目中的App.xaml

  2. 将以下 XAML 添加到文件中,该部分为粗体:

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

    <Color x:Key="CompletedColor">#1C8859</Color>
    <Color x:Key="ActiveColor">#D3D3D3</Color>        
</ResourceDictionary>

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

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

布置 MainView 和 ListView 项目

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

筛选按钮

筛选按钮允许我们切换列表的状态,只显示活动的待办事项和所有待办事项。让我们对其进行样式设置,使其在布局中更加突出:

  1. 找到筛选按钮。

  2. 进行以下更改:

<Button Style="{StaticResource FilterButton}"
        Text="{Binding FilterText, StringFormat='Filter: {0}'}" 
        BackgroundColor="{Binding ShowAll, Converter={StaticResource 
        statusColorConverter}}"
        TextColor="Black"
        Command="{Binding ToggleFilter}">

<Button.Triggers>
 <DataTrigger TargetType="Button" Binding="{Binding ShowAll}"  
      Value="True">
 <Setter Property="TextColor" Value="White" />
 </DataTrigger>
 </Button.Triggers>
</Button>

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

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

触摸 ListView

ListView可能需要进行一些微小的更改。第一个更改是将到期日期字符串格式化为更加人性化、可读的格式,第二个更改是将已完成标签的颜色更改为漂亮的绿色色调:

  1. 打开Views/MainView.xaml

  2. 找到在ListView中绑定Item.DueItem.Completed的标签:

<Label Grid.Column="1"
       Grid.Row="1" 
       Text="{Binding Item.Due, StringFormat='{0:MMMM d, yyyy}'}" 
       FontSize="Micro" />

<Label Grid.Column="1" 
       Grid.Row="1" 
       HorizontalTextAlignment="End" 
       Text="Completed" 
       IsVisible="{Binding Item.Completed}"
       FontSize="Micro" 
       TextColor="{StaticResource CompletedColor}" /> 

我们在绑定中添加了字符串格式化,以使用特定格式格式化日期。在这种情况下,0:MMMM d, yyyy格式将日期显示为字符串,格式为 2019 年 5 月 5 日。

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

摘要

现在,我们应该对从头开始创建 Xamarin.Forms 应用程序的所有步骤有了很好的掌握。我们已经了解了项目结构和新创建项目中的重要文件。我们谈到了依赖注入,使用 Autofac,并通过创建所需的所有ViewsViewModels来学习了 MVVM 的基础知识。我们还涵盖了在 SQLite 中进行数据存储,以便以快速和安全的方式在设备上持久保存数据。利用本章所学的知识,现在您应该能够创建任何您喜欢的应用程序的骨架。

下一章将重点介绍创建一个更丰富的用户体验,创建一个可以在屏幕上移动的图像匹配应用程序。我们将更仔细地研究 XAML 以及如何创建自定义控件。

第三章:使用动画创建具有丰富 UX 的匹配应用程序

在本章中,我们将为匹配应用程序创建基本功能。但由于隐私问题,我们不会对人们进行评分。相反,我们将从互联网上的随机来源下载图像。这个项目适用于任何想要了解如何编写可重用控件的人。我们还将研究如何使用动画使我们的应用程序更加愉快。这个应用程序不会是一个 MVVM 应用程序,因为我们想要将控件的创建和使用与 MVVM 的轻微开销隔离开来。

本章将涵盖以下主题:

  • 创建自定义控件

  • 如何将应用程序样式设置为带有描述性文本的照片

  • 使用 Xamarin.Forms 进行动画

  • 订阅自定义事件

  • 反复使用自定义控件

  • 处理平移手势

技术要求

要完成此项目,您需要安装 Visual Studio for Mac 或 Windows 以及必要的 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参阅第一章Xamarin 简介

项目概述

我们中的许多人都曾面临过左右滑动的困境。突然间,您可能会想知道:这是如何工作的?滑动魔术是如何发生的?在这个项目中,我们将学习所有这些。我们将首先定义一个MainPage文件,其中我们应用程序的图像将驻留。之后,我们将创建图像控件,并逐渐向其添加 GUI 和功能,直到我们完美地掌握了完美的滑动体验。

此项目的构建时间约为 90 分钟。

创建匹配应用程序

在这个项目中,我们将学习如何创建可添加到 XAML 页面的可重用控件。为了保持简单,我们不会使用 MVVM,而是使用裸露的 Xamarin.Forms,没有任何数据绑定。我们的目标是创建一个允许用户向左或向右滑动图像的应用程序,就像大多数流行的匹配应用程序一样。

好了,让我们开始创建项目吧!

创建项目

就像第二章中的待办事项应用程序构建我们的第一个 Xamarin.Forms 应用一样,本章将从干净的文件|新建项目方法开始。在本章中,我们将选择.NET 标准方法,而不是共享代码方法;如果您不确定为什么要这样做,请参考第二章构建我们的第一个 Xamarin.Forms 应用以更深入地了解它们之间的区别。

让我们开始吧!

创建新项目

打开 Visual Studio 并单击文件|新建|项目:

这将打开新项目对话框。展开 Visual C#节点,然后单击跨平台。从列表中选择移动应用程序(Xamarin.Forms)项目。通过为项目命名来完成表单。在这种情况下,我们将称我们的应用程序为Swiper。单击确定继续下一个对话框:

下一步是选择项目模板和代码共享策略。选择空白以创建最少的 Xamarin.Forms 应用程序,并确保代码共享策略设置为.NET 标准。通过单击确定完成设置向导,让 Visual Studio 为您搭建项目。这可能需要几分钟。

就这样,应用程序就创建好了。让我们继续更新 Xamarin.Forms 到最新版本。

更新 Xamarin.Forms NuGet 包

目前,您的项目将使用的 Xamarin.Forms 版本很可能有点旧。为了纠正这一点,我们需要更新 NuGet 包。请注意,您应该只更新 Xamarin.Forms 包,而不是 Android 包;做后者可能会导致您的包与彼此不同步,导致应用程序根本无法构建。要更新 NuGet 包,请执行以下步骤:

  1. 右键单击解决方案资源管理器中的我们的解决方案。

  2. 点击“管理解决方案的 NuGet 包...”:

这将在 Visual Studio 中打开 NuGet 包管理器。

要将 Xamarin.Forms 更新到最新版本,请执行以下步骤:

  1. 点击“更新”选项卡。

  2. 检查 Xamarin.Forms 并点击“更新”。

  3. 接受任何许可协议。

更新最多需要几分钟。检查输出窗格以找到有关更新的信息。此时,我们可以运行应用程序以确保它正常工作。我们应该在屏幕中央看到“欢迎使用 Xamarin.Forms!”的文字:

设计 MainPage 文件

创建了一个全新的空白 Xamarin.Forms 应用程序,名为Swiper,其中包含一个名为MainPage.xaml的页面。这位于由所有特定于平台的项目引用的.NET 标准项目中。我们需要用一个新的布局替换 XAML 模板,该布局将包含我们的Swiper控件。

让我们通过用我们需要的内容替换默认内容来编辑已经存在的MainPage.xaml文件:

  1. 打开MainPage.xaml文件。

  2. 用以下加粗标记的 XAML 代码替换页面的内容:

<?xml version="1.0" encoding="utf-8"?>
<ContentPage  

             x:Class="Swiper.MainPage">

 <Grid Padding="0,40" x:Name="MainGrid">
 <Grid.RowDefinitions>
 <RowDefinition Height="400" />
 <RowDefinition Height="*" />
 </Grid.RowDefinitions>
<Grid Grid.Row="1" Padding="30"> <!-- Placeholder for later --> </Grid>
 </Grid>
</ContentPage>

ContentPage节点内的 XAML 定义了应用程序中的两个网格。网格只是其他控件的容器。它根据行和列来定位这些控件。外部网格在这种情况下定义了两行,将覆盖整个屏幕的可用区域。第一行高度为 400 个单位,第二行的height="*"使用了剩余的可用空间。

内部网格,它在第一个网格内定义,并且使用属性Grid.Row="1"分配给第二行。行和列索引是从零开始的,所以"1"实际上指的是第二行。我们将在本章后面向这个网格添加一些内容,但现在我们将其保留为空白。

两个网格都定义了它们的填充。您可以输入一个数字,表示所有边都有相同的填充,或者像这种情况一样输入两个数字。我们输入了0,40,这意味着左右两侧应该有零单位的填充,顶部和底部应该有 40 个单位的填充。还有第三个选项,使用四个数字,按照特定顺序设置侧、顶部侧和底部的填充。

最后要注意的一件事是,我们给外部网格一个名称,x:Name="MainGrid"。这将使它可以直接从MainPage.xaml.cs文件中定义的代码后台访问。由于在这个示例中我们没有使用 MVVM,我们需要一种方法来访问网格而不使用数据绑定。

创建 Swiper 控件

这个项目的主要部分涉及创建Swiper控件。控件是一个自包含的 UI,带有相应的代码后台。它可以作为元素添加到任何 XAML 页面中,也可以在代码后台文件中的代码中添加。在这个项目中,我们将从代码中添加控件。

创建控件

创建Swiper控件是一个简单的过程。我们只需要确保选择正确的项模板,即内容视图:

  1. 在.NET 标准库项目中,创建一个名为Controls的文件夹。

  2. 右键单击“控件”文件夹,选择“添加”,然后点击“新建项...”。

  3. 在“添加新项”对话框框的左窗格中选择 Visual C#项目,然后选择 Xamarin.Forms。

  4. 选择内容视图(C#)项目。确保不选择 C#版本;这只会创建一个C#文件,而不是一个XAML文件。

  5. 将控件命名为SwiperControl.xaml

  6. 点击添加:

这将为 UI 添加一个 XAML 文件和一个 C#代码后台文件。它应该看起来像下面的截图:

定义主网格

让我们设置Swiper控件的基本结构:

  1. 打开SwiperControl.xaml文件。

  2. 用粗体标记的代码替换内容:

<?xml version="1.0" encoding="UTF-8"?>
<ContentView  

             x:Class="Swiper.Controls.SwiperControl">
    <ContentView.Content>
 <Grid>
 <Grid.ColumnDefinitions>
 <ColumnDefinition Width="100" />
 <ColumnDefinition Width="*" />
 <ColumnDefinition Width="100" />
 </Grid.ColumnDefinitions> 
 <!-- ContentView for photo here -->

            <!-- StackLayout for like here -->

            <!-- StackLayout for deny here -->
        </Grid> 
    </ContentView.Content>
</ContentView>

这定义了一个具有三列的网格。最左边和最右边的列将占据100个单位的空间,中间将占据其余的可用空间。两侧的空间将是我们将添加标签以突出用户所做选择的区域。我们还添加了三个注释,作为即将到来的 XAML 的占位符。

为照片添加内容视图

现在我们将通过添加定义我们希望照片看起来的 XAML 来扩展SwiperControl.xaml文件。我们的最终结果将看起来像下面的照片。由于我们将从互联网上获取图像,我们将显示一个加载文本,以确保用户了解正在发生什么。为了使其看起来像即时打印的照片,我们在照片下面添加了一些手写文本:

上面的照片是我们希望照片看起来的样子。为了使其成为现实,我们需要向SwiperControl添加一些 XAML:

  1. 打开SwiperControl.xaml

  2. 将粗体的 XAML 添加到以下注释中:<!-- ContentView for photo here -->。确保不要替换页面的整个ContentView;只需在注释下面添加如下。页面的其余部分应保持不变:

<!-- ContentView for photo here -->
<ContentView x:Name="photo" Padding="40" Grid.ColumnSpan="3" >
    <Grid x:Name="photoGrid" BackgroundColor="Black" Padding="1" >
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="40" />
         </Grid.RowDefinitions>

        <BoxView BackgroundColor="White" Grid.RowSpan="2" />

        <Image x:Name="image" Margin="10"
               BackgroundColor="#AAAAAA"
               Aspect="AspectFill" />

        <Label x:Name="loadingLabel"
                Text="Loading..."
                TextColor="White"
                FontSize="Large"
                FontAttributes="Bold"
                HorizontalOptions="Center"
                VerticalOptions="Center" />

        <Label x:Name="descriptionLabel" 
               Margin="10,0" 
               Text="A picture of grandpa" 
               Grid.Row="1"
               FontFamily="Bradley Hand" />
    </Grid>
</ContentView>

ContentView控件定义了一个新的区域,我们可以在其中添加其他控件。ContentView的一个非常重要的特性是它只接受一个子控件。大多数情况下,我们会添加其中一个可用的布局控件。在这种情况下,我们将使用Grid控件来布局控件,如前面的代码所示。

网格定义了两行:

  • 一个用于照片本身的行,在分配了其他行的空间后占据所有可用空间

  • 一个用于评论的行,其高度恰好为40个单位

Grid本身设置为使用黑色背景和1的填充。这与BoxView结合使用,BoxView具有白色背景,创建了我们在控件周围看到的框架。BoxView还设置为跨越网格的两行(Grid.RowSpan="2"),占据网格的整个区域,减去填充。

接下来是Image控件。它的背景颜色设置为漂亮的灰色(#AAAAAA),边距为40,这将使其与周围的框架分离一点。它还有一个硬编码的名称(x:Name="image"),这将允许我们从代码后台与其交互。最后一个属性叫做Aspect,确定如果图像控件与源图像的比例不同,我们应该怎么做。在这种情况下,我们希望填充整个图像区域,但不显示任何空白区域。这实际上会裁剪图像的高度或宽度。

最后,我们通过添加两个标签来结束,这些标签也有硬编码的名称以供以后参考。

创建 DescriptionGenerator

在图像的底部,我们看到了一个描述。由于我们没有来自即将到来的图像源的图像的一般描述,我们需要创建一个生成器来制作描述。下面是我们将如何做:

  1. 在.NET Standard 项目中创建一个名为Utils的文件夹。

  2. 在该文件夹中创建一个名为DescriptionGenerator的新类。

  3. System.Linq添加一个using语句(using System.Linq;)。

  4. 将以下代码添加到类中:

public class DescriptionGenerator
{
    private string[] _adjectives = { "nice", "horrible", "great", 
    "terribly old", "brand new" };                           
    private string[] _other = { "picture of grandpa", "car", "photo 
    of a forest", "duck" };
    private static Random random = new Random();

    public string Generate()
    {
        var a = _adjectives[random.Next(_adjectives.Count())];
        var b = _other[random.Next(_other.Count())];
        return $"A {a} {b}";
    }
} 

这个类只有一个目的。它从_adjectives数组中取一个随机单词,并将其与_other数组中的一个随机单词结合起来。通过调用Generate()方法,我们得到一个全新的组合。请随意在数组中输入自己的单词。请注意,Random实例是一个静态字段。这是因为如果我们在时间上创建了太接近的Random类的新实例,它们将以相同的值进行种子化,并返回相同的随机数序列。

创建一个图片类

为了抽象出我们想要显示的图像的所有信息,我们将创建一个封装了这些信息的类。我们的Picture类中没有太多信息,但这是一个很好的编码实践:

  1. Utils文件夹中创建一个名为Picture的新类。

  2. 将以下代码添加到类中:

public class Picture
{
 public Uri Uri { get; set; }
  public string Description { get; set; }

 public Picture()
 {
 Uri = new Uri($"https://picsum.photos/400/400/?random&ts= 
 {DateTime.Now.Ticks}");

 var generator = new DescriptionGenerator();
 Description = generator.Generate();
 }
}

Picture类有两个公共属性:

  • 图像的URI,指向其在互联网上的位置

  • 该图像的描述

在构造函数中,我们创建一个新的统一资源标识符URI),它指向一个我们可以使用的测试照片的公共来源。宽度和高度在 URI 的查询字符串部分中指定。我们还附加了一个随机时间戳,以避免 Xamarin.Forms 缓存图像。这样每次请求图像时都会生成一个唯一的 URI。

然后,我们使用我们创建的DescriptionGenerator类来为图像生成一个随机描述。

将图片绑定到控件

让我们开始连接Swiper控件,以便开始显示图像。我们需要设置图像的源,然后根据图像的状态控制加载标签的可见性。由于我们使用的是从互联网获取的图像,可能需要几秒钟才能下载。这必须向用户传达,以避免对正在发生的事情产生困惑。

设置源

我们首先设置图像的源。image控件(在代码中称为image)有一个source属性。这个属性是抽象类型ImageSource。有几种不同类型的图像源可以使用。我们感兴趣的是UriImageSource,它接受一个 URI,下载图像,并允许图像控件显示它。

让我们扩展Swiper控件以设置源和描述:

  1. 打开Controls/Swiper.Xaml.cs文件(Swiper控件的代码后端)。

  2. Swiper.Utils添加一个使用语句(using Swiper.Utils;)。

  3. 将加粗标记的代码添加到构造函数中:

public SwiperControl()
{
    InitializeComponent();

   var picture = new Picture();
 descriptionLabel.Text = picture.Description;
 image.Source = new UriImageSource() { Uri = picture.Uri };
} 

我们创建了一个Picture类的新实例,并通过设置该控件的文本属性将描述分配给 GUI 中的descriptionLabel。然后,我们将图像的源设置为UriImageSource类的新实例,并将 URI 从图片实例分配给它。这将开始从互联网下载图像,并在下载完成后立即显示它。

控制加载标签

在图像下载时,我们希望在图像上方显示一个居中的加载文本。这已经在我们之前创建的 XAML 文件中,所以我们真正需要做的是在图像下载完成后隐藏它。我们将通过控制loadingLabelIsVisibleProperty来实现这一点,通过将其绑定到图像的IsLoading属性。每当图像上的IsLoading属性发生变化时,绑定就会改变标签上的IsVisible属性。这是一个很好的一劳永逸的方法。

让我们添加控制加载标签所需的代码:

  1. 打开Swiper.xaml.cs代码后端文件。

  2. 将加粗标记的代码添加到构造函数中:

public SwiperControl()
{
    InitializeComponent();

    var picture = new Picture();
    descriptionLabel.Text = picture.Description;
    image.Source = new  UriImageSource() { Uri = picture.Uri };
 loadingLabel.SetBinding(IsVisibleProperty, "IsLoading");
    loadingLabel.BindingContext = image; 
} 

在上述代码中,loadingLabel设置了一个绑定到IsVisibleProperty,实际上属于所有控件继承的VisualElement类。它告诉loadingLabel监听绑定上下文中分配的对象的IsLoading属性的变化。在这种情况下,这是image控件。

处理平移手势

该应用程序的核心功能之一是平移手势。平移手势是指用户按住控件并在屏幕上移动它。当我们添加多个图像时,我们还将为Swiper控件添加随机旋转,使其看起来像是堆叠的照片。

我们首先向SwiperControl添加一些字段:

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

  2. 在类中添加以下字段:

private readonly double _initialRotation;
private static readonly Random _random = new Random();

第一个字段_initialRotation存储图像的初始旋转。我们将在构造函数中设置这个值。第二个字段是一个包含Random对象的static字段。您可能还记得,最好创建一个静态随机对象,以确保不会使用相同的种子创建多个随机对象。种子是基于时间的,因此如果我们在时间上创建对象太接近,它们会生成相同的随机序列,因此实际上并不会那么随机。

接下来我们要做的是为PanUpdated事件创建一个事件处理程序,我们将在本节末尾绑定到它:

  1. 打开SwiperControl.xaml.cs代码后台文件。

  2. OnPanUpdated方法添加到类中:

private void OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
    switch (e.StatusType)
    {
        case GestureStatus.Started:
             PanStarted();
             break;

        case GestureStatus.Running:
             PanRunning(e);
             break;

        case GestureStatus.Completed:
             PanCompleted();
             break;
     }
} 

代码非常简单。我们处理一个事件,该事件将PanUpdatedEventArgs对象作为第二个参数。这是处理事件的标准方法。然后我们有一个switch子句,检查事件所指的状态。

平移手势可以有三种状态:

  • GestureStatus.Started: 当开始拖动时,此状态会被触发一次

  • GestureStatus.Running: 然后会多次触发此事件,每次您移动手指时都会触发一次

  • GestureStatus.Completed: 当您松开时,事件会最后一次被触发

对于这些状态中的每一个,我们调用处理不同状态的特定方法。现在我们将继续添加这些方法:

  1. 打开SwiperControl.xaml.cs代码后台文件。

  2. 将这三个方法添加到类中:

private void PanStarted()
{
    photo.ScaleTo(1.1, 100);
}

private void PanRunning(PanUpdatedEventArgs e)
{
    photo.TranslationX = e.TotalX;
    photo.TranslationY = e.TotalY;
    photo.Rotation = _initialRotation + (photo.TranslationX / 25);
}

private void PanCompleted()
{
    photo.TranslateTo(0, 0, 250, Easing.SpringOut);
    photo.RotateTo(_initialRotation, 250, Easing.SpringOut);
    photo.ScaleTo(1, 250);
}

让我们从PanStarted()开始。当用户开始拖动图像时,我们希望添加它在表面上稍微抬起的效果。这是通过将图像缩放 10%来实现的。Xamarin.Forms 有一组出色的函数来实现这一点。在这种情况下,我们在图像控件(名为Photo)上调用ScaleTo()方法,并告诉它缩放到1.1,这对应于其原始大小的 10%。我们还告诉它在100毫秒内执行此操作。这个调用也是可等待的,这意味着我们可以在控件完成动画之前等待执行下一个调用。在这种情况下,我们将使用一种忘记并继续的方法。

接下来是PanRunning(),在平移操作期间会多次调用。这个方法接受一个参数,即来自PanRunning()事件处理程序的PanUpdatedEventArgs。我们也可以只传入XY值作为参数,以减少代码的耦合。这是您可以尝试的一些东西。该方法从事件的TotalX/TotalY属性中提取XY分量,并将它们分配给图像控件的TranslationX/TranslationY属性。我们还根据图像移动的距离微调旋转。

最后要做的是在释放图像时将所有内容恢复到初始状态。这可以在PanCompleted()中完成。首先,我们将图像平移(或移动)回其原始本地坐标(0,0)在250毫秒内。我们还添加了一个缓动函数,使其略微超出目标,然后再次动画。我们可以尝试使用不同的预定义缓动函数;这些对于创建漂亮的动画非常有用。最后,我们将图像缩放回其原始大小在250毫秒内。

现在是时候在构造函数中添加代码,以连接平移手势并设置一些初始旋转值:

  1. 打开SwiperControl.xaml.cs代码后台文件。

  2. 在构造函数中添加粗体文本。请注意,构造函数中还有更多代码,所以不要复制和粘贴整个方法,只需添加粗体文本:

public SwiperControl()
{
    InitializeComponent();

    var panGesture = new PanGestureRecognizer();
 panGesture.PanUpdated += OnPanUpdated;
 this.GestureRecognizers.Add(panGesture); _initialRotation = _random.Next(-10, 10);
    photo.RotateTo(_initialRotation, 100, Easing.SinOut); 

    <!-- other code omitted for brevity -->
}

所有 Xamarin.Forms 控件都有一个名为GestureRecognizers的属性。有不同类型的手势识别器,例如TapGestureRecognizerSwipeGestureRecognizer。在我们的情况下,我们对PanGestureRecognizer感兴趣。我们创建一个新的PanGestureRecognizer,并通过将其连接到我们之前创建的OnPanUpdated()方法来订阅PanUpdated事件。然后将其添加到Swiper控件的GestureRecognizers集合中。

然后我们设置图像的初始旋转,并确保我们存储它,以便我们可以修改旋转,然后将其旋转回原始状态。

测试控件

我们现在已经编写了所有代码来测试控件:

  1. 打开MainPage.xaml.cs

  2. 添加using语句用于Swiper.Controlsusing Swiper.Controls;)。

  3. 在构造函数中添加粗体标记的代码:

public MainPage()
{
    InitializeComponent();
    MainGrid.Children.Add(new SwiperControl());
} 

如果构建顺利,我们应该得到如下图所示的图像:

我们还可以拖动图像(平移)。注意当您开始拖动时的轻微抬起效果以及基于平移量的图像旋转,即总移动量。如果您放开图像,它会动画回到原位。

创建决策区域

没有每一侧屏幕上的特殊放置区域,交友应用程序就不完整。我们在这里想做一些事情:

  • 当用户将图像拖动到任一侧时,应该出现文本,显示LIKEDENY(决策区域)

  • 当用户将图像放在决策区域时,应用程序应该从页面中删除图像

我们将通过向SwiperControl.xaml文件添加一些 XAML 代码来创建这些区域,然后继续添加必要的代码来实现这一点。值得注意的是,这些区域实际上并不是放置图像的热点区域,而是用于在控件表面上显示标签。实际的放置区域是根据您拖动图像的距离来计算和确定的。

扩展网格

Swiper控件有三列定义。如果图像被拖到页面的任一侧,我们希望为用户添加某种视觉反馈。我们将通过在每一侧添加一个带有LabelStackLayout来实现这一点。

添加用于喜欢照片的 StackLayout

首先要做的是在控件的右侧添加用于喜欢照片的StackLayout

  1. 打开Controls/SwiperControl.xaml

  2. 在注释<!-- StackLayout for like here -->下添加以下代码:

<StackLayout x:Name="likeStackLayout" Grid.Column="2"
             Opacity="0" Padding="0, 100">
    <Label Text="LIKE" 
           TextColor="Lime" 
           FontSize="30" 
           Rotation="30" 
           FontAttributes="Bold" />
</StackLayout>

StackLayout是我们要显示的内容的容器。它有一个名称,并且被分配在第三列中(由于从零开始索引,代码中写着Grid.Column="2")。Opacity设置为0,使其完全不可见,并且Padding调整为使其从顶部向下移动一点。

StackLayout内,我们将添加一个Label

添加用于拒绝照片的 StackLayout

下一步是在控件的左侧添加用于拒绝照片的StackLayout

  1. 打开Controls/SwiperControl.xaml

  2. 在注释<!-- StackLayout for deny here -->下添加以下代码:

<StackLayout x:Name="denyStackLayout" Opacity="0" 
             Padding="0, 100" HorizontalOptions="End">
    <Label Text="DENY" 
           TextColor="Red"
           FontSize="30"
           Rotation="-20" 
           FontAttributes="Bold" />
</StackLayout> 

左侧StackLayout的设置与之相同,只是应该在第一列中,这是默认设置,因此不需要添加Grid.Column属性。我们还指定了HorizontalOptions="End",这意味着内容应右对齐。

确定屏幕大小

为了能够计算用户拖动图像的百分比,我们需要知道控件的大小。这在 Xamarin.Forms 布局控件之后才确定。

我们将重写OnSizeAllocated()方法,并在类中添加一个名为_screenWidth的字段,以便通过以下几个步骤跟踪窗口的当前宽度:

  1. 打开SwiperControl.xaml.cs

  2. 将以下代码添加到文件中。将字段放在类的开头,将OnSizeAllocated()方法放在构造函数下面:

private double _screenWidth = -1;

protected override void OnSizeAllocated(double width, double height)
{
    base.OnSizeAllocated(width, height);

    if (Application.Current.MainPage == null)
    {
        return;
    }

    _screenWidth = Application.Current.MainPage.Width;
} 

_screenWidth字段用于在解析后立即存储宽度。我们通过重写OnSizeAllocated()方法来实现这一点,该方法在 Xamarin.Forms 分配控件的大小时调用。这被调用多次。第一次调用实际上是在设置宽度和高度之前以及设置当前应用程序的MainPage之前。此时,宽度和高度设置为-1,并且Application.Current.MainPage为 null。我们通过对Application.Current.MainPage进行空检查并在其为 null 时返回来寻找这种状态。我们也可以检查宽度上的-1值。任一方法都可以工作。但是,如果它有一个值,我们希望将其存储在我们的_screenWidth字段中以供以后使用。

Xamarin.Forms 会在应用程序框架发生变化时调用OnSizeAllocated()方法。这对于 UWP 应用程序来说最为重要,因为它们在用户可以轻松更改的窗口中。Android 和 iOS 应用程序不太可能再次调用此方法,因为应用程序将占据整个屏幕的房地产。

添加夹取函数

为了能够计算状态,我们需要稍后夹取一个值。在撰写本文时,这个函数已经在 Xamarin.Forms 中,但它被标记为内部函数,这意味着我们不应该真的使用它。据传言,它将很快在 Xamarin.Forms 的后续版本中公开,但目前,我们需要重新定义它:

  1. 打开SwiperControl.xaml.cs

  2. 在类中添加以下static方法:

private static double Clamp(double value, double min, double max)
{
     return (value < min) ? min : (value > max) ? max : value;
} 

该方法接受一个要夹取的值,一个最小边界和一个最大边界。如果值大于或小于设置的边界,则返回值本身或边界值。

添加计算状态的代码

为了计算图像的状态,我们需要定义我们的区域,然后创建一个函数,该函数接受当前移动量并根据我们平移图像的距离更新 GUI 决策区域的不透明度。

定义一个用于计算状态的方法

让我们添加CalculatePanState()方法来计算我们已经平移图像的距离,以及它是否应该开始影响 GUI,按照以下几个步骤进行:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 将属性添加到顶部,将CalculatePanState()方法添加到类中的任何位置,如下面的代码所示:

private const double DeadZone = 0.4d;
private const double DecisionThreshold = 0.4d;

private void CalculatePanState(double panX)
{
    var halfScreenWidth = _screenWidth / 2;
    var deadZoneEnd = DeadZone * halfScreenWidth;

    if (Math.Abs(panX) < deadZoneEnd)
    {
        return;
    }

    var passedDeadzone = panX < 0 ? panX + deadZoneEnd : panX - 
    deadZoneEnd;
    var decisionZoneEnd = DecisionThreshold * halfScreenWidth;
    var opacity = passedDeadzone / decisionZoneEnd;

    opacity = Clamp(opacity, -1, 1);

    likeStackLayout.Opacity = opacity;
    denyStackLayout.Opacity = -opacity;
} 

我们将两个值定义为常量:

  • DeadZone定义了当平移图像时,中心点两侧可用空间的 40%(0.4)是死区。如果我们在这个区域释放图像,它将简单地返回到屏幕中心而不采取任何行动。

  • 下一个常量是DecisionThreshold,它定义了另外 40%(0.4)的可用空间。这用于插值StackLayout在布局两侧的不透明度。

然后,我们使用这些值来检查平移操作的状态。如果XpanX)的绝对平移值小于死区,我们将返回而不采取任何行动。如果不是,则我们计算我们已经超过死区的距离以及我们进入决策区的距离。我们根据这个插值计算不透明度值,并将值夹取在-11之间。

最后,我们为likeStackLayoutdenyStackLayout设置透明度为这个值。

连接平移状态检查

在图像被平移时,我们希望更新状态:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 将以下代码添加到PanRunning()方法中:

private void PanRunning(PanUpdatedEventArgs e)
{
    photo.TranslationX = e.TotalX;
    photo.TranslationY = e.TotalY;
    photo.Rotation = _initialRotation + (photo.TranslationX / 25);

    CalculatePanState(e.TotalX);
} 

PanRunning()方法的这个添加将在x轴上的总移动量传递给CalculatePanState()方法,以确定我们是否需要调整控件左侧或右侧的StackLayout的透明度。

添加退出逻辑

到目前为止,一切都很好,除了一个问题,即如果我们将图像拖到边缘然后放开,文本会保留。我们需要确定用户何时停止拖动图像,以及图像是否处于决策区。

检查图像是否应退出

我们希望有一个简单的函数来确定一张图片是否已经移动足够远,以便算作该图片的退出:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 在类中添加CheckForExitCritera()方法,如下所示:

private bool CheckForExitCriteria()
{
    var halfScreenWidth = _screenWidth / 2;
    var decisionBreakpoint = DeadZone * halfScreenWidth;
    return (Math.Abs(photo.TranslationX) > decisionBreakpoint); 
} 

此函数计算我们是否已经越过死区并进入决策区。我们需要使用Math.Abs()方法获取总绝对值进行比较。我们也可以使用<>运算符,但我们使用这种方法是因为它更可读。这是代码风格和品味的问题,随意按照自己的方式进行。

删除图像

如果我们确定图像已经移动足够远,使其退出,我们希望将其从屏幕上动画移出,然后从页面中删除图像:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 在类中添加Exit()方法,如下所示:

private void Exit()
{
    Device.BeginInvokeOnMainThread(async () =>
    {
        var direction = photo.TranslationX < 0 ? -1 : 1;

        await photo.TranslateTo(photo.TranslationX + 
        (_screenWidth * direction),
        photo.TranslationY, 200, Easing.CubicIn);
        var parent = Parent as Layout<View>;
        parent?.Children.Remove(this);
    });
}      

Exit()方法执行以下操作:

  1. 我们首先确保此调用在 UI 线程上完成,这也被称为MainThread。这是因为只有 UI 线程才能执行动画。

  2. 我们还需要异步运行此线程,以便一举两得。由于这个方法是关于将图像动画到屏幕的一侧,我们需要确定在哪个方向进行动画。

  3. 我们通过确定图像的总平移是正数还是负数来执行此操作。

  4. 然后我们使用这个值通过photo.TranslateTo()调用来等待翻译。

  5. 我们等待此调用,因为我们不希望代码执行继续,直到完成。完成后,我们将控件从父级的子级集合中移除,导致它永远消失。

更新 PanCompleted

决定图像是否应消失或仅返回到其原始状态是在PanCompleted()方法中触发的。在这里,我们连接了前两节中创建的两种方法:

  1. 打开Controls/SwiperControl.xaml.cs

  2. PanCompleted()方法中添加粗体代码:

private void PanCompleted()
{
 if (CheckForExitCriteria())
 {
 Exit();
 }

 likeStackLayout.Opacity = 0;
 denyStackLayout.Opacity = 0;

    photo.TranslateTo(0, 0, 250, Easing.SpringOut);
    photo.RotateTo(_initialRotation, 250, Easing.SpringOut);
    photo.ScaleTo(1, 250);
} 

本节中的最后一步是使用CheckForExitCriteria()方法和Exit()方法,如果满足退出条件,则执行这些条件。如果不满足退出条件,我们需要重置StackLayout的状态和不透明度,使一切恢复正常。

向控件添加事件

在控件本身中我们还剩下最后一件事要做,那就是添加一些事件,指示图像是否已被喜欢拒绝。我们将使用一个干净的接口,允许控件的简单使用,同时隐藏所有实现细节。

声明两个事件

为了使控件更容易从应用程序本身进行交互,我们需要为LikeDeny添加事件:

  1. 打开Controls/SwiperControl.xaml.cs

  2. 在类的开头添加两个事件声明,如下所示:

public event EventHandler OnLike;
public event EventHandler OnDeny; 

这是两个带有开箱即用的事件处理程序的标准事件声明。

触发事件

我们需要在Exit()方法中添加代码来触发我们之前创建的事件:

  1. 打开Controls/SwiperControl.xaml.cs

  2. Exit()方法中添加粗体代码:

private void Exit()
{
    Device.BeginInvokeOnMainThread(async () =>
    {
        var direction = photo.TranslationX < 0 ? -1 : 1;

 if (direction > 0)
 {
 OnLike?.Invoke(this, new EventArgs());
 }

 if (direction < 0)
 {
 OnDeny?.Invoke(this, new EventArgs());
 }

        await photo.TranslateTo(photo.TranslationX + (_screenWidth 
        * direction),
        photo.TranslationY, 200, Easing.CubicIn);
        var parent = Parent as Layout<View>;
        parent?.Children.Remove(this);
    });
}

在这里,我们注入代码来检查我们是喜欢还是不喜欢一张图片。然后根据这些信息触发正确的事件。

连接 Swiper 控件

我们现在已经到达本章的最后部分。在本节中,我们将连接图像并使我们的应用成为一个可以永远使用的闭环应用程序。我们将添加 10 张图像,这些图像将在应用程序启动时从互联网上下载。每次删除一张图像时,我们将简单地添加另一张图像。

添加图像

让我们首先创建一些代码,将图像添加到 MainView 中。我们将首先添加初始图像,然后创建逻辑,以便在每次喜欢或不喜欢图像时向堆栈底部添加新图像。

添加初始照片

为了使照片看起来像是堆叠在一起,我们至少需要 10 张照片:

  1. 打开MainPage.xaml.cs

  2. 将“AddInitalPhotos()”方法和“InsertPhotoMethod()”添加到类中:

private void AddInitialPhotos()
{
    for (int i = 0; i < 10; i++)
    {
        InsertPhoto();
    }
}

private void InsertPhoto()
{
    var photo = new SwiperControl();
    this.MainGrid.Children.Insert(0, photo);
} 

首先,我们创建一个名为“AddInitialPhotos()”的方法,该方法将在启动时调用。该方法简单地调用“InsertPhoto()”方法 10 次,并每次向MainGrid添加一个新的SwiperControl。它将控件插入到堆栈的第一个位置,从而有效地将其放在堆栈底部,因为控件集合是从开始到结束渲染的。

从构造函数中进行调用

我们需要调用此方法才能使魔术发生:

  1. 打开MainPage.xaml.cs

  2. 将粗体中的代码添加到构造函数中,并确保它看起来像下面这样:

public MainPage()
{
    InitializeComponent();
    AddInitialPhotos();
} 

这里没有什么可说的。在初始化MainPage之后,我们调用该方法添加 10 张我们将从互联网上下载的随机照片。

添加计数标签

我们还希望为应用程序添加一些价值观。我们可以通过在Swiper控件集合下方添加两个标签来实现这一点。每当用户对图像进行评分时,我们将递增两个计数器中的一个,并显示结果。

因此,让我们添加 XAML 以显示标签所需的内容:

  1. 打开MainPage.xaml

  2. 用粗体标记的代码替换注释<!-- Placeholder for later -->

<Grid Grid.Row="1" Padding="30">
    <Grid.RowDefinitions>
 <RowDefinition Height="auto" />
 <RowDefinition Height="auto" />
 <RowDefinition Height="auto" />
 <RowDefinition Height="auto" />
 </Grid.RowDefinitions>

 <Label Text="LIKES" />
 <Label x:Name="likeLabel" 
 Grid.Row="1"
 Text="0" 
 FontSize="Large" 
 FontAttributes="Bold" />

 <Label Grid.Row="2" 
 Text="DENIED" />
 <Label x:Name="denyLabel"
 Grid.Row="3" 
 Text="0" 
 FontSize="Large" 
 FontAttributes="Bold" />
</Grid> 

此代码添加了一个具有四个自动高度行的新Grid。这意味着我们计算每行内容的高度,并将其用于布局。这基本上与StackLayout相同,但我们想展示一种更好的方法。

在每行中添加一个Label,并将其中两个命名为likeLabeldenyLabel。这两个命名的标签将保存已喜欢的图像数量以及已拒绝的图像数量。

订阅事件

最后一步是连接OnLikeOnDeny事件,并向用户显示总计数。

添加方法以更新 GUI 并响应事件

我们需要一些代码来更新 GUI 并跟踪计数:

  1. 打开MainPage.xaml.cs

  2. 将以下代码添加到类中,如下所示:

private int _likeCount;
private int _denyCount;

private void UpdateGui()
{
    likeLabel.Text = _likeCount.ToString();
    denyLabel.Text = _denyCount.ToString();
}

private void Handle_OnLike(object sender, EventArgs e)
{
    _likeCount++;
    InsertPhoto();
    UpdateGui();
}

private void Handle_OnDeny(object sender, EventArgs e)
{
    _denyCount++;
    InsertPhoto();
    UpdateGui();
} 

顶部的两个字段跟踪喜欢和拒绝的数量。由于它们是值类型变量,它们默认为零。

为了使这些标签的更改传播到 UI,我们创建了一个名为“UpdateGui()”的方法。这将获取两个前述字段的值,并将其分配给两个标签的Text属性。

接下来的两个方法是将处理OnLikeOnDeny事件的事件处理程序。它们增加适当的字段,添加新照片,然后更新 GUI 以反映更改。

连接事件

每次创建新的SwiperControl时,我们需要连接事件:

  1. 打开“MainPage.xaml.cs”。

  2. 将粗体中的代码添加到“InsertPhoto()”方法中:

private void InsertPhoto()
{
    var photo = new SwiperControl();
 photo.OnDeny += Handle_OnDeny;
 photo.OnLike += Handle_OnLike;

    this.MainGrid.Children.Insert(0, photo);
} 

添加的代码连接了我们之前定义的事件处理程序。这些事件确实使与我们的新控件交互变得容易。自己尝试一下,并玩一下您创建的应用程序。

摘要

干得好!在本章中,我们学习了如何创建一个可重用的外观良好的控件,可用于任何 Xamarin.Forms 应用程序。为了增强应用程序的用户体验(UX),我们使用了一些动画,为用户提供了更多的视觉反馈。我们还在 XAML 的使用上有所创意,定义了一个看起来像是带有手写描述的照片的控件的 GUI。

之后,我们使用事件将控件的行为暴露给MainPage,以限制应用程序与控件之间的接触表面。最重要的是,我们涉及了GestureRecognizers的主题,这可以在处理常见手势时使我们的生活变得更加轻松。

在下一章中,我们将看看如何在 iOS 和 Android 设备上后台跟踪用户的位置。为了可视化我们正在跟踪的内容,我们将使用 Xamarin.Forms 中的地图组件。

第四章:使用 GPS 和地图构建位置跟踪应用程序

在本章中,我们将创建一个位置跟踪应用程序,将用户的位置保存并显示为热力图。我们将看看如何在 iOS 和 Android 设备上后台运行任务,以及如何使用自定义渲染器来扩展 Xamarin.Forms 地图的功能。

本章将涵盖以下主题:

  • 在 iOS 设备上后台跟踪用户位置

  • 在 Android 设备上后台跟踪用户位置

  • 如何在 Xamarin.Forms 应用程序中显示地图

  • 如何使用自定义渲染器扩展 Xamarin.Forms 地图的功能

技术要求

为了能够完成项目,您需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参阅第一章,“Xamarin 简介”。

项目概述

许多应用程序可以通过添加地图和位置服务而变得更加丰富。在这个项目中,我们将构建一个名为MeTracker的位置跟踪应用程序。该应用程序将跟踪用户的位置并将其保存到 SQLite 数据库中,以便我们可以将结果可视化为热力图。为了构建这个应用程序,我们将学习如何在 iOS 和 Android 上设置后台进程,因为我们无法在 iOS 和 Android 之间共享代码。对于地图,我们将使用Xamarin.Forms.Maps组件并扩展其功能以构建热力图。为此,我们将使用 iOS 的自定义渲染器和 Android 的自定义渲染器,以便我们可以使用平台 API。

入门

我们可以使用 PC 上的 Visual Studio 2017 或 Mac 上的 Visual Studio 来完成此项目。要使用 Visual Studio 在 PC 上构建 iOS 应用程序,您必须连接 Mac。如果您根本没有访问 Mac,您可以只完成此项目的 Android 部分。

构建 MeTracker 应用程序

现在是时候开始构建应用程序了。创建一个移动应用程序(Xamarin.Forms)。我们将在新项目对话框的跨平台选项卡下找到该模板。我们将项目命名为MeTracker

使用.NET Standard 作为代码共享策略,并选择 iOS 和 Android 作为平台。

确保使用 Android 版本 Oreo(API 级别 26)或更高版本进行编译。我们可以在项目属性的“应用程序”选项卡下设置这一点。

更新模板添加的 NuGet 包,以确保我们使用最新版本。

创建存储用户位置的存储库

我们要做的第一件事是创建一个存储库,我们可以用来保存用户的位置。

为位置数据创建模型

在创建存储库之前,我们将通过以下步骤创建一个代表用户位置的模型类:

  1. 创建一个新的文件夹,我们可以用于此和其他模型,名为Models

  2. Models文件夹中创建一个名为Location的类,并为IdLatitudeLongitude添加属性。

  3. 创建两个构造函数,一个为空的构造函数,另一个以latitudelongitude作为参数的构造函数,使用以下代码:

using System;

namespace MeTracker.Models
{
    public class Location
    {
        public Location() {}

        public Location(double latitude, double longitude)
        {
            Latitude = latitude;
            Longitude = longitude;
        }

        public int Id { get; set; }
        public double Latitude { get; set; }
        public double Longitude { get; set; }
    }
}

创建存储库

现在我们已经创建了一个模型,我们可以继续创建存储库。首先,我们将通过以下步骤为存储库创建一个接口:

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

  2. 在我们的新文件夹中,我们将创建一个名为ILocationRepository的接口。

  3. 在我们为interface创建的新文件中编写以下代码:

using MeTracker.Models;
using System;
using System.Threading.Tasks;

namespace MeTracker.Repositories
{
    public interface ILocationRepository
    {
        Task Save(Location location);
    }
}
  1. MeTracker.ModelsSystem.Threading.Tasks添加using指令,以解析LocationTask的引用。

一旦我们有了一个interface,我们需要通过以下步骤创建其实现:

  1. MeTracker项目中,创建一个名为LocationRepository的新类。

  2. 实现ILocationRepository接口,并在Save方法中添加async关键字,使用以下代码:

using System;
using System.Threading.Tasks;
using MeTracker.Models;

namespace MeTracker.Repositories
{
    public class LocationRepository : ILocationRepository 
    {
        public async Task Save(Location location)
        {
        }
    }
}

为了存储数据,我们将使用 SQLite 数据库和对象关系映射器(ORM)SQLite-net,以便我们可以针对领域模型编写代码,而不是使用 SQL 对数据库进行操作。这是由 Frank A. Krueger 创建的开源库。让我们通过以下步骤来设置这个:

  1. MeTracker项目中安装 NuGet 包sqlite-net-pcl

  2. 转到Location模型类,并为Id属性添加PrimaryKeyAttributeAutoIncrementAttribute。当我们添加这些属性时,Id属性将成为数据库中的主键,并将自动创建一个值。

  3. LocationRepository类中编写以下代码,以创建与 SQLite 数据库的连接。if语句用于检查我们是否已经创建了连接。如果是这样,我们将不会创建新的连接;相反,我们将使用已经创建的连接:

private SQLiteAsyncConnection connection;

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

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

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

现在,是时候实现Save方法了,该方法将以位置对象作为参数,并将其存储在数据库中。

现在,我们将在Save方法中使用CreateConnection方法,以确保在尝试将数据保存到数据库时创建连接。当我们知道有一个活动连接时,我们可以使用InsertAsync方法,并将Save方法的location参数作为参数传递。

编辑LocationRepository类中的Save方法,使其看起来像以下代码:

public async Task Save(Location location)
{
    await CreateConnection();
    await connection.InsertAsync(location);
}

Xamarin.Essentials

Xamarin.Essentials是由 Microsoft 和 Xamarin 创建的库,使开发人员能够从共享代码中使用特定于平台的 API。Xamarin.Essentials 目标是 Xamarin.iOS、Xamarin.Android 和 UWP。在这个项目中,我们将使用 Xamarin.Essentials 来执行各种任务,包括获取位置和在主线程上执行代码。

安装 NuGet 包

在撰写本文时,Xamarin.Essentials 处于预览状态。要找到预览中的 NuGet 包,我们需要勾选包括预览版本的复选框。

在 Android 上配置 Xamarin.Essentials

我们需要通过调用初始化方法在 Android 上初始化 Xamarin.Essentials。我们通过以下步骤来实现这一点:

  1. 在 Android 项目中,打开MainActivity.cs文件。

  2. global::Xamarin.Forms.Forms.Init方法下添加粗体代码:

protected override void OnCreate(Bundle savedInstanceState)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(savedInstanceState);

    global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
    Xamarin.Essentials.Platform.Init(this, savedInstanceState);

    LoadApplication(new App());
}

就是这样。我们已经准备就绪。

为位置跟踪创建一个服务

要跟踪用户的位置,我们需要根据平台编写代码。Xamarin.Essentials 具有用于在共享代码中获取用户位置的方法,但不能在后台使用。为了能够使用我们将为每个平台编写的代码,我们需要创建一个接口。对于ILocationRepository接口,将只有一个在两个平台上使用的实现,而对于位置跟踪服务,我们将在 iOS 平台和 Android 平台分别有一个实现。

通过以下步骤创建ILocationRepository接口:

  1. MeTracker项目中,创建一个新的文件夹,并命名为Services

  2. Services文件夹中创建一个名为ILocationTrackingService的新接口。

  3. 在接口中,添加一个名为StartTracking的方法,如下所示:

 public interface ILocationTrackingService
 {
      void StartTracking();
 } 

目前,我们将在 iOS 和 Android 项目中只创建一个空的接口实现,稍后在本章中我们将回到每个实现:

  1. 在 iOS 和 Android 项目中创建一个名为Services的文件夹。

  2. 在 iOS 和 Android 项目的新Service文件夹中,按照以下代码中所示创建一个名为LocationTrackingService的类的空实现:

public class LocationTrackingService : ILocationTrackingService
{
     public void StartTracking()
     {
     }
}

设置应用逻辑

我们现在已经创建了我们需要跟踪用户位置并在设备上保存位置的接口。现在是时候编写代码来开始跟踪用户了。我们仍然没有任何实际跟踪用户位置的代码,但如果我们已经编写了开始跟踪的代码,那么编写这部分代码将会更容易。

创建一个带有地图的视图

首先,我们将创建一个带有简单地图的视图,该地图以用户位置为中心。让我们通过以下步骤来设置这一点:

  1. MeTracker项目中,创建一个名为Views的新文件夹。

  2. Views文件夹中,创建一个基于 XAML 的ContentPage,并将其命名为MainView

Xamarin.Forms 包中没有地图控件,但是微软和 Xamarin 提供了一个官方包,可以在 Xamarin.Forms 应用中显示地图。这个包叫做Xamarin.Forms.Maps,我们可以通过以下步骤从 NuGet 安装它:

  1. MeTrackerMeTracker.AndroidMeTracker.iOS项目中安装Xamarin.Forms.Maps

  2. 使用以下代码为MainView添加Xamarin.Forms.Maps的命名空间:

 <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
              xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
              xmlns:map="clr- 
              namespace:Xamarin.Forms.Maps;assembly
              =Xamarin.Forms.Maps"
              x:Class="MeTracker.Views.MainView"> 

现在我们可以在我们的视图中使用地图了。因为我们希望Map覆盖整个页面,所以我们可以将它添加到ContentPage的根部。让我们通过以下步骤来设置这一点:

  1. map添加到ContentPage

  2. 给地图命名,以便我们可以从代码后台访问它。将其命名为Map,如下所示:

 <ContentPage  

              x:Class="MeTracker.Views.MainView"> 
 <map:Map x:Name="Map" /> 
</ContentPage>

为了使用map控件,我们需要在每个平台上运行代码来初始化它,通过以下步骤:

  1. 在 iOS 项目中,转到AppDelegate.cs

  2. FinishedLaunching方法中,在Xamarin.FormsInit之后,添加global::Xamarin.FormsMaps.Init()来初始化 iOS 应用中的map控件,使用以下代码:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
     global::Xamarin.Forms.Forms.Init();
     global::Xamarin.FormsMaps.Init();

     LoadApplication(new App());

     return base.FinishedLaunching(app, options);
} 

继续为 Android 初始化:

  1. 在 Android 项目中,转到MainActivity.cs

  2. OnCreate方法中,在Xamarin.FormsInit之后,添加global::Xamarin.FormsMaps.Init(this, savedInstanceState)来初始化 iOS 上的map控件。

  3. 通过以下代码初始化 Xamarin.Essentials:Xamarin.Essentials.Platform.Init(this, savedInstanceState)

protected override void OnCreate(Bundle savedInstanceState)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

     base.OnCreate(savedInstanceState);
     global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
     global::Xamarin.FormsMaps.Init(this, savedInstanceState);

     Xamarin.Essentials.Platform.Init(this, savedInstanceState); 

     LoadApplication(new App());
} 

对于 Android,我们还需要决定用户回答权限对话框后发生什么,并将结果发送给 Xamarin.Essentials。我们将通过将以下代码添加到MainActivity.cs来实现这一点:

public override void OnRequestPermissionsResult(int requestCode,                     
                 string[] permissions, 
                 [GeneratedEnum] Android.Content.PM.Permission[]          
                 grantResults)
{     Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode,   
                 permissions, grantResults);
                 base.OnRequestPermissionsResult(requestCode,   
                 permissions, grantResults);
}

对于 Android,我们需要一个API 密钥来获取 Google Maps 的地图。有关如何获取 API 密钥的 Microsoft 文档可以在docs.microsoft.com/en-us/xamarin/android/platform/maps-and-location/maps/obtaining-a-google-maps-api-key找到。以下是获取 API 密钥的步骤:

  1. 打开AndroidMainfest.xml,它位于 Android 项目的Properties文件夹中。

  2. 将元数据元素插入到应用程序元素中,如下所示:

 <application android:label="MeTracker.Android">
      <meta-data android:name="com.google.android.maps.v2.API_KEY" 
      android:value="{YourKeyHere}" />
</application> 

我们还希望地图以用户的位置为中心。我们将在MainView.xaml.cs的构造函数中实现这一点。因为我们希望异步运行获取用户位置的操作,并且它需要在主线程上执行,所以我们将使用MainThread.BeginInvokeOnMainThread来包装它。我们将使用 Xamarin.Essentials 来获取用户的当前位置。当我们有了位置信息后,我们可以使用MapMoveToRegion方法。我们可以通过以下步骤来设置这一点:

  1. MeTracker项目中,打开MainView.xaml.cs

  2. 将粗体字中的代码添加到MainView.xaml.cs类的构造函数中:

public MainView ()
{
    InitializeComponent ();

MainThread.BeginInvokeOnMainThread(async() =>
 {
 var location = await Geolocation.GetLocationAsync();
 Map.MoveToRegion(MapSpan.FromCenterAndRadius(
 new Position(location.Latitude, location.Longitude), 
 Distance.FromKilometers(5)));
 });
}

创建一个 ViewModel

在创建实际的视图模型之前,我们将创建一个所有视图模型都可以继承的抽象基础视图模型。这个基础视图模型的想法是我们可以在其中编写通用代码。在这种情况下,我们将通过以下步骤实现INotifyPropertyChanged接口:

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

  2. 编写以下代码并解析所有引用:

public abstract class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    public void RaisePropertyChanged(params string[] propertyNames)
    {
        foreach(var propertyName in propertyNames)
        {
            PropertyChanged?.Invoke(this, new  
            PropertyChangedEventArgs(propertyName));
        }
    }
} 

下一步是创建实际的视图模型,它将使用ViewModel作为基类。通过以下步骤来设置:

  1. MeTracker项目中,在ViewModels文件夹中创建一个名为MainViewModel的新类。

  2. 使MainViewModel继承ViewModel

  3. 添加一个ILocationTrackingService类型的只读字段,并命名为locationTrackingService

  4. 添加一个ILocationRepository类型的只读字段,并命名为locationRepository

  5. 创建一个构造函数,参数为ILocationTrackingServiceILocationRepository

  6. 使用参数的值设置我们在步骤3 和步骤4 中创建的字段的值,如下面的代码所示:

public class MainViewModel : ViewModel
{
         private readonly ILocationRepository locationRepository;
         private readonly ILocationTrackingService 
         locationTrackingService;

         public MainViewModel(ILocationTrackingService 
         locationTrackingService,
         ILocationRepository locationRepository)
         {
             this.locationTrackingService = 
             locationTrackingTrackingService;
             this.locationRepository = locationRepository;
         } 
}

为了使 iOS 应用程序开始跟踪用户的位置,我们需要通过以下步骤在主线程上运行启动跟踪的代码:

  1. 在新创建的MainViewModel的构造函数中,使用 Xamarin.Essentials 的MainThread.BeginInvokeOnMainThread调用主线程。Xamarin.Forms 有一个用于在主线程上调用代码的辅助方法,但如果我们使用 Xamarin.Essentials 的方法,我们可以在 ViewModel 中没有任何对 Xamarin.Forms 的依赖。如果在 ViewModels 中没有任何对 Xamarin.Forms 的依赖,我们可以在将来添加其他平台的应用程序中重用它们。

  2. 在传递给BeginInvokeOnMainThread方法的操作中调用locationService.StartTracking,如下面的代码所示:

public MainViewModel(ILocationTrackingService 
                     locationTrackingService, 
                     ILocationRepository locationRepository)
{
    this.locationTrackingService = locationTrackingTrackingService;
    this.locationRepository = locationRepository;

 MainThread.BeginInvokeOnMainThread(async() =>
 {
 locationTrackingService.StartTracking();
 });
}

最后,我们需要将MainViewModel注入到MainView的构造函数中,并将MainViewModel实例分配给视图的绑定上下文,通过以下步骤进行。这将允许数据绑定被处理,并且MainViewModel的属性将绑定到用户界面中的控件:

  1. MeTracker项目中,转到Views/MainView.xaml.cs文件的构造函数。

  2. MainViewModel作为构造函数的参数,并将其命名为viewModel

  3. BindingContext设置为MainViewModel的实例,如下面的代码所示:

public MainView(MainViewModel viewModel)
{
    InitializeComponent();

 BindingContext = viewModel; 

    MainThread.BeginInvokeOnMainThread(async () =>
    {
        var location = await 
        Geolocation.GetLastKnownLocationAsync();
        Map.MoveToRegion(MapSpan.FromCenterAndRadius(
        new Position(location.Latitude, location.Longitude), 
        Distance.FromKilometers(5)));
    });
}

创建一个解析器

在这个项目中,我们将使用依赖注入,我们将使用一个名为 Autofac 的库。Autofac 是一个开源的控制反转IoC)容器。我们将创建一个Resolver类,以便在本章后面将要添加到容器中的类型可以轻松地解析。为此,我们将通过以下步骤进行:

  1. MeTrackerMeTracker.AndroidMeTracker.iOS项目中从 NuGet 安装 Autofac。

  2. MeTracker项目中,在项目的根目录创建一个名为Resolver的新类。

  3. 创建一个名为containerprivate static IContainer字段。

  4. 创建一个名为Initializedstatic方法,它具有一个IContainer参数,并设置container字段的值,如下面的代码所示:

using Autofac;
using System;
using System.Collections.Generic;
using System.Text;

namespace MeTracker
{
    public class Resolver
    {
        private static IContainer container;

        public static void Initialize(IContainer container)
        {
            Resolver.container = container;
        }
    }
}

Initialize方法将在 Autofac 配置完成后调用,我们将在创建引导程序时进行配置。这个方法简单地获取作为参数的container并将其存储在static容器字段中。

现在,我们需要一个方法来访问它。创建一个名为Resolve的静态方法。这个方法将是通用的,当我们使用它时,我们将指定它的类型作为将要解析的类型。使用container字段来解析类型,如下面的代码所示:

public static T Resolve<T>()
{
     return container.Resolve<T>();
} 

Resolve<T>方法接受一个类型作为参数,并在容器中查找有关如何构造此类型的任何信息。如果有,我们就返回它。

所以,现在我们有了我们将用来解析对象类型实例的Resolver,我们需要对其进行配置。这是引导程序的工作。

创建引导程序

要配置依赖注入并初始化Resolver,我们将创建一个引导程序。我们将有一个共享的引导程序,以及其他针对每个平台的引导程序,以满足其特定的配置。我们需要它们是特定于平台的原因是,我们将在 iOS 和 Android 上有不同的ILocationTrackingService实现。要创建引导程序,我们需要按照以下步骤进行:

  1. MeTracker项目中创建一个新类,并命名为Bootstrapper

  2. 在新类中编写以下代码:

using Autofac;
using MeTracker.Repositories;
using MeTracker.ViewModels;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Xamarin.Forms;

namespace MeTracker
{
    public class Bootstrapper
    {
        protected ContainerBuilder ContainerBuilder { get; private 
        set; }

        public Bootstrapper()
        {
            Initialize();
            FinishInitialization();
        }

        protected virtual void Initialize()
        {
            ContainerBuilder = new ContainerBuilder();

            var currentAssembly = Assembly.GetExecutingAssembly();

            foreach (var type in currentAssembly.DefinedTypes.
                     Where(e => e.IsSubclassOf(typeof(Page))))
            {
                ContainerBuilder.RegisterType(type.AsType());
            }

            foreach (var type in currentAssembly.DefinedTypes.
                     Where(e => e.IsSubclassOf(typeof(ViewModel))))
            {
                ContainerBuilder.RegisterType(type.AsType());
            }

            ContainerBuilder.RegisterType<LocationRepository>
            ().As<ILocationRepository>();
        }

        private void FinishInitialization()
        {
            var container = ContainerBuilder.Build();
            Resolver.Initialize(container);
        }
    }
}

创建 iOS 引导程序

在 iOS 引导程序中,我们将有特定于 iOS 应用程序的配置。要创建 iOS 应用程序,我们需要按照以下步骤进行:

  1. 在 iOS 项目中,创建一个新类,并命名为Bootstrapper

  2. 使新类继承自MeTracker.Bootstrapper

  3. 编写以下代码:

using Autofac;
using MeTracker.iOS.Services;
using MeTracker.Services;

namespace MeTracker.iOS
{
    public class Bootstrapper : MeTracker.Bootstrapper
    {
        public static void Execute()
        {
            var instance = new Bootstrapper();
        }

        protected override void Initialize()
        {
            base.Initialize();

            ContainerBuilder.RegisterType<LocationTrackingService>()
            .As<ILocationTrackingService>().SingleInstance();
        }
    }
}
  1. 转到 iOS 项目中的AppDelegate.cs

  2. FinishedLaunching方法中的LoadApplication调用之前,调用平台特定引导程序的Init方法,如下面的代码所示:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
      global::Xamarin.Forms.Forms.Init();
      global::Xamarin.FormsMaps.Init();
      Bootstrapper.Init();

      LoadApplication(new App());

      return base.FinishedLaunching(app, options);
} 

创建 Android 引导程序

在 Android 引导程序中,我们将有特定于 Android 应用程序的配置。要在 Android 中创建引导程序,我们需要按照以下步骤进行:

  1. 在 Android 项目中,创建一个新类,并命名为Bootstrapper

  2. 使新类继承自MeTracker.Bootstrapper

  3. 编写以下代码:

using Autofac;
using MeTracker.Droid.Services;
using MeTracker.Services;

namespace MeTracker.Droid
{ 
    public class Bootstrapper : MeTracker.Bootstrapper
    {
         public static void Init()
         {
             var instance = new Bootstrapper();
         }

         protected override void Initialize()
         {
             base.Initialize();

             ContainerBuilder.RegisterType<LocationTrackingService()
             .As<ILocationTrackingService>().SingleInstance();
         }
    }
} 
  1. 进入 Android 项目中的MainActivity.cs文件。

  2. OnCreate方法中的LoadApplication调用之前,调用平台特定引导程序的Init方法,如下面的代码所示:

protected override void OnCreate(Bundle savedInstanceState)
{
     TabLayoutResource = Resource.Layout.Tabbar;
     ToolbarResource = Resource.Layout.Toolbar;

     base.OnCreate(savedInstanceState);
     Xamarin.Essentials.Platform.Init(this, savedInstanceState);

     global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
     global::Xamarin.FormsMaps.Init(this, savedInstanceState);

 Bootstrapper.Init();

     LoadApplication(new App());
} 

设置 MainPage

在我们首次启动应用程序之前的最后一步是通过以下步骤在App.xaml.cs文件中设置MainPage属性。但首先,我们可以删除我们启动项目时创建的MainPage.xaml文件和MainPage.xaml.cs文件,因为我们这里不使用它们:

  1. 删除MeTracker项目中的MainPage.xamlMainPage.xaml.cs,因为我们将把MainView设置为用户首次看到的第一个视图。

  2. 使用Resolver来创建MainView的实例。

  3. 在构造函数中将MainPage设置为MainView的实例,如下面的代码所示:

public App()
{
     InitializeComponent();
     MainPage = Resolver.Resolve<MainView>();
} 

解析器使用 Autofac 来找出我们创建MainView实例所需的所有依赖项。它查看MainView的构造函数,并决定它需要一个MainViewModel。如果MainViewModel有进一步的依赖项,那么该过程将遍历所有这些依赖项并构建我们需要的所有实例。

现在我们将能够运行该应用程序。它将显示一个以用户当前位置为中心的地图。我们现在将添加代码来使用后台位置跟踪来跟踪位置。

iOS 上的后台位置跟踪

位置跟踪的代码是我们需要为每个平台编写的。对于 iOS,我们将使用CoreLocation命名空间中的CLLocationManager

在后台启用位置更新

当我们想在 iOS 应用程序中后台执行任务时,我们需要在info.plist文件中声明我们想要做什么。以下步骤显示了我们如何做到这一点:

  1. MeTracker.iOS项目中,打开info.plist

  2. 转到 Capabilities 选项卡。

  3. 选择启用后台模式和位置更新,如下面的屏幕截图所示:

如果我们用 XML 编辑器直接在info.plist文件中打开它,我们也可以直接启用后台模式。在这种情况下,我们将添加以下 XML:

<key>UIBackgroundModes</key>
<array>
     <string>location</string>
</array>

获取使用用户位置的权限

在我们可以请求使用用户位置的权限之前,我们需要添加一个描述,说明我们将使用位置。自从 iOS 11 推出以来,我们不再允许只请求始终跟踪用户位置的权限;用户必须能够只在使用应用时允许我们跟踪他们的位置。我们将通过以下步骤向info.plist文件中添加描述:

  1. 用 XML(文本)编辑器打开MeTracker.iOS项目中的info.plist

  2. 添加键NSLocationWhenInUseUsageDescription,并附上描述。

  3. 添加键NSLocationAlwaysAndWhenInUsageDescription,并附上描述,如下面的代码所示:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We will use your location to track you</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We will use your location to track you</string>

订阅位置更新

现在我们已经为位置跟踪准备好了info.plist文件,是时候编写实际的代码来跟踪用户的位置了。如果我们不将CLLocationManager设置为不暂停位置更新,当位置数据不太可能改变时,iOS 可能会自动暂停位置更新。在这个应用程序中,我们不希望发生这种情况,因为我们希望多次保存位置,以便我们可以确定用户是否经常访问特定位置。让我们通过以下步骤来设置这个:

  1. MeTracker.iOS项目中打开LocationTrackingService

  2. CLLocationManager添加一个私有字段。

  3. StartTracking方法中创建CLLocationMananger的实例。

  4. PausesLocationUpdatesAutomatically设置为false

  5. AllowBackgroundLocationUpdates设置为true(如下所示的代码),以便即使应用在后台运行时,位置更新也会继续进行:

public void StartTracking()
{
    locationManager = new CLLocationManager
 {
 PausesLocationUpdatesAutomatically = false,
 AllowsBackgroundLocationUpdates = true }; // Add code here
}

下一步是请求用户允许跟踪他们的位置。我们将请求始终跟踪他们的位置的权限,但用户可以选择只在使用应用时允许我们跟踪他们的位置。因为用户也可以选择拒绝我们跟踪他们的位置的权限,所以在开始之前我们需要进行检查。让我们通过以下步骤来设置这个:

  1. 通过在locationManager上连接AuthorizationChanged事件来添加授权更改的事件监听器。

  2. 在事件监听器中,创建一个if语句来检查用户是否允许我们跟踪他们的位置。

  3. 调用我们最近在CLLocationManager中创建的实例的RequestAlwaysAuthorization方法。

  4. 代码应该放在// Add code here注释下,如下面的粗体所示:

public void StartTracking()
{
    locationManager = new CLLocationManager
    {
        PausesLocationUpdatesAutomatically = false,
        AllowsBackgroundLocationUpdates = true
    };

    // Add code here
 locationManager.AuthorizationChanged += (s, args) =>
 { 
 if (args.Status == CLAuthorizationStatus.Authorized)
 {
            // Next section of code goes here
 }
 };

    locationManager.RequestAlwaysAuthorization();
}

在开始跟踪用户位置之前,我们将设置我们希望从CLLocationManager接收的数据的准确性。我们还将添加一个事件处理程序来处理位置更新。让我们通过以下步骤来设置这个:

  1. DesiredAccuracy设置为CLLocation.AccurracyBestForNavigation。在后台运行应用程序时的一个限制是,DesiredAccuracy需要设置为AccurracyBestAccurracyBestForNavigation

  2. LocationsUpdated添加一个事件处理程序,然后调用StartUpdatingLocation方法。

  3. 代码应该放在// Next section goes here注释下,并且应该看起来像下面片段中的粗体代码:

   locationManager.AuthorizationChanged += (s, args) =>
    {
        if (args.Status == CLAuthorizationStatus.Authorized)
        {
            // Next section of code goes here
 locationManager.DesiredAccuracy = 
            CLLocation.AccurracyBestForNavigation;
            locationManager.LocationsUpdated += 
            async (object sender, CLLocationsUpdatedEventArgs e) =>
                {
                    // Final block of code goes here
                };

            locationManager.StartUpdatingLocation();
        }
    };

我们设置的精度越高,电池消耗就越高。如果我们只想跟踪用户去过哪里而不是一个地方有多受欢迎,我们还可以设置AllowDeferredLocationUpdatesUntil。这样,我们可以指定用户在更新位置之前必须移动特定距离。我们还可以使用timeout参数指定我们希望多久更新一次位置。跟踪用户在某个地方停留的最节能解决方案是使用CLLocationManagerStartMonitoringVisits方法。

现在,是时候处理LocationsUpdated事件了。让我们按照以下步骤进行:

  1. 添加一个名为locationRepository的私有字段,类型为ILocationRepository

  2. 添加一个构造函数,该构造函数以ILocationRepository作为参数。将参数的值设置为locationRepository字段。

  3. CLLocationsUpdatedEventArgsLocations属性上读取最新位置。

  4. 创建MeTracker.Models.Location的实例,并将最新位置的纬度和经度传递给它。

  5. 使用ILocationRepositorySave方法保存位置。

  6. 代码应放置在//最终的代码块放在这里的注释处,并且应该看起来像以下片段中的粗体代码:

locationManager.LocationsUpdated += 
    async (object sender, CLLocationsUpdatedEventArgs e) =>
    {
 var lastLocation = e.Locations.Last();
 var newLocation = new 
        Models.Location(lastLocation.Coordinate.Latitude,

        lastLocation.Coordinate.Longitude);

 await locationRepository.Save(newLocation);
    };

我们已经完成了 iOS 应用的跟踪部分。现在我们将为 Android 实现后台跟踪。之后,我们将可视化数据。

使用 Android 进行后台位置跟踪

在 Android 中进行后台更新的方式与我们在 iOS 中实现的方式非常不同。使用 Android,我们需要创建一个JobService并对其进行调度。

添加所需的权限以使用用户的位置

要在后台跟踪用户的位置,我们需要请求五个权限,如下表所示:

ACCESS_COARSE_LOCATION 获取用户的大致位置
ACCESS_FINE_LOCATION 获取用户的精确位置
ACCESS_NETWORK_STATE 因为 Android 中的位置服务使用来自网络的信息来确定用户的位置
ACCESS_WIFI_STATE 因为 Android 中的位置服务使用来自 Wi-Fi 网络的信息来确定用户的位置
RECEIVE_BOOT_COMPLETED 以便在设备重新启动后可以重新启动后台作业

权限可以从MeTracker.Android项目的属性中的 Android 清单选项卡或Properties文件夹中的AndroidManifest.xml文件中设置。当从 Android 清单选项卡进行更改时,更改也将写入AndroidMainfest.xml文件,因此无论您喜欢哪种方法都无所谓。

以下是在MeTracker.Android项目的属性中的 Android 清单选项卡中设置权限的屏幕截图:

uses-permission元素应添加到AndroidManifest.xml文件中的manifest元素中,如下面的代码所示:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> 

创建后台作业

要在后台跟踪用户的位置,我们需要通过以下步骤创建一个后台作业:

  1. 在 Android 项目中,在Services文件夹中创建一个名为LocationJobService的新类。

  2. 将类设置为public,并将Android.App.Job.JobService添加为基类。

  3. 实现OnStartJobOnStopJob的抽象方法,如下面的代码所示:

public class LocationJobService : JobService
{ 
     public override bool OnStopJob(JobParameters @params)
     {
         return true;
     }

     public override bool OnStartJob(JobParameters @params)
     {
         return true;
     } 
}

Android 应用中的所有服务都需要添加到AndroidManifest.xml文件中。我们不必手动执行此操作;相反,我们可以向类添加属性,然后该属性将在AndroidManifest.xml文件中生成。我们将使用NamePermission属性来设置所需的信息,如下面的代码所示:

 [Service(Name = "MeTracker.Droid.Services.LocationJobService",
 Permission = "android.permission.BIND_JOB_SERVICE")]
          public class LocationJobService : JobService

调度后台作业

当我们创建了一个作业,我们可以安排它。我们将从MeTracker.Android项目中的LocationTrackingService中执行此操作。要配置作业,我们将使用JobInfo.Builder类。

我们将使用SetPersisted方法来确保作业在重新启动后重新启动。这就是为什么我们之前添加了RECEIVE_BOOT_COMPLETED权限。

要安排作业,至少需要一个约束。在这种情况下,我们将使用SetOverrideDeadline。这将指定作业需要在指定的时间(以毫秒为单位)之前运行。

SetRequiresDeviceIdle代码短语可用于确保作业仅在设备未被用户使用时运行。如果我们希望确保在用户使用设备时不减慢设备速度,可以将true传递给该方法。

SetRequiresBatteryNotLow代码短语可用于指定作业在电池电量低时不运行。如果没有充分的理由在电池电量低时运行作业,我们建议始终将其设置为true。这是因为我们不希望我们的应用程序耗尽用户的电池。

因此,让我们通过以下步骤实现在Services文件夹中的 Android 项目中找到的LocationTrackingService

  1. 根据我们指定的 ID(这里我们将使用1)和组件名称(我们从应用程序上下文和 Java 类创建)创建JobInfo.Builder。组件名称用于指定哪些代码将在作业期间运行。

  2. 使用SetOverrideDeadline方法,并将1000传递给它,以使作业在创建作业后不到一秒钟就运行。

  3. 使用SetPersisted方法并传递true,以确保作业在设备重新启动后仍然持续存在。

  4. 使用SetRequiresDeviceIdle方法并传递false,以便即使用户正在使用设备,作业也会运行。

  5. 使用SetRequiresBatteryLow方法并传递true,以确保我们不会耗尽用户的电池。此方法是在 Android API 级别 26 中添加的。

  6. LocationTrackingService的代码现在应该如下所示:

using Android.App;
using Android.App.Job;
using Android.Content;
using MeTracker.Services;

namespace MeTracker.Droid.Services
{
    public class LocationTrackingService : ILocationTrackingService
    { 
        var javaClass = 
        Java.Lang.Class.FromType(typeof(LocationJobService));
        var componentName = new ComponentName(Application.Context, 
        javaClass);
        var jobBuilder = new JobInfo.Builder(1, componentName);

        jobBuilder.SetOverrideDeadline(1000);
        jobBuilder.SetPersisted(true);
        jobBuilder.SetRequiresDeviceIdle(false);
        jobBuilder.SetRequiresBatteryNotLow(true);

        var jobInfo = jobBuilder.Build();
    }
}

JobScheduler服务是一个系统服务。要获取系统服务的实例,我们将通过以下步骤使用应用程序上下文:

  1. 使用Application.Context上的GetSystemService方法来获取JobScheduler

  2. 将结果转换为JobScheduler

  3. JobScheduler类上使用Schedule方法,并传递JobInfo对象来安排作业,如下面的代码所示:

var jobScheduler =    
  (JobScheduler)Application.Context.GetSystemService
  (Context.JobSchedulerService);
  jobScheduler.Schedule(jobInfo); 

订阅位置更新

一旦我们安排了作业,我们可以编写代码来指定作业应该做什么,即跟踪用户的位置。为此,我们将使用LocationManager,这是一个SystemService。使用LocationManager,我们可以请求单个位置更新,或者我们可以订阅位置更新。在这种情况下,我们希望订阅位置更新。

我们将首先创建ILocationRepository接口的实例,用于将位置保存到 SQlite 数据库中。让我们通过以下步骤来设置这个:

  1. LocationJobService创建一个构造函数。

  2. ILocationRepository接口创建一个私有的只读字段,名称为locationRepository

  3. 在构造函数中使用Resolver来创建ILocationRepository的实例,如下面的代码所示:

private ILocationRepository locationRepository;
public LocationJobService()
{
     locationRepository = Resolver.Resolve<ILocationRepository>();
}

在订阅位置更新之前,我们将添加一个监听器。为此,我们将通过以下步骤使用Android.Locations.ILocationListener接口:

  1. Android.Locations.ILocationListener添加到LocationJobService

  2. 实现接口。

  3. 删除所有throw new NotImplementedException();的实例,该实例是在让 Visual Studio 生成接口的实现时添加的。

  4. OnLocationChanged方法中,将Android.Locations.Location对象映射到Model.Location对象。

  5. 使用LocationRepository类上的Save方法,如下所示:

public void OnLocationChanged(Android.Locations.Location location)
{
    var newLocation = new Models.Location(location.Latitude, 
    location.Longitude);
 locationRepository.Save(newLocation);
} 

创建监听器后,我们可以通过以下步骤订阅位置更新:

  1. 转到LocationJobService中的StartJob方法。

  2. 创建LocationManager类型的静态字段。

  3. 使用GetSystemService获取LocationManagerApplicationContext上。

  4. 要订阅位置更新,请使用RequestLocationUpdates方法,如下所示:

public override bool OnStartJob(JobParameters @params)
{      
     locationManager =  
     (LocationManager)ApplicationContext.GetSystemService
     (Context.LocationService);
 locationManager.RequestLocationUpdates
     (LocationManager.GpsProvider, 1000L, 0.1f, this);

     return true;
}

我们传递给RequestLocationUpdates方法的第一个参数确保我们从 GPS 获取位置。第二个确保位置更新之间至少间隔1000毫秒。第三个参数确保用户必须移动至少0.1米才能获得位置更新。最后一个指定我们应该使用哪个监听器。因为当前类实现了Android.Locations.ILocationListener接口,我们将传递this

创建热力图

为了可视化我们收集到的数据,我们将创建一个热力图。我们将在地图上添加许多点,并根据用户在特定位置停留的时间来设置它们的不同颜色。最受欢迎的地方将有温暖的颜色,而最不受欢迎的地方将有冷色。

LocationRepository添加一个GetAll方法

为了可视化数据,我们需要编写代码,以便从数据库中读取数据。让我们通过以下步骤来设置这个:

  1. MeTracker项目中,打开ILocationRepository.cs文件。

  2. 添加一个GetAll方法,使用以下代码返回Location对象的列表:

 Task<List<Location>> GetAll() ;
  1. MeTracker项目中,打开实现ILocationRepositoryLocationRepository.cs文件。

  2. 实现新的GetAll方法,并返回数据库中所有保存的位置,如下所示:

public async Task<List<Location>> GetAll()
{
      await CreateConnection();

      var locations = await connection.Table<Location>
      ().ToListAsync();

      return locations;
}

为可视化数据准备数据

在我们可以在地图上可视化数据之前,我们需要准备数据。我们将首先创建一个新的模型,用于准备好的数据。让我们通过以下步骤设置这个:

  1. MeTracker项目的Models文件夹中,创建一个新的类并命名为Point

  2. 添加LocationCountHeat的属性,如下所示:

namespace MeTracker.Models
{ 
    public class Point
    {
         public Location Location { get; set; }
         public int Count { get; set; } = 1;
         public Xamarin.Forms.Color Heat { get; set; }
    }
} 

MainViewModel将存储我们以后会找到的位置。让我们通过以下步骤添加一个用于存储Points的属性:

  1. MeTracker项目中,打开MainViewModel类。

  2. 添加一个名为pointsprivate字段,它具有List<Point>类型。

  3. 创建一个名为Points的属性,它具有List<Point>类型。

  4. get方法中,返回points字段的值。

  5. set方法中,将points字段设置为新值,并调用RaisePropertyChanged并将属性的名称作为参数。

  6. LoadData方法的末尾,将pointList变量分配给Points属性,如下所示:

private List<Models.Point> points;
public List<Models.Point> Points
{
      get => points;
      set
      {
           points = value;
           RaisePropertyChanged(nameof(Points));
      }
}

现在我们有了存储点的位置,我们必须添加代码来添加位置。我们将通过实现MainViewModel类的LoadData方法来实现这一点,并确保在位置跟踪开始后立即在主线程上调用它。

我们将首先对保存的位置进行分组,以便所有在 200 米范围内的位置将被视为一个点。我们将跟踪我们在该点内记录位置的次数,以便稍后决定地图上该点的颜色。让我们通过以下步骤设置这个:

  1. 添加一个名为 LoadData 的async方法,它返回MainViewModelTask

  2. ILocationTrackingServiceStartTracking方法调用后,从构造函数中调用LoadData方法,如下所示:

public MainViewModel(ILocationTrackingService 
                     locationTrackingService, 
                     ILocationRepository locationRepository)
{
    this.locationTrackingService = locationTrackingService;
    this.locationRepository = locationRepository;

    MainThread.BeginInvokeOnMainThread(async() => 
    {
         locationTrackingService.StartTracking();
 await LoadData();
    });
}

LoadData方法的第一步是从 SQLite 数据库中读取所有跟踪位置。当我们有了所有的位置后,我们将循环遍历它们并创建点。为了计算位置和点之间的距离,我们将使用Xamarin.Essentials.Location中的CalculateDistance方法,如下面的代码所示:

private async Task LoadData()
{ 
    var locations = await locationRepository.GetAll();
 var pointList = new List<Models.Point>();

 foreach (var location in locations)
 {
 //If no points exist, create a new one an continue to the next  
        location in the list
 if (!pointList.Any())
 {
 pointList.Add(new Models.Point() { Location = location });
 continue;
 }

 var pointFound = false;

 //try to find a point for the current location
 foreach (var point in pointList)
 {
 var distance =   
            Xamarin.Essentials.Location.CalculateDistance(
 new Xamarin.Essentials.Location(
            point.Location.Latitude, point.Location.Longitude),
 new Xamarin.Essentials.Location(location.Latitude,                             
            location.Longitude), DistanceUnits.Kilometers);

 if (distance < 0.2)
 {
 pointFound = true;
 point.Count++;
 break;
 }
 }

 //if no point is found, add a new Point to the list of points
 if (!pointFound)
 {
 pointList.Add(new Models.Point() { Location = location });
 }

        // Next section of code goes here
    }
} 

当我们有了点的列表,我们可以计算每个点的热度颜色。我们将使用颜色的色调、饱和度和亮度(HSL)表示,如下面的列表所述:

  • 色调:色调是色轮上从 0 到 360 的度数,0 是红色,240 是蓝色。因为我们希望我们最受欢迎的地方是红色(热的),我们最不受欢迎的地方是蓝色(冷的),我们将根据用户到达该点的次数计算每个点的值在 0 到 240 之间。这意味着我们只会使用比例的三分之二。

  • 饱和度:饱和度是一个百分比值:0%是灰色,而 100%是全彩。在我们的应用程序中,我们将始终使用 100%(在代码中表示为1)。

  • 亮度:亮度是光的百分比值:0%是黑色,100%是白色。我们希望它是中性的,所以我们将使用 50%(在代码中表示为0.5)。

我们需要做的第一件事是找出用户在最受欢迎和最不受欢迎的地方分别去过多少次。我们通过以下步骤找出这一点:

  1. 首先,检查点的列表是否为空。

  2. 获取点列表中Count属性的MinMax值。

  3. 计算最小值和最大值之间的差异。

  4. 代码应添加到LoadData方法底部的// 下一段代码放在这里注释处,如下面的代码所示:

private async Task LoadData()
{ 
    // The rest of the method has been commented out for brevity

    // Next section of code goes here
 if (pointList == null || !pointList.Any())
 {
 return;
 } 
 var pointMax = pointList.Select(x => x.Count).Max();
 var pointMin = pointList.Select(x => x.Count).Min();
 var diff = (float)(pointMax - pointMin);

    // Last section of code goes here
}

现在我们将能够通过以下步骤计算每个点的热度:

  1. 循环遍历所有点。

  2. 使用以下计算来计算每个点的热度。

  3. 代码应添加到LoadData()方法底部的// 最后一段代码放在这里注释处,如下面的粗体所示:

private async Task LoadData()
{ 
    // The rest of the method has been commented out for brevity

    // Next section of code goes here
  if (pointList == null || !pointList.Any())
    {
        return;
    }

    var pointMax = pointList.Select(x => x.Count).Max();
    var pointMin = pointList.Select(x => x.Count).Min();
    var diff = (float)(pointMax - pointMin);

 // Last section of code goes here
 foreach (var point in pointList)
 {
 var heat = (2f / 3f) - ((float)point.Count / diff);
 point.Heat = Color.FromHsla(heat, 1, 0.5);
 }

    Points = pointList;
}

这就是在MeTracker项目中设置位置跟踪的全部内容。现在让我们把注意力转向可视化我们得到的数据。

创建自定义渲染器

自定义渲染器是扩展 Xamarin.Forms 的强大方式。正如在第一章中提到的,Xamarin 简介,Xamarin.Forms 是使用渲染器构建的,因此对于每个 Xamarin.Forms 控件,都有一个渲染器来创建本机控件。通过覆盖现有的渲染器或创建新的渲染器,我们可以扩展和自定义 Xamarin.Forms 控件的呈现方式。我们还可以使用渲染器从头开始创建新的 Xamarin.Forms 控件。

渲染器是特定于平台的,因此当我们创建自定义渲染器时,我们必须为要更改或使用来扩展控件行为的每个平台创建一个渲染器。为了使我们的渲染器对 Xamarin.Forms 可见,我们将使用ExportRenderer程序集属性。这包含有关渲染器所用的控件以及将使用哪个渲染器的信息。

为地图创建自定义控件

为了在地图上显示热力图,我们将创建一个新的控件,我们将使用自定义渲染器。我们通过以下步骤设置这一点:

  1. MeTracker项目中,创建一个名为Controls的新文件夹。

  2. 创建一个名为CustomMap的新类。

  3. Xamarin.Forms.Maps.Map添加为新类的基类,如下面的代码所示:

using System.Collections.Generic;
using Xamarin.Forms;
using Xamarin.Forms.Maps;

namespace MeTracker.Controls
{
    public class CustomMap : Map
    {
    }
} 

如果我们想要绑定数据的属性,我们需要创建一个BindableProperty。这应该是类中的一个public static字段。我们还需要创建一个常规属性。属性的命名非常重要。BindableProperty的名称需要是{NameOfTheProperty}Property;例如,我们将在以下步骤中创建的BindableProperty的名称将是PointsProperty,因为属性的名称是Points。使用BindableProperty类上的静态Create方法创建BindableProperty。这需要至少四个参数,如下列表所示:

  • propertyName:这是属性的名称作为字符串。

  • 返回类型:这是从属性返回的类型。

  • declaringType:这是声明BindableProperty的类的类型。

  • defaultValue:如果未设置值,将返回的默认值。这是一个可选参数。如果未设置,Xamarin.Forms 将使用null作为默认值。

属性的setget方法将调用基类中的方法,从BindablePropertysetget值:

  1. MeTracker项目中,创建一个名为PointsPropertyBindableProperty,如下所示。

  2. 创建一个List<Models.Point>类型的名为Points的属性。记得将GetValue的结果转换为与属性相同的类型,因为GetValue将以类型对象返回值:

public static BindableProperty PointsProperty =   
  BindableProperty.Create(nameof(Points), 
  typeof(List<Models.Point>), typeof(CustomMap), new   
  List<Models.Point>());

public List<Models.Point> Points
{
      get => GetValue(PointsProperty) as List<Models.Point>;
      set => SetValue(PointsProperty, value);
} 

当我们创建了自定义地图控件后,我们将通过以下步骤使用它来替换MainView中的Map控件:

  1. MainView.xaml文件中,声明自定义控件的命名空间。

  2. 用我们创建的新控件替换Map控件。

  3. MainViewModelPoints属性中添加绑定,如下所示:

<ContentPage  

              x:Class="MeTracker.Views.MainView">
         <ContentPage.Content>
         **<map:CustomMap x:Name="Map" Points="{Binding Points}" />**
         </ContentPage.Content>
</ContentPage> 

创建自定义渲染器以扩展 iOS 应用中的地图

首先,我们将通过以下步骤为 iOS 创建自定义渲染器。因为我们想要扩展功能,所以我们将使用MapRenderer作为基类:

  1. MeTracker.iOS项目中创建一个名为Renderers的文件夹。

  2. 在此文件夹中创建一个新类,并命名为CustomMapRenderer

  3. MapRenderer添加为基类。

  4. 添加ExportRenderer属性,如下所示:

 using System.ComponentModel;
 using System.Linq;
 using MapKit;
 using MeTracker.Controls;
 using MeTracker.iOS.Renderers;
 using Xamarin.Forms;
 using Xamarin.Forms.Maps.iOS;
 using Xamarin.Forms.Platform.iOS; 

  [assembly:ExportRenderer(typeof(CustomMap),
  typeof(CustomMapRenderer))]
  namespace MeTracker.iOS.Renderers
{
     public class CustomMapRenderer : MapRenderer
     { 
     }
}

当我们为自定义渲染器编写控件的属性更改时,将调用OnElementPropertyChanged方法。该方法是一个虚方法,这意味着我们可以重写它。我们希望监听CustomMap控件中Points属性的任何更改。

为此,请按以下步骤操作:

  1. 覆盖OnElementPropertyChanged方法。每当元素(Xamarin.Forms 控件)中的属性值更改时,此方法将运行。

  2. 添加一个if语句来检查更改的是否是Points属性,如下所示:

protected override void OnElementPropertyChanged(object sender, 
     PropertyChangedEventArgs e)
{
     base.OnElementPropertyChanged(sender, e);

     if (e.PropertyName == CustomMap.PointsProperty.PropertyName)
     { 
          //Add code here
     }
}

为了创建热力图,我们将向地图添加圆圈作为覆盖物,每个点一个圆圈。但在此之前,我们需要添加一些代码来指定如何渲染覆盖物。让我们通过以下步骤设置这个:

  1. 创建一个mapView变量。将Control属性转换为MKMapView并将其赋值给变量。

  2. 创建一个customMap变量。将Element属性转换为CustomMap并将其赋值给变量。

  3. 使用带有MKMapViewIMKOverlay参数的表达式创建一个操作,并将其分配给map视图上的OverlayRenderer属性。

  4. overlay参数转换为MKCircle并将其分配给一个名为circle的新变量。

  5. 验证圆圈变量不为null

  6. 使用坐标从CustomMap对象的点列表中找到点对象。

  7. 创建一个新的MKCircleRenderer对象,并将圆圈变量传递给构造函数。

  8. FillColor属性设置为点的热色。使用扩展方法ToUIColor将其转换为UIColor

  9. Alpha属性设置为1.0f,以确保圆不会是透明的。

  10. 返回circleRenderer变量。

  11. 如果圆变量为null,则返回null

  12. 现在,代码应该看起来像以下片段中的粗体代码:

protected override void OnElementPropertyChanged(object sender,    
    PropertyChangedEventArgs e)
{
    base.OnElementPropertyChanged(sender, e);

    if (e.PropertyName == CustomMap.PointsProperty.PropertyName)
    { 
        var mapView = (MKMapView)Control; 
 var customMap = (CustomMap)Element;

 mapView.OverlayRenderer = (map, overlay) =>
 {
 var circle = overlay as MKCircle;

 if (circle != null)
 { 
 var point = customMap.Points.Single
 (x => x.Location.Latitude == 
                circle.Coordinate.Latitude &&
 x.Location.Longitude == 
                circle.Coordinate.Longitude);

 var circleRenderer = new MKCircleRenderer(circle)
 {
 FillColor = point.Heat.ToUIColor(),
 Alpha = 1.0f
 };

 return circleRenderer;
 }

 return null;
 };

        // Next section of code goes here
    }
}

我们已经实现了如何渲染地图的每个覆盖物。现在我们需要做的是遍历到目前为止收集到的所有点,并为每个点创建一个Overlay。让我们通过以下步骤来设置这一点:

  1. 循环遍历所有点。

  2. 使用MKCircle类上的static方法Circle创建一个圆覆盖物,如下面的代码所示。第一个参数是Circle的位置,第二个参数是Circle的半径。

  3. 使用AddOverlay方法将覆盖添加到地图上。

  4. 现在,代码应该看起来像以下片段中的粗体代码:

// Next section of code goes hereforeach (var point in customMap.Points)
{
        var overlay = MKCircle.Circle(
        new CoreLocation.CLLocationCoordinate2D
        (point.Location.Latitude, point.Location.Longitude), 100);

    mapView.AddOverlay(overlay);
}

这结束了如何扩展 iOS 上的Maps控件的部分。让我们为 Android 做同样的事情。

在 Android 应用程序中扩展地图创建一个自定义渲染器

现在,我们将为 Android 创建一个自定义渲染器。结构与我们用于 iOS 的相同。我们将以与 iOS 相同的方式使用ExportRenderer属性,并且还将MapRenderer类添加为基类。但这是特定于 Android 的MapRenderer

我们首先要为我们的CustomMap控件创建一个自定义渲染器。渲染器将继承自MapRenderer基类,以便我们可以扩展任何现有的功能。为此,请按照以下步骤进行:

  1. MeTracker.Android项目中创建一个名为Renderers的文件夹。

  2. 在此文件夹中创建一个新类,并将其命名为CustomMapRenderer

  3. 添加MapRenderer作为基类。

  4. 添加ExportRenderer属性。

  5. 添加一个以Context为参数的构造函数。将参数传递给基类的构造函数。

  6. 解决所有引用,如下面的代码所示:

using System.ComponentModel;
using Android.Content;
using Android.Gms.Maps;
using Android.Gms.Maps.Model;
using MeTracker.Controls;
using MeTracker.Droid.Renderers;
using Xamarin.Forms;
using Xamarin.Forms.Maps;
using Xamarin.Forms.Maps.Android;
using Xamarin.Forms.Platform.Android; 

[assembly: ExportRenderer(typeof(CustomMap), typeof(CustomMapRenderer))]
namespace MeTracker.Droid.Renderers
{
     public class CustomMapRenderer : MapRenderer
     {
         public CustomMapRenderer(Context context) : base(context)
         {
         } 
     }
}

要获得一个可操作的地图对象,我们需要请求它。我们通过重写所有自定义渲染器都具有的OnElementChanged方法来实现这一点。每当元素发生更改时,例如在首次解析 XAML 时设置元素或在代码中替换元素时,都会调用此方法。让我们通过以下步骤来设置这一点:

  1. 重写OnElementChanged方法。

  2. 如果ElementChangedEventArgsNewElement属性不为null,则使用Control属性上的GetMapAsync方法请求地图对象,如下面的代码所示:

protected override void OnElementChanged
                        (ElementChangedEventArgs<Map> e)
{
     base.OnElementChanged(e);

     if (e.NewElement != null)
     {
          Control.GetMapAsync(this);
     }
} 

当我们有一个地图可以操作时,虚拟的OnMapReady方法将被调用。为了添加我们自己的代码来处理这一点,我们通过以下步骤重写这个方法:

  1. 创建一个GoogleMap类型的私有字段,并将其命名为map

  2. 重写OnMapReady方法。

  3. 使用方法体中的参数为新字段赋值,如下面的代码所示:

protected override void OnMapReady(GoogleMap map)
{
     this.map = map;

     base.OnMapReady(map);
}

就像我们在 iOS 渲染器中所做的一样,我们需要处理自定义地图的Points属性的更改。为此,我们重写OnElementPropertyChanged方法,每当我们正在编写渲染器的控件上的属性发生更改时,都会调用此方法。让我们通过以下步骤来做到这一点:

  1. 重写OnElementPropertyChanged方法。每当Element(Xamarin.Forms 控件)的属性值发生更改时,此方法都会运行。

  2. 添加一个if语句来检查是否已更改了Points属性,如下面的代码所示:

protected override void OnElementPropertyChanged(object sender,    
     PropertyChangedEventArgs e)
{
     base.OnElementPropertyChanged(sender, e);

     if(e.PropertyName == CustomMap.PointsProperty.PropertyName)
     { 

     }
}

现在,我们可以添加代码来处理Points属性被设置的特定事件,通过在地图上绘制位置。为此,请按照以下步骤进行:

  1. 对于每个点,创建一个CircleOptions类的实例。

  2. 使用InvokeStrokeWidth方法将圆的描边宽度设置为0

  3. 使用InvokeFillColor方法设置圆的颜色。使用ToAndroid扩展方法将颜色转换为Android.Graphics.Color

  4. 使用InvokeRadius方法将圆的大小设置为200

  5. 使用InvokeCenter方法设置圆在地图上的位置。

  6. 使用map对象上的AddCircle方法将圆添加到地图中。

  7. 代码应该与以下片段中的粗体代码相同:

protected override void OnElementPropertyChanged(object sender, 
     PropertyChangedEventArgs e)
{
     base.OnElementPropertyChanged(sender, e);

     if(e.PropertyName == CustomMap.PointsProperty.PropertyName)
     { 
    var element = (CustomMap)Element;

        foreach (var point in element.Points)
 {
 var options = new CircleOptions();
 options.InvokeStrokeWidth(0);
 options.InvokeFillColor(point.Heat.ToAndroid());
 options.InvokeRadius(200);
 options.InvokeCenter(new 
            LatLng(point.Location.Latitude, 
            point.Location.Longitude));
            map.AddCircle(options);
 }
    }
}

在恢复应用程序时刷新地图

我们要做的最后一件事是确保在应用程序恢复时地图与最新的点保持同步。这样做的最简单方法是在App.xaml.cs文件中将MainPage属性设置为MainView的新实例,方式与构造函数中一样,如下面的代码所示:

protected override void OnResume()
{
     MainPage = Resolver.Resolve<MainView>();
} 

总结

在本章中,我们为 iOS 和 Android 构建了一个跟踪用户位置的应用程序。当我们构建应用程序时,我们学习了如何在 Xamarin.Forms 中使用地图以及如何在后台运行位置跟踪。我们还学会了如何使用自定义控件和自定义渲染器扩展 Xamarin.Forms。有了这些知识,我们可以创建在后台执行其他任务的应用程序。我们还学会了如何扩展 Xamarin.Forms 中的大多数控件。

下一个项目将是一个实时聊天应用程序。在下一章中,我们将建立一个基于 Microsoft Azure 服务的无服务器后端。一旦我们构建了应用程序,我们将在以后的章节中使用该后端。**

第五章:为多种形态因素构建天气应用程序

Xamarin.Forms 不仅可以用于创建手机应用程序;它也可以用于创建平板电脑和台式电脑应用程序。在本章中,我们将构建一个可以在所有这些平台上运行的应用程序。除了使用三种不同的形态因素外,我们还将在三种不同的操作系统上工作:iOS、Android 和 Windows。

本章将涵盖以下主题:

  • 如何在 Xamarin.Forms 中使用FlexLayout

  • 如何使用VisualStateManager

  • 如何为不同的形态因素使用不同的视图

  • 如何使用行为

技术要求

要开发这个项目,我们需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参阅 Xamarin 简介。

项目概述

iOS 和 Android 的应用程序可以在手机和平板上运行。很多时候,应用程序只是针对手机进行了优化。在本章中,我们将构建一个可以在不同形态因素上运行的应用程序,但我们不仅仅针对手机和平板——我们还将针对台式电脑。桌面版本将用于Universal Windows Platform (UWP)。

我们要构建的应用程序是一个天气应用程序,根据用户的位置显示天气预报。

入门

我们可以使用 Visual Studio 2017 for PC 或 Visual Studio for Mac 来开发这个项目。要使用 Visual Studio for PC 构建 iOS 应用程序,您必须连接 Mac。如果您根本没有 Mac,您可以选择只在这个项目的 Windows 和 Android 部分上工作。同样,如果您只有 Mac,您可以选择只在这个项目的 iOS 和 Android 部分上工作。

构建天气应用程序

是时候开始构建应用程序了。使用.NET Standard 作为代码共享策略,创建一个新的空白 Xamarin.Forms 应用程序,并选择 iOS、Android 和 Windows (UWP)作为平台。我们将项目命名为Weather

作为这个应用程序的数据源,我们将使用外部天气 API。这个项目将使用OpenWeatherMap,这是一个提供几个免费 API 的服务。您可以在openweathermap.org/api找到这个服务。在这个项目中,我们将使用名为5 day / 3 hour forecast的服务,它提供了五天的三小时间隔的天气预报。要使用OpenWeather API,我们必须创建一个帐户以获取 API 密钥。如果您不想创建 API 密钥,我们可以使用模拟数据。

为天气数据创建模型

在编写代码从外部天气服务获取数据之前,我们将创建模型,以便从服务中反序列化结果,以便我们有一个通用模型,可以用来从服务返回数据。

从服务中反序列化结果时,生成模型的最简单方法是在浏览器中或使用工具(如 Postman)调用服务,以查看 JSON 的结构。我们可以手动创建类,也可以使用一个可以从 JSON 生成 C#类的工具。可以使用的一个工具是quicktype,可以在quicktype.io/找到。

如果手动生成它们,请确保将命名空间设置为Weather.Models

正如所述,您也可以手动创建这些模型。我们将在下一节中描述如何做到这一点。

手动添加天气 API 模型

如果选择手动添加模型,则按照以下说明进行。我们将添加一个名为WeatherData.cs的单个代码文件,其中包含多个类:

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

  2. 添加一个名为WeatherData.cs的文件。

  3. 添加以下代码:

using System.Collections.Generic;

namespace Weather.Models
{
    public class Main
    {
        public double temp { get; set; }
        public double temp_min { get; set; }
        public double temp_max { get; set; }
        public double pressure { get; set; }
        public double sea_level { get; set; }
        public double grnd_level { get; set; }
        public int humidity { get; set; }
        public double temp_kf { get; set; }
    }

    public class Weather
    {
        public int id { get; set; }
        public string main { get; set; }
        public string description { get; set; }
        public string icon { get; set; }
    }

    public class Clouds
    {
        public int all { get; set; }
    }

    public class Wind
    {
        public double speed { get; set; }
        public double deg { get; set; }
    }

    public class Rain
    {
    }

    public class Sys
    {
        public string pod { get; set; }
    }

    public class List
    {
        public long dt { get; set; }
        public Main main { get; set; }
        public List<Weather> weather { get; set; }
        public Clouds clouds { get; set; }
        public Wind wind { get; set; }
        public Rain rain { get; set; }
        public Sys sys { get; set; }
        public string dt_txt { get; set; }
    }

    public class Coord
    {
        public double lat { get; set; }
        public double lon { get; set; }
    }

    public class City
    {
        public int id { get; set; }
        public string name { get; set; }
        public Coord coord { get; set; }
        public string country { get; set; }
    }

    public class WeatherData
    {
        public string cod { get; set; }
        public double message { get; set; }
        public int cnt { get; set; }
        public List<List> list { get; set; }
        public City city { get; set; }
    }
}

正如您所看到的,有相当多的类。这些直接映射到我们从服务获取的响应。

添加特定于应用程序的模型

在这一部分,我们将创建我们的应用程序将天气 API 模型转换为的模型。让我们首先通过以下步骤添加WeatherData类(除非您在前一部分手动创建了它):

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

  2. 添加一个名为WeatherData的新文件。

  3. 粘贴或编写基于 JSON 的类的代码。如果生成了除属性之外的代码,请忽略它,只使用属性。

  4. MainClass(这是 quicktype 命名的根对象)重命名为WeatherData

现在我们将根据我们感兴趣的数据创建模型。这将使代码的其余部分与数据源更松散地耦合。

添加 ForecastItem 模型

我们要添加的第一个模型是ForecastItem,它表示特定时间点的具体预报。具体步骤如下:

  1. Weather项目中,创建一个名为ForecastItem的新类。

  2. 添加以下代码:

using System;
using System.Collections.Generic;

namespace Weather.Models
{  
    public class ForecastItem
    {
        public DateTime DateTime { get; set; }
        public string TimeAsString => DateTime.ToShortTimeString();
        public double Temperature { get; set; }
        public double WindSpeed { get; set; }
        public string Description { get; set; }
        public string Icon { get; set; }
    }
}     

添加 Forecast 模型

接下来,我们将创建一个名为Forecast的模型,它将跟踪城市的单个预报。Forecast保留了多个ForeCastItem对象的列表,每个对象代表特定时间点的预报。让我们通过以下步骤设置这个:

  1. Weather项目中,创建一个名为Forecast的新类。

  2. 添加以下代码:

using System;
using System.Collections.Generic;

namespace Weather.Models
{ 
    public class Forecast
    {
        public string City { get; set; }
        public List<ForecastItem> Items { get; set; }
    }
}

现在我们已经为天气 API 和应用程序创建了模型,我们需要从天气 API 获取数据。

创建一个用于获取天气数据的服务

为了更容易地更改外部天气服务并使代码更具可测试性,我们将为服务创建一个接口。具体步骤如下:

  1. Weather项目中,创建一个新文件夹并命名为Services

  2. 创建一个新的public interface并命名为IWeatherService

  3. 添加一个根据用户位置获取数据的方法,如下所示。将方法命名为GetForecast

 public interface IWeatherService
 {
      Task<Forecast> GetForecast(double latitude, double longitude);
 }

当我们有了一个接口,我们可以通过以下步骤为其创建一个实现:

  1. Services文件夹中,创建一个名为OpenWeatherMapWeatherService的新类。

  2. 实现接口并在GetForecast方法中添加async关键字。

  3. 代码应如下所示:

using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Weather.Models; 

namespace Weather.Services
{ 
    public class OpenWeatherMapWeatherService : IWeatherService
    {
        public async Task<Forecast> GetForecast(double latitude, 
        double longitude)
        { 
        }
    }
}

在调用OpenWeatherMap API 之前,我们需要为调用天气 API 构建一个 URI。这将是一个GET调用,纬度和经度将被添加为查询参数。我们还将添加 API 密钥和我们希望得到响应的语言。让我们通过以下步骤来设置这个:

  1. WeatherProject中,打开OpenWeatherMapWeatherService类。

  2. 在以下代码片段中添加粗体标记的代码:

public class OpenWeatherMapWeatherService : IWeatherService
{
    public async Task<Forecast> GetForecast(double latitude, double 
    longitude)
    { 
        var language =  
        CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
 var apiKey = "{AddYourApiKeyHere}";
 var uri = 
        $"https://api.openweathermap.org/data/2.5/forecast?
        lat={latitude}&lon={longitude}&units=metric&lang=
        {language}&appid={apiKey}";
    }
}

为了反序列化我们从外部服务获取的 JSON,我们将使用Json.NET,这是.NET 应用程序中最流行的用于序列化和反序列化 JSON 的 NuGet 包。我们可以通过以下步骤安装它:

  1. 打开 NuGet 包管理器。

  2. 安装Json.NET包。包的 ID 是Newtonsoft.Json

为了调用Weather服务,我们将使用HttpClient类和GetStringAsync方法,具体步骤如下:

  1. 创建HttpClient类的新实例。

  2. 调用GetStringAsync并将 URL 作为参数传递。

  3. 使用JsonConvert类和Json.NETDeserializeObject方法将 JSON 字符串转换为对象。

  4. WeatherData对象映射到Forecast对象。

  5. 代码应如下代码片段中的粗体代码所示:

public async Task<Forecast> GetForecast(double latitude, double  
                                        longitude)
{ 
    var language = 
    CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
    var apiKey = "{AddYourApiKeyHere}";
    var uri = $"https://api.openweathermap.org/data/2.5/forecast?
    lat={latitude}&lon={longitude}&units=metric&lang=
    {language}&appid={apiKey}";

    var httpClient = new HttpClient();
    var result = await httpClient.GetStringAsync(uri);

    var data = JsonConvert.DeserializeObject<WeatherData>(result);

    var forecast = new Forecast()
    {
        City = data.city.name,
        Items = data.list.Select(x => new ForecastItem()
        {
            DateTime = ToDateTime(x.dt),
            Temperature = x.main.temp,
            WindSpeed = x.wind.speed,
            Description = x.weather.First().description,
            Icon = 
     $"http://openweathermap.org/img/w/{x.weather.First().icon}.png"
     }).ToList()
    };

    return forecast;
}

为了优化性能,我们可以将HttpClient用作单例,并在应用程序中的所有网络调用中重复使用它。以下信息来自 Microsoft 的文档:HttpClient**旨在实例化一次并在应用程序的整个生命周期内重复使用。为每个请求实例化 HttpClient 类将在重负载下耗尽可用的套接字数量。这将导致 SocketException 错误。这可以在以下网址找到:docs.microsoft.com/en-gb/dotnet/api/system.net.http.httpclient?view=netstandard-2.0

在上面的代码中,我们调用了一个ToDateTime方法,这是一个我们需要创建的方法。该方法将日期从 Unix 时间戳转换为DateTime对象,如下面的代码所示:

private DateTime ToDateTime(double unixTimeStamp)
{
     DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, 
     DateTimeKind.Utc);
     dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime();
     return dateTime;
}

默认情况下,HttpClient使用HttpClient的 Mono 实现(iOS 和 Android)。为了提高性能,我们可以改用特定于平台的实现。对于 iOS,使用NSUrlSession。这可以在 iOS 项目的项目设置中的 iOS Build 选项卡下设置。对于 Android,使用 Android。这可以在 Android 项目的项目设置中的 Android Options | Advanced 下设置。

配置应用程序以使用位置服务

为了能够使用位置服务,我们需要在每个平台上进行一些配置。我们将使用 Xamarin.Essentials 及其包含的类。在进行以下各节中的步骤之前,请确保已将 Xamarin.Essentials 从 NuGet 安装到解决方案中的所有项目中。

配置 iOS 应用程序以使用位置服务

要在 iOS 应用程序中使用位置服务,我们需要在info.plist文件中添加描述,以指示为什么要在应用程序中使用位置。在这个应用程序中,我们只需要在使用应用程序时获取位置,因此我们只需要为此添加描述。让我们通过以下步骤设置这一点:

  1. 使用 XML(文本)编辑器在Weather.iOS中打开info.plist

  2. 使用以下代码添加键NSLocationWhenInUseUsageDescription

<key>NSLocationWhenInUseUsageDescription</key>
<string>We are using your location to find a forecast for you</string>

配置 Android 应用程序以使用位置服务

对于 Android,我们需要设置应用程序需要以下两个权限:

  • ACCESS_COARSE_LOCATION

  • ACCESS_FINE_LOCATION

我们可以在Weather.Android项目的Properties文件夹中找到的AndroidManifest.xml文件中设置这一点,但我们也可以在项目属性下的 Android 清单选项卡中设置,如下面的屏幕截图所示:

当我们在 Android 应用程序中请求权限时,还需要在 Android 项目的MainActivity.cs文件中添加以下代码:

public override void OnRequestPermissionsResult(int requestCode, string[] permissions, 
[GeneratedEnum] Android.Content.PM.Permission[] grantResults)
{
     Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}

对于 Android,我们还需要初始化 Xamarin.Essentials。我们将在MainActivityOnCreate方法中执行此操作:

protected override void OnCreate(Bundle savedInstanceState)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(savedInstanceState);

    global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
    Xamarin.Essentials.Platform.Init(this, savedInstanceState);
    LoadApplication(new App());
}

配置 UWP 应用程序以使用位置服务

由于我们将在 UWP 应用程序中使用位置服务,因此我们需要在Weather.UWP项目的Package.appxmanifest文件的 Capabilities 下添加 Location capability,如下面的屏幕截图所示:

创建 ViewModel 类

现在我们有一个负责从外部天气源获取天气数据的服务。是时候创建一个ViewModel了。但是,首先,我们将创建一个基本视图模型,在其中可以放置可以在应用程序的所有视图模型之间共享的代码。让我们通过以下步骤设置这一点:

  1. 创建一个名为ViewModels的新文件夹。

  2. 创建一个名为ViewModel的新类。

  3. 使新类publicabstract

  4. 添加并实现INotifiedPropertyChanged接口。这是必要的,因为我们想要使用数据绑定。

  5. 添加一个Set方法,它将使得从INotifiedPropertyChanged接口中引发PropertyChanged事件更容易,如下所示。该方法将检查值是否已更改。如果已更改,它将引发事件:

public abstract class ViewModel : INotifyPropertyChanged
{
     public event PropertyChangedEventHandler PropertyChanged; 
     protected void Set<T>(ref T field, T newValue, 
     [CallerMemberName] string propertyName = null)
     {
          if (!EqualityComparer<T>.Default.Equals(field, 
          newValue))
          {
               field = newValue;
               PropertyChanged?.Invoke(this, new 
               PropertyChangedEventArgs(propertyName));
          }
     }
} 

如果要在方法体中使用CallerMemberName属性,可以使用该方法的名称或调用该方法的属性作为参数。但是,我们可以通过简单地向其传递值来始终覆盖这一点。当使用CallerMember属性时,参数的默认值是必需的。

现在我们有了一个基本的视图模型。我们可以将其用于我们现在正在创建的视图模型,以及以后将添加的所有其他视图模型。

现在是时候创建MainViewModel了,它将是我们应用程序中MainViewViewModel。我们通过以下步骤来实现这一点:

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

  2. 将抽象的ViewModel类添加为基类。

  3. 因为我们将使用构造函数注入,所以我们将添加一个带有IWeatherService接口作为参数的构造函数。

  4. 创建一个只读的private字段,我们将使用它来存储IWeatherService实例,使用以下代码:

public class MainViewModel : ViewModel
{
     private readonly IWeatherService weatherService;

     public MainViewModel(IWeatherService weatherService)
     {
          this.weatherService = weatherService;
     } 
}

MainViewModel接受任何实现IWeatherService的对象,并将对该服务的引用存储在一个字段中。我们将在下一节中添加获取天气数据的功能。

获取天气数据

现在我们将创建一个新的加载数据的方法。这将是一个三步过程。首先,我们将获取用户的位置。一旦我们拥有了这个,我们就可以获取与该位置相关的数据。最后一步是准备数据,以便视图可以使用它来为用户创建用户界面。

为了获取用户的位置,我们将使用 Xamarin.Essentials,这是我们之前安装的 NuGet 包,以及Geolocation类,该类公开了获取用户位置的方法。我们通过以下步骤来实现这一点:

  1. 创建一个名为LoadData的新方法。将其设置为异步方法,返回一个Task

  2. 使用Geolocation类上的GetLocationAsync方法获取用户的位置。

  3. GetLocationAsync调用的结果中传递纬度和经度,并将其传递给实现IWeatherService的对象上的GetForecast方法,使用以下代码:

public async Task LoadData()
{
     var location = await Geolocation.GetLocationAsync();
     var forecast = await weatherService.GetForecast
     (location.Latitude, location.Longitude); 
}

对天气数据进行分组

当我们呈现天气数据时,我们将按天分组,以便所有一天的预报都在同一个标题下。为此,我们将创建一个名为ForecastGroup的新模型。为了能够将此模型与 Xamarin.Forms 的ListView一起使用,它必须具有IEnumerable类型作为基类。让我们通过以下步骤来设置这一点:

  1. Models文件夹中创建一个名为ForecastGroup的新类。

  2. List<ForecastItem>添加为新模型的基类。

  3. 添加一个空构造函数和一个带有ForecastItem实例列表作为参数的构造函数。

  4. 添加一个Date属性。

  5. 添加一个名为DateAsString的属性,返回Date属性的短日期字符串。

  6. 添加一个名为Items的属性,返回ForecastItem实例的列表,如下所示:

using System;
using System.Collections.Generic;

namespace Weather.Models
{ 
    public class ForecastGroup : List<ForecastItem>
    {
        public ForecastGroup() { }
        public ForecastGroup(IEnumerable<ForecastItem> items)
        {
            AddRange(items);
        }

        public DateTime Date { get; set; }
        public string DateAsString => Date.ToShortDateString();
        public List<ForecastItem> Items => this;
    }
} 

完成此操作后,我们可以通过以下步骤更新MainViewModel,添加两个新属性:

  1. 为我们获取天气数据的城市名称创建一个名为City的属性。

  2. 创建一个名为Days的属性,用于包含分组的天气数据。

  3. MainViewModel类应该像以下片段中的粗体代码一样:

public class MainViewModel : ViewModel
{ 
 private string city;
 public string City
 {
 get => city;
 set => Set(ref city, value);
 }

 private ObservableCollection<ForecastGroup> days;
 public ObservableCollection<ForecastGroup> Days
 {
 get => days;
 set => Set(ref days, value);
 }

    // Rest of the class is omitted for brevity
} 

现在我们准备对数据进行实际分组。我们将在LoadData方法中执行此操作。我们将通过以下步骤循环遍历来自服务的数据,并通过以下步骤将项目添加到组中:

  1. 创建一个itemGroups变量,类型为List<ForecastGroup>

  2. 创建一个foreach循环,循环遍历forecast变量中的所有项目。

  3. 添加一个if语句,检查itemGroups属性是否为空。如果为空,向变量中添加一个新的ForecastGroup,并继续到项目列表中的下一个项目。

  4. itemGroups变量上使用SingleOrDefault方法(这是 System.Linq 中的一个扩展方法),以根据当前ForecastItem的日期获取一个组。将结果添加到一个新变量group中。

  5. 如果 group 属性为 null,则列表中没有当前日期的组。如果是这种情况,应向itemGroups变量中添加一个新的ForecastGroup,并且代码的执行将继续到forecast.Items列表中的下一个forecast项目。如果找到一个组,则应将其添加到itemGroups变量中的列表中。

  6. foreach循环之后,使用新的ObservableCollection<ForecastGroup>设置Days属性,并将itemGroups变量作为构造函数的参数。

  7. City属性设置为forecast变量的City属性。

  8. 现在,LoadData方法应该如下所示:

public async Task LoadData()
{ 
    var itemGroups = new List<ForecastGroup>();

    foreach (var item in forecast.Items)
    {
        if (!itemGroups.Any())
        {
            itemGroups.Add(new ForecastGroup(
             new List<ForecastItem>() { item }) 
             { Date = item.DateTime.Date});
             continue;
        }

        var group = itemGroups.SingleOrDefault(x => x.Date == 
        item.DateTime.Date);

        if (group == null)
        {
            itemGroups.Add(new ForecastGroup(
            new List<ForecastItem>() { item }) 
            { Date = item.DateTime.Date });

                      continue;
        }

        group.Items.Add(item);
    }

    Days = new ObservableCollection<ForecastGroup>(itemGroups);
    City = forecast.City;
}

当您想要添加多个项目时,不要在ObservableCollection上使用Add方法。最好创建一个新的ObservableCollection实例,并将集合传递给构造函数。原因是每次使用Add方法时,您都会从视图中进行绑定,并且它将触发视图的渲染。如果我们避免使用Add方法,我们将获得更好的性能。

创建一个 Resolver

我们将为Inversion of ControlIoC)创建一个辅助类。这将帮助我们基于配置的 IoC 容器创建类型。在这个项目中,我们将使用 Autofac 作为 IoC 库。让我们通过以下步骤来设置这一点:

  1. Weather项目中安装 NuGet 包 Autofac。

  2. Weather项目中创建一个名为Resolver的新类。

  3. 添加一个名为containerprivate static字段,类型为IContainer(来自 Autofac)。

  4. 添加一个名为Initializepublic static方法,带有IContainer作为参数。将参数的值设置为container字段。

  5. 添加一个名为Resolve<T>的通用的“public static”方法,它将返回指定类型的对象实例。然后,Resolve<T>方法将调用传递给它的IContainer实例上的Resolve<T>方法。

  6. 现在代码应该如下所示:

using Autofac;

namespace Weather
{ 
    public class Resolver
    {
        private static IContainer container;

        public static void Initialize(IContainer container)
        {
            Resolver.container = container;
        }

        public static T Resolve<T>()
        {
            return container.Resolve<T>();
        }
    }
} 

创建一个 bootstrapper

在这一部分,我们将创建一个Bootstrapper类,用于在应用程序启动阶段设置我们需要的常见配置。通常,每个目标平台都有一个 bootstrapper 的部分,而所有平台都有一个共享的部分。在这个项目中,我们只需要共享部分。让我们通过以下步骤来设置这一点:

  1. Weather项目中,创建一个名为Bootstrapper的新类。

  2. 添加一个名为Init的新的public static方法。

  3. 创建一个新的ContainerBuilder并将类型注册到container中。

  4. 使用ContainerBuilderBuild方法创建一个Container。创建一个名为container的变量,其中包含Container的实例。

  5. Resolver上使用Initialize方法,并将container变量作为参数传递。

  6. 现在Bootstrapper类应该如下所示:

using Autofac;
using TinyNavigationHelper.Forms;
using Weather.Services;
using Weather.ViewModels;
using Weather.Views;
using Xamarin.Forms;

namespace Weather
{ 
    public class Bootstrapper
    {
        public static void Init()
        {
            var containerBuilder = new ContainerBuilder();
            containerBuilder.RegisterType
            <OpenWeatherMapWeatherService>().As
            <IWeatherService>();
            containerBuilder.RegisterType<MainViewModel>();

            var container = containerBuilder.Build();

            Resolver.Initialize(container);
        }
    }
}

App.xaml.cs文件的构造函数中调用BootstrapperInit方法,该方法在调用InitializeComponent方法后调用。另外,将MainPage属性设置为MainView,如下所示:

public App()
{
    InitializeComponent();
    Bootstrapper.Init();
    MainPage = new NavigationPage(new MainView());
} 

基于 FlexLayout 创建一个 RepeaterView

在 Xamarin.Forms 中,如果我们想显示一组数据,可以使用ListView。使用ListView非常方便,我们稍后会在本章中使用它,但它只能垂直显示数据。在这个应用程序中,我们希望在两个方向上显示数据。在垂直方向上,我们将有天数(根据天数分组预测),而在水平方向上,我们将有特定一天内的预测。我们还希望一天内的预测在一行中没有足够的空间时换行。使用FlexLayout,我们可以在两个方向上添加项目。但是,FlexLayout是一个布局,这意味着我们无法将项目绑定到它,因此我们必须扩展其功能。我们将命名我们扩展的FlexLayoutRepeaterViewRepeaterView类将基于DataTemplate和添加到其中的项目呈现内容,就像使用了ListView一样。

按照以下步骤创建RepeaterView

  1. Weather项目中创建一个名为Controls的新文件夹。

  2. Controls文件夹中添加一个名为RepeaterView的新类。

  3. 创建一个名为Generate的空方法。我们稍后会向这个方法添加代码。

  4. 创建一个名为itemsTemplateDataTemplate类型的新私有字段。

  5. 创建一个名为ItemsTemplateDataTemplate类型的新属性。get方法将只返回itemsTemplate字段。set方法将设置itemsTemplate字段为新值。但是,它还将调用Generate方法来触发数据的重新生成。生成必须在主线程上进行,如下面的代码所示:

using System.Collections.Generic;
using Xamarin.Essentials;
using Xamarin.Forms;

namespace Weather.Controls
{ 
    public class ReperaterView : FlexLayout
    {
        private DataTemplate itemsTemplate;
        public DataTemplate ItemsTemplate
        {
            get => itemsTemplate;
            set
            {
                itemsTemplate = value;
                MainThread.BeginInvokeOnMainThread(() => 
                Generate());
            }
        } 

        public void Generate()
        {
        }
    }
}

为了绑定到属性,我们需要按照以下步骤添加BindableProperty

  1. 添加一个名为ItemsSourcePropertypublic static BindableProperty字段,返回默认值为null

  2. 添加一个名为ItemsSourcepublic属性。

  3. ItemSource添加一个 setter,设置ItemsSourceProperty的值。

  4. 添加一个ItemsSource属性的 getter,返回ItemsSourceProperty的值,如下面的代码所示:

public static BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable<object>), typeof(RepeaterView), null);

public IEnumerable<object> ItemsSource
{
    get => GetValue(ItemsSourceProperty) as IEnumerable<object>;
    set => SetValue(ItemsSourceProperty, value);
}

在前面的代码中的可绑定属性声明中,我们可以对不同的操作采取行动。我们感兴趣的是propertyChanged操作。如果我们为此属性分配一个委托,那么每当该属性的值发生变化时,它都会被调用,我们可以对该变化采取行动。在这种情况下,我们将重新生成RepeaterView的内容。我们通过以下步骤来实现这一点:

  1. 将属性更改委托(如下面的代码所示)作为BindablePropertyCreate方法的参数,以在ItemsSource属性更改时重新生成 UI。

  2. 在主线程上重新生成 UI 之前,检查DateTemplate是否不为null,如下面的代码所示:

public static BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable<object>), typeof(RepeaterView), null, 
             propertyChanged: (bindable, oldValue, newValue) => {

 var repeater = (RepeaterView)bindable;

 if(repeater.ItemsTemplate == null)
 {
 return;
 }

 MainThread.BeginInvokeOnMainThread(() => 
                     repeater.Generate());
                 }); 

RepeaterView的最后一步是在Generate方法中生成内容。

让我们通过以下步骤来实现Generate方法:

  1. 清除所有子控件,使用Children.Clear();

  2. 验证ItemSource不为null。如果为null,则返回空。

  3. 循环遍历所有项目,并从DataTemplate生成内容。将当前项目设置为BindingContext并将其添加为FlexLayout的子项,如下面的代码所示:

private void Generate()
{
    Children.Clear();

    if(ItemsSource == null)
    {
        return;
    }

    foreach(var item in ItemsSource)
    {
        var view = itemsTemplate.CreateContent() as View;

        if(view == null)
        {
            return;
        }

        view.BindingContext = item;

        Children.Add(view);
    }
} 

为平板电脑和台式电脑创建视图

下一步是创建应用程序在平板电脑或台式电脑上运行时将使用的视图。让我们通过以下步骤设置这一点:

  1. Weather项目中创建一个名为Views的新文件夹。

  2. 使用 XAML 创建一个名为MainView的新内容页。

  3. 在视图的构造函数中使用ResolverBindingContext设置为MainViewModel,如下面的代码所示:

public MainView ()
{
    InitializeComponent ();
    BindingContext = Resolver.Resolve<MainViewModel>();
} 

通过重写OnAppearing方法在主线程上调用LoadData方法来触发MainViewModel中的LoadData方法。我们需要确保调用被调度到 UI 线程,因为它将直接与用户界面交互。

要做到这一点,请按照以下步骤进行:

  1. Weather项目中,打开MainView.xaml.cs文件。

  2. 创建OnAppearing方法的重写。

  3. 在以下片段中加粗显示的代码:

protected override void OnAppearing()
{
    base.OnAppearing();

 if (BindingContext is MainViewModel viewModel)
 {
 MainThread.BeginInvokeOnMainThread(async () =>
 {
 await viewModel.LoadData();
 });
 }
} 

在 XAML 中,通过以下步骤将ContentPageTitle属性绑定到ViewModel中的City属性:

  1. Weather项目中,打开MainView.xaml文件。

  2. 在以下代码片段中加粗显示的地方将Title绑定到ContentPage元素。

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:controls="clr-namespace:Weather.Controls" 
    x:Class="Weather.Views.MainView" 
    Title="{Binding City}">

使用 RepeaterView

要将自定义控件添加到视图中,我们需要将命名空间导入视图。如果视图在另一个程序集中,我们还需要指定程序集,但在这种情况下,视图和控件都在同一个命名空间中,如以下代码所示:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
             xmlns:controls="clr-namespace:Weather.Controls"
             x:Class="Weather.Views.MainView" 

按照以下步骤构建视图:

  1. Grid添加为页面的根视图。

  2. ScrollView添加到Grid。如果内容高于页面的高度,我们需要这样做才能滚动。

  3. RepeaterView添加到ScrollView中,并将方向设置为Column,以便内容以垂直方向排列。

  4. MainViewModel中为Days属性添加绑定。

  5. DataTemplate设置为ItemsTemplate的内容,如以下代码所示:

<Grid>
    <ScrollView BackgroundColor="Transparent">
        <controls:RepeaterView ItemsSource="{Binding Days}"  
                 Direction="Column">
            <controls:RepeaterView.ItemsTemplate>
                <DataTemplate>
                  <!--Content will be added here -->
                </DataTemplate>
            </controls:RepeaterView.ItemsTemplate>
        </controls:RepeaterView>
    </ScrollView>
</Grid>

每个项目的内容将是带有日期的页眉和一行预测的水平RepeaterView。通过以下步骤设置这一点:

  1. Weather项目中,打开MainView.xaml文件。

  2. 添加StackLayout,以便将要添加到其中的子元素以垂直方向放置。

  3. ContentView添加到StackLayout中,将Padding设置为10,将BackgroundColor设置为#9F5010。这将是页眉。我们需要ContentView的原因是我们希望文本周围有填充。

  4. Label添加到ContentView,将TextColor设置为White,将FontAttributes设置为Bold

  5. LabelText属性添加DateAsString的绑定。

  6. 代码应该放在<!-- Content will be added here -->注释处,并且应该如以下代码所示:

<StackLayout>
    <ContentView Padding="10" BackgroundColor="#9F5010">
        <Label Text="{Binding DateAsString}" TextColor="White" 
         FontAttributes="Bold" />
    </ContentView> 
</StackLayout>

现在我们在用户界面中有了日期,我们需要通过以下步骤添加一个将在MainViewModel中的Items中重复的RepeaterViewRepeaterView是我们之前创建的从FlexLayout继承的控件:

  1. </ContentView>标记之后但在</StackLayout>标记之前添加一个RepeaterView

  2. JustifyContent设置为Start,以便从左侧添加Items而不是在可用空间上分布它们。

  3. AlignItems设置为Start,以将RepeaterView基于的FlexLayout中的每个项目的内容设置为左侧。

 <controls:RepeaterView ItemsSource="{Binding Items}" Wrap="Wrap"  
  JustifyContent="Start" AlignItems="Start"> 

定义RepeaterView后,我们需要提供一个ItemsTemplate,定义列表中每个项目的呈现方式。继续通过以下步骤直接在刚刚添加的<controls:RepeaterView>标签下添加 XAML:

  1. ItemsTemplate属性设置为DataTemplate

  2. 按照以下代码所示填充DataTemplate中的元素:

如果我们想要对绑定添加格式,我们可以使用StringFormat。在这种情况下,我们希望在温度后添加度符号。我们可以通过使用{Binding Temperature, StringFormat='{0}° C'}来实现这一点。通过绑定的StringFormat属性,我们可以使用与在 C#中执行相同的参数来格式化数据。这与在 C#中执行string.Format("{0}° C", Temperature)相同。我们也可以用它来格式化日期,例如{Binding Date, StringFormat='yyyy'}。在 C#中,这看起来像Date.ToString("yyyy")

<controls:RepeaterView.ItemsTemplate>
    <DataTemplate>
        <StackLayout Margin="10" Padding="20" WidthRequest="150" 
             BackgroundColor="#99FFFFFF">
            <Label FontSize="16" FontAttributes="Bold" Text="{Binding 
              TimeAsString}" HorizontalOptions="Center" />
            <Image WidthRequest="100" HeightRequest="100" 
              Aspect="AspectFit" HorizontalOptions="Center" Source=" 
              {Binding Icon}" />
            <Label FontSize="14" FontAttributes="Bold" Text="{Binding 
              Temperature, StringFormat='{0}° C'}"  
              HorizontalOptions="Center" /> 
            <Label FontSize="14" FontAttributes="Bold" Text="{Binding 
              Description}" HorizontalOptions="Center" />
        </StackLayout>
    </DataTemplate>
</controls:RepeaterView.ItemsTemplate>

作为ImageAspect属性的值,“AspectFill”短语表示整个图像始终可见,而且不会改变方面。 AspectFit短语也会保持图像的方面,但可以对图像进行缩放和裁剪以填充整个Image元素。 Aspect可以设置的最后一个值,Fill,表示图像可以被拉伸或压缩以匹配Image视图,而不必确保保持方面。

添加一个工具栏项以刷新天气数据

为了能够在不重新启动应用程序的情况下刷新数据,我们将在工具栏中添加一个刷新按钮。MainViewModel负责处理我们想要执行的任何逻辑,并且我们必须将任何操作公开为可以绑定的ICommand

让我们首先通过以下步骤在MainViewModel上创建Refresh命令属性:

  1. 在“天气”项目中,打开MainViewModel类。

  2. 添加一个名为RefreshICommand属性和返回新Commandget方法

  3. 将一个表达式作为Command的构造函数中的操作,调用LoadData方法,如下所示:

public ICommand Refresh => new Command(async() => 
{
    await LoadData();
}); 

现在我们已经定义了Command,我们需要将其绑定到用户界面,以便当用户单击工具栏按钮时,将执行该操作。

为此,请按照以下步骤操作:

  1. 在“天气”应用程序中,打开MainView.xaml文件。

  2. 将新的ToolbarItem添加到ContentPageToolbarItems属性中,将Text属性设置为Refresh,并将Icon属性设置为refresh.png(可以从 GitHub 下载图标;请参阅github.com/PacktPublishing/Xamarin.Forms-Projects/tree/master/Chapter-5)。

  3. Command属性绑定到MainViewModel中的Refresh属性,如下所示:

<ContentPage.ToolbarItems>
    <ToolbarItem Icon="refresh.png" Text="Refresh" Command="{Binding 
     Refresh}" />
</ContentPage.ToolbarItems> 

刷新数据到此结束。现在我们需要一种数据加载的指示器。

添加加载指示器

当我们刷新数据时,我们希望显示一个加载指示器,以便用户知道正在发生某事。为此,我们将添加ActivityIndicator,这是 Xamarin.Forms 中称呼此控件的名称。让我们通过以下步骤设置这一点:

  1. 在“天气”项目中,打开MainViewModel类。

  2. MainViewModel添加名为IsRefreshing的布尔属性。

  3. LoadData方法的开头将IsRefreshing属性设置为true

  4. LoadData方法的末尾,将IsRefreshing属性设置为false,如下所示:

private bool isRefreshing;
public bool IsRefreshing
{
    get => isRefreshing;
    set => Set(ref isRefreshing, value);
} 

public async Task LoadData()
{
    IsRefreshing = true; 
    .... // The rest of the code is omitted for brevity
    IsRefreshing = false;
}

现在我们已经在MainViewModel中添加了一些代码,我们需要将IsRefreshing属性绑定到用户界面元素,当IsRefreshing属性为true时将显示该元素,如下所示:

  1. Grid的最后一个元素ScrollView之后添加一个Frame

  2. IsVisible属性绑定到我们在MainViewModel中创建的IsRefreshing方法。

  3. HeightRequestWidthRequest设置为100

  4. VerticalOptionsHorizontalOptions设置为Center,以便Frame位于视图的中间。

  5. BackgroundColor设置为“#99000000”以将背景设置为略带透明的白色。

  6. 按照以下代码将ActivityIndicator添加到Frame中,其中Color设置为BlackIsRunning设置为True

 <Frame IsVisible="{Binding IsRefreshing}" 
      BackgroundColor="#99FFFFFF" 
      WidthRequest="100" HeightRequest="100" 
      VerticalOptions="Center" 
      HorizontalOptions="Center">
      <ActivityIndicator Color="Black" IsRunning="True" />
</Frame> 

这将创建一个旋转器,当数据加载时将可见,这是创建任何用户界面时的一个非常好的实践。现在我们将添加一个背景图片,使应用程序看起来更加美观。

设置背景图片

此视图的最后一件事是添加背景图片。我们在此示例中使用的图像是通过 Google 搜索免费使用的图像而获得的。让我们通过以下步骤设置这一点:

  1. 在“天气”项目中,打开MainView.xaml文件。

  2. ScrollView包装在Grid中。如果我们想要将元素分层,使用Grid是很好的。

  3. ScrollViewBackground属性设置为Transparent

  4. Grid中添加一个Image元素,将UriImageSource作为Source属性的值。

  5. CachingEnabled属性设置为true,将CacheValidity设置为5。这意味着图像将在五天内被缓存。

  6. 现在 XAML 应该如下所示:

<ContentPage 

             x:Class="Weather.Views.MainView" Title="{Binding 
                                                       City}">
    <ContentPage.ToolbarItems>
        <ToolbarItem Icon="refresh.png" Text="Refresh" Command="
        {Binding Refresh}" />
    </ContentPage.ToolbarItems>

 <Grid>
 <Image Aspect="AspectFill">
 <Image.Source>
 <UriImageSource 
           Uri="https://upload.wikimedia.org/wikipedia/commons/7/79/
           Solnedg%C3%A5ng_%C3%B6ver_Laholmsbukten_augusti_2011.jpg"            
           CachingEnabled="true" CacheValidity="1" />
 </Image.Source> </Image>
    <ScrollView BackgroundColor="Transparent"> 
        <!-- The rest of the code is omitted for brevity -->

我们也可以直接在Source属性中设置 URL,使用<Image Source="https://ourgreatimage.url" />。但是,如果我们这样做,就无法为图像指定缓存。

为手机创建视图

在平板电脑和台式电脑上构建内容在许多方面非常相似。然而,在手机上,我们在可以做的事情上受到了更大的限制。因此,在本节中,我们将通过以下步骤为手机上使用此应用程序创建一个特定的视图:

  1. Views文件夹中创建一个基于 XAML 的新内容页。

  2. 将新视图命名为MainView_Phone

  3. 在视图的构造函数中使用ResolverBindingContext设置为MainViewModel,如下所示:

public MainView_Phone ()
{
    InitializeComponent ();
    BindingContext = Resolver.Resolve<MainViewModel>();
} 

通过重写OnAppearing方法在主线程上调用MainViewModel中的LoadData方法来触发LoadData方法。通过以下步骤来实现这一点:

  1. Weather项目中,打开MainView_Phone.xaml.cs文件。

  2. 添加OnAppearing方法的重写,如下所示:

protected override void OnAppearing()
{
    base.OnAppearing();

    if (BindingContext is MainViewModel viewModel)
    {
        MainThread.BeginInvokeOnMainThread(async () =>
        {
            await viewModel.LoadData();
        });
    }
} 

在 XAML 中,将ContentPageTitle属性的绑定添加到ViewModel中的City属性,如下所示:

  1. Weather项目中,打开MainView_Phone.xaml文件。

  2. 添加一个绑定到MainViewModelCity属性的Title属性,如下所示:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:controls="clr-namespace:Weather.Controls" 
    x:Class="Weather.Views.MainView_Phone" 
    Title="{Binding City}">

使用分组的 ListView

我们可以在手机视图中使用RepeaterView,但是因为我们希望用户体验尽可能好,所以我们将使用ListView。为了获得每天的标题,我们将在ListView中使用分组。对于RepeaterView,我们有ScrollView,但对于ListView,我们不需要,因为ListView默认可以处理滚动。

让我们继续通过以下步骤为手机视图创建用户界面:

  1. Weather项目中,打开MainView_Phone.xaml文件。

  2. ListView添加到页面的根部。

  3. ListViewItemSource属性设置MainViewModel中的Days属性的绑定。

  4. IsGroupingEnabled设置为True,以在ListView中启用分组。

  5. HasUnevenRows设置为True,这样ListView中每个单元格的高度将为每个项目计算。

  6. CachingStrategy设置为RecycleElement,以重用不在屏幕上的单元格。

  7. BackgroundColor设置为Transparent,如下所示:

<ListView ItemsSource="{Binding Days}" IsGroupingEnabled="True" 
          HasUnevenRows="True" CachingStrategy="RecycleElement" 
          BackgroundColor="Transparent">
</ListView>

CachingStrategy设置为RecycleElement,以从ListView中获得更好的性能。这意味着它将重用不显示在屏幕上的单元格,因此它将使用更少的内存,如果ListView中有许多项目,我们将获得更流畅的滚动体验。

为了格式化每个标题的外观,我们将通过以下步骤创建一个DataTemplate

  1. DataTemplate添加到ListViewGroupHeaderTemplate属性中。

  2. ViewCell添加到DataTemplate中。

  3. 将行的内容添加到ViewCell中,如下所示:

<ListView ItemsSource="{Binding Days}" IsGroupingEnabled="True" 
                   HasUnevenRows="True" 
                   CachingStrategy="RecycleElement" 
                   BackgroundColor="Transparent">
       <ListView.GroupHeaderTemplate>
         <DataTemplate>
             <ViewCell>
                 <ContentView Padding="15,5"  
                  BackgroundColor="#9F5010">
              <Label FontAttributes="Bold" TextColor="White"  
              Text="{Binding DateAsString}"   
              VerticalOptions="Center"/>
                  </ContentView>
             </ViewCell>
         </DataTemplate>
    </ListView.GroupHeaderTemplate> 
</ListView>

为了格式化每个预测的外观,我们将创建一个DataTemplate,就像我们对分组标题所做的那样。让我们通过以下步骤来设置这个:

  1. DataTemplate添加到ListViewItemTemplate属性中。

  2. ViewCell添加到DataTemplate中。

  3. ViewCell中,添加一个包含四列的Grid。使用ColumnDefinition属性来指定列的宽度。第二列应为50,其他三列将共享其余的空间。我们将通过将Width设置为*来实现这一点。

  4. 添加内容到Grid,如下面的代码所示:

<ListView.ItemTemplate>
    <DataTemplate>
        <ViewCell>
            <Grid Padding="15,10" ColumnSpacing="10" 
                BackgroundColor="#99FFFFFF">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="50" />
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Label FontAttributes="Bold" Text="{Binding 
                  TimeAsString}" VerticalOptions="Center" />
                <Image Grid.Column="1" HeightRequest="50" 
                  WidthRequest="50" Source="{Binding Icon}"   
                  Aspect="AspectFit" VerticalOptions="Center" />
                <Label Grid.Column="2" Text="{Binding Temperature, 
                StringFormat='{0}°  C'}" VerticalOptions="Center" />
                <Label Grid.Column="3" Text="{Binding Description}" 
                 VerticalOptions="Center" />
            </Grid>
        </ViewCell>
    </DataTemplate>
</ListView.ItemTemplate> 

添加下拉刷新功能

对于视图的平板电脑和台式机版本,我们在工具栏中添加了一个按钮来刷新天气预报。然而,在手机版本的视图中,我们将添加下拉刷新,这是一种常见的刷新数据列表内容的方式。Xamarin.Forms 中的ListView内置支持下拉刷新。让我们通过以下步骤来设置这个功能:

  1. 转到MainView_Phone.xaml

  2. IsPullToRefreshEnabled属性设置为True,以启用ListView的下拉刷新。

  3. MainViewModel中的Refresh属性绑定到ListViewRefreshCommand属性,以在用户执行下拉刷新手势时触发刷新。

  4. 为了在刷新进行中显示加载图标,将MainViewModel中的IsRefreshing属性绑定到ListViewIsRefreshing属性。当我们设置这个属性时,当初始加载正在运行时,我们也会得到一个加载指示器,如下面的代码所示:

<ListView ItemsSource="{Binding Days}" IsGroupingEnabled="True" 
           HasUnevenRows="True" CachingStrategy="RecycleElement" 
           BackgroundColor="Transparent" 
 IsPullToRefreshEnabled="True" 
           RefreshCommand="{Binding Refresh}" 
 IsRefreshing="{Binding  
           IsRefreshing}"> 

根据形态因素导航到不同的视图

现在我们有两个不同的视图,应该在应用程序的同一个位置加载。如果应用程序在平板电脑或台式机上运行,应该加载MainView,如果应用程序在手机上运行,应该加载MainView_Phone

Xamarin.Forms 中的Device类有一个静态的Idiom属性,我们可以使用它来检查应用程序运行在哪种形态因素上。Idiom的值可以是PhoneTableDesktopWatchTV。因为我们在这个应用程序中只有一个视图,所以当我们在App.xaml.cs中设置MainPage时,我们可以使用if语句来检查Idiom的值。然而,相反地,我们将构建一个解决方案,也可以用于更大的应用程序。

一个解决方案是构建一个导航服务,我们可以使用它根据键导航到不同的视图。哪个视图将根据启动应用程序进行配置。通过这个解决方案,我们可以在不同类型的设备上为相同的键配置不同的视图。我们可以用于此目的的开源导航服务是TinyNavigationHelper,可以在github.com/TinyStuff/TinyNavigationHelper找到,由本书的作者创建。

还有一个名为TinyMvvm的 MVVM 库,它包含TinyNavigationHelper作为依赖项。TinyMvvm库是一个包含辅助类的库,可以让您在 Xamarin.Forms 应用程序中更快地开始使用 MVVM。我们创建了TinyMvvm,因为我们希望避免一遍又一遍地编写相同的代码。您可以在github.com/TinyStuff/TinyMvvm上阅读更多信息。

按照以下步骤将TinyNavigationHelper添加到应用程序中:

  1. Weather项目中安装TinyNavigationHelper.Forms NuGet 包。

  2. 转到Bootstrapper.cs

  3. Execute方法的开头,创建一个FormsNavigationHelper并将当前应用程序传递给构造函数。

  4. IdiomPhone时添加一个if语句来检查。如果是这样,MainView_Phone视图应该被注册为MainView键。

  5. 添加一个else语句,为MainView注册MainView键。

  6. Bootstrapper类现在应该如下面的代码所示,新代码用粗体标记出来:

public class Bootstrapper
{
    public static void Init()
    {
 var navigation = new 
        FormsNavigationHelper(Application.Current);

 if (Device.Idiom == TargetIdiom.Phone)
 {
 navigation.RegisterView("MainView",  
            typeof(MainView_Phone));
 }
 else
 {
 navigation.RegisterView("MainView", typeof(MainView));
 }

        var containerBuilder = new ContainerBuilder();
        containerBuilder.RegisterType<OpenWeatherMapWeatherService>
        ().As<IWeatherService>();
        containerBuilder.RegisterType<MainViewModel>();

        var container = containerBuilder.Build();

        Resolver.Initialize(container);
    }
}

现在,我们可以通过以下步骤在App类的构造函数中使用NavigationHelper类来设置应用程序的根视图:

  1. Weather应用程序中,打开App.xaml.cs文件。

  2. 找到App类的构造函数。

  3. 删除MainPage属性的赋值。

  4. 添加代码以通过NavigationHelper设置根视图。

  5. 构造函数现在应该看起来像以下片段中的粗体代码:

public App()
{
    InitializeComponent();
    Bootstrapper.Execute();
 NavigationHelper.Current.SetRootView("MainView", true);
} 

如果我们想在不同的操作系统上加载不同的视图,我们可以使用 Xamarin.Forms 的Device类上的静态RuntimePlatform方法,例如if(Device.RuntimePlatform == Device.iOS)

使用 VisualStateManager 处理状态

VisualStateManager在 Xamarin.Forms 3.0 中引入。这是一种从代码中对 UI 进行更改的方法。我们可以定义状态,并为选定的属性设置值,以应用于特定状态。VisualStateManager在我们想要在具有不同屏幕分辨率的设备上使用相同视图的情况下非常有用。它最初是在 UWP 中引入的,以便更容易地为多个平台创建 Windows 10 应用程序,因为 Windows 10 可以在 Windows Phone 以及台式机和平板电脑上运行(操作系统被称为 Windows 10 Mobile)。然而,Windows Phone 现在已经被淘汰。对于我们作为 Xamarin.Forms 开发人员来说,VisualStateManager非常有趣,特别是当 iOS 和 Android 都可以在手机和平板电脑上运行时。

在这个项目中,我们将使用它在平板电脑或台式机上以横向模式运行应用程序时使预报项目变大。我们还将使天气图标变大。让我们通过以下步骤来设置这个:

  1. Weather项目中,打开MainView.xaml文件。

  2. 在第一个RepeaterViewDataTemplate中,在第一个StackLayout中插入一个VisualStateManager.VisualStateGroups元素:

<StackLayout Margin="10" Padding="20" WidthRequest="150"  
    BackgroundColor="#99FFFFFF">
    <VisualStateManager.VisualStateGroups>
 <VisualStateGroup> 
 </VisualStateGroup>
 </VisualStateManager.VisualStateGroups> 
</StackLayout>

VisualStateGroup添加两个状态,我们将按照以下步骤进行:

  1. VisualStateGroup添加一个名为Portrait的新VisualState

  2. VisualState中创建一个 setter,并将WidthRequest设置为150

  3. VisualStateGroup中创建另一个名为LandscapeVisualState

  4. VisualState中创建一个 setter,并将WidthRequest设置为200,如下所示:

 <VisualStateGroup>
     <VisualState Name="Portrait">
 <VisualState.Setters>
 <Setter Property="WidthRequest" Value="150" />
 </VisualState.Setters>
 </VisualState>
 <VisualState Name="Landscape">
 <VisualState.Setters>
 <Setter Property="WidthRequest" Value="200" />
 </VisualState.Setters>
 </VisualState>
</VisualStateGroup> 

当项目本身变大时,我们还希望预报项目中的图标变大。为此,我们将再次使用VisualStateManager。让我们通过以下步骤来设置这个:

  1. 在第二个RepeaterViewDataTemplate中的Image元素中插入一个VisualStateManager.VisualStateGroups元素。

  2. PortraitLandscape添加VisualState

  3. 向状态添加 setter,设置WidthRequestHeightRequest。在Portrait状态中,值应为100,在Landscape状态中,值应为150,如下所示:

<Image WidthRequest="100" HeightRequest="100" Aspect="AspectFit" HorizontalOptions="Center" Source="{Binding Icon}">
    <VisualStateManager.VisualStateGroups>
 <VisualStateGroup>
 <VisualState Name="Portrait">
 <VisualState.Setters>
 <Setter Property="WidthRequest" Value="100" />
 <Setter Property="HeightRequest" Value="100" />
 </VisualState.Setters>
 </VisualState>
 <VisualState Name="Landscape">
 <VisualState.Setters>
 <Setter Property="WidthRequest" Value="150" />
 <Setter Property="HeightRequest" Value="150" />
 </VisualState.Setters>
 </VisualState>
 </VisualStateGroup>
 </VisualStateManager.VisualStateGroups>
</Image> 

创建一个用于设置状态更改的行为

使用Behavior,我们可以为控件添加功能,而无需对它们进行子类化。使用行为,我们还可以创建比对控件进行子类化更可重用的代码。我们创建的Behavior越具体,它就越可重用。例如,从Behavior<View>继承的Behavior可以用于所有控件,但从Button继承的Behavior只能用于按钮。因此,我们总是希望使用更少特定基类创建行为。

当我们创建一个Behavior时,我们需要重写两个方法:OnAttachedOnDetachingFrom。如果我们在OnAttached方法中添加了事件监听器,那么在OnDeattached方法中将其移除是非常重要的。这将使应用程序使用更少的内存。在OnAppearing方法运行之前,将值设置回它们之前的值也是非常重要的;否则,我们可能会看到一些奇怪的行为,特别是如果行为在重用单元的ListView中。

在这个应用程序中,我们将为RepeaterView创建一个Behavior。这是因为我们无法从代码后台设置RepeaterView中项目的状态。我们本可以在RepeaterView中添加代码来检查应用程序是在纵向还是横向运行,但如果我们使用Behavior,我们可以将该代码与RepeaterView分离,使其更具可重用性。相反,我们将在RepeaterView中添加一个Property string,它将设置RepeaterView及其中所有子项的状态。让我们通过以下步骤来设置这一点:

  1. Weather项目中,打开RepeaterView.cs文件。

  2. 创建一个名为visualState的新private string字段。

  3. 创建一个名为VisualState的新string属性。

  4. 创建一个使用表达式返回visualState的 getter。

  5. 在 setter 中,设置RepeaterView及所有子项的状态,如下所示:

private string visualState;
public string VisualState
{
    get => visualState;
    set 
    {
        visualState = value;

        foreach(var child in Children)
        {
            VisualStateManager.GoToState(child, visualState);
        }

        VisualStateManager.GoToState(this, visualState);
     }
} 

这将遍历每个child控件并设置视觉状态。现在让我们按照以下步骤创建将触发状态更改的行为:

  1. Weather项目中,创建一个名为Behaviors的新文件夹。

  2. 创建一个名为RepeaterViewBehavior的新类。

  3. Behavior<RepeaterView>作为基类添加。

  4. 创建一个名为viewprivate类型为RepeaterView的字段。

  5. 代码应如下所示:

using System;
using Weather.Controls;
using Xamarin.Essentials;
using Xamarin.Forms;

namespace Weather.Behaviors
{ 
    public class RepeaterViewBehavior : Behavior<RepeaterView>
    {
        private RepeaterView view;
    }
}

RepeaterViewBehavior是一个从Behavior<RepeaterView>基类继承的类。这将使我们能够重写一些虚拟方法,当我们将行为附加和分离到RepeaterView时将被调用。

但首先,我们需要通过以下步骤创建一个处理状态变化的方法:

  1. Weather项目中,打开RepeaterViewBehavior.cs文件。

  2. 创建一个名为UpdateStateprivate方法。

  3. MainThread上运行代码,以检查应用程序是在纵向还是横向模式下运行。

  4. 创建一个名为page的变量,并将其值设置为Application.Current.MainPage

  5. 检查Width是否大于Height。如果是,则将视图变量的VisualState属性设置为Landscape。如果不是,则将视图变量的VisualState属性设置为Portrait,如下所示:

private void UpdateState()
{
    MainThread.BeginInvokeOnMainThread(() =>
    {
        var page = Application.Current.MainPage;

        if (page.Width > page.Height)
        {
            view.VisualState = "Landscape";
            return;
        }

        view.VisualState = "Portrait";
    });
} 

现在添加了UpdateState方法。现在我们需要重写OnAttachedTo方法,当行为添加到RepeaterView时将被调用。当行为添加到RepeaterView时,我们希望通过调用此方法来更新状态,并且还要连接到MainPageSizeChanged事件,以便在大小更改时再次更新状态。

让我们通过以下步骤设置这一点:

  1. Weather项目中,打开RepeaterViewBehavior.cs文件。

  2. 重写基类中的OnAttachedTo方法。

  3. view属性设置为OnAttachedTo方法的参数。

  4. Application.Current.MainPage.SizeChanged添加事件监听器。在事件监听器中,调用UpdateState方法,如下所示:

protected override void OnAttachedTo(RepeaterView view)
{
    this.view = view;

    base.OnAttachedTo(view);

    UpdateState();

    Application.Current.MainPage.SizeChanged += 
    MainPage_SizeChanged;
} 

    void MainPage_SizeChanged(object sender, EventArgs e)
{
    UpdateState();
} 

当我们从控件中移除行为时,非常重要的是还要移除任何事件处理程序,以避免内存泄漏,并在最坏的情况下,导致应用程序崩溃。让我们通过以下步骤来做到这一点:

  1. Weather项目中,打开RepeaterViewBehavior.cs文件。

  2. 重写基类中的OnDetachingFrom

  3. Application.Current.MainPage.SizeChanged中删除事件监听器。

  4. view字段设置为null,如下所示:

protected override void OnDetachingFrom(RepeaterView view)
{
    base.OnDetachingFrom(view);

    Application.Current.MainPage.SizeChanged -= 
    MainPage_SizeChanged;
    this.view = null;
}

按照以下步骤将behavior添加到视图中:

  1. Weather项目中,打开MainView.xaml文件。

  2. 导入Weather.Behaviors命名空间,如下所示:

 <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
              xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
              xmlns:controls="clr-namespace:Weather.Controls" 
 xmlns:behaviors="clr-                          
 namespace:Weather.Behaviors"
              x:Class="Weather.Views.MainView" Title="{Binding City}"> 

我们要做的最后一件事是将RepeaterViewBehavior添加到第二个RepeaterView中,如下所示:

 <controls:RepeaterView ItemsSource="{Binding Items}" Wrap="Wrap"  
 JustifyContent="Start" AlignItems="Start">
    <controls:RepeaterView.Behaviors>
 <behaviors:RepeaterViewBehavior />
 </controls:RepeaterView.Behaviors>
    <controls:RepeaterView.ItemsTemplate> 

总结

我们现在已经成功地为三种不同的操作系统——iOS、Android 和 Windows——以及三种不同的形态因素——手机、平板和台式电脑创建了一个应用。为了在所有平台和形态因素上创造良好的用户体验,我们使用了FlexLayoutVisualStateManager。我们还学会了如何处理当我们想要为不同的形态因素使用不同的视图,以及如何使用Behaviors

接下来我们要构建的应用是一个具有实时通讯功能的聊天应用。在下一章中,我们将看看如何在 Azure 中使用 SignalR 服务作为聊天应用的后端。

第六章:使用 Azure 服务为聊天应用程序设置后端

在本章中,我们将构建一个具有实时通信的聊天应用程序。为此,我们需要一个后端。我们将创建一个后端,可以扩展以处理大量用户,但当用户数量减少时也可以缩小。为了构建该后端,我们将使用基于 Microsoft Azure 服务的无服务器架构。

本章将涵盖以下主题:

  • 在 Microsoft Azure 中创建 SignalR 服务

  • 使用 Azure 函数作为 API

  • 使用 Azure 函数调度作业

  • 使用 blob 存储来存储照片

  • 使用 Azure 认知服务扫描照片以查找成人内容

技术要求

为了能够完成这个项目,您需要安装 Mac 或 PC 上的 Visual Studio。有关如何设置您的环境的更多详细信息,请参阅第一章,Xamarin 简介。您还需要一个 Azure 帐户。如果您有 Visual Studio 订阅,每个月都包含特定数量的 Azure 积分。要激活您的 Azure 福利,请转到以下链接:my.visualstudio.com

您还可以创建一个免费帐户,在 12 个月内免费使用选定的服务。您将获得价值 200 美元的信用额度,以在 30 天内探索任何 Azure 服务,并且您还可以随时使用免费服务。在以下链接阅读更多信息:azure.microsoft.com/en-us/free/

Azure 无服务器服务

在我们开始构建具有无服务器架构的后端之前,我们需要定义无服务器实际意味着什么。在无服务器架构中,当然代码将在服务器上运行,但我们不需要担心这一点;我们唯一需要关注的是构建我们的软件。我们让其他人处理与服务器有关的一切。我们不需要考虑服务器需要多少内存或 CPU,甚至我们需要多少服务器。当我们在 Azure 中使用服务时,微软会为我们处理这一切。

Azure SignalR 服务

Azure SignalR 服务Microsoft Azure中用于服务器和客户端之间的实时通信的服务。该服务将向客户端推送内容,而无需他们轮询服务器以获取内容更新。SignalR 可用于多种类型的应用程序,包括移动应用程序、Web 应用程序和桌面应用程序。

如果可用,SignalR 将使用 WebSockets。如果不可用,SignalR 将使用其他通信技术,如服务器发送事件SSE)或长轮询。SignalR 将检测可用的传输技术并使用它,而开发人员根本不需要考虑这一点。

SignalR 可以在以下示例中使用:

  • 聊天应用程序:当新消息可用时,应用程序需要立即从服务器获取更新

  • 协作应用程序:例如,会议应用程序或多个设备上的用户正在使用相同文档时

  • 多人游戏:所有用户都需要实时更新其他用户的地方

  • 仪表板应用程序:用户需要实时更新的地方

Azure 函数

Azure 函数是微软 Azure 的一项服务,允许我们以无服务器的方式运行代码。我们将部署称为函数的小代码片段。函数部署在称为函数应用的组中。创建函数应用时,我们需要选择是否要在消耗计划或应用服务计划上运行。如果我们希望应用程序完全无服务器化,我们选择消耗计划,而对于应用服务计划,我们必须指定服务器的要求。使用消耗计划,我们支付执行时间和函数使用的内存量。应用服务计划的一个好处是可以配置为始终运行,并且只要不需要扩展到更多实例,就不会有任何冷启动。消耗计划的一个重要好处是它将根据需要的资源进行自动扩展。

函数可以通过多种方式触发运行。两个例子是HttpTriggerTimeTriggerHttpTrigger将在调用函数的 HTTP 请求时触发函数运行。使用TimeTrigger,函数将按照我们指定的间隔运行。还有其他 Azure 服务的触发器。例如,我们可以配置函数在文件上传到 blob 存储时运行,当新消息发布到事件中心或服务总线时运行,或者在 Azure CosmosDB 中的数据发生变化时运行。

Azure blob 存储

Azure blob 存储用于存储非结构化数据对象,如图像、视频、音频和文档。对象或 blob 可以组织成容器。Azure 的 Blob 存储可以在多个数据中心进行冗余。这是为了保护数据免受从瞬时硬件故障到网络或电源中断,甚至大规模自然灾害的不可预测事件的影响。Azure 的 Blob 存储可以有不同的层级,取决于我们希望使用存储的对象的频率。这包括存档和冷层,以及热层和高级层,用于需要更频繁访问数据的应用程序。除了 Blob 存储,我们还可以添加内容交付网络CDN)以使我们存储的内容更接近我们的用户。如果我们的用户遍布全球,这一点很重要。如果我们可以从更接近用户的地方提供我们的内容,我们可以减少内容的加载时间,并为用户提供更好的体验。

Azure 认知服务

描述Azure 认知服务最简单的方法是它是机器学习作为一项服务。只需简单的 API 调用,我们就可以在我们的应用程序中使用机器学习,而无需使用复杂的数据科学技术。当我们使用 API 时,我们正在针对 Microsoft 为我们训练的模型进行预测。

Azure 认知服务的服务已经组织成五个类别:

  • 视觉:视觉服务涉及图像处理。这包括面部识别、成人内容检测、图像分类和光学字符识别OCR)的 API。

  • 知识:知识服务的一个示例是问答QnA)制作者,它允许我们用知识库训练模型。当我们训练了模型,我们可以用它来获取问题的答案。

  • 语言:语言服务涉及文本理解,如文本分析、语言理解和翻译。

  • 语音:语音 API 的示例包括说话者识别、语音转文本功能和语音翻译。

  • 搜索:搜索服务是利用网络搜索引擎的力量来找到问题的答案。这包括从图像中获取知识、搜索查询的自动完成以及相似人员的识别。

项目概述

这个项目将是为聊天应用程序设置后端。项目的最大部分将是我们将在 Azure 门户中进行的配置。我们还将为处理 SignalR 连接的 Azure Functions 编写一些代码。将有一个函数返回有关 SignalR 连接的信息,还有一个函数将消息发布到 SignalR 服务。发布消息的函数还将确定消息是否包含图像。如果包含图像,它将被发送到 Azure 认知服务中的 Vision API,以分析是否包含成人内容。如果包含成人内容,它将不会发布到 SignalR 服务,其他用户也不会收到。由于 SignalR 服务有关于消息大小的限制,我们需要将图像存储在 blob 存储中,只需将图像的 URL 发布给用户。因为我们在这个应用程序中不保存任何聊天记录,我们还希望在特定间隔清除 blob 存储。为此,我们将创建一个使用TimeTrigger的函数。

以下图显示了此应用程序架构的概述:

完成此项目的估计时间约为两个小时。

构建无服务器后端

让我们开始根据前面部分描述的服务来设置后端。

创建 SignalR 服务

我们将设置的第一个服务是 SignalR:

  1. 转到 Azure 门户:portal.azure.com

  2. 创建一个新资源。SignalR 服务位于 Web 类别中。

  3. 填写表单中的资源名称。

  4. 选择要用于此项目的订阅。

  5. 我们建议您创建一个新的资源组,并将其用于为此项目创建的所有资源。我们希望使用一个资源组的原因是更容易跟踪与此项目相关的资源,并且更容易一起删除所有资源。

  6. 选择一个靠近您的用户的位置。

  7. 选择一个定价层。对于这个项目,我们可以使用免费层。我们可以始终在开发中使用免费层,然后扩展到可以处理更多连接的层。参考以下截图:

这就是我们设置 SignalR 服务所需做的一切。我们将在 Azure 门户中返回以获取连接字符串。

创建存储帐户

下一步是设置一个存储帐户,我们可以在其中存储用户上传的图像:

  1. 创建一个新的存储帐户资源。存储帐户位于存储类别下。

  2. 选择订阅和资源组。我们建议您使用与 SignalR 服务相同的订阅和资源组。

  3. 给存储帐户命名。

  4. 选择一个靠近您的用户的位置。

  5. 选择性能选项。如果我们使用高级存储,数据将存储在 SSD 磁盘上。为此项目选择标准存储。

  6. 使用 StorageV2 作为帐户类型。

  7. 在复制中,我们可以选择我们希望数据在数据中心之间如何复制。

  8. 对于访问层,我们将使用热层,因为在这个应用程序中我们需要频繁访问数据。

  9. 单击创建+审阅以在创建存储帐户之前审查设置。

  10. 单击创建以创建存储帐户:

blob 存储配置的最后一步是转到资源并为聊天图像创建一个容器:

  1. 转到资源并选择 Blobs。

  2. 创建一个名为chatimages的新容器。

  3. 将公共访问级别设置为 Blob(仅对 Blob 的匿名读取访问)。这意味着它将具有公共读取访问权限,但您必须获得授权才能上传内容。参考以下截图:

创建认知服务

为了能够使用认知服务来扫描成人内容的图像,我们需要在 Azure 门户中创建一个资源。这将为我们提供一个在调用 API 时可以使用的密钥:

  1. 创建一个新的自定义视觉资源。

  2. 给资源命名并选择订阅。

  3. 选择一个靠近用户的位置。

  4. 为预测和训练选择一个定价层。此应用程序将仅使用预测,因为我们将使用已经训练好的模型。

  5. 选择与您为其他资源选择的相同的资源组。

  6. 点击“确定”创建新资源。参考以下截图:

我们现在已经完成了创建认知服务。稍后我们将回来获取一个密钥,我们将用它来调用 API。

创建函数

我们将在后端编写的所有代码都将是函数。我们将使用 Azure Functions 的第 2 版,它将在.NET Core 之上运行。第 1 版是在完整的.NET 框架之上运行的。

创建用于函数的 Azure 服务

在开始编写任何代码之前,我们将创建 Function App。这将在 Azure 门户中包含函数:

  1. 创建一个新的 Function App 资源。Function App 在计算类别下找到。

  2. 给 Function App 命名。该名称也将成为函数 URL 的起始部分。

  3. 为 Function App 选择一个订阅。

  4. 为 Function App 选择一个资源组,应该与本章中创建的其他资源相同。

  5. 因为我们将使用.NET Core 作为函数的运行时,所以可以在 Windows 和 Linux 上运行它们。但在这种情况下,我们将在 Windows 上运行它们。

  6. 我们将使用消耗计划作为我们的托管计划,因此我们只支付我们使用的费用。Function App 将根据我们的要求进行上下缩放,而无需我们考虑任何事情,如果我们选择消耗计划。

  7. 选择一个靠近用户的位置。

  8. 选择.NET 作为运行时堆栈。

  9. 对于存储,我们可以创建一个新的存储帐户,或者使用我们在此项目中早期创建的存储帐户。

  10. 将应用程序洞察设置为打开,以便我们可以监视我们的函数。

  11. 点击“创建”以创建新资源:

创建一个函数来返回 SignalR 服务的连接信息

如果愿意,可以在 Azure 门户中创建函数。但我更喜欢使用 Visual Studio,因为代码编辑体验更好,而且可以对源代码进行版本跟踪:

  1. 在 Visual Studio 中创建一个 Azure Functions 类型的新项目。这可以在新项目对话框的云选项卡下找到。

  2. 将项目命名为Chat.Functions

  3. 点击“确定”继续:

下一步是创建我们的第一个函数:

  1. 在对话框顶部选择 Azure Functions v2 (.NET Core)。

  2. 选择 Http 触发器作为我们第一个函数的触发器。

  3. 访问权限从管理员更改为匿名。

  4. 点击“确定”继续,我们的函数项目将被创建:

我们的第一个函数将返回 SignalR 服务的连接信息。为此,我们需要通过向 SignalR 服务添加连接字符串来连接函数:

  1. 转到 Azure 门户中的 SignalR 服务资源。

  2. 转到 Keys 选项卡并复制连接字符串。

  3. 转到 Function App 资源并在应用程序设置下添加连接字符串。使用AzureSignalRConnectionString作为设置的名称。

  4. 将连接字符串添加到 Visual Studio 项目中的local.settings.json文件的Values数组中,以便能够在开发机器上本地运行函数:

 {
    "IsEncrypted": false,
    "Values": {
    "AzureWebJobsStorage": "",
    "AzureWebJobsDashboard": ""
    "AzureSignalRConnectionString": "{EnterTheConnectingStringHere}"
   }
 } 

现在,我们可以编写将返回连接信息的函数的代码。转到 Visual Studio 并按照以下说明操作:

  1. 在函数项目中安装Microsoft.Azure.WebJobs.Extensions.SignalRService NuGet 包。该包包含了我们与 SignalR 服务通信所需的类。这是一个预发布包,因此我们必须勾选包含预发布复选框。如果在此过程中出现错误,无法安装该包,请确保您的项目中所有其他包的版本都是最新的,然后重试。

  2. 将在创建函数项目时创建的函数重命名为GetSignalRInfo

  3. 还要将类重命名为GetSignalRInfo

  4. 为了实现与 SignalR 服务的绑定,我们将在函数的方法中添加一个SignalRConnectionInfo类型的参数。该参数还将具有SignalRConnectionInfo属性,指定HubName,如下代码所示。

  5. 返回连接信息参数:

using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;

    [FunctionName("GetSignalRInfo")]
    public static SignalRConnectionInfo GetSignalRInfo(
    [HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest req,
    [SignalRConnectionInfo(HubName = "chat")] SignalRConnectionInfo   
    connectionInfo)
{
    return connectionInfo;
}

创建一个消息库

我们现在将定义一些消息类,我们将用它们来发送聊天消息。我们将创建一个基本消息类,其中包含所有类型消息共享的信息。我们还将创建一个消息的独立项目,它将是一个.NET 标准库。我们之所以将它创建为一个独立的.NET 标准库,是因为我们可以在下一章中构建的应用程序中重用它。

  1. 创建一个新的.NET 标准 2.0 项目,命名为Chat.Messages

  2. Chat.Functions项目中添加对Chat.Messages的引用。

  3. Chat.Messages项目中创建一个新类,命名为Message

  4. Message类添加一个TypeInfo属性。我们在第七章中需要这个属性,构建实时聊天应用程序,当我们进行消息序列化时。

  5. 添加一个名为Id的字符串类型的属性。

  6. 添加一个DateTime类型的Timestamp属性。

  7. 添加一个string类型的Username属性。

  8. 添加一个空的构造函数。

  9. 添加一个以用户名为参数的构造函数。

  10. 将所有属性的值设置如下代码所示:

public class Message
{
    public Type TypeInfo { get; set; }
    public string Id {get;set;}
    public string Username { get; set; }
    public DateTime Timestamp { get; set; }

    public Message(){}
    public Message(string username)
    {
        Id = Guid.NewGuid().ToString();
        TypeInfo = GetType();
        Username = username;
        Timestamp = DateTime.Now;
    }
}

当新客户端连接时,将向其他用户发送一条消息,指示他们已连接:

  1. 创建一个名为UserConnectedMessage的新类。

  2. Message作为基类。

  3. 添加一个空的构造函数。

  4. 添加一个以用户名为参数的构造函数,并将其发送到基类的构造函数,如下代码所示:

public class UserConnectedMessage : Message
{
    public UserConnectedMessage() { }
    public UserConnectedMessage(string username) : base(username) { }
} 

当客户端发送带有文本的消息时,它将发送一个SimpleTextMessage

  1. 创建一个名为SimpleTextMessage的新类。

  2. Message作为基类。

  3. 添加一个空的构造函数。

  4. 添加一个以用户名为参数的构造函数,并将其发送到基类的构造函数。

  5. 添加一个名为Text的字符串属性。参考以下代码:

public class SimpleTextMessage : Message
{
    public SimpleTextMessage(){}
    public SimpleTextMessage(string username) : base(username){} 
    public string Text { get; set; }
} 

如果用户上传了一张图片,它将作为base64字符串发送到函数:

  1. 创建一个名为PhotoMessage的新类。

  2. Message作为基类。

  3. 添加一个空的构造函数。

  4. 添加一个以用户名为参数的构造函数,并将其发送到基类的构造函数。

  5. 添加一个名为Base64Photo的字符串属性。

  6. 添加一个名为FileEnding的字符串属性,如下代码片段所示:

public class PhotoMessage : Message
{
    public PhotoMessage() { }
    public PhotoMessage(string username) : base(username) { }

    public string Base64Photo { get; set; }
    public string FileEnding { get; set; }
} 

我们将创建的最后一个消息用于向用户发送有关照片的信息:

  1. 创建一个名为PhotoUrlMessage的新类。

  2. Message作为基类。

  3. 添加一个空的构造函数。

  4. 添加一个以用户名为参数的构造函数,并将其发送到基类的构造函数。

  5. 添加一个名为Url的字符串属性。参考以下代码:

public class PhotoUrlMessage : Message
{
    public PhotoUrlMessage() {}
    public PhotoUrlMessage(string username) : base(username){}

    public string Url { get; set; }
} 

创建存储助手

我们将创建一个辅助程序,以便在我们将为 Azure Blob Storage 编写的一些代码之间共享发送消息函数和我们将创建的清除照片函数。在 Azure 门户中创建 Function App 时,会创建一个用于连接字符串的设置,因此我们只需将其添加到local.settings.json文件中,以便能够在本地运行它。连接字符串的名称将是StorageConnection

 {
     "IsEncrypted": false,
     "Values": {
     "AzureWebJobsStorage": "",
     "AzureWebJobsDashboard": "",
     "AzureSignalRConnectionString": "{EnterTheConnectingStringHere}"
     "StorageConnection": "{EnterTheConnectingStringHere}"
   }
 } 

对于辅助程序,我们将创建一个新的静态类,如下所示:

  1. Chat.Functions项目中安装WindowsAzure.Storage NuGet包。这是为了获得我们需要与存储一起使用的类。

  2. Chat.Functions项目中创建一个名为StorageHelper的新类。

  3. 将类static

  4. 创建一个名为GetContainer的新静态方法。

  5. 使用Environment类上的静态GetEnviromentVariable方法读取存储的连接字符串。

  6. 使用静态Parse方法在CloudStorageAccount上创建一个CloudStorageAccount对象。

  7. 使用CloudStorageAccount类上的CreateCloudBlobClient方法创建一个新的CloudBlobClient

  8. 使用CloudBlobClient类上的GetContainerReference方法获取容器引用,并将我们在本章中早期创建的容器的名称作为参数传递:

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using System;
using System.IO;
using System.Threading.Tasks;

public static class StorageHelper
{

    private static CloudBlobContainer GetContainer()
    {    
        string storageConnectionString =  
        Environment.GetEnvironmentVariable("StorageConnection");
        var storageAccount =   
        CloudStorageAccount.Parse(storageConnectionString);
        var blobClient = storageAccount.CreateCloudBlobClient();

        var container = 
        blobClient.GetContainerReference("chatimages");

        return container;
    } 
}

为了将文件上传到 blob 存储,我们将创建一个具有照片的字节和照片类型的方法。照片类型将由其文件结束定义:

  1. 创建一个新的async static方法,返回Task<string>

  2. 向方法添加一个byte[]和一个string参数。将参数命名为bytesfileEnding

  3. 调用GetContainer方法获取对容器的引用。

  4. 为新的 blob 定义一个文件名,并将其作为参数传递给CloudBlobContainer类中的GetBlockBlobReference。使用GUID作为文件名,以确保其唯一性。

  5. 使用字节创建一个MemoryStream

  6. 使用BlockBlobReference类上的UploadFromStreamAsync方法将照片上传到云端。

  7. 返回 blob 的AbsoluteUri

public static async Task<string> Upload(byte[] bytes, string fileEnding)
{
  var container = GetContainer();
  var blob = container.GetBlockBlobReference($"  
  {Guid.NewGuid().ToString()}.{fileEnding}");

  var stream = new MemoryStream(bytes);
  await blob.UploadFromStreamAsync(stream);

  return blob.Uri.AbsoluteUri;
} 

我们将添加到辅助程序的第二个公共方法是一个方法,用于删除所有早于一小时的照片:

  1. 创建一个名为Clear的新的async static方法,返回Task

  2. 使用GetContainer方法获取对容器的引用。

  3. 通过调用ListBlobsSegmentedAsync方法并使用以下代码中显示的参数获取容器中的所有 blob。

  4. 循环遍历所有CloudBlob类型的 blob。

  5. 添加一个if语句来检查照片是否是一个小时前创建的。如果是,则应删除 blob:

public static async Task Clear()
{
    var container = GetContainer();
    var blobList = await 
    container.ListBlobsSegmentedAsync(string.Empty, false, 
    BlobListingDetails.None, int.MaxValue, null, null, null);

    foreach(var blob in blobList.Results.OfType<CloudBlob>())
    {
        if(blob.Properties.Created.Value.AddHours(1) < DateTime.Now)
        {
            await blob.DeleteAsync();
        }
    }
} 

创建一个发送消息的函数

为了处理用户发送的消息,我们将创建一个新函数:

  1. 创建一个带有HttpTrigger和匿名访问权限的函数。

  2. 将函数命名为Messages

  3. 添加一个SignalRMessage集合,如下所示。

  4. 使用SignalR属性指定 hub 名称:

[FunctionName("Messages")]
  public async static Task SendMessages(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] object 
     message,
    [SignalR(HubName = "chat")] IAsyncCollector<SignalRMessage>    
     signalRMessages)
  { 

消息参数将是用户发送的消息。它将是JObject类型(来自Newtonsoft.Json)。我们需要将其转换为我们之前创建的Message类型。为此,我们需要添加对Chat.Messages项目的引用。但是,因为参数是对象类型,我们首先需要将其转换为JObject。一旦我们做到这一点,我们就可以使用ToObject方法获得Message

var jsonObject = (JObject)message;
var msg = jsonObject.ToObject<Message>();

如果消息是PhotoMessage,我们将把照片上传到 blob 存储。所有其他消息将直接使用signalRmessages参数上的AddAsync方法发送到 SignalR 服务:

if (msg.TypeInfo.Name == nameof(PhotoMessage))
{
    //ToDo: Upload the photo to blob storage.
}

await signalRMessages.AddAsync(new SignalRMessage
  {
    Target = "newMessage",
    Arguments = new[] { message }
 }); 

在使用我们创建的辅助程序将照片上传到 blob 存储之前,我们需要将base64字符串转换为byte[]

  1. 使用Converter类上的静态FromBase64String方法将base64字符串转换为byte[]

  2. 使用StorageHelper上的静态Upload方法将照片上传到 blob 存储。

  3. 创建一个新的PhotoUrlMessage,将用户名传递给构造函数,并将其设置为msg变量的值。

  4. Timestamp属性设置为原始消息的值,因为我们对用户创建消息的时间感兴趣。

  5. Id属性设置为原始消息的值,以便在客户端上将其处理为相同的消息。

  6. Url属性设置为StorageHelper上传照片时返回的 URL。

  7. signalRMessages变量上使用AddAsync方法向 SignalR 服务发送消息。

  8. 添加一个空的返回语句:

if (msg.TypeInfo.Name == nameof(PhotoMessage))
{
    var photoMessage = jsonObject.ToObject<PhotoMessage>(); 
    var bytes = Convert.FromBase64String(photoMessage.Base64Photo);
    var url = await StorageHelper.Upload(bytes, 
    photoMessage.FileEnding);
 msg = new PhotoUrlMessage(photoMessage.Username)
 {
        Id = photoMessage.Id,
 Timestamp = photoMessage.Timestamp,
 Url = url
 }; await signalRMessages.AddAsync(new SignalRMessage
                                   {
                                    Target = "newMessage",
                                    Arguments = new[] { message }
                                    }); 
    return;
}

使用计算机视觉 API 扫描成人内容

为了最大程度地减少在我们的聊天中显示冒犯性照片的风险,我们将使用机器学习来尝试查找问题材料并防止其发布到聊天中。为此,我们将在 Azure 中使用计算机视觉 API,这是Azure 认知服务的一部分。要使用 API,我们需要一个密钥。我们将把它添加到功能应用程序的应用程序设置中:

  1. 转到 Azure 门户。

  2. 转到我们为 Custom Vision API 创建的资源。

  3. 密钥可以在“密钥”选项卡下找到。您可以使用 Key 1 或 Key 2。

  4. 转到“功能应用程序”的资源。

  5. 将密钥作为名为ComputerVisionKey的应用程序设置添加。还要将密钥添加到local.settings.json中。

  6. 还要将 Endpoint 添加为应用程序设置。使用名称ComputerVisionEndpoint。可以在功能应用程序资源的“概述”选项卡下找到 Endpoint。还要将 Endpoint 添加到local.settings.json中。

  7. 在 Visual Studio 的Chat.Functions项目中安装Microsoft.Azure.CognitiveServices.Vision.ComputerVision NuGet 包。这是为了获取使用计算机视觉 API 所需的类。

  8. 调用计算机视觉 API 的代码将被添加到Message函数中。之后,我们将把base 64字符串转换为byte[]

  9. 基于字节数组创建一个MemoryStream

  10. 按照以下代码中所示创建ComputerVisonClient并将凭据传递给构造函数。

  11. 创建我们在分析照片时将使用的功能列表。在这种情况下,我们将使用VisualFeatureTypes.Adult功能。

  12. ComputerVisionClient上使用AnalyzeImageInStreamAsync方法,并将流和功能列表传递给构造函数以分析照片。

  13. 如果结果是IsAdultContent,则使用空的返回语句停止函数的执行:

var stream = new MemoryStream(bytes); 
  var subscriptionKey =   
  Environment.GetEnvironmentVariable("ComputerVisionKey");
  var computerVision = new ComputerVisionClient(new   
  ApiKeyServiceClientCredentials(subscriptionKey), new 
  DelegatingHandler[] { });

  computerVision.Endpoint =   
  Environment.GetEnvironmentVariable("ComputerVisionEndpoint");

  var features = new List<VisualFeatureTypes>() { 
  VisualFeatureTypes.Adult };

  var result = await   
  computerVision.AnalyzeImageInStreamAsync(stream, features);

if (result.Adult.IsAdultContent)
{
    return;
} 

创建一个定期清除存储中照片的计划作业

我们要做的最后一件事是定期清理 blob 存储并删除超过一小时的照片。我们将通过创建一个由TimeTrigger触发的函数来实现这一点:

  1. 要创建新函数,请右键单击Chat.Functions项目,然后单击“新的 Azure 函数”,该选项将在“添加”菜单下找到。

  2. 将函数命名为ClearPhotos

  3. 选择函数将使用时间触发器,因为我们希望它按时间间隔运行。

  4. 使用时间表达式将 Schedule 设置为0 */60 * * * *,使其每 60 分钟运行一次:

ClearPhotos函数中,我们唯一要做的是调用本章前面创建的StorageHelperClear方法:

[FunctionName("ClearPhotos")]
  public static async Task Run(
    [TimerTrigger("0 */60 * * * *")]TimerInfo myTimer, ILogger log)
{
    await StorageHelper.Clear();
} 

将函数部署到 Azure

本章的最后一步是将函数部署到 Azure。您可以将其作为 CI/CD 流水线的一部分来完成,例如使用 Azure DevOps。但在这种情况下,将函数直接从 Visual Studio 部署是最简单的方法。按照以下步骤部署函数:

  1. 右键单击Chat.Functions项目,然后选择发布。

  2. 选择“选择现有选项”。还要勾选“从包文件运行”选项。

  3. 单击“创建配置文件”按钮。

  4. 登录到与我们在创建功能应用程序时在 Azure 门户中使用的相同的 Microsoft 帐户。

  5. 选择包含函数应用程序的订阅。我们在订阅中拥有的所有函数应用程序现在将被加载。

  6. 选择函数应用程序,然后点击“确定”。

  7. 创建配置文件后,点击“发布”按钮。

以下截图显示了最后一步。之后,发布配置文件被创建:

总结

在本章中,我们已经学习了如何为实时通信设置无服务器后端,使用 Azure Functions 和 Azure SignalR 服务。我们还学习了如何使用 blob 存储和 Azure 认知服务中的机器学习来扫描照片中的成人内容。

在下一章中,我们将构建一个聊天应用程序,该应用程序将使用我们在本项目中构建的后端。

第七章:构建实时聊天应用程序

在本章中,我们将构建一个具有实时通信的聊天应用程序。在该应用程序中,您将能够向其他用户发送和接收消息和照片,而无需刷新页面即可看到消息。我们将看看如何使用 SignalR 实现与服务器的实时连接。

本章将涵盖以下主题:

  • 如何在 Xamarin.Forms 应用程序中使用 SignalR

  • 如何为 ListView 使用模板选择器

  • 如何在 Xamarin.Forms 应用程序中使用 CSS 样式

技术要求

在构建此项目的应用程序之前,您需要构建我们在第六章,使用 Azure 服务为聊天应用程序设置后端中详细说明的后端。您还需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置环境的更多详细信息,请参阅第一章,Xamarin 简介。本章的源代码可在 GitHub 存储库中找到,网址为github.com/PacktPublishing/Xamarin.Forms-Projects/tree/master/Chapter-6-and-7

项目概述

在构建聊天应用程序时,实时通信非常重要,因为用户期望消息能够几乎立即到达。为了实现这一点,我们将使用 SignalR,这是一个用于实时通信的库。SignalR 将使用 WebSockets(如果可用),如果不可用,它将有几种备用选项可以使用。在该应用程序中,用户将能够从设备的照片库发送文本和照片。

该项目的构建时间约为 180 分钟。

入门

我们可以使用 PC 上的 Visual Studio 2017 或 Mac 上的 Visual Studio 来完成此项目。要使用 Visual Studio 在 PC 上构建 iOS 应用程序,您必须连接 Mac。如果根本没有 Mac,您可以选择仅构建应用程序的 Android 部分。

构建聊天应用程序

现在是时候开始构建应用程序了。我们建议您使用与第六章相同的方法,使用 Azure 服务为聊天应用程序设置后端,因为这将使代码共享更容易。在该解决方案中,创建一个名为Chat的移动应用程序(Xamarin.Forms):

选择空白模板,并将.NET Standard 作为代码共享策略。选择 iOS 和 Android 作为平台。创建项目后,我们将更新所有 NuGet 包到最新版本,因为项目模板的更新频率不如模板内部使用的包频繁:

创建聊天服务

我们将首先创建一个聊天服务,该服务将被 iOS 和 Android 应用程序共同使用。为了使代码更易于测试,并且在将来想要使用其他提供程序替换聊天服务更容易,我们将按照以下步骤进行:

  1. Chat项目中,添加对Chat.Messages项目的引用。

  2. Chat项目中创建一个名为Services的新文件夹。

  3. Services文件夹中创建一个名为IChatService的新接口。

  4. 创建一个名为IsConnectedbool属性。

  5. 创建一个名为SendMessage的方法,该方法以Message作为参数并返回Task

  6. 创建一个名为CreateConnection的方法,返回Task。该方法将创建并启动与 SignalR 服务的连接。

  7. 创建一个名为Dispose的方法,返回Task。当应用程序进入休眠状态时,将使用该方法来确保与 SignalR 服务的连接被正确关闭:

using Chat.Events;
using Chat.Messages;
using System;
using System.Threading.Tasks;

namespace Chat.Services
{
    public interface IChatService
    {        
        bool IsConnected { get; }

        Task CreateConnection();
        Task SendMessage(Message message);
        Task Dispose();
    }     
}

该接口还将包含一个事件,但在将事件添加到接口之前,我们将创建一个EventArgs类,该事件将使用。我们将按照以下步骤进行:

  1. Chat项目中,创建一个名为Events的新文件夹。

  2. Events文件夹中创建一个名为NewMessageEventArgs的新类。

  3. EventArgs添加为基类。

  4. 创建一个名为MessageMessage类型的属性,具有公共 getter 和私有 setter。

  5. 创建一个空的构造函数。

  6. 创建一个带有Message参数的构造函数。

  7. 将构造函数的参数设置为Message属性。

以下代码是这些步骤的结果:

using Chat.Messages;
using System;
namespace Chat.Events
{
    public class NewMessageEventArgs : EventArgs
    {
        public Message Message { get; private set; }

        public NewMessageEventArgs(Message message)
        {
            Message = message;
        }
    } 
}

现在我们已经创建了一个新的EventArgs类,我们可以使用它并在接口中添加一个事件。我们将事件命名为NewMessage

public interface IChatService
{
 event EventHandler<NewMessageEventArgs> NewMessage;

    bool IsConnected { get; }

    Task CreateConnection();
    Task SendMessage(Message message);
    Task Dispose();
} 

在服务中,我们将首先调用GetSignalRInfo服务,该服务是我们在第六章中创建的,使用 Azure 服务为聊天应用程序设置后端,以获取有关如何连接到 SignalR 服务的信息。为了序列化该信息,我们将创建一个新类:

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

  2. 创建一个名为ConnectionInfo的新类。

  3. string添加一个名为Url的字符串属性。

  4. string添加一个名为AccessToken的字符串属性:

public class ConnectionInfo
{
   public string Url { get; set; }
   public string AccessToken { get; set; }
} 

现在我们有了接口和一个用于获取连接信息的模型,是时候创建IChatService接口的实现了。要使用 SignalR,我们需要添加一个 NuGet 包,它将为我们提供必要的类。请按照以下步骤操作:

  1. Chat项目中,安装 NuGet 包Microsoft.AspNetCore.SignalR.Client

  2. Services文件夹中,创建一个名为ChatService的新类。

  3. IChatService接口添加并实现到ChatService中。

  4. HttpClient添加一个名为httpClient的私有字段。

  5. HubConnection添加一个名为hub的私有字段。

  6. SemaphoreSlim添加一个名为semaphoreSlim的私有字段,并在构造函数中使用初始计数和最大计数为 1 创建一个新实例:

using Chat.Events;
using Chat.Messages;
using Microsoft.AspNetCore.SignalR.Client;
using Newtonsoft.Json;
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

public class ChatService : IChatService
{
    private HttpClient httpClient;
    private HubConnection hub;
    private SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);     

    public event EventHandler<NewMessageEventArgs> NewMessage;
    public bool IsConnected { get; set; }

    public async Task CreateConnection() 
    {
    }

    public async Task SendMessage(Message message) 
    {
    }

    public async Task Dispose()
    {
    } 
}

我们将从CreateConnection开始,它将调用GetSignalRInfo函数。然后我们将使用这些信息连接到 SignalR 服务并开始监听消息。为此,请执行以下步骤:

  1. 调用SemaphoreSlimWaitAsync方法,以确保一次只有一个线程可以使用该方法。

  2. 检查httpClient是否为null。如果是,创建一个新实例。我们将重用httpClient的实例,因为从性能的角度来看这样做更好。

  3. 调用GetSignalRInfo并将结果序列化为ConnectionInfo对象:

public async Task CreateConnection()
{
    await semaphoreSlim.WaitAsync();

 if(httpClient == null)
 { 
 httpClient = new HttpClient();
 }

 var result = await     httpClient.GetStringAsync("https://{theNameOfTheFunctionApp}.azurewebsites.net/api/GetSignalRInfo");

 var info = JsonConvert.DeserializeObject<Models.ConnectionInfo>
    (result); 
}

当我们有关于如何连接到 SignalR 服务的信息时,我们可以使用HubConnectionBuilder来创建一个连接。然后我们可以开始监听消息:

  1. 创建一个新的HubConnectionBuilder

  2. 使用WithUrl方法指定 SignalR 服务的 URL 作为第一个参数。第二个参数是HttpConnectionObject类型的Action。这意味着您将获得一个HttpConnectionObject类型的对象作为参数。

  3. 在操作中,将AccessTokenProvider设置为一个返回ConnectionInfo对象上AccessToken属性值的Func

  4. 使用HubConnectionBuilderBuild方法创建一个连接对象。

  5. 使用HubConnection对象上的On<object>方法添加一个在新消息到达时运行的Action。将该操作指定为第二个参数。对于第一个参数,我们将指定目标的名称(在第六章中指定了目标,使用 Azure 服务为聊天应用程序设置后端,当我们发送消息时),即newMessage

  6. Action中,使用ToString方法将传入的消息转换为字符串,并将其反序列化为Message对象,以便读取其TypeInfo属性。为此,使用JsonConvert类和DeserializeObject<Message>方法。

我们必须两次反序列化对象的原因是,第一次我们只能得到Message类中属性的值。当我们知道我们收到的Message的哪个子类时,我们可以使用这个来为该类反序列化信息。我们将其转换为Message,以便将其传递给NewMessageEventArgs对象。在这种情况下,我们不会丢失子类的属性。要访问属性,我们只需将类转换回子类。

  1. 当我们知道消息的类型时,我们可以使用这个来将对象反序列化为实际类型。使用JsonConvertDeserializeObject方法,并将 JSON 字符串和TypeInfo传递给它,然后将其转换为Message

  2. 调用NewMessage事件,并将ChatService的当前实例和一个新的NewMessageEventArgs对象传递给它。将Message对象传递给NewMessageEventArgs的构造函数。

  3. 一旦我们有了连接对象,并且配置了消息到达时会发生什么,我们将开始使用HubConnectionStartAsync方法来监听消息。

  4. IsConnected属性设置为true

  5. 使用SemaphoreSlimRelease方法让其他线程进入CreateConnection方法:

var connectionBuilder = new HubConnectionBuilder();
connectionBuilder.WithUrl(info.Url, (Microsoft.AspNetCore.Http.Connections.Client.HttpConnectionOptions obj) =>
    {
        obj.AccessTokenProvider = () => Task.Run(() => 
        info.AccessToken);
    });

hub = connectionBuilder.Build();
hub.On<object>("newMessage", (message) =>
{
     var json = message.ToString();
     var obj = JsonConvert.DeserializeObject<Message>(json);
     var msg = (Message)JsonConvert.DeserializeObject(json, 
     obj.TypeInfo);
     NewMessage?.Invoke(this, new NewMessageEventArgs(msg));
});

await hub.StartAsync();

IsConnected = true;
semaphoreSlim.Release();

实现的下一个方法是SendMessage方法。这将向 Azure 函数发送消息,该函数将将消息添加到 SignalR 服务:

  1. 使用JsonConvert类的Serialize方法将Message对象序列化为 JSON。

  2. 创建一个StringContent对象,并将 JSON 字符串作为第一个参数,Encoding.UTF8作为第二个参数,内容类型application/json作为最后一个参数传递给构造函数。

  3. 使用HttpClient对象的PostAsync方法,将 URL 作为第一个参数,StringContent对象作为第二个参数,将消息发布到函数:

public async Task SendMessage(Message message)
{
    var json = JsonConvert.SerializeObject(message);

    var content = new StringContent(json, Encoding.UTF8, 
    "application/json");

    await 
    httpClient.PostAsync
("https://{TheNameOfTheFunctionApp}.azurewebsites.net/api/messages"
content);
} 

实现的最后一个方法是Dispose方法。这将在应用程序进入后台状态时关闭连接,例如当用户按下主页按钮或切换应用程序时:

  1. 使用WaitAsync方法确保在运行该方法时没有线程尝试创建连接或释放连接。

  2. 添加一个if语句,以确保hub字段不为null

  3. 如果不为空,调用HubConnectionStopAsync方法和DisposeAsync方法。

  4. httpClient字段设置为null

  5. IsConnected设置为false

  6. 使用Release方法释放SemaphoreSlim

public async Task Dispose()
{
    await semaphoreSlim.WaitAsync();

    if(hub != null)
    {
        await hub.StopAsync();
        await hub.DisposeAsync();
    }

    httpClient = null;

    IsConnected = false;

    semaphoreSlim.Release();
} 

初始化应用程序

现在我们准备为应用程序编写初始化代码。我们将设置控制反转IoC)并进行必要的配置。

创建一个解析器

我们将创建一个辅助类,以便通过 Autofac 轻松解析对象图的过程。这将帮助我们基于配置的 IoC 容器创建类型。在这个项目中,我们将使用Autofac作为 IoC 库:

  1. Chat项目中安装NuGetAutofac

  2. Chat项目中创建一个名为Resolver的新类。

  3. 添加一个名为containerIContainer类型(来自Autofac)的private static字段。

  4. 添加一个名为Initialize的公共静态方法,带有IContainer作为参数。将参数的值设置为容器字段。

  5. 添加一个名为Resolve的通用静态公共方法,它将返回一个基于参数类型的实例,使用IContainerResolve方法:

using Autofac;

public class Resolver
{
     private static IContainer container;

     public static void Initialize(IContainer container)
{
          Resolver.container = container;
     }

     public static T Resolve<T>()
     {
          return container.Resolve<T>();
     }
} 

创建一个 Bootstrapper

在这里,我们将创建一个Bootstrapper类,用于在应用程序启动阶段设置我们需要的常见配置。通常,Bootstrapper 的每个目标平台都有一个部分,所有平台都有一个共享部分。在这个项目中,我们只需要共享部分:

  1. Chat项目中创建一个名为Bootstrapper的新类。

  2. 添加一个名为Init的新的公共静态方法。

  3. 创建一个新的ContainerBuilder并将类型注册到container

  4. 使用ContainerBuilderBuild方法创建一个Container。创建一个名为container的变量,它应该包含Container的实例。

  5. Resolver上使用Initialize方法,并将container变量作为参数传递,如下所示:

using Autofac;
using Chat.Chat;
using System;
using System.Reflection;

public class Bootstrapper
{
     public static void Init()
     {
            var builder = new ContainerBuilder();

             builder.RegisterType<ChatService>().As<IChatService>
             ().SingleInstance();

             var currentAssembly = Assembly.GetExecutingAssembly();

             builder.RegisterAssemblyTypes(currentAssembly)
                      .Where(x => x.Name.EndsWith("View", 
                      StringComparison.Ordinal));

             builder.RegisterAssemblyTypes(currentAssembly)
                     .Where(x => x.Name.EndsWith("ViewModel", 
                     StringComparison.Ordinal));

             var container = builder.Build();

             Resolver.Initialize(container); 
     }
} 

App.xaml.cs文件中,在调用InitializeComponents之后,在构造函数中调用BootstrapperInit方法:

public App()
{
    InitializeComponent();
    Bootstrapper.Init();
    MainPage = new MainPage();
} 

创建基本 ViewModel

我们现在有一个负责处理与后端通信的服务。是时候创建一个视图模型了。但首先,我们将创建一个基本视图模型,其中可以放置在应用程序的所有视图模型之间共享的代码:

  1. 创建一个名为ViewModels的新文件夹。

  2. 创建一个名为ViewModel的新类。

  3. 将新类设置为 public 和 abstract。

  4. 添加一个名为NavigationINavigation类型的静态字段。这将用于存储 Xamarin.Forms 提供的导航服务的引用。

  5. 添加一个名为Userstring类型的静态字段。该字段将在连接到聊天服务时使用,以便您发送的消息将显示您的名称。

  6. 添加并实现INotifiedPropertyChanged接口。这是必要的,因为我们想要使用数据绑定。

  7. 添加一个Set方法,这样我们就可以更容易地从INotifiedPropertyChanged接口中触发PropertyChanged事件。该方法将检查值是否已更改。如果已更改,它将触发事件:

using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Xamarin.Forms;

public abstract class ViewModel : INotifyPropertyChanged
{
     public static INavigation Navigation { get; set; }
     public static string User { get; set; } 

     public event PropertyChangedEventHandler PropertyChanged; 
     protected void Set<T>(ref T field, T newValue, 
                           [CallerMemberName] string propertyName = 
                           null)
     {
          if (!EqualityComparer<T>.Default.Equals(field, newValue))
          {
               field = newValue;
               PropertyChanged?.Invoke(this, new 
               PropertyChangedEventArgs(propertyName));
          }
     }
} 

创建 MainView

现在我们已经设置好了ViewModel基类,并且已经编写了接收和发送消息的所有代码,是时候创建两个视图了。这些将充当应用程序的用户界面。

我们将从创建主视图开始。这是用户启动应用程序时将显示的视图。我们将添加一个输入控件(输入文本框),以便用户可以输入用户名,并添加一个命令以导航到聊天视图。

主视图将由以下内容组成:

  • 一个名为MainViewModel.cs的 ViewModel 文件

  • 一个名为MainView.xaml的 XAML 文件,其中包含布局

  • 一个名为MainView.xaml.cs的代码后台文件,将执行数据绑定

让我们从为MainView创建ViewModel开始。

创建 MainViewModel

我们即将创建的MainViewModel将保存用户将在 UI 中输入的用户名。它还将包含一个名为StartCommand属性,该属性将绑定到用户在输入用户名后单击的Button

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

  2. ViewModel继承该类。

  3. 将类设置为public

  4. 添加一个名为Usernamestring类型的属性。

  5. 添加一个名为StartICommand类型的属性,并按照以下方式实现它。Start命令将从Username属性中分配Username并将其分配给基本ViewModel中的静态User属性。然后,它使用Resolver创建ChatView的新实例,并将其推送到导航堆栈上。

MainViewModel现在应该如下所示:

 using System.Windows.Input;
 using Chat.Views;
 using Xamarin.Forms;

 namespace Chat.ViewModels
 {
     public class MainViewModel : ViewModel
     {
         public string Username { get; set; }

         public ICommand Start => new Command(() =>
         {
             User = Username;

             var chatView = Resolver.Resolve<ChatView>();
             Navigation.PushAsync(chatView);
         });
     }
 }

现在我们有了MainViewModel,我们需要一个与之配套的视图。是时候创建MainView了。

创建 MainView

MainView将显示一个用户界面,允许用户在开始聊天之前输入名称。本节将介绍创建MainView的 XAML 文件和该视图的代码。

我们将首先删除模板生成的MainPage,并将其替换为 MVVM 友好的MainView

替换 MainPage

当我们创建应用程序时,模板生成了一个名为MainPage的页面。由于我们使用 MVVM 作为模式,我们需要删除此页面,并将其替换为一个名为MainView的视图:

  1. Chat项目的根目录中,删除名为MainPage的页面。

  2. 创建一个名为Views的新文件夹。

  3. 在 Views 文件夹中添加一个名为MainView的新 XAML 页面。

编辑 XAML

现在是时候向新创建的 MainView.xaml 文件添加一些内容了。下面提到的图标可以在与其应该添加到的同一文件夹中找到,如果你去 GitHub 上的项目,就可以找到。GitHub 的 URL 可以在本章的开头找到。这里有很多内容,所以确保检查你写的代码:

  1. chat.png 图标添加到 Android 项目中 Resources 文件夹内的 Drawable 文件夹中。

  2. chat@2x.png 图标添加到 iOS 项目中的 Resources 文件夹中。

  3. 打开 MainView.xaml 文件。

  4. ContentPage 节点中添加一个 Title 属性。这将是应用程序导航栏中显示的标题。

  5. 添加一个 Grid,并在其中定义两行。第一行的高度应为 "*",第二行的高度应为 "2*"。这将把空间分成两行,第一行将占据空间的 1/3,第二行将占据空间的 2/3

  6. 添加一个 Image,将 Source 设置为 "chat.png",并将其 VerticalOptionsHorizontalOptions 设置为 "Center"

  7. 添加一个 StackLayout,将 Grid.Row 设置为 "1",将 Padding 设置为 "10",将 Spacing 设置为 "20"Grid.Row 属性将 StackLayout 定位在第二行。PaddingStackLayout 周围添加了 10 个单位的空间,Spacing 定义了在 StackLayout 中添加的每个元素之间的空间量。

  8. StackLayout 中,添加一个 Entry 节点,将其 Text 属性设置为 "{Binding UserName}",并将 Placeholder 属性设置为 "输入用户名"。文本节点的绑定将确保当用户在 Entry 控件中输入值时,它会在 ViewModel 中更新。

  9. StackLayout 中,添加一个 Button 控件,将其 Text 属性设置为 "Start",并将其 Command 属性设置为 "{Binding Start}"。当用户点击按钮时,Command 属性绑定将被执行。它将运行我们在 MainViewModel 类中定义的代码。

完成后,代码应如下所示:

 <?xml version="1.0" encoding="UTF-8"?>
 <ContentPage  

              x:Class="Chat.Views.MainView" Title="Welcome">
     <Grid>
 <Grid.RowDefinitions>
 <RowDefinition Height="*" />
 <RowDefinition Height="2*" />
 </Grid.RowDefinitions>
 <Image Source="chat.png" VerticalOptions="Center" 
                                  HorizontalOptions="Center" />
 <StackLayout Grid.Row="1" Padding="10" Spacing="20">
 <Entry Text="{Binding Username}" 
             Placeholder="Enter a username" />
 <Button Text="Start" Command="{Binding Start}" />
 </StackLayout>
 </Grid>
 </ContentPage> 

布局已完成,现在我们需要将焦点转向这个视图的代码,以解决一些问题。

修复视图的代码

与所有视图一样,在使用 MVVM 时,我们需要向视图传递一个 ViewModel。由于在这个项目中使用了依赖注入,我们将通过构造函数传递它,然后将其分配给视图本身的 BindingContext。我们还将确保启用安全区域,以避免控件部分隐藏在 iPhone X 顶部的刘海区域后面:

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

  2. MainView 类的构造函数中添加一个名为 viewModelMainViewModel 类型的参数。这个参数的参数将在运行时由 Autofac 注入。

  3. 添加一个指令,指示应用程序在 iOS 上使用安全区域。安全区域确保应用程序不会使用屏幕顶部 iPhone X 的刘海区域旁边的空间。

  4. viewModel 参数分配给视图的 BindingContext 属性。

所做的更改在代码中用粗体标记如下:

using Chat.ViewModels;
using Xamarin.Forms;
using Xamarin.Forms.PlatformConfiguration.iOSSpecific;
using Xamarin.Forms.Xaml;

public partial class MainView : ContentPage
{
         public MainView(MainViewModel viewModel)
         {
             InitializeComponent();

             On<Xamarin.Forms.PlatformConfiguration.iOS>
             ().SetUseSafeArea(true);

             BindingContext = viewModel;
         }
     } 

我们的 MainView 完成了,但我们仍然需要告诉应用程序使用它作为入口视图。

设置主视图

入口视图,也称为应用程序的 MainPage,在初始化 Xamarin.Forms 应用程序时设置。通常,在 App 类的构造函数中设置。我们将通过之前创建的解析器创建 MainView,并将其包装在 NavigationPage 中,以在应用程序运行的设备上启用特定于平台的导航:

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

  2. 通过使用解析器将一个 MainView 类的实例解析为一个名为 mainView 的变量。

  3. 通过将 mainView 变量作为构造函数参数传递并将其赋值给一个名为 navigationPage 的变量,创建一个新的 NavigationPage 实例。

  4. navigationPage.Navigation属性分配给ViewModel类型上的静态Navigation属性。稍后在页面之间导航时将使用此属性。

  5. navigationPage变量分配给App类的MainPage属性。这将设置我们应用程序的起始视图:

public App()
{
    InitializeComponent();
    Boostrapper.Init();

 var mainView = Resolver.Resolve<MainView>();
 var navigationPage = new NavigationPage(mainView);
 ViewModel.Navigation = navigationPage.Navigation;
 MainPage = navigationPage;
} 

这就是MainView;简单而容易。现在让我们转向更有趣的东西:ChatView,它将用于发送和接收消息。

创建 ChatView

ChatView是一个标准的聊天客户端。它将有一个用于显示传入和传出消息的区域,底部有一个文本字段,用户可以在其中输入消息。它还将有一个用于拍照的按钮和一个用于发送消息的按钮,如果用户没有在屏幕键盘上按回车键。

我们将首先创建ChatViewModel,它包含所有逻辑,充当视图和模型之间的粘合剂。在这种情况下,我们的模型由ChatService表示。

之后,我们将创建ChatView,它处理图形用户界面GUI)的渲染。

创建 ChatViewModel

如前所述,ChatViewModel是视觉表示(View)和模型(基本上是我们的ChatService)之间的粘合剂。ChatViewModel将处理消息的存储和与ChatService的通信,通过将发送和接收消息的功能连接起来。

创建类

ChatViewModel是一个简单的类,它继承自我们之前创建的ViewModel基类。在第一个代码练习中,我们将创建这个类,添加相关的using语句,并添加一个名为 Messages 的属性,用于存储我们收到的消息。视图将使用消息集合来在ListView中显示消息。

由于这是一个大块的代码,我们建议您先编写它,然后按照编号列表来了解已添加到类中的内容:

  1. Chat项目的ViewModels文件夹中创建一个名为ChatViewModel的新类。

  2. 将类设置为public,并从ViewModel基类继承,以从基类获得共同的基本功能。

  3. 添加一个名为chatServicereadonly属性,类型为IChatService。这将存储一个实现IChatService的对象的引用,并使ChatService的具体实现可替换。将任何服务公开为接口是一个良好的实践。

  4. 添加一个名为Messages的公共属性,类型为public ObservableCollection<Message>,带有私有的 setter。这个集合将保存所有消息。私有的 setter 使得该属性无法从类外部访问。这通过确保消息只能在类内部插入来维护集合的完整性。

  5. 添加一个名为chatService的构造函数参数,类型为IChatService。当我们使用依赖注入时,这是Autofac将注入实现IChatService的对象的地方。

  6. 在构造函数中,将chatService参数分配给chatService属性。这将存储对ChatService的引用,以便我们在ChatViewModel的生命周期内使用它。

  7. 在构造函数中,将Messages属性实例化为一个新的ObservableCollection<Message>

  8. 在构造函数中,创建一个Task.Run语句,如果chatService.IsConnected属性为false,则调用chatService.CreateConnection()方法。通过发送一个新的UserConnected消息来结束Task.Run语句:

 using System;
 using System.Collections.ObjectModel;
 using System.IO;
 using System.Linq;
 using System.Threading.Tasks;
 using System.Windows.Input;
 using Acr.UserDialogs;
 using Chat.Messages;
 using Chat.Services;
 using Plugin.Media;
 using Plugin.Media.Abstractions;
 using Xamarin.Forms;

 namespace Chat.ViewModels
 {
     public class ChatViewModel : ViewModel
     {
         private readonly IChatService chatService;
         public ObservableCollection<Message> Messages { get; 
         private set; }

         public ChatViewModel(IChatService chatService)
         {
             this.chatService = chatService;

             Messages = new ObservableCollection<Message>();

             Task.Run(async() =>
             {
                 if(!chatService.IsConnected)
                 {
                     await chatService.CreateConnection();
                 }

                 await chatService.SendMessage(new 
                 UserConnectedMessage(User));
             });
         }
    }
}

现在我们已经实例化了ChatViewModel,是时候添加一个属性,用于保存用户当前输入的内容。

添加文本属性

在 GUI 的底部,将有一个文本字段(输入控件),允许用户输入消息。这个输入将与ChatViewModel中的一个我们称为Text的属性进行数据绑定。每当用户更改文本时,将设置此属性。这是经典的数据绑定:

  1. 添加一个名为text的新私有字段,类型为string

  2. 添加一个名为Text的公共属性,在 getter 中返回私有文本字段,并在 setter 中调用基类的Set()方法。Set方法在ViewModel基类中定义,并且如果ChatViewModel中的属性发生变化,它将向视图引发事件,有效地保持它们的同步:

private string text;
public string Text
{
    get => text;
    set => Set(ref text, value);
} 

现在我们已经准备好进行数据绑定。让我们看一些从ChatService接收消息的代码。

接收消息

当从服务器通过 SignalR 发送消息时,ChatService将解析此消息并将其转换为一个 Message 对象。然后它将引发一个名为NewMessage的事件,该事件在 ChatService 中定义。

在本节中,我们将实现一个事件处理程序来处理这些事件,并将它们添加到 Messages 集合中,除非集合中已经存在具有相同 ID 的消息。

同样,按照以下步骤并查看代码:

  1. ChatViewModel中,创建一个名为ChatService_NewMessage的方法,它将是一个标准的事件处理程序。它有两个参数:sender,类型为object,和e,类型为Events.NewMessageEventArgs

  2. 在这个方法中加入Device.BeginInvokeOnMainThread(),因为我们将要向消息集合中添加消息。添加到此集合的项目将修改视图,任何修改视图的代码都必须在 UI 线程上运行。

  3. Device.BeginInvokeOnMainThread中,如果集合中不存在具有特定Message.Id的消息,则将来自e.Message的传入消息添加到Messages集合中。这是为了避免消息重复。

该方法应如下所示:

private void ChatService_NewMessage(object sender, Events.NewMessageEventArgs e)
{
    Device.BeginInvokeOnMainThread(() =>
    {
        if (!Messages.Any(x => x.Id == e.Message.Id))
        {
            Messages.Add(e.Message);
        }
    });
} 

当定义事件处理程序时,我们需要在构造函数中将其挂钩:

  1. 找到ChatViewModel类的构造函数。

  2. chatService.NewMessage事件与我们刚刚创建的ChatService_NewMessage处理程序连接起来。这样做的一个好地方是在实例化Messages集合下面。

加粗标记的代码是我们应该添加到ChatViewModel类中的:

public ChatViewModel(IChatService chatService)
{
    this.chatService = chatService;

    Messages = new ObservableCollection<Message>();

    chatService.NewMessage += ChatService_NewMessage;

    Task.Run(async() =>
    {
        if(!chatService.IsConnected)
        {
            await chatService.CreateConnection();
        }

        await chatService.SendMessage(new UserConnectedMessage(User));
    });
} 

应用现在将能够接收消息。那么如何发送消息呢?敬请关注!

创建 LocalSimpleTextMessage 类

为了更容易识别消息是来自服务器还是由执行代码的设备上的用户发送的,我们将创建一个LocalSimpleTextMessage

  1. Chat.Messages项目中创建一个名为LocalSimpleTextMessage的新类。

  2. SimpleTextMessage添加为基类。

  3. 创建一个以SimpleTextMessage为参数的构造函数。

  4. 将值设置为参数中的所有基本属性的值,如下面的代码所示:

public class LocalSimpleTextMessage : SimpleTextMessage
{
    public LocalSimpleTextMessage(SimpleTextMessage message)
    {
        Id = message.Id;
        Text = message.Text;
        Timestamp = message.Timestamp;
        Username = message.Username;
        TypeInfo = message.TypeInfo;
    }
}

发送文本消息

发送文本消息也非常简单。我们需要创建一个可以为 GUI 进行数据绑定的命令。当用户按下回车键或点击发送按钮时,命令将被执行。当用户执行这两个操作之一时,命令将创建一个新的SimpleTextMessage并传入当前用户以标识消息给其他用户。我们将从ChatViewModeltext属性中复制文本,而这个属性又与Entry控件同步。

然后,我们将把消息添加到消息集合中,触发将处理消息的ListView更新的操作。之后,我们将把消息传递给ChatService并清除ChatViewModel的文本属性。通过这样做,我们通知 GUI 它已经改变,并让数据绑定魔法清除字段。

参考以下步骤并查看代码:

  1. 创建一个名为SendICommand类型的新属性。

  2. 分配一个新的Command实例,并按照以下步骤实现它。

  3. 通过将基类的 User 属性作为参数传递来创建SimpleTextMessage类的新实例。将该实例分配给名为message的变量。

  4. 将消息变量的Text属性设置为ChatViewModel类的Text属性。这将复制稍后由 GUI 定义的聊天输入中的当前文本。

  5. 创建一个LocalSimpleTextMessage对象,并将消息变量作为构造函数参数传入。LocalSimpleTextMessageSimpleTextMessage,使视图能够识别它作为应用用户发送的消息,并在聊天区域的右侧有效地呈现它。将LocalSimpleTextMessage实例添加到 Messages 集合中。这将在视图中显示消息。

  6. 调用chatService.SendMessage()方法并将消息变量作为参数传递。

  7. 清空ChatViewModelText属性以清除 GUI 中的输入控件:

public ICommand Send => new Command(async()=> 
{
    var message = new SimpleTextMessage(User)
    {
        Text = this.Text
    };

    Messages.Add(new LocalSimpleTextMessage(message));

    await chatService.SendMessage(message);

    Text = string.Empty;
}); 

如果不能发送照片,聊天应用有何用?让我们在下一节中实现这一点。

安装 Acr.UserDialogs 插件

Acr.UserDialogs是一个插件,可以在代码中使用几个标准用户对话框,这些对话框在各个平台之间共享。要安装和配置它,我们需要遵循一些步骤:

  1. Acr.UserDialogs NuGet 包安装到Chat-Chat.iOSChat.Android项目中。

  2. MainActivity.cs文件中,在OnCreate方法中添加UserDialogs.Init(this)

protected override void OnCreate(Bundle savedInstanceState)
{
    TabLayoutResource = Resource.Layout.Tabbar;
    ToolbarResource = Resource.Layout.Toolbar;

    base.OnCreate(savedInstanceState);

    UserDialogs.Init(this);

    global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
    LoadApplication(new App());
}

安装媒体插件

我们将使用Xam.Plugin.Media NuGet 包来访问设备的照片库。我们需要在解决方案的Chat-Chat.iOSChat.Android项目中安装该包。但是,在使用该包之前,我们需要为每个平台进行一些配置。我们将从 Android 开始:

  1. 该插件需要WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE权限。插件将为我们添加这些权限,但我们需要在MainActivity.cs中覆盖OnRequestPermissionResult

  2. 调用OnRequestPermissionsResult方法。

  3. MainActivity.cs文件的OnCreate方法中,在 Xamarin.Forms 初始化后添加CrossCurrentActivity.Current.Init(this, savedInstanceState),如下面的代码所示:

public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Android.Content.PM.Permission[] grantResults)
{
   Plugin.Permissions.PermissionsImplementation.Current.OnRequestPermissionsResult(requestCode, permissions, grantResults);
} 

我们还需要为用户可以选择照片的文件路径添加一些配置:

  1. 在 Android 项目的Resources文件夹中添加一个名为xml的文件夹。

  2. 在新文件夹中创建一个名为file_paths.xml的新 XML 文件。

  3. 将以下代码添加到file_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="my_images" path="Pictures" />
    <external-files-path name="my_movies" path="Movies" />
</paths>

设置插件的最后一件事是在 Android 项目的AndroidManifest.xml字段中的应用程序元素中添加以下代码:

<manifest  android:versionCode="1" android:versionName="1.0" package="xfb.Chat">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27" />
     <application android:label="Chat.Android">
      <provider 
      android:name="android.support.v4.content.FileProvider"   
      android:authorities="${applicationId}.fileprovider" 
      android:exported="false" android:grantUriPermissions="true">
 <meta-data android:name="android.support.FILE_PROVIDER_PATHS" 
      android:resource="@xml/file_paths"></meta-data>
 </provider>
     </application>
 </manifest> 

对于 iOS 项目,我们唯一需要做的就是在info.plist中添加以下四个用途描述:

<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photos.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs access to the photo gallery.</string>

发送照片

为了能够发送照片,我们将不得不使用照片的来源。在我们的情况下,我们将使用相机作为来源。相机将在拍摄后将照片作为流返回。我们需要将该流转换为字节数组,然后最终将其 Base64 编码为一个易于通过 SignalR 发送的字符串。

我们即将创建的名为ReadFully()的方法接受一个流并将其转换为字节数组,这是实现 Base64 编码字符串的一步。这是一个标准的代码片段,它创建一个缓冲区,当我们读取Stream参数并将其以块的形式写入MemoryStream直到读取完整的流时,将使用该缓冲区,因此方法的名称。

跟着检查代码:

  1. 创建一个名为ReadFully的方法,该方法接受名为inputstream作为参数并返回一个byte数组。

  2. 声明一个byte[]类型的buffer变量,并将其初始化为 16KB 大小的字节数组(16 * 1024)。

  3. 在使用语句内,创建一个名为ms的新MemoryStream

  4. Stream的输入读取到ms变量中:

private byte[] ReadFully(Stream input)
{
    byte[] buffer = new byte[16 * 1024];
    using (MemoryStream ms = new MemoryStream())
    {
        int read;
        while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
        {
            ms.Write(buffer, 0, read);
        }
        return ms.ToArray();
    }
} 

接下来,我们有一大块代码。该代码公开了一个命令,当用户在应用程序中点击照片按钮时将执行该命令。它首先配置了CrossMedia(一个媒体插件),指示照片的质量,然后启动了照片选择器。当照片选择器从async调用PickPhotoAsync()返回时,我们开始上传照片。为了通知用户,我们使用UserDialogs.Instance.ShowLoading创建一个带有消息的加载覆盖,以指示我们正在上传照片。

然后我们将获取照片的流,使用ReadFully()方法将其转换为字节数组,并将其 Base64 编码为字符串。该字符串将被包装在一个PhotoMessage实例中,添加到ChatViewModel的本地Message集合中,然后发送到服务器。

按照以下步骤并学习代码:

  1. 创建一个名为PhotoICommand类型的新属性。为其分配一个新的Command实例。

  2. 创建一个匿名的async方法(lambda 表达式),并将即将定义的代码添加到其中。您可以在随后的代码部分中看到该方法的完整代码。

  3. 创建PickMediaOptions类的一个新实例,并将CompressionQuality属性设置为50

  4. 使用async方法调用CrossMedia.Current.PickPhotoAsync,并将结果保存到名为photo的本地变量中。

  5. 安装 NuGet 包。

  6. 通过调用UserDialogs.Instance.ShowLoading()显示一个消息对话框,文本为“正在上传照片”。

  7. 通过调用photo变量的GetStream()方法获取照片流,并将其保存到名为stream的变量中。

  8. 通过调用ReadFully()方法将流转换为字节数组。

  9. 使用Convert.ToBase64String()方法将字节数组转换为 Base64 编码的字符串。将字符串保存到名为base64photo的变量中。

  10. 创建一个新的PhotoMessage实例,并将User作为构造函数参数传递。将Base64Photo属性设置为base64photo变量,将FileEnding属性设置为photo.Path字符串的文件结束,使用字符串对象的Split函数。将新的PhotoMessage实例存储在名为message的变量中。

  11. 将消息对象添加到Messages集合中。

  12. 通过调用异步的chatService.SendMessage()方法将消息发送到服务器。

  13. 通过调用UserDialogs.Instance.HideLoading()隐藏加载对话框。

以下代码显示了如何实现这一点:

public ICommand Photo => new Command(async() =>
{
    var options = new PickMediaOptions();
    options.CompressionQuality = 50;

    var photo = await CrossMedia.Current.PickPhotoAsync();

    UserDialogs.Instance.ShowLoading("Uploading photo");

    var stream = photo.GetStream();
    var bytes = ReadFully(stream);

    var base64photo = Convert.ToBase64String(bytes);

    var message = new PhotoMessage(User)
    {
        Base64Photo = base64photo,
        FileEnding = photo.Path.Split('.').Last()
    };

    Messages.Add(message);
    await chatService.SendMessage(message);

    UserDialogs.Instance.HideLoading();
}); 

ChatViewModel已经完成。现在是时候可视化我们的 GUI 了。

创建 ChatView

ChatView 负责创建用户将与之交互的用户界面。它将显示本地和远程消息,包括文本和照片,并在远程用户加入聊天时通知用户。我们将首先创建一个转换器,将以 Base64 编码的字符串表示的照片转换为可用作 XAML 中图像控件源的ImageSource

创建 Base64ToImageConverter

当我们使用手机相机拍照时,它将作为字节数组交给我们。为了将其发送到服务器,我们将其转换为 Base64 编码的字符串。为了在本地显示该消息,我们需要将其转换回字节数组,然后将该字节数组传递给ImageSource类的辅助方法,以创建ImageSource对象的实例。该对象将对Image控件有意义,并显示图像。

由于这里有很多代码,我们建议您按照步骤进行,并在跟随时仔细查看每行代码:

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

  2. Converters文件夹中创建一个名为Base64ImageConverter的新类;让该类实现IValueConverter接口。

  3. 在类的Convert()方法中,将名为 value 的对象参数转换为名为base64String的字符串。

  4. 使用System.Convert.FromBase64String()方法将base64String转换为字节数组。将结果保存到名为bytes的变量中。

  5. 通过将字节数组传递到其构造函数来创建一个新的MemoryStream。将流保存到名为stream的变量中。

  6. 调用ImageSource.FromStream()方法,并将流作为返回流变量的 lambda 表达式传递。返回创建的ImageSource对象。

  7. 不需要实现ConvertBack()方法,因为我们永远不会通过数据绑定将图像转换回 Base64 编码的字符串。我们只需让它抛出NotImplementedException

using System;
using System.Globalization;
using Xamarin.Forms;
using System.IO;

namespace Chat.Converters
{
    public class Base64ToImageConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, 
                              object parameter, CultureInfo culture)
        {
            var base64string = (string)value;
            var bytes = 
            System.Convert.FromBase64String(base64string);
            var stream = new MemoryStream(bytes);
            return ImageSource.FromStream(() => stream);
        }

        public object ConvertBack(object value, Type targetType,
                                  object parameter, CultureInfo 
                                  culture)
        {
            throw new NotImplementedException();
        }
    }
} 

现在是时候开始向视图添加一些实际的 XAML 代码了。我们将首先创建主要的布局骨架,然后逐渐构建,直到完成视图。

创建骨架 ChatView

这个 XAML 文件将包含我们发送和接收的消息列表的视图。创建这个文件相当大,所以在这一部分,我建议你复制 XAML 并仔细研究每一步:

  1. Views文件夹中创建一个名为ChatView的新XAML Content Page

  2. Chat.SelectorsChat.Converters添加 XML 命名空间,并将它们命名为selectorsconverters

  3. 添加一个ContentPage.Resources节点,稍后将包含此视图的资源。

  4. ScrollView添加为页面内容。

  5. Grid作为ScrollView的唯一子元素,并通过将x:Name属性设置为MainGrid来命名它。

  6. 创建一个包含三行的RowDefinitions元素。第一行的高度应为*,第二行的高度为1,第三行的高度根据平台使用OnPlatform元素进行设置。

  7. 为稍后插入的ListView保存一些空间。

  8. 通过将HeightRequest属性设置为1BackgroundColor属性设置为#33000000,将Grid.Row属性设置为1,添加一个BoxView,它将充当视觉分隔符。这将在网格的一单位高的行中定位BoxView,有效地在屏幕上绘制一条单行。

  9. 添加另一个Grid,通过将Grid.Row属性设置为2来使用第三行的空间。还可以通过将Padding属性设置为10来添加一些填充。在网格中定义三行,高度分别为30*30

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage  

             x:Class="Chat.Views.ChatView">
    <ContentPage.Resources>
        <!-- TODO Add resources -->
    </ContentPage.Resources>
    <ScrollView>
        <Grid x:Name="MainGrid">
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="1" />
                <RowDefinition>
                    <RowDefinition.Height>
                        <OnPlatform x:TypeArguments="GridLength">
                            <On Platform="iOS" Value="50" />
                            <On Platform="Android" Value="100" />
                        </OnPlatform>
                    </RowDefinition.Height>
                </RowDefinition>
            </Grid.RowDefinitions>

            <!-- TODO Add ListView -->

            <BoxView Grid.Row="1" HeightRequest="1" 
            BackgroundColor="#33000000" />
            <Grid Grid.Row="2" Padding="10">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="30" />
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="30" />
                </Grid.ColumnDefinitions>
                <!-- TODO Add buttons and entry controls -->

             </Grid>
         </Grid>
     </ScrollView>
 </ContentPage> 

现在我们已经完成了页面的主要骨架,我们需要开始添加一些具体的内容。首先,我们将添加ResourceDictionary来创建一个DataTemplate选择器,用于为不同的聊天消息选择正确的布局。然后,我们需要使用Base64ToImageConverter,为此,我们需要在视图中定义它。

添加 ResourceDictionary

现在是时候向视图添加一些资源了。在这种情况下,我们将添加一个模板选择器,稍后我们将创建它,以及我们之前创建的Base64ToImageConverter。模板选择器将查看我们将绑定到ListView的每一行,该行将呈现消息并选择最适合该消息的布局模板。为了能够从 XAML 中使用这些代码片段,我们需要定义 XAML 解析器找到它们的方法:

  1. ContentPage.Resources元素内部找到<!-- TODO Add resources -->注释。

  2. 步骤 1中的注释下面,按照示例中的 XAML 添加 XAML:

        <ResourceDictionary>
            <selectors:ChatMessageSelector 
            x:Key="SelectMessageTemplate" />
            <converters:Base64ToImageConverter x:Key="ToImage" />
        </ResourceDictionary>

这将创建我们定义的每个资源的一个实例,并使其可以访问到视图的其余部分。

添加 ListView

我们将使用ListView来显示聊天应用中的消息。再次,按照步骤并查看代码,确保你理解每一步:

  1. ChatView.xaml文件中找到<!-- TODO Add ListView -->注释。

  2. 添加一个ListView,并将x:Name属性设置为MessageList

  3. 通过将ItemsSource属性设置为{Binding Messages}来对ListView进行数据绑定。这将使ListView意识到ObservableCollection<Message>中的更改,该集合通过Messages属性公开。每当添加或删除消息时,ListView都会更新以反映这一变化。

  4. 将我们在上一节中定义的SelectMessageTemplate资源添加到ItemTemplate属性。这将在每次添加项目时运行一些代码,以确保我们以编程方式选择特定消息的正确视觉模板。别担心,我们很快就会写那段代码。

  5. 通过将HasUnevenRows属性设置为true,确保ListView能够创建不均匀高度的行。

  6. 我们需要设置的最后一个属性是SeparatorVisibility,我们将其设置为None,以避免在每一行之间添加一行。

  7. 我们定义了一个占位符,我们将在其中添加资源。我们将添加的资源是我们将用于呈现不同类型消息的不同DataTemplate

XAML 应该如下所示:

<ListView x:Name="MessageList" ItemsSource="{Binding Messages}" 
 ItemTemplate="{StaticResource SelectMessageTemplate}" 
 HasUnevenRows="true" SeparatorVisibility="None">
   <ListView.Resources>
     <ResourceDictionary>
       <!-- Resources go here later on --> 
     </ResourceDictionary>
   </ListView.Resources>
</ListView>

添加模板

我们现在将添加五个不同的模板,每个模板对应应用程序发送或接收的特定消息类型。每个这些模板都放在前一节代码片段中的<!--稍后放置资源-->注释下。

我们不会逐步解释每个模板,因为它们包含的 XAML 应该在这一点上开始感到熟悉。

每个模板都以相同的方式开始:根元素是具有设置名称的DataTemplate。名称很重要,因为我们很快将在代码中引用它。DataTemplate的第一个子元素始终是ViewCell,并将IsEnabled属性设置为false,以避免用户能够与内容交互。我们只是想显示它。此元素之后的内容是构建行的实际内容。

ViewCell内部的绑定也将针对ListView呈现的每个项目或行进行本地化。在这种情况下,这将是Message类的一个实例,因为我们正在将ListView的数据绑定到Message对象的集合。您将在代码中看到一些StyleClass属性。在最终使用层叠样式表CSS)对应用程序进行最终样式设置时,将使用这些属性。

我们的任务是在<!--稍后放置资源-->注释下编写每个模板。

SimpleText是当消息是远程消息时选择的DataTemplate。它将在列表视图的左侧呈现,就像您可能期望的那样。它显示了usernametext消息:

<DataTemplate x:Key="SimpleText">
    <ViewCell IsEnabled="false">
        <Grid Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Frame StyleClass="remoteMessage" HasShadow="false">
                <StackLayout>
                 <Label Text="{Binding Username}" 
                  StyleClass="chatHeader" />
                 <Label Text="{Binding Text}" StyleClass="chatText" />
                </StackLayout>
            </Frame>
        </Grid>
    </ViewCell>
</DataTemplate>

LocalSimpleText模板与SimpleText数据模板相同,只是通过将Grid.Column属性设置为1,有效地使用右列,它在ListView的右侧呈现:

<DataTemplate x:Key="LocalSimpleText">
    <ViewCell IsEnabled="false">
        <Grid Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Frame Grid.Column="1" StyleClass="localMessage" 
            HasShadow="false">
                <StackLayout>
                  <Label Text="{Binding Username}" 
                  StyleClass="chatHeader" />
                  <Label Text="{Binding Text}" StyleClass="chatText" />
                </StackLayout>
            </Frame>
        </Grid>
    </ViewCell>
</DataTemplate> 

当用户连接到聊天时使用此DataTemplate

<DataTemplate x:Key="UserConnected">
    <ViewCell IsEnabled="false">
        <StackLayout Padding="10" BackgroundColor="#33000000" 
        Orientation="Horizontal">
            <Label Text="{Binding Username}" StyleClass="chatHeader" 
            VerticalOptions="Center" />
            <Label Text="connected" StyleClass="chatText" 
            VerticalOptions="Center" />
        </StackLayout>
    </ViewCell>
</DataTemplate>

通过 URL 访问服务器上上传的照片。此DataTemplate基于 URL 显示图像,并用于远程图像:

<DataTemplate x:Key="Photo">
    <ViewCell IsEnabled="false">
        <Grid Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <StackLayout>
                <Label Text="{Binding Username}" 
                 StyleClass="chatHeader" />
                <Image Source="{Binding Url}" Aspect="AspectFill" 
                HeightRequest="150" HorizontalOptions="Fill" />
            </StackLayout>
        </Grid>
    </ViewCell>
</DataTemplate>

包含用户发送并直接基于我们从相机生成的 Base64 编码图像进行渲染的照片的消息。由于我们不想等待图像上传,我们使用这个DataTemplate,它利用我们之前编写的Base64ImageConverter将字符串转换为可以由 Image 控件显示的ImageSource

<DataTemplate x:Key="LocalPhoto">
    <ViewCell IsEnabled="false">
        <Grid Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <StackLayout Grid.Column="1">
                <Label Text="{Binding Username}" 
                StyleClass="chatHeader" />
                <Image Source="{Binding Base64Photo, Converter=
                {StaticResource ToImage}}" 
                Aspect="AspectFill" HeightRequest="150" 
                HorizontalOptions="Fill" />
            </StackLayout>
        </Grid>
    </ViewCell>
</DataTemplate>

这些就是我们需要的所有模板。现在是时候添加一些代码,以确保我们选择正确的模板来显示消息。

创建模板选择器

使用模板选择器是一种根据正在进行数据绑定的项目注入不同布局的强大方式。在这种情况下,我们将查看要显示的每条消息,并为它们选择最佳的DataTemplate。代码有些重复,所以我们将使用与 XAML 相同的方法——简单地添加代码,让您自己学习它:

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

  2. Selectors文件夹中创建一个名为ChatMessagesSelector的新类,并从DataTemplateSelector继承它。

  3. 添加以下代码,它将查看每个数据绑定的对象,并从我们刚刚添加的资源中提取正确的DataTemplate

using Chat.Messages;
using Xamarin.Forms;

namespace Chat.Selectors
{
    public class ChatMessagesSelector : DataTemplateSelector
    {
        protected override DataTemplate OnSelectTemplate(object 
        item, BindableObject container)
        {
            var list = (ListView)container;

            if(item is LocalSimpleTextMessage)
            {
                return   
            (DataTemplate)list.Resources["LocalSimpleText"];
            }
            else if(item is SimpleTextMessage)
            {
                return (DataTemplate)list.Resources["SimpleText"];
            }
            else if(item is UserConnectedMessage)
            {
                return 
            (DataTemplate)list.Resources["UserConnected"];
            }
            else if(item is PhotoUrlMessage)
            {
                return (DataTemplate)list.Resources["Photo"];
            }
            else if (item is PhotoMessage)
            {
                return (DataTemplate)list.Resources["LocalPhoto"];
            }

            return null;
        }
    }
}

添加按钮和输入控件

现在我们将添加用户用于编写聊天消息的按钮和输入。我们使用的图标可以在本章的 GitHub 存储库中找到。对于 Android,图标将放在Resource文件夹内的Drawable文件夹中,而对于 iOS,它们将放在Resource文件夹中。GitHub 上的同一文件夹中有这些图标:

  1. ChatView.xaml文件中找到<!-- TODO Add buttons and entry controls -->的注释。

  2. 添加一个ImageButtonSource应设置为photo.pngCommand设置为{Binding Photo}VerticalOptionsHorizontalOptions设置为CenterSource用于显示图像;当用户点击图像时,Command将被执行,HorizontalOptionsVerticalOptions将用于将图像居中在控件的中间。

  3. 添加一个Entry控件,允许用户输入要发送的消息。Text属性应设置为{Binding Text}。将Grid.Column属性设置为1,将ReturnCommand设置为{Binding Send},以在用户按下Enter时执行ChatViewModel中的发送命令。

  4. 一个ImageButtonGrid.Column属性设置为2Source设置为send.pngCommand设置为{Binding Send}(与返回命令相同)。水平和垂直居中:

<ImageButton Source="photo.png" Command="{Binding Photo}"
             VerticalOptions="Center" HorizontalOptions="Center" />
             <Entry Text="{Binding Text}" Grid.Column="1" 
             ReturnCommand="{Binding Send}" />
<ImageButton Grid.Column="2" Source="send.png" 
             Command="{Binding Send}" 
             VerticalOptions="Center" HorizontalOptions="Center" />

修复代码后面

现在 XAML 已经完成,我们需要在代码后面做一些工作。我们将首先修改类为部分类,然后添加一些using 语句

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

  2. 将类标记为partial

  3. 添加一个名为viewModelChatViewModel类型的private字段,它将保存对ChatViewModel的本地引用。

  4. Chat.ViewModelsXamarin.FormsXamarin.Forms.PlatformConfiguration.iOSSpecific添加using 语句

现在该类应该如下所示。粗体代码表示应该已经更改的内容:

using System.Linq;
using Chat.ViewModels;
using Xamarin.Forms;
using Xamarin.Forms.PlatformConfiguration.iOSSpecific;

namespace Chat.Views
{
    public partial class ChatView : ContentPage
    {
        private ChatViewModel viewModel;

        public ChatView()
        {
            InitializeComponent();
        }
    }
}

当有新消息到达时,将其添加到ChatViewModel中的 Messages 集合中。为了确保MessageListListView适当滚动以使新消息可见,我们需要编写一些额外的代码:

  1. 创建一个名为Messages_CollectionChanged的新方法,它以对象作为第一个参数,以NotifyCollectionChangedEventArgs作为第二个参数。

  2. 调用MessageList.ScrollTo()方法,并通过调用viewModel.Messages.Last()viewModel.Messages集合中的最后一条消息传递给它。第二个参数应设置为ScrollPosition.End,表示我们要使整个消息ListView行可见。第三个参数应设置为true以启用动画。

该方法现在应该如下所示:

private void Messages_CollectionChanged(object sender, 
            System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
    MessageList.ScrollTo(viewModel.Messages.Last(), 
    ScrollToPosition.End, true);
}

现在是时候扩展构造函数,使其以ChatViewModel作为参数,并以我们习惯的方式设置BindingContext。构造函数还将确保在渲染控件时使用安全区域,并确保我们连接到处理ChatViewModelMessages集合中的更改所必需的事件:

  1. ChatView类中修改构造函数,使其以ChatViewModel作为唯一参数,并将参数命名为viewModel

  2. 将构造函数中的viewModel参数分配给类中的本地viewModel字段。

  3. InitializeComponent()方法的调用中,添加一个特定于平台的调用SetUseSafeArea(true)方法,以确保应用程序在 iPhone X 上可视上是安全的,不会部分隐藏在顶部的刘海后面:

 public ChatView(ChatViewModel viewModel)
 {
     this.viewModel = viewModel;

     InitializeComponent();
     On<Xamarin.Forms.PlatformConfiguration.iOS>
     ().SetUseSafeArea(true);

 viewModel.Messages.CollectionChanged += 
     Messages_CollectionChanged;
 BindingContext = viewModel;
 }

每次视图出现时,都会调用OnAppearing()方法。这个方法是虚拟的,我们可以重写它。我们将使用这个特性来确保MainGrid的高度是正确的。这是因为我们必须将所有内容包装在ScrollView中,因为视图在键盘出现时必须能够滚动。如果我们不计算MainGrid的宽度,它可能会比屏幕大,因为ScrollView允许它扩展。

  1. 覆盖OnAppearing()方法。

  2. 通过调用特定于平台的方法On<Xamarin.Forms.PlatformConfiguration.iOS>().SafeAreaInsets()来计算要使用的安全区域。这将返回一个Xamarin.Forms.Thickness对象,其中包含我们需要的插入信息,以便计算MainGrid的高度。将Thickness对象分配给名为safeArea的变量。

  3. MainGrid.HeightRequest属性设置为视图的高度(this.Height),然后减去safeAreaTopBottom属性:

protected override void OnAppearing()
{
    base.OnAppearing();
    var safeArea = On<Xamarin.Forms.PlatformConfiguration.iOS>
    ().SafeAreaInsets();
    MainGrid.HeightRequest = this.Height - safeArea.Top - 
    safeArea.Bottom;
} 

样式

样式是应用程序的重要组成部分。就像 HTML 一样,您可以通过直接设置每个控件的属性或在应用程序的资源字典中设置Style元素来进行样式设置。然而,最近,Xamarin.Forms 出现了一种新的样式设置方式,即使用层叠样式表,通常称为 CSS。

由于 CSS 并不能覆盖所有情况,我们还将回退到标准的应用程序资源字典样式。

使用 CSS 进行样式设置

Xamarin.Forms 支持通过 CSS 文件进行样式设置。它具有您从普通 CSS 中期望的功能的子集,但是每个版本的支持都在不断改进。我们将使用两种不同的选择器来应用样式。

首先,让我们创建样式表,然后再讨论其内容:

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

  2. Css文件夹中创建一个新的文本文件,并将其命名为Styles.css

  3. 将以下样式表复制到该文件中:

button {
 background-color: #A4243B;
 color: white;
}

.chatHeader {
 color: white;
 font-style: bold;
 font-size: small;
}

.chatText {
 color: white;
 font-size: small;
}

.remoteMessage {
 background-color: #F04D6A;
 padding: 10;
}

.localMessage {
 background-color: #24A43B;
 padding: 10;
}

第一个选择器 button 适用于整个应用程序中的每个按钮控件。它将背景颜色设置为#A4243B,前景颜色设置为白色。您几乎可以为 Xamarin.Forms 中的每种类型的控件执行此操作。

我们使用的第二个选择器是类选择器,以句点开头,例如.chatHeader。这些选择器在 XAML 中与StyleClass属性一起使用。回顾一下我们之前创建的ChatView.xaml文件,您将在模板资源中找到这些内容。

CSS 中的每个属性都映射到控件本身的属性。还有一些特定于 Xamarin.Forms 的属性可以使用,但这些超出了本书的范围。如果您在互联网上搜索 Xamarin.Forms 和 CSS,您将找到深入了解此内容所需的所有信息。

应用样式表

样式表本身是不够的。我们需要将其应用到我们的应用程序中。我们还需要在 NavigationPage 上设置一些样式,因为我们无法直接从 CSS 中访问它。

我们将添加一些资源和对样式表的引用。复制代码并参考步骤来学习每行代码的作用:

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

  2. Application.Resources节点中,添加一个<StyleSheet Source="/Css/Styles.css" />节点来引用样式表。

  3. 以下是StyleSheet节点。添加一个TargetType设置为"NavigationPage"Style节点,并为BarBackgroundColor属性创建一个值为"#273E47"的 setter,为BarTextColor属性创建一个值为"White"的 setter。

App.xaml文件现在应如下所示:

<?xml version="1.0" encoding="utf-8"?>
<Application  

             x:Class="Chat.App">
    <Application.Resources>
        <StyleSheet Source="/Css/Styles.css" />
        <ResourceDictionary>
 <Style TargetType="NavigationPage">
 <Setter Property="BarBackgroundColor" Value="#273E47" />
 <Setter Property="BarTextColor" Value="White" />
 </Style>
 </ResourceDictionary>
    </Application.Resources>
</Application> 

处理生命周期事件

最后,我们需要添加一些生命周期事件,以便在应用程序进入睡眠状态或再次唤醒时处理我们的 SignalR 连接:

  1. 打开App.Xaml.cs文件。

  2. 在类中的某个地方添加以下代码:

protected override void OnSleep()
{
    var chatService = Resolver.Resolve<IChatService>();
    chatService.Dispose();
}

protected override void OnResume()
{
    Task.Run(async() =>
    {
        var chatService = Resolver.Resolve<IChatService>();

        if (!chatService.IsConnected)
        {
            await chatService.CreateConnection();
        }
    });

    Page view = null;

    if(ViewModel.User != null)
    {
        view = Resolver.Resolve<ChatView>();
    }
    else
    {
        view = Resolver.Resolve<MainView>();
    }

    var navigationPage = new NavigationPage(view);
    MainPage = navigationPage;
} 

当用户最小化应用程序时,将调用OnSleep()方法,并通过关闭活动连接来处理任何正在运行的chatServiceOnResume()方法有更多的内容。如果没有活动连接,它将重新创建连接,并根据用户是否已设置,解析到正确的视图。如果用户不存在,它将显示MainView;否则它将显示ChatView。最后,它将选定的视图包装在导航页面中。

总结

到此为止 - 干得好!我们现在已经创建了一个连接到后端的聊天应用程序。我们已经学会了如何使用 SignalR,如何用 CSS 样式化应用程序,如何在ListView中使用模板选择器,以及如何使用值转换器将byte[]转换为 Xamarin.Forms 的ImageSource

在下一章中,我们将深入探讨增强现实世界!我们将使用 UrhoSharp 和 ARKit(iOS)以及 ARCore(Android)共同为 iOS 和 Android 创建一个 AR 游戏。

第八章:创建增强现实游戏

在本章中,我们将使用 Xamarin.Forms 探索增强现实AR)。我们将使用自定义渲染器注入特定于平台的代码,使用UrhoSharp来渲染场景和处理输入,并使用MessagingCenter在应用程序中传递内部消息。

本章将涵盖以下主题:

  • 设置项目

  • 使用 ARKit

  • 使用 ARCore

  • 学习如何使用 UrhoSharp 来渲染图形和处理输入

  • 使用自定义渲染器注入特定于平台的代码

  • 使用MessagingCenter发送消息

技术要求

为了能够完成这个项目,我们需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参见第一章,Xamarin 简介

您不能在模拟器上运行 AR。要运行 AR,您需要一个物理设备,以及以下软件:

  • 在 iOS 上,您需要 iOS 11 或更高版本,以及一个 A9 处理器或更高版本的设备

  • 在 Android 上,您需要 Android 8.1 和支持 ARCore 的设备

基本理论

本节将描述 AR 的工作原理。实现在不同平台之间略有不同。谷歌的实现称为ARCore,苹果的实现称为ARKit

AR 的全部内容都是关于在相机反馈的基础上叠加计算机图形。这听起来是一件简单的事情,除了您必须以极高的精度跟踪相机位置。谷歌和苹果都编写了一些很棒的 API 来为您完成这个魔术,借助手机的运动传感器和相机数据。我们添加到相机反馈上的计算机图形与周围真实物体的坐标空间同步,使它们看起来就像是图像上看到的一部分。

项目概述

在本章中,我们将创建一个探索 AR 基础知识的游戏。我们还将学习如何在 Xamarin.Forms 中集成 AR 控制。Android 和 iOS 以不同的方式实现 AR,因此我们需要在途中统一平台。我们将使用 UrhoSharp,一个开源的 3D 游戏引擎,来进行渲染。这只是使用.NET 和 C#与 Urho3D 绑定的Urho3D引擎。

游戏将在 AR 中渲染盒子,用户需要点击以使其消失。然后,您可以通过学习 Urho3D 引擎来扩展游戏。

共享代码将放置在一个共享项目中。这与我们迄今为止采取的通常的.NET 标准库方法不同。这样做的原因是,UrhoSharp 在撰写本书时不支持.NET 标准。学习如何创建共享项目也是一个好主意。共享库中的代码本身不会编译。它需要链接到平台项目(如 iOS 或 Android),然后编译器可以编译所有源文件以及平台项目。这与直接将文件复制到该项目中完全相同。因此,通过定义一个共享项目,我们不需要重复编写代码。

这种策略还解锁了另一个强大的功能:条件编译。考虑以下示例:

#if __IOS__ 
   // Only compile this code on iOS
#elif __ANDROID__ 
   // Only compile this code on Android
#endif

上述代码显示了如何在共享代码文件中插入特定于平台的代码。这在这个项目中将非常有用。

该项目的预计构建时间为 90 分钟。

开始项目

是时候开始编码了!但首先,请确保您已经按照第一章中描述的设置好了开发环境,Xamarin 简介

本章将是一个经典的文件|新建项目章节,将逐步指导您完成创建应用程序的过程。完全不需要下载。

创建项目

打开 Visual Studio,然后点击“文件”|“新建”|“项目”,如下截图所示:

这将打开“新建项目”对话框。展开“Visual C#”节点,然后单击“跨平台”。在列表中选择“移动应用程序(Xamarin.Forms)”项目。通过为您的项目命名来完成表单。在本示例中,我们将称我们的应用程序为WhackABox。点击“确定”继续到下一个对话框,如下截图所示:

下一步是选择项目模板和代码共享策略。选择“空白模板”选项以创建最基本的 Xamarin.Forms 应用程序,并确保代码共享策略设置为“共享项目”。在“平台”标题下取消选中“Windows(UWP)”复选框,因为此应用程序只支持iOS和 Android。点击“确定”完成设置向导,让 Visual Studio 为您创建项目。这可能需要几分钟。请注意,本章我们将使用共享项目——这一点非常重要!您可以在以下截图中看到需要选择的字段和选项:

就这样,应用程序已经创建好了。让我们继续更新 Xamarin.Forms 到最新版本。

更新 Xamarin.Forms NuGet 包

目前,您的项目创建时使用的 Xamarin.Forms 版本很可能有点过时。为了纠正这一点,我们需要更新 NuGet 包。请注意,您应该只更新 Xamarin.Forms 包,而不是 Android 包;更新 Android 包可能导致包不同步,导致应用程序根本无法构建。要更新 NuGet 包,请按以下步骤操作:

  1. 在“解决方案资源管理器”中右键单击我们的解决方案。

  2. 点击“管理解决方案的 NuGet 包...”,如下截图所示:

这将在 Visual Studio 中打开 NuGet 包管理器,如下截图所示:

要将 Xamarin.Forms 更新到最新版本,请按以下步骤操作:

  1. 点击“更新”选项卡。

  2. 勾选“Xamarin.Forms”复选框,然后点击“更新”。

  3. 接受任何许可协议。

更新最多需要几分钟。查看输出窗格以获取有关更新的信息。此时,我们可以运行应用程序以确保其正常工作。我们应该在屏幕中央看到“欢迎使用 Xamarin.Forms!”的文本。

将 Android 目标设置为 8.1

ARCore 可用于 Android 8.1 及更高版本。因此,我们将通过以下步骤验证 Android 项目的目标框架:

  1. 在“解决方案资源管理器”中的 Android 项目下双击“属性”节点。

  2. 验证目标框架版本至少为 Android 8.0(Oreo),如下截图所示:

如果目标框架不是至少 Android 8.0(Oreo),则需要选择 Android 8.1(或更高版本)。如果目标框架名称旁边有一个星号,则需要通过以下步骤安装该 SDK:

  1. 在工具栏中找到 Android SDK Manager。

  2. 点击突出显示的按钮打开 SDK Manager,如下截图所示:

这是系统上安装的所有 Android SDK 版本的控制中心:

  1. 展开您想要安装的 SDK 版本。在我们的情况下,这应该至少是 Android 8.1 - Oreo。

  2. 选择 Android SDK 平台<版本号>节点。您还可以安装模拟器映像,供模拟器运行所选版本的 Android。

  3. 点击“应用更改”,如下截图所示:

向 Android 添加相机权限

为了在 Android 中访问相机,我们必须在 Android 清单中添加所需的权限。可以通过以下步骤完成:

  1. 在解决方案资源管理器中打开 Android 项目节点。

  2. 双击属性节点以打开 Android 的属性。

  3. 单击左侧的 Android 清单选项卡,然后向下滚动,直到看到所需权限部分。

  4. 定位相机权限并选中复选框。

  5. 通过单击Ctrl +* S*或文件和保存来保存文件。

现在我们已经配置了 Android,在准备编写一些代码之前,我们只需要在 iOS 上做一个小小的改变。

为 iOS 添加相机使用说明

在 iOS 中,您需要指定为什么需要访问相机。这样做的方法是在 iOS 项目的根文件夹中的info.plist文件中添加条目。info.plist文件是一个 XML 文件,您可以在任何文本编辑器中编辑。但是,更简单的方法是使用 Visual Studio 提供的通用 PList 编辑器。

使用通用 PList 编辑器添加所需的相机使用说明,如下所示:

  1. 定位WhackABox.iOS项目。

  2. 右键单击info.plist,然后单击“使用...”,如下面的屏幕截图所示:

  1. 选择通用 PList 编辑器,然后单击确定,如下面的屏幕截图所示:

  1. 在属性列表的底部找到加号(+)图标。

  2. 单击加号(+)图标以添加新键。确保密钥位于文档的根目录下,而不是在另一个属性下,如下面的屏幕截图所示:

通用 PList 编辑器通过给属性提供更用户友好的名称来帮助您找到正确的属性。让我们添加我们需要的值来描述我们为什么要使用相机:

  1. 在新创建的行上打开下拉菜单。

  2. 选择隐私-相机使用说明。

  3. 在右侧的值字段中写一个好的理由,如下面的屏幕截图所示。原因字段是一个自由文本字段,因此请使用简单的英语描述您的应用程序为什么需要访问相机:

就是这样。 Android 和 iOS 的设置已经完成,现在我们可以专注于有趣的部分-编写代码!

您还可以在任何文本编辑器中打开Info.plist文件,因为它是一个 XML 文件。密钥的名称是

NSCameraUsageDescription,并且必须作为根节点的直接子节点添加。

定义用户界面

我们将首先定义将包装 AR 组件的用户界面。首先,我们将定义一个自定义控件,我们将使用它作为注入包含 AR 组件的UrhoSurface的占位符。然后,我们将在包含有关我们在 AR 中找到多少平面以及世界中有多少活动箱子的网格中添加此控件。游戏的目标是在 AR 中使用手机找到箱子,并点击它们使它们消失。

让我们首先定义自定义的ARView控件。

创建 ARView 控件

ARView控件属于共享项目,因为它将成为两个应用程序的一部分。它是一个标准的 Xamarin.Forms 控件,直接从Xamarin.Forms.View继承。它不会加载任何 XAML(因此它只是一个单一的类),也不会包含任何功能,只是简单地被定义,因此我们可以将它添加到主网格中。

转到 Visual Studio,并按照以下三个步骤创建ARView控件:

  1. WhackABox项目中,添加一个名为Controls的文件夹。

  2. Controls文件夹中创建一个名为ARView的新类。

  3. 将以下代码添加到ARView类中:

using Xamarin.Forms;

namespace WhackABox.Controls
{
    public class ARView : View
    {
    }
} 

我们在这里创建了一个简单的类,没有实现,它继承自Xamarin.Forms.View。这样做的目的是利用每个平台的自定义渲染器,允许我们指定特定于平台的代码插入到我们放置这个控件的 XAML 中。您的项目现在应该如下所示:

ARView控件就那样坐在那里是不行的。我们需要将它添加到MainPage中。

修改 MainPage

我们将替换MainPage的全部内容,并添加对WhackABox.Controls命名空间的引用,以便我们可以使用ARView控件。让我们通过以下步骤来设置这个:

  1. WhackABox项目中,打开MainPage.xaml文件。

  2. 编辑 XAML 以使其看起来像以下代码。粗体的 XAML 表示必须添加的新元素:

<?xml version="1.0" encoding="utf-8">
<ContentPage  

           x:Class="WhackABox.MainPage">

 **<Grid>**
 **<Grid.ColumnDefinitions>**
 **<ColumnDefinition Width="*" />**
 **<ColumnDefinition Width="*" />**
 **</Grid.ColumnDefinitions>**

 **<Grid.RowDefinitions>**
 **<RowDefinition Height="100" />**
 **<RowDefinition Height="*" />**
 **</Grid.RowDefinitions>**

 **<StackLayout Grid.Row="0" Padding="10">**
 **<Label Text="Plane count" />**
 **<Label Text="0" FontSize="Large"  
             x:Name="planeCountLabel" />**
 **</StackLayout>**

 **<StackLayout** **Grid.Row="0"** **Grid.Column="1" Padding="10">**
 **<Label Text="Box count" />**
 **<Label Text="0" FontSize="Large"   
          x:Name="boxCountLabel"/>**
 **</StackLayout>**

 **<controls:ARView Grid.Row="1" Grid.ColumnSpan="2" />**
 **</Grid>**
 </ContentPage> 

现在我们有了代码,让我们一步一步地来看:

  • 首先,我们定义一个控件命名空间,指向代码中的WhackABox.Controls命名空间。这个命名空间用于在 XAML 末尾定位ARView控件。

  • 然后,我们通过将其设置为Grid来定义内容元素。一个页面只能有一个子元素,在这种情况下是一个GridGrid定义了两列和两行。列将Grid分成两个相等的部分,其中有一行在顶部高度为100个单位,另一行占据了下面所有可用的空间。

  • 我们使用前两个单元格来添加StackLayout的实例,其中包含游戏中平面数量和箱子数量的信息。这些StackLayout的实例在网格中的位置由Grid.Row=".."Grid.Column=".."属性定义。请记住,行和列是从零开始的。实际上,您不必为行或列0添加属性,但有时为了提高代码可读性,这样做可能是一个好习惯。

  • 最后,我们有ARView控件,它位于第 1 行,但通过指定Grid.ColumnSpan="2"跨越了两列。

下一步是安装 UrhoSharp,它将是我们用来渲染表示现实增强部分的图形的库。

添加 Urhosharp

Urho 是一个开源的 3D 游戏引擎。UrhoSharp 是一个包,其中包含了对 iOS 和 Android 二进制文件的绑定,使我们能够在.NET 中使用 Urho。这是一个非常有竞争力的软件,我们只会使用它的一小部分来在应用程序中渲染平面和箱子。我们建议您了解更多关于 UrhoSharp 的信息,以添加您自己的酷功能到应用程序中。

安装 UrhoSharp 只需要为每个平台下载一个 NuGet 包。iOS 平台使用 UrhoSharp NuGet 包,Android 使用 UrhoSharp.ARCore 包。此外,在 Android 中,我们需要添加一些代码来连接生命周期事件,但我们稍后会讲到。基本上,我们将在每个平台上设置一个UrhoSurface。我们将访问这个平台以向节点树添加节点。然后根据它们的类型和属性来渲染这些节点。

但首先,我们需要安装这些包。

为 iOS 安装 UrhoSharp NuGet 包

对于 iOS,我们只需要添加 UrhoSharp NuGet 包。这个包包含了我们 AR 应用所需的一切。您可以按照以下步骤添加该包:

  1. 右键单击WhackABox.iOS项目。

  2. 点击“管理 NuGet 包...”,如下截图所示:

  1. 这将打开 NuGet 包管理器。点击窗口左上角的“浏览”链接。

  2. 在搜索框中输入UrhoSharp,然后按Enter

  3. 选择 UrhoSharp 包,并在窗口右侧点击“安装”,如下截图所示:

这就是 iOS 的全部内容。Android 设置起来有点棘手,因为它需要一个特殊的 UrhoSharp 包和一些代码来连接一切。

为 Android 安装 UrhoSharp.ARCore Nuget 包

对于 Android,我们将添加 UrhoSharp.ARCore 包,其中包含 ARCore 的扩展。它依赖于 UrhoSharp,因此我们不必专门添加该包。您可以按照以下方式添加 UrhoSharp.ARCore 包:

  1. 右键单击WhackABox.Android项目。

  2. 单击“管理 NuGet 包...”,如下截图所示:

  1. 这将打开 NuGet 包管理器。单击窗口左上角的“浏览”链接。

  2. 在搜索框中输入UrhoSharp.ARCore,然后按Enter

  3. 选择 UrhoSharp.ARCore 包,然后单击窗口右侧的“安装”,如下截图所示:

这就是全部——您的项目中所有对 UrhoSharp 的依赖项都已安装。现在我们必须连接一些生命周期事件。

添加 Android 生命周期事件

在 Android 中,Urho需要知道一些特定事件,并能够相应地做出响应。我们还需要使用MessagingCenter添加内部消息,以便稍后在应用程序中对OnResume事件做出反应。在初始化 ARCore 时我们将会做到这一点。但现在,按照以下方式添加 Android 事件的五个必需重写:

  1. 在 Android 项目中,打开MainActivity.cs

  2. MainActivity类的任何位置添加以下代码中的五个重写。

  3. 通过为Urho.DroidXamarin.Forms添加using语句来解决未解析的引用,如下所示:

protected override void OnResume()
{
    base.OnResume();
    UrhoSurface.OnResume();

    MessagingCenter.Send(this, "OnResume");
}

protected override void OnPause()
{
    UrhoSurface.OnPause();
    base.OnPause();
}

protected override void OnDestroy()
{
    UrhoSurface.OnDestroy();
    base.OnDestroy();
}

public override void OnBackPressed()
{
    UrhoSurface.OnDestroy();
    Finish();
}

public override void OnLowMemory()
{
    UrhoSurface.OnLowMemory();
    base.OnLowMemory();
} 

这些事件一一映射到内部的 UrhoSharp 事件,除了OnBackPressed,它调用UrhoSharp.OnDestroy()。这样做是为了内存管理,以便 UrhoSharp 知道何时清理。

MessagingCenter库是一个内置的 Xamarin.Forms 发布-订阅库,用于在应用程序中传递内部消息。它依赖于 Xamarin.Forms。我们创建了一个名为TinyPubSub的自己的库,它打破了这种依赖关系,并且具有稍微更容易的 API(以及一些附加功能)。您可以在 GitHub 上查看它:github.com/TinyStuff/TinyPubSub

定义 PlaneNode

Urho中,您将使用包含节点树的场景。节点可以是游戏中的几乎任何东西,比如渲染器、声音播放器,或者只是子节点的占位符。

正如我们在讨论 AR 基础知识时所说的,平面是在平台之间共享的常见实体。我们需要创建一个代表平面的共同基础,这可以通过扩展Urho节点来实现。位置和旋转将由节点本身跟踪,但我们需要添加一个属性来跟踪平面的原点和大小,由 ARKit 和 ARCore 表示为平面的范围。

我们现在将添加这个类,并在每个平台上实现 AR 相关代码时使用它。这样做的代码很简单,可以通过以下步骤设置:

  1. WhackABox项目中,在项目的根目录创建一个名为PlaneNode.cs的新文件。

  2. 添加以下类的实现:

using Urho;

namespace WhackABox
{
    public class PlaneNode :Node
    {
        public string PlaneId { get; set; }
        public float ExtentX { get; set; }
        public float ExtentZ { get; set; }
    }
} 

PlaneId将是一个标识符,允许我们跟踪此节点代表的特定于平台的平面。在 iOS 中,这将是一个字符串,而在 Android 中,它将是转换为字符串的平面对象的哈希码。ExtentYExtentZ属性表示平面的大小(以米为单位)。我们现在准备开始创建游戏逻辑,并将我们的应用程序连接到 AR SDK。

为 ARView 控件添加自定义渲染器

自定义渲染器是将特定于平台的行为扩展到自定义控件的一种非常聪明的方式。它们还可以用于覆盖已定义的控件上的行为。事实上,Xamarin.Forms 中的所有控件都使用渲染器将 Xamarin.Forms 控件转换为特定于平台的控件。

我们将创建两个渲染器,一个用于 iOS,一个用于 Android,它们将初始化我们将要渲染的UrhoSurfaceUrhoSurface的实例化在每个平台上都有所不同,这就是为什么我们需要两种不同的实现。

对于 iOS

自定义渲染器是从另一个渲染器继承的类。它允许我们为重要事件添加自定义代码,例如在解析 XAML 文件时创建 XAML 元素时。由于ARView控件继承自View,我们将使用ViewRenderer作为基类。通过以下步骤创建ARViewRenderer

  1. 在 iOS 项目中,创建一个名为Renderers的文件夹。

  2. 在该文件夹中添加一个名为ARViewRenderer的新类。

  3. 将以下代码添加到类中:

using System.Threading.Tasks;
using Urho.iOS;
using WhackABox.Controls;
using WhackABox.iOS.Renderers;using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

 [assembly: ExportRenderer(typeof(ARView), typeof(ARViewRenderer))]

 namespace WhackABox.iOS.Renderers
{
    public class ARViewRenderer : ViewRenderer<ARView, UrhoSurface>
    {
          protected async override void 
          OnElementChanged(ElementChangedEventArgs<ARView> e)
        {
            base.OnElementChanged(e);

            if (Control == null)
            {
                await Initialize();
            }
         }

         private async Task Initialize()
         {
             var surface = new UrhoSurface();
             SetNativeControl(surface);
             await surface.Show<Game>();
         }
     }
}

ExportRenderer属性将此渲染器注册到 Xamarin.Forms,以便它知道当解析(或编译)ARView元素时,应该使用此特定的渲染器进行渲染。它接受两个参数:第一个是我们要注册渲染器的Control,第二个是渲染器的类型。此属性必须放在命名空间声明之外。

ARViewRenderer类继承自ViewRenderer<ARView, UrhoSurface>。这指定了此渲染器为哪个控件创建,以及它应该渲染哪个本地控件。在这种情况下,ARView将被一个UrhoSurface控件本地替换,这本身是一个 iOS 特定的UIView

我们重写OnElementChanged()方法,该方法在ARView元素每次更改时被调用,无论是创建还是替换。然后我们可以检查Control属性是否已设置。控件是UrhoSurface类型,因为我们在类定义中声明了它。如果它是null,那么我们就调用Initialize()来创建它。

创建非常简单。我们只需创建一个新的UrhoSurface控件,并将本地控件设置为这个新创建的对象。然后我们调用Show<Game>()方法来启动游戏,指定代表我们的Urho游戏的类。请注意,Game类尚未定义,但它将很快定义,就在我们为 Android 创建自定义渲染器之后。

对于 Android

Android 的自定义渲染器与 iOS 的自定义渲染器做的事情相同,但还需要检查权限。通过以下步骤创建 Android 的ARViewRenderer

  1. 在 Android 项目中,创建一个名为Renderers的文件夹。

  2. 在该文件夹中添加一个名为ARViewRenderer的新类。

  3. 将以下代码添加到类中:

 using System.Threading.Tasks;
 using Android;
 using Android.App;
 using Android.Content;
 using Android.Content.PM;
 using Android.Support.V4.App;
 using Android.Support.V4.Content;
 using WhackABox.Droid.Renderers;
 using WhackABox;
 using WhackABox.Controls;
 using WhackABox.Droid;
 using Urho.Droid;
 using Xamarin.Forms;
 using Xamarin.Forms.Platform.Android;

  [assembly: ExportRenderer(typeof(ARView), 
  typeof(ARViewRenderer))]
  namespace WhackABox.Droid.Renderers
 {
     public class ARViewRenderer : ViewRenderer<ARView,  
     Android.Views.View>
     {
         private UrhoSurfacePlaceholder surface;
         public ARViewRenderer(Context context) : base(context)
         {
             MessagingCenter.Subscribe<MainActivity>(this,  
             "OnResume", async (sender) =>
             {
                 await Initialize();
             });
         }

         protected async override void 
         OnElementChanged(ElementChangedEventArgs<ARView> e)
         {
             base.OnElementChanged(e);

             if (Control == null)
             {
                 await Initialize();
             }
         }

         private async Task Initialize()
         {
             if (ContextCompat.CheckSelfPermission(Context, 
                 Manifest.Permission.Camera) != Permission.Granted)
             {
                 ActivityCompat.RequestPermissions(Context as  
                 Activity, new[] { Manifest.Permission.Camera },  
                 42);
                 return;
             }

             if (surface != null)
                 return;

             surface = UrhoSurface.CreateSurface(Context as 
             Activity);
             SetNativeControl(surface);

             await surface.Show<Game>();
         }
     }
 }

这个自定义渲染器也继承自ViewRenderer<T1, T2>,其中第一个类型是渲染器本身的类型,第二个是渲染器将生成的本地控件的类型。在这种情况下,本地控件将是一个继承自Android.Views.View的控件。渲染器创建一个UrhoSurfacePlaceholder实例,并将其分配为本地控件。UrhoSurfacePlaceholder是一个包装Urho在 Android 上使用的Simple DirectMedia LayerSDL)库的一些功能的类,用于访问媒体功能。它的最后一步是基于即将存在的Game类启动游戏。我们将在本章的下一部分中定义这个类。

创建游戏

要编写一个使用Urho的应用程序,我们需要创建一个从Urho.Application继承的类。这个类定义了一些虚拟方法,我们可以用来设置场景。我们将使用的方法是Start()。然而,在那之前,我们需要创建这个类。这个类将被分成三个文件,使用部分类来描述,如下列表所述:

  • Game.cs文件中将包含跨平台的代码

  • Game.iOS.cs文件中将包含仅在应用的 iOS 版本中编译的代码

  • Game.Android.cs文件中将包含仅在应用的 Android 版本中编译的代码

我们将使用条件编译来实现。我们在项目介绍中讨论了条件编译。简单来说,这意味着我们可以使用称为预处理指令的东西来确定在编译时是否应该包含代码。实际上,这意味着我们将通过在Game.iOS.csGame.Android.cs中定义相同的InitializeAR()方法来在 Android 和 iOS 中编译不同的代码。在初始化期间,我们将调用此方法,并且根据我们在其上运行的平台,它将以不同的方式实现。这只能通过共享项目完成。

Visual Studio 对条件编译有很好的支持,并且将根据您设置为启动项目的项目或您在代码文件本身上方的工具栏中选择的项目来解析正确的引用。

对于这个项目,我们可以将Game.iOS.cs文件移动到 iOS 项目中,将Game.Android.cs文件移动到 Android 项目中,并删除条件编译预处理语句。应用程序将编译正常,但为了学习如何工作,我们将把它们包含在共享项目中。这也可能是一个积极的事情,因为我们将相关代码聚集在一起,使架构更容易理解。

添加共享的部分 Game 类

我们首先创建包含共享代码的Game.cs文件。让我们通过以下步骤设置这个:

  1. WhackABox项目中,在项目的根目录下创建一个名为Game.cs的新文件。

  2. 将以下代码添加到类中:

using System;
using System.Linq;
using Urho;
using Urho.Shapes;

namespace WhackABox
{
    public partial class Game : Application
    {
        private Scene scene; 

        public Game(ApplicationOptions options) : base(options)
        {
        } 
    }
}

首先要注意的是类中的partial关键字。这告诉编译器这不是整个实现,还会在其他文件中存在更多的代码。那些文件中的代码将被视为在这个文件中; 这是将大型实现拆分成不同文件的好方法。

Game继承自Urho.Application,它将处理关于游戏本身的大部分工作。我们定义了一个名为sceneScene类型的属性。在Urho中,Scene代表游戏的一个屏幕(例如,我们可以为游戏的不同部分或菜单定义不同的场景)。在这个游戏中,我们只会定义一个场景,稍后将对其进行初始化。一个scene维护了组成它的节点的层次结构,每个节点可以有任意数量的子节点和任意数量的组件。它是组件在工作。例如,稍后我们将渲染盒子,这将由一个附加了Box组件的节点表示。

Game类本身是从我们在前面部分定义的自定义渲染器中实例化的,并且它在构造函数中以ApplicationOptions实例作为参数。这需要传递给基类。现在我们需要编写一些将是 AR 特定的并且将在以后编写的代码中使用的方法。

CreateSubPlane

第一个方法是CreateSubPlane()方法。当应用程序找到可以放置对象的平面时,它将创建一个节点。我们很快将为每个平台编写该代码。该节点还定义了一个子平面,将定位一个代表该平面位置和大小的盒子。我们已经在本章前面定义了PlaneNode类。

让我们通过以下步骤添加代码:

  1. WhackABox项目中,打开Game.cs类。

  2. 将以下CreateSubPlane()方法添加到类中:

private void CreateSubPlane(PlaneNode planeNode)
{
    var node = planeNode.CreateChild("subplane");
    node.Position = new Vector3(0, 0.05f, 0);

    var box = node.CreateComponent<Box>();
    box.Color = Color.FromHex("#22ff0000");
} 

任何从Urho.Node继承的类,比如PlaneNode,都有CreateChild()方法。这允许我们创建一个子节点并为该节点指定一个名称。稍后将使用该名称来查找特定的子节点执行操作。我们将节点定位在与父节点相同的位置,只是将其提高0.05米(5 厘米)以上平面。

为了看到平面,我们添加了一个半透明红色的box组件。box是通过在我们的节点上调用CreateComponent()创建的组件。颜色以 AARRGGBB 模式定义,其中 AA 是 alpha 分量(透明度),RRGGBB 是标准的红绿蓝格式。我们使用颜色的十六进制表示。

UpdateSubPlane

ARKit 和 ARCore 都会持续更新平面。我们感兴趣的是子平面位置和其范围的变化。通过扩展,我们指的是平面的大小。让我们通过以下步骤来设置这个:

  1. WhackABox项目中,打开Game.cs类。

  2. Game.cs类的任何位置添加UpdateSubPlane()方法,如下面的代码所示:

private void UpdateSubPlane(PlaneNode planeNode, Vector3 position)
{
    var subPlaneNode = planeNode.GetChild("subplane");
    subPlaneNode.Scale = new Vector3(planeNode.ExtentX, 0.05f, 
    planeNode.ExtentZ);
    subPlaneNode.Position = position;
}

该方法接受我们想要更新的PlaneNode以及一个新的位置。我们通过查询当前节点中名为"subplane"的任何节点来定位子平面。请记住,我们在AddSubPlane()方法中命名了子平面。现在我们可以很容易地通过名称访问节点。我们通过从PlaneNode中获取ExtentXExtentZ属性来更新子平面节点的比例。在调用UpdateSubPlane()之前,平面节点将通过一些特定于平台的代码进行更新。最后,我们将子平面的位置设置为传递的position参数。

FindNodeByPlaneId

我们需要一个快速找到节点的方法。ARKit 和 ARCore 都会持续更新平面。我们感兴趣的是子平面位置和其范围的变化。通过扩展,我们指的是平面的大小。让我们通过以下步骤来设置这个:

PlaneNode是一个string,因为 ARKit 以类似全局唯一标识符GUID)的形式定义了平面 ID。GUID 是一系列十六进制数字的结构化序列,可以以string格式表示,如下面的代码所示:

private PlaneNode FindNodeByPlaneId(string planeId) =>
                    scene.Children.OfType<PlaneNode>()
                    .FirstOrDefault(e => e.PlaneId == planeId); 

该方法使用Linq查询场景,并查找具有给定平面 ID 的第一个子节点。如果找不到,则返回null,因为null是引用类型对象的默认值。

这些都是我们在共享代码中下降到 ARKit 和 ARCore 之前需要的所有方法。

添加特定于平台的部分类

现在是利用条件编译的时候了。我们将创建两个部分类,一个用于 iOS,一个用于 Android,它们将有条件地编译到Game类中。

在这一部分,我们将简单地为这些文件设置骨架代码。

添加特定于 iOS 的部分类

让我们从在 iOS 上创建Gamepartial类开始,并将整个代码文件包装在一个预处理指令中,指定这段代码只会在 iOS 上编译:

  1. WhackABox项目中,添加一个名为Game.iOS.cs的新文件。

  2. 如果 Visual Studio 没有自动完成,可以在代码中重命名Game类。

  3. 使类publicpartial

  4. 添加#if#endif预处理指令,以允许条件编译,如下面的代码所示:

#if __IOS__ 
namespace WhackABox
{
    public partial class Game
    {
    }
}
#endif

代码的第一行是一个预处理指令,编译器将使用它来确定#if#endif指令内的代码是否应该包含在编译中。如果包含,将定义一个partial类。这个类中的代码可以是特定于 iOS 的,即使我们在共享项目中定义它。Visual Studio 足够智能,可以将这个部分中的任何代码视为直接存在于 iOS 项目中。在这里实例化UIView不会有问题,因为该代码永远不会被编译到除 iOS 之外的任何平台。

添加特定于 Android 的部分类

同样适用于 Android:只有文件名和预处理指令会改变。让我们通过以下步骤来设置这个:

  1. WhackABox项目中,添加一个名为Game.Android.cs的新文件。

  2. 如果 Visual Studio 没有自动完成,就在代码中重命名Game类。

  3. 使类publicpartial

  4. 添加#if#endif条件编译语句,如下面的代码所示:

#if __ANDROID__namespace WhackABox
{
    public partial class Game
    { 
    }
}
#endif

与 iOS 一样,只有在#if#endif语句之间才会编译 Android 的代码。

现在让我们开始添加一些特定于平台的代码。

编写 ARKit 特定的代码

在本节中,我们将为 iOS 编写特定于平台的代码,该代码将初始化 ARKit,查找平面,并创建节点以供 UrhoSharp 在屏幕上渲染。我们将利用一个在 iOS 中包装 ARKit 的Urho组件。我们还将编写所有将定位、添加和移除节点的函数。ARKit 使用anchors,它们充当将叠加的图形粘合到现实世界的虚拟点。我们特别寻找ARPlaneAnchor,它代表 AR 世界中的平面。还有其他类型的锚点可用,但对于这个应用程序,我们只需要找到水平平面。

让我们首先定义ARKitComponent,以便以后可以使用它。

定义 ARKitComponent

我们首先添加一个将在稍后初始化的ARKitComponentprivate字段。让我们通过以下步骤设置这一点:

  1. WhackABox项目中,打开Game.iOS.cs

  2. 添加一个持有ARKitComponentprivate字段,如下面的代码中所示:

#if __IOS__using System;
using System.Collections.Generic;
using System.Text;
using System.Linq;
using ARKit;
using Urho;
using Urho.iOS;

namespace WhackABox
{
    public partial class Game
    {
        private ARKitComponent arkitComponent;
    }
}
#endif

确保添加所有using语句,以确保我们后来使用的所有代码都解析正确的类型。

编写用于添加和更新锚点的处理程序

现在我们将添加必要的代码,以添加和更新锚点。我们还将添加一些方法来帮助设置节点在 ARKit 更新锚点后的方向。

SetPositionAndRotation

SetPositionAndRotation()方法将被添加和更新锚点使用,因此我们需要在创建由 ARKit 引发的事件处理程序之前定义它。让我们通过以下步骤设置这一点:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 按照下面的代码,在类中添加SetPositionAndRotation()方法:

private void SetPositionAndRotation(ARPlaneAnchor anchor, PlaneNode 
                                    node)
{
     arkitComponent.ApplyOpenTkTransform(node, anchor.Transform, 
                                         true);

     node.ExtentX = anchor.Extent.X;
     node.ExtentZ = anchor.Extent.Z;

     var position = new Vector3(anchor.Center.X, anchor.Center.Y, -
                                anchor.Center.Z);
     UpdateSubPlane(node, position);
} 

该方法接受两个参数。第一个是由 ARKit 定义的ARPlaneAnchor,第二个是我们在场景中拥有的PlaneNode。该方法的目的是确保PlaneNode与 ARKit 传递的ARPlaneAnchor对象同步。arkitComponent有一个名为ApplyOpenTkTransform()的辅助方法,将ARPlaneAnchor对象的位置和旋转转换为Urho使用的位置和旋转对象。然后我们更新平面的Extent(大小)到PlaneNode,并从ARPlaneAnchor获取anchor中心位置。最后,我们调用之前定义的方法来更新持有Box组件的子平面节点,该组件将实际将平面渲染为半透明红色框。

我们需要一个处理更新和添加功能的方法。

更新或添加平面节点

UpdateOrAddPlaneNode()正如其名称所示:它以ARPlaneAnchor作为参数,要么更新要么添加一个新的PlaneNodescene。让我们通过以下步骤设置这一点:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 按照下面的代码描述,添加UpdateOrAddPlaneNode()方法:

private void UpdateOrAddPlaneNode(ARPlaneAnchor anchor)
{
    var node = FindNodeByPlaneId(anchor.Identifier.ToString());

    if (node == null)
    {
        node = new PlaneNode()
        {
            PlaneId = anchor.Identifier.ToString(),
            Name = $"plane{anchor.GetHashCode()}"
        };

        CreateSubPlane(node);
        scene.AddChild(node);
    }

    SetPositionAndRotation(anchor, node);
} 

一个节点要么已经存在于场景中,要么需要被添加。代码的第一行调用FindNodeByPlaneId()来查询具有给定PlaneId的对象。对于 iOS,我们使用anchor.Identifier属性来跟踪 iOS 定义的平面。如果这个调用返回null,这意味着该平面不在场景中,我们需要创建它。为此,我们实例化一个新的PlaneNode,给它一个PlaneId和一个用于调试目的的用户友好的名称。然后我们通过调用CreateSubPlane()来创建子平面来可视化平面本身,我们之前定义过,并将节点添加到scene中。最后,我们更新位置和旋转。对于每次调用UpdateOrAddPlaneNode()方法,我们都这样做,因为对于新节点和现有节点来说都是一样的。现在是时候编写我们最终将直接连接到 ARKit 的处理程序了。

OnAddAnchor

让我们添加一些代码。OnAddAnchor()方法将在每次 ARKit 更新描述我们在虚拟世界中使用的点的锚点集合时被调用。我们特别寻找ARPlaneAnchor类型的锚点。

通过以下两个步骤在Game.iOS.cs类中添加OnAddAnchor()方法:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 在类中的任何地方添加OnAddAnchor()方法,如下面的代码所示:

private void OnAddAnchor(ARAnchor[] anchors)
{
    foreach (var anchor in anchors.OfType<ARPlaneAnchor>())
    {
        UpdateOrAddPlaneNode(anchor);
    }
}

该方法以ARAnchors数组作为参数。我们过滤出ARPlaneAnchor类型的锚点,并遍历列表。对于每个ARPlaneAnchor,我们调用之前创建的UpdateOrAddPlaneNode()方法来向场景中添加一个节点。现在让我们为 ARKit 想要更新锚点时做同样的事情。

OnUpdateAnchors

每当 ARKit 接收到关于锚点的新信息时,它将调用此方法。我们与之前的代码一样,遍历列表以更新场景中anchor的范围和位置:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 在类中的任何地方添加OnUpdateAnchors()方法,如下面的代码所示:

private void OnUpdateAnchors(ARAnchor[] anchors)
{
    foreach (var anchor in anchors.OfType<ARPlaneAnchor>())
    {
        UpdateOrAddPlaneNode(anchor);
    }
}

该代码是OnAddAnchors()方法的副本。它根据 ARKit 提供的信息更新场景中的所有节点。

我们还需要编写一些代码来移除 ARKit 已经移除的锚点。

编写一个处理移除锚点的处理程序

当 ARKit 决定一个锚点无效时,它将从场景中移除它。这种情况并不经常发生,但处理这个调用是一个好习惯。

OnRemoveAnchors

让我们通过以下步骤添加一个处理移除ARPlaneAnchor的方法:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 在类中的任何地方添加OnRemoveAnchors()方法,如下面的代码所示:

private void OnRemoveAnchors(ARAnchor[] anchors)
{
    foreach (var anchor in anchors.OfType<ARPlaneAnchor>())
    {
        FindNodeByPlaneId(anchor.Identifier.ToString())?.Remove();
    }
} 

AddRemove函数一样,这个方法接受一个ARAnchor数组。我们遍历这个数组,寻找ARPlaneAnchor类型的锚点。然后我们调用FindNodeByPlaneId()方法寻找表示这个平面的节点。如果不是null,那么我们调用移除该节点。请注意在Remove()调用之前的空值检查运算符。

初始化 ARKit

现在我们来到了 iOS 特定代码的最后部分,这是我们初始化 ARKit 的地方。这个方法叫做InitializeAR(),不需要参数。它与 Android 的方法相同,但由于它们永远不会同时编译,因为使用了条件编译,调用这个方法的代码将不会知道区别。

初始化 ARKit 的代码很简单,ARKitComponent为我们做了很多工作。让我们通过以下步骤设置它:

  1. WhackABox项目中,打开Game.iOS.cs文件。

  2. 在类中的任何地方添加InitializeAR()方法,如下面的代码所示:

private void InitializeAR()
{
    arkitComponent = scene.CreateComponent<ARKitComponent>();
    arkitComponent.Orientation = 
    UIKit.UIInterfaceOrientation.Portrait;
    arkitComponent.ARConfiguration = new 
    ARWorldTrackingConfiguration
    {
        PlaneDetection = ARPlaneDetection.Horizontal
    };
    arkitComponent.DidAddAnchors += OnAddAnchor;
    arkitComponent.DidUpdateAnchors += OnUpdateAnchors;
    arkitComponent.DidRemoveAnchors += OnRemoveAnchors;
    arkitComponent.RunEngineFramesInARKitCallbakcs = 
    Options.DelayedStart;
    arkitComponent.Run();
} 

代码首先创建了一个ARKitComponent。然后我们设置了允许的方向,并创建了一个ARWorldTrackingConfiguration类,说明我们只对水平平面感兴趣。为了响应平面的添加、更新和移除,我们附加了之前创建的事件处理程序。

我们指示 ARKit 组件延迟调用回调函数,以便 ARKit 能够正确初始化。请注意RunEngineFramesInARKitCallbakcs属性中的拼写错误。这是一个很好的例子,说明为什么需要对代码进行审查,因为更改这个名称将很难保持向后兼容。命名是困难的。

最后一件事是告诉 ARKit 开始运行。我们通过调用arkitComponent.Run()方法来实现这一点。

编写特定于 ARCore 的代码

现在是时候为 Android 与 ARCore 做同样的事情了。就像 iOS 一样,我们将把所有特定于 Android 的代码放在自己的文件中。这个文件就是我们之前创建的Game.Android.cs

定义 ARCoreComponent

首先,我们将添加一个字段,用于存储对ARCoreComponent的引用。这个组件包装了与 ARCore 的大部分交互。ARCoreComponent定义在我们在本章开头安装的 UrhoSharp.ARCore NuGet 包中。

通过以下步骤添加一些using语句和字段:

  1. WhackABox项目中,打开Game.Android.cs文件。

  2. 按照以下代码描述添加arCore私有字段。同时确保添加了粗体标记的using语句:

#if __ANDROID__
using Com.Google.AR.Core;
using Urho;
using Urho.Droid;

namespace WhackABox
{
    public partial class Game
    {
        private ARCoreComponent arCore;
    }
}
#endif

using语句将允许我们在这个文件中解析所需的类型,而arCore属性将是我们在访问 ARCore 函数时的简写。

我们将继续向这个类添加一些方法。

SetPositionAndRotation

每当检测到或更新平面时,我们需要添加或更新一个PlaneNodeSetPositionAndRotation()方法会更新传递的PlaneNode,并根据AR.Core.Plane对象的内容设置该节点的属性。让我们通过以下步骤来设置这一点:

  1. WhackABox项目中,打开Game.Android.cs文件。

  2. 按照以下代码在类中添加SetPositionAndRotation()方法:

private void SetPositionAndRotation(Com.Google.AR.Core.Plane plane,  
                                    PlaneNode node)
{
    node.ExtentX = plane.ExtentX;
    node.ExtentZ = plane.ExtentZ;
    node.Rotation = new Quaternion(plane.CenterPose.Qx(),
                                   plane.CenterPose.Qy(),
                                   plane.CenterPose.Qz(),
                                   -plane.CenterPose.Qw());

    node.Position = new Vector3(plane.CenterPose.Tx(),
                                plane.CenterPose.Ty(),
                                -plane.CenterPose.Tz());
}

前面的代码更新了节点的平面范围并创建了一个旋转Quaternion。如果你不知道Quaternion是什么,不要担心,很少有人知道,但它们似乎以一种非常灵活的方式神奇地保存了模型的旋转信息。plane.CenterPose属性是一个包含平面位置和方向的矩阵。最后,我们根据CenterPose属性更新节点的位置。

下一步是创建一个处理来自 ARCore 的帧更新的方法。

编写 ARFrame 更新的处理程序

Android 处理来自 ARCore 的更新与 ARKit 有些不同,后者暴露了三种不同的事件,用于添加、更新和移除节点。当使用 ARCore 时,我们会在任何更改发生时被调用,而将处理这些更改的处理程序将是我们即将添加的处理程序。

通过以下步骤添加该方法:

  1. WhackABox项目中,打开Game.Android.cs文件。

  2. 按照以下代码在类中的任何位置添加OnARFrameUpdated()方法:

private void OnARFrameUpdated(Frame arFrame)
{
    var all = arCore.Session.GetAllTrackables(
                  Java.Lang.Class.FromType(
                  typeof(Com.Google.AR.Core.Plane)));

    foreach (Com.Google.AR.Core.Plane plane in all)
    {
        var node = 
        FindNodeByPlaneId(plane.GetHashCode().ToString());

        if (node == null)
        {
            node = new PlaneNode
            {
                PlaneId = plane.GetHashCode().ToString(),
                Name = $"plane{plane.GetHashCode()}"
            };

            CreateSubPlane(node);
            scene.AddChild(node);
        }

        SetPositionAndRotation(plane, node);
        UpdateSubPlane(node, Vector3.Zero);
    }
} 

我们首先查询arCore组件跟踪的所有平面。然后我们遍历这个列表,通过调用FindNodeByPlaneId()方法,使用平面的哈希码作为标识符,来查看我们在场景中是否有任何节点。如果找不到任何节点,我们就创建一个新的PlaneNode,并将哈希码分配为PlaneId。然后我们创建一个包含Box组件以可视化平面的子平面,最后将其添加到场景中。然后我们更新平面的位置和旋转,并调用更新子平面。现在我们已经编写了处理程序,需要将其连接起来。

初始化 ARCore

为了初始化 ARCore,我们将添加两种方法。第一种是一个方法,负责 ARCore 的配置,称为“OnConfigRequested()”。第二种是将从共享的Game类中稍后调用的“InitializeAR()”方法。这个方法也在 iOS 特定的代码中定义,但是正如我们之前讨论的,当我们为 Android 编译时,这个方法在 iOS 中永远不会被编译,因为我们使用条件编译,它会过滤掉未选择平台的代码。

OnConfigRequested

ARCore 需要知道一些东西,就像 iOS 一样。在 Android 中,这是通过定义一个 ARCore 组件在初始化时调用的方法来完成的。要创建该方法,请按照以下步骤进行:

  1. WhackABox项目中,打开Game.Android.cs文件。

  2. 在类中的任何位置添加“OnConfigRequested()”方法,如下面的代码所示:

private void OnConfigRequested(Config config)
{
    config.SetPlaneFindingMode(Config.PlaneFindingMode.Horizontal);
    config.SetLightEstimationMode

    (Config.LightEstimationMode.AmbientIntensity);
    config.SetUpdateMode(Config.UpdateMode.LatestCameraImage);
} 

该方法接受一个Config对象,该对象将存储您在此方法中进行的任何配置。首先,我们设置要查找的平面类型。对于这个游戏,我们对“水平”平面感兴趣。我们定义要使用的光估计模式的类型,最后,我们选择要使用的更新模式。在这种情况下,我们要使用最新的相机图像。您可以在配置期间进行很多微调,但这超出了本书的范围。一定要查看 ARCore 的文档,了解更多关于它强大功能的信息。

现在我们已经有了初始化 ARCore 所需的所有代码。

InitializeAR

如前所述,“InitializeAR()”方法与 iOS 特定的代码共享相同的名称,但由于使用条件编译,编译器只会在构建中包含其中一个。让我们按照以下步骤设置这个:

  1. WhackABox项目中,打开Game.Android.cs文件。

  2. 在类中的任何位置添加“InitializeAR()”方法,如下面的代码所示:

private void InitializeAR()
{
    arCore = scene.CreateComponent<ARCoreComponent>();
    arCore.ARFrameUpdated += OnARFrameUpdated;
    arCore.ConfigRequested += OnConfigRequested;
    arCore.Run();
} 

第一步是创建 UrhoSharp 提供的ARCoreComponent。这个组件包装了本地 ARCore 类的初始化。然后我们添加两个事件处理程序:一个用于处理帧更新,一个在初始化期间调用。我们做的最后一件事是在ARCoreComponent上调用“Run()”方法,以开始跟踪世界。

现在我们已经配置好了 ARKit 和 ARCore,准备开始编写实际的游戏了。

编写游戏

在这一部分,我们将通过设置相机、灯光和渲染器来初始化 Urho。相机是确定对象渲染位置的对象。AR 组件负责更新相机的位置,以虚拟跟踪您的手机,以便我们渲染的任何对象都在与您所看到的相同的坐标空间中。首先,我们需要一个相机,它将是场景的观察点。

添加相机

添加相机是一个简单的过程,如下面的步骤所示:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中添加“相机”属性,如下面的代码所示。您应该将其放在类本身的声明之后,但在类内的任何位置放置它都可以。

  3. 在类中的任何位置添加“InitializeCamera()”方法,如下面的代码所示:

private Camera camera; 

private void InitializeCamera()
{
    var cameraNode = scene.CreateChild("Camera");
    camera = cameraNode.CreateComponent<Camera>();
} 

在 UrhoSharp 中,一切都是一个节点,就像在 Unity 中一切都是一个 GameObject,包括“相机”。我们创建一个新节点,称为“相机”,然后在该节点上创建一个“相机”组件,并保留对它的引用以供以后使用。

配置渲染器

UrhoSharp 需要将场景渲染到一个“视口”中。一个游戏可以有多个视口,基于多个摄像头。想象一下你开车的游戏。主要的“视口”将是驾驶员视角的游戏。另一个“视口”可能是后视镜,实际上它们本身就是摄像头,将它们所看到的渲染到主“视口”上。让我们按照以下步骤设置这个:

  1. WhackABox项目中,打开Game.cs文件。

  2. 添加viewport属性到类中,如下面的代码所示。您应该将其放在类本身的声明之后,但在类内的任何位置放置它都可以。

  3. 在类中的任何位置添加InitializeRenderer()方法,如下面的代码所示:

private Viewport viewport; 

private void InitializeRenderer()
{
    viewport = new Viewport(Context, scene, camera, null);
    Renderer.SetViewport(0, viewport);
}

viewport属性将保存对viewport的引用,以备后用。viewport是通过实例化一个新的viewport类来创建的。该类的构造函数需要基类提供的Context,在初始化游戏时我们将创建的scene,一个相机以知道从空间的哪个点进行渲染,以及一个渲染路径,默认为null。渲染路径允许在渲染时对帧进行后处理。这也超出了本书的范围,但也值得一看。

现在,让光明存在。

添加光

为了使对象可见,我们需要定义一些光照。我们通过创建一个定义游戏中我们想要的光照类型的方法来实现这一点。让我们通过以下步骤来设置这一点:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中的任何位置添加InitializeLights()方法,如下面的代码所示:

private void InitializeLights()
{
    var lightNode = camera.Node.CreateChild();
    lightNode.SetDirection(new Vector3(1f, -1.0f, 1f));
    var light = lightNode.CreateComponent<Light>();
    light.Range = 10;
    light.LightType = LightType.Directional;
    light.CastShadows = true;
    Renderer.ShadowMapSize *= 4;
} 

同样,UrhoSharp 中的一切都是节点,光也不例外。我们通过访问存储的相机组件并访问它所属的节点,在相机节点上创建一个通用节点。然后我们设置该节点的方向并创建一个Light组件来定义光。光的范围将是 10 个单位的长度。类型是方向性的,这意味着它将从节点的位置沿着定义的方向发光。它还将投射阴影。我们将ShadowMapSize设置为默认值的四倍,以给阴影贴图更多的分辨率。

在这一点上,我们已经有了初始化 UrhoSharp 和 AR 组件所需的一切。

实现游戏启动

Game类的基类提供了一些虚拟方法,我们可以重写。其中之一是Start(),它将在自定义渲染器设置UrhoSurface后不久被调用。

通过以下步骤添加方法:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中的任何位置添加Start()方法,如下面的代码所示:

protected override void Start()
{
   scene = new Scene(Context);
   var octree = scene.CreateComponent<Octree>();

    InitializeCamera();
    InitializeLights();
    InitializeRenderer();

    InitializeAR();
} 

我们一直在谈论的场景是在这个方法的第一行创建的。这是我们在运行 UrhoSharp 时看到的场景。它跟踪我们添加到其中的所有节点。UrhoSharp 中的所有 3D 游戏都需要一个Octree,这是一个实现空间分区的组件。它被 3D 引擎用来在 3D 空间中快速找到对象,而不必在每一帧中查询每一个对象。方法的第二行直接在场景上创建了这个组件。

接下来,我们有四种方法来初始化相机、灯光和渲染器,并调用两种InitializeAR()方法中的一种,这取决于我们正在编译的平台。如果此时启动应用程序,您应该会看到它找到平面并对其进行渲染,但没有其他操作。是时候添加一些与之交互的东西了。

添加框

我们现在要专注于向我们的增强现实世界添加虚拟框。我们将编写两种方法。第一个是AddBox()方法,它将在平面上的随机位置添加一个新框。第二个是OnUpdate()方法的重写,UrhoSharp 在每帧调用它来执行游戏逻辑。

AddBox()

要向平面添加框,我们需要添加一个方法来实现。这个方法叫做AddBox()。让我们通过以下步骤来设置这一点:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中添加random属性(最好在顶部,但在类的任何位置都可以)。

  3. 在类中的任何位置添加AddBox()方法,如下面的代码所示:

private static Random random = new Random(); 

private void AddBox(PlaneNode planeNode)
{
    var subPlaneNode = planeNode.GetChild("subplane");

    var boxNode = planeNode.CreateChild("Box");
    boxNode.SetScale(0.1f);

    var x = planeNode.ExtentX * (float)(random.NextDouble() - 0.5f);
    var z = planeNode.ExtentZ * (float)(random.NextDouble() - 0.5f);

    boxNode.Position = new Vector3(x, 0.1f, z) +  
    subPlaneNode.Position;

    var box = boxNode.CreateComponent<Box>();
    box.Color = Color.Blue;
} 

我们创建的静态random对象将用于随机化平面上方块的位置。我们想要使用静态的Random实例,因为我们不想冒险创建可能以相同值进行种子化的多个实例,因此返回完全相同的随机数序列。该方法首先通过调用planeNode.GetChild("subplane")找到我们传入的PlaneNode实例的子平面。然后我们创建一个将渲染方块的节点。为了使方块适应世界,我们需要将比例设置为0.1,这将使其大小为 10 厘米。

然后,我们使用ExtentXExtentZ属性随机化方块的位置,乘以一个介于01之间的新随机值,我们首先从中减去0.5。这是为了使位置居中,因为父节点的位置是平面的中心。然后,我们将方块节点的位置设置为随机位置,并且在平面上方 0.1 个单位。我们还需要添加子平面的位置,因为它可能与父节点有一点偏移。最后,我们添加要渲染的实际方块,并将颜色设置为蓝色。

现在让我们添加代码来调用AddBox()方法,基于一些游戏逻辑。

OnUpdate()

大多数游戏使用游戏循环。这会调用一个Update()方法,该方法接受输入并计算游戏的状态。UrhoSharp 也不例外。我们游戏的基类有一个虚拟的OnUpdate()方法,我们可以覆盖它,以便我们可以编写每帧都会执行的代码。这个方法经常被调用,通常大约每秒 50 次。

现在我们将覆盖Update()方法,添加游戏逻辑,每隔一秒添加一个新的方块。让我们通过以下步骤设置这个逻辑:

  1. WhackABox项目中,打开Game.cs文件。

  2. newBoxTtl字段和newBoxIntervalInSeconds字段添加到代码顶部的类中。

  3. 在类中的任何位置添加OnUpdate()方法,如下面的代码所示:

private float newBoxTtl;
private readonly float newBoxIntervalInSeconds = 2; 

protected override void OnUpdate(float timeStep)
{
    base.OnUpdate(timeStep);

    newBoxTtl -= timeStep;

    if (newBoxTtl < 0)
    {
        foreach (var node in scene.Children.OfType<PlaneNode>())
        {
            AddBox(node);
        }

        newBoxTtl += newBoxIntervalInSeconds;
    }
} 

第一个字段newBoxTtl,其中Ttl存活时间TTL),是一个内部计数器,将减去自上一帧以来经过的毫秒数。当它低于0时,我们将向场景的每个平面添加一个新的方块。我们通过查询场景的Children集合并仅返回PlaneNode类型的子项来找到所有PlaneNode的实例。第二个字段newBoxIntervalInSeconds表示newBoxTtl达到0后我们将添加多少秒到newBoxTtl。为了知道自上一帧以来经过了多少时间,我们使用 UrhoSharp 传递给OnUpdate()方法的timeStep参数。该参数的值是自上一帧以来的秒数。通常是一个小值,如果更新循环以每秒 50 帧运行,它将是0.016。它可能会有所不同,这就是为什么您会想要使用这个值来进行newBoxTtl的减法运算。

如果现在运行游戏,您将看到方块出现在检测到的平面上。但是,我们仍然无法与它们交互,它们看起来相当无聊。让我们继续使它们旋转。

使方块旋转

您可以通过创建一个从Urho.Component继承的类来向 UrhoSharp 添加自己的组件。我们将创建一个组件,使方块围绕三个轴旋转。

创建旋转组件

正如我们提到的,组件是从Urho.Component继承的类。这个基类定义了一个名为OnUpdate()的虚拟方法,其行为与Game类本身的Update()方法相同。这使我们能够向组件添加逻辑,以便它可以修改它所属节点的状态。

让我们通过以下步骤创建rotate组件:

  1. WhackABox项目中,在项目的根目录中创建一个名为Rotator.cs的新类。

  2. 添加以下代码:

using Urho;

namespace WhackABox
{
    public class Rotator : Component
    {
        public Vector3 RotationSpeed { get; set; }

        public Rotator()
        {
            ReceiveSceneUpdates = true;
        }

        protected override void OnUpdate(float timeStep)
        {
            Node.Rotate(new Quaternion(
                RotationSpeed.X * timeStep,
                RotationSpeed.Y * timeStep,
                RotationSpeed.Z * timeStep),
                TransformSpace.Local);
        }
    }
}

RotationSpeed属性将用于确定围绕任何特定轴的旋转速度。当我们在下一步中将组件分配给箱子节点时,它将被设置。为了使组件能够在每一帧接收到对OnUpdate()方法的调用,我们需要将ReceiveSceneUpdates属性设置为true。如果不这样做,组件将不会在每次更新时被 UrhoSharp 调用。出于性能原因,默认情况下它被设置为false

所有有趣的事情都发生在OnUpdate()方法的override中。我们创建一个新的四元数来表示新的旋转状态。同样,我们不需要详细了解这是如何工作的,只需要知道四元数属于高等数学的神秘世界。我们将RotationSpeed向量中的每个轴乘以timeStep来生成一个新值。timeStep参数是自上一帧以来经过的秒数。我们还将旋转定义为围绕此框的本地坐标空间。

现在组件已经创建,我们需要将它添加到箱子中。

分配 Rotator 组件

添加Rotator组件就像添加任何其他组件一样简单。让我们通过以下步骤来设置这个:

  1. WhackABox项目中,打开Game.cs文件。

  2. 更新AddBox()方法,通过在以下代码中加粗标记的代码来添加:

private void AddBox(PlaneNode planeNode)
{
    var subPlaneNode = planeNode.GetChild("subplane");

    var boxNode = planeNode.CreateChild("Box");
    boxNode.SetScale(0.1f);

    var x = planeNode.ExtentX * (float)(random.NextDouble() - 0.5f);
    var z = planeNode.ExtentZ * (float)(random.NextDouble() - 0.5f);

    boxNode.Position = new Vector3(x, 0.1f, z) + 
    subPlaneNode.Position;

    var box = boxNode.CreateComponent<Box>();
    box.Color = Color.Blue;

 var rotationSpeed = new Vector3(10.0f, 20.0f, 30.0f);
 var rotator = new Rotator() { RotationSpeed = rotationSpeed };
 boxNode.AddComponent(rotator);
} 

首先,我们通过创建一个新的Vector3结构并将其分配给一个名为rotationSpeed的新变量来定义我们希望箱子如何旋转。在这种情况下,我们希望它围绕x轴旋转10个单位,围绕y轴旋转20个单位,围绕z轴旋转30个单位。我们使用rotationSpeed变量来设置我们在添加的代码的第二行中实例化的Rotator组件的RotationSpeed属性。

最后,我们将组件添加到box节点。现在箱子应该以有趣的方式旋转。

添加箱子命中测试

现在我们有了不断堆叠的旋转箱子。我们需要添加一种方法来移除箱子。最简单的方法是添加一个功能,当我们触摸它们时移除箱子,但我们要比这更花哨一点:每当我们触摸一个箱子时,我们希望它在从场景中移除之前缩小并消失。为此,我们将使用我们新获得的组件知识,然后添加一些代码来确定我们是否触摸到一个箱子。

添加死亡动画

我们即将添加的Death组件与我们在上一节中创建的Rotator组件具有相同的模板。让我们通过以下步骤来添加它并查看代码:

  1. WhackABox项目中,创建一个名为Death.cs的新类。

  2. 用以下代码替换类中的代码:

 using Urho;
 using System;

 namespace WhackABox
 {
     public class Death : Component
     {
         private float deathTtl = 1f;
         private float initialScale = 1;

         public Action OnDeath { get; set; }

         public Death()
         {
             ReceiveSceneUpdates = true;
         }

         public override void OnAttachedToNode(Node node)
         {
             initialScale = node.Scale.X;
         }

         protected override void OnUpdate(float timeStep)
         {
             Node.SetScale(deathTtl * initialScale);

             if (deathTtl < 0)
             {
                 Node.Remove();
             }

             deathTtl -= timeStep;
         }
     }
 } 

我们首先定义两个字段。deathTtl字段确定动画持续的时间(以秒为单位)。initialScale字段在组件附加到节点时跟踪节点的比例。为了接收更新,我们需要在构造函数中将ReceiveSceneUpdates设置为true。当组件附加到节点时,将调用重写的OnAttachedToNode()方法。我们使用这个方法来设置initialScale字段。组件附加后,我们开始在每一帧上调用OnUpdate()。在每次调用时,我们根据deathTtl字段乘以initialScale字段设置节点的新比例。当deathTtl字段达到零时,我们将节点从场景中移除。如果我们没有达到零,那么我们减去自上一帧被调用以来的时间量,这是由timeStep参数给出的。现在我们需要做的就是弄清楚何时向箱子添加Death组件。

DetermineHit()

我们需要一个方法,可以解释屏幕 2D 表面上的触摸,并使用从摄像机到我们正在查看的场景的虚拟射线来找出我们击中的箱子。这个方法叫做DetemineHit。让我们通过以下步骤来设置这个方法:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中的任何位置添加DetemineHit()方法,如下面的代码所示:

private void DetermineHit(float x, float y)
{
    var cameraRay = camera.GetScreenRay(x, y);
    var result = scene.GetComponent<Octree>
    ().RaycastSingle(cameraRay);

    if (result?.Node?.Name?.StartsWith("Box") == true)
    {
        var node = result?.Node;

        if (node.Components.OfType<Death>().Any())
        {
            return;
        }

        node.CreateComponent<Death>();
    }
} 

传递给方法的xy参数的范围是从01,其中0表示屏幕的左边缘或顶部边缘,1表示屏幕的右边缘或底部边缘。屏幕的确切中心将是x=0.5y=0.5。由于我们想从相机获取一个射线,我们可以直接在相机组件上使用一个叫做GetScreenRay()的方法。它返回一个从场景中相机的射线,与相机设置的方向相同。我们使用这个射线,并将其传递给Octree组件的RaycastSingle()方法,如果命中,则返回一个包含单个节点的结果。

我们检查结果,执行多个空值检查,最后检查节点的名称是否以Box开头。如果是这样,我们检查我们击中的箱子是否已经注定,通过检查是否附加了Death组件来判断。如果有,我们return。如果没有,我们创建一个Death组件并让箱子死去。

到目前为止一切看起来都很好。现在我们需要一些东西来调用DetermineHit()方法。

OnTouchBegin()

在 UrhoSharp 中,触摸被处理为事件,这意味着它们需要事件处理程序。让我们通过以下步骤为TouchBegin事件创建一个处理程序:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在代码中的任何位置添加OnTouchBegin()方法,如下所示:

private void OnTouchBegin(TouchBeginEventArgs e)
{
    var x = (float)e.X / Graphics.Width;
    var y = (float)e.Y / Graphics.Height;

    DetermineHit(x, y);
}

当触摸被注册时,将调用此方法,并将有关该触摸事件的信息作为参数发送。此参数有一个X和一个Y属性,表示我们触摸的屏幕上的点。由于DetermineHit()方法希望值在01的范围内,我们需要将屏幕的宽度和高度除以XY坐标。

完成后,我们调用DetermineHit()方法。要完成这一部分,我们只需要连接事件。

连接输入

现在剩下的就是将事件连接到 UrhoSharp 的Input子系统。这是通过在Start()方法中添加一行代码来完成的,如下所示的步骤:

  1. WhackABox项目中,打开Game.cs文件。

  2. Start()方法中,添加以下代码片段中加粗的代码:

protected override void Start()
{
 scene = new Scene(Context);
 var octree = scene.CreateComponent<Octree>();

 InitializeCamera();
 InitializeLights();
 InitializeRenderer();

 Input.TouchBegin += OnTouchBegin;

 InitializeAR();
} 

这将TouchBegin事件连接到我们的OnTouchBegin事件处理程序。

如果现在运行游戏,当你点击它们时,箱子应该会动画并消失。现在我们需要一些统计数据,显示有多少飞机和有多少箱子还活着。

更新统计数据

在本章的开头,我们在 XAML 中添加了一些控件,显示了游戏中存在的飞机和箱子的数量。现在是时候添加一些代码来更新这些数字了。我们将使用内部消息传递来解耦游戏和我们用来显示这些信息的 Xamarin.Forms 页面。

游戏将向主页发送一个包含我们需要的所有信息的类的消息。主页将接收此消息并更新标签。

定义一个统计类

我们将在 Xamarin.Forms 中使用MessagingCenter,它允许我们发送消息的同时发送一个对象。我们需要创建一个可以携带我们想要传递的信息的类。让我们通过以下步骤来设置这个:

  1. WhackABox项目中,创建一个名为GameStats.cs的新类。

  2. 将以下代码添加到类中:

public class GameStats
{
    public int NumberOfPlanes { get; set; }
    public int NumberOfBoxes { get; set; }
} 

这个类将是一个简单的数据载体,指示我们有多少飞机和箱子。

通过 MessagingCenter 发送更新

当一个节点被创建或移除时,我们需要将统计信息发送给任何正在监听的东西。为了做到这一点,我们需要一个新的方法,它将遍历场景并计算我们有多少飞机和箱子,然后发送一条消息。让我们通过以下步骤来设置这个方法:

  1. WhackABox项目中,打开Game.cs文件。

  2. 在类中的任何地方添加一个名为SendStats()的方法,如下面的代码所示:

private void SendStats()
{
    var planes = scene.Children.OfType<PlaneNode>();
    var boxCount = 0;

    foreach (var plane in planes)
    {
        boxCount += plane.Children.Count(e => e.Name == "Box");
    }

    var stats = new GameStats()
    {
        NumberOfBoxes = boxCount,
        NumberOfPlanes = planes.Count()
    };

    Xamarin.Forms.Device.BeginInvokeOnMainThread(() =>
    {
        Xamarin.Forms.MessagingCenter.Send(this, "stats_updated",  
        stats);
    });
} 

该方法检查scene对象的所有子节点,以查找PlaneNode类型的节点。我们遍历所有这些节点,并计算节点的子节点中有多少个名称为Box,然后在名为boxCount的变量中指示这个数字。当我们有了这个信息,我们创建一个GameStats对象,并用盒子计数和平面计数进行初始化。

最后一步是发送消息。我们必须确保我们正在使用 UI 线程(MainThread),因为我们将要更新 GUI。只有 UI 线程才允许触摸 GUI。这是通过将MessagingCenter.Send()调用包装在BeginInvokeOnMainThread()中来完成的。

发送的消息是stats_updated。它包含统计信息作为参数。现在让我们使用SendStats()方法。

连接事件

场景中有很多事件可以连接。我们将连接到NodeAddedNodeRemoved以确定何时需要发送统计信息。让我们通过以下步骤设置这一点:

  1. WhackABox项目中,打开Game.cs文件。

  2. Start()方法中,添加以下代码中加粗的行:

protected override void Start()
{
    scene = new Scene(Context);
    scene.NodeAdded += (e) => SendStats();
 scene.NodeRemoved += (e) => SendStats();
    var octree = scene.CreateComponent<Octree>();

    InitializeCamera();
    InitializeLights();
    InitializeRenderer();

    Input.TouchEnd += OnTouchEnd;

    InitializeAR();
} 

每当节点被添加或移除时,都会向 GUI 发送一个新消息。

更新 GUI

这将是我们添加到游戏中的最后一个方法。它处理信息更新,并更新 GUI 中的标签。让我们通过以下步骤添加它:

  1. WhackABox项目中,打开MainPage.xaml.cs文件。

  2. 在代码中的任何地方添加一个名为StatsUpdated()的方法,如下面的片段所示:

private void StatsUpdated(Game sender, GameStats stats)
{
    boxCountLabel.Text = stats.NumberOfBoxes.ToString();
    planeCountLabel.Text = stats.NumberOfPlanes.ToString();
}

该方法接收我们发送的GameStats对象,并更新 GUI 中的两个标签。

订阅 MainForm 中的更新

要添加的最后一行代码将StatsUpdated处理程序连接到传入的消息。让我们通过以下步骤设置这一点:

  1. WhackABox项目中,打开MainPage.xaml.cs文件。

  2. 在构造函数中,添加以下代码中加粗的行:

public MainPage()
{
    InitializeComponent();
    MessagingCenter.Subscribe<Game, GameStats>(this,  
    "stats_updated", StatsUpdated);
} 

这行代码将传入消息与内容stats_updated连接到StatsUpdated方法。现在运行游戏,走出去寻找那些方块吧!

完成的应用程序看起来像以下截图,随机出现旋转的方块:

总结

在本章中,我们学习了如何通过使用自定义渲染器将 AR 集成到 Xamarin.Forms 中。我们利用了 UrhoSharp 来使用跨平台渲染、组件和输入管理来与世界交互。我们还学习了一些关于MessagingCenter的知识,它可以用于在应用程序的不同部分之间发送内部进程消息,以减少耦合。

接下来,我们将深入机器学习,并创建一个可以识别图像中的热狗的应用程序。

第九章:使用机器学习的热狗或不是热狗

在本章中,我们将学习如何使用机器学习创建一个用于图像分类的模型。我们将导出该模型为 TensorFlow 模型,可以在 Android 设备上使用,以及 CoreML 模型,可以在 iOS 设备上使用。为了训练和导出模型,我们将使用 Azure 认知服务和 Custom Vision 服务。

一旦我们导出了模型,我们将学习如何在 Android 和 iOS 应用程序中使用它们。

本章将涵盖以下主题:

  • 使用 Azure 认知服务 Custom Vision 训练模型

  • 如何在 Android 设备上使用 TensorFlow 模型进行图像分类

  • 如何在 iOS 设备上使用 CoreML 模型进行图像分类

技术要求

要完成这个项目,您需要安装 Visual Studio for Mac 或 PC,以及 Xamarin 组件。有关如何设置您的环境的更多详细信息,请参见《Xamarin 简介》的第一章。要使用 Azure 认知服务,您需要一个 Microsoft 帐户。本章的源代码可在 GitHub 存储库中找到。

机器学习

机器学习这个术语是由美国人工智能先驱阿瑟·塞缪尔于 1959 年创造的。美国计算机科学家汤姆·M·米切尔后来提供了对机器学习的更正式定义。

计算机程序据说可以从经验 E 中学习某类任务 T 和性能度量 P,如果它在 T 中的任务表现,根据 P 来衡量,随着经验 E 的增加而提高。

简而言之,这句话描述了一个计算机程序,它具有无需明确编程即可学习的能力。在机器学习中,算法用于构建样本数据或训练数据的数学模型。这些模型用于计算机程序进行预测和决策,而无需为所涉及的任务明确编程。

Azure 认知服务——Custom Vision

Custom Vision 是一个用于训练图像分类模型和检测图像中对象的工具或服务。在 Custom Vision 中,我们可以上传自己的图像并对其进行标记,以便对图像分类进行训练。如果我们为对象检测训练模型,我们还可以标记图像的特定区域。由于模型已经预先训练用于基本图像识别,我们不需要大量数据就可以获得很好的结果。建议每个标签至少有 30 张图像。

当我们训练了一个模型后,我们可以使用 Custom Vision 服务中的 API。然而,我们也可以将模型导出为 CoreML(iOS)、TensorFlow(Android)、ONNX(Windows)和 Dockerfile(Azure IoT Edge,Azure Functions 和 AzureML)。这些模型可以用于进行分类或对象检测,而无需连接到 Custom Vision 服务。

CoreML

CoreML 是在 iOS 11 中引入的一个框架。CoreML 使得将机器学习模型集成到 iOS 应用程序中成为可能。在 CoreML 之上,我们有三个高级 API——Vision API 用于图像分析,自然语言 API 用于自然语言处理,以及 Gameplay Kit 用于评估学习决策树。有关 CoreML 的更多信息可以在苹果的官方文档中找到。

TensorFlow

TensorFlow 是一个开源的机器学习框架,可以在www.tensorflow.org/找到。TensorFlow 不仅可以用于在移动设备上运行模型,还可以用于训练模型。为了在移动设备上运行它,我们有 TensorFlow Mobile 和 TensorFlow Lite。从 Azure 认知服务导出的模型是为 TensorFlow Mobile 设计的。还有 Xamarin 绑定可用作 NuGet 软件包,用于 TensorFlow Mobile 和 TensorFlow Lite。然而,请记住,计划在 2019 年停用 TensorFlow Mobile。这并不意味着停用后我们就不能使用它,但意味着在停用后它不太可能再得到更新,只要 Custom Vision 仍然为 TensorFlow Mobile 导出模型,我们将继续使用它。即使 API 看起来有些不同,概念仍将是相同的。

项目概述

如果你看过电视剧《硅谷》,你可能听说过《不是热狗》应用程序。在本章中,我们将学习如何构建该应用程序。本章的第一部分将涉及收集我们用于创建可以检测照片中是否有热狗的机器学习模型的数据。

在本章的第二部分,我们将为 iOS 和 Android 构建一个应用程序,用户可以从照片库中选择照片,以便分析照片是否有热狗。完成此项目的预计时间为 120 分钟。

入门

我们可以使用 PC 上的 Visual Studio 2017 或 Mac 上的 Visual Studio 来完成此项目。要使用 PC 上的 Visual Studio 构建 iOS 应用程序,必须连接 Mac。如果根本没有 Mac,可以选择只完成此项目的 Android 部分。同样,如果只有 Mac,可以选择只完成此项目的 iOS 或 Android 部分。

使用机器学习构建热狗或不是热狗应用程序

让我们开始吧!我们将首先训练一个图像分类模型,以便在本章后面决定照片是否有热狗。

训练模型

要为图像分类训练模型,我们需要收集热狗照片和不含热狗的照片。因为世界上大多数物品都不是热狗,所以我们需要更多不含热狗的照片。最好是热狗照片涵盖许多不同的热狗场景——有面包、有番茄酱或芥末等。这样模型就能够识别不同情况下的热狗。当我们收集不含热狗的照片时,我们还需要有各种各样的照片,既与热狗相似又完全不同的物品的照片。

GitHub 上的解决方案中的模型是用 240 张照片训练的,其中 60 张是热狗,180 张不是。

收集了所有照片后,我们将准备通过以下步骤开始训练模型:

  1. 转到customvision.ai

  2. 登录并创建一个新项目。

  3. 为项目命名——在我们的案例中是HotDogOrNot

  4. 项目类型应为分类。

  5. 选择通用(紧凑)作为域。如果我们想要在移动设备上导出模型并运行它们,我们就使用紧凑域。

  6. 点击创建项目继续,如下截图所示:

给图像打标签

创建项目后,我们可以开始上传图像并对其进行标记。我们将通过以下步骤开始添加热狗照片:

  1. 点击添加图像。

  2. 选择应上传的热狗照片。

  3. 使用如下截图所示的 hotdog 标记照片:

一旦我们上传了所有的热狗照片,就该上传不是热狗的照片了。为了获得最佳结果,我们还应该包括看起来类似热狗但实际不是的物体的照片:

  1. 点击“添加图片”。

  2. 选择不是热狗的照片。

  3. 使用not-hotdog标记照片,如下截图所示。将此标记设置为负标记。负标记用于不包含我们为其他标记创建的任何对象的照片。在这种情况下,我们上传的照片中都不包含热狗:

训练模型

一旦我们上传了照片,就该训练模型了。我们上传的照片并不都用于训练;有些将用于验证,以便给出模型的好坏得分。如果我们分批上传照片并在每批后训练模型,就能看到我们的得分在提高。点击页面顶部的绿色“训练”按钮来训练模型。

以下截图显示了一次训练迭代的结果,模型的精度为 93.4%:

导出模型

一旦我们训练好了模型,就可以导出它以便在设备上使用。如果需要的话,我们可以使用 API,但为了快速分类并且能够离线进行,我们将把模型添加到应用程序包中。导出并下载 CoreML 模型和 TensorFlow 模型,如下截图所示:

构建应用

一旦我们有了一个 CoreML 模型和一个 TensorFlow 模型,就该构建应用了。我们的应用将使用训练好的模型来对照片进行分类,判断它们是否是热狗照片。从 Custom Vision 服务中导出的 CoreML 模型将用于 iOS,而 TensorFlow 模型将用于 Android。

使用 Xamarin.Forms 模板创建一个新项目。该模板可以在跨平台选项卡下找到。将项目命名为HotDotOrNot,如下截图所示:

在下一步中,我们将选择应该使用哪个 Xamarin.Forms 模板。对于我们的项目,选择空白。对于这个项目,我们将以 Android 和 iOS 为平台,并使用.NET Standard 作为代码共享策略,如下截图所示:

在做任何其他事情之前,我们将更新 Xamarin.Forms NuGet 包,以确保我们拥有最新版本。

使用机器学习对图像进行分类

我们将用于图像分类的代码无法在 iOS 和 Android 项目之间共享。但是,为了能够从共享代码(HotDogOrNot项目)进行分类,我们将创建一个接口。不过,首先我们将通过以下步骤为接口创建一个EventArgs类:

  1. HotDogOrNot项目中,创建一个名为ClassificationEventArgs的新类。

  2. EventArgs作为基类添加,如下代码所示:

using System;
using System.Collections.Generic; 

public class ClassificationEventArgs : EventArgs
{
    public Dictionary<string, float> Classifications { get; private  
    set; }

    public ClassificationEventArgs(Dictionary<string, float> 
    classifications)
    {
        Classifications = classifications;
    }
} 

现在我们已经创建了ClassificationEventArgs,我们可以通过以下步骤创建接口:

  1. HotdogOrNot项目中,在HotdogOrNot项目中创建一个名为IClassifier的新接口。

  2. 添加一个名为Classify的方法,它不返回任何内容,但接受一个字节数组作为参数。

  3. 添加一个使用ClassificationEventArgs的事件,并将其命名为ClassificationCompleted,如下代码所示:

using System;
using System.Collections.Generic; 

public interface IClassifier
{
    void Classify(byte[] bytes);
    event EventHandler<ClassificationEventArgs> 
    ClassificationCompleted;
}

使用 CoreML 进行图像分类

首先,我们要做的是通过以下步骤将 CoreML 模型添加到HotDogOrNot.iOS项目中:

  1. 解压从 Custom Vision 服务获得的 ZIP 文件。

  2. 找到.mlmodel文件并将其重命名为hotdog-or-not.mlmodel

  3. 将其添加到 iOS 项目的Resources文件夹中。

  4. 确保构建操作是BundleResource。如果您在 Mac 上使用 Visual Studio,则会创建一个.cs文件。删除此文件,因为在没有代码的情况下使用模型会更容易。

当我们将文件添加到 iOS 项目后,我们将准备通过以下步骤创建IClassifier接口的 iOS 实现:

  1. HotDogOrNotDog.iOS项目中创建一个名为CoreMLClassifier的新类。

  2. 添加IClassifier接口。

  3. 实现ClassificationCompleted事件和接口中的Classify方法,如下所示:

using System;
using System.Linq;
using CoreML;
using Foundation;
using ImageIO;
using Vision;
using System.Collections.Generic; 

namespace HotDogOrNot.iOS
{
    public class CoreMLClassifier : IClassifier
    {
        public event EventHandler<ClassificationEventArgs> 
        ClassificationCompleted;

        public void Classify(byte[] bytes)
        {
            //Code will be added here
        }
    } 
}

Classify方法中,我们将首先编译 CoreML 模型,具体步骤如下:

  1. 使用NSBundle.MainBundle.GetUrlForResource方法获取模型的路径。

  2. 使用MLModel.CompileModel方法编译模型。传递模型的 URL 和一个错误对象,该对象将指示在编译模型过程中是否发生了一个或多个错误。

  3. 使用CompileModel方法的 URL 并将其传递给MLModel.Create以创建一个我们可以使用的模型对象,如下所示:

var modelUrl = NSBundle.MainBundle.GetUrlForResource("hotdog-or-not", "mlmodel");
var compiledUrl = MLModel.CompileModel(modelUrl, out var error);
var compiledModel = MLModel.Create(compiledUrl, out error);

因为我们将使用 CoreML 模型的照片,所以我们可以使用建立在 CoreML 之上的 Vision API。为此,我们将使用VNCoreMLRequest。然而,在创建请求之前,我们将创建一个回调,该回调将处理请求完成时的情况,具体步骤如下:

  1. 打开CoreMLClassifier.cs

  2. 创建一个名为HandleVNRequest的新私有方法,该方法有两个参数,一个是VNRequst类型,一个是NSError类型。

  3. 如果错误是null,则使用ClassificationEventArgs调用ClassificationCompleted事件,其中包含一个空的Dictionary

  4. 如果错误不是 null,则使用VNRequest对象上的GetResults方法获取结果。

  5. Confidence对分类进行排序,以便具有最高置信度的分类排在第一位。

  6. 使用ToDictionary方法将结果转换为Dictionary

  7. 使用ClassificationEventArgs调用ClassificationCompleted事件,其中包含排序后的字典。如下所示:

private void HandleVNRequest(VNRequest request, NSError error)
{
    if (error != null) 
    {
    ClassificationCompleted?.Invoke(this, new 
    ClassificationEventArgs(new Dictionary<string, float>())); 
    }

    var result = request.GetResults<VNClassificationObservation>();
    var classifications = result.OrderByDescending(x => 
    x.Confidence).ToDictionary(x => x.Identifier, x => 
    x.Confidence);

    ClassificationCompleted?.Invoke(this, new 
    ClassificationEventArgs(classifications));  
}

创建回调后,我们将返回到Classify方法,并通过以下步骤执行分类:

  1. 将模型转换为VNCoreMLModel,因为我们需要使用 Vision API。使用VNCoreMLModel.FromMLModel方法转换模型。

  2. 创建一个新的VNCoreMLRequest对象,并将VNCoreMLModel和我们创建的回调作为参数传递给构造函数。

  3. 使用NSData.FromArray方法将输入数据转换为NSData对象。

  4. 创建一个新的VNImageRequestHandler对象,并将数据对象、CGImagePropertyOrientation.Up和一个新的VNImageOptions对象传递给构造函数。

  5. VNImageRequestHandler上使用Perform方法,并将VNCoreMLRequest作为参数传递给该方法,如下所示:

public void Classify(byte[] bytes)
{
    var modelUrl = NSBundle.MainBundle.GetUrlForResource("hotdog-or-
    not", "mlmodel");
    var compiledUrl = MLModel.CompileModel(modelUrl, out var error);
    var compiledModel = MLModel.Create(compiledUrl, out error); 

    var vnCoreModel = VNCoreMLModel.FromMLModel(compiledModel, out 
    error);

 var classificationRequest = new VNCoreMLRequest(vnCoreModel,    
    HandleVNRequest); 

 var data = NSData.FromArray(bytes);
 var handler = new VNImageRequestHandler(data,  
    CGImagePropertyOrientation.Up, new VNImageOptions()); 
 handler.Perform(new[] { classificationRequest }, out error);
}

使用 TensorFlow 进行图像分类。

现在我们已经在 iOS 中编写了识别热狗的代码,现在是时候为 Android 编写代码了。首先要做的是将我们从 Custom Vision 导出的文件添加到 Android 项目中。对于 TensorFlow,实际模型和标签(标签)分为两个文件。通过以下步骤设置:

  1. 提取我们从 Custom Vision 服务中获得的 ZIP 文件。

  2. 找到model.pb文件并将其重命名为hotdog-or-not-model.pb

  3. 找到labels.txt文件并将其重命名为hotdog-or-not-labels.txt

  4. 将文件导入到 Android 项目的Assets文件夹中。确保构建操作是 Android Asset。

将文件导入到 Android 项目后,我们可以开始编写代码。为了获取 TensorFlow 所需的库,我们还需要通过以下步骤安装 NuGet 包:

  1. HotDogOrNotDog.Android项目中,安装Xam.Android.Tensorflow NuGet 包。

  2. 然后,在HotDogOrNotDog.Android项目中创建一个名为TensorflowClassifier的新类。

  3. IClassifier接口添加到TensorflowClassifier类中。

  4. 实现ClassificationCompleted事件和接口中的Classify方法,如下所示的代码:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Android.App;
using Android.Graphics;
using Org.Tensorflow.Contrib.Android; 

public class TensorflowClassifier : IClassifier
{
         public event EventHandler<ClassificationEventArgs> 
         ClassificationCompleted;

         public void Classify(byte[] bytes)
         {
            //Code will be added here
         }
}

Classify方法中,我们将首先从Assets文件夹中读取模型和标签文件,通过以下步骤进行:

  1. 使用TensorFlowInferenceInterface类导入模型。然后,使用资产文件夹的路径和模型文件的名称作为构造函数的参数。

  2. 使用StreamReader来读取标签。

  3. 读取整个文本文件,按行分割('/n'),并修剪每一行的文本以去除空格。我们还将过滤掉空或 null 的项目,并将结果转换为字符串列表,如下所示的代码:

public void Classify(byte[] bytes)
{
    varassets = Application.Context.Assets;

 var inferenceInterface = new 
    TensorFlowInferenceInterface(assets, "hotdog-or-not-model.pb");
 var sr = new StreamReader(assets.Open("hotdog-or-not-
    labels.txt"));
 var labels = sr.ReadToEnd().Split('\n').Select(s => s.Trim())
 .Where(s => !string.IsNullOrEmpty(s)).ToList();
}

TensorFlow模型无法理解图像,因此我们需要将它们转换为二进制数据。图像需要转换为点值的浮点数组,每个像素的红色、绿色和蓝色值各一个。还需要对颜色值进行一些调整。此外,我们需要调整图像的大小,使其为227 x 227像素。为此,编写以下代码:

var bitmap = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length); 
var resizedBitmap = Bitmap.CreateScaledBitmap(bitmap, 227, 227, false)
                               .Copy(Bitmap.Config.Argb8888, false);

var floatValues = new float[227 * 227 * 3];
var intValues = new int[227 * 227];

resizedBitmap.GetPixels(intValues, 0, 227, 0, 0, 227, 227);

for (int i = 0; i < intValues.Length; ++i)
{
    var val = intValues[i];
    floatValues[i * 3 + 0] = ((val & 0xFF) - 104);
    floatValues[i * 3 + 1] = (((val >> 8) & 0xFF) - 117);
    floatValues[i * 3 + 2] = (((val >> 16) & 0xFF) - 123);
} 

现在我们准备通过以下步骤运行模型:

  1. 创建一个与标签列表大小相同的浮点数数组。模型的输出将被获取到这个数组中。数组中的一个项目将表示标签的置信度。标签列表中的匹配标签将与浮点数组中的置信度结果在相同的位置。

  2. 运行TensorFlowInferenceInterfaceFeed方法,并将"Placeholder"作为第一个参数,二进制数据作为第二个参数,图像的尺寸作为第三个参数。

  3. 运行TensorFlowInferenceInterfaceRun方法,并传递一个包含值为"loss"的字符串的数组。

  4. 运行TensorFlowInferenceInterfaceFetch方法。将"loss"作为第一个参数,将输出的浮点数组作为第二个参数。

  5. 创建一个Dictionary <string, float>并用标签和每个标签的置信度填充它。

  6. 使用ClassificationCompleted事件和包含字典的ClassificationEventArgs调用事件,如下所示的代码:

var outputs = new float[labels.Count];
inferenceInterface.Feed("Placeholder", floatValues, 1, 227, 227, 3);
inferenceInterface.Run(new[] { "loss" });
inferenceInterface.Fetch("loss", outputs);

var result = new Dictionary<string, float>();

for (var i = 0; i < labels.Count; i++)
{
    var label = labels[i];
    result.Add(label, outputs[i]);
}

ClassificationCompleted?.Invoke(this, new ClassificationEventArgs(result)); 

创建一个基本的 ViewModel

在初始化应用程序之前,我们将创建一个基本的 ViewModel,以便在注册其他 ViewModel 时可以使用它。在其中,我们将放置可以在应用程序的所有 ViewModel 之间共享的代码。通过以下步骤来设置这一点:

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

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

  3. 将新的类设置为公共和抽象。

  4. 添加并实现INotifiedPropertyChanged接口。这是必要的,因为我们想要使用数据绑定。

  5. 添加一个Set方法,这将使我们更容易从INotifiedPropertyChanged接口中引发PropertyChanged事件。该方法将检查值是否已更改。如果是,它将引发事件。

  6. 添加一个名为NavigationINavigation类型的静态属性,如下所示的代码:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Xamarin.Forms; 

namespace HotDogOrNot
{
    public abstract class ViewModel : INotifyPropertyChanged
    {
         public event PropertyChangedEventHandler PropertyChanged; 
         protected void Set<T>(ref T field, T newValue, 
         [CallerMemberName] string propertyName = null)
         {
              if (!EqualityComparer<T>.Default.Equals(field, 
                 newValue))
              {
                   field = newValue;
                   PropertyChanged?.Invoke(this, new 
                   PropertyChangedEventArgs(propertyName));
              }    
         }    

         public static INavigation Navigation { get; set; } 
    } 
}

初始化应用程序

现在我们准备为应用程序编写初始化代码。我们将设置控制反转IoC)并进行必要的配置。

创建一个解析器

现在,我们将创建一个辅助类,它将简化通过Autofac解析对象图的过程。这将帮助我们根据配置的 IoC 容器创建类型。在这个项目中,我们将通过以下步骤使用Autofac作为 IoC 库:

  1. HotDogOrNot项目中,安装NuGetAutofacHotDogOrNot项目。

  2. 在根目录中创建一个名为Resolver的新类。

  3. 添加一个名为containerIContainer类型的私有静态字段(来自Autofac)。

  4. 添加一个名为Initialize的公共静态方法,带有IContainer作为参数。将参数的值设置为容器字段。

  5. 添加一个名为Resolve的通用static public方法,该方法将返回一个基于IContainerResolve方法的类型参数的实例,如下面的代码所示:

using System;
using Autofac; 

namespace HotDogOrNot
{    
    public class Resolver
    {
         private static IContainer container;

         public static void Initialize(IContainer container)
         {
              Resolver.container = container;
         }

         public static T Resolve<T>()
         {
              return container.Resolve<T>();
         }
    } 
}

创建引导程序

为了配置依赖注入并初始化Resolver,我们将创建一个引导程序。我们将有一个共享的引导程序和一个用于匹配特定配置的每个平台的引导程序。在 iOS 和 Android 中,我们将有不同的IClassifier实现。要创建引导程序,请按照以下步骤进行:

  1. HotDogOrNot项目中创建一个名为Bootstrapper的新类。

  2. 在新类中编写以下代码,如下面的代码所示:

using System.Linq;
using System.Reflection;
using Autofac;
using HotdogOrNot.ViewModels;
using Xamarin.Forms;

namespace HotDogOrNot
{
    public class Bootstrapper
    {
         protected ContainerBuilder ContainerBuilder { get; private 
         set; }

         public Bootstrapper()
         {
             Initialize();
             FinishInitialization();
         }

         protected virtual void Initialize()
         {
             ContainerBuilder = new ContainerBuilder();

             var currentAssembly = Assembly.GetExecutingAssembly();

             foreach (var type in 
             currentAssembly.DefinedTypes.Where(e => 
             e.IsSubclassOf(typeof(Page))))
             {
                 ContainerBuilder.RegisterType(type.AsType());
             }

             foreach (var type in 
             currentAssembly.DefinedTypes.Where(e => 
             e.IsSubclassOf(typeof(ViewModel))))
             {
                 ContainerBuilder.RegisterType(type.AsType());
             }
         }

         private void FinishInitialization()
         {
             var container = ContainerBuilder.Build();

             Resolver.Initialize(container);
         }
    } 
}

创建 iOS 引导程序

在 iOS 引导程序中,我们将有特定于 iOS 应用程序的配置。要创建 iOS 应用程序,我们需要按照以下步骤进行:

  1. HotDogOrNot.iOS项目中,创建一个名为Bootstrapper的新类。

  2. 使新类继承自HotDogOrNot.Bootstrapper

  3. 编写以下代码并解析所有引用:

using System;
using Autofac; 

public class Bootstrapper : HotdogOrNot.Bootstrapper
{
    public static void Init()
    {
        var instance = new Bootstrapper();
    }

    protected override void Initialize()
    {
        base.Initialize();

        ContainerBuilder.RegisterType<CoreMLClassifier>
        ().As<IClassifier>();
    }
}
  1. 转到 iOS 项目中的AppDelegate.cs

  2. FinishedLaunching方法中的LoadApplication调用之前,调用平台特定引导程序的Init方法,如下面的代码所示:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
      global::Xamarin.Forms.Forms.Init();
      Bootstrapper.Init();

      LoadApplication(new App());

      return base.FinishedLaunching(app, options);
}

创建 Android 引导程序

在 Android 引导程序中,我们将有特定于 Android 应用程序的配置。要在 Android 中创建引导程序,我们需要按照以下步骤进行:

  1. 在 Android 项目中,创建一个名为Bootstrapper的新类。

  2. 使新类继承自HotDogOrNot.Bootstrapper

  3. 编写以下代码并解析所有引用:

using System;
using Autofac; 

public class Bootstrapper : HotDogOrNot.Bootstrapper
{
         public static void Init()
         {
             var instance = new Bootstrapper();
         }

         protected override void Initialize()
         {
             base.Initialize();

             ContainerBuilder.RegisterType<TensorflowClassifier>
             ().As<IClassifier>().SingleInstance();
         }
}
  1. 转到 Android 项目中的MainActivity.cs文件。

  2. OnCreate方法中的LoadApplication调用之前,调用平台特定引导程序的Execute方法,如下面的代码所示:

protected override void OnCreate(Bundle savedInstanceState)
{
     TabLayoutResource = Resource.Layout.Tabbar;
     ToolbarResource = Resource.Layout.Toolbar;

     base.OnCreate(savedInstanceState);
     global::Xamarin.Forms.Forms.Init(this, savedInstanceState);

 Bootstrapper.Init();

     LoadApplication(new App());
}

构建第一个视图

该应用程序中的第一个视图将是一个简单的视图,其中有两个按钮。一个按钮用于启动相机,以便用户可以拍摄某物的照片,以确定它是否是热狗。另一个按钮用于从设备的照片库中选择照片。

构建 ViewModel

我们将首先创建ViewModel,它将处理用户点击按钮时会发生什么。让我们通过以下步骤设置这个:

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

  2. ViewModel作为MainViewModel的基类添加。

  3. 创建一个IClassifier类型的私有字段,并将其命名为classifier

  4. 创建一个具有IClassifier作为参数的构造函数。

  5. 将分类器字段的值设置为构造函数中的参数值,如下面的代码所示:

using System.IO;
using System.Linq;
using System.Windows.Input;
using HotdogOrNot.Models;
using HotdogOrNot.Views;
using Xamarin.Forms; 

public class MainViewModel : ViewModel
{
    private IClassifier classifier;

    public MainViewModel(IClassifier classifier)
    {
        this.classifier = classifier;
    } 
}

我们将使用Xam.Plugin.Media NuGet 包来拍摄照片和访问设备的照片库。我们需要通过 NuGet 包管理器为解决方案中的所有项目安装该包。但是,在我们可以使用该包之前,我们需要为每个平台进行一些配置。我们将从 Android 开始。让我们通过以下步骤设置这个:

  1. 该插件需要WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE权限。插件将为我们添加这些权限,但我们需要在MainActivity.cs中重写OnRequestPermissionResult

  2. 调用OnRequestPermissionsResult方法,如下面的代码所示。

  3. MainActivity.cs文件的OnCreate方法中初始化 Xamarin.Forms 后,添加CrossCurrentActivity.Current.Init(this, savedInstanceState),如下面的代码所示:

public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Android.Content.PM.Permission[] grantResults)
{
   Plugin.Permissions.PermissionsImplementation.Current.OnRequestPermissionsResult(requestCode, permissions, grantResults);
} 

我们还需要添加一些关于用户可以选择照片的文件路径的配置。让我们通过以下步骤来设置这一点:

  1. HotDogOrNot.Android项目中,在Resources文件夹中添加一个名为xml的文件夹。

  2. 在新文件夹中创建一个名为file_paths.xml的新 XML 文件。

  3. file_paths.xml中添加以下代码:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="my_images" path="Pictures" />
    <external-files-path name="my_movies" path="Movies" />
</paths>

为 Android 项目设置插件的最后一件事是在AndroidManifest.xml中添加以下代码(可以在 Android 项目的Properties文件夹中找到),在应用程序元素内部:

 <manifest xmlns:android="http://schemas.android.com/apk/res/android"   
  android:versionCode="1" android:versionName="1.0"   
  package="xfb.HotdogOrNot">
     <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="27"  
      />
     <application android:label="HotdogOrNot.Android">
     <provider android:name="android.support.v4.content.FileProvider" 
     android:authorities="${applicationId}.fileprovider" 
     android:exported="false" android:grantUriPermissions="true">
 <meta-data android:name="android.support.FILE_PROVIDER_PATHS" 
     android:resource="@xml/file_paths"></meta-data>
 </provider>
     </application>
 </manifest> 

对于 iOS 项目,我们唯一需要做的就是在info.plist中添加以下四个用途描述:

<key>NSCameraUsageDescription</key>
<string>This app needs access to the camera to take photos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to microphone.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs access to the photo gallery.</string>

一旦我们完成了插件的配置,我们就可以开始使用它。我们将首先创建一个方法,该方法将处理我们在用户拍照时和选择照片时都会得到的媒体文件。

让我们通过以下步骤来设置这一点:

  1. 打开MainViewModel.cs文件。

  2. 创建一个名为HandlePhoto的私有方法,该方法具有MediaFile类型的参数。

  3. 添加一个if语句来检查MediaFile参数是否为null。如果是,执行空返回。

  4. 使用MediaFile类的GetStream方法获取照片的流。

  5. 添加一个名为bytesbyte []类型的私有字段。

  6. 使用我们将在下一步中创建的ReadFully方法将流转换为字节数组。

  7. 为分类器的ClassificationCompleted事件添加一个事件处理程序。我们将在本章后面创建事件处理程序。

  8. 最后,调用分类器的Classify方法,并将字节数组作为参数,如下所示:

private void HandlePhoto(MediaFile photo)
{
    if(photo == null)
    {
        return;
    }

    var stream = photo.GetStream();
    bytes = ReadFully(stream);

    classifier.ClassificationCompleted += 
    Classifier_ClassificationCompleted;
    classifier.Classify(bytes);
} 

我们现在将创建ReadFully方法,该方法在前面的代码中调用。我们将使用它来将完整的流读入一个字节数组。代码如下所示:

private byte[] ReadFully(Stream input)
{
    byte[] buffer = new byte[16 * 1024];
    using (MemoryStream memoryStream = new MemoryStream())
    {
        int read;
        while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
        {
            memoryStream.Write(buffer, 0, read);
        }

        return memoryStream.ToArray();
    }

} 

在创建事件处理程序之前,我们将通过以下步骤创建一个我们将在事件处理程序中使用的模型:

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

  2. Models文件夹中创建一个名为Result的新类。

  3. 添加一个名为IsHotdogbool类型的属性。

  4. 添加一个名为Confidencefloat类型的属性。

  5. 添加一个名为PhotoBytesbyte[]类型的属性,如下所示:

public class Result
{
    public bool IsHotdog { get; set; }
    public float Confidence { get; set; }
    public byte[] PhotoBytes { get; set; }
} 

现在我们可以通过以下步骤为 ViewModel 添加一个事件处理程序:

  1. 创建一个名为Classifier_ClassificationCompleted的方法,该方法具有一个object和一个ClassificationEventArgs参数。

  2. 从分类器中删除事件处理程序,以便我们不会分配不必要的内存。

  3. 检查分类字典是否包含任何项。如果有,对字典进行排序,使具有最高置信度(值)的分类首先出现。

  4. 创建一个新的Result对象,并按以下代码中所示设置属性:

void Classifier_ClassificationCompleted(object sender, ClassificationEventArgs e)
{
    classifier.ClassificationCompleted -= 
    Classifier_ClassificationCompleted;

     Result result = null;

     if (e.Classifications.Any())
     {
         var classificationResult = 
         e.Classifications.OrderByDescending(x => x.Value).First();

         result = new Result()
         {
             IsHotdog = classificationResult.Key == "hotdog",
             Confidence = classificationResult.Value,
             PhotoBytes = bytes
         };
    }
    else
    {
        result = new Result()
        {
            IsHotDog = false,
            Confidence = 1.0f,
            PhotoBytes = bytes
        };
    } 
} 

创建结果视图后,我们将返回事件处理程序,以添加导航到结果视图。在这个ViewModel中,我们要做的最后一件事是为视图中的按钮创建一个Command属性。让我们从设置拍照按钮开始,通过以下步骤:

  1. MainViewModel.cs文件中创建一个名为TakePhotoICommand类型的新属性。

  2. 使用表达式返回一个新的Command

  3. 将一个Action作为表达式传递给Command的构造函数。

  4. Action中,使用CrossMedia.Current.TakePhotoAsync方法,并将StoreCameraMediaOptions对象传递给它。

  5. StoreCameraMediaOptions中,使用DefaultCamera属性将默认相机设置为后置相机。

  6. 将对TakePhotoAsync方法的调用结果传递给HandlePhoto方法,如下所示:

public ICommand TakePhoto => new Command(async() =>
{
     var photo = await CrossMedia.Current.TakePhotoAsync(new 
     StoreCameraMediaOptions()
     {
       DefaultCamera = CameraDevice.Rear
     });

   HandlePhoto(photo);
});

现在我们将在MainViewModel中处理从库中选择照片按钮被点击时发生的情况。让我们按照以下步骤设置这个方法:

  1. 创建一个名为PickPhotoICommand类型的新属性。

  2. 使用表达式返回一个新的Command

  3. 将一个Action作为表达式传递给Command的构造函数。

  4. Action中,使用CrossMedia.Current.PickPhotoAsync来打开操作系统的默认照片选择器。

  5. TakePhotoAsync方法的调用结果传递给HandlePhoto方法,如下所示:

 public ICommand PickPhoto => new Command(async () =>
 {
     var photo = await CrossMedia.Current.PickPhotoAsync();

     HandlePhoto(photo);
 });

构建视图

现在,一旦我们创建了ViewModel,就是时候为 GUI 创建代码了。按照以下步骤创建MainView的 GUI:

  1. HotDogOrNot项目中创建一个名为Views的新文件夹。

  2. 创建一个名为MainView的新的XAML ContentPage

  3. ContentPageTitle属性设置为Hotdog or Not hotdog

  4. 在页面上添加一个StackLayout并将其VerticalOptions属性设置为Center

  5. StackLayout中添加一个名为Take PhotoButton。对于Command属性,添加到ViewModel中的TakePhoto属性的绑定。

  6. StackLayout中添加一个名为Pick PhotoButton。对于Command属性,添加到ViewModel中的Pick Photo属性的绑定,如下所示:

<ContentPage  

              x:Class="HotDogOrNot.Views.MainView"
              Title="Hot dog or Not hot dog">
     <ContentPage.Content>
         <StackLayout VerticalOptions="Center">
             <Button Text="Take Photo" Command="{Binding TakePhoto}" />
             <Button Text="Pick Photo" Command="{Binding PickPhoto}" />
         </StackLayout>
     </ContentPage.Content>
</ContentPage>

MainView的代码后台,按照以下步骤设置视图的绑定上下文:

  1. MainViewModel作为构造函数的参数。

  2. InitialComponent方法调用之后,将视图的BindingContext属性设置为MainViewModel参数。

  3. NavigationPage类上使用静态方法SetBackButtonTitle,以便在结果视图的导航栏中显示返回到此视图的箭头,如下所示:

public MainView(MainViewModel viewModel)
{
    InitializeComponent();

    BindingContext = viewModel;
    NavigationPage.SetBackButtonTitle(this, string.Empty);
}

现在我们可以转到App.xaml.cs,并按照以下步骤将MainPage设置为MainView

  1. HotDogOrNot项目中,转到App.xaml.cs

  2. 使用Resolver上的Resolve方法创建MainView的实例。

  3. 创建一个NavigationPage并将MainView传递给构造函数。

  4. ViewModel上的静态Navigation属性设置为NavigationPage上的Navigation属性的值。

  5. MainPage属性设置为我们在步骤 3 中创建的NavigationPage的实例。

  6. 删除MainPage.xaml,因为我们不再需要它。你应该剩下以下代码:

public App()
{
    InitializeComponent();

    var mainView = Resolver.Resolve<MainView>();
 var navigationPage = new NavigationPage(mainView);

 ViewModel.Navigation = navigationPage.Navigation;

 MainPage = navigationPage;
}

构建结果视图

在这个项目中,我们需要做的最后一件事是创建结果视图。这个视图将显示输入的照片,以及它是否是一个热狗。

构建 ViewModel

在创建视图之前,我们将创建一个ViewModel来处理视图的所有逻辑,按照以下步骤进行:

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

  2. ViewModel作为ResultViewModel的基类添加。

  3. 创建一个名为Titlestring类型的属性。为该属性添加一个私有字段。

  4. 创建一个名为Descriptionstring类型的属性。为该属性添加一个私有字段。

  5. 创建一个名为PhotoBytesbyte[]类型的属性。为该属性添加一个私有字段,如下所示:

using HotdogOrNot.Models;

namespace HotDogOrNot.ViewModels
{
    public class ResultViewModel : ViewModel
    { 
        private string title;
        public string Title
        {
            get => title;
            set => Set(ref title, value);
        }

        private string description;
        public string Description
        {
            get => description;
            set => Set(ref description, value);
        }

        private byte[] photoBytes;
        public byte[] PhotoBytes
        {
            get => photoBytes;
            set => Set(ref photoBytes, value);
        } 
    }
}

ViewModel中创建一个带有结果作为参数的Initialize方法。让我们按照以下步骤设置这个方法:

  1. Initialize方法中,将PhotoBytes属性设置为result参数的PhotoBytes属性的值。

  2. 添加一个if语句,检查result参数的IsHotDog属性是否为true,以及Confidence是否高于 90%。如果是这样,将Title设置为"Hot dog",并将Description设置为"This is for sure a hotdog"

  3. 添加一个else if语句,检查result参数的IsHotdog属性是否为true。如果是这样,将Title设置为"Maybe",将Description设置为"This is maybe a hotdog"

  4. 添加一个else语句,将Title设置为"Not a hot dog",将Description设置为"This is not a hot dog",如下面的代码所示:

public void Initialize(Result result)
{
    PhotoBytes = result.PhotoBytes;

    if (result.IsHotdog && result.Confidence > 0.9)
    {
        Title = "Hot dog";
        Description = "This is for sure a hot dog";
    }
    else if (result.IsHotdog)
    {
        Title = "Maybe";
        Description = "This is maybe a hot dog";
    }
    else
    {
        Title = "Not a hot dog";
        Description = "This is not a hot dog";
    }
} 

构建视图

因为我们想要在输入视图中显示输入照片,所以我们需要将其从byte[]转换为Xamarin.Forms.ImageSource。我们将通过以下步骤在值转换器中执行此操作,然后可以与 XAML 中的绑定一起使用:

  1. HotDogOrNot项目中创建一个名为Converters的新文件夹。

  2. 创建一个名为BytesToImageConverter的新类。

  3. 添加并实现IValueConverter接口,如下面的代码所示:

using System;
using System.Globalization;
using System.IO;
using Xamarin.Forms;

public class BytesToImageConverter : IValueConverter
{ 
    public object Convert(object value, Type targetType, object 
    parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }

   public object ConvertBack(object value, Type targetType, object 
   parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

ViewModel更新视图时,将使用Convert方法。当View更新ViewModel时,将使用ConvertBack方法。在这种情况下,我们只需要编写Convert方法的代码,如下面的步骤所示:

  1. 首先检查value参数是否为null。如果是,我们应该返回null

  2. 如果值不为null,则将其强制转换为byte[]

  3. 从字节数组创建一个MemoryStream

  4. 返回ImageSource.FromStream方法的结果,我们将向其中传递流,如下面的代码所示:

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
    if(value == null)
    {
        return null;
    }

    var bytes = (byte[])value;
    var stream = new MemoryStream(bytes);

    return ImageSource.FromStream(() => stream);
} 

视图将包含照片,占据屏幕的三分之二。在照片下面,我们将添加结果的描述。让我们通过以下步骤设置这一点:

  1. Views文件夹中,创建一个名为ResultView的新XAML ContentPage

  2. 导入转换器的命名空间。

  3. BytesToImageConverter添加到页面的Resources中,并为其指定键"ToImage"

  4. ContentPageTitle属性绑定到ViewModelTitle属性。

  5. 在页面上添加一个具有两行的Grid。第一行的RowDefinitionHeight值应为2*。第二行的高度应为*。这些是相对值,意味着第一行将占Grid的三分之二,而第二行将占Grid的三分之一。

  6. Grid添加一个Image,并将Source属性绑定到ViewModel中的PhotoBytes属性。使用转换器将字节转换为Source属性的ImageSource

  7. 添加一个Label,并将Text属性绑定到ViewModelDescription属性,如下面的代码所示:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
             xmlns:converters="clr-namespace:HotdogOrNot.Converters"
             x:Class="HotdogOrNot.Views.ResultView"
             Title="{Binding Title}">
<ContentPage.Resources>
         <converters:BytesToImageConverter x:Key="ToImage" />
</ContentPage.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="2*" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Image Source="{Binding PhotoBytes, Converter=
        {StaticResource ToImage}}" Aspect="AspectFill" />
        <Label Grid.Row="1" HorizontalOptions="Center" 
        FontAttributes="Bold" Margin="10" Text="{Binding 
        Description}" />
    </Grid>
</ContentPage> 

我们还需要设置视图的BindingContext。我们将在与MainView相同的方式中进行,即在代码后台文件(ResultView.xaml.cs)中,如下面的代码所示:

public ResultView (ResultViewModel viewModel)
{
    InitializeComponent ();

    BindingContext = viewModel;
}

我们需要做的最后一件事是从MainView导航到ResultView。我们将在MainViewModelClassifier_ClassificationCompleted方法的末尾添加以下代码来实现这一点:

var view = Resolver.Resolve<ResultView>();
((ResultViewModel)view.BindingContext).Initialize(result);

Navigation.PushAsync(view);

下面您可以看到,如果我们上传一张热狗的照片,应用程序将是什么样子:

总结

在本章中,我们构建了一个可以识别照片是否有热狗的应用程序。我们通过使用 Azure 认知服务和自定义视觉服务训练图像分类的机器学习模型来实现这一点。

我们为 CoreML 和 TensorFlow 导出了模型,并学习了如何在 iOS 和 Android 应用中使用它们。在这些应用中,用户可以拍照或从照片库中选择照片。这张照片将被发送到模型进行分类,我们将得到一个结果,告诉我们这张照片是否是热狗。

posted @ 2024-05-17 17:51  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报