Xamarin-Forms-项目-三-

38 阅读12分钟

Xamarin.Forms 项目(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:为多种形态因素构建天气应用程序

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 认知服务中的机器学习来扫描照片中的成人内容。

在下一章中,我们将构建一个聊天应用程序,该应用程序将使用我们在本项目中构建的后端。