在上一节中,我们使用单一项目系统将一个简单的 Xamarin.Forms 应用程序转换为 .NET MAUI。该项目没有使用任何高级的 Xamarin.Forms 功能,例如外部 NuGet 包、自定义控件或任何商业控件。这些是在将您的应用程序从 Xamarin.Forms 迁移到 .NET MAUI 时需要考虑的额外项目。
在本节中,我们将讨论您在迁移您的 Xamarin.Forms 应用程序到 .NET MAUI 时应该遵循的基本流程。这绝对不是一份详尽的列表;.NET MAUI 团队正在更新一个维基页面,其中详细说明了他们所有的知识。
.NET 升级助手是一个工具,它将尝试为您执行前四个步骤。然而,在我们深入使用 .NET 升级助手之前,我们将查看每个步骤包含的内容,以便我们能够牢固地理解如何在 .NET 升级助手无法在应用程序项目中操作时迁移我们的应用程序。
一些 Xamarin.Forms 项目基于 .NET Framework 项目模板。这是一个冗长的项目格式,已经更新为适用于 .NET 项目。新的格式,通常称为 SDK 风格,是一个更简洁的格式,具有更好的默认设置。使用 Visual Studio 16.5 或更高版本创建的 Xamarin.Forms 项目使用较新的 SDK 格式。
接下来要查看的项目类型是 iOS 项目。这里的更改将与 Android 项目的更改非常相似,但带有 iOS 特色。Xamarin.Forms iOS 项目也是一个.NET Framework 风格的项目,因此我们需要将<Project …>元素更改为以下内容:
将代码从 Xamarin.Forms 更新到 .NET MAUI 包含几个步骤。首先,我们需要添加一些初始化 .NET MAUI 所必需的新代码。有关所需文件的更多详细信息,请参阅 第二章 的 检查文件部分 。
Android SDK versions
Since the Android SDK is updated yearly, it may be the case that the version of .NET for Android and .NET MAUI that you are using is also using a version of the Android SDK that is greater than `33`. The good news is that you will get an error in Visual Studio if `targetSdkVersion` is too low or is not installed. Just follow the instructions in the build error to set the SDK version correctly.
That is all the changes we need to make in the Android project for now. Moving on to the iOS project, the `AppDelegate.cs` file can be updated so that it matches the following:
public partial class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.
CreateMauiApp();
}
The final change for the iOS project is to open the `Info.plist` file and change the `MinimumOSVersion` property to `15.2`.
With the base changes needed to start your app as a .NET MAUI app done, the next changes are much broader brush strokes:
1. Remove all `Xamarin.*` namespaces from `.``cs` files.
2. Change all `xaml` namespace declarations from the following:
You will need to amend them like so:
You may notice that these are the same changes we made in the previous section when using a .NET MAUI Single Project.
About images
.NET MAUI has improved image handling for the various platforms that it targets. You can provide a single SVG image file, and it will resize the image correctly for all platforms for you. Since the SVG format is based on vectors, it will render much better than other formats such as JPG and PNG after resizing. It is recommended that you convert your images into SVG format, if possible, to take advantage of this feature in .NET MAUI.
Updating any incompatible NuGet packages
There are a lot of NuGet packages out there and there is no way we can cover them all. But, in general, for each of the NuGet packages that are in use in your app, be sure to look for a version that specifically supports .NET MAUI or the version of .NET that you are targeting. You can use the NuGet Gallery page to determine whether a package supports .NET MAUI. Using a popular package such as PCLCrypto version 2.0.147 ([`www.nuget.org/packages/PCLCrypto/2.0.147#supportedframeworks-body-tab`](https://www.nuget.org/packages/PCLCrypto/2.0.147#supportedframeworks-body-tab)) targets classic Xamarin projects but not .NET 6 or .NET 7\. You can find the compatible frameworks under the **Frameworks** tab:

Figure 3.13 – NuGet Gallery page for PCLCrypto v2.0.147
However, version 2.1.40-alpha ([`www.nuget.org/packages/PCLCrypto/2.1.40-alpha#supportedframeworks-body-tab`](https://www.nuget.org/packages/PCLCrypto/2.1.40-alpha#supportedframeworks-body-tab)) lists .NET 6 and .NET 7 as compatible frameworks:

Figure 3.14 – NuGet Gallery page for PCLCrypto v2.1.40-alpha
Currently, we know of the following NuGet changes:
* Remove all Xamarin.Forms and Xamarin.Essentials NuGet references from your projects. These are now included in .NET MAUI directly. You will have to make some namespace adjustments as those have changed.
* Replace Xamarin.Community Toolkit with the latest preview of .NET MAUI Community Toolkit. You will have to make some namespace adjustments as those have changed.
* If you reference any of the following SkiaSharp NuGet packages directly, replace them with the latest previews:
* **SkiaSharp.Views.Maui.Controls**
* **SkiaSharp.Views.Maui.Core**
* **SkiaSharp.Views.Maui.Controls.Compatibility**
You can find the latest version of NuGet packages on the NuGet Gallery website at [`nuget.org/packages`](https://nuget.org/packages).
Addressing any breaking API changes
Unfortunately, there is no magic bullet for any of these types of changes. You will simply have to start from the top of your error list and work your way through them. You can review the release notes linked in the official migration guide, available at [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/), as helpful hints.
For example, a common type of error that’s seen is `error CS0104: 'ViewExtensions' is an ambiguous reference between 'Microsoft.Maui.Controls.ViewExtensions' and 'Microsoft.Maui.ViewExtensions'`. This can be fixed by explicitly using the full namespace when referencing the type or by using a type alias – for example, `using ViewExtensions =` `Microsoft.Maui.Controls.ViewExtensions`.
Custom renderers and effects
Your application may use custom renderers or effects to provide a unique user experience. Covering how to upgrade these components is beyond the scope of this chapter. To learn more about how to upgrade renderers and effects, visit the Microsoft Learn site for .NET MAUI migration at [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/).
Running the converted app and verifying its functionality
This is not the last step – I recommend that you attempt to do this after each change. Building your app as you make changes ensures that you are moving in the right direction. I recommend that you `obj` and `bin` folders beforehand. This will ensure that you are building with the latest changes and dependencies, by forcing a NuGet restore.
Now that we know the basics of how to convert a Xamarin.Forms app, let’s use .NET Upgrade Assistant to migrate a project for us.
Installing and running .NET Upgrade Assistant
As stated previously, .NET Upgrade Assistant will attempt to perform the first four steps of the migration of your Xamarin.Forms app to .NET MAUI outlined in the previous section. The tool is under active development and as the team discovers new improvements, they are added. This is mostly due to feedback they receive from developers like you.
At the time of writing, .NET Upgrade Assistant did not work on all projects and has the following limitations:
* Xamarin.Forms must be version 5.0 and higher
* Only Android and iOS projects are converted
* .NET MAUI must be properly installed with the appropriate workloads
If you have followed the steps from *Chapter 1*, then the last should should already be satisfied.
If your Xamarin.Forms app meets these criteria, then we can get started by installing the tool.
Installing .NET Upgrade Assistant
.NET Upgrade Assistant is a Visual Studio extension on Windows and a command-line tool on Windows and macOS. You can use the integrated developer PowerShell in Visual Studio or any command-line prompt to install the tool. Follow these steps to install the tool using Visual Studio on Windows:
1. In Visual Studio, select the **Extensions** menu, then the **Manage Extensions** item. This will open the **Manage** **Extensions** dialog.
2. In the `upgrade`.
3. Select **.NET Upgrade Assistant** and click **Download**:

Figure 3.15 – Visual Studio – the Manage Extensions dialog
1. Once the extension has been downloaded, you will need to close and reopen Visual Studio to install the extension:

Figure 3.16 – Installing the .NET Upgrade Assistant VSIX
1. Click **Modify** and then follow the instructions to complete the installation.
2. Once the installation is complete, reopen Visual Studio.
Now that the tool has been installed, we can use it to convert a Xamarin.Forms project!
Preparing to run .NET Upgrade Assistant
For the remainder of this chapter, we will be using Visual Studio on Windows to migrate a Xamarin.Forms app to .NET MAUI.
To run .NET Upgrade Assistant, we will need a Xamarin.Forms project to upgrade. For this portion of the chapter, we will use the Xamarin.Forms app that was demoed at the Microsoft Build conference in 2019\. The source can be found at [`github.com/mindofai/Build2019Chat`](https://github.com/mindofai/Build2019Chat), though you can find it in this book’s GitHub repository at [`github.com/PacktPublishing/MAUI-Projects-3rd-Edition`](https://github.com/PacktPublishing/MAUI-Projects-3rd-Edition) under the `Chapter03/Build2019Chat` folder.
Once you have downloaded the source, open the `BuildChat.sln` file in Visual Studio. Once the project has finished loading, make sure your configuration is correct by running the app first. You should see a screen that looks like this on Android:

Figure 3.17 – The original app on Android
Now that we have confirmed that the original app runs, we can follow these steps to prepare for running .NET Upgrade Assistant:
1. Right-click the `BuildChat` solution node in **Solution Explorer** and select **Manage NuGet Packages** **for Solution…**:

Figure 3.18 – Solution context menu
1. In the **NuGet – Solution** window that opens, select the **Updates** tab:

Figure 3.19 – The NuGet – Solution window
1. Click the **Select all packages** checkbox, then click **Update**.
2. Visual Studio will prompt you with a preview of all the changes that will be made. Click **OK** once you have reviewed them.
3. Visual Studio will then prompt you to accept the license terms for packages that have them. Once you have reviewed the license terms, click **I Accept**.
4. After updating, you may still have a **gold bar** indicator in the Visual Studio window from running the application earlier. You can safely dismiss the message by clicking the **X** button on the right:
Figure 3.20 – Xamarin.Forms version gold bar
1. Once the packages have been updated, let’s make sure the app is still working by running it again. You should get a build error like the following:

Figure 3.21 – Error after upgrading packages
1. To resolve this error, in `BuildChat.Android` project, then press *Alt* and *Enter* at the same time to open the project properties page.
2. Use the `Android 8.1 (Oreo)` to `Android 10.0`.
Google Play support
You may get a warning about Google Play requiring new apps and updates to support a specific version of Android. To remove that warning, just set **Target Framework** to the version indicated in the warning message.
1. Visual Studio will prompt you to confirm the change as it has to close and re-open the project. Select **Yes**.
2. Visual Studio may also prompt you to install the Android version if you haven’t installed it. Follow the prompts to install the Android version.
3. Attempting to run the project again yields a new set of errors:

Figure 3.22 – Missing packages error
1. To resolve this error, right-click the `BuildChat.Android` project and select **Unload Project**. The project file should open in Visual Studio automatically.
2. Locate `<ItemGroup>` in the file with `<PackageReference>` items and make the changes highlighted in the following snippet:
```
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client">
<Version>7.0.9</Version>
</PackageReference>
<PackageReference Include="Xamarin.Forms" Version="5.0.0.2578" />
<PackageReference Include="Xamarin.Android.Support.Design" Version="28.0.0.3" />
<PackageReference Include="Xamarin.Android.Support.v7.AppCompat" Version="28.0.0.3" />
<PackageReference Include="Xamarin.Android.Support.v4" Version="28.0.0.3" />
<PackageReference Include="Xamarin.Android.Support.v7.CardView" Version="28.0.0.3" />
<PackageReference Include="Xamarin.AndroidX.MediaRouter" Version="1.2.0" />
<PackageReference Include="Xamarin.AndroidX.Palette" Version="1.0.0.5" />
</ItemGroup>
```cs
You could also use Visual Studio’s NuGet Package Manager to add these packages.
3. Save and reload the project before trying to run it again. Since the project was unloaded, you will need to set the `BuildChat.Android` project as the startup project again.
You should be able to run the application at this time since some warnings can be ignored. If not, review the previous steps to make sure you made all the changes correctly. At this point, we are ready to run the upgrade assistant to convert from Xamarin.Forms into .NET MAUI.
Treat warnings as errors
If you have the project option to treat warnings as errors set to anything other than none, then the warnings will prevent you from running the app. Set the option to none to allow the app to run. The option defaults to none.
Running .NET Upgrade Assistant
Running .NET Upgrade Assistant from within Visual Studio is a straightforward process. We will upgrade each project individually; there isn’t any method to upgrade all the projects in one go.
Upgrading the BuildChat project
Let’s start with the shared project, `BuildChat`, by following these steps:
1. Select the `BuildChat` project in **Solution Explorer**.
2. Use the context menu to select the **Upgrade** menu item:

Figure 3.23 – Upgrading the BuildChat project
This will open the **Upgrade** assistant in a document window:

Figure 3.24 – Upgrading the BuildChat project
1. Select the **In-place project** **upgrade** option.
2. Depending on the versions of .NET you have installed, you will be prompted to choose one. If you followed the setup instructions in *Chapter 1*, you should have the .NET 7.0 option available. Select **.NET 7.0** and select **Next**:

Figure 3.25 – Choosing the preferred target framework
1. At this point, you are allowed to review the changes that will be made by expanding each node in the list. You can also choose to not upgrade certain items by removing the check in the checkbox next to that item. When you have inspected all the changes, make sure all items are checked again, then click **Upgrade selection**:

Figure 3.26 – Reviewing the upgrade
1. Visual Studio will start the upgrade process. You can monitor it as it completes each item:

Figure 3.27 – Upgrade in progress
1. When it’s finished, you can inspect each item to see what the result of the upgrade was:

Figure 3.28 – Upgrade complete
A white check in a green circle indicates some transformation was completed and successful, a green check with a white background means the step was skipped since nothing was needed, and a red cross (not shown) means the transformation failed. You can view the complete output from the tool by inspecting the **Upgrade Assistant** log in the output pane:

Figure 3.29 – Upgrade Assistant log output
Do not be concerned with the errors in the error window at this point. There will be errors until we finish upgrading the remaining projects. Now that the `BuildChat` project has been upgraded, we can upgrade the `BuildChat.Android` project.
Upgrading the BuildChat.Android project
The steps for upgrading the remaining projects are largely the same – the only difference will be the steps involved in upgrading each project. The next two sections will skip the screenshots and just provide the steps. To complete the upgrade for the `BuildChat.Android` project, follow these steps:
1. Select the **BuildChat.Android** project in **Solution Explorer**.
2. Use the context menu to select the **Upgrade** menu item.
3. This will open the **Upgrade** assistant in a document window.
4. Select the **In-place project** **upgrade** option.
5. Select the **.NET 7.0** option, then select **Next**.
6. Review the changes that will be made by expanding each node in the list. Make sure all items are checked, then click **Upgrade Selection**.
7. Visual Studio will complete the upgrade process.
Now that .NET Upgrade Assistant has completed the `BuildChat.Android` project, we can upgrade the `BuildChat.iOS` project.
Upgrading the BuildChat.iOS project
The steps for upgrading the iOS project are largely the same – the only difference will be the steps involved in upgrading each project. To complete the upgrade for the `BuildChat.iOS` project, follow these steps:
1. Select the `BuildChat.iOS` project in **Solution Explorer**.
2. Use the context menu to select the **Upgrade** menu item.
3. This will open the **Upgrade** assistant in a document window:
4. Select the **In-place project** **upgrade** option.
5. Select the **.NET 7.0** option, then select **Next**.
6. Review the changes that will be made by expanding each node in the list. Make sure all items are checked, then click **Upgrade Selection**.
7. Visual Studio will complete the upgrade process.
Now that .NET Upgrade Assistant has completed the `BuildChat.iOS` project, we can see how well it worked.
Completing the upgrade to .NET MAUI
With .NET Upgrade Assistant having done all the work it can to upgrade the projects, we can now see what is left for us to complete the upgrade to .NET MAUI.
The first thing we want to do is make sure that the project is clean of all the previous build artifacts. This will ensure we are referencing all the right dependencies in our build output by forcing a restore and build. The best way to accomplish this is to remove the `bin` and `obj` folders from each project folder.
Use `bin` and `obj` folders from the `BuildChat`, `BuildChat.Android` and `BuildChat.iOS` folders, then build the solution.
We’ll end up with a few build errors for each project, as shown in the following figure:

Figure 3.30 – Package issues
To resolve these errors, either use Visual Studio’s NuGet Package Manager to add a reference to version 7.0.1 of the `Microsoft.Extensions.Logging.Abstractions` package to all the projects, or follow these steps to update the project files manually:
1. Select the `BuildChat` project in **Solution Explorer**.
The project file will open in a document window automatically.
2. Locate the `ItemGroup` element that contains the `PackageReference` items.
3. Make the changes highlighted in the following snippet:
```
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
</ItemGroup>
```cs
4. Select the `BuildChat.Android` project in **Solution Explorer**.
The project file will open in a document window automatically.
5. Locate the `ItemGroup` element that contains the `PackageReference` items.
6. Make the changes highlighted in the following snippet:
```
<ItemGroup>
<PackageReference Include="Xamarin.AndroidX.MediaRouter" Version="1.2.0" />
<PackageReference Include="Xamarin.AndroidX.Palette" Version="1.0.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
</ItemGroup>
```cs
7. Select the `BuildChat.iOS` project in **Solution Explorer**.
The project file will open in a document window automatically.
8. Locate the `ItemGroup` element that contains the `PackageReference` items.
9. Make the changes highlighted in the following snippet:
```
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
</ItemGroup>
```cs
Now that we have added the required package references, we can try building the app again. After this build, we’ll get two new errors:

Figure 3.31 – Namespace does not exist errors
These two errors show two areas that the upgrade assistant did not upgrade. Luckily, we covered how to upgrade these two files easier in this chapter. Let’s upgrade them again, starting with the `BuildChat.Android` project.
Open the `MainActivity.cs` file and make the changes highlighted in the following code:
使用 Microsoft.Maui;
命名空间 BuildChat.Droid
{
[Activity(Label = "BuildChat", Icon = "@mipmap/icon", Theme = "@style/Theme.MaterialComponents", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
}
}
}
That should complete the changes needed for the Android project. Now, to upgrade the iOS project, open the `AppDelegate.cs` file in `BuildChat.iOS` and update it so that it matches the following:
使用 Foundation;
使用 Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace BuildChat.iOS
{
// 应用的 UIApplicationDelegate。此类负责启动
// 应用的用户界面,以及监听(并可选地响应)
// 从 iOS 接收的应用事件。
[Register("AppDelegate")]
public partial class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}
The final change for the iOS project is to open the `Info.plist` file and change the `MinimumOSVersion` property to `15.2`. To make this change, use `Info.plist` file and make this change, follow these steps:
1. Select the `Info.plist` file in the `BuildChat.iOS` project.
2. Use the context menu (right-click) and select **Open With…**.
3. In the **Open With** dialog, select **Generic Plist Editor**, then select **OK**:

Figure 3.32 – Opening the Info.plist file
1. Find the entry labeled `Minimum system version` and change the value from `8.0` to `15.1`:

Figure 3.33 – Changing the minimum system version
Great – that should complete the changes needed to get the app running as a .NET MAUI application! The following are the before and after screenshots of the application; *before* is on the left and *after* is on the right:

Figure 3.34 – Xamarin.Forms versus .NET MAUI
There are some visual changes between Xamarin.Forms and .NET MAUI, and you can tweak the .NET MAUI settings for the layouts and controls to get a very similar output.
Summary
In this chapter, we focused on upgrading a Xamarin.Forms app to .NET MAUI. We learned how to upgrade the project files from .NET Framework to SDK-style projects, application startup files, XAML views, and C# files needed for .NET MAUI. We started by doing this manually to learn about all the required steps and changes. We ended this chapter by using .NET Upgrade Assistant to make many of the changes for us. We also learned how to upgrade to the Single Project format, which is the default for .NET MAUI. Now, we can pick the best method for the project we are upgrading.
While we covered a lot in this chapter, it was not exhaustive. There is a lot of variation in different projects, from NuGet package dependencies and vendor-provided controls to customizations using renderers and effects. The app you are upgrading may use one or all of these, and you can find additional help on the Microsoft Learn site for upgrading Xamarin.Forms to .NET MAUI: [`learn.microsoft.com/en-us/dotnet/maui/migration/`](https://learn.microsoft.com/en-us/dotnet/maui/migration/).
If you are interested in seeing the `BuildChat` app fully functional, try using .NET Upgrade Assistant on the service that was also built for the 2019 Microsoft Build conference. You can find the source on GitHub at [`github.com/mindofai/SignalRChat/tree/master`](https://github.com/mindofai/SignalRChat/tree/master). You could also use ChatGPT to help you build the service yourself using Azure Functions and SignalR.
In the next chapter, we will build an app that displays news articles using the new .NET MAUI Shell.
第二部分:基本项目
在这部分,你将学习.NET MAUI 的特性,例如 Shell、CollectionView、Image、Button、Label、CarouselView、Grid、自定义控件和手势。你将探索使用位置服务、调用自定义 Web API 以及为不同形态设计你的 XAML。
本部分包含以下章节:
第四章:使用 .NET MAUI Shell 构建新闻应用
在本章中,我们将创建一个利用微软 .NET MAUI 团队提供的 Shell 导航功能构建的新闻应用。我们之前使用 ContentPage 、FlyoutPage 、TabbedPage 或 NavigationPage 作为主页的方法仍然有效,就像我们在 第二章 中所做的那样,但我们确信您会喜欢定义应用程序结构的新方法。此外,您还可以混合使用新旧方法。
到本章结束时,您将学习如何使用 Shell 定义应用程序结构,从 REST API 消费数据,配置导航,以及使用查询样式路由在视图之间传递数据。
那么,Shell 是什么呢?在 Shell 中,您使用 可扩展应用程序标记语言 (XAML )来定义您应用程序的结构,而不是将其隐藏在应用程序中分散的代码片段中。您还可以使用路由进行导航,就像那些花哨的网页开发者所做的那样。
本章将涵盖以下主题:
技术要求
要完成这个项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载组件。有关如何设置环境的更多详细信息,请参阅 第一章 ,.NET MAUI 简介 。
您可以在 github.com/PacktPublishing/MAUI-Projects-3rd-Edition 找到本章的源代码。
项目概述
我们将使用 单项目 功能作为代码共享策略来创建一个 .NET MAUI 项目。它将包含以下两个部分:
第二部分不是学习 Shell 所必需的,但它将使您在构建完整应用程序的道路上更进一步。
本项目的构建时间大约为 1.5 小时。
构建新闻应用
本章将从头开始构建新闻应用。它将指导您完成每个步骤,但不会深入每个细节。为此,我们建议阅读 第二章 ,构建我们的第一个 .NET MAUI 应用程序 ,其中包含更多详细信息。
开心编码!
设置项目
与所有其他项目一样,本项目是一个 文件 | 新建 | 项目... 风格的项目。这意味着我们根本不会导入任何代码。因此,本节全部关于创建项目和设置基本项目结构。
创建新项目
第一步是创建一个新的 .NET MAUI 项目:
打开 Visual Studio 2022 并选择 创建一个 新项目 :
图 4.1 – Visual Studio 2022
这将打开 创建一个新项目 向导。
在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项:
图 4.2 – 创建一个新项目
点击 下一步 。
如下截图所示,输入 News 作为应用程序的名称:
图 4.3 – 配置您的新的项目
点击 下一步 。
最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用作为 长期支持 (LTS ),而 .NET 7 可用作为 标准期限支持 。对于本书,我们假设您将使用 .NET 7:
图 4.4 – 其他信息
通过点击 创建 并等待 Visual Studio 创建项目来完成设置。
项目创建到此结束。
让我们继续设置应用程序的结构。
创建应用程序的结构
在本节中,我们将开始构建应用程序的 视图 和 ViewModel 。第二章 中的 使用 MVVM – 创建视图和 ViewModel 部分包含有关 模型-视图-ViewModel (MVVM )作为设计模式的更多详细信息。如果您不知道 MVVM 是什么,建议您先阅读。
创建 ViewModel 基类
ViewModel 是 View 和 Model 之间的中介。让我们创建一个具有常见功能的基础类 ViewModels,我们可以重用它。在实践中,ViewModel 必须实现一个名为 INotifyPropertyChanged 的接口,以便 MVVM 能够运行。我们将在基类中这样做,并添加一个名为 CommunityToolkit.Mvvm 的小巧助手工具,这将为我们节省大量时间。如果您对 MVVM 感到不确定,请再次查看 第二章 ,构建我们的第一个 .NET MAUI 应用 。
第一步是创建一个基类。按照以下步骤操作:
在 News 项目中,创建一个名为 ViewModels 的文件夹。
在 ViewModels 文件夹中,创建一个名为 ViewModel 的类。
将现有类更改为以下样子:
namespace News.ViewModels;
public abstract class ViewModel
{
}
太棒了!让我们在基 ViewModel 类中实现 INotifyPropertyChanged。
CommunityToolkit.Mvvm 是一个包含几个源生成器的 NuGet 包,这些生成器可以自动生成 INotifyPropertyChanged 所需的实现细节。更具体地说,它将注入一个调用,每当调用设置器时都会引发 PropertyChanged 事件。它还负责属性依赖关系;如果更改 FirstName 属性,只读属性 FullName 也会收到一个 PropertyChanged 事件。在 CommunityToolkit.Mvvm 之前,您将不得不手动编写此代码。
更详细的内容请参阅 第二章 ,构建我们的第一个 .NET MAUI 应用程序 。你读过它了吗?
CommunityToolkit.Mvvm 及其依赖项使用 NuGet 安装。因此,让我们安装 NuGet 包:
在 News 项目中,安装 CommunityToolkit.Mvvm NuGet 包,版本 8.0.0。
接受任何许可对话框。
这将安装相关的 NuGet 包。
实现 INotifyPropertyChanged
ViewModel 位于 View 和 Model 之间。当 ViewModel 发生变化时,View 必须被通知。这种机制的实现是 INotifyPropertyChanged 接口,它定义了一个 View 控件订阅的事件。ObservableObject 属性是生成我们 INotifyPropertyChanged 实现的魔法。按照以下步骤进行:
在 News 项目中,打开 ViewModels.cs。
在粗体中添加以下代码:
using CommunityToolkit.Mvvm.ComponentModel;
[ObservableObject]
public abstract partial class ViewModel
{
}
这指示 CommunityToolkit.Mvvm 实现了 INotifyPropertyChanged 接口。下一步是减少我们将要编写的代码行数。通常,您需要手动从您的代码中引发 PropertyChanged 事件,但多亏了在构建时编写代码的源生成器,我们只需创建常规属性,让 CommunityToolkit.Mvvm 做出魔法。
让我们继续前进,创建我们的第一个 ViewModel。
创建 HeadlinesViewModel 类
现在,我们将开始创建一些 View 和 ViewModel 占位符,我们将在本章中对其进行扩展。我们不会直接实现所有图形功能;相反,我们将保持简单,并将所有这些页面视为未来内容的占位符。
第一个是 HeadlinesViewModel 类,它将作为 HeadlinesView 的 ViewModel。按照以下步骤进行:
在 News 项目中,在 ViewModels 文件夹下,创建一个名为 HeadlinesViewModel 的新类。
编辑类,使其从以下粗体代码片段中的 ViewModel 基类继承:
namespace News.ViewModels;
public class HeadlinesViewModel : ViewModel
{
public HeadlinesViewModel()
{
}
}
好的 – 不坏。它现在还没有做什么,但我们先这样吧。让我们创建匹配的视图。
创建 HeadlinesView
这个视图最终将显示新闻列表,但到目前为止,它将保持简单。按照以下步骤创建页面:
在 News 项目中,创建一个名为 Views 的文件夹。
右键点击 Views 文件夹,选择 添加 ,然后点击 新建项... 。
如果您使用的是 Visual Studio 17.7 或更高版本,请点击弹出的对话框中的 显示所有模板 按钮。否则,继续下一步。
在左侧的 C# 项 节点下,选择 .NET MAUI 。
选择 HeadlinesView。
点击 添加 创建页面。
参考以下截图查看上述信息:
图 4.5 – 添加新项
让我们在HeadlinesView中添加一些占位符代码,以便有东西可以导航到和从。我们将在本章稍后用更热的东西替换它,但为了保持简单,让我们添加一个标签。要这样做,请按照以下步骤进行:
在News项目中,在Views文件夹下,打开HeadlinesView.xaml。
通过添加以下加粗代码来编辑 XAML 代码:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="News.Views.HeadlinesView"
Title="Home">
<VerticalStackLayout>
<Label
Text="HeadLinesView!"
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
这将设置页面的标题并在页面中间添加一个带有文本HeadlinesView的标签。让我们继续并创建一些额外的视图占位符。
创建 ArticleItem
应用程序最终将显示一系列文章,其中每篇文章都将使用一个可重用组件进行渲染。我们将把这个可重用组件称为ArticleItem。在.NET MAUI 中,一个可重用组件被称为ContentView 。请不要将其与表示为.NET MAUI 中页面的 MVVM View 混淆。我们知道这很令人困惑,但规则是.NET MAUI 页面是一个 MVVM View,而.NET MAUI ContentView 基本上是一个可重用控件。
话虽如此,让我们创建ArticleItem类,如下所示:
在News项目中,右键单击Views文件夹,选择添加 ,然后点击新建项... 。
如果你使用的是 Visual Studio 17.7 或更高版本,请点击弹出对话框中的显示所有模板 按钮。否则,继续下一步。
在左侧的C# Items 节点下,选择.NET MAUI 。
重要 :确保在下一步中选择ContentView 模板,而不是ContentPage 模板。
选择ArticleItem。
点击添加 来创建视图。
参考以下截图查看上述信息:
图 4.6 – 添加新项 – ArticleItem.xaml
目前我们不需要修改生成的 XAML 代码,所以我们将其保持原样。
创建 ArticleView
在上一节中,我们创建了ArticleItem内容视图。这个视图(ArticleView)将包含WebView以显示每篇文章。但到目前为止,我们只需将ArticleView作为一个占位符添加。按照以下步骤进行操作:
在News项目中,右键单击Views文件夹,选择添加 ,然后点击新建项... 。
如果你使用的是 Visual Studio 17.7 或更高版本,请点击弹出对话框中的显示所有模板 按钮。否则,继续下一步。
在左侧的C# Items 节点下选择.NET MAUI 。
选择ArticleView。
点击添加 来创建页面。
由于这个视图目前也是一个占位符视图,我们只需添加一个标签来指示页面的类型。按照以下步骤编辑内容:
在News项目中,打开ArticleView.xaml。
通过添加以下加粗代码来编辑 XAML 代码:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="News.Views.ArticleView"
Title="ArticleView">
<VerticalStackLayout>
<Label
Text="ArticleView!"
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
好的 – 在我们开始连接东西之前,还有一个视图需要模拟。
创建 AboutView
最后一个视图将以与其他所有视图相同的方式进行创建。按照以下步骤进行:
在News项目中,右键单击Views文件夹,选择添加 ,然后点击新建项... 。
在左侧的C# 项目 节点下,选择.****NET MAUI 。
选择AboutView。
点击添加 以创建页面。
这种视图是唯一会保留为占位符视图的视图。如果你选择在以后从这个项目中构建一些酷炫的东西,那么就需要你来处理它。因此,我们只会添加一个标签来声明这是一个AboutView:
在News项目中,打开AboutView.xaml。
通过添加以下加粗代码来编辑 XAML 代码:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="News.Views.AboutView"
Title="AboutView">
<VerticalStackLayout>
<Label
Text="AboutView!"
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
这样,我们就有了开始连接应用所需的所有视图。第一步是配置依赖注入。
连接依赖注入
通过使用依赖注入作为模式,我们可以使我们的代码更干净、更易于测试。这个应用将使用构造函数注入,这意味着一个类所拥有的所有依赖都必须通过其构造函数传递。容器随后为你构建对象,因此你不需要过多关注依赖链。由于.NET MAUI 已经包含了一个名为 Microsoft.Extensions.DependencyInjection 的依赖注入框架,因此不需要安装任何额外的东西。
对依赖注入感到困惑?
在第二章 ,构建我们的第一个.NET MAUI 应用 中查看连接依赖注入 部分,以获取有关依赖注入的更多详细信息。
使用依赖注入注册视图和 ViewModel
在使用容器注册类时,建议使用扩展方法来分组类型。扩展方法将接受一个参数并返回一个值,即MauiAppBuilder实例。这就是CreateMauiApp方法的工作方式。对于这个应用,我们现在需要注册Views和ViewModels。让我们创建这个方法:
在News项目中,打开MauiProgram.cs文件。
对MauiProgram类进行以下更改;更改已在代码中突出显示:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.RegisterAppTypes();
return builder.Build();
}
public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
{
// ViewModels
mauiAppBuilder.Services.AddTransient<ViewModels.
HeadlinesViewModel>();
// Views
mauiAppBuilder.Services.AddTransient<Views.AboutView>();
mauiAppBuilder.Services.AddTransient<Views.
ArticleView>();
mauiAppBuilder.Services.AddTransient<Views.
HeadlinesView>();
return mauiAppBuilder;
}
}
.NET MAUI 的MauiAppBuilder类公开了Services属性,它是依赖注入容器。我们只需要添加我们想要依赖注入了解的类型;容器会为我们完成剩下的工作。顺便说一句,将构建器想象成收集了大量需要完成的信息的东西,然后最终构建我们需要的对象。它本身就是一个非常有用的模式。
目前我们只使用构建器来做一件事。稍后,我们将使用它来注册任何从我们的抽象ViewModel类继承的类。容器现在已为我们准备好请求这些类型。
现在,我们需要对我们的应用做一些图形上的调整。我们将依靠Font Awesome 来完成魔法。
下载和配置 Font Awesome
Font Awesome 是一个免费集合,将图像打包成字体。.NET MAUI 在工具栏、导航栏以及各个地方使用 Font Awesome 方面有出色的支持。虽然制作这个应用程序并不严格需要它,但我们认为额外的往返是值得的,因为你很可能在你的新杀手级应用程序中需要类似的东西。
第一步是下载字体。
下载 Font Awesome
下载字体很简单。请注意文件的重命名——这不是必需的,但如果文件名更简单,编辑配置文件等会更容易。按照以下步骤获取并复制字体到每个项目中:
浏览到fontawesome.com/download 。
点击Free for Desktop 按钮下载 Font Awesome。
解压下载的文件,然后找到otfs文件夹。
将Font Awesome 5 Free-Solid-900.otf文件重命名为FontAwesome.otf(你可以保留原始名称,但如果重命名,输入会更少)。由于 Font Awesome 不断更新,你的文件名可能不同,但应该类似。
将FontAwesome.otf复制到News项目的Resources/Fonts文件夹中。
好的——现在,我们需要将 Font Awesome 注册到.NET MAUI 中。
配置.NET MAUI 以使用 Font Awesome
如果我们只需要将字体文件复制到项目文件夹中那就太好了。仅仅这一步就会发生很多事情。默认的.NET MAUI 模板在News.csproj文件中包含了Resources/Fonts文件夹中的所有字体,其项目定义如下:
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
这确保了字体文件会被处理并自动包含在应用程序包中。剩下的只是将字体注册到.NET MAUI 运行时,以便它可以在我们的 XAML 资源中使用。为此,将以下高亮行添加到MauiProgram.cs文件中:
.ConfigureFonts(fonts =>
{
fonts.AddFont("FontAwesome.otf", "FontAwesome");
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
这行代码添加了一个别名,我们可以在下一节中使用它来创建静态资源。第一个参数是字体文件的文件名,而第二个是我们可以在FontFamily属性中使用字体别名。
剩下的只是需要在资源字典中定义一些图标。
在资源字典中定义一些图标
现在我们已经定义了字体,我们将使用它并定义五个要在我们的应用程序中使用的图标。我们首先添加 XAML;然后,我们将检查一个FontImage标签。
按照以下步骤操作:
在News项目中打开App.xaml。
在现有的ResourceDictionary.MergedDictionaries标签下添加以下加粗代码:
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<FontImage x:Key="HomeIcon" FontFamily="FontAwesome" Glyph="" Size="22" Color="Black" />
<FontImage x:Key="HeadlinesIcon" FontFamily="FontAwesome" Glyph="" Size="22" />
<FontImage x:Key="NewsIcon" FontFamily=" FontAwesome" Glyph="" Size="22" />
<FontImage x:Key="SettingsIcon" FontFamily="FontAwesome" Glyph="" Size="22" Color="Black" />
<FontImage x:Key="AboutIcon" FontFamily="FontAwesome" Glyph="" Size="22" Color="Black" />
</ResourceDictionary>
FontImage是一个可以在.NET MAUI 的任何地方使用的类,它期望一个ImageSource对象。它被设计用来将一个字符(或符号)渲染成图像。FontImage类需要一些属性才能工作,详细说明如下:
这是一个键,它将被应用程序中的其他视图使用。
一个使用别名引用回我们在上一节中定义的Font资源的FontFamily资源。
Glyph 对象,代表要显示的图像。要找出这些神秘值引用的是哪个图像,请访问 fontawesome.com ,点击 图标 ,选择 免费和开源图标 ,并开始浏览。
Size 和 Color。这些不是严格必需的,但定义它们是好的。它们用于此应用中的一些图标,以便它们在浅色主题中正确渲染。
Font Awesome 现已安装并配置。我们已经做了很多工作,才到达本章的实际主题。现在是定义壳的时候了!
定义壳
如前所述,.NET MAUI Shell 是定义您应用结构的新方法。本书中的不同项目使用不同的方法来定义应用的整体结构,但根据我们的观点,.NET MAUI Shell 是定义 UI 结构的最佳方式。我们希望您觉得它和我们都一样令人兴奋!
定义基本结构
我们将首先为应用定义一个基本结构,而不真正添加我们定义的任何视图。之后,我们将逐个添加实际视图。但让我们先添加一些内容,并使用 XAML 直接创建 ContentPage 对象。按照以下两个步骤操作:
在 News 项目中,打开 AppShell.xaml 文件。
修改文件,使其看起来像以下代码:
<?xml version="1.0" encoding="UTF-8"?>
<Shell
x:Class="News.AppShell">
<FlyoutItem Title="Home" Icon="{StaticResource HomeIcon}">
<ShellContent Title="Headlines" Icon="{StaticResource HeadlinesIcon}" >
<ContentPage Title="Headlines" />
</ShellContent>
<Tab Title="News" Icon="{StaticResource NewsIcon}">
<ContentPage Title="Local" />
<ContentPage Title="Global" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Settings" Icon="{StaticResource SettingsIcon}">
<ContentPage Title="Settings" />
</FlyoutItem>
<ShellContent Title="About" Icon="{StaticResource AboutIcon}">
<ContentPage Title="About"/>
</ShellContent>
</Shell>
让我们分解一下。首先,默认情况下,.NET MAUI Shell 模板禁用了飞出菜单。由于我们想在应用中使用它,您必须删除禁用它的那一行。Shell 本身的直接子对象是两个 FlyoutItem 对象和一个 ShellContent 对象。这三个对象都有定义的 Title 和 Icon 属性,如下面的截图所示。图标引用了我们之前创建的 Font Awesome 资源。这将渲染一个飞出菜单,如下面的截图所示:
图 4.7 – 应用飞出菜单
通过从左侧滑动可以访问飞出菜单。Flyout 对象可以有多个子对象,而 ShellContent 元素只能有一个子对象。
ShellContent 包含一个标题为 Headlines 的页面和一个定义其自身两个子页面的标签页。第一级子页面将在应用的底部渲染标签栏,如下面的截图所示。在具有 News 标题的 Tab 元素下的第二级子页面将直接在顶部导航栏的标题下方渲染为一个标签栏:
图 4.8 – Shell 标签和页面
设置 和 关于 飞出菜单将简单地渲染它们定义的页面。
使应用运行
是时候尝试这个应用并看看它是否看起来像本章中展示的截图了。现在应用应该可以运行了。如果不行,保持冷静,只需再次检查代码即可。一旦你完成了对应用的导航,我们就可以创建一个新闻服务来获取新闻,并扩展我们创建的所有视图。
创建新闻服务
为了找到有趣的内容,我们将使用由newsapi.org 提供的现有News API。为此,我们必须注册一个 API 密钥,我们可以用它来请求新闻。如果你不习惯这样做,你可以模拟新闻服务,而不是使用 API。
我们必须做的第一件事是获取一个 API 密钥。
获取 API 密钥
注册过程相当简单。然而,请注意,newsapi.org 的 UI 在你阅读此内容时可能已经改变。
好的——让我们获取这个密钥:
浏览到newsapi.org/ 。
点击获取 API 密钥 。
按照以下截图所示填写表格:
图 4.9 – 注册 API 密钥
复制下一页上提供的 API 密钥,如图所示:
现在,我们需要一个地方来存储密钥以便于访问。我们将创建一个静态类来为我们保存密钥。按照以下步骤操作:
在News项目的根文件夹中创建一个名为Settings的新类。
添加以下代码片段中的代码,将前面的步骤中获得的 API 密钥替换占位文本:
namespace News;
internal static class Settings
{
public static string NewsApiKey => "<Your APIKEY Here>";
}
这里重要的是将密钥复制并粘贴到文件中。现在,我们需要模型。
关于令牌和其他秘密的说明
这不是存储 API 密钥或其他应安全存储在应用中的令牌的推荐方式。为了安全地存储令牌和其他数据,你应该使用安全存储 (见learn.microsoft.com/en-us/dotnet/maui/platform-integration/storage/secure-storage )并从安全服务器获取数据,最好是通过某种形式的用户身份验证。你也可以要求用户通过设置页面提供 API 密钥——提示,提示。
创建模型
从 API 返回的数据需要存储在某个地方,最方便的访问方式是将数据反序列化到Models中。让我们创建我们的模型:
在News项目中,创建一个名为Models的新文件夹。
在Models文件夹中,添加一个名为NewsApiModels的新类。
将以下代码添加到类中:
namespace News.Models;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class Source
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Article
{
[JsonPropertyName("source")]
public Source Source { get; set; }
[JsonPropertyName("author")]
public string Author { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
[JsonPropertyName("urlToImage")]
public string UrlToImage { get; set; }
[JsonPropertyName("publishedAt")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
}
public class NewsResult
{
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("totalResults")]
public int TotalResults { get; set; }
[JsonPropertyName("articles")]
public List<Article> Articles { get; set; }
}
每个属性的 JsonPropertyName 特性允许 System.Text.Json 反序列化器将来自 Web API 的 JSON 接收到的名称映射到 C#对象中。当我们调用 API 时,API 将返回一个 NewsResult 对象,该对象将包含一系列文章。下一步是创建一个封装 API 并允许我们访问最新新闻的服务。
创建 POCO 类的技巧
如果你需要从一大堆 JavaScript Object Notation (JSON ) 创建一个类模型,你可以使用 Windows Visual Studio 中的 Paste JSON as Classes 工具(编辑 | 粘贴特殊 | 粘贴 JSON 为类 )。
创建服务类
服务类将封装 API,以便我们可以以类似.NET 的方式访问它。
但我们首先定义一个枚举,它将定义我们请求的新闻范围。
创建 NewsScope 枚举
NewsScope 枚举定义了我们服务支持的不同类型的新闻。让我们按照以下几个步骤添加它:
在 News 项目中,创建一个新的文件夹,名为 Services。
在 Services 文件夹中,添加一个名为 NewsScope.cs 的新文件。
将以下代码添加到该文件中:
namespace News.Services;
public enum NewsScope
{
Headlines,
Local,
Global
}
下一步是创建将封装对 News API 调用的 NewsService 类。
创建 NewsService 类
NewsService 类的目的是封装对新闻 REST API 的 HTTP 调用,并使其以常规.NET 方法调用的形式轻松访问我们的代码。为了更容易替换新闻的来源——例如,在测试中使用模拟——我们将使用一个接口。
要创建 INewsService 接口,按照以下步骤操作:
在 Services 文件夹中,创建一个新的接口,名为 INewsService。
编辑接口,使其看起来像这样:
namespace News.Services;
using News.Models;
public interface INewsService
{
public Task<NewsResult> GetNews(NewsScope scope);
}
创建 NewsService 类现在相当直接。按照以下步骤操作:
在 Services 文件夹中,创建一个新的类,名为 NewsService。
编辑类,使其看起来像这样:
namespace News.Services;
using News.Models;
using System.Net.Http.Json;
public class NewsService : INewsService, IDisposable
{
private bool disposedValue;
const string UriBase = "https://newsapi.org/v2";
readonly HttpClient httpClient = new() {
BaseAddress = new(UriBase),
DefaultRequestHeaders = { { "user-agent", "maui-projects-news/1.0" } }
};
public async Task<NewsResult> GetNews(NewsScope scope)
{
NewsResult result;
string url = GetUrl(scope);
try
{
result = await httpClient.GetFromJsonAsync<NewsResult>(url);
}
catch (Exception ex) {
result = new() { Articles = new() { new() { Title = $"HTTP Get failed: {ex.Message}", PublishedAt = DateTime.Now} } };
}
return result;
}
private string GetUrl(NewsScope scope) => scope switch
{
NewsScope.Headlines => Headlines,
NewsScope.Global => Global,
NewsScope.Local => Local,
_ => throw new Exception("Undefined scope")
};
private static string Headlines => $"{UriBase}/top-headlines?country=us&apiKey={Settings.NewsApiKey}";
private static string Local => $"{UriBase}/everything?q=local&apiKey={Settings.NewsApiKey}";
private static string Global => $"{UriBase}/everything?q=global&apiKey={Settings.NewsApiKey}";
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
httpClient.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
NewsService 类由五个方法组成;我知道技术上应该有八个,但我们会稍后讨论这一点。
第一个方法 GetNews 是我们将最终从我们的应用程序中调用的方法。它接受一个参数 scope,这是我们之前创建的枚举。根据此参数的值,我们将获得不同类型的新闻。此方法的第一件事是解析要调用的 URL,它是通过调用带有范围的 GetUrl 方法来完成的。
GetUrl 方法使用 switch 表达式解析 URL,并根据传递的 scope 参数的值返回三个 URL 之一。该 URL 指向 News API 的 REST API,其中包含一些预定义的查询参数和为我们注册的 API 密钥。
当我们解决了正确的 URL 后,我们就准备好发起 HTTP 请求并以 JSON 形式下载新闻。.NET 内置的HttpClient类为我们很好地获取了 JSON。在获取数据后,剩下的就是将其反序列化为我们之前定义的新闻模型。
现在让我们简单谈谈剩余的方法和HttpClient类。HttpClient现在是请求网络数据时推荐的类。它是一个比之前可用的实现更安全的实现。它随.NET 5+一起发货,并且可以作为单独的 NuGet 包用于旧版本。有了这个,使用HttpClient时有一些特殊之处。
首先,HttpClient会保留原生资源,因此必须正确地释放。为了正确地释放HttpClient,我们需要从IDisposable派生并实现它。这就是为什么在类中有额外的Dispose(bool)和Dispose()方法的原因。它们所做的只是确保HttpClient的实例被正确地释放。
其次,HttpClient会池化这些原生资源,因此建议尽可能多地重用HttpClient的实例。这就是为什么在NewsService构造函数中创建HttpClient实例的原因。
最后的话 - 由于GetFromJsonAsync调用可能会抛出异常,并且它是在一个async方法中调用的,因此你必须处理这个异常;否则,它将在执行线程上丢失,而你唯一能意识到有问题的情况就是你没有项目。对于这个应用,我们只是创建一个包含一个有异常的Article的NewsResult对象,以便显示一些内容。处理错误有更好的方法,但这对这个应用来说已经足够了。
下一步是连接NewsService类。
连接NewsService类
我们现在准备好在我们的应用中连接NewsService类,并将其与真实的新闻源集成。我们将扩展所有现有的ViewModels,并定义 UI 元素以在Views中渲染新闻。
扩展HeadlinesViewModel类
在 MVVM 中,ViewModel是处理应用逻辑的地方。模型是我们将从NewsService类中获取的新闻数据。我们现在将扩展HeadlinesViewModel类,使其使用NewsService来获取新闻:
在News项目中,展开ViewModels文件夹并打开HeadlinesViewModel.cs文件。
添加以下加粗的代码并解决引用:
namespace News.ViewModels;
using System.Threading.Tasks;
using System.Web;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using News.Models;
using News.Services;
public partial class HeadlinesViewModel : ViewModel
{
private readonly INewsService newsService;
[ObservableProperty]
private NewsResult currentNews;
public HeadlinesViewModel(INewsService newsService)
{
this.newsService = newsService;
}
public async Task Initialize(string scope) =>
await Initialize(scope.ToLower() switch
{
"local" => NewsScope.Local,
"global" => NewsScope.Global,
"headlines" => NewsScope.Headlines,
_ => NewsScope.Headlines
});
public async Task Initialize(NewsScope scope)
{
CurrentNews = await newsService.GetNews(scope);
}
[RelayCommand]
public void ItemSelected(object selectedItem)
{
var selectedArticle = selectedItem as Article;
var url = HttpUtility.UrlEncode(selectedArticle.Url);
// Placeholder for more code later on
}
}
由于我们使用(构造函数)依赖注入,我们需要将依赖注入到构造函数中。这个ViewModel唯一的依赖是NewsService,我们将其内部存储在类的字段中。
CurrentNews属性被定义为获取绑定 UI 的内容。
然后,我们有两个 Initialize 方法——一个接受 scope 作为枚举,另一个接受 scope 作为字符串。字符串重载的 Initialize 方法将在 XAML 中使用。它只是将字符串转换为 scope 的枚举表示形式,然后调用另一个 Initialize 方法,该方法反过来调用新闻服务上的 GetNews(...) 方法。
最后一个属性 ItemSelected 返回一个 .NET MAUI 命令,我们将将其连接到当应用程序用户选择一个项目时响应的事件。方法的一半从一开始就实现了。所选的项目将被传递到方法中。然后,我们编码文章的 URL,因为我们将在应用程序内导航时将其作为查询参数传递。我们稍后会回到导航部分。
如果你好奇关于 ObservableProperty 和 RelayCommand 属性,可以通过回顾 第二章 ,构建我们的第一个 .NET MAUI 应用程序 来刷新你的记忆。
现在我们已经有了获取数据的代码,接下来我们将转向定义用于显示数据的用户界面。
扩展 HeadlinesView
HeadlinesView 是一个共享视图,将在应用程序的几个地方使用。这个视图的目的是显示文章列表,并允许用户从一个文章导航到显示整个文章的网页浏览器。
要扩展 HeadlinesView,我们必须做两件事——首先,我们必须编辑 XAML 并定义 UI;然后,我们需要添加一些代码来初始化它。按照以下步骤进行:
在 News 项目中,展开 Views 文件夹并打开 HeadlinesView.xaml 文件。
编辑 XAML,如下面的代码块所示:
<?xml version="1.0" encoding="UTF-8"?>
<ContentPage
x:Name="headlinesview"
x:Class="News.Views.HeadlinesView"
x:DataType="viewModels:HeadlinesViewModel"
Title="Home" Padding="14">
<CollectionView ItemsSource="{Binding CurrentNews.Articles}">
<CollectionView.EmptyView>
<Label Text="Loading" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Article">
<ContentView>
<ContentView.GestureRecognizers>
<TapGestureRecognizer Command="{Binding BindingContext.ItemSelectedCommand, Source={x:Reference headlinesview}}" CommandParameter="{Binding .}" />
</ContentView.GestureRecognizers>
<views:ArticleItem />
</ContentView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage>
HeadlinesView 使用 CollectionView 来显示文章列表。ItemsSource 属性设置为 ViewModel 的 CurrentNews.Articles 属性,在加载新闻后,应该包含一个新闻列表。当列表为空或正在加载时,我们将在 CollectionView.EmptyView 元素内显示一个加载标签。当然,你可以在该标签内创建任何有效的 UI 来创建一个更酷的加载界面。
CurrentNews.Articles 列表中的每一篇文章都将使用 CollectionView.ItemTemplate 元素内部的内容进行渲染,而 ContentView 元素内部的内容将代表实际的项目。文章将通过一个 ArticleItem 视图进行渲染,这是一个我们之前定义的自定义控件。我们将在完成这个视图后定义这个视图。
要启用从视图的导航,我们需要检测用户何时点击特定的文章。我们可以通过添加 TapGestureRecognizer 并将其绑定到根 ViewModel 的 ItemSelectedCommand 属性来实现。Source={x:Reference headlinesview}} 这段代码是引用当前上下文回到页面的根,而不是我们正在迭代的列表中的当前文章。如果我们没有指定源,绑定引擎将尝试将 ItemSelectedCommand 属性绑定到在 CurrentNews.Articles 属性中定义的当前文章的属性。
GUI 部分就到这里。现在,我们需要修改代码背后的部分,以便根据我们从 XAML 本身传递的数据进行初始化。按照以下步骤进行操作以实现这一点:
在 News 项目中,打开 HeadlinesView.xaml.cs 代码背后的文件。
将以下加粗的代码添加到文件中:
namespace News.Views
using System.Threading.Tasks;
using News.Services;
using News.ViewModels;
public partial class HeadlinesView : ContentPage
{
readonly HeadlinesViewModel viewModel;
public HeadlinesView(HeadlinesViewModel viewModel)
{
this.viewModel = viewModel;
InitializeComponent();
Task.Run(async () => await Initialize(GetScopeFromRoute()));
}
private async Task Initialize(string scope)
{
BindingContext = viewModel;
await viewModel.Initialize(scope);
}
private string GetScopeFromRoute()
{
var route = Shell.Current.CurrentState.Location
.OriginalString.Split("/").LastOrDefault();
return route;
}
}
通常,我们不想直接在视图的代码背后添加代码,但我们需要做出例外,以便可以将参数从 XAML 传递到我们的 ViewModel。
根据创建视图所使用的路由信息,我们将以不同的方式初始化 ViewModel。GetScopeFromRoute 方法将解析 Shell 中的位置信息以确定用于查询新闻服务的范围。然后,我们可以调用一个私有方法为我们创建 HeadlinesViewModel 的实例,将其设置为视图的绑定上下文,并在 ViewModel 上调用 Initialize() 方法,这会向 News API 发起 REST 调用。我们将在编辑 shell 文件时定义路由。
但首先,我们需要扩展 ArticleItem 的 ContentView 以显示新闻列表中的单行项。
扩展 ArticleItem 的 ContentView
ArticleItem 的 ContentView 代表新闻列表中的一个条目,如图中所示:
图 4.11 – 一个示例新闻条目
要创建如图 图 4 .11 所示的布局,我们将使用 Grid 控件。按照以下步骤创建布局:
在 News 项目中,展开 Views 文件夹并打开 ArticleItem.xaml 文件。
编辑以下代码块中的 XAML 代码:
<?xml version="1.0" encoding="UTF-8"?>
<ContentView
x:Class="News.Views.ArticleItem"
x:DataType="models:Article">
<Grid Margin="0">
<Grid.RowDefinitions>
<RowDefinition Height="10" />
<RowDefinition Height="40" />
<RowDefinition Height="15" />
<RowDefinition Height="10" />
<RowDefinition Height="1" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="65" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Grid.Row="1" Grid.Column="1" Text="{Binding Title}" Padding="10,0" FontSize="Small" FontAttributes="Bold" />
<Label Grid.Row="2" Grid.Column="1" Text="{Binding PublishedAt, StringFormat='{0:MMMM d, yyyy}'}" Padding="10,0,0,0" FontSize="Micro" />
<Border Grid.Row="1" Grid.RowSpan="2" StrokeShape="RoundRectangle 15,15,15,15" Padding="0" Margin="0,0,0,0" BackgroundColor="#667788" >
<Image Source="{Binding UrlToImage}" Aspect="AspectFill" HeightRequest="55" HorizontalOptions="Center" VerticalOptions="Center" />
</Border>
<BoxView Grid.Row="4" Grid.ColumnSpan="2" BackgroundColor="LightGray" />
</Grid>
</ContentView>
上述 XAML 代码定义了一个具有两列和五行的网格布局。Grid.Row 和 Grid.Column 属性将子元素定位到网格中,而 Grid.ColumnSpan 属性允许控件跨越多个列。
使用具有 StrokeShape="RoundRectangle 15,15,15,15" 的 Border 元素,并设置 Image 的 Aspect 属性为 AspectFill,可以创建一个圆形图像。
标签中的字符串可以直接在绑定语句中格式化。查看 Text="{Binding PublishedAt, StringFormat='{0:MMMM d, yyyy}'}" 这行代码,它将日期格式化为特定的字符串格式。
最后,灰色分隔线是 XAML 代码末尾的 BoxView。
现在我们已经创建了 NewsService 并修复了所有相关视图,是时候使用它们了。
添加到依赖注入
由于 HeadlinesViewModel 依赖于 INewsService,我们需要在我们的依赖注入容器中注册它(请参阅 第二章 中的 连接依赖注入 部分,了解更多关于 .NET MAUI 中依赖注入的细节)。按照以下步骤进行操作:
在 News 项目中,打开 MauiProgram.cs 文件。
定位到 RegisterAppTypes() 方法并添加以下加粗的行:
public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
{
// Services
mauiAppBuilder.Services.AddSingleton<Services.INewsService>((serviceProvider) => new Services.NewsService());
// ViewModels
mauiAppBuilder.Services.AddTransient<ViewModels.HeadlinesViewModel>();
//Views
mauiAppBuilder.Services.AddTransient<Views.AboutView>();
mauiAppBuilder.Services.AddTransient<Views.ArticleView>();
mauiAppBuilder.Services.AddTransient<Views.HeadlinesView>();
return mauiAppBuilder;
}
这将允许进行 NewsService 的依赖注入。
添加 ContentTemplate 属性
到目前为止,我们的 AppShell 文件中只有占位符代码。让我们用实际内容替换它,如下所示:
在 News 项目中,打开 AppShell.xaml。
定位到标题设置为 Home 的 FlyoutItem 元素。
编辑 XAML,使 ShellContent 元素成为自闭合的,添加以下加粗的 ContentTemplate 属性,并替换 Tab 元素的全部内容:
<Shell
x:Class="News.AppShell"
>
<FlyoutItem Title="Home" Icon="{StaticResource HomeIcon}">
<ShellContent Title="Headlines" Route="headlines" Icon="{StaticResource HeadlinesIcon}" ContentTemplate="{DataTemplate views:HeadlinesView}" />
<Tab Title="News" Route="news" Icon="{StaticResource NewsIcon}">
<ShellContent Title="Local" Route="local" ContentTemplate="{DataTemplate views:HeadlinesView}" />
<ShellContent Title="Global" Route="global" ContentTemplate="{DataTemplate views:HeadlinesView}" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Settings" Icon="{StaticResource SettingsIcon}">
<ContentPage Title="Settings" />
</FlyoutItem>
<ShellContent Title="About" Icon="{StaticResource AboutIcon}">
<ContentPage Title="About"/>
</ShellContent>
</Shell>
这里有两件事情在进行。第一件事是我们使用 ContentTemplate 属性指定 ShellContent 的内容。这意味着我们指向当壳可见时要创建的视图类型。通常,你希望在即将显示之前才创建视图,而 ContentTemplate 属性就提供了这个功能。注意,那个 FlyoutItem 的 Route 属性被设置为 headlines。
第二件事是对于 Local 和 Global 新闻,我们在下面做同样的事情,但使用的是 local 和 global 路由。
如果你现在运行这个应用,你应该得到以下截图所示的内容:
图 4.12 – 主要列表视图
我们需要实现的最后一件事是在我们点击列表中的项目时如何查看文章。
处理导航
我们现在离这个应用完成只剩下最后一步了。我们唯一需要做的是实现导航到文章视图,该视图将在网页中显示整个文章。由于我们使用 Shell,我们将使用路由进行导航。路由可以直接在 Shell 标记中注册 – 例如,在 AppShell.xaml 文件中。我们可以通过在 ShellContent 元素上使用 Route 属性来实现,就像我们在上一节中所做的那样。
在下面的代码中,我们将以编程方式添加一个路由并注册一个视图来为我们处理它。我们还将创建一个导航服务来抽象化导航的概念。
所以,系好安全带,让我们完成这个应用!
创建导航服务
第一步是定义一个将包装 .NET MAUI 导航的接口。我们为什么要这样做呢?因为将接口与实现分离是一种良好的实践;这使得单元测试更容易,等等。
创建 INavigation 接口
INavigation 接口很简单,我们可能会稍微超出目标。我们只对 NavigateTo 方法感兴趣,但我们会添加 PushModal() 和 PopModal() 方法,因为如果你继续扩展应用程序,你可能会用到它们。
添加导航接口很简单,以下步骤将说明:
在 News 项目中,展开 ViewModels 文件夹,并添加一个名为 INavigate.cs 的新文件。
将以下代码添加到文件中:
namespace News.ViewModels;
public interface INavigate
{
Task NavigateTo(string route);
Task PushModal(Page page);
Task PopModal();
}
NavigateTo() 方法的声明接受我们想要导航到的路由。这是我们将会调用的方法。PushModal() 方法在导航堆栈顶部添加一个新的页面作为模态页面,强制用户只能与这个特定的页面进行交互。PopModal() 方法将其从导航堆栈中移除。所以,如果你使用 PushModal() 方法,确保你给用户一个方法来将其从堆栈中移除。
否则,你将永远卡在查看模态页面。
接口部分就到这里。让我们使用 .NET MAUI Shell 创建一个实现。
使用 .NET MAUI Shell 实现 INavigate 接口
实现非常直接,因为每个方法都只是调用由 Shell API 提供的 .NET MAUI 静态方法。
按照以下步骤创建 Navigator 类:
在 News 项目中,添加一个名为 Navigator 的新类。
将以下代码添加到类中:
namespace News;
using News.ViewModels;
public class Navigator : INavigate
{
public async Task NavigateTo(string route) => await Shell.Current.GoToAsync(route);
public async Task PushModal(Page page) => await Shell.Current.Navigation.PushModalAsync(page);
public async Task PopModal() => await Shell.Current.Navigation.PopModalAsync();
}
这只是简单的透传代码,调用已经存在的方法。现在,我们需要将类型注册到我们的依赖注入容器中,以便它可以被 ViewModel 类消费。
使用依赖注入注册 Navigator 类
为了让 ViewModel 类及其派生类能够访问 Navigator 实例,我们必须将其注册到容器中,就像我们之前对 NewService 所做的那样;只需按照以下步骤操作:
在 News 项目中,打开 MauiProgram.cs 文件。
找到 RegisterAppTypes 方法,并添加以下突出显示的代码:
public static MauiAppBuilder RegisterAppTypes(this MauiAppBuilder mauiAppBuilder)
{
// Services
mauiAppBuilder.Services.AddSingleton<Services.INewsService>((serviceProvider) => new Services.NewsService());
mauiAppBuilder.Services.AddSingleton<ViewModels.INavigate>((serviceProvider) => new Navigator());
// ViewModels
…
}
现在,我们可以将 INavigate 接口添加到 ViewModel 类及其派生类中。
将 INavigate 接口添加到 ViewModel 类
为了能够访问 Navigator,我们必须扩展 ViewModel 基类,使其对所有 ViewModels 可用。按照以下步骤操作:
在 News 项目中,打开 ViewModels 文件夹,然后打开 ViewModel.cs 文件。
将以下突出显示的代码添加到类中:
public abstract class ViewModel
{
public INavigate Navigation { get; init; }
internal ViewModel(INavigate navigation) => Navigation = navigation;
}
打开 HeadlinesViewModel.cs 文件,并对构造函数进行突出显示的更改:
public HeadlinesViewModel(INewsService newsService, INavigate navigation) : base (navigation)
基础 ViewModel 现在通过 INavigate 接口公开了 Navigator 属性。到这一点,我们就准备好将导航连接到我们的 Article 视图了。
使用路由进行导航
路由是导航的一个非常方便的方法,因为它们抽象了页面创建的过程。我们只需要知道我们想要导航到的视图的路由 - .NET MAUI Shell 会为我们处理其余的事情。如果你熟悉网络导航的工作方式,你可能会认出我们在路由中传递参数的方式。它们作为查询参数传递。
完成 ItemSelected 命令
之前,我们在 HeadlinesViewModel 类中定义了 ItemSelected 方法。现在,是时候添加将执行导航到 ArticleView 的代码了:
在 新闻 项目中,展开 ViewModels 文件夹并打开 HeadlinesViewModel.cs。
定位到 ItemSelected 方法并添加以下加粗的行:
[RelayCommand]
public async Task ItemSelected(object selectedItem)
{
var selectedArticle = selectedItem as Article;
var url = HttpUtility.UrlEncode(selectedArticle.Url);
await Navigation.NavigateTo($"articleview?url={url}");
}
在这里,我们定义了一个名为 articleview 的路由,它接受一个名为 url 的查询行参数,该参数指向文章本身的 URL。它看起来可能像这样:articleview?url=www.mypage.com。只有传递给 url= 参数之后的数据必须使用 HttpUtility.UrlEncode() 方法进行编码,该方法由 System.Web 为我们定义。
前面的 NavigateTo() 方法调用使用查询参数中的此编码数据。在导航调用的接收端,我们需要处理传入的 url 参数。
扩展 ArticleView 以接收查询数据
ArticleView 负责为我们渲染文章。为了保持简单(并且说明你并不总是需要 ViewModel),我们不会为这个类定义 ViewModel;相反,我们将定义 BindingContext 为 UrlWebViewSource 类的一个实例。
将以下代码添加到 ArticleView.xaml.cs 文件中:
在 新闻 项目中,展开 视图 文件夹并打开 ArticleView.xaml.cs 文件。
将以下加粗的代码添加到文件中:
namespace News.Views;
using System.Web;
[QueryProperty("Url", "url")]
public partial class ArticleView : ContentPage
{
public string Url
{
set
{
BindingContext = new UrlWebViewSource
{
Url = HttpUtility.UrlDecode(value)
};
}
}
public ArticleView()
{
InitializeComponent();
}
}
ArticleView 依赖于一个已设置的 URL,我们通过定义一个只读属性 Url 来实现这一点。当此属性被设置时,它将创建一个新的 UrlWebViewSource 实例,并将属性值赋给它,然后将其分配给页面的 BindingContext。这个设置器是由 Shell 框架调用的,因为我们向类本身添加了一个名为 QueryProperty 的属性。它接受两个参数 - 第一个是设置哪个属性,第二个是 url 查询参数的名称。
由于数据是 URL 编码的,我们需要使用 HttpUtility.UrlDecode() 方法对其进行解码。
这样,我们就有一个指向我们想要显示的网页的绑定上下文。现在,我们只需要在 XAML 中定义 WebView。
通过 WebView 扩展 ArticleView
这个页面只有一个目的,那就是显示我们传递给它的 URL 中的网页。让我们在页面上添加一个 WebView 控件,如下所示:
在 新闻 项目中,展开 视图 文件夹并打开 ArticleView.xaml。
添加以下突出显示的 XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="News.Views.ArticleView"
Title="ArticleView">
<WebView Source="{Binding .}" />
</ContentPage>
WebView 控件将占据视图中的所有可用空间。源设置为 .,这意味着它与 ViewModel 的 BindingContext 属性相同。在这种情况下,BindingContext 属性是一个 UrlWebViewSource 实例,这正是 WebView 需要导航和显示内容所需的。
我们只剩下一步了——我们的应用需要了解 ArticleView 路由以及如何处理它。
注册路由
如前所述,路由可以在 XAML 中声明性添加(Route="MyDucks")或通过代码添加,如下所示:
在 新闻 项目中,打开 AppShell.xaml.cs 文件。
添加以下加粗的代码行:
namespace News;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("articleview", typeof(Views.ArticleView));
}
}
RegisterRoute() 方法接受两个参数——第一个是我们想要使用的路由,这是我们指定在 NavigateTo() 调用中的路由。第二个是我们想要创建的页面(视图)的类型——在我们的例子中,我们想要创建 ArticleView。
太棒了!这就完成了。应用现在应该可以运行,你应该能够从你的 CollectionViews 中导航到文章。干得好!
摘要
在本章中,我们学习了如何使用 .NET MAUI Shell 定义导航结构,如何使用路由导航到视图,以及如何以查询字符串的形式在视图之间传递参数。Shell 还有很多内容,但这应该能让你开始并足够自信地探索 Shell API。此外,请记住,Shell API 正在不断发展,确保查看最新的功能。
我们还学习了如何为任意 REST API 创建 API 客户端,这在编写大多数应用时非常有用,因为大多数应用都需要在某个时候与服务器通信。有很大可能性,服务器将通过 REST API 公开其数据和功能。
如果你感兴趣,想进一步扩展应用,尝试设计自己的 News API 密钥,并通过设置进行设置。
下一个项目将关于创建一个匹配应用,以及如何仅使用 .NET MAUI 渲染和动画跨平台 UI 控件来创建一个带有滑动功能的是/否图片选择应用。
第五章:使用动画的丰富 UX 的匹配应用
在本章中,我们将创建匹配应用的基线功能。然而,由于隐私问题,我们不会对人员进行评分。相反,我们将从互联网上的随机来源下载图片。这个项目是为那些想要了解如何编写可重用控件的人准备的。我们还将探讨如何使用动画使我们的应用程序更易于使用。这个应用将不会是一个模型-视图-视图模型 (MVVM )应用程序,因为我们想将控件创建和使用与 MVVM 的轻微开销隔离开来。
本章将涵盖以下主题:
技术要求
为了能够完成本章的项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载。有关如何设置环境的更多详细信息,请参阅 第一章 ,* .NET MAUI 简介*。
您可以在github.com/PackPublishing/MAUI-Projects-3rd-Edition 找到本章代码的完整源代码。
项目概述
许多人都曾面临过这样的困境:是滑动左键还是右键。突然间,你可能开始 wonder:这是怎么工作的?滑动魔法是如何发生的? 好吧,在这个项目中,我们将学习所有关于它的知识。我们将从定义一个MainPage文件开始,我们的应用程序图像将驻留在其中。之后,我们将实现图像控制,并逐渐添加图形用户界面 (GUI )和功能,直到我们打造出完美的滑动体验。
该项目的构建时间大约为 90 分钟。
创建匹配应用
在这个项目中,我们将学习更多关于创建可重用控件的知识,这些控件可以添加到可扩展应用程序标记语言 (XAML )页面中。为了保持简单,我们不会使用 MVVM,而是使用不带任何数据绑定的裸机 .NET MAUI。我们的目标是创建一个允许用户左右滑动图片的应用程序,就像大多数流行的匹配应用一样。
好吧,让我们从创建项目开始吧!
设置项目
这个项目,就像所有其他项目一样,是一个文件 | 新建 | 项目... 风格的程序。这意味着我们不会导入任何代码。因此,这个第一部分完全是关于创建项目和设置基本项目结构。
让我们开始吧!
创建新项目
那么,让我们开始吧。
第一步是创建一个新的 .NET MAUI 项目:
打开 Visual Studio 2022 并选择创建一个 新项目 :
图 5.1 – Visual Studio 2022
这将打开 创建新项目 向导。
在搜索框中,键入 maui 并从列表中选择 .NET MAUI 应用 项:
图 5.2 – 创建一个新项目
点击 下一步 。
通过命名您的项目来完成向导的下一步。在这种情况下,我们将我们的应用程序命名为 Swiper。通过点击 创建 ,如图所示,继续到下一个对话框:
图 5.3 – 配置您的全新项目
点击 下一步 。
最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持 (LTS ),.NET 7 可用为 标准期限支持 。对于这本书,我们假设您将使用 .NET 7:
图 5.4 – 补充信息
通过点击 创建 并等待 Visual Studio 创建项目来完成设置。
就这样,应用程序已经创建。让我们先设计 MainPage 文件。
设计 MainPage 文件
已创建一个名为 Swiper 的新 .NET MAUI Shell 应用程序,包含一个名为 MainPage.xaml 的单页。这位于项目的根目录中。我们需要将默认的 XAML 模板替换为包含我们的 Swiper 控件的新布局。
让我们通过替换默认内容来编辑已存在的 MainPage.xaml 文件:
打开 MainPage.xaml 文件。
将页面内容替换为以下突出显示的 XAML 代码:
<?xml version="1.0" encoding="utf-8"?>
<ContentPage
xmlns=http://schemas.microsoft.com/dotnet/2021/maui
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,这意味着左侧和右侧应该有 0 个单位的填充,顶部和底部应该有 40 个单位的填充。还有一个第三个选项,使用四个数字,它设置了 左侧 、顶部 、右侧 和 底部 的填充,按照特定的顺序。
最后要注意的是,我们给外层网格起了一个名字,x:Name="MainGrid"。这将使得它可以直接从 MainPage.xaml.cs 文件中定义的后台代码中访问。由于在这个例子中我们没有使用 MVVM,我们需要一种方法来访问网格而不使用数据绑定。
创建 Swiper 控件
这个项目的核心部分是创建 Swiper 控件。在一般意义上,控件是一个自包含的 ContentView,与 ContentPage 相对,后者是 XAML 页面。它可以作为一个元素添加到任何 XAML 页面中,或者在代码的后台文件中。在这个项目中,我们将从代码中添加控件。
创建控件
创建 Swiper 控件是一个简单的过程。我们只需要确保我们选择了正确的项目模板,即 内容视图 ,通过以下操作:
在 Swiper 项目中,创建一个名为 Controls 的文件夹。
右键单击 Controls 文件夹,选择 添加 ,然后点击 新建项... 。
在 添加新项 对话框的左侧面板中选择 C# 项 ,然后选择 .NET MAUI 。
选择 .NET MAUI 内容视图 (XAML) 项。确保您不要选择 .NET MAUI 内容视图 (C#) 选项;这只会创建一个 C# 文件,而不是 XAML 文件。
将控件命名为 SwiperControl.xaml。
点击 添加 。
参考以下截图查看上述信息:
图 5.5 – 添加新项
这添加了一个用于 UI 的 XAML 文件和一个 C# 后台代码文件。它应该看起来如下:
图 5.6 – 解决方案布局
定义主网格
让我们设置 Swiper 控件的基本结构:
打开 SwiperControl.xaml 文件。
将以下代码块中的内容替换为高亮的代码:
<?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 文件。我们的最终结果将看起来像 图 5.7 。由于我们将从互联网上拉取图片,我们将显示一个加载文本,以确保用户能够得到关于正在发生什么的反馈。为了使其看起来像即时打印的照片,我们在照片下方添加了一些手写的文本,如下面的图所示:
图 5.7 – 照片 UI 设计
前面的图显示了我们希望照片看起来像什么。为了使这成为现实,我们需要通过以下方式向SwiperControl文件添加一些 XAML 代码:
打开SwiperControl.xaml。
在<!-- ContentView for photo here -->注释之后添加高亮的 XAML 代码。确保不要替换页面的整个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 Grid.RowSpan="2" BackgroundColor="White" />
<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 Grid.Row="1" x:Name="descriptionLabel" Margin="10,0" Text="A picture of grandpa" FontFamily="Bradley Hand" />
</Grid>
</ContentView>
ContentView控件定义了一个新的区域,我们可以在这里添加其他控件。ContentView控件的一个非常重要的特性是它只接受一个子控件。大多数时候,我们会添加一个可用的布局控件。在这种情况下,我们将使用Grid控件来布局控件,如前述代码所示。
网格定义了两行:
Grid控件本身设置为使用黑色背景和 1 的填充。这与一个白色背景的BoxView控件结合使用,创建了我们在控件周围看到的框架。BoxView控件也被设置为跨越网格的两行(Grid.RowSpan="2"),占据网格的整个区域,减去填充。
接下来是Image控件。它设置了一个背景颜色为漂亮的灰色调(#AAAAAA)和 40 的边距,这将使其与周围的框架稍微隔开。它还有一个硬编码的名称(x:Name="image"),这将允许我们从代码后端与之交互。最后一个属性,称为Aspect,决定了如果图像控件与源图像的比例不同时我们应该做什么。在这种情况下,我们希望填充整个图像区域,但不显示任何空白区域。这实际上在高度或宽度上裁剪了图像。
我们通过添加两个标签来完成,这些标签也有硬编码的名称供以后参考。
现在 XAML 的部分就到这里了;让我们继续为这张照片创建一个描述。
创建DescriptionGenerator类
在图片底部,我们可以看到一个描述。由于我们来自即将到来的图片源没有任何通用的图片描述,我们需要创建一个生成描述的生成器。这里有一个简单而有趣的方法来做这件事:
在Swiper项目中创建一个名为Utils的文件夹。
在那个文件夹中创建一个名为DescriptionGenerator的新类。
向这个类添加以下代码:
internal 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();
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类
为了抽象出我们想要显示的图片的所有信息,我们将创建一个封装这些信息的类。在我们的Picture类中信息不多,但这是一个好的编码实践。按照以下步骤进行:
在Utils文件夹中创建一个新的类,名为Picture。
将以下代码添加到类中:
public class Picture
{
public Uri Uri { get; init; }
public string Description { get; init; }
public Picture()
{
Uri = new Uri($"https://picsum.photos/400/400/?random&ts={DateTime.Now.Ticks}");
var generator = new DescriptionGenerator();
Description = generator.Generate();
}
}
Picture类有以下两个公共属性:
Uri属性,指向其在互联网上的位置
该图片的描述,作为Description属性公开
在构造函数中,我们创建一个新的 URI,它指向一个公共测试照片源,我们可以使用。宽度和高度在 URI 的查询字符串部分指定。我们还附加了一个随机时间戳,以避免.NET MAUI 缓存图片。这为我们每次请求图片时生成一个唯一的 URI。
我们随后使用之前创建的DescriptionGenerator类为图片生成一个随机描述。
注意,属性并不定义一个set方法,而是使用init。由于我们创建对象后永远不需要更改URL或Description的值,这些属性可以是只读的。init只允许在构造函数完成之前设置值。如果您在构造函数运行之后尝试设置值,编译器将生成一个错误。
现在我们已经拥有了开始显示图片所需的所有组件,让我们开始把它们整合起来。
将图片绑定到控件上
让我们开始连接Swiper控件,以便它开始显示图片。我们需要设置图片的来源,然后根据图片的状态控制加载标签的可见性。由于我们使用的是从互联网上获取的图片,可能需要几秒钟的时间来下载。一个好的用户界面将提供适当的反馈,帮助用户避免对正在发生的事情产生困惑。
我们将首先设置图片的源。
设置源
Image控件(在代码中称为image)有一个source属性。这个属性是ImageSource抽象类型。您可以使用几种不同类型的图像源。我们感兴趣的是UriImageSource类型,它接受一个 URI,下载图片,并允许图像控件显示它。
让我们扩展 Swiper 控件,以便我们可以设置源和描述:
打开 Controls/Swiper.Xaml.cs 文件(Swiper 控件的代码隐藏文件)。
为 Swiper.Utils 添加一个 using 语句(using Swiper.Utils;),因为我们将会使用该命名空间中的 Picture 类。
将以下突出显示的代码添加到构造函数中:
public SwiperControl()
{
InitializeComponent();
var picture = new Picture();
descriptionLabel.Text = picture.Description;
image.Source = new UriImageSource() { Uri = picture.Uri };
}
在这里,我们创建了一个 Picture 类的新实例,并通过设置该控制器的文本属性将描述分配给 GUI 中的 descriptionLabel 控制器。然后,我们将图像的源设置为 UriImageSource 类的新实例,并将 picture 实例的 URI 分配给它。这将导致图像从互联网上下载,并在下载完成后立即显示。
接下来,我们将更改加载标签的可见性以提供积极的用户反馈。
控制加载标签
当图像正在下载时,我们想在图像上方显示一个居中的加载文本。这已经在之前创建的 XAML 文件中,所以我们需要做的是在图像下载后隐藏它。我们将通过控制 loadingLabel 控件的 IsVisibleProperty 属性(是的,属性实际上命名为 IsVisibleProperty)来实现这一点,通过将绑定设置到图像的 IsLoading 属性。每当图像上的 IsLoading 属性发生变化时,绑定就会更改标签上的 IsVisible 属性。这是一个很好的“点火并忘记”的方法。
你可能已经注意到,当我们说我们不会使用绑定时,我们使用了绑定。这被用作一个快捷方式,以避免我们不得不编写与这个绑定本质上相同功能的代码。而且公平地说,虽然我们说过不要使用 MVVM 和数据绑定,但我们是在绑定到自身,而不是在类之间绑定,所以所有代码都包含在 Swiper 控件内部。
让我们添加控制 loadingLabel 控件的代码,如下所示:
打开 Swiper.xaml.cs 代码隐藏文件。
将以下加粗的代码添加到构造函数中:
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 属性的变化。在这种情况下,这是图像控件。
接下来,我们将允许用户“向右滑动”或“向左滑动”。
处理滑动手势
本应用的核心功能是滑动手势。滑动手势是指用户按下控件并在屏幕上移动它。我们还将向 Swiper 控件添加随机旋转,以便在添加多个图像时使其看起来像一堆照片。
我们将首先向 SwiperControl 类添加一些字段,如下所示:
打开 SwiperControl.xaml.cs 文件。
在代码中将以下字段添加到类中:
private readonly double _initialRotation;
private static readonly Random _random = new Random();
第一个字段_initialRotation存储图像的初始旋转。我们将在构造函数中设置这个值。第二个字段是一个包含Random对象的static字段。你可能记得,最好创建一个静态的随机对象,以确保不会创建具有相同种子的多个随机对象。种子基于时间,所以如果我们创建的对象在时间上太接近,它们将生成相同的随机序列,这根本不是随机的。
接下来,我们必须创建一个事件处理程序来处理PanUpdated事件,我们将在本节末尾将其绑定,如下所示:
打开SwiperControl.xaml.cs代码隐藏文件。
将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:当你放手时,事件最后一次被触发
对于这些状态中的每一个,我们调用特定的方法来处理不同的状态。我们现在将继续添加这些方法:
打开SwiperControl.xaml.cs代码隐藏文件。
将以下三个方法添加到类中,如下所示:
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%的比例缩放来实现的。.NET MAUI 有一套出色的函数来完成这个任务。在这种情况下,我们在图像控件(命名为Photo)上调用ScaleTo()方法,并告诉它缩放到1.1,这对应于其原始大小的 10%。我们还告诉它在100毫秒(ms )内完成这个动作。这个调用也是可等待的,这意味着我们可以在执行下一个调用之前等待控件完成动画。在这种情况下,我们将使用一种触发即忘掉的方法。
接下来是PanRunning(),它在平移操作期间多次被调用。这个方法从调用PanRunning()的事件处理程序中接收一个名为PanUpdatedEventArgs的参数。我们也可以只传递X 和Y 值作为参数来减少代码的耦合。这是一件你可以实验的事情。该方法从事件的TotalX/TotalY属性中提取X 和Y 分量,并将它们分配给图像控件的TranslationX/TranslationY属性。我们还根据图像移动的距离稍微调整了旋转。
我们最后需要做的是在图像释放时将其恢复到初始状态。这可以在 PanCompleted() 中完成。首先,我们将图像(或移动)在 250 毫秒内翻译(或移动)回其原始局部坐标(0,0)。我们还添加了一个缓动函数,使其略微超出目标,然后动画回弹。我们可以尝试不同的预定义缓动函数;这些对于创建漂亮的动画很有用。我们同样将图像移动回其初始旋转。最后,我们在 250 毫秒内将其缩放回原始大小。
现在,是时候在构造函数中添加代码,以连接平移手势并设置一些初始旋转值。按照以下步骤进行:
打开 SwiperControl.xaml.cs 代码隐藏文件。
将以下加粗代码添加到构造函数中。注意构造函数中还有更多代码,所以不要覆盖整个方法;只需添加以下代码块中显示的加粗文本:
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);
var picture = new Picture();
descriptionLabel.Text = picture.Description;
image.Source = new UriImageSource() { Uri = picture.Uri };
loadingLabel.SetBinding(IsVisibleProperty, "IsLoading");
loadingLabel.BindingContext = image;
}
所有 .NET MAUI 控件都有一个名为 GestureRecognizers 的属性。有不同类型的手势识别器,例如 TapGestureRecognizer 或 SwipeGestureRecognizer。在我们的情况下,我们感兴趣的是 PanGestureRecognizer 类型。我们创建一个新的 PanGestureRecognizer 实例,并通过将其连接到我们之前创建的 OnPanUpdated() 方法来订阅 PanUpdated 事件。然后,我们将它添加到 Swiper 控件的 GestureRecognizers 集合中。
然后,我们设置图像的初始旋转,并确保我们存储当前的旋转值,以便我们可以修改旋转,然后将其旋转回原始状态。
接下来,我们将临时连接控件,以便我们可以对其进行测试。
测试控件
我们现在已经编写了所有代码,可以对这个控件进行测试运行。按照以下步骤进行:
打开 MainPage.xaml.cs。
为 Swiper.Controls 添加一个 using 语句(using Swiper.Controls;)。
将以下加粗代码添加到构造函数中:
public MainPage()
{
InitializeComponent();
MainGrid.Children.Add(new SwiperControl());
}
如果构建一切顺利,我们最终应该得到如图所示的照片:
图 5.8 – 测试应用
我们还可以拖动照片(平移它)。注意当你开始拖动时会有轻微的抬起效果,以及根据平移量(即总移动量)的照片旋转。如果你放手,照片会动画回到原位。
现在我们有了显示照片并可以左右滑动它的控件,我们需要对那些滑动做出反应。
创建决策区域
一个配对应用如果没有屏幕两侧的特殊拖放区域,那就什么都不是。我们在这里想做一些事情:
我们将通过在SwiperControl.xaml文件中添加一些 XAML 代码来创建这些区域,然后添加必要的代码来实现这一功能。值得注意的是,这些区域不是放置图像的热点,而是用于在控制表面上方显示标签。实际的放置区域是根据你拖动图像的距离来计算和确定的。
第一步是添加左右滑动动作的 UI。
扩展网格
Swiper控制有三个列(左侧、右侧和中间)定义。我们希望在图像被拖动到页面任一侧时,向用户添加某种视觉反馈。我们将通过添加一个带有每侧Label控制的StackLayout控制来实现这一点。
我们将首先添加右侧。
添加用于喜欢照片的 StackLayout
我们需要做的第一件事是在控制的右侧添加用于喜欢照片的StackLayout控制:
打开Controls/SwiperControl.xaml。
在<!-- StackLayout for like here -->注释下添加以下代码:
<StackLayout Grid.Column="2" x:Name="likeStackLayout" 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控制:
打开Controls/SwiperControl.xaml。
在<!-- StackLayout for deny here -->注释下添加以下代码:
<StackLayout x:Name="denyStackLayout" Opacity="0" Padding="0, 100" HorizontalOptions="Start">
<Label Text="DENY" TextColor="Red" FontSize="30" Rotation="-20" FontAttributes="Bold" />
</StackLayout>
左侧StackLayout的设置与右侧相同,只是它应该位于第一列,这是默认设置,因此不需要添加Grid.Column属性。我们还将HorizontalOptions="End"指定为HorizontalOptions,这意味着内容应该右对齐。
UI 设置完成后,我们现在可以着手实现逻辑,通过调整LIKE或DENIED文本控制的透明度,在照片平移时为用户提供视觉反馈。
确定屏幕大小
为了能够计算用户拖动图像的距离百分比,我们需要知道控件的大小。这直到.NET MAUI 布局控件后才确定。
我们将重写OnSizeAllocated()方法并在类中添加一个_screenWidth字段来跟踪窗口的当前宽度:
打开SwiperControl.xaml.cs。
将以下代码添加到文件中,将字段放在类的开头,并在构造函数下方添加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 字段用于在解决后立即存储宽度。我们通过重写 .NET MAUI 调用的 OnSizeAllocated() 方法来实现这一点,当控制的大小被分配时调用。这会被多次调用。第一次调用实际上是在宽度和高度设置之前,以及当前应用程序的 MainPage 属性设置之前。此时,宽度和高度被设置为 -1,Application.Current.MainPage 属性为 null。我们通过检查 Application.Current.MainPage 是否为 null 来寻找此状态,如果是 null,则返回。我们也可以检查宽度上的 -1 值。两种方法都可行。然而,如果它具有值,我们希望将其存储在我们的 _screenWidth 字段中供以后使用。
.NET MAUI 会在应用框架发生变化时调用 OnSizeAllocated() 方法。这对于 WinUI 应用程序尤其相关,因为它们位于用户可以轻松更改的窗口中。Android 和 iOS 应用程序不太可能再次收到此方法的调用,因为应用程序将占据整个屏幕的空间。
添加代码以计算状态
为了计算图像的状态,我们需要定义我们的区域,然后创建一个函数,该函数接受当前的移动量,并根据我们平移图像的距离更新 GUI 决策区域的不透明度。
定义计算状态的函数
让我们按照以下几个步骤添加 CalculatePanState() 方法来计算平移图像的距离,并确定是否应该开始影响 GUI:
打开 Controls/SwiperControl.xaml.cs。
在类中任何位置添加顶部属性和 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 = double.Clamp(opacity, -1, 1);
likeStackLayout.Opacity = opacity;
denyStackLayout.Opacity = -opacity;
}
我们将以下两个值定义为常量:
然后,每当平移发生变化时,我们使用这些值来检查平移动作的状态。如果 X 的绝对平移值 (panX) 小于死区,则不执行任何操作并返回。如果不满足条件,我们计算超过死区的距离以及进入决策区的距离。我们根据这个插值计算不透明度值,并将值限制在 -1 和 1 之间。
最后,我们将不透明度设置为 likeStackLayout 和 denyStackLayout 的此值。
连接平移状态检查
当图像正在平移时,我们想要更新状态,如下所示:
打开 Controls/SwiperControl.xaml.cs。
在 PanRunning() 方法中添加以下加粗代码:
private void PanRunning(PanUpdatedEventArgs e)
{
photo.TranslationX = e.TotalX; photo.TranslationY = e.TotalY;
photo.Rotation = _initialRotation + (photo.TranslationX / 25);
CalculatePanState(e.TotalX);
}
这个 PanRunning() 方法的添加将总移动量传递到 CalculatePanState() 方法,以确定是否需要调整控件右侧或左侧的 StackLayout 的不透明度。
添加退出逻辑
到目前为止,一切顺利,除了如果我们拖动图片到边缘并释放,文本会保留下来。我们需要确定用户何时停止拖动图片,以及,如果是这样,图片是否在决策区域。
让我们添加将照片动画回原始位置的代码。
检查图片是否应该退出
我们需要一个简单的函数来判断图片是否已经平移足够远,可以算作图片的退出。要创建这样的函数,请按照以下步骤操作:
打开 Controls/SwiperControl.xaml.cs 文件。
将 CheckForExitCriteria() 方法添加到类中,如下代码片段所示:
private bool CheckForExitCriteria()
{
var halfScreenWidth = _screenWidth / 2;
var decisionBreakpoint = DeadZone * halfScreenWidth;
return (Math.Abs(photo.TranslationX) > decisionBreakpoint);
}
这个函数计算我们是否已经越过了死区并进入了决策区。我们需要使用 Math.Abs() 方法来获取总绝对值以进行比较。我们也可以使用 < 和 > 操作符,但我们使用这种方法因为它更易读。这是一个关于代码风格和品味的问题——请随意按照您的方式来做。
移除图片
如果我们确定图片已经平移足够远,可以算作退出,我们希望将其动画移出屏幕,然后从页面上移除图片。为此,请按照以下步骤操作:
打开 Controls/SwiperControl.xaml.cs 文件。
将 Exit() 方法添加到类中,如下代码块所示:
private void Exit()
{
MainThread.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;
parent?.Children.Remove(this);
});
}
让我们分解前面的代码块,了解 Exit() 方法的作用:
我们首先确保这个调用是在 UI 线程上完成的,这也被称为 MainThread 线程。这是因为只有 UI 线程可以进行动画。
我们还需要异步运行这个线程,这样我们就可以一石二鸟。因为这个方法完全是关于将图片动画到屏幕的任一侧,我们需要确定动画的方向。我们通过确定图片的总平移量是正数还是负数来实现这一点。
然后,我们使用这个值通过 photo.TranslateTo() 调用等待平移。
我们使用 await 来等待这个调用,因为我们不希望代码执行继续直到它完成。一旦完成,我们就从父控件的子控件集合中移除该控件,使其永远消失。
更新 PanCompleted
关于图片是否应该消失或简单地返回到原始状态的决定是在 PanCompleted() 方法中触发的。在这里,我们将连接我们之前两个部分中创建的两个方法。请按照以下步骤操作:
打开 Controls/SwiperControl.xaml.cs 文件。
将以下代码以粗体形式添加到 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的不透明度,使一切恢复正常。
现在我们可以左右滑动,让我们添加一些事件,当用户滑动时触发。
添加到控制器的事件
在控制器本身中,我们剩下要做的最后一件事是添加一些事件,以指示图片是否已被喜欢 或拒绝 。我们将使用一个干净的界面,允许简单使用控件,同时隐藏所有实现细节。
声明两个事件
为了使控制器更容易从应用程序本身进行交互,我们需要添加Like和Deny事件,如下所示:
打开Controls/SwiperControl.xaml.cs。
在类开始处添加两个事件声明,如下代码片段所示:
public event EventHandler OnLike;
public event EventHandler OnDeny;
这两个是标准的事件声明,带有开箱即用的事件处理器。
触发事件
我们需要在Exit()方法中添加代码来触发我们之前创建的事件,如下所示:
打开Controls/SwiperControl.xaml.cs。
在Exit()方法中添加以下加粗代码:
private void Exit()
{
MainThread.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;
parent?.Children.Remove(this);
});
}
在这里,我们注入代码以检查我们是在喜欢还是拒绝图片。然后,根据这些信息触发正确的事件。
我们现在准备好最终化这个应用;Swiper控制器已完成,因此现在我们需要添加正确的初始化代码来完成它。
连接 Swiper 控制器
我们现在已经到达了本章的最后一部分。在本节中,我们将连接图片,并使我们的应用成为一个闭环应用,可以永久使用。当应用启动时,我们将添加 10 张图片,这些图片将从互联网上下载。每次移除一张图片,我们就会简单地添加另一张。
添加图片
让我们先创建一些代码,这些代码将添加图片到MainView类。首先,我们将添加初始图片;然后,我们将为每次图片被喜欢或拒绝时在堆栈底部添加新图片创建一个逻辑模型。
添加初始图片
要使照片看起来像堆叠的,我们需要至少 10 张。按照以下步骤进行:
打开MainPage.xaml.cs。
将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。它将控件插入堆栈的第一个位置,由于控件集合是从开始到结束渲染的,因此这实际上将控件放置在堆栈底部。
在构造函数中发起调用
我们需要调用此方法以实现魔法效果,因此请按照以下步骤进行操作:
打开MainPage.xaml.cs。
在构造函数中添加以下加粗代码:
public MainPage()
{
InitializeComponent();
AddInitialPhotos();
}
这里没有太多可说的。一旦MainPage对象被初始化,我们调用方法添加 10 张从互联网下载的随机照片。
添加计数标签
我们还想在应用程序中添加一些值。我们可以通过在Swiper控件集合下方添加两个标签来实现。每次用户对图像进行评分时,我们将增加两个计数器之一,并显示结果。
因此,让我们添加显示标签所需的 XAML 代码:
打开MainPage.xaml。
将<!-- 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控件,并将其中两个命名为likeLabel和denyLabel。这两个命名标签将包含有关有多少图像被点赞和有多少被拒绝的信息。
订阅事件
最后一步是连接OnLike和OnDeny事件,并将总计数显示给用户。
添加方法以更新 GUI 和响应用件
我们需要一些代码来更新 GUI 并跟踪计数。按照以下步骤进行:
打开MainPage.xaml.cs。
将以下代码添加到类中:
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属性。
下面的两个方法是处理OnLike和OnDeny事件的处理器。它们增加适当的字段,添加一张新照片,然后更新 GUI 以反映变化。
连接事件
每次创建一个新的SwiperControl实例时,我们需要连接事件,如下所示:
打开MainPage.xaml.cs。
在InsertPhoto()方法中,以下代码需要加粗:
private void InsertPhoto()
{
var photo = new SwiperControl();
photo.OnDeny += Handle_OnDeny;
photo.OnLike += Handle_OnLike;
this.MainGrid.Children.Insert(0, photo);
}
添加的代码连接了我们之前定义的事件处理程序。这些事件使得与我们的新控件交互变得容易。自己试一试,并玩一玩你创建的应用程序。
摘要
干得好!在本章中,我们学习了如何创建一个可重用、外观良好的控件,可以在任何.NET MAUI 应用程序中使用。为了增强应用程序的用户体验 (UX ),我们使用了一些动画,为用户提供更多的视觉反馈。我们还巧妙地使用了 XAML 来定义一个看起来像照片的控件 GUI,并附有手写描述。
之后,我们使用事件将控制的行为暴露回MainPage页面,以限制您的应用和控制之间的接触面。最重要的是,我们触及了GestureRecognizers的主题,这在我们处理常见手势时可以使我们的生活变得更加轻松。
正在寻找如何使这个应用变得更好的想法?试试这个:保留点赞和踩不喜欢的记录,并添加一个视图来显示每个收藏。
在下一章中,我们将使用CollectionView和CarouselView控件创建一个照片库应用。该应用还将允许您通过使用存储来保留在应用运行之间的收藏列表,来收藏您喜欢的照片。
第六章:使用 CollectionView 和 CarouselView 构建 Photo Gallery 应用程序
在本章中,我们将构建一个应用程序,展示用户设备相册(照片库)中的照片。用户还可以选择照片作为收藏夹。然后我们将探讨不同的照片显示方式——在轮播图中和在多列网格控件中。通过使用 .NET MAUI CarouselView 控件显示一组图像,用户可以滑动查看每张图像。为了显示大量图像,我们将使用 .NET MAUI CollectionView 控件和垂直滚动,以便用户查看所有图像。通过学习如何使用这些控件,我们将在构建实际应用程序时能够将它们用于许多其他情况。
本章将涵盖以下主题:
从用户请求访问数据的权限
如何从 iOS 和 Mac Catalyst 照片库导入照片
如何从 Android 照片库导入照片
如何从 Windows 照片库导入照片
如何在 .NET MAUI 中使用 CarouselView
如何在 .NET MAUI 中使用 CollectionView
技术要求
要完成此项目,您需要安装 Visual Studio for Mac 或 Windows,以及必要的 .NET MAUI 工作负载。有关如何设置环境的更多详细信息,请参阅 第一章 ,.NET MAUI 简介 。
要使用 Visual Studio for PC 构建 iOS 应用程序,您必须连接一个 Macintosh (Mac )设备。如果您根本无法访问 Mac,您可以只遵循此项目的 Android 和 Windows 部分。
您可以在本章中找到代码的完整源代码,请访问 github.com/PacktPublishing/MAUI-Projects-3rd-Edition/ 。
项目概述
几乎所有应用程序都会可视化数据集合,在本章中,我们将重点关注两个可以用于显示数据集合的 .NET MAUI 控件——CollectionView 和 CarouselView。我们的应用程序将展示用户设备上的照片;为此,我们需要为每个平台创建一个照片导入器——一个用于 iOS 和 Mac Catalyst,一个用于 Windows,一个用于 Android。
此项目的构建时间约为 60 分钟。
构建 Photo Gallery 应用程序
此项目,就像所有其他项目一样,是一个 文件 | 新建 | 项目... 风格的项目,这意味着我们根本不会导入任何代码。因此,本节全部关于创建项目和设置基本项目结构。
是时候开始使用以下步骤构建应用程序了。让我们开始吧!
创建新项目
第一步是创建一个新的 .NET MAUI 项目:
打开 Visual Studio 2022 并选择 创建新项目 :
图 6.1 – Visual Studio 2022
这将打开 创建新项目 向导。
在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项:
图 6.2 – 创建新项目
点击 下一步 。
通过命名您的项目来完成向导的下一步。在本例中,我们将我们的应用程序命名为 GalleryApp。通过点击 下一步 ,继续到下一个对话框,如图所示:
图 6.3 – 配置您的项目
点击 下一步 。
最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持 (LTS ),而 .NET 7 可用为 标准期限支持 。在本书中,我们假设您将使用 .NET 7。
图 6.4 – 补充信息
通过点击 创建 并等待 Visual Studio 创建项目来最终完成设置。
就这样,应用程序就创建完成了。让我们先获取一些照片来显示。
导入照片
照片的导入是在所有平台上执行的操作,因此我们将创建一个照片导入接口。该接口将有两个 Get 方法—一个支持分页,另一个获取指定文件名的照片。这两种方法也将接受一个质量参数,但我们只会在 iOS 照片导入器中使用该参数。质量参数将是一个具有两个选项的 enum 类型—High 和 Low。然而,在我们创建接口之前,我们将创建一个模型类,该类将使用以下步骤表示导入的照片:
在 GalleryApp 项目中创建一个名为 Models 的新文件夹。
在最近创建的文件夹中创建一个名为 Photo 的新类:
namespace GalleryApp.Models;
public class Photo
{
public string Filename { get; set; }
public byte[] Bytes { get; set; }
}
现在我们已经创建了模型类,我们可以继续创建接口:
在项目中创建一个名为 Services 的新文件夹。
在 Services 文件夹中创建一个名为 IPhotoImporter 的新接口:
namespace GalleryApp.Services;
using System.Collections.ObjectModel;
using GalleryApp.Models;
public interface IPhotoImporter
{
Task<ObservableCollection<Photo>> Get(int start, int count,
Quality quality = Quality.Low);
Task<ObservableCollection<Photo>> Get(List<string> filenames,
Quality quality = Quality.Low);
}
在 Services 文件夹中,添加一个新文件并创建一个名为 Quality 的 enum 类型,包含两个成员—Low 和 High:
namespace GalleryApp.Services;
public enum Quality
{
Low,
High
}
在 Services 文件夹中创建一个名为 PhotoImporter 的新类:
namespace GalleryApp.Services;
using GalleryApp.Models;
using System.Collections.ObjectModel;
internal partial class PhotoImporter : IPhotoImporter
{
private partial Task<string[]> Import();
public partial Task<ObservableCollection<Photo>> Get(int
start, int count, Quality quality);
public partial Task<ObservableCollection<Photo>>
Get(List<string> filenames, Quality quality);
}
此类为我们提供了特定平台实现的基础。通过将其标记为 partial,我们告诉编译器该类在其他文件中还有更多内容。我们将在稍后把实现放在特定平台的文件夹中。
现在我们有了接口,我们可以添加应用程序权限。
请求应用程序权限
如果您的应用程序不需要设备任何额外的功能,如位置、相机或互联网,那么您将需要使用权限来请求访问这些资源。虽然每个平台对权限的实现略有不同,但 .NET MAUI 将特定平台的权限映射到一组通用的权限,以简化操作。.NET MAUI 的权限系统也是可扩展的,这样您就可以创建最适合您应用程序的自定义权限。
让我们通过一个具体的例子来看看请求权限是如何工作的。GalleryApp 显示来自设备照片库的图片。在 iOS 和 Android 的案例中,应用必须在能够使用照片库之前声明并请求访问权限。虽然这些权限的配置和命名方式不同,但 .NET MAUI 定义了一个 Photo 权限,隐藏了这些实现细节。
按照以下步骤向 GalleryApp 添加权限检查:
在 GalleryApp 项目中创建一个名为 AppPermissions 的新类。
修改类定义以添加 partial 修饰符,并移除默认构造函数:
namespace GalleryApp;
internal partial class AppPermissions
{
}
将以下类定义添加到 AppPermissions 类中:
internal partial class AppPermissions
{
internal partial class AppPermission : Permissions.Photos
{
}
}
这创建了一个名为 AppPermission 的类型,它从默认的 .NET MAUI Photos 权限类继承。它也被标记为 partial,以便添加特定于平台的实现细节。剧透一下:我们将需要一些特定于平台的权限。
将以下方法添加到 AppPermissions 类中:
public static async Task<PermissionStatus>
CheckRequiredPermission() => await Permissions.
CheckStatusAsync<AppPermission>();
CheckRequiredPermission 方法用于确保在我们尝试任何可能会因为权限不足而失败的操作之前,我们的应用拥有正确的权限。其实现是调用 .NET MAUI 的 CheckSyncStatus 方法,并使用我们的 AppPermission 类型。它返回一个 PermissionStatus,这是一个枚举类型。我们主要对 Denied 和 Granted 值感兴趣。
将 CheckAndRequestRequiredPermission 方法添加到 AppPermissions 类中:
public static async Task<PermissionStatus>
CheckAndRequestRequiredPermission()
{
PermissionStatus status = await Permissions.
CheckStatusAsync<AppPermission>();
if (status == PermissionStatus.Granted)
return status;
if (status == PermissionStatus.Denied && DeviceInfo.Platform
== DevicePlatform.iOS)
{
// Prompt the user to turn on in settings
// On iOS once a permission has been denied it may not be
requested again from the application
await App.Current.MainPage.DisplayAlert("Required App
Permissions", "Please enable all permissions in Settings for
this App, it is useless without them.", "Ok");
}
if
(Permissions.ShouldShowRationale<AppPermission>())
{
// Prompt the user with additional information as to why the
permission is needed
await App.Current.MainPage.DisplayAlert("Required App
Permissions", "This is a Photo gallery app, without these
permissions it is useless.", "Ok");
}
status = await MainThread.InvokeOnMainThreadAsync(Permissions.
RequestAsync<AppPermission>);
return status;
}
}
CheckAndRequestRequiredPermission 方法处理从用户请求访问权限的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果您在 iOS 上且权限已被拒绝,则无法再次请求,因此您必须指导用户如何通过设置面板授予应用权限。在请求行为中,Android 包括如果用户拒绝访问时骚扰用户的能力。这种行为通过 .NET MAUI 的 ShouldShowRationale 方法公开。对于不支持此行为的任何平台,它将返回 false;在 Android 上,第一次用户拒绝访问时将返回 true,如果用户第二次拒绝,则返回 false。最后,我们请求用户对 AppPermission 进行访问。同样,.NET MAUI 正在隐藏所有平台实现细节,使得检查和请求访问某些资源变得非常直接。
看起来熟悉吗?
如果前面的代码看起来很熟悉,那可能是因为它。这正是 .NET MAUI 文档中描述的实现。您可以在 learn.microsoft.com/en-us/dotnet/maui/platform-integration/appmodel/permissions 找到它。
现在我们已经设置了共享的 AppPermissions,我们可以开始平台实现。
从 iOS 照片库导入照片
首先,我们将编写 iOS 代码。为了访问照片,我们需要用户的权限,并且我们需要解释为什么我们需要请求权限。为此,我们将解释为什么需要权限的文本添加到 info.plist 文件中。当请求用户权限时,将显示此文本。要打开 info.plist 文件,在 Platforms/iOS 文件夹中的文件上右键单击并点击 Info.plist 编辑器。将以下文本添加到 <``dict> 元素的末尾:
<key> NSPhotoLibraryUsageDescription </key>
<string> We want to show your photos in this app </string>
我们将要做的第一件事是实现 Import 方法,该方法读取可以加载哪些照片:
在 Platforms/iOS 文件夹中的 GalleryApp 项目中,创建一个名为 PhotoImporter 的新类。
将命名空间声明从 GalleryApp.Platforms.iOS 更改为 GalleryApp.Services。
即使部分类定义在不同的文件夹中,它们也必须在同一个命名空间中。
添加 partial 修饰符。
解析所有引用。
创建一个名为 assets 的 PHAsset 字典的 private 字段。这将用于存储照片信息:
private Dictionary<string,PHAsset> assets;
创建一个名为 Import 的新 private partial 方法:
private partial async Task<string[]> Import()
{
}
在 Import 方法中,使用 AppPermissions.Check``AndRequestRequiredPermission 方法请求授权:
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
如果用户已经授予访问权限,则使用 PHAsset.FetchAssets 通过 PHAsset 获取所有图像资产:
internal partial class PhotoImporter
{
private Dictionary<string,PHAsset> assets;
private partial async Task<string[]> Import()
{
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
assets = PHAsset.FetchAssets(PHAssetMediaType.Image, null)
.Select(x => (PHAsset)x)
.ToDictionary(asset => asset.
ValueForKey((NSString)"filename").ToString(), asset => asset);
}
return assets?.Keys.ToList().ToArray();
}
现在,我们已经获取了所有照片的 PHAssets,但要显示照片,我们需要获取实际的照片。在 iOS 上,为了做到这一点,我们需要请求资产的图像。这是一项异步执行的操作,因此我们将使用 ObservableCollection:
private void AddImage(ObservableCollection<Photo> photos, string path,
PHAsset asset, Quality quality)
{
var options = new PHImageRequestOptions()
{
NetworkAccessAllowed = true,
DeliveryMode = quality == Quality.Low ?
PHImageRequestOptionsDeliveryMode.FastFormat :
PHImageRequestOptionsDeliveryMode.HighQualityFormat
};
PHImageManager.DefaultManager.RequestImageForAsset(asset,
PHImageManager.MaximumSize, PHImageContentMode.AspectFill, options,
(image, info) =>
{
using NSData imageData = image.AsPNG();
var bytes = new byte[imageData.Length];
System.Runtime.InteropServices.Marshal.Copy(imageData.
Bytes, bytes, 0, Convert.ToInt32(imageData.Length));
photos.Add(new Photo()
{
Bytes = bytes,
Filename = Path.GetFileName(path)
});
});
}
现在,我们已经拥有了开始实现接口中的两个 Get 方法所需的一切。我们将从部分 Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法开始,该方法将用于从加载照片的 CollectionView 视图中获取照片:
public partial async Task<ObservableCollection<Photo>> Get(int start,
int count, Quality quality)
{
var photos = new ObservableCollection<Photo>();
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
var result = await Import();
if (result.Length == 0)
{
return photos;
}
Index startIndex = start;
Index endIndex = start + count;
if (endIndex.Value >= result.Length)
{
endIndex = result.Length;
}
if (startIndex.Value > endIndex.Value)
{
return photos;
}
foreach (var path in result[startIndex..endIndex])
{
AddImage(photos, path, assets[path], quality);
}
}
return photos;
}
来自 IPhotoImporter 接口的另一个方法 Task<ObservableCollection<Photo>> Get(List<string> filenames, Quality quality = Quality.Low) 与 Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法非常相似。唯一的区别是没有处理索引的代码,并且遍历结果数组的 foreach 循环包含一个 if 语句,检查文件名是否与当前的 PHAsset 对象相同,如果是,则调用 AddImage 方法:
public partial async Task<ObservableCollection<Photo>>
Get(List<string> filenames, Quality quality)
{
var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result?.Length == 0)
{
return photos;
}
foreach (var path in result)
{
if (filenames.Contains(path))
{
AddImage(photos, path, assets[path], quality);
}
}
return photos;
}
在前面的代码中,我们设置了 NetworkAccessAllowed = true。我们这样做是为了使下载来自 iCloud 的照片成为可能。
现在,我们项目中的四个照片导入器之一已经完成。下一个我们将实现的是 Mac Catalyst 导入器。
从 Mac Catalyst 照片库导入照片
Mac Catalyst 导入器与我们刚刚为 iOS 所做的是完全相同的。然而,并没有一种方便的方式来表达,“我只需要这个类用于 iOS 和 Mac Catalyst,而不需要其他任何东西。 ”因此,我们将走最简单的路径,直接将类复制到 Mac Catalyst 平台文件夹中:
右键单击项目中的Platforms/iOS文件夹中的PhotoImporter.cs文件并选择复制 。
右键单击Platforms/MacCatalyst文件夹并选择粘贴 。
右键单击Platforms/MacCatalyst文件夹中的Info.plist文件并点击<dict>元素:
<key> NSPhotoLibraryUsageDescription </key>
<string> We want to show your photos in this app </string>
这就完成了PhotoImporter类的 Mac Catalyst 实现。接下来,我们将着手处理 Android 平台。
从 Android 照片库导入图片
现在我们已经为 iOS 创建了一个实现,我们将为 Android 做同样的处理。在我们直接进入导入器之前,我们需要解决 Android 上的权限问题。
在 Android API 版本 33 中,添加了三个新权限以启用对媒体文件的读取访问:ReadMediaImages、ReadMediaVideos和ReadMediaAudio。在 API 版本 33 之前,所需的只是ReadExternalStorage权限。为了正确请求设备的 API 版本的正确权限,在Platform/Android文件夹中创建一个名为AppPermissions的新文件,并将其修改如下:
using Android.OS;
[assembly: Android.App.UsesPermission(Android.Manifest.Permission.
ReadMediaImages)]
[assembly: Android.App.UsesPermission(Android.Manifest.Permission.
ReadExternalStorage, MaxSdkVersion = 32)]
namespace GalleryApp;
internal partial class AppPermissions
{
internal partial class AppPermission : Permissions.Photos
{
public override (string androidPermission, bool isRuntime)[]
RequiredPermissions
{
get
{
List<(string androidPermission, bool isRuntime)> perms = new();
if (Build.VERSION.SdkInt >= BuildVersionCodes.Tiramisu)
perms.Add((global::Android.Manifest.Permission.
ReadMediaImages, true));
else
perms.Add((global::Android.Manifest.Permission.
ReadExternalStorage, true));
return perms.ToArray();
}
}
}
}
前两行将所需的权限添加到AndroidManifet.xml文件中,这与我们手动对 iOS 的info.plist文件所做的是类似的。然而,我们只需要ReadMediaImages权限用于 API 33 及以上版本,以及ReadExternalStorage权限用于低于 33 版本的 API 版本,因此我们为ReadExternalStorage属性设置了MaxSdkVersion。然后,我们通过实现RequirePermissions属性来扩展AppPermission类。在RequirePermissions中,如果 API 版本为 33 或更高,我们返回包含ReadMediaImages权限的数组;如果 API 版本低于 33,则返回ReadExternalStorage权限。perms数组中的布尔值表示权限是否需要在运行时请求用户的访问权限。现在,当应用启动时,它将根据设备的 API 级别请求正确的权限。
现在我们已经整理好了 Android 特定的权限,我们可以按照以下步骤导入图片:
在Platforms/Android文件夹中的项目中创建一个名为PhotoImporter的新类。
将命名空间声明从GalleryApp.Platforms.Android更改为GalleryApp.Services。
即使部分类定义在不同的文件夹中,它们也必须在同一个命名空间中。
添加partial修饰符。
添加一个using语句以使用GalleryApp.Models中的Photo类。
与 iOS 实现类似,我们将首先实现Import方法。添加一个名为Import的新方法,如下所示:
private partial async Task<string[]> Import()
{
var paths = new List<string>();
return paths.ToArray();
}
从用户那里请求权限以获取照片(以下代码块中突出显示):
private partial async Task<string[]> Import()
{
var paths = new List<string>();
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
}
return paths.ToArray();
}
现在,使用 ContentResolver 查询文件并将它们添加到结果中:
private partial async Task<string[]> Import()
{
var paths = new List<string>();
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
var imageUri = MediaStore.Images.Media.ExternalContentUri;
var projection = new string[] { MediaStore.IMediaColumns.
Data };
var orderBy = MediaStore.Images.IImageColumns.DateTaken;
var cursor = Platform.CurrentActivity.ContentResolver.
Query(imageUri, projection, null, null, orderBy);
while (cursor.MoveToNext())
{
string path = cursor.GetString(cursor.
GetColumnIndex(MediaStore.IMediaColumns.Data));
paths.Add(path);
}
}
return paths.ToArray();
}
然后,我们将开始编辑 Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low) 方法。如果导入成功,我们将继续编写处理在此图像加载中应导入哪些照片的代码。条件由 start 和 count 参数指定。使用以下代码列表来实现第一个 Get 方法:
public partial async Task<ObservableCollection<Photo>> Get(int start,
int count, Quality quality)
{
var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result.Length == 0)
{
return photos;
}
Index startIndex = start;
Index endIndex = start + count;
if (endIndex.Value >= result.Length)
{
endIndex = result.Length;
}
if (startIndex.Value > endIndex.Value)
{
return photos;
}
foreach (var path in result[startIndex..endIndex])
{
photos.Add(new()
{
Bytes = File.ReadAllBytes(path),
Filename = Path.GetFileName(path)
});
}
return photos;
}
让我们回顾一下前面的代码。第一步是调用 Import 方法并验证是否有照片要导入。如果没有,我们简单地返回一个空列表。如果有照片要导入,那么我们需要知道 photos 数组中的 startIndex 和 endIndex 以导入。代码默认 endIndex 为 startIndex 加上要导入的照片数量。如果要导入的照片数量大于 Import 方法返回的照片数量,则将 endindex 调整为 Import 方法返回的照片长度。如果 startIndex 大于 endIndex,则返回照片列表。最后,我们可以从照片数组中读取 startIndex 到 endIndex 的图像,并返回每个条目的文件字节和文件名。
现在,我们将继续处理其他 Task<ObservableCollection<Photo>> Get (List filenames, Quality quality = Quality.Low) 方法。
创建一个 foreach 循环来遍历所有照片并检查每个照片是否在 filenames 参数中指定。如果照片在 filenames 参数中指定,则从路径读取照片,就像第一个 Get 方法一样:
public partial async Task<ObservableCollection<Photo>>
Get(List<string> filenames, Quality quality)
{
var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result.Length == 0)
{
return photos;
}
foreach (var path in result)
{
var filename = Path.GetFileName(path);
if (!filenames.Contains(filename))
{
continue;
}
photos.Add(new Photo()
{
Bytes = File.ReadAllBytes(path),
Filename = filename
});
}
return photos;
}
随着 Android 导入器的完成,我们可以转向 Windows 的最终导入器。
从 Windows 照片库导入照片
我们需要的最终导入器是为 Windows 平台。代码将遵循与其他平台相同的模式;然而,对于 Windows,我们将使用 Windows 搜索 服务来获取照片列表。让我们通过以下步骤查看此平台是如何实现的:
导入 tlbimp-Windows.Search.Interop 和 System.Data.OleDB NuGet 包。这些包用于在文件系统中搜索图像。
通过在 解决方案资源管理器 中双击它来打开 GalleryApp 项目;编辑新的导入以添加一个条件:
<PackageReference Include="System.Data.OleDb" Version="7.0.0"
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(Target
Framework)')) == 'windows'" />
<PackageReference Include="tlbimp-Microsoft.Search.Interop"
Version="1.0.0" Condition="$([MSBuild]::GetTargetPlatform
PackageReference so that it is only used when TargetPlatformIdentifier is 'windows'.
在 Windows 平台文件夹中创建一个名为 PhotoImporter 的新类,并将其标记为 partial。
将命名空间声明从 GalleryApp.Platforms.Windows 更改为 GalleryApp.Services。
partial 类定义必须在同一个命名空间中,即使它们在不同的文件夹中。
添加 using 指令,以便我们可以使用那些命名空间中的类:
using GalleryApp.Models;
using Microsoft.Search.Interop;
using System.Data.OleDb;
向 QueryHelper 引用添加一个 private 字段:
ISearchQueryHelper queryHelper;
与之前的实现类似,我们将首先实现 Import 方法,因此添加一个名为 Import 的新方法,如下所示:
private partial async Task<string[]> Import()
{
var paths = new List<string>();
return paths.ToArray();
}
从用户那里请求权限以获取照片(以下代码块中突出显示):
private partial async Task<string[]> Import()
{
var paths = new List<string>();
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
}
return paths.ToArray();
}
现在,使用QueryHelper获取所有图像路径:
private partial async Task<string[]> Import()
{
var paths = new List<string>();
var status = await AppPermissions.
CheckAndRequestRequiredPermission();
if (status == PermissionStatus.Granted)
{
string sqlQuery = queryHelper.GenerateSQLFromUserQuery(" ");
using OleDbConnection conn = new(queryHelper.
ConnectionString);
conn.Open();
using OleDbCommand command = new(sqlQuery, conn);
using OleDbDataReader WDSResults = command.ExecuteReader();
while (WDSResults.Read())
{
var itemUrl = WDSResults.GetString(0);
paths.Add(itemUrl);
}
}
return paths.ToArray();
}
在这里,使用QueryHelper创建一个 SQL 查询,并使用OleDbConnection查询搜索索引以获取所有匹配的文件。
我们现在可以开始编辑Task<ObservableCollection<Photo>> Get(int start, int count, Quality quality = Quality.Low)方法。将以下声明添加到PhotoImporter类中:
public partial async Task<ObservableCollection<Photo>> Get(int start,
int count, Quality quality)
{
}
现在,我们将开始实现方法,设置文件模式和我们将要搜索的位置:
string[] patterns = { ".png", ".jpeg", ".jpg" };
string[] locations = {
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
Environment.GetFolderPath(Environment.SpecialFolder.
CommonPictures),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.
UserProfile),"OneDrive","Camera Roll")
};
这些数组定义了我们将会搜索的文件扩展名和文件夹,我们将从tlbimp-Windows.Search.Interop NuGet 包中创建QueryHelper,并使用这些数组来配置查询参数:
queryHelper = new CSearchManager().GetCatalog("SystemIndex").
GetQueryHelper();
queryHelper.QueryMaxResults = start + count;
queryHelper.QuerySelectColumns = "System.ItemPathDisplay";
queryHelper.QueryWhereRestrictions = "AND (";
foreach (var pattern in patterns)
queryHelper.QueryWhereRestrictions += " Contains(System.
FileExtension, '" + pattern + "') OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";
queryHelper.QueryWhereRestrictions += " AND (";
foreach (var location in locations)
queryHelper.QueryWhereRestrictions += " scope='" + location + "'
OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";
queryHelper.QuerySorting = "System.DateModified DESC";
将QueryMaxResults设置为只检索我们正在寻找的结果。然后,我们指定只返回数据列"System.ItemPathDisplay"。接下来,我们从我们的扩展名列表中设置QueryWhereRestrictions。注意在查询字符串中移除尾随的"OR"时使用了range运算符。我们使用相同的技巧将位置添加到QueryWhereRestrictions中。最后,我们设置排序顺序。
方法的其余部分将与之前平台的类似。如果导入成功,我们将继续处理这次加载图像中应该导入的照片。条件由start和count参数指定。使用以下代码列表来完成第一个Get方法的实现:
var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result?.Length == 0)
{
return photos;
}
Index startIndex = start;
Index endIndex = start + count;
if (endIndex.Value >= result.Length)
{
endIndex = result.Length;
}
if (startIndex.Value > endIndex.Value)
{
return photos;
}
foreach (var uri in result[startIndex..endIndex])
{
var path = new System.Uri(uri).AbsolutePath;
photos.Add(new()
{
Bytes = File.ReadAllBytes(path),
Filename = Path.GetFileName(path)
});
}
return photos;
让我们快速回顾一下前面的代码。第一步是调用Import方法并验证是否有照片要导入。如果没有,我们简单地返回一个空列表。如果有照片要导入,那么我们需要知道photos数组中的startIndex和endIndex以导入。startIndex和endIndex被调整以确保它们对于要导入的照片是有效的。然后,我们可以从照片数组中读取从startIndex到endIndex的图像,并返回每个条目的文件字节和文件名。
现在,我们将继续其他Task<ObservableCollection<Photo>> Get(List<string> filenames, Quality quality = Quality.Low)方法。将以下声明添加到PhotoImporter类中:
public partial async Task<ObservableCollection<Photo>>
Get(List<string> filenames, Quality quality)
{
}
现在,我们将开始实现方法,设置搜索参数:
queryHelper = new CSearchManager().GetCatalog("SystemIndex").
GetQueryHelper();
queryHelper.QuerySelectColumns = "System.ItemPathDisplay";
queryHelper.QueryWhereRestrictions = "AND (";
foreach (var filename in filenames)
queryHelper.QueryWhereRestrictions += " Contains(System.Filename,
'" + filename + "') OR";
queryHelper.QueryWhereRestrictions = queryHelper.
QueryWhereRestrictions[..²];
queryHelper.QueryWhereRestrictions += ")";
对于这个方法,我们只需要将所有文件名添加到QueryWhereRestrictions中。随后,调用Import方法,如果它返回结果,则使用foreach循环遍历所有照片,并检查每张照片是否在filenames参数中指定。如果照片在filenames参数中指定,则从路径读取照片,就像第一个Get方法中那样:
var photos = new ObservableCollection<Photo>();
var result = await Import();
if (result?.Length == 0)
{
return photos;
}
foreach (var uri in result)
{
var path = new System.Uri(uri).AbsolutePath;
var filename = Path.GetFileName(path);
if (filenames.Contains(filename))
{
photos.Add(new()
{
Bytes = File.ReadAllBytes(path),
Filename = filename
});
}
}
return photos;
照片导入器现在已经完成,我们准备编写应用程序的其余部分,这主要涉及添加在各个平台之间共享的代码。
编写应用初始化代码
我们现在已经编写了将用于获取数据的代码。让我们继续构建应用,从初始化应用的核心部分开始。
配置依赖注入
通过使用依赖注入作为模式,我们可以使我们的代码更干净、更易于测试。此应用将使用构造函数注入,这意味着一个类所拥有的所有依赖项都必须通过其构造函数传递。然后容器为您构建对象,因此您不必太关心依赖链。由于.NET MAUI 已经包含了依赖注入框架Microsoft.Extensions.DependencyInjection ,因此无需安装任何额外的内容。
对依赖注入感到困惑?
在第二章 ,构建我们的第一个.NET MAUI 应用 中查看配置依赖注入 部分,以获取有关依赖注入的更多详细信息。
虽然建议使用扩展方法来分组类型,但在此应用中要注册的类型很少,所以我们将在下一节中使用不同的方法。
使用依赖注入注册 PhotoImporter
让我们添加必要的代码来注册我们迄今为止创建的类型,如下所示:
在GalleryApp项目中,打开MauiProgram.cs。
对MauiProgram类进行以下更改(更改已突出显示):
using GalleryApp.Services;
using Microsoft.Extensions.Logging;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf",
"OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf",
"OpenSansSemibold");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<IPhotoImporter>(serviceProvider
=> new PhotoImporter());
return builder.Build();
}
}
.NET MAUI 的MauiAppBuilder类公开了Services属性,它是依赖注入容器。我们只需添加我们想要依赖注入了解的类型,容器就会为我们完成剩余的工作。顺便说一句,将构建器视为收集大量需要完成的信息的东西,然后构建我们需要的对象。它是一个非常有用的模式。
目前我们只使用构建器做一件事。稍后,我们将使用它来注册程序集中从我们的抽象ViewModel类和视图继承的任何类。容器现在已为我们准备好,以便我们可以请求这些类型。
创建外壳
此应用的主要导航将在屏幕底部显示标签。应用将有一个飞出菜单,包含两个选项——主页 和画廊 :
在项目中创建一个名为Views的新文件夹。
在Views文件夹中,使用MainView创建两个新文件,并命名为GalleryView。
从项目的根目录中删除MainPage.Xaml和MainPage.Xaml.cs文件,因为我们不再需要那些文件。
打开项目根目录中的AppShell.xaml文件。
使用ShellContent的ContentTemplate属性将两个视图添加到Shell对象中。使用DataTemplate标记扩展从依赖注入容器中加载视图:
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="GalleryApp.AppShell"
>
<ShellContent Title="Home" ContentTemplate="{DataTemplate views:MainView}" />
<ShellContent Title="Gallery" ContentTemplate="{DataTemplate views:GalleryView}" />
</Shell>
由于视图是通过DataTemplates加载的,它们必须与依赖注入进行注册。在MauiProgram.cs文件中,在IPhotoInmporter行之后添加突出显示的代码:
builder.Services.
AddSingleton<IPhotoImporter>(serviceProvider => new
PhotoImporter());
builder.Services.AddTransient<Views.MainView>();
builder.Services.AddTransient<Views.GalleryView>();
return builder.Build();
现在我们已经创建了一个外壳,在开始创建视图之前,让我们继续编写一些其他的基础代码。
创建基视图模型
在创建实际视图模型之前,我们将创建一个所有视图模型都可以继承的抽象基视图模型。这个基视图模型背后的想法是我们可以在其中编写通用代码。在这种情况下,我们将通过以下步骤实现 INotifyPropertyChanged 接口:
在 GalleryApp 项目中,创建一个名为 ViewModels 的文件夹。
将 CommunityToolkit.Mvvm 添加为 NuGet 引用;我们使用 CommunityToolkit.Mvvm 来实现 INotifyPropertyChanged 接口,就像在其他章节中做的那样。
创建一个新的抽象类名为 ViewModel:
namespace GalleryApp.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
public abstract partial class ViewModel: ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
private bool isBusy;
public bool IsNotBusy => !IsBusy;
abstract protected internal Task Initialize();
}
在此应用的 ViewModel 类中,我们为 Initialize 添加了一个抽象方法。每个 ViewModel 实现都将覆盖此方法并异步加载图像以供显示。IsBusy 和 NotIsBusy 属性用作标志,指示数据何时完成加载。
现在,我们有一个 ViewModel 基类,我们可以用于在此项目中稍后创建的所有 ViewModel 实例。
创建画廊视图
现在,我们将开始构建视图。我们将从画廊视图开始,该视图将作为网格显示照片。我们将从 GalleryViewModel 开始,然后创建 GalleryView。首先创建视图模型允许 Visual Studio 使用 GalleryViewModel 定义来检查 XAML 文件中的数据绑定语法。
创建 GalleryViewModel
GalleryViewModel 是负责获取数据和处理视图逻辑的类。由于照片将被异步添加到照片集合中,我们不想在调用 PhotoImporter 的 Get 方法后立即将 IsBusy 设置为 false。相反,我们首先等待 3 秒钟。然而,我们也会向集合添加一个事件监听器,以便我们可以监听变化。如果集合发生变化并且其中包含项目,我们将 IsBusy 设置为 false。在 ViewModels 文件夹中创建一个名为 GalleryViewModel 的类,并添加以下代码以实现此功能:
namespace GalleryApp.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
using GalleryApp.Models;
using GalleryApp.Services;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
public partial class GalleryViewModel : ViewModel
{
private readonly IPhotoImporter photoImporter;
[ObservableProperty]
public ObservableCollection<Photo> photos;
public GalleryViewModel(IPhotoImporter photoImporter) : base()
{
this.photoImporter = photoImporter;
}
override protected internal async Task Initialize()
{
IsBusy = true;
Photos = await photoImporter.Get(0, 20);
Photos.CollectionChanged += Photos_CollectionChanged;
await Task.Delay(3000);
IsBusy = false;
}
private void Photos_CollectionChanged(object sender, System.
Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null && e.NewItems.Count > 0)
{
IsBusy = false;
Photos.CollectionChanged -= Photos_CollectionChanged;
}
}
}
最后,在 MauiProgram 中使用依赖注入注册 GalleryViewModel:
builder.Services.AddSingleton<IphotoImporter>(serviceProvider
=> new PhotoImporter());
builder.Services.AddTransient<ViewModels.GalleryViewModel>();
builder.Services.AddTransient<Views.MainView>();
builder.Services.AddTransient<Views.GalleryView>();
return builder.Build();
现在,GalleryViewModel 已经准备好了,所以我们可以开始创建 GalleryView。
创建画廊视图
首先,我们将创建一个将 byte[] 转换为 Microsft.Maui.Controls.ImageSource 的转换器。在 GalleryApp 项目中,创建一个新的文件夹名为 Converters,并在文件夹内创建一个新的类名为 BytesToImageConverter:
namespace GalleryApp.Converters;
using System.Globalization;
internal class BytesToImageConverter : IValueConverter
{
public object Convert(object value, Type targetType, object
parameter, CultureInfo culture)
{
if (value != null)
{
var bytes = (byte[])value;
var stream = new MemoryStream(bytes);
return ImageSource.FromStream(() => stream);
}
return null;
}
public object ConvertBack(object value, Type targetType, object
parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
要使用转换器,我们需要将其添加为资源。我们将通过将其添加到 GalleryView 的 Resources 属性中的 Resource 字典 对象来完成此操作。
打开 GalleryView.xaml,并将以下突出显示的代码添加到视图中:
<ContentPage
x:Class="GalleryApp.Views.GalleryView"
Title="GalleryView">
<ContentPage.Resources>
<ResourceDictionary>
<converters:BytesToImageConverter x:Key="ToImage" />
</ResourceDictionary>
</ContentPage.Resources>
</ContentPage>
为了能够绑定到 ViewModel,我们将 BindingContext 设置为 GalleryViewModel。在 GalleryView.xaml.cs 中使用构造函数依赖注入创建 GalleryViewModel 的实例。
打开 GalleryView.xaml.cs,并将以下突出显示的代码添加到类中:
public GalleryView(GalleryViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
MainThread.InvokeOnMainThreadAsync(viewModel.Initialize);
GalleryViewModel. That instance is set as BindingContext for the page. This object will be used in the XAML bindings of the view. Finally, we initialize the view model asynchronously.
What we will show in this view is a grid with three columns. To build this with .NET MAUI, we will use the `CollectionView` control. To specify the layout that `CollectionView` should have, add a `GridItemsLayout`element to the `ItemsLayout` property of `CollectionView`. Follow these steps to build this view:
1. Navigate to `GalleryView.xaml`.
Import the namespaces for `GalleryApp.ViewModels` and `GalleryApp.Models` as `viewModels` and `models`, respectively:
```
`x:Class=" GalleryApp.Views.GalleryView"`
```cs
2. On `ContentPage`, set `x:DataType` to `viewModels:GalleryViewModel`. This makes the bindings compile, which will make our view faster to render:
```
`<CollectionView x:Name="Photos" ItemsSource="{Binding Photos}">
`<CollectionView.ItemsLayout>`
`<GridItemsLayout Orientation="Vertical" Span="3"
`HorizontalItemSpacing="0" />`
`<CollectionView.ItemsLayout>`
`<CollectionView.ItemTemplate>`
`<DataTemplate x:DataType="models:Photo">
`<Grid>`
`<Image Aspect="AspectFill" Source="{Binding Bytes,
`Converter={StaticResource ToImage}}" HeightRequest="120" />`
</Grid>
</DataTemplate>
`<CollectionView.ItemTemplate>`
`<CollectionView>`
```cs
Now, we can see the photos in the view. However, we will also need to create the content that will be shown when we don’t have any photos to show as they have not been loaded yet, or if there are no photos available. Add the following highlighted code to create a `DataTemplate` object to show when `CollectionView`doesn’t have any data:
`<CollectionView :Name="Photos" ItemsSource="{Binding Photos}"
`EmptyView="{Binding}">
…
<CollectionView.EmptyViewTemplate>
`
<Grid>
<ActivityIndicator IsVisible="{Binding IsBusy}" />
`<Label Text="No photos to import could be found"
IsVisible="{Binding IsNotBusy}" HorizontalOptions="Center"
VerticalOptions="Center" HorizontalTextAlignment="Center" />
<CollectionView.EmptyViewTemplate>
<CollectionView>
Now, we can run the app. The next step is to load more photos when a user reaches the end of the view.
Loading photos incrementally
To load more than the first 20 items, we will load photos incrementally so that when users scroll to the end of `CollectionView`, it will start to load more items. `CollectionView` has built-in support for loading data incrementally. Because we get an `ObservableCollection`object back from the photo importer and data is added asynchronously to it, we need to create an event listener to handle when items are added to the photo importer so that we can add it to the `ObservableCollection`instance that we bound to `CollectionView`. Create the event listener by navigating to `GalleryViewModel.cs` and adding the following code at the end of the class:
private int itemsAdded;
`private void Collection_CollectionChanged(object sender, System.
Collections.Specialized.NotifyCollectionChangedEventArgs args)
{
foreach (Photo photo in args.NewItems)
{
itemsAdded++;
Photos.Add(photo);
}
if (itemsAdded == 20)
{
var collection = (ObservableCollection<Photo>)sender;
collection.CollectionChanged -= Collection_CollectionChanged;
}
}
private int currentStartIndex = 0;
[RelayCommand]
public async Task LoadMore()
{
currentStartIndex += 20;
itemsAdded = 0;
var collection = await photoImporter.Get(currentStartIndex, 20);
collection.CollectionChanged += Collection_CollectionChanged;
}
The only thing we have left to do to get the incremental load to work is to bind `CollectionView` to the code we created in `ViewModel`. The following code will trigger the loading of more photos when the user has just five items left:
`<CollectionView x:Name="Photos" EmptyView="{Binding}"
ItemsSource="{Binding Photos}" RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
Now that we have a view that shows photos and loads them incrementally, we can make it possible to add photos as favorites.
Saving favorites
In `GalleryView`, we want to be able to select favorites that we can show in `MainView`. To do that, we need to store the photos that we have selected so that it remembers our selection. Create a new interface in the `GalleryApp` project named `ILocalStorage` in the `Services` folder:
`public interface ILocalStorage
{
void Store(string filename);
List<string> Get();
}
The easiest way to store/persist data in .NET MAUI is to use the built-in property store. `Preferences` is a static class in the `Microsoft.Maui.Storage` namespace. Follow these steps to use it:
1. Create a new class named `MauiLocalStorage` in the `Services` folder.
2. Implement the `ILocalStorage` interface:
```
`namespace GalleryApp.Services;`
`using System.Text.Json;`
`public class MauiLocalStorage : ILocalStorage`
{
`public const string FavoritePhotosKey = "FavoritePhotos";`
`public List<string> Get()`
{
`if (Preferences.ContainsKey(FavoritePhotosKey))`
{
`var filenames = Preferences.Get(FavoritePhotosKey,string.Empty);`
`return JsonSerializer.Deserialize<List<string>>(filenames);`
}
`return new List<string>();`
}
`public void Store(string filename)`
{
`var filenames = Get();`
`filenames.Add(filename);`
`var json = JsonSerializer.Serialize(filenames);`
`Preferences.Set(FavoritePhotosKey, json);`
}
}
```cs
To be able to use `ILocalStorage` with constructor injection, we need to register it with the container. Navigate to the `MauiProgram` class and add the following highlighted code:
`builder.Services.AddSingleton(serviceProvider => new
PhotoImporter());
`builder.Services.AddTransient(ServiceProvider => new
MauiLocalStorage());
builder.Services.AddTransient<ViewModels.MainViewModel>();
Now, we are ready to use the local storage.
Navigate to the `GalleryViewModel` class, add the `ILocalStorage`interface to the constructor, and assign it to a field:
private readonly IPhotoImporter photoImporter;
private readonly ILocalStorage localStorage;
public GalleryViewModel(IPhotoImporter photoImporter, ILocalStorage
`localStorage)
{
this.photoImporter = photoImporter;
this.localStorage = localStorage;
}
The next step is to create a command that we can bind to from the view when we select photos. The command will monitor which photos we have selected and notify other views that we have added favorite photos. We will use `WeakReferenceManager` from `CommunityToolkit` to send messages from `GalleryViewModel` to `MainViewModel`.
Follow these steps to implement the `GalleryViewModel` side:
1. Create a new class in the `Services` folder named `Messages`:
```
`namespace GalleryApp.Services;`
`internal static class Messages`
{
public const string FavoritesAddedMessage =
nameof(FavoritesAddedMessage);
}
```cs
This is used to define the message type we are sending to `MainViewModel`.
2. Navigate to `GalleryViewModel`.
3. Create a new method named `AddFavorites` that is attributed to the `RelayCommand` type.
4. Add the following code:
```
[RelayCommand]
public void AddFavorites(List<Photo> photos)
{
foreach (var photo in photos)
{
localStorage.Store(photo.Filename);
}
WeakReferenceMessenger.Default.Send<string>(Messages.
FavoritesAddedMessage);
}
```cs
Now, we are ready to start working with the view. The first thing we will do is make it possible to select photos. Navigate to `GalleryView.xaml` and set the `SelectionMode` mode of `CollectionView` to `Multiple` to make it possible to select multiple items:
<CollectionView x:Name="Photos"
EmptyView="{Binding}" ItemsSource="{Binding Photos}"
SelectionMode="Multiple" RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMore}">
When a user selects a photo, we want it to be clear which photos have been selected. To achieve this, we will use `VisualStateManager`. We will do this by creating a style for `Grid` and setting `Opacity` to `0.5`, as in the following code. Add the code to `Resources` of the page:
<ContentPage.Resources>
<converters:BytesToImageConverter x:Key="ToImage" />
</ContentPage.Resources>
To save the selected photos, we will create a toolbar item that the user can tap:
1. Add `ToolbarItem` with the `Text` property set to `Select`.
2. Add an event handler named `SelectToolBarItem_Clicked`:
```
<ContentPage.ToolbarItems>
<ToolbarItem Text="Select" Clicked="SelectToolBarItem_
Clicked" />
</ContentPage.ToolbarItems>
```cs
3. Navigate to the code behind the `GalleryView.xaml.cs` file.
4. Add the following `using` statements:
```
using GalleryApp.Models;
using GalleryApp.ViewModels;
```cs
5. Create an event handler named `SelectToolBarItem_Clicked`:
```
private void SelectToolBarItem_Clicked(object sender, EventArgs e)
{
if (!Photos.SelectedItems.Any())
{
DisplayAlert("No photos", "No photos selected", "OK");
return;
}
var viewModel = (GalleryViewModel)BindingContext;
viewModel.AddFavoritesCommand.Execute(Photos.
SelectedItems.Select(x =>(Photo)x).ToList());
DisplayAlert("Added", "Selected photos have been added to
favorites", "OK");
}
```cs
Now that we are done with `GalleryView`, we will continue with the main view, which will show the latest photos and the favorite photos in two carousels.
Creating the carousels for MainView
The last view in this app is `MainView`, which is the view that is visible when users start the app. This view will show two carousel views—one with recent photos and one with favorite photos.
Creating the view model for MainView
We will start by creating `ViewModel` that we will use for the view. In the `ViewModel` folder, create a new class named `MainViewModel`:
namespace GalleryApp.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using GalleryApp.Models;
using GalleryApp.Services;
using System.Collections.ObjectModel;
public partial class MainViewModel : ViewModel
{
private readonly IPhotoImporter photoImporter;
private readonly ILocalStorage localStorage;
[ObservableProperty]
private ObservableCollection recent;
[ObservableProperty]
private ObservableCollection favorites;
public MainViewModel(IPhotoImporter photoImporter, ILocalStorage
localStorage)
{
this.photoImporter = photoImporter;
this.localStorage = localStorage;
}
override protected internal async Task Initialize()
{
var photos = await photoImporter.Get(0, 20, Quality.Low);
Recent = photos;
await LoadFavorites();
WeakReferenceMessenger.Default.Register(this, async
(sender, message) => {
if( message == Messages.FavoritesAddedMessage )
{
await MainThread.InvokeOnMainThreadAsync(LoadFavorites);
}
});
}
private async Task LoadFavorites()
{
var filenames = localStorage.Get();
var favorites = await photoImporter.Get(filenames, Quality.Low);
Favorites = favorites;
}
}
In the preceding code, the `Initialize` method is used to register a callback with `Weak` **ReferenceManager**. This callback invokes the `LoadFavorites` method if the message sent was `Message.FavoritesAddedMessage`. Recall that `Messages.Favorites` **AddedMessage** is sent from `GalleryViewModel` after selecting new photos.
In the `LoadFavorites` method, the favorites are loaded from the storage provider instance in `localStorage`. Then, the photos from the favorites are imported using the `photoImporter` instance.
We need to add the view model to dependency injection so that we can use it in the view. Open `MauiProgram` and add the highlighted code:
builder.Services.AddTransient(ServiceProvider
=> new MauiLocalStorage());
builder.Services.AddTransient<ViewModels.MainViewModel>();
builder.Services.AddTransient<ViewModels.GalleryViewModel>();
Now that we have created `MainViewModel`, we will continue with the latest photos.
Showing the latest photos
We are now ready to set up the carousel views. We have already created the view model, so we can use the view model to populate the view with content.
Let’s look at the steps to create the view:
1. In the constructor of the code, behind the `MainView.xaml.cs` file, set `ViewModel` to `BindingContext`:
```
public MainView(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
MainThread.InvokeOnMainThreadAsync(viewModel.Initialize);
}
```cs
2. Navigate to `MainView.xaml`.
3. Add the following code:
```
<ContentPage
x:Class="GalleryApp.Views.MainView"
x:DataType="viewModels:MainViewModel"
Title="My Photos">
<ContentPage.Resources>
<ResourceDictionary>
<converters:BytesToImageConverter x:Key="ToImage" />
</ResourceDictionary>
</ContentPage.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="50" />
<RowDefinition Height="*" />
<RowDefinition Height="20" />
</Grid.RowDefinitions>
<CarouselView ItemsSource="{Binding Recent}"
PeekAreaInsets="40,0,40,0" >
<CarouselView.ItemsLayout>
<LinearItemsLayout Orientation="Horizontal" SnapPointsAlignment="Start"
SnapPointsType="Mandatory" />
</CarouselView.ItemsLayout>
<CarouselView.ItemTemplate>
<DataTemplate x:DataType="models:Photo">
<Image Source="{Binding Bytes,
Converter={StaticResource ToImage}}" Aspect="AspectFill" />
</DataTemplate>
</CarouselView.ItemTemplate>
</CarouselView>
</Grid>
</ContentPage>
```cs
The `CarouselView` control is used to present data to the user in a scrollable layout, where the user can swipe to move through the collection of items. It is very similar to `CollectionView`; however, the uses of the two controls are different. You would use `CollectionView` when you want to display a list of items with an indeterminate length, and `CarouselView` is used to highlight items from a list of items with a limited length. Since `CarouselView` shares implementations with the `CollectionView` control, it uses the familiar `ItemTemplate` property to customize how each item is displayed. It adds an `ItemsLayout` property to define how the collection of items is displayed. `CarouselView` can use either a `Horizontal` or `Vertical` layout direction, with `Horizontal` being the default.
In `MainView`, `CarouselView` is used to display the `Recent` photos from `MainViewModel`. `ItemsLayout` is customized to set the scrolling behavior so that items will snap into view using the start, or left edge of the image. The `SnapPointType` property set to `Mandatory` makes sure that `CarouselView` snaps the image into place after scrolling, which would ensure a single image is always in view.
`ItemsTemplate` is used to display an image that is data-bound to each photo and displays the image from the bytes in the `Photo` model. `BytesToImageConverter` converts the byte array from the `Photo` model into `ImageSource` that can be displayed by the `Image` control. The `Image` control has the `Aspect` property set to `AspectFill`, allowing the image control to resize the image, maintaining the aspect ratio of the source image to fill the available visible space.
Now that we have shown the latest photos in a carousel, the next (and the last) step is to show the favorite photos in another carousel.
Showing the favorite photos
The last thing we will do in this app is add a carousel to show favorite photos. Add the following highlighted code inside `Grid`, after the first `CarouselView`, as shown in the following code snippet:
<!—Code omitted for brevity -->
<!—Code omitted for brevity -->
<Label Grid.Row="1" Margin="10" Text="Favorites" FontSize="Subtitle"
FontAttributes="Bold" />
<CarouselView Grid.Row="2" ItemsSource="{Binding Favorites}"
PeekAreaInsets="0,0,40,0" IndicatorView="Indicator">
<CarouselView.ItemsLayout>
<LinearItemsLayout Orientation="Horizontal"
SnapPointsAlignment="Start" SnapPointsType="MandatorySingle" />
</CarouselView.ItemsLayout>
<CarouselView.EmptyViewTemplate>
</CarouselView.EmptyViewTemplate>
<CarouselView.ItemTemplate>
<Border Grid.RowSpan="2" StrokeShape="RoundRectangle
15,15,15,15" Padding="0" Margin="0,0,0,0" BackgroundColor="#667788" >
<Image Source="{Binding Bytes, Converter={StaticResource
ToImage}}" Aspect="AspectFill" />
</CarouselView.ItemTemplate>
For the `Favorites` photos, again, `CarouselView` is used with a few changes from `CarouselView` displaying the `Recent` photos. The most visible change is that the `ItemsLayout` property is now using `MandatorySingle` for the value of `SnapPointsType`. This forces a behavior that only allows the user to swipe one image at a time, snapping each image into view.
The `ItemTemplate` property has also been changed to add a rounded border around each image, with a background color.
New to this `CarouselView` is the `EmptyViewTemplate` property. This is used to display the text `"No favorites selected"` when the `Favorites` property is empty.
Finally, `IndicatorView` was added to provide the user with a visual cue of how many items are in `CarouselView` and which item is currently displayed. `CarouselView` is connected to `IndicatorView` by the `IndicatorView` property of `CarouselView`. The `IndicatoryView` property is set to the `x:Name` property of `IndicatorView`. The `IndicatorView` displays on the page as a series of horizontal light gray dots, with the dot representing the current image in red.
That is all—now, we can run the app and see both the most recent photos and the photos that have been marked as favorites.
Summary
In this chapter, we focused on photos. We learned how to import photos from the platform-specific photo galleries and how we can display them as a grid using `CollectionView` and in carousels using `CarouselView`. This makes it possible for us to build other apps and provides multiple options for presenting data to users, as we can now pick the best method for the situation.
Additionally, we learned about permissions and how to check and request permission to use protected resources in our app.
If you are interested in extending the app even further, try creating a page to view the details of the photo, or to view the photo in full screen by tapping on the photo.
In the next chapter, we will build an app using location services and look at how to visualize location data on a map.
第七章:使用 GPS 和地图构建位置跟踪应用
在本章中,我们将创建一个位置跟踪应用,该应用将保存用户的地理位置并以热图的形式显示。我们将学习如何在 iOS、macOS 和 Android 设备上后台运行任务。我们将扩展 .NET MAUI 的 Map 控件,以便直接在地图中显示保存的地理位置。
本章将涵盖以下主题:
让我们开始吧!
技术要求
要完成此项目,您需要安装 Visual Studio for Mac 或 Windows,以及 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章 ,.NET MAUI 简介 。如果您使用 Visual Studio for Windows 构建 iOS 应用,您必须连接一台 Mac。如果您根本无法访问 Mac,您只需完成此项目的 Android 部分。
您可以在本章中找到代码的完整源代码,链接为 github.com/PacktPublishing/MAUI-Projects-3rd-Edition 。
Windows 用户的重要信息
在撰写本文时,.NET MAUI 在 Windows 平台上没有 Map 控件。这是由于底层 WinUI 平台上缺少 Map 控件。有关 Windows 上 Map 支持的最新信息,请访问 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/map 的 Map 文档。
项目概述
通过添加地图和位置服务,许多应用可以变得更加丰富。在本项目中,我们将构建一个名为 MeTracker 的位置跟踪应用。此应用将跟踪用户的地理位置并将其保存到 SQLite 数据库中,以便我们可以以热图的形式可视化结果。为了构建此应用,我们将学习如何在 iOS、macOS 和 Android 上设置后台进程。幸运的是,iOS 和 macOS 的实现是相同的;然而,Android 的实现非常不同。对于地图,我们将使用 .NET MAUI 的 Maps 组件并扩展其功能以构建热图。
由于 Windows 平台上缺少 Map 支持,以及为了增加一些多样性,本章将使用 Visual Studio for Mac 的截图和参考。如果您没有 Mac,不要担心;您仍然可以在 Windows 开发机器上完成 Android 项目的开发。如果您需要帮助,可以查看一些早期章节中的等效步骤。
此项目的预计构建时间为 180 分钟。
构建 MeTracker 应用
是时候开始构建应用了。使用以下步骤从模板创建项目:
打开 Visual Studio for Mac 并点击 新建 :
图 7.1 – Visual Studio for Mac 启动屏幕
在 选择新项目的模板 对话框中,使用位于 多平台 | 应用 下的 .NET MAUI App 模板;然后,点击 继续 :
图 7.2 – 新项目
在 配置你的新 .NET MAUI 应用 对话框中,确保已选择 .NET 7.0 目标框架,然后点击 继续 :
图 7.3 – 选择目标框架
在 MeTracker 中,然后点击 创建 :
图 7.4 – 命名新应用
如果你现在运行应用程序,你应该会看到以下类似的内容:
图 7.5 – MeTracker 应用在 macOS 上
现在我们已经从一个模板中创建了一个项目,是时候开始编码了!
创建一个用于保存用户位置的仓库
我们首先要做的是创建一个仓库,我们可以用它来保存用户的地理位置。
为位置数据创建模型
在我们创建仓库之前,我们将创建一个表示用户位置的模型类。按照以下步骤进行操作:
创建一个用于所有模型的 Models 文件夹。
在 Models 文件夹中创建一个 Location 类,并添加 Id、Latitude 和 Longitude 属性。
创建两个构造函数 – 一个为空,另一个接受 latitude 和 longitude 作为参数。使用以下代码进行操作:
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; }
}
现在我们已经创建了一个模型,我们可以开始创建仓库。
创建仓库
首先,我们将为仓库创建一个接口。按照以下步骤进行操作:
创建一个名为 Repositories 的新文件夹。
在我们的新文件夹中,创建一个名为 ILocationRepository 的接口。
在我们为接口创建的新文件中编写以下代码:
using MeTracker.Models;
using System;
using System.Threading.Tasks;
namespace MeTracker.Repositories;
public interface ILocationRepository
{
Task SaveAsync(Models.Location location);
}
现在我们有了接口,我们需要创建它的实现。按照以下步骤进行操作:
在 Repositories 文件夹中创建一个新的 LocationRepository 类。
实现 ILocationRepository 接口,并在 SaveAsync 方法中添加 async 关键字,使用以下代码:
using System;
using System.Threading.Tasks;
using MeTracker.Models;
namespace MeTracker.Repositories;
public class LocationRepository : ILocationRepository
{
public async Task SaveAsync(Models.Location location)
{
}
}
关于 Async 后缀的说明
你会在本书的许多章节中看到方法后使用 Async 作为后缀。在所有异步方法上附加 Async 后缀是 .NET 的一个约定。我们如何知道接口中的方法是否是异步的,因为我们看不到 async 关键字?它很可能会返回一个 Task 或 ValueTask 对象。在某些情况下,异步方法将返回 void;然而,这并不被看好,正如 Stephen Cleary 在他的文章 msdn.microsoft.com/en-us/magazine/jj991977.aspx 中解释的那样,所以你不会在本书中看到它的使用。
为了存储数据,我们将使用 SQLite 数据库和名为对象关系映射器 (ORM )的 SQLite-net,这样我们就可以针对领域模型编写代码,而不是使用 SQL 对数据库进行操作。这是一个由 Frank A. Krueger 创建的开源库。让我们通过以下步骤来设置它:
通过在解决方案资源管理器 中的Dependencies节点上右键单击来添加对sqlite-net-pcl的引用:
图 7.6 – 添加 NuGet 包
从上下文菜单中选择管理 NuGet 包… 以打开NuGet 包 窗口。
如下所示,在搜索框中检查sqlite-net-pcl:
图 7.7 – 添加 sqlite-net-pcl 包
最后,勾选sqlite-net-pcl旁边的复选框,然后点击添加包 。
前往Location模型类,并将PrimaryKeyAttribute和Auto IncrementAttribute 属性添加到Id属性。当我们添加这些属性时,Id属性将成为数据库中的主键,并且将自动为其创建一个值。现在Location类应该看起来如下所示:
using SQLite;
namespace MeTracker.Models;
public class Location
{
public Location() { }
public Location(double latitude, double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
[PrimaryKey]
[AutoIncrement]
public int Id { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
}
在LocationRepository类中编写以下代码以连接到 SQLite 数据库。使用if语句检查我们是否已经创建了连接。如果我们已经有了,我们不会创建一个新的;相反,我们将使用我们已创建的连接:
private SQLiteAsyncConnection connection;
private async Task CreateConnectionAsync()
{
if (connection != null)
{
return;
}
var databasePath = Path.Combine(Environment.GetFolderPath (Environment.SpecialFolder .MyDocuments), "Locations.db");
connection = new SQLiteAsyncConnection(databasePath);
await connection.CreateTableAsync<Location>();
}
现在,是时候实现SaveAsync方法了,它将接受一个location对象作为参数并将其存储在数据库中。
我们将在SaveAsync方法中使用CreateConnectionAsync方法来确保在我们尝试将数据保存到数据库时创建一个连接。当我们知道我们有一个活动的连接时,我们就可以直接使用InsertAsync方法,并将SaveAsync方法的location参数作为参数传递。
编辑LocationRepository类中的SaveAsync方法,使其看起来像这样:
public async Task SaveAsync(Models.Location location)
{
await CreateConnectionAsync();
await connection.InsertAsync(location);
}
目前这个仓库就到这里,接下来让我们继续到位置跟踪服务。
创建位置跟踪服务
要跟踪用户的位置,我们需要根据平台编写代码。.NET MAUI 有获取用户位置的方法,但不能在后台使用。为了能够使用我们将为每个平台编写的代码,我们需要创建一个接口。对于ILocationRepository接口,只有一个实现将在两个平台(iOS 和 Android)上使用,而对于位置跟踪服务,我们将为每个平台提供一个实现。
按照以下步骤创建一个ILocationTrackingService接口:
创建一个名为Services的新文件夹。
在Services文件夹中创建一个新的ILocationTrackingService接口。
在接口中添加一个名为StartTracking的方法,如下面的代码片段所示:
public interface ILocationTrackingService
{
void StartTracking();
}
为了确保我们可以在为每个平台实现位置跟踪服务的同时运行和测试我们的应用程序,我们将使用部分类。类的主要部分将在项目的共享代码部分,而类的特定平台部分将在特定平台文件夹中。我们将在本章后面部分回到每个实现。
在 Services 文件夹中创建一个名为 LocationTrackingService 的类,如下所示:
public partial class LocationTrackingService : ILocationTrackingService
{
public void StartTracking()
{
StartTrackingInternal();
}
partial void StartTrackingInternal();
}
我们使用接口来抽象我们的实现。我们还使用部分类来抽象每个特定的实现,但提供基础实现,这样我们就不必立即为每个平台实现。然而,这两种方法(部分类和基类继承)并不能与相同的方法一起使用。
实现 StartTracking 接口方法需要一个 public 关键字,它看起来像这样:
public void StartTracking() {}
然后,将其设置为部分类,如下所示:
public partial void StartTracking() {}
编译器抱怨没有部分方法的初始定义——也就是说,没有实现的方法。
删除空定义,如下所示:
public partial void StartTracking();
编译器现在抱怨因为它有一个可访问性修饰符,public。
在这种情况下,根本无法让编译器满意。因此,为了避免这些问题,我们通过调用 StartTrackingInternal 部分方法来实现 StartTracking 接口方法。我们将在本章后面部分访问每个平台的 StartTrackingInternal 的实现;现在,即使我们没有实现 StartTrackingInternal,应用程序也应该可以编译和运行。
现在我们已经有了位置跟踪服务的接口和基础实现,我们可以将注意力转向应用程序逻辑和用户界面。
设置应用程序逻辑
现在我们已经创建了接口,我们需要跟踪用户的位置并将其保存在设备上本地。是时候编写一些代码,以便我们可以开始跟踪用户了。我们还没有任何跟踪用户位置的代码,但如果我们已经编写了启动跟踪过程的代码,这将更容易编写。
创建带有地图的视图
首先,我们将创建一个带有简单地图的视图,该地图以用户的位置为中心。让我们通过以下步骤来设置它:
在 Views 文件夹中创建一个名为 Views 的新文件夹。
在 Views 文件夹中,创建一个基于 XAML 的 ContentPage 模板,并将其命名为 MainView:
图 7.8 – 添加 .NET MAUI XAML ContentPage 组件
通过在 解决方案资源管理器 中的 Dependencies 节点右键单击来添加对 Microsoft.Maui.Controls.Maps 的引用:
图 7.9 – 添加 NuGet 包
从上下文菜单中选择 管理 NuGet 包… 以打开 NuGet 包 管理器 窗口。
在搜索框中键入 Microsoft.Maui.Controls.Maps,如下所示:
图 7.10 – 添加 .NET MAUI Maps 包
最后,勾选 Microsoft.Maui.Controls.Maps 旁边的复选框,然后单击 添加包 。
通过打开 MauiProgram.cs 文件并做出高亮更改来添加 Map 初始化代码:
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.UseMauiMaps();
return builder.Build();
使用以下高亮代码在 MainView 中添加 Microsoft.Maui.Controls.Maps 命名空间:
<ContentPage
_idTextAnchor605"/>om/winfx/2009/xaml"
xmlns:maps="clr-namespace:Microsoft.Maui.Controls.Maps;assembly=Microsoft.Maui.Controls.Maps"
x:Class="MeTracker.Views.MainView"
Title="MainView">
现在,我们可以在我们的视图中使用地图了。因为我们想让 Map 覆盖整个页面,所以我们可以将其添加到 ContentPage 的根目录。
将 Map 添加到 ContentPage 并为其命名,以便我们可以从代码隐藏文件中访问它。命名为 Map,如下面的代码片段所示:
<ContentPage
x:Class="MeTracker.Views.MainView"
Title="MainView">
<maps:Map x:Name="Map" />
</ContentPage>
在我们能够启动应用程序并首次看到 Map 控件之前,我们需要将外壳设置为使用我们新的 MainView 模板而不是默认的 MainPage 模板。但首先,我们将删除我们在启动项目时创建的 MainPage.xaml 和 MainPage.xaml.cs 文件,因为我们在这里不会使用它们:
由于我们将设置 MainView 模板为用户看到的第一个视图,因此请删除项目中的 MainPage.xaml 和 MainPage.xaml.cs 文件。
编辑 AppShell.xaml 文件,如下所示的高亮代码:
<Shell
x:Class="MeTracker.AppShell"
Shell.FlyoutBehavior="Disabled">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:MainView}"
Route="MainView" />
</Shell>
我们能否使用现有的 MainPage 模板呢?当然可以——对于编译器来说,XAML 文件的名字或位置并没有任何区别,但为了保持一致性,并且按照 .NET MAUI 的 MVVM 习惯,我们将我们的 页面 放在 Views 文件夹中,并在页面名称后缀加上 Views。
选择 Mac Catalyst 或 iOS 模拟器并运行应用程序将产生 图 7 .11 中所示的结果。Android 不会工作,直到我们完成下一节:
图 7.11 – 添加 Map 控件后运行应用程序
现在我们有一个带有 Map 控件的页面,我们需要确保我们已从用户那里获得使用位置信息的权限。
声明特定平台的定位权限
要使用 Map 控件,我们需要声明我们需要位置信息的权限。如果需要,Map 控件将进行运行时请求。iOS/Mac Catalyst 和 Android 各自有声明所需权限的方式。我们将从 iOS/Mac Catalyst 开始,之后我们将进行 Android。
通过双击将其打开到 属性列表编辑器 中,在 Platforms/iOS 文件夹中的 info.plist 文件中。向文件中添加两个新条目,如下一个屏幕截图所示的高亮部分:
图 7.12 – 编辑 iOS 的 info.plist 文件
在 Platforms/MacCatalyst 文件夹中的 info.plist 文件中做出相同的更改,如下所示:
图 7.13 – 编辑 Mac Catalyst 的 info.plist 文件
Windows 用户
要在 Windows 上编辑info.plist文件,您需要通过右键单击文件,选择打开方式… ,然后选择XML 编辑器 来在文本编辑器中打开文件。然后,添加下一代码片段中突出显示的条目。
使用属性列表编辑器 编辑info.plist文件会导致以下高亮显示的更改:
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Can we use your location at all times?</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Can we use your location when your app is being used?</string>
</dict>
</plist>
要在 Android 中后台跟踪用户的位置,我们需要声明五个权限,如下表所示:
ACCESS_COARSE_LOCATION
获取用户的大致位置
ACCESS_FINE_LOCATION
获取用户的确切位置
ACCESS_NETWORK_STATE
我们需要这个权限,因为 Android 中的位置服务使用网络信息来确定用户的位置
ACCESS_WIFI_STATE
我们需要这个权限,因为 Android 中的位置服务使用 Wi-Fi 网络的信息来确定用户的位置
RECEIVE_BOOT_COMPLETED
这样后台任务在设备重启后可以再次启动
以下步骤将声明我们应用所需的权限:
在Platforms/Android文件夹中打开MainActivity.cs文件。
在using声明块的末尾添加以下assembly属性,如下所示:
using Android.App;
using Android.Runtime;
[assembly: UsesPermission(Android.Manifest.Permission.AccessCoarseLocation)]
[assembly: UsesPermission(Android.Manifest.Permission.AccessFineLocation)]
[assembly: UsesPermission(Android.Manifest.Permission.AccessWifiState)]
[assembly: UsesPermission(Android.Manifest.Permission.ReceiveBootCompleted)]
namespace MeTracker;
注意,我们没有声明Android.Manifest.Permission.AccessNetworkState,因为它包含在.NET MAUI 模板中。
现在我们已经声明了我们所需的全部权限,我们可以在 Android 上启用地图服务。
Android 需要API 密钥 才能使Google Maps 与地图一起工作。有关如何获取 API 密钥的 Microsoft 文档可以在learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/map?view=net-maui-7.0#get-a-google-maps-api-key 找到。按照这些说明获取您的 Google Maps 密钥,然后按照以下步骤在应用中使用您的密钥来配置 Google Maps API 密钥:
通过右键单击文件并选择打开方式… ,然后选择XML (****文本) 编辑器 来打开位于Platforms/Android文件夹中的AndroidManifest.xml文件。
在应用程序元素下插入一个元数据元素,如下所示的高亮代码中所示,将"{YourKeyHere}"替换为从 Google 获得的密钥:
<application android:label="MeTracker.Android">
<meta-data android:name="com.google.android.geo.API_KEY" android:value="{YourKeyHere}" />
</application>
Android 和 iOS 的最近版本已经改变了权限的处理方式。在应用运行时,某些权限,如位置权限,如果没有用户的明确批准则不会授予。也有可能用户会拒绝权限。让我们在下一节中看看如何处理运行时权限请求。
在运行时请求位置权限
在我们可以使用用户的位置之前,我们需要从用户那里请求权限。.NET MAUI 有跨平台的权限 API,我们只需要一点代码来使处理请求更加优雅。要实现权限请求处理,请按照以下步骤操作:
在项目的根目录下创建一个名为AppPermissions的新类。
编辑新文件以看起来像以下内容:
namespace MeTracker;
internal partial class AppPermissions
{
internal partial class AppPermission : Permissions.LocationWhenInUse
{
}
public static async Task<PermissionStatus> CheckRequiredPermissionAsync() => await Permissions.CheckStatusAsync<AppPermission>();
public static async Task<PermissionStatus> CheckAndRequestRequiredPermissionAsync()
{
PermissionStatus status = await Permissions.CheckStatusAsync<AppPermission>();
if (status == PermissionStatus.Granted)
return status;
if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
{
// Prompt the user to turn on in settings
// On iOS once a permission has been denied it may not be requested again from the application
await App.Current.MainPage.DisplayAlert("Required App Permissions", "Please enable all permissions in Settings for this App, it is useless without them.", "Ok");
}
if (Permissions.ShouldShowRationale<AppPermission>())
{
// Prompt the user with additional information as to why the permission is needed
await App.Current.MainPage.DisplayAlert("Required App Permissions", "This is a location based app, without these permissions it is useless.", "Ok");
}
status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<AppPermission>);
return status;
}
}
这创建了一个名为AppPermission的类型,它从默认的.NET MAUI LocationWhenInUse权限类继承。
CheckRequiredPermission 方法用于确保在我们尝试任何可能会失败的操作之前,我们的应用程序拥有正确的权限。其实现是调用.NET MAUI 的CheckSyncStatus方法并使用我们的AppPermission类型。它返回一个PermissionStatus类型,这是一个枚举。我们主要对Denied和Granted值感兴趣。
CheckAndRequestRequiredPermission 方法处理从用户请求访问的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果我们处于 iOS 并且权限已被拒绝,则无法再次请求,因此您必须指导用户如何通过设置面板授予应用程序权限。Android 在请求行为中包括如果用户拒绝访问则骚扰用户的能力。此行为通过.NET MAUI 的ShouldShowRationale方法公开。对于不支持此行为的任何平台,它将返回false;在 Android 上,如果用户第一次拒绝访问,它将返回true,如果用户第二次拒绝,则返回false。最后,我们从用户那里请求对AppPermission类型的访问。同样,.NET MAUI 正在隐藏所有平台实现细节,使得检查和请求访问某些资源变得非常直接。
现在我们已经设置了AppPermissions类,我们可以使用它来请求用户的当前位置,并将地图中心定位在该位置。
在当前用户位置上居中地图
我们将在MainView.xaml.cs的构造函数中使地图以用户的位置为中心。因为我们想异步获取用户的位置,并且这需要在主线程上执行,所以我们将使用MainThread.BeginInvokeOnMainThread在主线程上运行一个匿名方法。一旦我们有了位置,我们就可以使用Map的MoveToRegion方法。我们可以通过以下步骤来设置它:
打开MainView.xaml.cs。
将以下代码片段中突出显示的代码添加到MainView.xaml.cs类的构造函数中:
public MainView ()
{
InitializeComponent();
MainThread.BeginInvokeOnMainThread(async () =>
{
var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync();
if (status == PermissionStatus.Granted)
{
var location = await Geolocation.GetLastKnownLocationAsync();
if (location == null)
{
location = await Geolocation.GetLocationAsync();
}
Map.MoveToRegion(MapSpan.FromCenterAndRadius(
location,
Distance.FromKilometers(50)));
}
});
}
如果现在运行应用程序,它应该看起来像以下内容:
图 7.14 – 以用户位置为中心的地图
现在我们已经有了显示我们当前位置的地图,让我们开始构建应用程序其余部分的逻辑,从我们的 ViewModel 类开始。
创建 ViewModel 类
在我们创建实际的 ViewModel 类之前,我们将创建一个所有视图模型都可以继承的抽象基视图模型。这个基视图模型背后的想法是我们可以在其中编写通用代码。在这种情况下,我们将通过使用 CommunityToolkit.Mvvm NuGet 包来实现 INotifyPropertyChanged 接口。要添加包,请按照以下步骤操作:
通过在 Solution Explorer 中的 Dependencies 节点右键单击添加对 CommunityToolkit.Mvvm 的引用:
图 7.15 – 添加 NuGet 包
从上下文菜单中选择 Manage NuGet Packages… 以打开 NuGet package manager 窗口。
在搜索框中输入 CommunityToolkit.Mvvm,如下所示:
图 7.16 – 添加 CommunityToolkit.Mvvm 包
最后,勾选 CommunityToolkit.Mvvm 旁边的复选框,然后单击 Add Package 。
现在,我们可以通过以下步骤创建一个 ViewModel 类:
在项目中创建一个名为 ViewModels 的文件夹。
创建一个名为 ViewModel 的新类。
修改模板代码以匹配以下内容:
using CommunityToolkit.Mvvm.ComponentModel;
namespace MeTracker.ViewModels;
public partial class ViewModel : ObservableObject
{
}
下一步是创建一个实际使用 ViewModel 作为基类的视图模型。让我们通过以下步骤来设置它:
在 ViewModels 文件夹中创建一个新的 MainViewModel 类。
使 MainViewModel 类继承 ViewModel。
添加一个只读字段,类型为 ILocationTrackingService,命名为 locationTrackingService。
添加一个只读字段,类型为 ILocationRepository,命名为 locationRepository。
创建一个带有 ILocationTrackingService 和 ILocationRepository 作为参数的构造函数。
使用参数的值设置我们在 步骤 3 和 步骤 4 中创建的字段的值,如下面的代码片段所示:
public class MainViewModel : ViewModel
{
private readonly ILocationRepository locationRepository;
private readonly ILocationTrackingService locationTrackingService;
public MainViewModel(ILocationTrackingService locationTrackingService,
ILocationRepository locationRepository)
{
this.locationTrackingService = locationTrackingService;
this.locationRepository = locationRepository;
}
}
要使应用程序开始跟踪用户的地理位置,我们需要在主线程上运行启动跟踪过程的代码。按照以下步骤操作:
在新创建的 MainViewModel 类的构造函数中,使用 MainThread.BeginInvokeOnMainThread 向主线程添加调用。
在传递给 BeginInvokeOnMainThread 方法的操作中调用 locationService.StartTracking。这在上面的高亮代码中显示:
public MainViewModel(ILocationTrackingService locationTrackingService, ILocationRepository locationRepository)
{
this.locationTrackingService = locationTrackingService;
this.locationRepository = locationRepository;
MainThread.BeginInvokeOnMainThread(() =>
{
locationTrackingService.StartTracking();
});
}
最后,我们需要将 MainViewModel 类注入到 MainView 的构造函数中,并将 MainViewModel 实例分配给视图的绑定上下文。这将允许我们完成的数据绑定被处理,并且 MainViewModel 的属性将被绑定到用户界面中的控件。按照以下步骤操作:
前往 Views/MainView.xaml.cs 文件的构造函数。
将 MainViewModel 作为构造函数的参数并命名为 viewModel。
将 BindingContext 设置为 MainViewModel 的实例,如下代码片段所示:
public MainView(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
MainThread.BeginInvokeOnMainThread(async () =>
{
var location = await Geolocation.GetLastKnownLocationAsync();
if(location == null)
{
location = await Geolocation.GetLocationAsync();
}
Map.MoveToRegion(MapSpan.FromCenterAndRadius(
location, Distance.FromKilometers(5)));
});
}
为了让 .NET MAUI 定位到我们在此部分已实现的类,我们需要将它们添加到 依赖注入 (DI ) 容器中。
将类添加到 DI 容器中
由于我们向视图的构造函数中添加了一个参数,.NET MAUI View 框架将无法自动构建视图。因此,我们需要将 MainView、MainViewModel、LocationTrackingService 和 LocationRepository 实例添加到 DI 容器中。为此,请按照以下步骤操作:
打开 MauiProgram.cs 文件。
将以下突出显示的行添加到 CreateMauiApp 方法中:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.UseMauiMaps();
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<Services.ILocationTrackingService, Services.LocationTrackingService>();
builder.Services.AddSingleton<Repositories.ILocationRepository, Repositories.LocationRepository>();
builder.Services.AddTransient(typeof(ViewModels.MainViewModel));
builder.Services.AddTransient(typeof(Views.MainView));
return builder.Build();
}
现在,我们再次能够运行应用程序。我们没有更改任何接口,所以它应该看起来和表现与之前相同。如果它没有,请仔细回顾前面的部分,确保所有代码都是正确的。
让我们添加一些代码,以便我们可以使用后台位置跟踪跟踪用户的位置随时间的变化。
iOS 和 Mac Catalyst 的后台位置跟踪
位置跟踪的代码是我们需要为每个平台编写的。对于 iOS 和 Mac Catalyst,我们将使用 CoreLocation 命名空间中的 CLLocationManager。
启用后台位置更新
当我们想在 iOS 或 Mac Catalyst 应用程序中执行后台任务时,我们需要在 info.plist 文件中声明我们想要执行的操作。以下步骤展示了我们如何进行这一操作:
打开 info.plist;您需要为 Platforms/iOS/info.plist 和 Platforms/MacCatalyst/info.plist 都这样做。
使用 属性列表编辑器 通过从下拉菜单中选择 Required background modes 并选择 App registers for location updates ,按照以下截图所示添加以下突出显示的条目:
图 7.17 – 添加位置更新
我们也可以使用 XML 编辑器直接在 info.plist 文件中启用后台模式。在这种情况下,我们将添加以下 XML:
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
订阅位置更新
现在我们已经为位置跟踪准备好了 info.plist 文件,是时候编写实际跟踪用户位置的代码了。如果我们没有将 CLLocationManager 设置为不暂停位置更新,iOS 或 Mac Catalyst 在位置数据不太可能改变时可以自动暂停位置更新。在这个应用程序中,我们不希望发生这种情况,因为我们想多次保存位置,以便我们可以确定用户是否频繁访问特定位置。
如果您还记得,我们之前已经将服务定义为部分类和部分方法;现在,我们将通过实现服务的平台特定部分来完成服务。让我们设置如下:
在 Platforms/iOS 文件夹中创建一个名为 Services 的新文件夹。
在 Services 文件夹中创建一个名为 LocationTrackingService 的新类。
修改类以匹配以下内容:
namespace MeTracker.Services;
public partial class LocationTrackingService : ILocationTrackingService
{
partial void StartTrackingInternal()
{
}
}
为 CLLocationManager 添加一个私有字段。
在 StartTrackingInternal 方法中创建一个 CLLocationManager 的实例。
将 PausesLocationUpdatesAutomatically 设置为 false。
在我们可以开始跟踪用户的位置之前,我们需要设置我们从 CLLocationManager 收到的数据的精度。我们还将添加一个事件处理程序来处理位置更新。
将 DesiredAccuracy 设置为 CLLocation.AccuracyBestForNavigation。在应用程序在后台运行时,DesiredAccuracy 需要设置为 AccuracyBest 或 AccuracyBestForNavigation 之一。
将 AllowBackgroundLocationUpdates 设置为 true(如以下代码片段所示),这样即使应用程序在后台运行,位置更新也会继续。
您的更改应如下所示:
CLLocation locationManager;
partial void StartTrackingInternal()
{
locationManager = new CLLocationManager
{
PausesLocationUpdatesAutomatically = false,
DesiredAccuracy = CLLocation.AccuracyBestForNavigation,
AllowsBackgroundLocationUpdates = true
};
// Add code here
}
下一步是请求用户允许跟踪他们的位置。我们将请求始终跟踪用户的位置,但用户可以选择只在他们使用应用程序时才给我们权限跟踪他们的位置。因为用户也有权拒绝我们跟踪他们的位置,所以在开始之前我们需要检查这一点。让我们设置它:
在 // Add code here 注释之后添加 LocationsUpdated 的事件处理程序。它应类似于以下片段中高亮的代码:
partial void StartTrackingInternal()
{
locationManager = new CLLocationManager
{
PausesLocationUpdatesAutomatically = false,
DesiredAccuracy = CLLocation.AccuracyBestForNavigation,
AllowsBackgroundLocationUpdates = true
};
// Add code here
locationManager.LocationsUpdated +=
async (object sender, CLLocationsUpdatedEventArgs e) =>
{
// Final block of code goes here
};
};
在事件处理程序之后,调用我们最近在 CLLocationManager 中创建的实例的 RequestAlwaysAuthorization 方法:
partial void StartTrackingInternal()
{
locationManager = new CLLocationManager
{
PausesLocationUpdatesAutomatically = false,
DesiredAccuracy = CLLocation.AccurracyBestForNavigation,
AllowsBackgroundLocationUpdates = true
};
// Add code here
locationManager.LocationsUpdated +=
async (object sender, CLLocationsUpdatedEventArgs e) =>
{
// Final block of code goes here
};
locationManager.RequestAlwaysAuthorization();
};
然后,调用 locationManager 的 StartUpdatingLocation 方法:
partial void StartTrackingInternal()
{
locationManager = new CLLocationManager
{
PausesLocationUpdatesAutomatically = false,
DesiredAccuracy = CLLocation.AccurracyBestForNavigation,
AllowsBackgroundLocationUpdates = true
};
// Add code here
locationManager.LocationsUpdated +=
async (object sender, CLLocationsUpdatedEventArgs e) =>
{
// Final block of code goes here
};
locationManager.RequestAlwaysAuthorization();
locationManager.StartUpdatingLocation();
};
小贴士
精度越高,电池消耗就越高。如果我们只想跟踪用户去过哪里,而不关心一个地方有多受欢迎,我们也可以设置 AllowDeferredLocationUpdatesUntil。这样,我们可以指定用户必须移动一定距离后才会更新位置。我们还可以使用 timeout 参数指定我们希望位置更新的频率。要跟踪用户在一个地方停留了多久,最节能的解决方案是使用 CLLocationManager 的 StartMonitoringVisits 方法。
现在,是时候处理 LocationsUpdated 事件了。让我们按以下步骤进行:
添加一个名为 locationRepository 的私有字段,其类型为 ILocationRepository。
添加一个具有 ILocationRepository 作为参数的构造函数。将参数的值设置为 locationRepository 字段。您的类应类似于以下代码片段:
using CoreLocation;
using MeTracker.Repositories;
namespace MeTracker.Services;
public partial class LocationTrackingService : ILocationTrackingService
{
CLLocationManager locationManager;
ILocationRepository locationRepository;
public LocationTrackingService(ILocationRepository locationRepository)
{
this.locationRepository = locationRepository;
}
partial void StartTrackingInternal()
{
// Remainder of code omitted for brevity
}
读取 CLLocationsUpdatedEventArgs 的 Locations 属性的最新位置。
创建一个 MeTracker.Models.Location 的实例,并将最新位置的纬度和经度传递给它。
使用 ILocationRepository 的 SaveAsync 方法保存位置。
代码应放置在 // Final block of code goes here 注释之后。它应类似于以下片段中加粗的代码:
locationManager.LocationsUpdated +=
async (object sender, CLLocationsUpdatedEventArgs e) =>
{
// Final block of code goes here
var lastLocation = e.Locations.Last();
var newLocation = new Models.Location(lastLocation.Coordinate.Latitude, lastLocation.Coordinate.Longitude);
await locationRepository.SaveAsync(newLocation);
};
这样,我们就完成了 iOS 应用程序的跟踪部分。对于 Mac Catalyst 的实现是相同的;你可以重复本节中的步骤为 Mac Catalyst(但将文件创建为 Platforms/MacCatalyst/Services 而不是 Platforms/iOS/Services),或者将 Platforms/iOS/Services/LocationTrackingService.cs 文件复制到 Platforms/MacCatalyst/Services 文件夹中。
现在,我们将实现 Android 的后台跟踪,之后我们将可视化位置跟踪数据。
Android 后台位置跟踪
Android 实现后台更新的方式与我们用 iOS 实现的方式非常不同。在 Android 中,我们需要创建一个 JobService 类并对其进行调度。
创建后台作业
要在后台跟踪用户的位置,我们需要创建一个后台作业。后台作业由操作系统使用,允许开发者在应用不在前台或屏幕上可见时执行代码。按照以下步骤创建一个后台作业以捕获用户的位置:
在 Platforms/Android 文件夹中创建一个名为 Services 的新文件夹。
在 Platforms/Android 文件夹中创建一个名为 LocationJobService 的新类。
将类继承自 Android.App.Job.JobService 作为基类。
在文件顶部添加 using Android.App.Job 和 using Android.App.Job 声明。
实现 OnStartJob 和 OnStopJob 抽象方法,如下面的代码片段所示:
using Android.App;
using Android.App.Job;
namespace MeTracker.Platforms.Android.Services;
internal class LocationJobService : JobService
{
public override bool OnStartJob(JobParameters @params)
{
return true;
}
public override bool OnStopJob(JobParameters @params)
{
return true;
}
}
应用程序中的所有 Android 服务都需要添加到 AndroidManifest.xml 文件中。我们不必手动进行此操作;相反,我们可以在 LocationJobService 类中添加一个属性,然后它将在 AndroidManifest.xml 文件中生成。我们将使用 Name 和 Permission 属性来设置所需的信息,如下面的代码片段所示:
[Service(Name = "MeTracker.Platforms.Android.Services.LocationJobService", Permission = "android.permission.BIND_JOB_SERVICE")]
internal class LocationJobService : JobService
调度后台作业
当我们创建了一个作业后,我们需要对其进行调度。我们将从 Platforms/Android/LocationTrackingService 文件夹中进行操作。为了配置作业,我们将使用 JobInfo.Builder 类。
我们将使用 SetPersisted 方法来确保在重启后作业会再次启动。这就是为什么我们之前添加了 RECEIVE_BOOT_COMPLETED 权限。
要调度一个作业,至少需要一个约束。在这种情况下,我们将使用 SetOverrideDeadline。这将指定作业需要在指定时间(以毫秒为单位)过去之前运行。
SetRequiresDeviceIdle 方法可以用来确保作业仅在设备未被用户使用时运行。如果我们想确保在用户使用设备时不会减慢设备速度,我们可以将 true 传递给该方法。
SetRequiresBatteryNotLow 方法可以用来指定当电池电量低时不应运行作业。如果你没有在电池电量低时运行作业的合理理由,我们建议始终将其设置为 true。这是因为我们不希望我们的应用程序耗尽用户的电池电量。
因此,让我们实现 LocationTrackingService。按照以下步骤进行:
在 Platforms/Android/Services 文件夹中创建一个名为 LocationTrackingService 的新类。
修改类,使其看起来如下:
namespace MeTracker.Services;
public partial class LocationTrackingService : ILocationTrackingService
{
partial void StartTrackingInternal()
{
}
}
在 StartTrackingInternal 方法中,基于我们将指定的 ID(这里我们将使用 1)和组件名称(我们将从应用程序上下文和 Java 类中创建)创建一个 JobInfo.Builder 类。组件名称用于指定在作业期间将运行哪些代码。
使用 SetOverrideDeadline 方法并将 1000 传递给它,以确保作业在作业创建后 1 秒内运行。
使用 SetPersisted 方法并传递 true,使作业即使在设备重新启动后也能持续。
使用 SetRequiresDeviceIdle 方法并传递 false,这样即使用户正在使用设备,作业也会运行。
使用 SetRequiresBatteryLow 方法并传递 true,以确保我们不会耗尽用户的电池。此方法是在 Android API 级别 26 中添加的。
LocationTrackingService 的代码现在应该看起来像这样:
using Android.App.Job;
using Android.Content;
using MeTracker.Platforms.Android.Services;
namespace MeTracker.Services;
public partial class LocationTrackingService : ILocationTrackingService
{
partial void StartTrackingInternal()
{
var javaClass = Java.Lang.Class.FromType(typeof(LocationJobService));
var componentName = new ComponentName(global::Android.App.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();
}
}
StartTrackingInternal 方法中的最后一步是使用 JobScheduler 系统安排作业。JobScheduler 服务是一个 Android 系统服务。为了获取系统服务的实例,我们将使用应用程序上下文。按照以下步骤进行:
使用 GetSystemService 方法在 Application.Context 上获取 JobScheduler 服务。
将结果转换为 JobScheduler。
在 JobScheduler 类上使用 Schedule 方法并传递 JobInfo 对象来安排作业,如下面的代码片段所示:
var jobScheduler = (JobScheduler)global::Android.App.Application.Context.GetSystemService(Context.JobSchedulerService);
jobScheduler.Schedule(jobInfo);
现在作业已经安排好了,我们可以开始接收位置更新;让我们继续这个工作。
订阅位置更新
一旦我们安排了作业,我们可以编写代码来指定作业应该做什么——即跟踪用户的地理位置。为此,我们将使用 LocationManager,这是一个 SystemService 类。使用 LocationManager,我们可以请求单个位置更新或订阅位置更新。在这种情况下,我们想要订阅位置更新。
我们将首先创建 ILocationRepository 接口的一个实例。我们将使用它将位置保存到 SQLite 数据库中。让我们设置一下:
为 LocationJobService 创建一个构造函数。
为 ILocationRepository 接口创建一个名为 locationRepository 的 private 只读字段。
在构造函数中使用 Services.GetService<T> 创建 ILocationRepository 的实例,如下面的代码片段所示:
private ILocationRepository locationRepository;
public LocationJobService()
{
locationRepository = MauiApplication.Current.Services.GetService<ILocationRepository>();
}
在我们订阅位置更新之前,我们将添加一个监听器。为此,我们将使用 Android.Locations.ILocationListener 接口。
按照以下步骤进行:
将 Android.Locations.ILocationListener 接口添加到 LocationJobService。
将以下命名空间声明添加到文件顶部:
using Android.Content;
using Android.Locations;
using Android.OS;
using Android.Runtime;
using MeTracker.Repositories;
实现接口并移除所有 throw new NotImplemented Exception(); 实例。这是在您让 Visual Studio 生成接口实现时添加到方法中的。
方法实现应类似于以下代码片段:
public override bool OnStartJob(JobParameters @params)
{
return true;
}
public void OnLocationChanged(global::Android.Locations.Location location) { }
public override bool OnStopJob(JobParameters @params) => true;
public void OnStatusChanged(string provider, [GeneratedEnum] Availability status, Bundle extras) { }
public void OnProviderDisabled(string provider) { }
public void OliknProviderEnabled(string provider) { }
在 OnLocationChanged 方法中,将 Android.Locations.Location 对象映射到 Model.Location 对象。
使用 LocationRepository 类上的 SaveAsync 方法,如下代码片段所示:
public void OnLocationChanged(Android.Locations.Location location)
{
var newLocation = new Models.Location(location.Latitude, location.Longitude);
locationRepository.SaveAsync(newLocation);
}
现在我们已经创建了一个监听器,我们可以订阅位置更新。按照以下步骤进行:
创建一个名为 locationManager 的 LocationManager 类型的 static 字段。确保它具有与应用程序相同的生命周期。
在 Android 中,JobService 可能会在 MainView 显示之前启动,并且我们请求位置权限。为了避免因权限缺失而导致的任何错误,我们将首先检查权限。
public override bool OnStartJob(JobParameters @params)
{
PermissionStatus status = PermissionStatus.Unknown;
Task.Run(async ()=> status = await AppPermissions.CheckRequiredPermissionAsync()).Wait();
if (status == PermissionStatus.Granted)
{
}
}
我们在 Task.Run 实例中运行 CheckRequiredPermissionsAsync,因为它是一个 async 调用,我们不能将 async 添加到方法中,因为返回类型不兼容。对 Wait 的调用将 async 调用转换为同步调用。如果结果是 Granted,则我们可以继续。
前往 LocationJobService 中的 StartJob 方法。通过在 ApplicationContext 上调用 GetSystemService 获取 LocationManager。
要订阅位置更新,使用如下代码片段所示的 RequestLocationUpdates 方法:
public override bool OnStartJob(JobParameters @params)
{
PermissionStatus status = PermissionStatus.Unknown;
Task.Run(async ()=> status = await AppPermissions.CheckRequiredPermissionAsync()).Wait();
if (status == PermissionStatus.Granted)
{
locationManager = (LocationManager)ApplicationContext.GetSystemService (Context.LocationService);
locationManager.RequestLocationUpdates (LocationManager.GpsProvider, 1000L, 0.1f, this);
return true;
}
return false;
}
我们传递给 RequestLocationUpdates 方法的第一个参数确保我们从 GPS 获取位置。第二个参数确保位置更新之间至少有 1000 毫秒的间隔。第三个参数确保用户至少移动 0.1 米以获取位置更新。最后一个参数指定我们应使用哪个监听器。因为当前类实现了 Android.Locations.ILocationListener 接口,所以我们将传递 this。
现在我们已经从用户那里收集了位置数据并将其存储在我们的 SQLite 数据库中,我们现在可以在地图上显示这些数据。
创建热图
为了可视化我们收集的数据,我们将创建一个热图。我们将在地图上添加很多点,并根据用户在特定地点花费的时间长短使它们呈现不同的颜色。最受欢迎的地方将呈现暖色调,而最不受欢迎的地方将呈现冷色调。
在我们将点添加到地图之前,我们需要从存储库中获取所有位置。
向 LocationRepository 添加 GetAllAsync 方法
为了可视化数据,我们需要编写一些代码,以便可以从数据库中读取位置数据。让我们设置如下:
打开 ILocationRepository.cs 文件。
添加一个返回 Location 对象列表的 GetAllAsync 方法,如下代码所示:
Task<List<Models.Location>> GetAllAsync();
打开实现 ILocationRepository 的 LocationRepository.cs 文件。
实现新的 GetAllAsync 方法,并返回数据库中所有保存的位置,如下代码片段所示:
public async Task<List<Location>> GetAllAsync()
{
await CreateConnectionAsync();
var locations = await connection.Table<Location> ().ToListAsync();
return locations;
}
准备可视化数据
在我们可以在地图上可视化数据之前,我们需要准备数据。我们首先要做的是创建一个新的模型,我们可以用它来准备数据。让我们设置一下:
在 Models 文件夹中,创建一个名为 Point 的新类。
添加 Location、Count 和 Heat 属性,如下代码片段所示:
namespace MeTracker.Models{
public class Point
{
public Location Location { get; set; }
public int Count { get; set; } = 1;
public Color Heat { get; set; }
}
}
MainViewModel 将存储我们稍后找到的位置。让我们为存储点添加一个属性。
打开 MainViewModel 类。
添加一个名为 points 的 private 字段,其类型为 List<Point>。
将 ObservableProperty 属性添加到 points 字段,如下代码片段所示:
[ObservableProperty]
private List<Models.Point> points;
现在我们已经有了点的存储,我们必须添加一些代码,以便我们可以添加位置。我们将通过实现 MainViewModel 类的 LoadDataAsync 方法来完成此操作,并确保它在位置跟踪开始后立即在主线程上调用。
我们首先要做的是将保存的位置分组,以便所有在 200 米范围内的位置都将作为一个点处理。我们将跟踪在该点内记录位置的次数,以便我们可以决定点在地图上的颜色。让我们设置一下:
添加一个名为 LoadDataAsync 的 async 方法。此方法返回一个 Task 对象给 MainViewModel。
在调用 ILocationTrackingService 上的 StartTracking 方法之后,从构造函数中调用 LoadDataAsync 方法,如下代码片段所示:
public MainViewModel(ILocationTrackingService locationTrackingService, ILocationRepository locationRepository)
{
this.locationTrackingService = locationTrackingService;
this.locationRepository = locationRepository;
MainThread.BeginInvokeOnMainThread(async() =>
{
locationTrackingService.StartTracking();
await LoadDataAsync();
});
}
LoadDataAsync 方法的第一步是从 SQLite 数据库中读取所有跟踪的位置。当我们拥有所有位置后,我们将遍历它们并创建点。
要计算位置和点之间的距离,我们将使用 CalculateDistance 方法,如下代码片段所示:
private async Task LoadDataAsync()
{
var locations = await locationRepository.GetAll();
var pointList = new List<Models.Point>();
foreach (var location in locations)
{
//If no points exist, create a new one and 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 = Location.CalculateDistance(
new Location(point.Location.Latitude, point.Location.Longitude),
new 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 )表示,如下所述:
我们首先需要做的是找出用户访问最热门和最不热门地点的次数。让我们看一下:
首先,检查点的列表不为空。
获取点列表中 Count 属性的 Min 和 Max 值。
计算最小值和最大值之间的差异。
代码应该在LoadDataAsync方法底部的// Next section of code goes here注释之后添加,如下面的代码片段所示:
private async Task LoadDataAsync()
{
// The rest of the method has been omitted 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
}
现在,我们可以计算每个点的热值,如下所示:
遍历所有点。
应该在LoadDataAsync()方法底部的// Last section of code goes here注释之后添加以下代码(以下代码片段已突出显示):
private async Task LoadDataAsync()
{
// The rest of the method has been omitted 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项目设置位置跟踪所需做的全部工作。现在,让我们将注意力转向可视化我们接收到的数据。
添加数据可视化
在.NET MAUI 中,Map控件可以在地图上渲染额外的信息。这包括图钉和自定义形状,被称为MapElements。我们可以简单地添加存储在存储库中的每个位置作为图钉;然而,为了得到热图,我们想在地图上的每个位置添加一个彩色圆点,所以我们将为每个位置使用MapElements。
如果MapElements属性是BindableProperty,我们就可以使用转换器将MainViewModel的Points属性映射到地图的MapElements属性进行绑定。但是MapElements不是一个可绑定的属性,所以这不会那么简单。
让我们先创建一个自定义地图控件。
创建地图的自定义控件
为了在我们的地图上显示热图,我们将创建一个新的控件。由于Map是一个密封类,我们无法直接继承它;相反,我们将使用BindableProperty将Map控件封装在ContentView中,以便从ViewModel访问Points数据。
按照以下步骤创建自定义控件:
创建一个名为Controls的新文件夹。
创建一个名为CustomMap的新类。
将ContentView作为基类添加到新类中,如下面的代码片段所示:
namespace MeTracker.Controls;
public class CustomMap : ContentView
{
public CustomMap()
{
}
}
现在,我们需要将Map控件添加到自定义控件中。按照以下步骤添加Map控件:
从.NET MAUI 的Map控件派生出CustomMap控件,如下面的代码片段所示:
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using Map = Microsoft.Maui.Controls.Maps.Map;
namespace MeTracker.Controls;
public class CustomMap : Map
{
public CustomMap()
{
}
}
在构造函数中初始化地图,如下所示(新更改已突出显示):
public CustomMap()
{
IsScrollEnabled = true;
IsShowingUser = true;
}
如果我们想要将数据绑定到属性,我们需要创建一个BindableProperty类。这应该是类中的一个公共静态字段。我们还需要创建一个常规属性来保存值。属性的命名非常重要。BindableProperty的名称需要是{NameOfTheProperty}Property;例如,我们将在以下步骤中创建的BindableProperty的名称将是PointsProperty,因为属性的名称是Points。BindableProperty是通过BindableProperty类的静态Create方法创建的。这至少需要四个参数,如下所示:
该属性的 set 和 get 方法将调用基类中的方法来设置或从 BindableProperty 获取值:
创建一个名为 PointsProperty 的 BindableProperty,如下面的代码片段所示:
public static BindableProperty PointsProperty = BindableProperty.Create(nameof(Points), typeof(List<Models.Point>), typeof(CustomMap), new List<Models.Point>());
创建一个名为 Points 的 List<Models.Point> 类型的属性。记住将 GetValue 的结果转换为与属性相同的类型。我们需要这样做,因为 GetValue 将返回一个 type 对象作为值:
public List<Models.Point> Points
{
get => GetValue(PointsProperty) as List<Models.Point>;
set => SetValue(PointsProperty, value);
}
为了显示 Points,我们需要将它们转换为 MapElements。这是通过一个名为 PropertyChanged 的 BindingProperty 事件来完成的。每当 BindingProperty 发生变化时,都会触发 PropertyChanged。要添加事件并将 Points 转换为 MapElements,请将以下突出显示的代码添加到类中:
public readonly static BindableProperty PointsProperty = BindableProperty.Create(nameof(Points), typeof(List<Models.Point>), typeof(MapView), new List<Models.Point>(), propertyChanged: OnPointsChanged);
private static void OnPointsChanged(BindableObject bindable, object oldValue, object newValue)
{
var map = bindable as Map;
if (newValue == null) return;
if (map == null) return;
foreach (var point in newValue as List<Models.Point>)
{
// Instantiate a Circle
Circle circle = new()
{
Center = new Location(point.Location.Latitude, point.Location.Longitude),
Radius = new Distance(200),
StrokeColor = Color.FromArgb("#88FF0000"),
StrokeWidth = 0,
FillColor = point.Heat
};
// Add the Circle to the map's MapElements collection
map.MapElements.Add(circle);
}
}
public List<Models.Point> Points
{
get => GetValue(PointsProperty) as List<Models.Point>;
set => SetValue(PointsProperty, value);
}
现在我们已经创建了一个自定义地图控件,我们将使用它来替换 MainView 中的 Map 控件。请按照以下步骤操作:
在 MainView.xaml 文件中,声明自定义控件的命名空间。
用我们创建的新控件替换 Map 控件。
在 MainViewModel 中将 Points 属性绑定,如下面的代码片段所示:
<ContentPage
x:Class="MeTracker.Views.MainView">
<map:CustomMap x:Name="Map" Points="{Binding Points}" />
</ContentPage>
这就完成了关于如何扩展 Maps 控件的这一部分。我们应用程序的最终步骤是在应用程序恢复时刷新地图。
在应用程序恢复时刷新地图
我们将要做的最后一件事是确保在应用程序恢复时地图与最新的点保持最新。最简单的方法是在 App.xaml.cs 文件中将 MainPage 属性设置为 AppShell 的新实例,就像构造函数一样,如下面的代码片段所示:
protected override void OnResume()
{
base.OnResume();
MainPage = new AppShell();
}
现在 MeTracker 应用程序已经完成 – 尝试使用它。一个示例截图显示在 图 7.18 :
图 7.18 – iOS 和 Android 上的 MeTracker
摘要
在本章中,我们为 iOS、Mac Catalyst 和 Android 开发了一个应用程序,该应用程序跟踪用户的地理位置。在构建应用程序时,我们学习了如何在 .NET MAUI 中使用地图以及如何在后台运行时进行位置跟踪。我们还学习了如何使用自定义控件扩展 .NET MAUI。有了这些知识,我们可以创建执行其他后台任务的程序。我们还学习了如何扩展 .NET MAUI 中的大多数控件。
这里有一些方法可以进一步扩展这个应用程序:
下一个项目将是一个天气应用程序。在下一章中,我们将使用现有的天气服务 API 获取天气数据,然后在应用程序中显示这些数据。
第八章:为多种形态构建天气应用
.NET MAUI 不仅用于创建手机应用;它还可以用于创建平板电脑和桌面电脑的应用。在本章中,我们将构建一个适用于所有这些平台的应用,并为每个形态优化用户界面。除了使用三种不同的形态,我们还将针对四个不同的操作系统进行工作:iOS、macOS、Android 和 Windows。
本章将涵盖以下主题:
让我们开始吧!
技术要求
要处理此项目,我们需要安装 Visual Studio for Mac 或 PC,以及必要的 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章 ,.NET MAUI 简介 。如果你使用 Visual Studio for PC 构建 iOS 应用,你需要连接一台 Mac。如果你根本无法访问 Mac,你可以选择只处理此项目的 Windows 和 Android 部分。同样,如果你只有 Mac,你可以选择只处理此项目的 iOS 和 Android 部分。
你可以在本章中找到代码的完整源代码,链接为 github.com/PacktPubliching/MAUI-Projects-3rd-Edition 。
项目概述
iOS 和 Android 应用可以在手机和平板电脑上运行。通常,应用只是针对手机进行优化。在本章中,我们将构建一个适用于不同形态的应用,但不会仅限于手机和平板电脑——我们还将针对桌面电脑。桌面版本将使用 Window UI Library (WinUI )和 macOS 通过 Mac Catalyst。
我们将要构建的应用是一个天气应用,它根据用户的地理位置显示天气预报。对于本章,我们将使用 Visual Studio for Mac 的说明。如果你使用 Visual Studio for Windows,你应该能够跟上。如果你需要帮助,可以使用其他章节进行参考。
构建天气应用
是时候开始构建应用了。按照以下步骤在 Visual Studio for Mac 中创建一个新的空白 .NET MAUI 应用:
打开 Visual Studio for Mac 并点击 新建 :
图 8.1 – Visual Studio 2022 for Mac 启动屏幕
在 选择 你的新项目模板 对话框中,使用位于 多平台 | 应用 下的 .NET MAUI 应用 模板,然后点击 继续 :
图 8.2 – 新建项目
在 配置 你的新 .NET MAUI 应用 对话框中,确保已选择 .NET 7.0 目标框架,然后点击 继续 :
图 8.3 – 选择目标框架
在 Weather 中,点击 创建 :
图 8.4 – 命名新应用
如果你现在运行该应用,你应该看到以下类似的内容:
图 8.5 – macOS 上的天气应用
现在我们已经从模板创建了项目,是时候开始编码了!
为天气数据创建模型
在我们编写从外部天气服务获取数据的代码之前,我们将创建用于反序列化服务结果的模型。我们将这样做,以便我们有一个通用的模型,我们可以用它来从服务返回数据。
作为此应用的数据源,我们将使用外部天气 API。本项目将使用 OpenWeatherMap ,这是一个提供几个免费 API 的服务。你可以在 openweathermap.org/api 找到这个服务。在本项目中,我们将使用 5 天 / 3 小时预报 服务,该服务以 3 小时为间隔提供 5 天的预报。为了使用 OpenWeatherMap API,我们必须创建一个账户以获取 API 密钥。如果你不想创建 API 密钥,你可以模拟数据。
按照以下说明在 home.openweathermap.org/users/sign_up 创建你的账户并获取你的 API 密钥,你需要用它来调用 API。
生成用于反序列化服务结果的模型的最简单方法是,在浏览器或使用工具(如浏览器中的 https://api.openweathermap.org/data/2.5/forecast?lat=44.34&lon=10.99&appid={API key})中对服务进行调用,将 {API KEY} 替换为你的 API 密钥。
注意
如果你遇到了 401 错误,请等待几个小时后再使用你的 API,如 openweathermap.org/faq#error401 中所述。
我们可以手动创建类或使用一个可以从 JSON 生成 C# 类的工具。一个可以使用的工具是 quicktype ,可以在 quicktype.io/ 找到。只需将 API 调用的输出粘贴到 quicktype 中,即可生成你的 C# 模型。
如果你使用工具生成它们,请确保将命名空间设置为 Weather.Models。
如前所述,你也可以手动创建这些模型。我们将在下一节中描述如何进行此操作。
手动添加天气 API 模型
如果你希望手动添加模型,请按照以下说明进行。我们将添加一个名为 WeatherData.cs 的单个代码文件,其中将包含多个类:
创建一个名为 Models 的文件夹。
在新创建的文件夹中添加一个名为 WeatherData.cs 的文件。
将以下代码添加到 WeatherData.cs 文件中:
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 类(除非你在前面的部分手动创建了它):
在 Weather 项目中创建一个名为 Models 的新文件夹。
添加一个名为 WeatherData.cs 的新文件。
将 quicktype 生成的代码粘贴过来,或者根据 JSON 写出类的代码。如果生成了除属性以外的代码,忽略它,只使用属性。
重命名 MainClass(这是 WeatherData 的内容)。
现在,我们将创建基于我们感兴趣的数据的模型。这将使其余的代码与数据源耦合得更松散。
添加 ForecastItem 模型
我们将要添加的第一个模型是 ForecastItem,它代表特定时间点的特定预测。我们可以这样做:
在 Weather 项目和 Models 文件夹中,创建一个名为 ForecastItem 的新类。
添加以下代码:
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; }
}
}
现在我们已经有了每个预测的模型,我们需要一个容器模型来按 City 对 ForecastItems 进行分组。
添加 Forecast 模型
在本节中,我们将创建一个名为 Forecast 的模型,该模型将跟踪一个城市的单个预测。Forecast 模型保留多个 ForeCastItem 对象的列表,每个对象代表特定时间点的预测。让我们设置它:
在 Models 文件夹中创建一个名为 Forecast 的新类。
添加以下代码:
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 获取数据。
创建一个获取天气数据的服务
为了更容易更改外部天气服务并使代码更易于测试,我们将为服务创建一个接口。以下是我们可以如何进行:
创建一个名为 Services 的新文件夹。
创建一个名为 IWeatherService 的新 public interface。
添加一个基于用户位置获取数据的方法,如下所示。将方法命名为 GetForecastAsync:
using System.Threading.Tasks;
using Weather.Models;
namespace Weather.Services;
public interface IWeatherService
{
Task<Forecast> GetForecastAsync(double latitude, double longitude);
}
现在我们有一个接口,我们可以创建一个实现,如下所示:
在 Services 文件夹中,创建一个名为 OpenWeatherMapWeatherService 的新类。
实现接口,并将 async 关键字添加到 GetForecastAsync 方法中:
using System;
using System.Globalization;
using Weather.Models;
using System.Text.Json;
namespace Weather.Services;
public class OpenWeatherMapWeatherService : IWeatherService
{
public async Task<Forecast> GetForecastAsync(double latitude, double longitude)
{
}
}
在我们调用 OpenWeatherMap API 之前,我们需要为对天气 API 的调用构建一个 URI。这将是一个 GET 调用,位置的位置纬度和经度将作为查询参数添加。我们还将添加 API 密钥和我们希望响应使用的语言。让我们设置它:
打开 OpenWeatherMapWeatherService 类。
将以下代码片段中高亮显示的代码添加到 OpenWeatherMap 的 WeatherService 类中:
public async Task<Forecast> GetForecastAsync(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}”;
}
将 {AddYourApiKeyHere} 替换为从 Creating models for the weather data 部分获得的密钥
为了反序列化我们从外部服务获取的 JSON,我们将使用 System.Text.JSON。
要调用 Weather 服务,我们将使用 HttpClient 类和 GetStringAsync 方法,如下所示:
创建 HttpClient 类的新实例。
调用 GetStringAsync 并将 URL 作为参数传递。
使用 System.Text.Json 中的 JsonSerializer 类和 DeserializeObject 方法将 JSON 字符串转换为对象。
将 WeatherData 对象映射到 Forecast 对象。
此代码应类似于以下代码片段中高亮显示的代码:
public async Task<Forecast> GetForecastAsync(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 = JsonSerializer.Deserialize<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 错误 。” 这可以在 learn.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 构建选项卡 ”下的项目设置中设置。对于 Android,使用 Android 。这可以在 Android 项目的“Android 选项 ” | 高级 ”下设置。
配置应用程序平台以使用位置服务
要使用位置服务,我们需要在每个平台上进行一些配置。
配置 iOS 平台以使用位置服务
要在 iOS 应用程序中使用位置服务,我们需要在 info.plist 文件中添加一个描述,说明我们为什么想要使用位置。在这个应用程序中,我们只需要在我们使用应用程序时获取位置,所以我们只需要为此添加一个描述。让我们设置它:
使用 XML (****Text) Editor 打开 Platforms/iOS 中的 info.plist。
使用以下代码添加 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
我们可以在 AndroidManifest.xml 文件中设置此内容,该文件位于 Platforms\Android\ 文件夹中。然而,我们也可以在项目属性中的 Android Manifest 选项卡中设置此内容,如下面的截图所示:
图 8.6 – 选择位置权限
配置 WinUI 平台以使用位置服务
由于我们将在 WinUI 平台中使用位置服务,我们需要在项目的 Platforms/Windows 文件夹中的 Package.appxmanifest 文件下添加 Location 功能,如下面的截图所示:
图 8.7 – 向 WinUI 应用添加位置
创建 ViewModel 类
既然我们已经有一个负责从外部天气源获取天气数据的服务,那么是时候创建一个 ViewModel 了。然而,首先我们将创建一个基视图模型,我们可以在这里放置所有应用中 ViewModels 之间可以共享的代码。让我们来设置它:
创建一个名为 ViewModels 的新文件夹。
创建一个名为 ViewModel 的新类。
将新类设置为 public 和 abstract。
添加对 CommunityToolkit.MVVM 的包引用:
public abstract partial class ViewModel : ObservableObject
{
}
现在我们有一个基视图模型。我们可以使用它来创建我们即将创建的视图模型。
现在,是时候创建 MainViewModel 了,它将是应用中 MainView 的 ViewModel。执行以下步骤来完成此操作:
在 ViewModels 文件夹中,创建一个名为 MainViewModel 的新类。
将抽象的 ViewModel 类添加为基类。
由于我们将使用构造函数注入,我们将添加一个带有 IWeatherService 接口参数的构造函数。
创建一个 read-only private 字段。我们将使用它来存储 IweatherService 实例:
public class MainViewModel : ViewModel
{
private readonly IWeatherService weatherService;
public MainViewModel(IWeatherService weatherService)
{
this.weatherService = weatherService;
}
}
MainViewModel 接受任何实现 IWeatherService 的对象,并将对该服务的引用存储在一个字段中。我们将在下一节添加将获取天气数据的功能。
获取天气数据
现在,我们将创建一个新的加载数据的方法。这将是一个三步过程。首先,我们将获取用户的位置。一旦我们有了这个,我们就可以获取与该位置相关的数据。最后一步是为视图准备数据,以便创建用户界面。
要获取用户的位置,我们将使用 Geolocation 类,该类公开了可以获取用户位置的方法。执行以下步骤:
创建一个名为 LoadDataAsync 的新方法。使其成为一个返回 Task 的异步方法。
使用 Geolocation 类中的 GetLocationAsync 方法来获取用户的位置。
从 GetLocationAsync 调用的结果中传递纬度和经度,并使用以下代码将其传递给实现 IWeatherService 的对象上的 GetForecast 方法:
public async Task LoadDataAsync()
{
var location = await Geolocation.GetLocationAsync();
var forecast = await weatherService.GetForecastAsync(location.Latitude, location.Longitude);
}
现在我们可以从服务中获取数据,我们需要通过分组单个数据项来为我们的用户界面结构化它。
对天气数据进行分组
当我们展示天气数据时,我们将按天对其进行分组,以便所有针对一天的预测都将位于同一标题下。为此,我们将创建一个新的模型,称为 ForecastGroup。为了使该模型能够与 .NET MAUI 的 CollectionView 一起使用,它必须有一个 IEnumerable 类型作为基类。让我们设置它:
在 Models 文件夹中创建一个新的类 ForecastGroup。
将 List<ForecastItem> 作为新模型的基类。
添加一个空的构造函数和一个带有 ForecastItem 实例列表参数的构造函数。
添加一个 Date 属性。
添加一个名为 DateAsString 的属性,它返回 Date 属性作为短日期字符串。
添加一个名为 Items 的属性,它返回 ForecastItem 实例的列表,如下所示:
using System;
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 的两个新属性:
创建一个名为 city 的私有字段,并使用 ObservableProperty 属性来获取我们正在获取天气数据的城市的名称。
创建一个名为 days 的私有字段,并使用 ObservableProperty 属性,它将包含分组后的天气数据。
MainViewModel 类应该看起来像以下代码片段中高亮显示的代码:
public partial class MainViewModel : ViewModel
{
[ObservableProperty]
private string city;
[ObservableProperty]
private ObservableCollection<ForecastGroup> days;
// Rest of the class is omitted for brevity
}
现在,我们准备好对数据进行分组了。我们将在 LoadDataAsync 方法中这样做。我们将遍历服务中的数据,并将项目添加到不同的组中,如下所示:
创建一个 itemGroups 变量,其类型为 List<ForecastGroup>。
创建一个 foreach 循环,遍历 forecast 变量中的所有项目。
添加一个 if 语句来检查 itemGroups 属性是否为空。如果是空的,则向变量中添加一个新的 ForecastGroup 并继续到项目列表中的下一个项目。
在 itemGroups 变量上使用 SingleOrDefault 方法(这是来自 System.Linq 的扩展方法)来根据当前 ForecastItem 的日期获取一个组。将结果添加到一个新变量 group 中。
如果 group 属性为 null,则当前天在组列表中没有组。如果是这种情况,应在 itemGroups 变量中添加一个新的 ForecastGroup。代码将继续执行,直到它到达 forecast.Items 列表中的下一个 forecast 项目。如果找到组,则应将其添加到 itemGroups 变量中的列表。
在 foreach 循环之后,使用新的 ObservableCollection 设置 Days 属性,并将 itemGroups 变量作为构造函数的参数。
将 City 属性设置为 forecast 变量的 City 属性。
LoadDataAsync 方法现在应该如下所示:
public async Task LoadDataAsync()
{
var location = await Geolocation.GetLocationAsync();
var forecast = await weatherService.GetForecastAsync(location.Latitude, location.Longitude);
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 方法,我们将获得更好的性能。
为平板电脑和桌面电脑创建视图
下一步是创建当应用在平板电脑或桌面电脑上运行时我们将使用的视图。让我们设置它:
在 Weather 项目中创建一个名为 Views 的新文件夹。
在 Views 文件夹中创建一个名为 Desktop 的新文件夹。
在 Views\Desktop 文件夹中创建一个名为 MainView 的新 .NET MAUI ContentPage (XAML) 文件:
图 8.8 – 添加 .NET MAUI XAML ContentPage
在视图的构造函数中传递 MainViewModel 的实例以设置 BindingContext,如下面的代码所示:
public MainView (MainViewModel mainViewModel)
{
InitializeComponent ();
BindingContext = mainViewModel;
}
在后面的 添加服务和 ViewModels 到依赖注入 部分中,我们将配置依赖注入以为我们提供实例。
要在主线程上触发 MainViewModel 中的 LoadDataAsync 方法,通过覆盖主线程上的 OnNavigatedTo 方法来调用 LoadDataAsync 方法。我们需要确保调用是在 UI 线程上执行的,因为它将交互用户界面。
要执行此操作,请按照以下步骤操作:
在 Views\Desktop 文件夹中打开 MainView.xaml.cs 文件。
创建 OnNavigatedTo 方法的覆盖版本。
将以下代码片段中突出显示的代码添加到 OnNavigateTo 方法中:
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
if (BindingContext is MainViewModel viewModel)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await viewModel.LoadDataAsync();
});
}
}
在 MainView XAML 文件中,将 ContentPage 的 Title 属性绑定到 ViewModel 中的 City 属性,如下所示:
在 Views\Desktop 文件夹中打开 MainView.xaml 文件。
将 Title 绑定添加到以下代码片段中突出显示的 ContentPage 元素中:
<ContentPage
xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
x:Class=”Weather.Views.Desktop.MainView”
Title=”{Binding City}”>
在接下来的部分中,我们将使用 FlexLayout 将 ViewModel 中的数据渲染到屏幕上。
使用 FlexLayout
在.NET MAUI 中,如果我们想显示一组数据,可以使用CollectionView或ListView。在大多数情况下,使用CollectionView和ListView都很好,我们将在本章后面使用CollectionView,但ListView只能垂直显示数据。在这个应用中,我们希望两个方向都能显示数据。在垂直方向上,我们将有天数(我们根据天数分组预测),而在水平方向上,我们将有特定天内的预测。我们还希望如果在一行中不足以显示所有预测时,预测内容可以换行。CollectionView可以在水平方向显示数据,但它不会自动换行。使用FlexLayout,我们可以添加两个方向的项目,并且我们可以使用BindableLayout将其绑定。当我们使用BindableLayout时,我们将使用ItemSource和ItemsTemplate作为附加属性。
执行以下步骤来构建视图:
将Grid作为页面的根视图添加。
将ScrollView添加到Grid中。我们需要这样做,以便如果内容高于页面高度,则可以滚动。
将FlexLayout添加到ScrollView中,并将方向设置为Column,以便内容将垂直排列。
使用BindableLayout.ItemsSource将MainViewModel中的Days属性添加绑定。
将DataTemplate设置为ItemsTemplate的内容,如下面的代码所示:
<Grid>
<ScrollView BackgroundColor=”Transparent”>
<FlexLayout BindableLayout.ItemsSource=”{Binding Days}” Direction=”Column”>
<BindableLayout.ItemTemplate>
<DataTemplate>
<!--Content will be added here -->
</DataTemplate>
</BindableLayout.ItemTemplate>
</FlexLayout>
</ScrollView>
</Grid>
每个项目的内联内容将是一个包含日期的标题以及一个水平FlexLayout,其中包含该天的预测。让我们设置如下:
打开MainView.xaml文件。
添加StackLayout,以便我们将添加到其中的子项垂直排列。
将ContentView添加到StackLayout中,并将Padding设置为10,BackgroundColor设置为#9F5010。这将作为标题。我们需要ContentView的原因是我们希望文本周围有填充。
将Label添加到ContentView中,并将TextColor设置为White,FontAttributes设置为Bold。
为Label的Text属性添加对DateAsString的绑定。
代码应放置在<!-- Content will be added here -->注释中,并应如下所示:
<StackLayout>
<ContentView Padding=”10” BackgroundColor=”#9F5010”>
<Label Text=”{Binding DateAsString}” TextColor=”White” FontAttributes=”Bold” />
</ContentView>
</StackLayout>
现在我们已经在用户界面中有了日期,我们需要添加一个FlexLayout属性,该属性将在MainViewModel中的任何项目中重复。执行以下步骤来完成此操作:
在</ContentView>标签之后但在</StackLayout>标签之前添加FlexLayout。
将JustifyContent设置为Start,以便将项目从左侧添加,而不在可用空间中分配它们。
将AlignItems设置为Start,以便将内容设置为FlexLayout中每个项目的左侧,如下面的代码所示:
<FlexLayout BindableLayout.ItemsSource=”{Binding Items}” Wrap=”Wrap” JustifyContent=”Start” AlignItems=”Start”>
</FlexLayout>
在定义了FlexLayout之后,我们需要提供一个ItemsTemplate属性,该属性定义了列表中每个项目应该如何渲染。继续在您刚刚添加的<FlexLayout>标签下直接添加 XAML,如下所示:
将ItemsTemplate属性设置为DataTemplate。
使用以下代码将元素添加到FillDataTemplate中:
提示
如果我们想在绑定中添加格式,可以使用StringFormat。在这种情况下,我们想在温度后面添加度符号。我们可以通过使用{Binding Temperature, StringFormat=’{0}° C’}短语来实现。通过绑定的StringFormat属性,我们可以使用与在 C#中执行此操作时相同的参数格式化数据。这相当于 C#中的string.Format(“{0}° C”, Temperature)。我们还可以用它来格式化日期;例如,{Binding Date, StringFormat=’yyyy’}。在 C#中,这看起来像Date.ToString(“yyyy”)。
<BindableLayout.ItemTemplate>
<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>
</BindableLayout.ItemTemplate>
提示
AspectFill短语作为Image的Aspect属性的值,意味着整个图像始终可见,并且不会更改其比例。AspectFit短语也将保持图像的比例,但图像可以放大和缩小,并裁剪以填充整个Image元素。Aspect可以设置的最后一个值Fill意味着图像可以拉伸或压缩以匹配Image视图,从而确保保持宽高比。
添加工具栏项以刷新天气数据
为了能够在不重新启动应用程序的情况下刷新数据,我们将向工具栏添加一个Refresh按钮。MainViewModel负责处理我们想要执行的任何逻辑,并且我们必须将任何操作公开为可绑定的ICommand,以便我们可以将其绑定到。
让我们从在MainViewModel上创建Refresh命令方法开始:
打开MainViewModel类。
为CommunityToolkit.Mvvm.Input添加using声明:
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Weather.Models;
using Weather.Services;
添加一个名为RefreshAsync的方法,该方法调用LoadDataAsync方法,如下所示:
public async Task RefreshAsync()
{
await LoadDataAsync();
}
由于这些方法是异步的,Refresh将返回Task,我们可以使用async和await来调用LoadDataAsync而不会阻塞 UI 线程。
向RefreshAsync方法添加RelayCommand属性以自动生成方法的可绑定ICommand属性:
[RelayCommand]
public async Task RefreshAsync()
{
await LoadDataAsync();
}
现在我们已经定义了Refresh命令,我们需要将其绑定到用户界面,以便当用户点击工具栏按钮时,将执行该操作。
要完成此操作,请执行以下步骤:
打开MainView.xaml文件。
从raw.githubusercontent.com/PacktPublishing/MAUI-Projects-3rd-Edition/main/Chapter08/Weather/Resources/Images/refresh.png 下载refresh.png文件,并将其保存到项目的Resources/Images文件夹中。
向ContentPage的ToolbarItems属性添加一个新的ToolbarItem,将Text属性设置为Refresh,并将IconImageSource属性设置为refresh.png(或者,您可以将IconImageSource属性设置为图片的 URL,.NET MAUI 将下载该图片)。
将 Command 属性绑定到 MainViewModel 中的 Refresh 属性,如下所示:
<ContentPage.ToolbarItems>
<ToolbarItem IconImageSource=”refresh.png” Text=”Refresh” Command=”{Binding RefreshCommand}” />
</ContentPage.ToolbarItems>
数据刷新的所有内容都已完成。现在,我们需要某种指示数据正在加载的指示器。
添加加载指示器
当我们刷新数据时,我们希望显示一个加载指示器,以便用户知道正在发生某些事情。为此,我们将添加 ActivityIndicator,这是 .NET MAUI 中对该控件的称呼。让我们设置如下:
打开 MainViewModel 类。
在 MainViewModel 中添加一个名为 isRefreshing 的 Boolean 字段。
在 isRefreshingField 上添加 ObservableProperty 属性以生成 IPropertyChanged 实现。
在 LoadDataAsync 方法的开始处将 IsRefreshing 属性设置为 true。
在 LoadDataAsync 方法的末尾,将 IsRefreshing 属性设置为 false,如下所示:
[ObservableProperty]
private bool isRefreshing;
....// The rest of the code is omitted for brevity
public async Task LoadData()
{
IsRefreshing = true;
....// The rest of the code is omitted for brevity
IsRefreshing = false;
}
现在我们已经在 MainViewModel 中添加了一些代码,我们需要将 IsRefreshing 属性绑定到当 IsRefreshing 属性为 true 时将显示的用户界面元素,如下所示:
在 MainView.xaml 中,将 Frame 添加到 ScrollView 之后,作为 Grid 中的最后一个元素。
将 IsVisible 属性绑定到我们在 MainViewModel 中创建的 IsRefreshing 方法。
将 HeightRequest 和 WidthRequest 设置为 100。
将 VerticalOptions 和 HorizontalOptions 设置为 Center,以便 Frame 将位于视图的中间。
将 BackgroundColor 设置为 #99000000 以将背景设置为带有一定透明度的白色。
在 Frame 中添加 ActivityIndicator,将 Color 设置为 Black,将 IsRunning 设置为 True,如下所示:
<Frame IsVisible=”{Binding IsRefreshing}” BackgroundColor=”#99FFFFFF” WidthRequest=”100” HeightRequest=”100” VerticalOptions=”Center” HorizontalOptions=”Center”>
<ActivityIndicator Color=”Black” IsRunning=”True” />
</Frame>
这将创建一个在数据加载时可见的旋转器,这在创建任何用户界面时都是一个非常好的实践。现在,我们将添加一个背景图像,使应用看起来更美观。
设置背景图像
对于这个视图,我们目前要做的最后一件事是添加一个背景图像。在这个例子中,我们将使用的是通过 Google 搜索免费使用图像的结果。让我们设置如下:
打开 MainView.xaml 文件。
将 ScrollView 的 Background 属性设置为 Transparent。
在 Grid 中添加一个 Image 元素,将 UriImageSource 设置为 Source 属性的值。
将 CachingEnabled 属性设置为 true,将 CacheValidity 设置为 5。这意味着图像将被缓存 5 天。
注意
如果您使用了 URL 作为 Refresh IconImageSource 属性的值,也可以设置这些属性以避免在每次运行应用时下载图像。
XAML 现在应该看起来如下所示:
<ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
x:Class=”Weather.Views.Desktop.MainView”
Title=”{Binding City}”>
<ContentPage.ToolbarItems>
<ToolbarItem Icon=”refresh.png” Text=”Refresh” Command=”{Binding RefreshCommand}” />
</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=”5” />
</Image.Source>
</Image>
<ScrollView BackgroundColor=”Transparent”>
<!-- The rest of the code is omitted for brevity -->
我们也可以通过使用 <Image Source=”https://ourgreatimage.url” /> 直接在 Source 属性中设置 URL。然而,如果我们这样做,我们无法指定对图像的缓存。
桌面视图完成后,我们需要考虑当我们在手机或平板上运行应用时,这个页面将如何显示。
创建手机视图
在平板电脑和台式计算机上结构化内容在很多方面非常相似。然而,在手机上,我们能够做的事情却非常有限。因此,在本节中,我们将为在手机上使用此应用时创建一个特定的视图。为此,请按照以下步骤操作:
创建一个新的基于 XAML 的 Views 文件夹。
在 Views 文件夹中,创建一个名为 Mobile 的新文件夹。
在 Views\Mobile 文件夹中创建一个名为 MainView 的新 .NET MAUI ContentPage (XAML) 文件:
图 8.9 – 添加 .NET MAUI XAML ContentPage
在视图的构造函数中传递 MainViewModel 的实例以设置 BindingContext,如下所示:
public MainView (MainViewModel mainViewModel)
{
InitializeComponent();
BindingContext = mainViewModel;
}
在 添加服务和 ViewModels 到依赖注入 部分中,我们将配置依赖注入以为我们提供实例。
要触发 MainViewModel 中的 LoadDataAsync 方法,通过在主线程上重写 OnNavigatedTo 方法来调用 LoadDataAsync 方法。我们需要确保调用在 UI 线程上执行,因为它将交互用户界面。
要执行此操作,请按照以下步骤进行:
在 Views\Mobile 文件夹中打开 MainView.xaml.cs 文件。
重写 OnNavigatedTo 方法。
将以下片段中突出显示的代码添加到 OnNavigateTo 方法中:
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
if (BindingContext is MainViewModel viewModel)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await viewModel.LoadDataAsync();
});
}
}
在 MainView XAML 文件中,将 ContentPage 的 Title 属性绑定到 ViewModel 中的 City 属性,如下所示:
在 Views\Mobile 文件夹中打开 MainView.xaml 文件。
将 Title 绑定添加到以下代码片段中突出显示的 ContentPage 元素:
<ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
x:Class=”Weather.Views.Desktop.MainView”
Title=”{Binding City}”>
在下一节中,我们将使用 CollectionView 来显示天气数据,而不是像桌面视图那样使用 FlexView。
使用分组 CollectionView
我们可以使用 FlexLayout 来实现手机的视图,但由于我们希望用户体验尽可能好,我们将使用 CollectionView。为了获取每天的标题,我们将对 CollectionView 使用分组。对于 FlexLayout,我们有 ScrollView,但对于 CollectionView,我们不需要这个,因为 CollectionView 默认可以处理滚动。
让我们继续为手机的视图创建用户界面:
在 Views\Mobile 文件夹中打开 MainView.xaml 文件。
将 CollectionView 添加到页面的根处。
在 MainViewModel 中为 ItemSource 属性设置对 Days 属性的绑定。
将 IsGrouped 设置为 True 以在 CollectionView 中启用分组。
将 BackgroundColor 设置为 Transparent,如下所示:
<CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
</CollectionView>
为了格式化每个标题的外观,我们将创建一个 DataTemplate 属性,如下所示:
将 DataTemplate 属性添加到 CollectionView 的 GroupHeaderTemplate 属性中。
将行内容添加到 DataTemplate 中,如下所示:
<CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
<CollectionView.GroupHeaderTemplate>
<DataTemplate>
<ContentView Padding=”15,5” BackgroundColor=”#9F5010”>
<Label FontAttributes=”Bold” TextColor=”White” Text=”{Binding DateAsString}” VerticalOptions=”Center”/>
</ContentView>
</DataTemplate>
</CollectionView.GroupHeaderTemplate>
</CollectionView>
为了格式化每个预报的外观,我们将创建一个 DataTemplate 属性,就像我们对组标题所做的那样。让我们设置这个:
将 DataTemplate 属性添加到 CollectionView 的 ItemTemplate 属性中。
在 DataTemplate 中添加一个包含四个列的 Grid 属性。使用 ColumnDefinition 属性指定列的宽度。第二列应该是 50;其他三列将共享剩余的空间。我们将通过将 Width 设置为 * 来实现这一点。
将以下内容添加到 Grid:
<CollectionView.ItemTemplate>
<DataTemplate>
<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>
</DataTemplate>
</CollectionView.ItemTemplate>
添加下拉刷新功能
对于视图的平板和桌面版本,我们在工具栏中添加了一个按钮来刷新天气预报。然而,在手机版本的视图中,我们将添加下拉刷新功能,这是在数据列表中刷新内容的一种常见方式。.NET MAUI 中的 CollectionView 没有内置下拉刷新的支持,就像 ListView 一样。
相反,我们可以使用 RefreshView。RefreshView 可以用于向任何控件添加下拉刷新行为。让我们设置这个:
前往 Views\Mobile\MainView.xaml。
将 CollectionView 包裹在 RefreshView 内。
将 MainViewModel 中的 RefreshCommand 属性绑定到 RefreshView 的 Command 属性,以便在用户执行下拉刷新手势时触发刷新。
要在刷新进行时显示加载图标,将 MainViewModel 中的 IsRefreshing 属性绑定到 RefreshView 的 IsRefreshing 属性。当我们设置这个时,我们也会在初始加载运行时获得一个加载指示器,如下面的代码所示:
<RefreshView Command=”{Binding Refresh}” IsRefreshing=”{Binding IsRefreshing}”>
<CollectionView ItemsSource=”{Binding Days}” IsGrouped=”True” BackgroundColor=”Transparent”>
....
</CollectionView>
</RefreshView>
这就完成了当前视图的创建。现在,让我们将它们连接到依赖注入,以便我们可以看到我们的工作。
添加服务和 ViewModels 到依赖注入
为了让我们的视图获取 MainViewModel 的实例,以及让 MainViewModel 获取 OpenWeatherMapWeatherService 的实例,我们需要将它们添加到依赖注入中。让我们设置这个:
打开 MauiProgram.cs。
添加以下突出显示的代码:
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<IWeatherService, OpenWeatherMapWeatherService>();
builder.Services.AddTransient<MainViewModel, MainViewModel>();
return builder.Build();
在下一节中,我们将根据设备的形态添加视图的导航。
根据设备形态导航到不同的视图
现在我们有两个不同的视图,它们应该在应用程序的同一位置加载。如果应用程序在平板或桌面上运行,则应加载 Weather.Views.Desktop.MainView;如果应用程序在手机上运行,则应加载 Weather.Views.Mobile.MainView。
.NET MAUI 中的 Device 类有一个静态的 Idiom 属性,我们可以使用它来检查应用程序正在哪个形态上运行。Idiom 的值可以是 Phone、Tablet、Desktop、Watch 或 TV。因为我们在这个应用程序中只有一个视图,所以我们可以在 App.xaml.cs 中设置 MainPage 时使用 if 语句来检查 Idiom 的值。
由于我们只需要一个视图,我们只需在依赖注入中注册我们需要的视图即可——我们只需要一个公共类型来注册视图。让我们创建一个新的接口,我们的视图将实现它:
在 Views 文件夹中创建一个名为 IMainView 的新接口。
我们不会向接口添加任何额外的属性或方法,我们只是将其用作标记。
打开 Views\Desktop\MainView.xaml.cs 并将 IMainView 接口添加到类中:
public partial class MainView : ContentPageViews\Mobile\MainView.xaml.cs and add the IMainView interface to the class:
public partial class MainView : ContentPage, IMainView
现在我们有一个公共接口,我们可以通过依赖注入注册视图:
打开 MauiProgram.cs 文件。
添加以下代码:
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<IWeatherService, OpenWeatherMapWeatherService>();
builder.Services.AddTransient<MainViewModel, MainViewModel>();
if (DeviceInfo.Idiom == DeviceIdiom.Phone)
{
builder.Services.AddTransient<IMainView, Views.Mobile.MainView>();
}
else
{
builder.Services.AddTransient<IMainView, Views.Desktop.MainView>();
}
return builder.Build();
通过这些更改,我们现在可以测试我们的应用程序。如果你运行你的应用,你应该看到以下内容:
图 8.10 – 在 macOS 和 iOS 上运行的应用
接下来,让我们通过使用 VisualStateManager 来更新桌面视图,以便正确处理调整大小。
使用 VisualStateManager 处理状态
VisualStateManager 是一种从代码中更改 UI 的方法。我们可以定义状态并为选定的属性设置值,以应用于特定状态。VisualStateManager 在我们想要为具有不同屏幕分辨率的设备使用相同视图的情况下非常有用。对于我们这些 .NET MAUI 开发者来说,VisualStateManager 非常有趣,尤其是在 iOS 和 Android 都可以在手机和平板上运行的情况下。
在此项目中,我们将使用它来在平板电脑或桌面上的横幅模式下运行应用时使 forecast 项更大。我们还将使天气图标更大。让我们设置它:
打开 Views\Desktop\MainView.xaml 文件。
在第一个 FlexLayout 和 DataTemplate 中,将一个 VisualStateManager.VisualStateGroups 元素插入到第一个 StackLayout:
<StackLayout Margin=”10” Padding=”20” WidthRequest=”150” BackgroundColor=”#99FFFFFF”>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
......
</StackLayout>
关于 VisualStateGroup,我们应该添加两个状态,如下所示:
在 VisualStateGroup 中添加一个名为 Portrait 的新 VisualState。
在 VisualState 中创建一个设置器,并将 WidthRequest 设置为 150。
将另一个名为 Landscape 的 VisualState 添加到 VisualStateGroup 中。
在 VisualState 中创建一个设置器,并将 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。让我们设置它:
在第二个 FlexLayout 和 DataTemplate 中的 Image 元素中插入一个 VisualStateManager.VisualStateGroups 元素。
为 Portrait 和 Landscape 添加 VisualState。
向状态添加设置器以设置 WidthRequest 和 HeightRequest。在 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 时,需要重写两个方法:OnAttached 和 OnDetachingFrom。如果我们在 OnAttached 方法中添加了事件监听器,那么在 OnDeattached 方法中移除它们是非常重要的。这将使应用程序使用更少的内存。同样重要的是将值设置回 OnAppearing 方法运行之前的状态;否则,我们可能会看到一些奇怪的行为,尤其是在行为位于重用单元格的 CollectionView 或 ListView 视图中。
在此应用程序中,我们将为 FlexLayout 创建一个行为。这是因为我们不能从代码后端设置 FlexLayout 中项的状态。我们可以在 FlexLayout 中添加一些代码来检查应用程序是否以纵向或横向运行,但如果我们使用 Behavior,则可以将该代码从 FlexLayout 中分离出来,使其更具可重用性。执行以下步骤来完成此操作:
创建一个名为 Behaviors 的新文件夹。
创建一个名为 FlexLayoutBehavior 的新类。
将 Behavior<FlexLayoutView> 作为基类添加。
创建一个名为 view 的 FlexLayout 类型的 private 字段。
代码应如下所示:
using System;
namespace Weather.Behaviors;
public class FlexLayoutBehavior : Behavior<FlexLayout>
{
private FlexLayout view;
}
FlexLayout 是一个继承自 Behavior<FlexLayout> 基类的类。这将使我们能够覆盖一些在将行为附加到或从 FlexLayout 类中移除时将被调用的虚拟方法。
但首先,我们需要创建一个处理状态变化的方法。执行以下步骤来完成此操作:
打开 FlexlayoutBehavior.cs 文件。
创建一个名为 SetState 的 private 方法。此方法将有一个 VisualElement 值和一个 string 参数。
调用 VisualStateManager.GoToState 并传递参数给它。
如果视图是 Layout 类型,可能还有需要获取新状态的子元素。为此,我们将遍历布局的所有子元素。我们不会直接将状态设置到子元素,而是调用 SetState 方法,这是我们已经在其中的方法。这样做的原因是,一些子元素可能有它们自己的子元素:
private void SetState(VisualElement view, string state)
{
VisualStateManager.GoToState(view, state);
if (view is Layout layout)
{
foreach (VisualElement child in layout.Children)
{
SetState(child, state);
}
}
}
现在我们已经创建了 SetState 方法,我们需要编写一个使用它并确定要设置什么状态的方法。执行以下步骤来完成此操作:
创建一个名为 UpdateState 的 private 方法。
在 MainThread 上运行代码以检查应用程序是否以纵向或横向模式运行。
创建一个名为 page 的变量,并将其值设置为 Application.Current.MainPage。
检查Width是否大于Height。如果是true,将view变量的VisualState属性设置为Landscape。如果是false,将view变量的VisualState属性设置为Portrait,如下面的代码所示:
private void UpdateState()
{
MainThread.BeginInvokeOnMainThread(() =>
{
var page = Application.Current.MainPage;
if (page.Width > page.Height)
{
SetState(view, “Landscape”);
return;
}
SetState(view, “Portrait”);
});
}
这样,UpdateState方法已经添加。现在,我们需要重写OnAttachedTo方法,该方法将在行为添加到FlexLayout时被调用。当它被添加时,我们想要通过调用此方法并将其连接到MainPage的SizeChanged事件来更新状态,以便当大小改变时,我们将再次更新状态。
让我们设置如下:
打开FlexLayoutBehavior文件。
从基类中重写OnAttachedTo方法。
将view属性设置为OnAttachedTo方法中的参数。
向Application.Current.MainPage.SizeChanged添加事件监听器。在事件监听器中添加对UpdateState方法的调用,如下面的代码所示:
protected override void OnAttachedTo(FlexLayout view)
{
this.view = view;
base.OnAttachedTo(view);
UpdateState();
Application.Current.MainPage.SizeChanged += MainPage_SizeChanged;
}
void MainPage_SizeChanged(object sender, EventArgs e)
{
UpdateState();
}
当我们从控件中移除行为时,非常重要的一点是也要从其中移除任何事件处理器,以避免内存泄漏,在最坏的情况下,防止应用崩溃。让我们这样做:
打开FlexLayoutBehavior.cs文件。
从基类中重写OnDetachingFrom方法。
从Application.Current.MainPage.SizeChanged中移除事件监听器。
将view字段设置为null,如下面的代码所示:
protected override void OnDetachingFrom(FlexLayout view)
{
base.OnDetachingFrom(view);
Application.Current.MainPage.SizeChanged -= MainPage_SizeChanged;
this.view = null;
}
执行以下步骤以将behavior添加到视图中:
打开Views/Desktop文件夹中的MainView.xaml文件。
按照以下代码导入Weather.Behaviors命名空间:
<ContentPage xmlns=”http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=”http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:behaviors=”clr-namespace:Weather.Behaviors”
x:Class=”Weather.Views.Desktop.MainView”
Title=”{Binding City}”>
我们最后要做的就是将FlexLayoutBehavior添加到第二个FlexLayout中,如下面的代码所示:
<FlexLayout ItemsSource=”{Binding Items}” Wrap=”Wrap” JustifyContent=”Start” AlignItems=”Start”>
<FlexLayout.Behaviors>
<behaviors:FlexLayoutBehavior />
</FlexLayout.Behaviors>
<FlexLayout.ItemsTemplate>
恭喜——天气应用已经完成!
图 8.11——平板电脑、手机和桌面上的完成应用
摘要
在本章中,我们成功地为四个不同的操作系统——iOS、macOS、Android 和 Windows——以及三种不同的形态——手机、平板电脑和桌面电脑——创建了一个应用。为了在所有平台和形态上提供良好的用户体验,我们使用了FlexLayout和VisualStateManager。我们还学习了如何处理不同形态的不同视图,以及如何使用Behaviors。
我们接下来要构建的应用将是一个具有实时通信的游戏。在下一章中,我们将探讨如何使用Azure 中的SignalR 服务作为后端游戏服务。
第三部分:高级项目
在本部分,你将处理更高级的主题和复杂的项目。你将学习如何在 Azure 中创建和部署服务。此外,你将使用 Azure 存储和 SignalR 服务。你将学习如何从.NET MAUI 应用程序中调用你的服务,正确处理错误条件,并将摄像头集成到你的应用程序中。你将探索一个嵌入到.NET MAUI 应用程序中的 Blazor 项目,并学习如何将人工智能服务集成到.NET MAUI 应用程序中。
本部分包含以下章节:
第九章:使用 Azure 服务设置游戏的后端
在本章中,我们将设置一个具有实时通信功能的游戏应用的后端。我们不仅将创建一个可以扩展以处理大量用户的后端,当用户数量减少时还可以缩小规模。为了构建这个后端,我们将使用基于Microsoft Azure 服务的无服务器架构。
本章将涵盖以下主题:
技术要求
要能够完成此项目,您需要安装 Visual Studio for Mac 或 PC,以及必要的.NET MAUI 组件。有关如何设置您的环境的更多详细信息,请参阅第一章 ,.NET MAUI 简介 。
您还需要一个 Azure 账户。如果您有 Visual Studio 订阅,则每月包含一定数量的 Azure 信用额度。要激活您的 Azure 福利,请访问my.visualstudio.com 。
您还可以创建一个免费账户,在 12 个月内您可以免费使用所选服务。您将获得价值 200 美元的信用额度,用于探索任何 Azure 服务 30 天,您还可以随时使用免费服务。更多信息请参阅azure.microsoft.com/en-us/free/ 。
如果您没有并且不想注册免费 Azure 账户,您可以使用本地开发工具在无需 Azure 的情况下运行服务。
您可以在本章中找到代码的完整源代码,请参阅github.com/PacktPublishing/MAUI-Projects-3rd-Edition 。
项目概述
本项目的主要目标将是设置游戏的后端。项目的大部分工作将是我们将在 Azure 门户中进行的配置。我们还将为 Azure 函数编写一些代码,以处理 SignalR 连接以及一些游戏逻辑和状态。SignalR 是一个使应用程序中的实时通信更简单的库。Azure SignalR 是一个使通过 SignalR 库连接多个客户端发送消息更简单的服务。SignalR 将在后面详细描述。将会有函数来返回有关 SignalR 连接的信息,管理匹配玩家进行对战,并将每位玩家的回合结果发布到 SignalR 服务。
下图显示了此应用程序架构的概述:
图 9.1 – 应用程序架构
完成此项目部分所需的时间估计约为 2 小时。
游戏概述
Sticks & Stones 是一款基于两个童年游戏概念结合而成的回合制社交游戏,即点线格和井字棋。游戏板布局为一个 9x9 的网格。每位玩家将轮流在盒子的旁边放置一根棍子,获得一分。如果一根棍子完成了一个盒子,那么玩家将获得该盒子的所有权,获得五分。当玩家在水平、垂直或对角线上拥有三个连续的盒子时,游戏结束。如果没有任何玩家能拥有三个连续的盒子,则游戏胜利者由得分最高的玩家决定。
为了保持应用和服务端相对简单,我们将消除大量的状态管理。当玩家打开应用时,他们需要连接到游戏服务。他们必须提供游戏标签或用户名和电子邮件地址。可选地,他们可以上传自己的照片作为头像。
一旦连接,玩家将看到连接到同一游戏服务的所有其他玩家的列表;这被称为大厅。玩家的状态,无论是“准备游戏”还是“正在比赛中”,将和玩家的游戏标签和头像一起显示。如果玩家不在比赛中,则还会有一个按钮挑战玩家进行比赛。
挑战玩家进行比赛将导致应用提示对手回应挑战,要么接受要么拒绝。如果对手接受挑战,那么两位玩家将被导航到一个新的游戏板,接受挑战的玩家将先走一步。所有其他玩家的大厅中,两位玩家的状态都将更新为“正在比赛中”。玩家将轮流选择放置一根棍子的位置。每次玩家放置一根棍子时,游戏板和分数将在两位玩家的设备上更新。当放置的棍子完成一个或多个方块时,玩家就“拥有”了那个方块,并在方块的中央放置一堆石头。当所有棍子都放置完毕,或者玩家拥有三个连续的石头时,游戏结束,玩家返回大厅,状态更新为“准备游戏”。
如果玩家在游戏中离开应用,那么他们将放弃比赛,剩余的对手将获得胜利,并返回大厅。
以下截图应能给您一个概念,了解应用完成后的样子,如第 10 章所述:
图 9.2 – 主游戏界面
理解不同的 Azure 无服务器服务
在我们开始构建无服务器架构的后端之前,我们需要定义无服务器 的含义。在无服务器架构中,代码将在服务器上运行,但我们无需担心这一点;我们唯一需要关注的是构建我们的软件。我们让其他人处理所有与服务器相关的事情。我们不需要考虑服务器需要多少内存或中央处理器 (CPUs ),甚至不需要考虑我们需要多少服务器。当我们使用 Azure 中的服务时,Microsoft 会为我们处理这些事情。
Azure SignalR 服务
Azure SignalR 服务 是Microsoft Azure 中的一项服务,用于服务器和客户端之间的实时通信。该服务将内容推送到客户端,而无需客户端轮询服务器以获取内容更新。SignalR 可用于多种类型的应用程序,包括移动应用程序、Web 应用程序和桌面应用程序。
SignalR 将使用WebSockets ,如果该选项可用。如果不可用,SignalR 将使用其他通信技术,例如服务器发送事件 (SSEs )或长轮询 。SignalR 将检测哪种传输技术可用,并使用它,而无需开发者进行任何思考。
SignalR 可以在以下示例中使用:
Azure Functions
Azure Functions 是 Microsoft Azure 的一项服务,允许我们以无服务器的方式运行代码。我们将部署称为函数 的小块代码。函数以组的形式部署,称为函数应用 。当我们创建函数应用时,我们需要选择是否希望它在消费计划或应用服务计划上运行。如果我们希望应用程序完全无服务器,则选择消费计划;而使用应用服务计划,我们必须指定服务的要求。使用消费计划,我们只需为函数的执行时间和使用的内存付费。应用服务计划的一个好处是,您可以将其配置为始终开启 ,这样就不会有任何冷启动,只要您不需要扩展到更多实例。消费计划的一个主要好处是,它将始终根据当时所需的资源进行扩展。
函数可以通过多种方式触发运行。两个例子是 HttpTrigger 和 TimeTrigger。HttpTrigger 将在 HTTP 请求调用函数时触发函数运行。使用 TimeTrigger,函数将按照我们指定的间隔运行。还有其他 Azure 服务的触发器。例如,我们可以配置一个函数在文件上传到 Azure Blob 存储时运行,当新消息发布到事件中心或服务总线时运行,或者当 Azure Cosmos DB 服务中的数据发生变化时运行。
现在我们已经了解了 Azure SignalR 服务和 Functions 提供的功能,让我们使用它们来构建我们的游戏后端。
构建无服务器后端
在本节中,我们将根据上一节中描述的服务设置后端。
创建 SignalR 服务
我们将首先设置 SignalR 服务。要创建此类服务,请按照以下步骤操作:
访问 Azure 门户 portal.azure.com 。
创建一个新的资源。SignalR 服务 资源位于 Web & 移动 类别。
在表单中为资源提供名称。
选择您想用于此项目的订阅。
我们建议您创建一个新的 资源组 并将其用于我们将为该项目创建的所有资源。我们想要一个资源组的原因是跟踪与该项目相关的资源更容易,并且也更容易一起删除所有资源。
选择一个靠近您用户的位置。
选择一个定价层。对于此项目,我们将使用 免费 层。我们始终可以使用 免费 层进行开发,然后扩展到可以处理更多连接的层。
将 服务模式 设置为 无服务器 。
点击 审查 + 创建 在创建 SignalR 服务之前审查设置。
点击 创建 创建存储账户。
参考以下截图查看上述信息:
图 9.3 – 创建 SignalR 服务
这就是我们设置 SignalR 服务所需做的所有事情。我们将在 Azure 门户中稍后返回以获取其连接字符串。
下一步是在存储账户中设置一个账户,我们可以将用户上传的图片存储在其中。
在创建计算机视觉服务后,我们现在可以创建 Azure Functions 服务,该服务将运行我们的游戏逻辑并使用 SignalR、Blob 存储和认知服务,这些服务我们刚刚创建。
使用 Azure Functions 作为 API
我们将为后端编写的所有代码都将使用 Azure Functions。我们将使用 Visual Studio 项目来编写、调试和部署我们的函数。在创建项目之前,我们必须设置和配置 Azure Functions 服务。然后,我们将实现连接玩家到游戏并提供客户端当前玩家列表的函数。接下来,我们将编写允许一个玩家向另一个玩家挑战游戏的函数。最后,我们将通过编写允许玩家轮流在棋盘上放置木棒的函数来结束。
让我们从创建 Azure Functions 服务开始。
创建 Azure Functions 服务
在我们编写任何代码之前,我们将创建函数应用。这将包含 Azure 门户中的函数。按照以下步骤操作:
创建一个新的 Function App 资源。Function App 可在 计算 类别下找到。
选择函数应用的订阅。
选择函数应用的资源组。这应该与我们在本章中创建的其他资源相同。
给函数应用起一个名字。这个名字也将是函数 URL 的起始部分。
选择 代码 作为部署机制。
将 .NET 作为函数的运行时堆栈。
选择 .NET 6.0 (长期支持) 作为版本。
选择离您的用户最近的位置。
选择 Windows 作为 操作系统 。
我们将使用 消费 计划作为我们的 托管 计划,因此我们只为使用的内容付费。Function app 将根据我们的需求进行扩展和缩减 – 而无需我们考虑 – 如果我们选择 消费 计划。
参考以下截图查看上述信息:
图 9.4 – 创建 Function App – 基础
点击 审查 + 创建 在创建函数应用之前审查设置。
点击 创建 以创建函数应用。
创建项目
如果您愿意,您可以在 Azure 门户中创建函数。我更喜欢使用 Visual Studio,因为代码编辑体验更好,并且可以使用源代码控制。对于此项目,我们需要在我们的解决方案中创建和配置单独的项目 – 一个 Azure Functions 项目和一个用于函数和将在第十章中构建的 .NET MAUI 应用之间共享代码的类库。要创建和配置项目,请按照以下步骤操作:
在 Visual Studio 中创建一个新项目。
在搜索字段中输入 function 以找到 Azure Functions 的模板。
点击以下截图所示的 Azure Functions 模板以继续:
图 9.5 – 创建新项目
将项目命名为 SticksAndStones.Functions。
将解决方案命名为 SticksAndStones.Functions,如以下截图所示,然后点击 下一步 :
图 9.6 – 配置您的新的项目
下一步是创建我们的第一个函数,如下所示:
在对话框顶部选择 .Net 6.0 (长期支持 ) 作为 函数工作器 。
将 Http 触发器 作为我们第一个函数的触发器。
点击 创建 以继续;我们的函数项目将被创建。
参考以下截图查看上述信息:
图 9.7 – 创建新的 Azure Functions 应用程序 – 其他信息
我们的第一个函数将返回 SignalR 服务的连接信息。为此,我们需要通过向 SignalR 服务添加连接字符串来连接函数,如下所示:
前往 Azure 门户中的 SignalR 服务 资源。
切换到左侧的 键 选项卡并复制连接字符串。
将 AzureSignalRConnectionString 作为设置的名称。
将连接字符串添加到 Visual Studio 项目中的 local.settings.json 文件中,以便能够在开发机上本地运行函数,如下面的代码块所示:
{ "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet", "AzureSignalRConnectionString": "SticksAndStones.Functions project, and add the code listed previously with your connection string.
接下来,在 SticksAndStones.Functions 项目中,我们需要引用 Microsoft.Azure.WebJobs.Extensions.SignalRService NuGet 包。此包包含我们与 SignalR 服务通信所需的类。如果在安装此包时发生错误并且您无法安装包,请确保项目中所有其他包都是最新版本,然后重试。
我们需要做的最后一个更改是调整自动命名空间生成。默认情况下,默认命名空间是项目的名称,这意味着本项目中所有类型都将有一个根命名空间 SticksAndStones.Functions。我们不需要 Functions 这部分,所以让我们将其删除:
在 解决方案资源管理器 中右键单击 SticksAndStones.Functions 项目并选择 属性 。
在 默认命名空间。
修改 $(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))。
这将根据点号 . 分割项目名称,仅使用第一部分并将任何空格替换为下划线。
现在,当我们创建一个新的类时,命名空间将仅以 SticksAndStones 开头。是时候创建一个共享项目,以便我们可以在 .NET MAUI 客户端和 Azure Functions 服务中重用代码了。
共享代码将放入一个类库项目中。要创建项目并将其从 SticksAndStones.Functions 项目中引用,请按照以下步骤操作:
在 解决方案资源管理器 中右键单击 SticksAndStones 解决方案节点并选择 添加 ,然后 新建项目 。
在如图所示的 添加新项目 对话框中搜索 类库 。
图 9.8 – 添加新项目
选择 类库 模板,然后点击 下一步 。
在StickAndStones.Shared命名空间中,如图下所示,然后点击下一步 :
图 9.9 – 配置您的新的项目
在附加信息 对话框中,选择.NET 6.0 (长期支持 )框架项目,然后点击创建 。
删除由项目模板创建的Class1.cs文件。
在SticksAndStones.Functions项目中添加对SticksAndStones.Shared的引用。
正如我们对SticksAndStones.Functions项目所做的那样,我们将通过以下步骤更改默认命名空间:
在解决方案资源管理器 中右键单击SticksAndStones.Functions项目并选择属性 。
在默认命名空间 。
修改$(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))。
这将根据.分割项目名称,只使用第一部分,并将任何空格替换为下划线。
现在,我们可以编写返回连接信息的函数的代码。
将玩家连接到游戏
游戏的第一步是建立连接。建立连接会将您添加到可用玩家的列表中,这样您或其他玩家就可以加入游戏。正如我们在本书中的其他项目中做的那样,首先,我们将创建存储或在不同服务之间传输数据的所需模型。然后,我们将实现Connect函数本身。
创建模型
我们需要在应用的生命周期中调用几个函数,这些函数位于第十章。第一个是建立与游戏服务的连接,称为Connect。本质上,这告诉服务有一个新的或现有的玩家正在活跃并准备游戏。Connect函数将注册玩家详细信息并返回连接字符串到 SignalR 中心,以便应用可以接收消息。在完成函数之前,我们需要一些模型。需要一个Player模型,一个Game模型,以及帮助在 Azure 函数和 SignalR 服务之间传递数据的模型。
在我们深入创建库之前,我们应该讨论本章中使用的命名约定。有一个命名约定将使确定类的使用方式变得更容易。当应用调用任何 Azure 函数时,如果需要发送任何数据,它将使用后缀为-Request的类来发送,任何返回数据的 Azure 函数将使用以-Response结尾的类来发送。对于通过 SignalR 中心发送的任何数据,我们将使用带有EventArgs后缀的类。这些类将包含对我们实际模型的引用,仅作为数据的容器。拥有这些类意味着您可以修改发送或接收的数据,而不会影响模型本身。
由于这是一个双人对战游戏,我们需要跟踪一些状态信息,以便我们知道谁在线以及正在进行的比赛。对于这个项目,我们将保持状态简单,不涉及实际的数据库,但我们将仍然使用 Entity Framework 来为我们完成大部分工作。
现在我们已经创建并引用了新的项目,并且我们已经建立了命名约定,我们可以开始创建所需的类。我们将从两个模型 Player 和 Match 开始。Player 代表每个人,而 Game 是两个 Player 实例之间的比赛以及游戏状态。要创建这两个模型,请按照以下步骤操作:
在 SticksAndStones.Shared 项目中创建一个新的文件夹名为 Models。
在 Models 文件夹中创建一个新的类名为 Player。
在 Player 类中创建一个名为 Id 的 public 属性,类型为 Guid,并将其初始化为 Guid.Empty。
创建另一个名为 GamerTag 的 public 属性,类型为 string,并将其初始化为 string.Empty。
创建一个名为 GameId 的 public 属性,类型为 Guid,并将其初始化为 Guid.Empty。
您的 Player 类现在应该类似于以下代码块:
namespace SticksAndStones.Models;public class Player { public Guid Id { get; set; } = Guid.Empty; public string GamerTag { get; set; } = string.Empty; public string EmailAddress { get; set; } = string.Empty; public Guid MatchId { get; set; } = Guid.Empty;}
我们的模型类将使用 Id 字段作为唯一标识符,以便我们可以单独定位每个对象。它将用于定位特定玩家进行消息传递以及将 Match 实例与 Player 实例相关联。GamerTag 将是玩家的显示名称,而 EmailAddress 是我们关联离开应用程序并再次登录的玩家的方式。最后,MatchId 属性将跟踪玩家是否正在积极进行游戏。
现在我们已经定义了 Player 类,是时候定义 Match 类了:
在 Models 文件夹中创建一个新的类名为 Match。
在 Game 类中创建一个名为 Id 的 public 属性,类型为 Guid,并将其初始化为 Guid.Empty。
创建一个名为 PlayerOneId 的 public 属性,类型为 Guid。
添加一个名为 PlayerOneScore 的 public 属性,类型为 int。
创建另一个名为 PlayerTwoId 的 public 属性,类型为 Guid。
添加一个名为 PlayerTwoScore 的 public 属性,类型为 int。
创建一个名为 NextPlayerId 的 public 属性,类型为 Guid。
创建一个名为 Sticks 的 public 属性,类型为 List<int>,并将其初始化为 new List<int>(24)。
创建一个名为 Stones 的 public 属性,类型为 List<int>,并将其初始化为 new List<int>(9)。
创建一个名为 Scores 的 public 属性,类型为 List<int>,并将其初始化为 new List<int>(2)。
创建一个名为 Completed 的 public 属性,类型为 bool,并将其初始化为 false。
创建一个名为 WinnerId 的 public 属性,类型为 Guid,并将其初始化为 Guid.Empty。
添加一个名为 New 的 public static 方法,该方法接受两个参数,参数类型均为 Guid,分别命名为 challengerId 和 opponentId。该方法返回一个 Game 类型的对象。该方法应返回一个新的 Game 实例,并将 Id 属性设置为 Guid.NewGuid(),PlayerOneId 和 NextPlayerId 设置为 opponentId,PlayerTwoId 设置为 challengerId。
Player 类现在应类似于以下代码块:
using System;using System.Collections.Generic;namespace SticksAndStones.Models;public class Match { public Guid Id { get; set; } = Guid.Empty; public Guid PlayerOneId { get; set; } public int PlayerOneScore { get; set; } public Guid PlayerTwoId { get; set; } public int PlayerTwoScore { get; set; } public Guid NextPlayerId { get; set; } public List<int> Sticks {get; set; } = new(new int[24]); public List<int> Stones {get; set;} = new(new int[9]); public List<int> Score = new(new int[2]); public bool Completed { get; set; } = false; public Guid WinnerId { get; set; } = Guid.Empty; public static Game New(Guid challengerId, Guid opponentId) { return new() { Id = Guid.NewGuid(), PlayerOne = opponent, PlayerTwo = challenger, NextPlayer = opponent }; }}
Player 和 Match 类将用于客户端和服务器之间的数据存储和数据传输。在我们创建模型之前,让我们使用 Entity Framework 添加数据库。执行以下步骤以添加对 Entity Framework 的引用并创建数据库上下文,以便 Player 和 Match 类可以存储在 InMemory 数据库中:
将 Microsoft.EntityFrameworkCore.InMemory 包引用添加到 SticksAndStones.Functions 项目中。
在 SticksAndStones.Functions 项目中创建一个名为 Repository 的新文件夹。
在 Repository 文件夹中创建一个名为 GameDbContext 的类。
修改类的构造函数以设置数据库选项:
public GameDbContext(DbContextOptions<GameDbContext> options) : base(options) { }
添加一个公共 Players 属性以存储 Player 对象:
public DbSet<Player> Players { get; set; }
添加一个公共 Matches 属性以存储 Match 对象:
public DbSet<Match Matches { get; set; }
在 OnModelCreating 方法中添加一个重写:
protected override void OnModelCreating(ModelBuilder modelBuilder){}
此方法是我们指定 Entity Framework 如何在关系数据库中将我们的类关联在一起的地方。
首先在 OnModelCreating 方法中声明每个类的标识符,如下所示:
modelBuilder.Entity<Player>() .HasKey<Player>(p => p.Id);modelBuilder.Entity<Match>() .HasKey<Match>(g => g.Id);base.OnModelCreating(modelBuilder);
Entity Framework 无法正确处理我们的 List<int> 属性。它假设由于它是一个列表,它们是相关实例。为了在 Entity Framework 中更改默认行为,我们可以使用以下突出显示的代码:
modelBuilder.Entity<Game>() .HasKey(g => g.Id);modelBuilder.Entity<Match>() .Property(p => p.Sticks) .HasConversion( toDb => string.Join(",", toDb), fromDb => fromDb.Split(',', StringSplitOptions.None).Select(int.Parse).ToList() ?? new(new int[24]));
modelBuilder.Entity<Match>() .Property(p => p.Stones) .HasConversion( toDb => string.Join(",", toDb), fromDb => fromDb.Split(',', StringSplitOptions.None).Select(int.Parse).ToList() ?? new(new int[9]));
base.OnModelCreating(modelBuilder);
每个块所做的是定义属性的转换。转换有两个 Lambda 表达式 - 一个是从 C# 对象到数据库的转换,另一个是从数据库到 C# 对象的转换。对于我们的 List<int> 属性,我们希望将 C# List<int> 转换为逗号分隔的整数字符串,然后将逗号分隔的整数字符串转换为 List<int>。
toDB 是 List<int> 的一个实例,因此要将它转换为逗号分隔的数字列表,我们可以使用 String.Join 函数将列表中的每个元素与它们之间的 , 连接起来。
fromDb 是一个包含逗号分隔的数字的 string 值。要将该值转换为 List<int>,我们可以使用 String.Split 方法来隔离每个数字,然后将每个数字传递给 Int.Parse 方法以将数字转换为 int 值。Select 将生成 IEnumberable<int>;我们可以使用 ToList 将其转换为 List<int>。如果它没有创建列表,我们可以提供一个默认值列表,就像我们在 Match 类本身中所做的那样。
要初始化 Entity Framework 以使用内存数据库,我们需要创建一个 Startup 方法。要创建该方法并初始化数据库,请按照以下步骤操作:
在 SticksAndStones.Functions 项目的根目录中创建一个名为 Startup 的新类。
修改以下突出显示的代码的类文件:
using Microsoft.Azure.Functions.Extensions.DependencyInjection;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.DependencyInjection;using SticksAndStones.Repository;[assembly: FunctionsStartup(typeof(SticksAndStones.Startup))]
namespace SticksAndStones;public class Startup : FunctionsStartup { public override void Configure(IFunctionsHostBuilder builder) { string SqlConnection = Environment.GetEnvironmentVariable("SqlConnectionString"); builder.Services.AddDbContextFactory<GameDbContext>( options => { options.UseInMemoryDatabase("SticksAndStones"); }); }}
当 SticksAndStones.Functions 项目在运行时加载时,将调用 Startup 方法。然后,它将为之前创建的 GameDbContext 类创建一个工厂以创建其实例,并使用内存数据库对其进行初始化。
这就完成了我们对 Entity Framework 和基本模型Player和Game的设置。我们还需要一个最终模型,将 SignalR 连接信息发送到客户端。要创建此模型,请按照以下步骤操作:
在Models文件夹中创建一个名为ConnectionInfo的新类。
添加一个名为Url的公共属性,它是一个string值。
添加另一个名为AccessToken的公共属性,它也是一个string值。
ConnectionInfo类应该看起来像这样:
namespace SticksAndStones.Models;public class ConnectionInfo { public string Url { get; set; } public string AccessToken { get; set; }}
现在模型已经创建,我们可以开始创建Connect函数。
创建 Connect 函数
我们将从一个连接我们的玩家到游戏的函数开始,这个函数恰当地命名为Connect。这个函数将期望在请求体中发送一个部分填充的Player对象。该函数将返回一个完全填充的Player对象、当前连接的玩家列表以及客户端连接到 SignalR 中心所需连接信息。为了使输入和输出更清晰,我们将它们包装起来。
要创建输入和输出类,请按照以下步骤操作:
在Messages文件夹中创建一个名为ConnectMessages的新类。
修改ConnectMessages.cs使其看起来像这样:
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct ConnectRequest(Player Player);public record struct ConnectResponse(Player Player, List<Player> Players, ConnectionInfo ConnectionInfo);
对于所有将在客户端和 Azure 函数或 SignalR 服务之间传输数据的类,我们将使用record语法。由于这些类没有任何实际功能,它们唯一的目的就是包含我们的模型。通过使用record结构体,我们还提高了我们函数的内存使用效率,因为新实例将在本地内存中创建,而不是全局内存中,这需要额外的处理。record语法将构造函数和属性声明合并为单行代码,消除了大量无实际益处的样板代码。
你会注意到我们正在使用我们在创建模型 部分讨论的约定。后缀为Request或Response的类被用作任何 Azure 函数的输入和输出。对于通过 SignalR 服务发送的任何数据,该类将使用后缀为EventArgs。
当新客户端连接时,将通过 SignalR 服务向其他用户发送消息,以表明他们已连接。此消息还将用于通知玩家开始或结束游戏。要创建此类消息,请按照以下步骤操作:
在SticksAndStones.Shared项目的Messages文件夹中创建一个名为PlayerUpdatedEventArgs的新类。
修改该类,使其成为一个具有单个Player参数的record结构体,如下面的代码片段所示:
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct PlayerUpdatedEventArgs(Player Player);
现在我们已经创建了Connect函数所需的架构,我们可以开始编写函数本身:
创建一个名为Hubs的新文件夹。我们将把我们的服务类放入这个文件夹。
将Function1.cs文件移动到Hubs文件夹。
对于移动文件和调整命名空间的下两个提示,回答是 。
将默认的 Function1.cs 文件重命名为 GameHub.cs,并在重命名提示中点击 是 。
打开 GameHub.cs 文件,将类名 GameHub 重命名为 GameHub,将 internal static 访问修饰符替换为 public,并从 ServerlessHub 基类派生,如以下突出显示所示:
public class GameHub : ServerlessHub
{ [FunctionName("Function1")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, ILogger log) { log.LogInformation("C# HTTP trigger function processed a request."); string name = req.Query["name"]; string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); dynamic data = JsonConvert.DeserializeObject(requestBody); name = name ?? data?.name; string responseMessage = string.IsNullOrEmpty(name) ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response." : $"Hello, {name}. This HTTP triggered function executed successfully."; return new OkObjectResult(responseMessage); }}
将默认的 Function1 函数重命名为 Connect,同时移除 static 修饰符。方法签名应如下所示的高亮代码片段:
[FunctionName("HttpTrigger attribute indicates that this function is called by using the HTTP protocol and not by some other means, such as a SignalR message or a timer. The function is only called using the HTTP POST method, not GET.
要向连接到 SignalR 中心的所有客户端发送消息,我们需要另一个 SignalR 绑定。这次是一个 HubName,类型为 IAsyncCollector<SignalRMessage>,如下所示:
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req,[SignalRConnectionInfo(HubName = "GameHub")] SignalRConnectionInfo connectionInfo,[SignalR(HubName = "GameHub")] IAsyncCollector<SignalRMessage> signalRMessages,ILogger log)
删除 Connect 方法的所有内容,并按照以下步骤实现功能:
在 Azure Functions 中,日志记录非常重要,因为它有助于在生产环境中调试。因此,让我们添加一个 log 消息:
log.LogInformation("A new client is requesting connection");
客户端,一个 .NET MAUI 应用,将在 HTTP 请求体中发送 ConnectRequest 作为 JSON。要从请求体中获取 ConnectRequest 的实例,请使用以下代码行:
var result = await JsonSerializer.DeserializeAsync<ConnectRequest>(req.Body, jsonOptions);var newPlayer = result.Player;
您将需要在命名空间声明中添加 using System.Text.Json。这使用 System.Text.Json.JsonSerializer 类来读取请求体的内容,并从中创建一个 ConnectRequest 对象。它使用 jsonOptions 正确反序列化对象。
现在,我们需要定义 jsonOptions 字段。在 Connect 方法上方添加以下代码行:
internal class GameHub : ServerlessHub { private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
[FunctionName("Connect")] public async Task<IActionResult> Connect(
JsonSerializerDefaults.Web 确保了 JSON 格式正确,以便 Azure Functions 和 SignalR 服务可以正确地序列化和反序列化对象。主要将强制执行以下操作:
如果我们收到不良玩家数据,向客户端返回 ArgumentException,如下所示:
if (newPlayer is null){ var error = new ArgumentException("No player data.", "Player"); log.LogError(error, "Failure to deserialize arguments"); return new BadRequestObjectResult(error);}if (string.IsNullOrEmpty(newPlayer.GamerTag)){ var error = new ArgumentException("A GamerTag is required for all players.", "GamerTag"); log.LogError(error, "Invalid value for GamerTag"); return new BadRequestObjectResult(error);}if (string.IsNullOrEmpty(newPlayer.EmailAddress)){ var error = new ArgumentException("An Email Address is required for all players.", "EmailAddress"); log.LogError(error, "Invalid value for EmailAddress"); return new BadRequestObjectResult(error);}
由于函数的返回类型是 IActionResult,我们无法简单地返回自定义对象。相反,我们需要创建一个派生或实现 IActionResult 的对象,并传入我们的结果。在错误的情况下,我们将使用 BadRequestObjectResult,它将在构造函数中接受 Exception 作为参数。BadRequestObjectResult 将 HTTP 状态码设置为 400,表示错误。然后,客户端可以检查此状态码,以确定在解析响应体之前请求是否成功。
下几个步骤将需要我们查询数据库,因此我们需要将数据库上下文工厂添加到类中。添加 Microsoft.EntityFrameworkCore 命名空间声明:
using Microsoft.Azure.WebJobs.Extensions.SignalRService;private field to store the context factory and a constructor with an argument that will be fulfilled by dependency injection, as follows:
private readonly JsonSerializerOptions jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); private readonly IDbContextFactory dbContextFactory;public GameHub(IDbContextFactory dbcontext){ contextFactory = dbContextFactory;}
[FunctionName("Connect")]public async Task Connect(
添加 System.Linq 的命名空间声明以允许使用 Linq 查询:
using System;GamerTag in the database to ensure it isn’t in use already by another player, as follows:
使用 var context = contextFactory.CreateDbContext();log.LogInformation("Checking for GameTag usage");var gamerTagInUse = (from p in context.Players where string.Equals(p.GamerTag, newPlayer.GamerTag, StringComparison.InvariantCultureIgnoreCase) && !string.Equals(p.EmailAddress, newPlayer.EmailAddress, StringComparison.OrdinalIgnoreCase) select p).Any();if (gamerTagInUse){ var error = new ArgumentException($"The GamerTag {newPlayer.GamerTag} is in use, please choose another.", "GamerTag"); log.LogError(error, "GamerTag in use."); return new BadRequestObjectResult(error);}
The first step is to get a new database context from the factory. The `Players` list from the database context to compare `GamerTag` value to other GamerTagsvalues. However, we want to exclude a result if it matches `EmailAddress` since that would indicate the records are identical, and this user is just signing back in again.
现在,查询 Players 数据集以找到匹配的电子邮件的玩家:
log.LogInformation("Locating Player record."); var thisPlayer = (from p in context.Players where string.Equals(p.EmailAddress, newPlayer.EmailAddress, StringComparison.OrdinalIgnoreCase) select p).FirstOrDefault();
如果在 Players 中没有匹配的 Player,则将 Player 添加到数据集中:
if (thisPlayer is null){ log.LogInformation("Player not found, creating."); thisPlayer = newPlayer; thisPlayer.Id = Guid.NewGuid(); context.Add(thisPlayer); await context.SaveChangesAsync();}
我们为 Player 对象分配一个新的 Guid,以便每个 Player 都有一个唯一的标识符。这也可以通过 Entity Framework 来完成;然而,我们将在这里处理它。然后使用上下文添加 Player 实例,以便跟踪任何更改。之后,SaveChangesAsync 将提交所有更改到数据库。
在 Connect 函数的下一步中,我们需要向所有已连接的玩家发送一条消息,告知他们有新玩家加入。我们可以使用 SendAsync 方法来完成此操作。SendAsync 方法接受两个参数 - 一个作为 string 值的方法名称,该消息旨在为其发送,以及一个作为 object 值的消息。为了确保我们发送和接收正确的方法,我们将创建一个常量值。在 SticksAndStones.Shared 项目的根目录下创建一个名为 Constants 的新类,然后更新它,使其看起来像这样:
namespace SticksAndStones;public static class Constants { public static class Events { public static readonly string PlayerUpdated = nameof(PlayerUpdated); }}
现在,我们可以通知其他已连接的玩家有新玩家连接。打开 GameHub 类,并在 Connect 方法的末尾添加以下代码:
log.LogInformation("Notifying connected players of new player."); await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(thisPlayer));
此代码使用 ServerlessHub 基类中 Clients.All 集合的 SendAsync 方法向所有已连接的客户端发送消息。我们传递 Constants.Events.PlayerUpdated,即 "PlayerUpdated" 字符串,作为方法名称。作为参数,我们发送包裹在 PlayerUpdatedEventArgs 中的 Player 实例。我们将在 第十章 中处理此消息。
现在,从数据库中获取可用的玩家集合以发送回客户端:
// Get the set of available players log.LogInformation("Getting the set of available players."); var players = (from player in context.Players where player.Id != thisPlayer.Id select player).ToList();
使用 Linq,我们可以轻松查询 Players 集合并排除当前玩家。
在这个阶段,我们需要从 SignalR 服务中获取 SignalR 连接信息。这可以通过调用 ServerlessHub 基类中的 NegotiateAsync 方法来实现。此外,为了能够向单个用户发送定向消息,我们将连接的 UserId 值设置为玩家 ID 值。添加以下代码行以配置和检索 SignalR 连接信息:
var connectionInfo = await NegotiateAsync(new NegotiationOptions() { UserId = thisPlayer.Id.ToString() });
现在我们已经拥有了返回给客户端所需的所有信息,我们可以构建ConnectResponse对象。我们将使用ConnectionInfo类并将SignalRConnection属性映射到它,这样我们就可以避免在共享库中引用 SignalR 服务:
log.LogInformation("Creating response.");var connectResponse = new ConnectResponse(){ Player = thisPlayer, Players = players, ConnectionInfo = new Models.ConnectionInfo { Url = connectionInfo.Url, AccessToken = connectionInfo.AccessToken }};
一旦ConnectResponse被初始化,我们可以通过使用OkObjectResult来返回它,这将使用 HTTP 响应代码200 OK :
log.LogInformation("Sending response.");return new OkObjectResult(connectResponse);
要测试我们刚刚编写的函数,你可以在 Visual Studio 中按下F5 后,使用 PowerShell 命令提示符和以下命令:
Invoke-WebRequest -Headers @{ ContentType = "application/json" } -Uri http://localhost:7024/api/Connect -Method Post -Body ''
在Uri参数中使用的端口号可能因项目而异。你可以通过打开SticksAndStones.Functions项目的Properties文件夹中的launchSettings.json文件来获取正确的端口号。端口号设置在commandLineArgs属性中,如下所示:
{ "profiles": { "SticksAndStones.Functions": { "commandName": "Project", "commandLineArgs": "--port Uri parameter after localhost:.
In the `Body` parameter, you can add the JSON that the command is expecting. For the `Connect` function, this would be `ConnectRequest` and would look like this:
'{ "player": { "gamerTag": "NewPlayer2", "emailAddress": "newplayer2@gmail.com", }}'
The full command will look like this:
Invoke-WebRequest -Headers @{ ContentType = "application/json" } -Uri http://localhost:7024/api/Connect -Method Post -Body '{ "player": { "gamerTag": "NewPlayer2", "emailAddress": "newplayer2@gmail.com", }}'
Go ahead and try out various versions of the command to see how the function reacts.
Now that we can connect players to the game server, let’s look at what is needed for the lobby.
Refreshing the lobby
In the Sticks and Stones App, which you will create in Chapter 10, once a player has connected, they will move to the lobby page. Initially, the lobby will be populated from the list of players sent in the response from the `Connect` function. Additionally, as each player connects, the lobby will be updated through a SignalR event.
But we all get impatient and want a way to refresh the list immediately. So, the lobby page has a way to refresh the list; to do so, it will call the `GetAllPlayers` function.
Let’s start by creating the messages needed for `GetAllPlayers`.
Creating the messages
`GetAllPlayers` takes no parameters, so we only need to create the `GetAllPlayersResponse` type. Follow these steps to add `GetAllPlayersResponse`:
1. In the `SticksAndStones.Shared` project, create a new file named `GetAllPlayers` **Messages.cs** in the `Messages` folder.
2. Modify the contents of the file so that it looks as follows:
```
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct GetAllPlayersResponse(List<Player> Players);
```cs
With the messages created, we can move on to the `GetAllPlayers` function.
Getting all the players
`GetAllPlayers` is called using the `Http` `GET` method and has an optional parameter that is passed through `QueryString` using the `id` key. The optional parameter is used to exclude a specific `id` from the returned list. This makes it so that the app can send the current player’s `id` and not have it returned in the list. To create the `GetAllPlayers` function, follow these steps:
1. In the `GameHub` class, after the `Connect` method, add the following method declaration:
```
[FunctionName("GetAllPlayers")]public IActionResult GetAllPlayers([HttpTrigger(AuthorizationLevel.Function, "get", Route = "Players/GetAll")] HttpRequest req,ILogger log){}
```cs
Not much is new here other than using `"get"` instead of `"post"` for the `Http` method, and `Route` is set to `"Players/GetAll"`, which would make the URL for the function `http://localhost:7024/api/Players/GetAll`.
2. In the method, we will process the `id` option parameter. To do so, add the following code:
```
// 排除提供的 playerId Guid playerId = Guid.Empty;if (req.Query.ContainsKey("id")){ string id = req.Query["id"]; if (!string.IsNullOrEmpty(id)) { playerId = new Guid(id); }}
```cs
In this code, we check for the existence of a key named `id`. If it exists, then its value is retrieved and converted into a `Guid` value and assigned to the `playerId` variable.
3. Next, we can query the database for all players, and exclude `player.Id` using the following code:
```
using var context = contextFactory.CreateDbContext();// 获取可用玩家的集合 log.LogInformation("获取可用玩家的集合.");var players = (from player in context.Players where player.Id != playerId select player).ToList();
```cs
4. Finally, return `OkObjectResult` with a new `GetAllPlayersResponse` object initialized with the list of `players`:
```
return new OkObjectResult(new GetAllPlayersResponse(players));
```cs
Now that we can refresh the lobby with a list of all the players, it’s time to match them up for a game.
Challenging another player to a game
To test your skills at this game, you’ll need an opponent – someone who would also like to test their skills against yours. This section will build the functionality needed in the `SticksAndStones.Function` project to have one player – the challenger – challenge another player – the opponent – to a game. The opponent has the option to accept the challenge or deny it. We will also handle the case where the opponent does not respond since they might have put their phone down; this is an edge case.
The interactions in this use case can get tricky, so let’s review the following diagram to get a better understanding of what we are building:

Figure 9.10 – Challenge diagram
The process starts with a user interaction that results in the client making an HTTP request to the `GameHub` instance via the `IssueChallenge` function. The client will pass the challenger and opponent details when making the HTTP call. `IssueChallenge` will create an `Id` value to track this process. `IssueChallenge` will then send a direct message to the opponent via the SignalR hub using the `SendAsync` method. The message will include the `Id` value that was created earlier, the challenger, and the opponent details as an instance of `ChallengeEventArgs`. The opponent’s client will receive the message via an `On<ChallengeEventArgs>` event handler. The opponent will then have the choice of accepting or declining the challenge. The response is sent back to the `GameHub` instance using the `AcknowledgeChallenge` function. The `Id` value of the challenge is sent along with `ChallengeResponse`, either `Accept` or `Decline`. A third possibility is `Timeout`. If the opponent never responds, then after a certain amount of time has passed, `Challenge` will `Timeout`. In either event, the result is then returned to the challenger using the response of the `IssueChallenge` call.
Let’s get started by defining the messages.
Creating the messages and models
We will start with `IssueChallengeRequest` since that is the first message that is being sent. Follow these steps to create the class:
1. In the `SticksAndStones.Shared` project, create a new file named `ChallengeMessages.cs` under the `Message` folder.
2. Modify the file so that it looks as follows:
```
using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct IssueChallengeRequest(Player Challenger, Player Opponent);
```cs
As we have in the `Connect` function, we use a `record` struct to eliminate a lot of the boilerplate code needed to define a `struct` value. Our message only needs two `Player` objects – `Challenger` and `Opponent`. The client will have both available when it makes the call to this function. `Challenger` will be the client making the call to `IssueChallenge` and `Opponent` will be the opposing side.
The `IssueChallenge` function will return `IssueChallengeResponse`. This `Issue` **ChallengeResponse** will have just one field, `Response`, which will be an `enum` value called `ChallengeResponse`. Follow these steps to create `ChallengeResponse`:
1. In the `SticksAndStones.Shared` project, in the `Models` folder, create a new `enum` named `ChallengeResponse`.
2. Add the following values to the `enum` value:
* `None`
* `Accepted`
* `Declined`
* `Timeout`
Your code should look like this:
```
namespace SticksAndStones.Models;public enum ChallengeResponse { None, Accepted, Declined, TimeOut }
```cs
To create the remaining messages for the `IssueChallenge` and `AcknowledgeChallenge` functions, follow these steps:
1. Open the `ChallengeMessages.cs` file and add the following declaration at the end of the file:
```
public record struct IssueChallengeResponse(ChallengeResponse Response);
```cs
2. When the opponent responds to a challenge, they will call the `AcknowledgeChallenge` function and pass an `AcknowledgeChallengeRequest` object as an argument. In the `ChallengeMessages.cs` file, add the following declaration to create `AcknowledgeChallengeRequest`:
```
public record struct AcknowledgeChallengeRequest(Guid Id, ChallengeResponse Response);
```cs
That completes the messages that are sent or received from the `GameHub` functions for a challenge. That just leaves `ChallengeEventArgs`, which is sent from `GameHub` to the opponent. To create the `ChallengeEventArgs` class, follow these steps:
1. In the `Messages` folder of the `SticksAndStones.Shared` project, create a new file named `ChallengeEventArgs.cs`.
2. Replace the contents of the `ChallengeEventArgs.cs` file with the following:
```
using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct ChallengeEventArgs(Guid Id, Player Challenger, Player Opponent);
```cs
3. To add the method name constant for the `SendAsync` method, open the `Constants.cs` file in the `SticksAndStones.Shared` project and add the following highlighted field to the `Events` class:
```
public static class Events { public static readonly string PlayerUpdated = nameof(PlayerUpdated); public static readonly string Challenge = nameof(Challenge);
```cs
As with the previous message definitions, `ChallengeEventArgs` is also defined as `public record struct`. The parameters are an `Id` value of the `Guid` type and two `Player` objects – one for `Challenger` or the initiator, and one for `Opponent` or the receiver. `Id` will be created in the `IssueChallenge` function and is used to correlate the challenge with the response. This is needed because we are tracking the challenge and if a certain amount of time has passed, we expire the challenge. `Id` is used to track that state and check whether the challenge is still valid if the client responds.
What was not included in the diagram in *Figure 9**.10* is a structure that is used to track the challenge in the `GameHub` class. It is only needed while a challenge is active and hasn’t timed out. To create the `Challenge` class, follow these steps:
1. Create a file named `Challenge.cs` in the `Models` folder of the `SticksAndStones.Shared` project.
2. Replace the contents of the `Challenge.cs` file with the following code:
```
using System;namespace SticksAndStones.Models;public record struct Challenge(Guid Id, Player Challenger, Player Opponent, ChallengeResponse Response);
```cs
As with previous models, we use a `record` struct. The `Challenge` class has various properties – `Id`, `Challenger` as a `Player` type, `Opponent` as a `Player` type, and `ChallengeResponse`, which is called `Response`.
If a player accepts the challenge, then the two players will start a match with each other. Each player will be notified that the match has begun by receiving a `MatchStarted` SignalR event. To create the event and its arguments, follow these steps:
1. Open the `Constants.cs` file in the `SticksAndStones.Shared` project and add the following highlighted field to the `Events` class:
```
public static class Events { public static readonly string PlayerUpdated = nameof(PlayerUpdated); public static readonly string Challenge = nameof(Challenge); public static readonly string GameStarted = nameof(MatchStarted);}
```cs
2. In the `Messages` folder of the `SticksAndStones.Shared` project, create a new file named `MatchStartedEventArgs.cs`.
3. Replace the contents of the `MatchStartedEventArgs.cs` file with the following:
```
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct MatchStartedEventArgs(Match Match);
```cs
That concludes the new messages and models that are needed to allow one player to challenge another to a game of `SticksAndStones`. Next, we will create the first of two functions that will handle the process in the `GameHub` class.
Creating the IssueChallenge function
We’ll start with the `IssueChallenge` function. This function is called from the `Challenger` client to start the challenge process. The client will pass their `Player` object, `Challenger`, and the player they are challenging – that is, `Opponent`. These two models are contained in an `IssueChallengeRequest` object. The `IssueChallenge` function will need to perform the following actions:
* Validate input
* Create a challenge object
* Send a challenge to the opponent
* Wait for a response from the opponent
* Send the response to the challenger
To create the function and implement these actions, follow these steps:
1. Open the `GameHub.cs` file in the `SticksAndStones.Functions` project.
2. Add the `IssueChallenge` function declaration, as follows:
```
[FunctionName("IssueChallenge")]public async Task<IssueChallengeResponse> IssueChallenge([HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Challenge/Issue")] HttpRequest req, ILogger log){}
```cs
The `FunctionName` attribute tells Azure Functions that this is an available function. The `req` parameter is an `HttpRequest` object, and the Azure Functions runtime will provide its instance when the function is called. It is attributed by the `HttpTrigger` attribute, which makes the function available via an `Http` API call. `HttpRequest` must use the `POST` method and not `GET` when making the call and the function’s `Route` or `Url` will end with `Challenge/Issue`. The function returns `IssueChallengeResponse` to the caller.
3. The first action the function performs is to validate the inputs. The `IssueChallengeRequest` object is sent as part of the body of the `Http` POST request. To retrieve the instance, use the following code in the `IssueChallenge` function:
```
{ var result = await JsonSerializer.DeserializeAsync<IssueChallengeRequest>(req.Body, jsonOptions);}
```cs
This is the same way we retrieved the arguments that were passed in the `Connect` function. The main difference is that the type of object being returned by the `DeserializeAsync` method is `IssueChallengeRequest`, not `ConnectRequest`. The `jsonOptions` field is already defined in the `GameHub` class.
4. Now, we need to check whether the challenger and opponent are valid. Valid means that they exist in our database, and neither are currently in a match. We will use `IssueChallenge` function to verify that the players exist:
```
using var context = contextFactory.CreateDbContext();Guid challengerId = result.Challenger.Id;var challenger = (from p in context.Players where p.Id == challengerId select p).FirstOrDefault();
Guid opponentId = result.Opponent.Id;var opponent = (from p in context.Players where p.Id == opponentId select p).FirstOrDefault();if (challenger is null)throw new ArgumentException(paramName: nameof(challenger), message: $"{challenger.GamerTag} is not a valid player.");if (opponent is null)throw new ArgumentException(paramName: nameof(opponent), message: $"{opponent.GamerTag} is not a valid player.");
```cs
First, we capture the `Id` value of the player. We use this `Id` to query the `Players` `DbSet` in the database context for a matching `Id` and return the `Player` object if it exists; otherwise, we return `null`. If the object is `null`, then we exit the function by throwing `ArgumentException` and passing the name of the object as the `paramName` argument and a message detailing the issue. This can be used on the client to display an error message.
5. The following code will check whether the players are currently engaged in a match with another player. Add the following code to the end of the `IssueChallenge` function:
```
var challengerInMatch = (from g in context.Matches
where g.PlayerOneId == challengerId || g.PlayerTwoId == challengerId
select g).Any();
var opponentInMatch = (from g in context.Matches
where g.PlayerOneId == opponentId || g.PlayerTwoId == opponentId
select g).Any();
if (challengerInMatch)
throw new ArgumentException(paramName: nameof(challenger), message: $"{challenger.GamerTag} is already in a match!");
if (opponentInMatch)
throw new ArgumentException(paramName: nameof(opponent), message: $"{opponent.GamerTag} is already in a match!");
```cs
Again, we use `Matches` `DbSet`. We are not looking for the `Match` instance, just the fact that one does exist, where either `PlayerOneId` or `PlayerTwoId` is the player’s `Id` value. We use the `Any` function to return `true` if there are any results and `false` if there are no results. Again, we throw `ArgumentException` if either player is in a match with an appropriate message.
6. At this point, we have validated that both players exist and can join a new game. We will need to capture the game `Id` value if the challenge is accepted, so let’s create the variable and log some details before moving on:
```
Guid matchId = Guid.Empty;log.LogInformation($"{challenger.GamerTag} has challenged {opponent.GamerTag} to a match!");
```cs
The next step in the `IssueChallenge` function is to create a `Challenge` object. But because we want to track how long `Challenge` is waiting so that we can time it out, we need a helper class to abstract that detail away from the function.
Don’t reinvent the wheel
The implementation of `ChallengeHandler` is heavily based on `AckHandler` from the **Azure SignalR AckableChatRoom** sample. The source for the sample is available at [`github.com/aspnet/AzureSignalR-samples/tree/main/samples/AckableChatRoom`](https://github.com/aspnet/AzureSignalR-samples/tree/main/samples/AckableChatRoom).
Let’s create the `ChallengeHandler` class by following these steps:
1. Create a new folder in the `SticksAndStones.Functions` folder named `Handlers`.
2. Create a new class named `ChallengeHandler` in the `Handlers` folder, and change the access modifier from `internal` to `public`.
3. Add a constructor for the class that has three parameters – `completeAcksOnTimeout` as `bool`, `ackThreshold` as `TimeSpan`, and `ackInterval` as `TimeSpan`. The constructor will create a timer to periodically clear out old challenges and store the `ackThreshold` value in a class field. The class’s contents should look like this:
```
private readonly TimeSpan ackThreshold;private readonly Timer timer;
public ChallengeHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval){ if (completeAcksOnTimeout) { timer = new Timer(_ => CheckAcks(), state: null, dueTime: ackInterval, period: ackInterval); } this.ackThreshold = ackThreshold;}
```cs
You will need to add a `using` declaration for the `System.Threading` namespace to use the `Timer` type. The `CheckAcks` method will be created later in this section.
4. To provide some reasonable defaults for the constructor, we will create a parameterless constructor and provide the defaults, as shown in the following snippet:
```
private readonly Timer timer; public ChallengeHandler() : this(completeAcksOnTimeout: true, ackThreshold: TimeSpan.FromSeconds(30), ackInterval: TimeSpan.FromSeconds(1)) { }
public ChallengeHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval)
```cs
This will provide the default values to the main constructor.
5. Now, to create a new `Challenge`, the `IssueChallenge` function will call a method named `CreateChallenge`, as shown here:
```
public (Guid id, Task<Challenge> responseTask) CreateChallenge(Player challenger, Player opponent){ var id = Guid.NewGuid(); var tcs = new TaskCompletionSource<Challenge>(TaskCreationOptions.RunContinuationsAsynchronously); handlers.TryAdd(id, new(id, tcs, DateTime.UtcNow, new(id, challenger, opponent, ChallengeResponse.None))); return (id, tcs.Task);}
private record struct ChallengeRecord(Guid Id, TaskCompletionSource<Challenge> ResponseTask, DateTime Created, Challenge Challenge);
```cs
Add this declaration to the top of the `ChallengeHandler` class. This `record` has an `Id` value – for uniqueness, `TaskCompletionSource` – a `DateTime` value to track the creation time, and the `Challenge` object itself. We keep a list of `ChallengeRecord` instances in another field called `handlers`. The `handlers` field, which is declared right after the `ChallengeRecord` class, is as follows:
```
private readonly ConcurrentDictionary<Guid, ChallengeRecord> handlers = new();
```cs
We use `ConcurrentDictionary` since we may be accessing the field from several different threads at the same time. `ConncurrentDictionary` is designed to prevent data corruption in multithreaded situations, like this one.
Once `TaskCompletionSource`, `Challenge`, and `ChallengeRecord` have been created, the `Challenge` `Id` value and the `Task` value associated with `TaskCompletionSource` are returned as a `Tuple` value to the `IssueChallenge` function. We will see what happens to that data later in this section when we complete the `IssueChallege` function.
Finally, to resolve the missing namespaces, add the following highlighted namespace declarations to your `ChallengeHandler` class file:
```
using SticksAndStones.Models;
using System;
using System.Collections.Concurrent;
使用 System.Threading;
AcknowledgeChallenge 函数将调用一个名为 Respond 的方法。Respond 方法会从字典中移除 ChallengeRecord(如果存在),并返回关联的 Challenge。如果没有 ChallengeRecord,则返回一个新的空 Challenge 记录,如下代码所示:
```cs
public Challenge Respond(Guid id, ChallengeResponse response){ if (handlers.TryRemove(id, out var res)) { var challenge = res.Challenge; challenge.Response = response; res.ResponseTask.TrySetResult(challenge); return challenge; } return new Challenge();}
```
```cs
6. The `CheckAcks` method, which is called periodically to check for `Challenge` objects that have expired and have not been responded to, looks like this:
```
private void CheckAcks(){ foreach (var pair in handlers) { var elapsed = DateTime.UtcNow - pair.Value.Created; if (elapsed > ackThreshold) { pair.Value.ResponseTask.TrySetException(new TimeoutException("Response time out")); } }}
```cs
This method will iterate over all the pairs in the `handlers` dictionary. For each one, it will determine whether the elapsed time is greater than the threshold provided in the constructor. If it is, then the task fails with a `TimeOutException` error.
7. To wrap up this class, we need to make sure that we clean up any remaining tasks when the service shuts down. We will handle canceling tasks in a `Dispose` method, which is implemented via the `IDisposable` interface. Add the `IDisposable` interface to the `ChallengeHandler` class and add the following `Dispose` method to the end of the class:
```
public void Dispose(){ timer?.Dispose(); foreach (var pair in handlers) { pair.Value.ResponseTask.TrySetCanceled(); }}
```cs
The `Dispose` method will dispose of the timer since we don’t want that firing any longer. Then, it iterates over the handlers and cancels each of the tasks.
That should complete the `ChallengeHandler` class. We can now resume the implementation of the `IssueChallenge` function:
1. Open the `GameHub.cs` file and locate the constructor, modifying it as highlighted in the following code:
```
private readonly GameDbContext context;private readonly ChallengeHandler challengeHandler;
public GameHub(GameDbContext dbcontext, ChallengeHandler handler){ context = dbcontext; challengeHandler = handler;
}
```cs
Since we will need the `ChallengeHandler` class, and it needs to maintain state, we will use dependency injection and have the Azure Functions runtime supply us with the instance.
2. Open the `Startup.cs` file in the `SticksAndStones.Function` project and add the following line of code at the end of the `Configure` method:
```
builder.Services.AddSingleton<ChallengeHandler>();
```cs
This will register `ChallengeHandler` with dependency injection to allow the Azure Functions runtime to manage the instance creation and lifetime.
3. Open the `GameHub.cs` file and navigate to the bottom of the `IssueChallenge` function.
4. Add the following lines of code:
```
var challengeInfo = challengeHandler.CreateChallenge(challenger, opponent);log.LogInformation($"Challenge [{challengeInfo.id}] has been created.");
log.LogInformation($"Waiting on response from {opponent.GamerTag} for challenge[{challengeInfo.id}].");await Clients.User(opponent.Id.ToString()).SendAsync(Constants.Events.Challenge, new ChallengeEventArgs(challengeInfo.id, challenger, opponent));
```cs
This code will first call `CreateChallenge` using the `ChallengeHandler` instance that we are getting in the constructor. `challengeInfo` is a `Tuple` value of the `Challenge` `Id` type and `task`.
Next, the opponent is sent a SignalR `Challenge` message with `ChallengeEventArgs`. This message is sent slightly differently since this message will only be sent to the client that matches the opponent’s `Id`.
5. Now, we need to wait for the opponent’s response, or a timeout from `ChallengeHandler`, by using the following code:
```
ChallengeResponse response;try { var challenge = await challengeInfo.responseTask.ConfigureAwait(false); log.LogInformation($"Got response from {opponent.GamerTag} for challenge[{challengeInfo.id}]."); response = challenge.Response;}catch { log.LogInformation($"Never received a response from {opponent.GamerTag} for challenge[{challengeInfo.id}], it timed out."); response = ChallengeResponse.TimeOut;}return new(response);
```cs
The real trick in this code is the `challengeInfo.responseTask` await. `responseTask` is the task that is created as part of `TaskCompletionSource` in `ChallengeHandler`. By awaiting it, we do not continue until either the `Respond` method is called and the task is completed, or the task is failed by setting a `TimeoutException` error in the `CheckAcks` method of `ChallengeHandler`.
Once one of those conditions is `true`, the method completes and we can get the response from the returned `Challenge`, or in the case of a timeout, handle the exception and return the response to the client in a new instance of `IssueChallengeResponse`.
The `IssueChallenge` function is now complete. The client can call the function and it will send a message to the opponent’s client and wait for the response. If the opponent client does not respond in a defined amount of time, which is 30 seconds by default, then the challenge will time out. Now, let’s work on accepting or declining a challenge. As with the `Connect` function, you can try it out using the command line. You just need to connect two players, and then have one challenge the other!
Creating the AcknowledgeChallenge function
The `AcknowledgeChallenge` function is used by the client to respond to an open challenge from another player. Let’s create the function by following these steps:
1. Add a new function to the `GameHub` class, as follows:
```
[FunctionName("AcknowledgeChallenge")]public async Task AcknowledgeChallenge( [HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Challenge/Ack")] HttpRequest req, ILogger log)
{}
```cs
2. In the body of the function, deserialize the arguments using the following line of code:
```
var result = await JsonSerializer.DeserializeAsync<AcknowledgeChallengeRequest>(req.Body, jsonOptions);
```cs
3. Use `challengeHandler` to `Respond` to the challenge:
```
var challenge = challengeHandler.Respond(result.Id, result.Response);if (challenge.Id == Guid.Empty){ return;}
```cs
4. If the response is `Declined`, then just log a message:
```
var challenger = challenge.Challenger;var opponent = challenge.Opponent;if (result.Response == ChallengeResponse.Declined){ log.LogInformation($"{opponent.GamerTag} has declined the challenge from {challenger.GamerTag}!");}
```cs
5. If the response is `Accepted`, then create a match and notify the players:
```
if (result.Response == ChallengeResponse.Accepted)
{
log.LogInformation($"{opponent.GamerTag} has accepted the challenge from {challenger.GamerTag}!");
using var context = contextFactory.CreateDbContext();
var game = Match.New(challenger.Id, opponent.Id);
context.Matches.Add(game);
opponent.MatchId = challenger.MatchId = match.Id;
context.Players.Update(opponent);
context.Players.Update(challenger);
context.SaveChanges();
log.LogInformation($"创建了玩家 {opponent.GamerTag} 和 {challenger.GamerTag} 之间的比赛 {match.Id}!");
// 为游戏创建组
await UserGroups.AddToGroupAsync(opponent.Id.ToString(), $"Match[{match.Id}]");
await UserGroups.AddToGroupAsync(challenger.Id.ToString(), $"Match[{match.Id}]");
await Clients.Group($"Match[{match.Id}]").SendAsync(Constants.Events.MatchStarted, new MatchStartedEventArgs(match));
await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(opponent));
await Clients.All.SendAsync(Constants.Events.PlayerUpdated, new PlayerUpdatedEventArgs(challenger));
}
```cs
So, ignoring all the logging, since that is non-functional, the preceding code starts by creating a new `Match` instance and assigning `PlayerOneId`, `PlayerTwoId`, and `NextPlayerId`. The `Match` object’s `Id` property is then assigned to both of the players, and all the changes are saved to the database.
Next, is the SignalR messages. First, we create a SignalR group with just the two players in it and use the `Match` object’s `Id` property in the name. This way we can send messages to the group and both players will receive them.
The first message we will send will indicate the start of a new game and it will send the `match` instance wrapped in `MatchStartedEventArgs`.
Finally, we send a message for each player to all players, indicating a change in their status.
That completes the functionality for one player to challenge another player to a match of Sticks and Stones! It’s time to move on to playing a match. But first, we will need a function to return the game to the player.
Getting the match
You may be wondering why we need this functionality since, in the previous function, we sent the `Match` object to both players through a SignalR message. The answer is rather simple – if the user accidentally closes the Sticks and Stones app while in the middle of a game, then when they return to the Sticks and Stones app and log back in, the app will detect that they are still in a match and navigate to the `Match` page. It will use this function to retrieve the `Match` object in this case since it wasn’t sent during `Connect`, just `Id`.
So, let’s create a function to return a game by its `Id`, starting with the messages.
Creating the messages
This function will only need a response message object. Unlike the previous functions, the `GetMatch` function will use the `Http` GET method, and we will pass the match `Id` value as part of the URL. The response from the `GetGame` function will be the `Match` instance. To create the `GetGameResponse` message, follow these steps:
1. In the `SticksAndStones.Shared` project, create a new file named `GetGameMessages.cs` in the `Messages` folder.
2. Modify the contents of the file so that it’s as follows:
```
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct GetMatchResponse(Match Match);
```cs
With the response message class in place, we can create the `GetMatch` function.
Getting a match by its ID
The `GetMatch` function will accept a single integer named `id` as a parameter. The parameter is bound to a part of the URL that’s used to call the function. Let’s look at an example. If we wanted to get `Match` identified by a `Guid` type of `c39c7490-f4bc-425a-84ab-0a4ad916ea48`, then the URL would be `http://localhost:7024/api/Game/c39c7490-f4bc-425a-84ab-0a4ad916ea48`.
Follow these steps to implement the `GetMatch` function:
1. Open the `GameHub.cs` file in the `Hubs` folder of the `SticksAndStones.Functions` project.
2. Add the following method declaration after the `AcknowledgeChallenge` method:
```
[FunctionName("GetMatch")]
public IActionResult GetMatch(
[HttpTrigger(AuthorizationLevel.Function, "get", Route =
"Match/{id}")] HttpRequest req,
Guid id, ILogger log){}
```cs
There are a few differences from the other functions. First, the `Http` method that’s used is `"get"`, not `"post"`. Second, `Route` is set to `"Game/{id}"`; `{id}` in `Route` tells the Azure Functions runtime that this function has a parameter named `id` and that the value in that position of the URL should be passed in as an argument. You can see that the third change is that there is an `id` parameter of the `Guid` type. This means that whatever is on the URL in the `{id}` position must be able to be converted into the `Guid` type; otherwise, the Azure Functions runtime will return an HTTP `500 Internal` `Server` error.
3. To query our database for the `Match` object that matches the `id` value, use the following lines of code:
```
using var context = contextFactory.CreateDbContext();Match match = (from m in context.Matches where m.Id == id select m).FirstOrDefault();
```cs
4. If the method gets this far, then it has been completed successfully, so we can return `OkObjectResult`. The object that’s returned will be a `GetMatchResponse` instance with the `Match` instance that was found, or `null`:
```
return new OkObjectResult(new GetMatchResponse(match));
```cs
Since this function uses the HTTP GET method, you can test it out in your favorite browser:
1. Press *F5* to start the project in debug mode.
2. Wait for txhe service to start, then copy the URL for the `GetMatch` function from the output window – for example, http://localhost:####/api/Game/{id}, where `###` is your port number.
3. Open your browser and paste the URL in the address bar.
4. Change `{id}` to anything.
5. Press *Enter*.
You should get an error page in your browser. Try a valid `Guid` value such as `c39c7490-f4bc-425a-84ab-0a4ad916ea48`. You should get a response similar to the following:
```
{ "match": null }
```cs
Since there are no active games, you won’t be able to retrieve an actual `Match` instance.
Now that we can retrieve the `Match` object, we will tackle how players make and receive moves and how to determine the score and winner of the game.
Playing the game
Sticks and Stones is an interactive, fast-paced, turn-based game.
Let's review the following diagram to get a better understanding of what we are building:

Figure 9.11 – Processing turns
Once a match has started, players will take turns choosing a location to place one of their sticks. The client application will then send a message to the GameHub’s `ProcessTurn` function. The `ProcessTurn` function will then validate the move, recalculate the score, check for a winner, and finally, send an update to the players.
Creating the ProcessTurn messages and models
The `ProcessTurn` function has three parameters – the `Match Id`, the player making the move, and the position of the move. The function will return an updated `Match` instance. Follow these steps to add the `ProcessTurn` messages:
1. Add a new file named `ProcessTurnMessages.cs` to the `Messages` folder in the `SticksAndStones.Shared` project.
2. Modify the contents of the file so that it looks as follows:
```
using SticksAndStones.Models;using System;namespace SticksAndStones.Messages;public record struct ProcessTurnRequest(Guid MatchId, Player Player, int Position);
public record struct ProcessTurnResponse(Match Match);
```cs
As part of the turn, `ProcessTurn` will send the updated `Match` instance to the players. This will require a new SignalR event. Perform the following steps to add it:
1. `SaveMatchAndSendUpdates` sends a new event via SignalR to the clients, so we need to add that to our constants. Add the highlighted code in the following snippet to the `Constants.cs` file in the `SticksAndStones.Shared` project:
```
public static class Events { public static readonly string PlayerUpdated = nameof(PlayerUpdated); public static readonly string Challenge = nameof(Challenge); public static readonly string MatchStarted = nameof(MatchStarted);
public static readonly string MatchUpdated = nameof(MatchUpdated);
}
```cs
2. Add the `MatchUpdatedEventArgs` class that we used to send the updated `Match` to the players when the `MatchUpdated` event is set by adding a new file named `MatchUpdatedEventArgs.cs` to the `Messages` folder in the `SticksAndStones.Shared` project.
3. Modify the contents of the file so that it’s as follows:
```
using SticksAndStones.Models;namespace SticksAndStones.Messages;public record struct MatchUpdatedEventArgs(Match Match);
```cs
That concludes the additional models, events, and messages that are used by the `ProcessTurn` function. Next, we can start working on the ProcessTurn (P-Code) function.
Processing turns
The `ProcessTurn` function has a few responsibilities. It will need to do the following:
* Validate the turn
* Make the necessary changes to the `Match` object
* Recalculate the score
* Determine whether there is a winner
* Notify the players
To start the implementation of the `ProcessTurn` function, we will create stubs for each of the methods that we will call when processing a turn. Follow these steps to create the method stubs:
1. Add a new method declaration to the `GameHub` class for the `ProcessTurn` function:
```
[FunctionName("ProcessTurn")]public async Task<IActionResult> ProcessTurn(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = $"Game/Move")] HttpRequest req,
ILogger log)
{}
```cs
2. Add a new method declaration for `ValidateProcessTurnRequest`:
```
private Exception ValidateProcessTurnRequest(ProcessTurnRequest args)
{ return null;}
```cs
The method accepts `ProcessTurnRequest` as the only argument. If there are no errors in the arguments, then it will default to returning `null`. If there is an error, then it will return an `Exception` error.
3. We will use another method to verify the match state before processing the move. Create a new method in the `GameHub` class named `VerifyMatchState`, as follows:
```
private Exception VerifyMatchState(Match match, ProcessTurnRequest args)
{ return null;}
```cs
Just like with `ValidateProcessTurnRequest`, we will return `null` for the error if everything is okay; otherwise, we will return an error.
Now that we have the helper method signatures in place, let’s implement them, starting with `ValidateProcessTurnRequest`. Follow these steps to add the implementation to `ValidateProcessTurnRequest`:
1. In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid position:
```
if (args.Position <= 0 || args.Position > 23)
{
return new IndexOutOfRangeException("位置超出范围,必须在 1 到 24 之间");}
```cs
2. In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid player:
```
if (args.Player is null){ return new ArgumentException("无效的玩家");}
```cs
3. In `ValidateProcessTurnRequest`, add the following at the top of the method to check for a valid game `Id` value:
```
if (args.MatchId == Guid.Empty){
return new ArgumentException("无效的比赛 ID");}
```cs
That completes the `ValidateProcessTurnRequest` method. Now, we can add the code to `VerifyMatchState`, as follows:
1. In `VerifyMatchState`, add the following at the top of the method to check that the position hasn’t already been played:
```
if (match.Sticks[args.Position] != 0){ return new ArgumentException($"位置 [{args.Position}] 已经被玩过");}
```cs
2. In `VerifyMatchState`, add the following at the top of the method to check that the correct player is taking their turn:
```
if (args.Player.Id != game.NextPlayerId){ return new ArgumentException($"现在不是 {args.Player.GamerTag}'s 轮次");}
```cs
3. In `VerifyMatchState`, add the following at the top of the method to check that the game isn’t over already:
```
if (match.WinnerId != Guid.Empty){ return new ArgumentException("比赛已完成");}
```cs
4. In `VerifyMatchState`, add the following at the top of the method to check that the game object exists:
```
if (match is null)
{
return new ArgumentException("无效的 MatchId");
}
```cs
Now that we have created these helper methods, we can implement the `ProcessTurn` method by following these steps:
1. Deserialize the arguments that are passed to the `ProcessTurn` function using `JsonSerializer`, as follows:
```
var args = await JsonSerializer.DeserializeAsync<ProcessTurnRequest>(req.Body, jsonOptions);
```cs
2. In the `ProcessTurn` method, we can call `ValidateProcessTurnRequest`. If there is an error, we can handle it, as follows:
```
var error = ValidateProcessTurnRequest(args);
if (error is not null)
{
log.LogError(error, "验证回合请求时出错");
return new BadRequestObjectResult(error);
}
```cs
3. With the arguments verified, we can query the database for the game, and fail if it doesn’t exist:
```
using var context = contextFactory.CreateDbContext();
var game = (from g in context.Matches where m.Id == args.MatchId select m).FirstOrDefault() ?? throw new ArgumentException("无效的 MatchId.");
```cs
4. Now, we can call `VerifyGameState`. If there is an error, we can handle it, as follows:
```
error = VerifyGameState(game, args);
if (error is not null)
{
log.LogError(error, "验证游戏状态时出错");
return new BadRequestObjectResult(error);
}
```cs
5. We must do one final check before making the move and updating the scores – we need to check to see whether the player made their selection before their turn expired using the following code:
```
if (turnHandler.EndTurn(args.GameId) == TurnHandler.TurnStatus.Forfeit)
{
error = new ArgumentException($"The turn has expired.");
log.LogError(error, $"玩家未在规定时间内做出回应。");
return new BadRequestObjectResult(error);
}
```cs
With the validation of the input and game out of the way, we can now focus on applying the player’s move to the current state of the game, updating the score, and determining a winner. To make the code for updating the score simpler, we will need a complex data structure. Let’s explore this further.
When a player chooses a location to place one of their sticks, it may complete a square. If it does, then the player who placed the stick gets the square and an additional five points. The trick is how to determine that a stick has completed a square. We are storing the state of all sticks and stones as an array of integers. What we need is a map from a stone index (0–8) to the sticks that make up its sides. But we can simplify the logic a bit more once we know what sticks make up a square since we are only interested in a single stick, and a single stick can complete, at most, two squares. So, we can now have a structure that maps each stick position (0–23) to an array of tuples. Each tuple has an integer that is the index for the stone and another integer array that is the other three stick indexes that make up the square.
Let’s use an example to illustrate this. Pretend that we have a game that’s in the following state:

Figure 9.12 – Sample view of the board game
Now, pretend that a player has chosen to place their stick at location 9, highlighted in aqua:

Figure 9.13 – Board game with stick placement highlighted
We will only need to check the two squares highlighted in brown. This means that we need to check whether there are sticks in positions 2, 5, and 6 for the stone in the upper brown box, and 12, 13, and 16 for the stone in the lower brown box.
This means we need two tuples – one for stone 2 and a second for stone 5 – each with an array of integers of the sides – f example, { (2, {2, 5, 6} ), (5, {12, 13, 16) }. Using that data, we can check the two possible squares that could be completed by placing a stick at position 9.
Using old-fashioned sticky notes with a pencil and eraser, we can determine that the complete mapping will look like this:
(int stone, int[] sticks)[][] stickToStoneMap = new (int, int[])[][] {/* 1 / new (int, int[])[] { (1, new int[] { 4, 5, 8}), (0, new int[] { 0, 0, 0})},/ 2 / new (int, int[])[] { (2, new int[] { 5, 6, 9}), (0, new int[] { 0, 0, 0})},/ 3 / new (int, int[])[] { (3, new int[] { 6, 7,10}), (0, new int[] { 0, 0, 0})},/ 4 / new (int, int[])[] { (1, new int[] { 1, 5, 8}), (0, new int[] { 0, 0, 0})},/ 5 / new (int, int[])[] { (1, new int[] { 1, 4, 8}), (2, new int[] { 2, 6, 9})},/ 6 / new (int, int[])[] { (2, new int[] { 2, 5, 9}), (3, new int[] { 3, 7,10})},/ 7 / new (int, int[])[] { (3, new int[] { 3, 6,10}), (0, new int[] { 0, 0, 0})},/ 8 / new (int, int[])[] { (1, new int[] { 1, 4, 5}), (4, new int[] {11,12,15})},/ 9 / new (int, int[])[] { (2, new int[] { 2, 5, 6}), (5, new int[] {12,13,16})},/ 10 / new (int, int[])[] { (3, new int[] { 3, 6, 7}), (6, new int[] {13,14,17})},/ 11 / new (int, int[])[] { (4, new int[] { 8,12,15}), (0, new int[] { 0, 0, 0})},/ 12 / new (int, int[])[] { (4, new int[] { 8,11,15}), (5, new int[] { 9,13,16})},/ 13 / new (int, int[])[] { (5, new int[] { 9,12,16}), (6, new int[] {10,14,17})},/ 14 / new (int, int[])[] { (6, new int[] {10,13,17}), (0, new int[] { 0, 0, 0})},/ 15 / new (int, int[])[] { (4, new int[] { 8,11,12}), (7, new int[] {18,19,22})},/ 16 / new (int, int[])[] { (5, new int[] { 9,12,13}), (8, new int[] {19,20,23})},/ 17 / new (int, int[])[] { (6, new int[] {13,14,17}), (9, new int[] {20,21,24})},/ 18 / new (int, int[])[] { (7, new int[] {15,19,22}), (0, new int[] { 0, 0, 0})},/ 19 / new (int, int[])[] { (7, new int[] {15,18,22}), (8, new int[] {16,20,23})},/ 20 / new (int, int[])[] { (8, new int[] {16,19,23}), (9, new int[] {17,21,24})},/ 21 / new (int, int[])[] { (9, new int[] {17,20,24}), (0, new int[] { 0, 0, 0})},/ 22 / new (int, int[])[] { (7, new int[] {15,18,19}), (0, new int[] { 0, 0, 0})}, / 23 / new (int, int[])[] { (8, new int[] {16,19,20}), (0, new int[] { 0, 0, 0})},/ 24 */ new (int, int[])[] { (9, new int[] {17,20,21}), (0, new int[] { 0, 0, 0})},};
Add the preceding code to the `GameHub` class after the `turnHandler` field declaration. Now that we have declared the data structure for finding completed boxes, let’s continue processing the turn:
1. Return the `ProcessTurn` method, and add the following code at the end:
```
match.Sticks[args.Position] = args.Player.Id == match.PlayerOneId ? 1 : -1;
```cs
This will assign the position `-1` or `1`, depending on who the active player is. We will use a value of `-1` and `1` later in determining a winner, in the case of three stones in a row.
2. Now that we have placed the stick, we need to adjust the player’s score, as follows:
```
if (args.Player.Id == game.PlayerOneId){ match.PlayerOneScore += 1;}else { match.PlayerTwoScore += 1;}
```cs
3. The following code will use the data structure from earlier to determine whether placing the stick completed any squares:
```
// Determine if this play creates a square
foreach (var tuple in stickToStoneMap[args.Position])
{
if (tuple.stone == 0) continue;
var stickCompletesABox =
(
Math.Abs(match.Sticks[tuple.sticks[0] - 1]) +
Math.Abs(match.Sticks[tuple.sticks[1] - 1]) +
Math.Abs(match.Sticks[tuple.sticks[2] - 1])
) == 3;
if (stickCompletesABox)
{
// If so, place stone, and adjust score
var player = args.Player.Id == match.PlayerOneId ? 1 : -1;
match.Stones[tuple.stone - 1] = player;
if (player > 0)
{
match.PlayerOneScore += 5;
}
else
{
match.PlayerTwoScore += 5;
}
}
}
```cs
This code will iterate over the tuples declared at the array position of the newly placed stick, adjusting for C# arrays being 0-based. It will then use the array of stick positions from the tuple to index into the array of sticks in the match The value at that location will be either `1` for player one, `-1` for player two, or `0` for unclaimed. We can add the absolute value of all three locations and if it is 3, then the newly placed stick completes a box. If so, then assign the stone location from the tuple, adjusting for 0-based arrays again, to the player, and give them five points.
4. To help determine whether the match is over, we are going to use a couple of helper functions to make the code cleaner and easier to read. The first of those returns a `boolean` value if all the sticks have been played in the match. Add the following code to the end of the `GameHub` class:
```
private static bool AllSticksHaveBeenPlayed(Match match)
{
return !(from s in match.Sticks where s == 0 select s).Any();
}
```cs
This function uses a straightforward LINQ query to search the `Sticks` array for any element that has a value of `0`, meaning unclaimed. If there are, the function returns.
5. The next function is a little more complex as it is used to determine whether a player has three stones in a row, either horizontally, vertically, or diagonally, and returns the `Id` value of the player that does. Add the following code to the end of the `GameHub` class:
```
private static int HasThreeInARow(List<int> stones){ for (var rc = 0; rc < 3; rc++) { var rowStart = rc * 3; var rowValue = stones[rowStart] + stones[rowStart + 1] + stones[rowStart + 2]; if (Math.Abs(rowValue) == 3) // we Have a winner! { return rowValue; } var colStart = rc; var colValue = stones[colStart] + stones[colStart + 3] + stones[colStart + 6]; if (Math.Abs(colValue) == 3) // We have a winner! { return colValue ; } } var tlbrValue = stones[0] + stones[4] + stones[8]; var trblValue = stones[2] + stones[4] + stones[6]; if (Math.Abs(tlbrValue) == 3) { return tlbrValue; } if (Math.Abs(trblValue) == 3) { return trblValue; } return 0;}
```cs
This method starts by checking all the rows and columns for 3 stones in a row, for the same player. Since there are nine stones arranged in a 3x3 grid, we only need to check three columns and three rows. Using a single iterator, each row or column is checked by adding the values stored at each position in the row or column and if the absolute value of the sum is 3, then a single player has a winning row. If the sum is positive, player one has won; otherwise, player two has won. Since there are only two possible diagonals, those checks use the same logic but are done individually, rather than looping.
6. Now, we can use those two functions to determine whether there is a winner. To do so, we can use the following code at the end of the `ProcessTurn` function:
```
// Does one player have 3 stones in a row?
var winner = Guid.Empty;
var threeInARow = HasThreeInARow(match.Stones);
if (threeInARow != 0)
winner = threeInARow > 0 ? match.PlayerOneId : match.PlayerTwoId;
if (winner == Guid.Empty) // No Winner yet
{
// Have all sticks been played, if yes, use top score.
if (HaveAllSticksBeenPlayed(match))
{
winner = match.PlayerOneScore > match.PlayerTwoScore ? match.PlayerOneId : match.PlayerTwoId;
}
}
```cs
Here, we use the two methods we just created to do the main checks and assign the winner. We capture the winner as the `Guid` type from the `Id` property of the `Player` class, so some translation is needed.
7. Next, we can set the next player’s turn, or if there is a winner, complete the match, as follows:
```
if (winner == Guid.Empty)
{
match.NextPlayerId = args.Player.Id == match.PlayerOneId ? match.PlayerTwoId : match.PlayerOneId;
}
else
{
match.NextPlayerId = Guid.Empty;
match.WinnerId = winner;
match.Completed = true;
}
```cs
8. The final steps are to save any changes we have made and send updates to the players. We will use a helper method called `SaveGameAndSendUpdates` to handle that as we will need the same code when a turn expires. Add the following code to the end of the `GameHub` class:
```
private async Task SaveMatchAndSendUpdates(GameDbContext context, Match match)
{
context.Matches.Update(match);
await context.SaveChangesAsync();
await Clients.Group($"Match[{match.Id}]").SendAsync(Constants.Events.MatchUpdated, new MatchUpdatedEventArgs(match));
if (match.Completed)
{
await UserGroups.RemoveFromGroupAsync(match.PlayerOneId.ToString(), $"Match[{match.Id}]");
await UserGroups.RemoveFromGroupAsync(game.PlayerTwoId.ToString(), $"Match[{match.Id}]");
}
}
```cs
This function will save the current match state to the database, then sends a message to the SignalR group for the match indicating that there have been updates to the match. If the match is over, then we remove the players from the group.
1. The following final three lines of code complete the `ProcessTurn` method:
```
await SaveMatchAndSendUpdates(context, match);
return new OkObjectResult(new ProcessTurnResponse(match));
```cs
After saving the match changes and notifying the players of the match updates, if the match is not over yet, we notify the next player that it is their turn to play. To wrap things up we return the updated match object back to the player that just made their move.
2. We also need to call `SaveGameAndSendUpdates` when there is an error after calling `VerifyGameState`. Modify that section of code in `ProcessTurn` using the following snippet:
```
error = VerifyMatchState(game, args);
if (error is not null)
{
await SaveMatchAndSendUpdates(game);
log.LogError(error, "Error validating match state.");
return new BadRequestObjectResult(error);
}
```cs
We have now completed all the required functions to connect a player to the service, challenge another player to a match, and then process each player’s turn and determine the winner.
Let’s take a short look back at what we have accomplished so far in this chapter. We started by creating the Azure services that our game server backend would need, a SignalR service for real-time communication, and finally, the Functions service to host our backend functions. We then implemented the Azure functions that would provide the functionality for our game:
* `Connect`: To register players to the game service
* `IssueChallenge`: To allow one player to request a game with another player
* `AcknowledgeChallenge`: To accept or decline a request
* `ProcessTurn`: To manage the gameplay between two players and determine the winner
Our backend is now complete, and we are ready to publish it to Azure so that we can consume the services from the game app in *Chapter 10*.
Deploying the functions to Azure
The final step in this chapter is to deploy the functions to Azure. You can do that as a part of a **continuous integration/continuous deployment** (**CI/CD**) pipeline – for example, with Azure DevOps. But the easiest way to deploy the functions, in this case, is to do it directly from Visual Studio. Perform the following steps to deploy the functions:
1. Right-click on the `SticksAndStones.Functions` project and select **Publish**.
2. Select **Azure** as the destination for publishing and click **Next**:

Figure 9.14 – Target selection when publishing
1. Choose **Azure Function App (Windows)** in the **Specific target** tab, then click **Next**:

Figure 9.15 – Container selection when publishing
1. Sign in to the same Microsoft account that we used in the Azure portal when we were creating the **Function** **App** resource.
2. Select the subscription that contains the function app. All function apps we have in the subscription will now be loaded.
3. Select the function app and click **Finish**. If your app isn’t showing up, click **Back** and choose the **Azure Function App (Linux)** option as you may not have changed the default when creating the service in the *Creating the Azure service for* *functions* section.
4. When the profile is created, click the **Publish** button.
The following screenshot shows the last step. After that, the publishing profile will be created:

Figure 9.16 – Publishing Azure functions
Summary
In this chapter, we started by learning about a few Azure services, including SignalR, and Functions. Then, we created the services in Azure that our game server backend would need – a SignalR service for real-time communication, and finally, the Functions service to host our backend functions. After this, we implemented the Azure functions that would provide the functionality for our game.
We wrapped up this chapter by publishing our function code to the Azure Functions instance in Azure.
In the next chapter, we will build a game app that will use the backend we have built in this project.
第十章:构建实时游戏
在本章中,我们将构建一个支持多人、面对面实时通讯的游戏应用。在应用中,您将能够连接到游戏服务器并查看其他已连接玩家的列表。然后,您可以选择一个玩家请求与他们玩游戏,如果他们接受,就可以玩一场棍棒与石头 游戏。我们将探讨如何使用 SignalR 实现与服务器的实时连接。
本章将涵盖以下主题:
让我们开始吧。
技术要求
在开始构建此项目的应用之前,您需要构建我们在第九章 中详细说明的后端,即使用 Azure 服务设置游戏后端 。您还需要安装 Visual Studio for Mac 或 PC,以及.NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅第一章 ,.NET MAUI 简介 。本章的源代码可在本书的 GitHub 仓库中找到:github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/chapters/ten/main 。
项目概述
在构建一个面对面游戏应用时,拥有实时通讯功能非常重要,因为用户期望其他玩家的动作能够尽可能快地到达。为了实现这一点,我们将使用 SignalR,这是一个用于实时通讯的库。如果 WebSocket 可用,SignalR 将使用 WebSocket,如果不可用,它将提供几个备选方案。在应用中,我们将使用 SignalR 通过我们在第九章 中构建的 Azure Functions 发送玩家和游戏状态更新。
此项目的构建时间大约为 180 分钟。
开始使用
我们可以使用 PC 上的 Visual Studio 或 Mac 来完成这个项目。要使用 PC 上的 Visual Studio 构建 iOS 应用,您必须连接一台 Mac。如果您根本无法访问 Mac,您可以选择只构建应用的 Android 部分。
让我们从第九章 回顾一下游戏的主要内容。
游戏概述
棍与石 是一款基于两个童年游戏概念结合而成的回合制社交游戏,即点与框(en.wikipedia.org/wiki/Dots_and_boxes )和井字棋(en.wikipedia.org/wiki/Tic-tac-toe )的概念。游戏板以三乘三的网格布局。每位玩家将轮流在方框的旁边、两个点之间放置一根棍子,以获得一分。如果一根棍子完成了一个方框,那么该玩家将获得该方框的所有权,获得五分。当玩家在一条水平、垂直或对角线上拥有三个方框时,游戏获胜。如果没有任何玩家能够连续拥有三个方框,则游戏获胜者由得分最高的玩家决定。
为了保持应用和服务端相对简单,我们将消除大量的状态管理。当玩家打开应用时,他们需要连接到游戏服务。他们需要提供一个游戏标签、用户名或电子邮件地址。可选地,他们可以上传自己的照片作为头像。
一旦连接,玩家将看到所有连接到同一游戏服务的其他玩家的列表;这被称为大厅。玩家的状态(准备游戏 或正在比赛中 )将与玩家的游戏标签和头像一起显示。如果玩家不在比赛中,将有一个按钮可供挑战其他玩家进行比赛。
向玩家发起比赛邀请会导致应用提示对手回应挑战,无论是接受还是拒绝。如果对手接受挑战,那么两位玩家将被导航到一个新的游戏板,接受挑战的玩家将先走一步。所有其他玩家的大厅中,两位玩家的状态都将更新为正在比赛中 。
玩家们将轮流选择放置一根棍子的位置。每次玩家放置一根棍子,游戏板和分数将在两位玩家的设备上更新。当放置的棍子完成一个或多个方格时,该玩家赢得该方格,并在方格中心放置一堆石头。当所有棍子都放置完毕,或者某个玩家在一条直线上拥有三颗石头时,游戏结束,玩家们将被导航回大厅,他们的状态更新为“准备游戏”。
如果玩家在游戏中离开应用,那么他们将放弃比赛,剩余的对手将被计入胜利,并导航回大厅。
既然我们已经了解了我们想要构建的内容,那么让我们深入细节。
我们建议您使用我们在 第九章 ,使用 Azure 服务设置游戏后端 中使用的相同解决方案,因为这会使代码共享更容易。如果您不想阅读 第九章 的全部内容,您可以从 第九章 中获取完成的源代码,github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/chapters/nine/main 。
我们将分四个部分来构建这个应用程序:
服务 – 所需的所有类,用于连接并与在 第九章 ,使用 Azure 服务设置游戏后端 中构建的 Azure 函数后端进行交互。
连接页面 – 这将包括允许用户作为玩家连接到游戏服务器的视图和视图模型。
大厅页面 – 大厅是玩家可以与其他玩家发送和接收挑战的地方。在本节中,我们将构建大厅的视图和视图模型。
游戏页面 – 这是玩家可以轮流玩 棍子和石头 游戏的地方。在本节中,我们将构建实现这一功能的视图和视图模型。
让我们先创建 .NET MAUI 应用程序的项目。
构建游戏应用程序
是时候开始构建应用程序了。从上一章打开 SticksAndStones 解决方案,按照以下步骤创建项目:
通过选择 Visual Studio 菜单中的 文件 ,添加 ,然后 新建项目… 来打开 创建新项目 向导:
图 10.1 – 文件 | 添加 | 新项目…
在搜索框中输入 maui 并从列表中选择 .NET MAUI 应用 项,或者如果列出,从 最近的项目模板 中选择它:
图 10.2 – 创建新项目
点击 下一步 。
将应用程序名称输入为 SticksAndStones.App,并在 解决方案 下选择 添加到解决方案 ,如图下所示:
图 10.3 – 配置您的新的项目
点击 下一步 。
最后一步将提示您选择支持 .NET Core 的版本。在撰写本文时,.NET 6 可用为 长期支持 (LTS ),.NET 7 可用为 标准期限支持 。为了本书的目的,我们假设您将使用 .NET 7:
图 10.4 – 补充信息
通过点击 创建 完成设置,并等待 Visual Studio 创建项目。
现在我们已经为我们的游戏屏幕创建了 .NET MAUI 项目,让我们配置它,以便它可以添加服务和视图。我们需要将 SticksAndStones.Shared 项目添加为项目引用,以及一些 NuGet 包。按照以下步骤完成 SticksAndStones.App 项目的设置:
右键单击 Solution Explorer 中的 SticksAndStones.App 项目,并选择 Properties 。
在 Default namespace 中。
修改 $(MSBuildProjectName.Split(".")[0].Replace(" ", "``_"))。
这将根据 "." 分割项目名称,仅使用第一部分,并将任何空格替换为下划线。
将 NuGet 包引用添加到 CommunityToolkit.Mvvm,因为在其他章节中,我们将使用此包来简化数据绑定到属性和命令的实现。
将 NuGet 包引用添加到 CommunityToolkit.Maui。我们将使用此包中的 GravatarImageSource 类来渲染用户的头像。对于 .NET 7,您需要使用 NuGet 包的 6.1.0 版本。7.0+ 版本以 .NET 8 为依赖项。
打开 MauiProgram.cs 文件,并添加此处显示的突出显示行:
using CommunityToolkit.Maui;
using Microsoft.Extensions.Logging;
namespace SticksAndStones.App
{
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.UseMauiCommunityToolkit();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
}
这将为应用程序内的 CommunityToolkit 配置。
将 NuGet 包引用添加到 Microsoft.Extensions.Logging.Abstractions。此包用于记录 Azure Functions 函数的消息以进行调试。
将 NuGet 包引用添加到 Microsoft.Extensions.Logging.Debugging。此包用于记录 Azure Functions 函数的消息以进行调试。
将 NuGet 包引用添加到 Microsoft.AspNetCore.SignalR.Client。此包对于应用程序连接到我们在第九章中创建的 SignalR Hub 并接收消息是必需的。
将项目引用添加到 SticksAndStones.Shared 项目。这将使我们能够访问在第九章中创建的消息和对象。
项目创建到此结束。接下来,我们将开始创建与我们的服务直接交互的类。
创建游戏服务
我们首先要做的是创建一个服务,该服务将用于与在第九章中创建的 Azure Functions 函数服务进行通信,即使用 Azure 服务设置游戏后端。该服务将分为三个主要类:
GameService – 用于调用 Azure Functions 和接收 SignalR 消息的方法和属性。
ServiceConnection – 包含对 HttpClient 和 SignalR Hub 实例的引用。同时提供用于安全调用 HttpClient 的方法。
Settings – 存储和检索 HttpClient 所使用的服务器 URL。它还存储用户提供的连接详细信息。
我们将从 Settings 类开始,因为 GameService 和 ServiceConnection 都将依赖于 Settings。
创建 Settings 服务
Settings 服务用于存储应用程序运行之间所需的价值。它将使用 .NET MAUI 的 Preferences 类以跨平台方式存储这些值。使用以下步骤实现 Settings 类:
在 SticksAndStones.App 项目中,创建一个名为 Services 的新文件夹。
在新创建的 Services 文件夹中,创建一个名为 Settings 的新类。
将类设置为公共。
创建一个名为 LastPlayerKey 的 const string 字段,并按如下方式初始化:
private const string LastPlayerKey = nameof(LastPlayerKey);
创建一个名为 ServerUrlKey 的 const string 字段,并按如下方式初始化:
private const string ServerUrlKey = nameof(ServerUrlKey);
这两个字段被 .NET MAUI 的 Preferences 类用来存储服务器 URL 和用户上次登录的登录详情。
添加一个名为 ServerUrlDefault 的 private const string 字段,如下所示:
#if DEBUG && ANDROID
private const string ServerUrlDefault = "http://10.0.2.2:7071/api";
#else
private const string ServerUrlDefault = "http://localhost:7071/api";
#endif
ServerlUrlDefault value for Android devices. The 10.0.2.2 IP address is a special value used by the Android emulators to be able to access the host computer’s localhost address. This is very useful when testing the app using the Azurite development environment for Azure Functions.
你可能需要调整前面列表中突出显示的端口号,以适应你特定的开发环境。Azure Functions 将在从 Visual Studio 启动时显示服务器 URL,如下面的屏幕截图所示:
图 10.5 – Azure Functions 控制台输出
使用托管在 Azure 中的 Azure Functions
如果你遵循了 第九章 部分中名为 将函数部署到 Azure 的步骤,那么你可以在 创建函数的 Azure 服务 部分中使用 第九章 中创建的 Azure Function App 的 URL。该 URL 显示在 Azure Functions App 的 概览 选项卡上。
现在,添加一个名为 ServerUrl 的 public string 属性,其实现如下:
public string ServerUrl
{
get => Preferences.ContainsKey(ServerUrlKey) ?
Preferences.Get(ServerUrlKey, ServerUrlDefault) :
ServerUrlDefault;
set => Preferences.Set(ServerUrlKey, value);
}
如果存在,此代码将从 Preferences 存储中获取服务器 URL;如果不存在,它将使用 serverUrlDefault 值。该属性将在 Preferences 存储中存储新值。
在 Settings.cs 文件的顶部添加以下 using 声明:
using SticksAndStones.Models;
using System.Text.Json;
这将使我们能够使用我们的模型和 JsonSerializer 类。
创建一个名为 LastPlayer 的新属性,其类型为 Player,如下所示:
public Player LastPlayer
{
get
{
if (Preferences.ContainsKey(LastPlayerKey))
{
var playerJson = Preferences.Get(LastPlayerKey, string.Empty);
return JsonSerializer.Deserialize<Player>(playerJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new();
}
return new();
}
set => Preferences.Set(LastPlayerKey, JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
在这里,属性的 set 方法将在将 Player 对象存储在 Preferences 之前将其转换为 Json 字符串,在获取属性时,如果它在 Preferences 存储中存在,则将存储的 Json 转换为 Player 对象。如果没有在 Preferences 存储中找到值,则 get 方法将返回一个空的 Player 对象。
Settings 类的最终步骤是将它注册到依赖注入容器中。打开 SticksAndStones.App 项目的 MauiProgram.cs 文件,然后在 CreateMauiApp 方法中添加以下突出显示的代码:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<Services.Settings>();
return builder.Build();
}
随着 Settings 类的完成,我们现在可以专注于 ServiceConnection 类。
创建 ServiceConnection 类
ServiceConnection 类封装了与 Azure Functions 服务通信所需的功能。它具有调用函数方法并返回结果的方法,并具有适当的错误处理。它还负责初始化用于实时通信的 SignalR Hub 实例。ServiceConnection 类有几个我们需要的依赖项,所以让我们先放在一起。
首先要添加的是日志。在调试期间有日志可以帮助找出问题,尤其是在处理异步过程时。与 Azure Functions 通信将涉及大量的异步操作。为了在调试时启用日志记录,将高亮代码添加到SticksAndStones.App项目中的MauiProgram类的CreateMauiApp方法:
#if DEBUG
builder.Logging.AddDebug();
builder.Services.AddLogging(configure =>
{
configure.AddDebug();
});
#endif
builder.Services.AddSingleton<Services.Settings>();
return builder.Build();
这将在服务容器中添加一个ILoggingProvider实例。ILoggerProvider实例将提供ILogger<T>实例。这将使ILogger<T>能够在ServiceConnection类构造函数中作为依赖项使用。
更多关于日志提供者的信息
在learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers 了解更多关于日志提供者如何工作以及日志的一般信息。
现在,当使用 HTTP 向 API 发出请求时,使用异步调用是一种常见且良好的做法,这样就不会阻塞主线程或 UI 线程。所有 UI 更新,如动画、按钮点击、屏幕上的轻触或文本更改,都发生在 UI 线程上。HTTP 调用可能需要相当长的时间才能完成,这可能导致应用程序对用户无响应。
异步编程中的错误处理可能很困难。为了帮助在调用 API 时处理错误,我们将使用几个类来封装异常;这些类是AsyncError和AsyncExceptionError。我们需要AsyncError和AsyncExceptionError,因为将任何从System.Exception派生的类实例序列化和反序列化都是一种不好的做法。并非所有从System.Exception派生的类都是可序列化的,即使它们是可序列化的,也可能由于缺少类型而无法反序列化——例如,类型在服务器上可用但在客户端不可用。在SticksAndStones.App项目中创建一个名为AsyncError.cs的新文件,并用以下代码替换其内容:
using System.Text.Json.Serialization;
namespace SticksAndStones;
public record AsyncError
{
[JsonPropertyName("message")]
public string Message { get; set; }
}
public record AsyncExceptionError : AsyncError
{
[JsonPropertyName("innerException")]
public string InnerException { get; set; }
}
AsyncError类有一个单独的属性Message。Message属性被JsonPropertyName属性装饰,以便在需要时可以序列化,使用属性名的小写版本。AsyncExceptionError从AsyncError继承并添加了一个额外的属性InnerException。InnerException属性也被JsonPropertyName属性装饰。
我们还需要最后一个类 AsyncLazy<T>。你可能已经在你编写的一些其他应用程序中使用了 Lazy<T>。当你想要延迟创建一个类直到你真正需要它时,它非常方便。如果你永远不需要它,它就不会被创建。但是 Lazy<T> 与异步编程不太兼容,所以如果你想要异步创建一个类,这会变得很繁琐。幸运的是,Stephen Toub,他在微软的 .NET 团队工作,创建了 AsyncLazy<T>。要将它添加到 SticksAndStones.App 项目中,创建一个名为 AsyncLazy~1.cs 的新文件,并用以下内容替换其内容:
using System.Runtime.CompilerServices;
namespace SticksAndStones;
// AsyncLazy<T>, Microsoft, Stephen Toub, .NET Parallel Programming Blog, https://devblogs.microsoft.com/pfxteam/asynclazyt/
public class AsyncLazy<T> : Lazy<Task<T>>
{
public AsyncLazy(Func<T> valueFactory) :
base(() => Task.Factory.StartNew(valueFactory))
{ }
public AsyncLazy(Func<Task<T>> taskFactory) :
base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap())
{ }
public TaskAwaiter<T> GetAwaiter() { return Value.GetAwaiter(); }
}
了解更多关于 AsyncLazy
访问 .NET 博客了解更多关于 Stephen Toub 创建 AsyncLazy<T> 类的信息:devblogs.microsoft.com/pfxteam/asynclazyt/ 。
这就完成了开始实现 ServiceConnection 类所需的所有更改。要创建该类,请按照以下步骤操作:
在 SticksAndStones.App 项目的 Services 文件夹中创建一个名为 ServiceConnection 的新类。
将类改为 public sealed 并继承自 IDisposable:
public sealed class ServiceConnection : IDisposable
将文件顶部的命名空间声明修改为以下内容:
using Microsoft.AspNetCore.Http.Connections.Client;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using SticksAndStones.Models;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
这些是在以下步骤中需要的,以引用所需的类型。
向类中添加以下 private 字段:
private readonly ILogger log;
private readonly HttpClient httpClient;
private readonly JsonSerializerOptions serializerOptions;
serializerOptions 用于确保从 Azure Functions 函数发送和接收的 JSON 可以正确地序列化和反序列化。
现在,添加一个名为 Hub 的 public 属性。Hub 的类型为 AsyncLazy<HubConnection>。HubConnection 是来自 SignalR 客户端库的类型,用于从 SignalR 服务接收消息。该属性应如下所示:
public AsyncLazy<HubConnection> Hub { get; private set; }
HubConnection 在 ConnectHub 方法中初始化。但首先,让我们添加构造函数。
ServiceConnection 类的构造函数有两个参数:ILogger<ServiceConnection> 和一个 Settings 参数。在构造函数的主体中,初始化在 步骤 3 中创建的 private 字段如下:
public ServiceConnection(ILogger<ServiceConnection> logger, Settings settings)
{
httpClient = new()
{
BaseAddress = new Uri(settings.ServerUrl)
};
httpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
log = logger;
}
logger 和 settings 参数由 .NET MAUI 依赖注入服务提供。httpClient 字段被初始化,并且它的 BaseAddress 被分配为设置 ServerUrl 属性作为 URI。然后,DefaultHeaders 被修改以指示服务器期望结果以 JSON 格式返回。serializerOptions 实例被初始化为 Web 的默认值,这与 Azure Functions 使用的格式一致。最后,log 字段被初始化为 logger 参数的值。
现在,让我们实现Dispose方法。它将清理任何可能持有任何原生资源的值,例如网络、文件句柄等。这个类中需要释放引用的两个值是httpClient和Hub。请注意,我们不需要自己调用Dispose,因为.NET MAUI 依赖注入系统会为我们完成这个操作。将以下代码添加到ServiceConnection类中:
public void Dispose()
{
httpClient?.Dispose();
Hub?.Value?.Dispose();
GC.SuppressFinalize(this);
}
现在,通过将以下高亮显示的代码行添加到MauiProgram.cs文件中,将类添加到依赖注入中:
builder.Services.AddSingleton<Services.Settings>();
builder.Services.AddSingleton<Services.ServiceConnection>();
return builder.Build();
Hub属性的初始化将在ConnectHub方法中发生。SignalR Hub 连接的配置作为Connect函数的结果返回给应用程序。由于我们在这个类构造之前没有也不会调用那个方法,所以我们不能在构造函数中创建Hub。在初始化Hub实例之前需要配置。ConnectHub方法有一个名为ConnectionInfo的单个参数。使用以下代码片段添加该方法:
public void ConnectHub(ConnectionInfo config)
{
Hub = new(async () =>
{
var connectionBuilder = new HubConnectionBuilder();
connectionBuilder.WithUrl(config.Url, (HttpConnectionOptions obj) =>
{
obj.AccessTokenProvider = async () => await Task.FromResult(config.AccessToken);
});
connectionBuilder.WithAutomaticReconnect();
var hub = connectionBuilder.Build();
await hub.StartAsync();
return hub;
});
}
此方法将Hub属性初始化为一个新的AsyncLazy<HubConnection>实例。AsyncLazy<T>的构造函数接受Func<T>,它通过匿名方法语法提供。匿名方法也被标记为async方法,这意味着它将包含一个等待的方法调用。匿名方法不接受任何参数,在方法体中,首先创建一个新的HubConnectionBuilder。然后,在HubConnectionBuilder上调用WithUrl扩展方法来设置 SignalR 服务的 URL 并提供建立连接所需的AccessToken值。AccessTokenProvider是Task<string>,因此config.AccessToken通过另一个async匿名函数提供。WithAutomaticReconnect方法将HubConnection实例设置为在连接丢失时自动尝试重新连接 SignalR 服务。如果没有调用WithAutomaticReconnect,则当连接丢失时,应用程序负责重新连接。通过调用HubConnectionBuilder.Build创建HubConnection实例。然后,通过StartAsync启动Hub实例,这是等待的,然后返回Hub。这里要记住的是,当调用ConnectHub时,匿名函数不会执行。该方法只有在第一次访问Hub属性的一个属性或方法时才会被调用。
ServiceConnection 类包含两个辅助函数,这些函数从 GameService 类中使用,以向 Azure Functions 服务发送 HTTP 请求。第一个是 GetAsync<T>,它接受两个参数:一个 URL 和一个字典,该字典包含要随 URL 一起传递的查询参数。它返回一个 T 实例和 AsyncError 作为 Tuple。GetAsync 方法在发起 HTTP 请求时将使用 GET HTTP 方法。另一个辅助方法是 PostAsync<T>,它使用 POST HTTP 方法,并接受两个参数:一个 URL 和一个对象,该对象作为请求体中的 JSON 格式发送。它将从响应中返回 T 的一个实例。
GetAsync<T> 和 PostAsync<T> 使用几个辅助方法;使用以下代码片段将它们添加到 ServiceConnection 类中:
UriBuilder GetUriBuilder(Uri uri, Dictionary<string, string> parameters)
=> new(uri)
{
Query = string.Join("&",
parameters.Select(kvp =>
$"{kvp.Key}={kvp.Value}"))
};
async ValueTask<AsyncError?> GetError(HttpResponseMessage responseMessage, Stream content)
{
AsyncError? error;
if (responseMessage.StatusCode == HttpStatusCode.Unauthorized)
{
log.LogError("Unauthorized request {@Uri}", responseMessage.RequestMessage?.RequestUri);
return new()
{
Message = "Unauthorized request."
};
}
try
{
error = await JsonSerializer.DeserializeAsync<AsyncError>(content, serializerOptions);
}
catch (Exception e)
{
error = new AsyncExceptionError()
{
Message = e.Message,
InnerException = e.InnerException?.Message,
};
}
log.LogError("{@Error} {@Message} for {@Uri}", responseMessage.StatusCode, error?.Message, responseMessage?.RequestMessage?.RequestUri);
return error;
}
GetUriBuilder 方法将返回一个新的 UriBuilder,该 UriBuilder 从提供的 URL 和键值对 Dictionary 中获取,用于查询字符串。GetError 方法将根据状态码或 HTTP 方法调用响应的内容返回 AsyncError 对象或 AsyncExceptionError 对象。
现在,我们可以使用以下代码将 GetAsync<T> 方法添加到 ServiceConnection 类中:
public async Task<(T Result, AsyncError Exception)> GetAsync<T>(Uri uri, Dictionary<string, string> parameters)
{
var builder = GetUriBuilder(uri, parameters);
var fullUri = builder.ToString();
log.LogDebug("{@ObjectType} Get REST call @{RestUrl}", typeof(T).Name, fullUri);
try
{
var responseMessage = await httpClient.GetAsync(fullUri);
log.LogDebug("Response {@ResponseCode} for {@RestUrl}", responseMessage.StatusCode, fullUri);
if (responseMessage.IsSuccessStatusCode)
{
try
{
var content = await responseMessage.Content.ReadFromJsonAsync<T>();
log.LogDebug("Object of type {@ObjectType} parsed for {@RestUrl}", typeof(T).Name, fullUri);
return (content, null);
}
catch (Exception e)
{
log.LogError("Error {@ErrorMessage} for when parsing ${ObjectType} for {@RestUrl}", e.Message, typeof(T).Name, fullUri);
return (default, new AsyncExceptionError()
{
InnerException = e.InnerException?.Message,
Message = e.Message
});
}
}
log.LogDebug("Returning error for @{RestUrl}", fullUri);
return (default, await GetError(responseMessage, await responseMessage.Content.ReadAsStreamAsync()));
}
catch (Exception e)
{
log.LogError("Error {@ErrorMessage} for REST call ${ResUrl}", e.Message, fullUri);
// The service might not be happy with us, we might have connection issues etc..
return (default, new AsyncExceptionError()
{
InnerException = e.InnerException?.Message,
Message = e.Message
});
}
}
虽然这个方法有点长,但它所做的事情并不复杂。首先,它使用 GetUriBuilder 方法创建 UriBuilder 实例,并从该实例构建 fullUri 字符串值。然后,它使用 HttpClient 实例向 URL 发起 HTTP GET 请求。如果发生任何失败,异常处理程序将捕获它并返回 AsynExceptionError。如果没有错误发生,并且响应代码指示成功,则处理并返回结果。否则,将读取结果内容以查找错误,如果找到,则返回。当 GetAsync<T> 方法返回时,它将始终返回两个项目:T 类型的响应和 AsyncError。如果其中任何一个不存在,则返回它们的默认值或 null。
审查并添加以下代码片段到 ServiceConnection 类以实现 PostAsync<T> 方法:
public async Task<(T Result, AsyncError Exception)> PostAsync<T>(Uri uri, object parameter)
{
log.LogDebug("{@ObjectType} Post REST call @{RestUrl}", typeof(T).Name, uri);
try
{
var responseMessage = await httpClient.PostAsJsonAsync(uri, parameter, serializerOptions);
log.LogDebug("Response {@ResponseCode} for {@RestUrl}", responseMessage.StatusCode, uri);
await using var content = await responseMessage.Content.ReadAsStreamAsync();
if (responseMessage.IsSuccessStatusCode)
{
if(string.IsNullOrEmpty(await.responseMessage.Content.ReadAsStringAsync()))
return (default, null);
try
{
log.LogDebug("Parse {@ObjectType} SUCCESS for {@RestUrl}", typeof(T).Name, uri);
var result = await responseMessage.Content.ReadFromJsonAsync<T>();
log.LogDebug("Object of type {@ObjectType} parsed for {@RestUrl}", typeof(T).Name, uri);
return (result, null);
}
catch (Exception e)
{
log.LogError("Error {@ErrorMessage} for when parsing ${ObjectType} for {@RestUrl}", e.Message, typeof(T).Name, uri);
return (default, new AsyncExceptionError()
{
InnerException = e.InnerException?.Message,
Message = e.Message
});
}
}
log.LogDebug("Returning error for @{RestUrl}", uri);
return (default, await GetError(responseMessage, content));
}
catch (Exception e)
{
log.LogError("Error {@ErrorMessage} for REST call ${ResUrl}", e.Message, uri);
// The service might not be happy with us, we might have connection issues etc..
return (default, new AsyncExceptionError()
{
InnerException = e.InnerException?.Message,
Message = e.Message
});
}
}
此方法基本上与 GetAsync<T> 相同,但有一些小的变化。首先,它不需要调用 GetUriBuilder 将参数添加到 Uri 的 QueryString 中,因为参数作为请求体的一部分发送。其次,它使用 HTTP POST 方法而不是 GET。有了这些变化,方法的大部分是错误处理,以确保我们返回正确的数据。
这样就完成了 ServiceConnection 类。ServiceConnection 和 Settings 服务类将在下一节中使用,其中我们将创建 GameService 类。
创建 GameService 类
GameService 类是 UI 和网络之间的一个层。它使用 ServiceConnection 类来处理特定的网络调用,以创建我们需要与 Azure Functions 交互的逻辑。对于我们在 第九章 中创建的每个函数,GameService 类都有一个对应的方法来调用函数并返回结果(如果有的话)。
按照以下步骤创建和初始化类:
在 SticksAndStones.App 项目的 Services 文件夹下创建一个名为 GameService 的新类。
将类定义更改为 public sealed 并从 IDisposable 接口继承:
public sealed class GameService : IDisposable
将以下命名空间声明添加到文件顶部:
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.AspNetCore.SignalR.Client;
using SticksAndStones.Messages;
using SticksAndStones.Models;
GameService 类将依赖于 Settings 服务和 ServiceConnection 服务,因此我们需要将它们添加到构造函数中,并将引用存储在类字段中,如下所示:
private readonly ServiceConnection service;
private readonly Settings settings;
public GameService(Settings settings, ServiceConnection service)
{
this.service = service;
this.settings = settings;
}
GameService class. .NET MAUI will provide the Settings and ServiceConnection instances through dependency injection.
通过向 GameService 类添加以下方法来实现 IDisposable 接口:
public void Dispose()
{
service.Dispose();
GC.SuppressFinalize(this);
}
现在,通过将以下高亮显示的代码行添加到 MauiProgram.cs 文件中,将类添加到依赖注入中:
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<Services.Settings>();
builder.Services.AddSingleton<Services.ServiceConnection>();
builder.Services.AddSingleton<Services.GameService>();
return builder.Build();
我们将从 Connect 方法开始。Connect 将接受一个要连接的 Player 对象,并返回一个更新的 Player 对象。此外,如果连接成功,它将配置 SignalR Hub。要创建 Connect 函数,请按照以下步骤操作:
创建一个名为 semaphoreSlim 的 private 字段,并使用一个具有初始和最大计数为 1 的新实例初始化该字段:
public sealed class GameService : IDisposable
{
private readonly SemaphoreSlim semaphoreSlim = new(1, 1);
private readonly ServiceConnection service;
SemaphoreSlim 类是限制一次执行操作线程数量的好方法。在我们的情况下,我们只想让一个线程在每次进行网络调用。它将在所有从 GameService 类进行网络调用的方法中使用。
GameService 将在名为 CurrentPlayer 的 public 属性中跟踪当前玩家;使用以下代码将属性添加到类中:
private readonly Settings settings;
public Player CurrentPlayer { get; private set; } = new Player() { Id = Guid.Empty, GameId = Guid.Empty };
属性被初始化为一个空的 Player 对象。
一旦用户以玩家身份连接,我们还需要一个地方来存储在线玩家的列表。为此,将以下属性添加到 GameService 类中:
public ObservableCollection<Player> Players { get; } = new();
GameService 类在名为 IsConnected 的属性中跟踪当前连接状态;使用以下代码片段将属性添加到 GameService 类中:
public bool IsConnected { get; private set; }
将一个名为 Connect 的 public async 方法添加到 GameService 类中。它应该返回 Task<Player> 并接受一个 Player 参数,如下所示:
public async Task<Player> Connect(Player player)
{
}
在 Connect 方法中,第一步是确保一次只有一个线程在该方法中操作:
await semaphoreSlim.WaitAsync();
这使用 C# 中的 async/await 结构创建一个锁,只有当 SemaphoreSlim 中有足够的开放槽位时才会释放。由于 SemaphoreSlim 实例仅初始化了一个槽位,因此一次只能有一个线程处理 Connect 方法。
为了确保 the SemaphoreSlim 实例释放槽位,我们需要在方法的其他部分添加异常处理,并在最后调用 Release。将以下代码片段添加到 Connect 方法中:
try
{
}
finally
{
semaphoreSlim.Release();
}
return CurrentPlayer;
try/finally 块确保我们将在方法结束时始终调用 Release,这将防止 the SemaphoreSlim 实例被饿死,防止任何其他线程进入该方法。最后,我们返回 CurrentPlayer 的值,我们将在 try 块中设置它。
处理 SemaphoreSlim 的另一种方法是
使用 try/catch/finally 块是可行的,但如果您正确处理所有异常或没有异常,则略显笨拙。Tom Dupont 在他的博客上发布了一个辅助类,允许您使用 using 语句来管理 the SemaphoreSlim 实例的生命周期。您可以在他的帖子 www.tomdupont.net/2016/03/how-to-release-semaphore-with-using.html 中阅读他的帖子。以下是他扩展的示例:
using var handle = semaphoreSlim.UseWaitAsync();
在 try 块中,添加以下代码行:
CurrentPlayer = player;
var (response, error) = await service.PostAsync<ConnectResponse>(new($"{settings.ServerUrl}/Connect"), new ConnectRequest(player));
if (error is null)
{
service.ConnectHub(response.ConnectionInfo);
response.Players.ForEach(Players.Add);
CurrentPlayer = response.Player;
IsConnected = true;
}
else
{
WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
}
此代码块处理 Azure Functions 服务中 Connect 函数的调用。我们首先将传入的玩家详细信息设置为 CurrentPlayer 属性。然后,将 player 实例打包成一个 ConnectRequest 对象,并将其传递给 ServiceConnection 实例上的 PostAsync<T> 调用。URL 是从存储在 Settings 服务中的 ServerUrl 属性拼接 /Connect 创建的。预期的响应类型为 ConnectResponse,并将其存储在 response 中。如果我们没有收到任何错误,那么我们可以在 ServiceConnection 实例上调用 ConnectHub,填充我们的 Players 集合,并将 the CurrentPlayer 属性设置为返回的 Player 实例,该实例将包含来自服务器的额外详细信息。如果发生任何问题,则错误对象将被填充,我们将向 UI 发送包含该错误的消息。
ServiceError 是我们需要从 GameService 类发送到 ViewModel 实例的第一个消息。它用于将 ServiceConnection 实例的错误发送到 ViewModel 实例。我们将在下一步添加 ServiceError 类。
在 SticksAndStones.App 项目中,创建一个名为 Messages 的新文件夹。
在 SticksAndStones.App 项目的 Messages 文件夹中创建一个名为 ServiceError 的新类。
ServiceError 消息是对 AyncError 对象的简单包装,可用于向视图模型发送消息。将 ServiceError.cs 文件的内容替换为以下内容:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SticksAndStones.Messages;
internal class ServiceError : ValueChangedMessage<AsyncError>
{
public ServiceError(AsyncError error) : base(error)
{
}
}
最后,由于我们正在使用 SemaphoreSlim 并且它可以保留原生资源,我们应该确保这些资源被正确释放。将以下高亮代码添加到 Dispose 方法中以清理 semaphoreSlim 字段:
public void Dispose()
{
semaphoreSlim.Release();
semaphoreSlim.Dispose();
service.Dispose();
GC.SuppressFinalize(this);
}
这就完成了当前的 Connect 方法。
接下来的三个方法是从 Lobby 页面调用的。第一个方法用于刷新玩家列表。当用户下拉列表以刷新或如果 SignalR Hub 重新连接时,将调用此方法。要实现 the RefreshPlayerList 方法,请按照以下步骤操作:
the RefreshPlayerList 方法不接受任何参数并返回 Task;将此方法添加到 GameService 类中,如下所示:
public async Task RefreshPlayerList()
{
await semaphoreSlim.WaitAsync();
try
{
var getAllPlayers = service.GetAsync<GetAllPlayersResponse>(new($"{settings.ServerUrl}/Players/GetAll"), new Dictionary<string, string> { { "id", $"{CurrentPlayer.Id}" } });
var (response, error) = await getAllPlayers;
if (error is null)
{
Players.Clear();
response.Players.ForEach(Players.Add);
}
else
{ WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
}
}
finally
{
semaphoreSlim.Release();
}
}
当 SignalR Hub 重新连接时,刷新玩家列表,请将以下高亮代码添加到 Connect 方法中:
if (error is null)
{
service.ConnectHub(response.ConnectionInfo);
response.Players.ForEach(Players.Add);
CurrentPlayer = response.Player;
(await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
}
这行代码很有趣。首先,我们等待 service.Hub,然后设置 Reconnected 事件为一个匿名函数,该函数调用 RefreshPlayerList。如果你还记得,ServiceConnection 类中的 Hub 属性是 AsyncLazy<T>。第一次引用 Hub 属性时,它将异步初始化自身,因此有 await 调用。
下一个从 Lobby 页面使用的方法是 IssueChallenge。当玩家希望与其他玩家进行比赛时,Lobby 页面会调用 IssueChallenge 方法。由于实际的挑战响应将通过 SignalR Hub 返回,因此 IssueChallenge 方法不返回任何值。该方法将向服务器发送请求并处理任何错误,如下所示:
public async Task IssueChallenge(Player opponent)
{
await semaphoreSlim.WaitAsync();
try
{
var (response, error) = await service.PostAsync<IssueChallengeResponse>(new($"{settings.ServerUrl}/Challenge/Issue"), new IssueChallengeRequest(CurrentPlayer, opponent));
if (error is not null)
{ WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
}
}
finally
{
semaphoreSlim.Release();
}
}
将前面的代码添加到 GameService 类中。当对手响应挑战时调用的 SendChallengeResponse 方法与 IssueChallenge 方法非常相似,如下所示:
public async Task SendChallengeResponse(Guid challengeId, Models.ChallengeResponse challengeResponse)
{
await semaphoreSlim.WaitAsync();
try
{
var (response, error) = await service.PostAsync<string>(new($"{settings.ServerUrl}/Challenge/Ack"), new AcknowledgeChallengeRequest(challengeId, challengeResponse));
if (error is not null)
{ WeakReferenceMessenger.Default.Send<ServiceError>(new(error));
}
}
finally
{
semaphoreSlim.Release();
}
}
将 SendChallengeResponse 方法添加到 GameService 类中。这样就完成了支持 Lobby 页面所需的方法。我们应用中的最后一页是 Game 页面。还需要三个由 Game 页面需要的方法。按照以下步骤添加它们:
添加 EndTurn 方法,该方法将玩家的移动发送到 Game 服务器,使用以下代码片段:
public async Task<(Game?, string?)> EndTurn(Guid gameId, int position)
{
await semaphoreSlim.WaitAsync();
try
{
var (response, error) = await service.PostAsync<ProcessTurnResponse>(new($"{settings.ServerUrl}/Game/Move"), new ProcessTurnRequest(gameId, CurrentPlayer, position));
if (error is not null)
{
return (null, error.Message);
}
else return (response.Game, null);
}
finally
{
semaphoreSlim.Release();
}
}
EndTurn 方法与 IssueChallenge 和 SendChallengeResponse 方法非常相似,只有一个小的例外:它返回更新后的 Game 对象和错误消息(如果有的话)。
the GetPlayerId 方法是一个小的辅助函数,用于搜索 Players 列表并返回与传入的 ID 匹配的 Player 实例。使用以下代码片段添加 GetPlayerById 方法:
public Player? GetPlayerById(Guid playerId)
{
if (playerId == CurrentPlayer.Id)
return CurrentPlayer;
return (from p in Players where p.Id == playerId select p).FirstOrDefault();
}
the GetMatchById 方法是最后一个将调用后端的方法。在这种情况下,它将根据 ID 获取一个 Match 对象。使用以下代码片段将 GetMatchById 添加到 GameService 类中:
public async Task<Match> GetMatchById(Guid matchId)
{
await semaphoreSlim.WaitAsync();
try
{
var (response, error) = await service.GetAsync<GetMatchResponse>(new($"{settings.ServerUrl}/Match/{matchId}"), new());
if (error != null) { }
if (response.Match != null)
return response.Match;
return new Match();
}
finally
{
semaphoreSlim.Release();
}
}
GameService 类的最后一部分是处理通过 SignalR Hub 接收的事件。为了从 第九章 中刷新我们的记忆,后端函数将通过 SignalR 将以下事件发送到客户端:
PlayerUpdatedEventArgs
ChallengeEventArgs
GameStartedEventArgs
GameUpdatedEventArgs
我们将在 GameService 方法中处理这些事件中的每一个。要实现这些事件的处理器,请按照以下步骤操作:
当 Hub 接收到 PlayerUpdatedEventArgs 时,我们需要使用新值更新 Players 集合中的 Player。我们将创建一个辅助函数来处理这项工作,如下所示:
private void PlayerStatusChangedHandler(PlayerUpdatedEventArgs args)
{
var changedPlayer = (from player in Players
where player.Id == args.Player.Id
select player).FirstOrDefault();
if (changedPlayer is not null)
{
changedPlayer.MatchId = args.Player.MatchId;
}
else if (args.Player.Id != CurrentPlayer.Id)
{
Players.Add(args.Player);
}
}
PlayerStatusChangedHandler 方法将在 Players 集合中定位已更改的玩家并更新实例的相关字段或如果不存在则添加它。
当接收到 PlayerUpdated 事件时调用 PlayerStatusUpdateHandler 类,请将以下突出显示的代码添加到 Connect 方法:
if (error is null)
{
service.ConnectHub(response.ConnectionInfo);
response.Players.ForEach(Players.Add);
CurrentPlayer = response.Player;
IsConnected = true;
(await service.Hub).On<PlayerUpdatedEventArgs>(Constants.Events.PlayerUpdated, PlayerStatusChangedHandler);
(await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
}
其他三个事件将通过 WeakReferenceManager 向 ViewModel 实例发送消息。首先,我们需要添加消息类型,按照以下步骤操作:
在 SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 ChallengeReceived 的新类。
用以下内容替换 ChallengeReceived.cs 文件的内容:
using CommunityToolkit.Mvvm.Messaging.Messages;
using SticksAndStones.Models;
namespace SticksAndStones.Messages;
public class ChallengeRecieved : ValueChangedMessage<Player>
{
public Guid Id { get; init; }
public ChallengeRecieved(Guid id, Player challenger) : base(challenger)
{
Id = id;
}
}
在 SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 MatchStarted 的新类。
用以下代码替换 MatchStarted.cs 文件的内容:
using CommunityToolkit.Mvvm.Messaging.Messages;
using SticksAndStones.Models;
namespace SticksAndStones.Messages;
public class MatchStarted : ValueChangedMessage<Match>
{
public MatchStarted(Match match) : base(match)
{
}
}
在 SticksAndStones.App 项目的 Messages 文件夹中添加一个名为 MatchUpdated 的新类。
用以下代码替换 MatchUpdated.cs 文件的内容:
using CommunityToolkit.Mvvm.Messaging.Messages;
using SticksAndStones.Models;
namespace SticksAndStones.Messages;
class MatchUpdated : ValueChangedMessage<Match>
{
public MatchUpdated(Match match) : base(match)
{
}
}
当接收到事件时发送消息,请将以下突出显示的代码添加到 GameService 类中的 Connect 方法:
service.ConnectHub(response.ConnectionInfo);
response.Players.ForEach(Players.Add);
CurrentPlayer = response.Player;
IsConnected = true;
(await service.Hub).On<PlayerUpdatedEventArgs>(Constants.Events.PlayerUpdated, PlayerStatusChangedHandler);
(await service.Hub).On<ChallengeEventArgs>(Constants.Events.Challenge, (args) => WeakReferenceMessenger.Default.Send(new ChallengeRecieved(args.Id, args.Challenger)));
(await service.Hub).On<MatchStartedEventArgs>(Constants.Events.MatchStarted, (args) => WeakReferenceMessenger.Default.Send(new MatchStarted(args.Game)));
(await service.Hub).On<MatchUpdatedEventArgs>(Constants.Events.MatchUpdated, (args) => WeakReferenceMessenger.Default.Send(new MatchUpdated(args.Game)));
(await service.Hub).Reconnected += (s) => { return RefreshPlayerList(); };
这就完成了 GameService 类。我们拥有了与后端功能交互所需的所有方法,并且我们正在处理发送到客户端的事件。本章的下一部分将添加用于向用户展示屏幕所需的页面,从 连接 页面开始。
创建连接页面
如 图 10.6 所示的 连接 页面,是应用加载后用户首先看到的屏幕。该页面包含四个主要元素:玩家游戏标签的输入框、玩家电子邮件地址的输入框、玩家头像的图像控件以及 连接 按钮。
图 10.6 – 连接页面
连接 页面将包含几个部分:
一个名为 ConnectViewModel.cs 的 ViewModel 文件
一个名为 ConnectView.xaml 的 XAML 文件,其中包含布局
一个名为 ConnectView.xaml.cs 的代码隐藏文件,将执行数据绑定过程
包含自定义按钮控件布局的 XAML 文件,称为 ActivityButton.xaml
ActivityButton.xaml.cs 中 ActivityButton 的代码隐藏
我们将首先实现 ConnectViewModel。
添加 ConnectViewModel
ConnectViewModel 与 LobbyViewModel 和 GameView 模型一起将继承自一个名为 ViewModelBase 的单一基类。ViewModelBase 类提供了实现页面刷新所需的功能。并非所有页面都会使用此功能,但它将是可用的。要添加 ViewModelBase,请按照以下步骤操作:
在 SticksAndStones.App 项目中创建一个名为 ViewModels 的新文件夹。
在 ViewModels 文件夹中添加一个名为 ViewModelBase 的新类。
在 ViewModelBase.cs 文件顶部添加以下命名空间声明:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
将类声明更改为 public abstract partial 并从 ObservableRecipient 派生类:
ObservableRecipient comes from CommunityToolkit. If you have worked through the other chapters in this book, you will have seen view models that derive from ObservableObject, which implements INotifyPropertyChanged. ObservableRecipient extends ObservableObject and adds built-in support for working with implementations of the .NET MAUI IMessage interface. To learn more about ObservableRecipient, visit https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/observablerecipient.
添加一个名为 canRefresh 的 private bool 字段,并使用 ObservableProperty 属性:
[ObservableProperty]
private bool canRefresh;
添加一个名为 isRefreshing 的 private bool 字段,并使用 ObservableProperty 属性:
[ObservableProperty]
private bool isRefreshing;
添加一个无参数返回 bool 类型的 private 方法名为 CanExecuteRefresh。方法签名和实现如下代码片段所示:
private bool CanExecuteRefresh() => CanRefresh && !IsRefreshing;
添加一个名为 RefreshInternal 的新 protected virtual 方法,它返回一个 Task,其实现返回 Task.CompletedTask,如下所示:
protected virtual Task RefreshInternal() => Task.CompletedTask;
添加如下所示的 Refresh 方法:
[RelayCommand(CanExecute = nameof(CanExecuteRefresh))]
public async Task Refresh()
{
IsRefreshing = true;
await RefreshInternal();
IsRefreshing = false;
return;
}
Refresh 方法是一个 Command,这意味着它可以绑定到 XAML 元素作为 RefreshCommand。CanExecuteRefresh 方法用于确定命令的启用/禁用状态。命令本身翻转 IsRefreshing 布尔值并调用 RefreshInternal 方法,在从 ViewModelBase 派生的类中放置具体的实现。
现在 ViewModelBase 已经实现,我们可以实现 ConnectViewModel。ConnectViewModel 类具有可绑定的玩家游戏标签和电子邮件地址属性以及各种命令状态。最后,有一个用于建立与游戏服务的连接的命令。让我们按照以下步骤实现 ConnectViewModel 类:
在 ViewModels 文件夹中创建一个名为 ConnectViewModel 的新类。
将类定义更改为 public partial 并从 ViewModelBase 派生,如下所示:
public partial class ConnectViewModel : ViewModelBase
{
}
ConnectViewModel 依赖于 GameService 和 Settings 服务,因此让我们添加一个构造函数来通过依赖注入获取它们,并添加 private 字段来存储它们的值,如下所示:
public partial class ConnectViewModel : ViewModelBase
{
private readonly GameService gameService;
private readonly Settings settings;
public ConnectViewModel(GameService gameService, Settings settings)
{
this.gameService = gameService;
this.settings = settings;
}
}
要使用 GameService 和 Settings 类,您需要在文件顶部添加一个命名空间声明:
using SticksAndStones.Services;
在代码片段中添加一个名为 gamerTag 的 private string 字段,并使用 ObservableProperty 属性来使其可绑定,如下所示:
[ObservableProperty]
private string gamerTag;
在代码片段中添加一个名为 emailAddress 的 private string 字段,并使用 ObservableProperty 属性来使其可绑定,如下所示:
[ObservableProperty]
private string emailAddress;
在 ConnectViewModel 的构造函数中,从用户上次连接时初始化可绑定属性,如下所示:
{
this.gameService = gameService;
this.settings = settings;
// Load Player settings
var player = settings.LastPlayer;
Username = player.GamerTag;
EmailAddress = player.EmailAddress;
}
Connect 页面不需要刷新视图,因此通过在构造函数开头添加以下行代码来禁用该功能:
CanRefresh = false;
要实现 Connect 命令,我们需要四样东西:一个表示命令状态的 string,一个表示命令当前状态的 bool,一个返回命令是否启用的方法,以及命令本身的方法。要将状态作为字符串添加,请在 ConnectViewModel 类的构造函数上方添加以下代码:
[ObservableProperty]
private string connectStatus;
我们使用 ObservableProperty 标记此字段,以便它可以绑定到视图中。
要将 isConnecting 字段添加以跟踪命令的状态,请在 connectStatus 字段下添加以下代码:
[ObservableProperty]
private bool isConnecting;
CanExecuteConnect 方法将在命令启用时返回 true,否则返回 false。请在 isConnecting 字段下方使用以下代码片段添加方法:
private bool CanExecuteConnect() => !string.IsNullOrEmpty(GamerTag) && !string.IsNullOrEmpty(EmailAddress) && !IsConnecting;
Connect 命令将调用 Connect 方法与游戏服务器建立连接。这主要是为了保持方法小且易于管理。请将以下私有 Connect 方法添加到 ConnectViewModel 类中:
private async Task<Player> Connect(Player player)
{
// Get SignalR Connection
var playerUpdate = await gameService.Connect(player);
if (gameService.IsConnected)
{
// If the player has an in progress match, take them to it.
if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
{
await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
}
else
{
await Shell.Current.GoToAsync($"///Lobby");
}
}
return playerUpdate;
}
此方法将在 GameService 类上调用 Connect 方法,传入玩家详细信息。如果连接成功,则用户将被导航到大厅页面,除非他们当前正在玩游戏,在这种情况下,他们将被导航到游戏页面。
.NET MAUI Shell 中的导航
在 .NET MAUI 中,导航是通过从 Shell 对象调用 GotoAsync 来实现的。Shell 对象可以通过将 App.Current.MainPage 强制转换为 Shell 对象,或者使用 Shell.Current 属性来获取。传递给 GotoAsync 的路由可以是相对于当前位置的,也可以是绝对路径。相对和绝对路由的有效形式如下:
• route – 路由将从当前位置向上搜索,如果找到,将被推送到导航堆栈
• /route – 路由将从当前位置向下搜索,如果找到,将被推送到导航堆栈
• //route – 路由将从当前位置向上搜索,如果找到,将替换导航堆栈
• ///route – 路由将从当前位置向下搜索,如果找到,将替换导航堆栈
要了解更多关于路由和导航的信息,请访问 learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation 。
使用以下代码片段将实现 Connect 命令的方法添加到 Connect ViewModel 类的底部:
[RelayCommand(CanExecute = nameof(CanExecuteConnect))]
public async Task Connect()
{
IsConnecting = true;
ConnectStatus = "Connecting...";
var player = settings.LastPlayer;
player.GamerTag = GamerTag;
player.EmailAddress = EmailAddress;
player.Id = (await Connect(player)).Id;
settings.LastPlayer = player;
ConnectStatus = "Connect";
IsConnecting = false;
}
命令非常直接。它设置 IsConnecting 和 ConnectStatus 属性,然后从视图更新 Player 值。然后,它调用 Connect,传入当前的 Player 实例。返回的玩家 ID 被捕获并设置回 Settings 中的 LastPlayer。最后,将 ConnectStatus 和 IsConnecting 属性设置回默认值。
为了总结,我们需要添加一些属性以确保值更改时属性得到适当的更新。例如,当 IsConnecting 属性更改时,我们需要确保再次评估 CanExecuteConnect 方法。为此,我们在 IsConnecting 字段上添加 NotifyCanExecuteChangeFor 属性,如下所示:
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
private bool isConnecting;
由于 gamerTag 字段和 emailAddress 字段也在 CanExecuteConnect 方法中被引用,因此我们应将这些字段也添加属性,如下所示:
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
private string gamerTag;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
private string emailAddress;
我们几乎完成了 ConnectViewModel 的实现。要实现的功能是处理可能从 GameService 收到的消息。CommunityToolkit 的 ObservableObject 实现提供了一个使订阅和取消订阅这些消息变得简洁的功能。要实现消息处理程序,请按照以下步骤操作:
在 ConnectViewModel 类中添加一个名为 OnServiceError 的新 private 方法,使用以下代码片段:
private void OnServiceError(AsyncError error)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await Shell.Current.CurrentPage.DisplayAlert("There is a problem...", error.Message, "Ok");
});
}
我们将从 ObservableObject 类的 OnActivated 事件方法订阅 ServiceError 消息。将以下方法添加到 ConnectViewModel 类中,以订阅 ServiceError 消息:
protected override void OnActivated() => Messenger.Register<ServiceError>(this, (r, m) => OnServiceError(m.Value));
要取消订阅 ServiceError 消息,请将以下方法添加到 ConnectViewModel 类中:
protected override void OnDeactivated() => Messenger.Unregister<ServiceError>(this);
在 ConnectViewModel 的构造函数中,我们需要启用由 ObservableObject 触发的 OnActivated 和 OnDeactivated 事件。这些事件是推荐订阅和取消订阅消息的地方。将以下代码行添加到构造函数的末尾以启用事件:
IsActive = true;
将 IsActive 设置为 true 将触发 OnActivated 事件。将其设置为 false 将触发 OnDeactivated 事件。
要触发 OnDeactivated 事件,我们需要将 IsActive 设置为 false。在 Connect 方法中添加以下突出显示的代码行:
private async Task<Player> Connect(Player player)
{
// Get SignalR Connection
var playerUpdate = await gameService.Connect(player);
if (gameService.IsConnected)
{
IsActive = false;
// If the player has an in progress match, take them to it.
if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
{
await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
}
else
{
await Shell.Current.GoToAsync($"///Lobby");
}
}
return playerUpdate;
}
在 SticksAndStones.App 项目的 MauiProgram.cs 文件中打开,并将以下突出显示的行添加到注册 ConnectViewModel 以进行依赖注入:
builder.Services.AddSingleton<Services.GameService>();
builder.Services.AddTransient<ViewModels.ConnectViewModel>();
return builder.Build();
这完成了 ConnectViewModel 的实现。ConnectViewModel 类控制用户游戏标签和电子邮件的输入。它使用玩家的详细信息将用户连接到游戏服务器。
添加连接视图
Connect 视图看起来相当简单,但其中有很多内容。我们将创建视图的创建分为三个部分:
创建 ActivityButton 控件:
ActivityButton控件是用于启动与后端服务连接的按钮。虽然一个简单的按钮可能可以完成任务,但如果有一个动画指示connect操作正在进行,并且按钮的文本也更新了会怎样?这正是ActivityButton将要做的,在一个可重用的控件中。
创建图片:
在这个页面上使用了一些图片。所有这些图片都是使用 AI 生成的。我们将探讨这是如何完成的。
构建视图:
这是我们将ActivityButton与我们的自定义图片和.NET MAUI 的内置控件结合起来,使ConnectView看起来像图中的样子。
让我们从构建ActivityButton控件开始。
那么,这个ActivityButton控件是什么?它基本上是一个带有ActivityIndicator的按钮,只有在按钮背后的任务正在工作时才会显示出来。这个控件复杂性的来源在于我们正在制作一个通用控件而不是一个专用控件。因此,从所有目的和用途来看,它需要表现得像一个正常的Button和ActivityIndicator。我们只为这个应用程序实现所需的特性,即便如此,它仍然是一个可重用的控件。
从Button,我们希望有以下 XAML 属性:
Text、FontFamily和FontSize
Command和CommandParameter
从ActivityIndicator,我们将有IsRunning。
这些 XAML 元素将像它们的原始属性一样可绑定。以下是一个示例,说明如何声明这个控件作为一个元素:
<controls:ActivityButton IsRunning="{Binding IsConnecting}"
Text="{Binding ConnectStatus}"
BackgroundColor="#e8bc65"
Command="{Binding ConnectCommand}"
HorizontalOptions="Center"
WidthRequest="200"
HeightRequest="48"/>
此列表来自本节稍后我们将创建的ConnectView.xaml的实际 XAML。
从两个底层控件复制的属性需要能够绑定到视图模型。这要求它们被实现为绑定属性。要创建一个绑定属性,你需要两样东西:一个属性和一个引用该属性的BindableProperty。BindableProperty提供了保持实现INotifyPropertyChanged的视图模型属性与控件属性的功能。以下是一个创建Command绑定属性的示例:
在SticksAndStones.App项目中创建一个名为Controls的新文件夹。
添加一个新的.NET MAUI ContentView(XAML)名为ActivityButton。
打开ActivityButton.xaml.cs文件。
创建一个名为Command的public ICommand属性,如下所示:
public ICommand Command
{
get => (ICommand)GetValue(CommandProperty);
set { SetValue(CommandProperty, value); }
}
BindableProperty属性与它们所绑定到的属性之间存在循环引用,因此直到我们完成下一步,你将得到红色的波浪线。这看起来几乎与我们所创建的每个属性都一样,除了get和set方法只是分别委托给GetValue和SetValue方法。GetValue和SetValue由BindableObject类提供,ContentView最终从这个类继承。GetValue和SetValue是视图模型中INotifyPropertyChanged的等价物。调用它们不仅存储值,还会发送通知,表明值已更改。
现在,为Command属性添加BindableProperty属性,使用以下代码片段:
public static readonly BindableProperty CommandProperty = BindableProperty.Create(
propertyName: nameof(Command),
returnType: typeof(ICommand),
declaringType: typeof(ActivityButton),
defaultBindingMode: BindingMode.TwoWay);
CommandProperty是BindableProperty类型,并使用BindableProperty类的Create工厂方法创建。我们传入我们正在绑定的属性的名称(Command),该属性返回的类型(Icommand),声明类型(在这种情况下是ActivityButton),然后是我们想要绑定的模式。BindingMode有四个选项:
OneWay —— 默认选项,将更改从源(视图模型)传播到目标(控件)
OneWayToSource —— 这是OneWay的反向,将更改从目标(控件)传播到源(视图模型)
TwoWay —— 这在两个方向上传播更改
OneTime —— 仅当BindingContext更改时传播更改,并忽略所有INotifyPropertyChanged事件
这两个组件——你将在大多数 C#类中使用的一般属性,以及BindableProperty——提供了我们创建自定义控件所需的所有功能。
现在我们已经了解了如何在 XAML 控件上实现BindableProperty,我们可以完成ActivityButton的实现。
让我们先更新 XAML,然后我们将继续剩余的BindableProperty实现。以下步骤将指导你创建控件:
我们选择的用于创建 XAML 和.cs文件的模板并不完全符合我们为ActivityButton的需求。我们需要将底层根控件从ContentView更改为Frame。我们使用Frame来用边框包裹我们的布局。打开ActivityButton.cs文件,并更新类定义以从ContentView继承到Frame,如下所示:
public partial class ActivityButton : ActivityButton.xaml file and modify it to look like the following:
<Frame
x:Class="SticksAndStones.Controls.ActivityButton">
</ContentView to Frame,同时删除 Frame 的内容,因为我们不会重新使用它。
让我们给我们的控件命名,以便以后更容易引用它。通常,在 C#中,如果你想引用类的实例,你会使用this关键字。在 XAML 中默认不存在this,所以添加x:Name属性并使用this的值来模拟 C#。
更新 Frame 元素并添加 BackgroundColor 属性,其值为 {x:StaticResource Primary}。Primary 在 Resources/Styles/Colors.xaml 文件中定义,我们可以使用 StaticResource 扩展方法来引用它。
更新 Frame 元素并添加 CornerRadius 属性,其值为 5。这将给我们的按钮带来圆角。
将 Padding 属性的值设置为 12 添加到 Frame 元素。这将确保在控件周围有足够的空白。Frame 元素现在应该看起来像以下:
<?xml version="1.0" encoding="utf-8" ?>
<Frame
x:Class="SticksAndStones.Controls.ActivityButton"
x:Name="this"
BackgroundColor="{x:StaticResource Primary}"
CornerRadius="5"
Padding="12">
</Frame>
要在 Frame 中使 ActivityIndicator 和 Label 侧边对齐,我们将使用包含在 VerticalStackLayout 中的 HorizontalStackLayout。StackLayout 控件忽略控制方向的对齐选项,例如,VerticalStackLayout 忽略其子控件的 VerticalOptions 属性,而 HorizontalStackLayout 忽略其子控件的 HorizontalOptions 属性。这是因为,根据其本质,HorizontalStackLayout 控制在水平平面上布局其子控件,同样,VerticalStackLayout 也是如此,只是在垂直平面上。将以下突出显示的代码添加到 XAML 中:
<Frame
x:Class="SticksAndStones.Controls.ActivityButton"
BackgroundColor="{x:StaticResource Primary}"
CornerRadius="5"
Padding="12">
<VerticalStackLayout>
<HorizontalStackLayout HorizontalOptions="CenterAndExpand" Spacing="10">
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
在 HorizontalStackLayout 元素内,添加以下 XAML:
<ActivityIndicator HeightRequest="15" WidthRequest="15"
Color="{x:StaticResource White}"
IsRunning="{Binding Source={x:Reference this},Path=IsRunning}"
IsVisible="{Binding Source={x:Reference this},Path=IsRunning}"
VerticalOptions="CenterAndExpand"/>
ActivityIndicator 将具有 Height 和 Width 值为 15 和 Color 值为 White。IsRunning 和 IsVisible 属性绑定到控件的 IsRunning 属性。我们尚未创建 IsRunning 属性,因此这不会工作,直到我们这样做。x:Reference 标记扩展允许我们将属性绑定到父控件,我们在 步骤 3 中将其命名为 this。
现在,我们可以使用以下 XAML 在 HorizontalStackLayout 内添加 Label:
<Label x:Name="buttonLabel" TextColor="{x:StaticResource White}"
Text="{Binding Source={x:Reference this},Path=Text}"
FontSize="15"
VerticalOptions="CenterAndExpand"
VerticalTextAlignment="Center"
HorizontalTextAlignment="Start" />
当用户在 Frame 的任何地方点击或轻触时,应运行 Command。为此,我们将使用 GestureRecognizer。GestureRecognizer 是 XAML 提供事件处理程序的方式。有几种不同类型的 GestureRecognizer:
对于 ActivityButton,我们关注的是 TapGestureRecognizer。由于在此控件在视图中使用之前,要执行的操作尚未定义,因此当 Frame 被点击时,TapGestureRecognizer 将调用一个命令。将以下 XAML 添加到 Frame 元素以创建 TapGestureRecognizer:
<Frame.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={x:Reference this},Path=Command}" CommandParameter="{Binding Source={x:Reference this},Path=CommandParameter}" />
</Frame.GestureRecognizers>
TapGestureRecognizer 的 Command 属性和 CommandParameter 属性设置为绑定到父控件的 Command 和 CommandParameter 属性。
如果 IsRunning 属性为 true,则 Frame 应该被禁用,反之亦然。DataTrigger 是一种 XAML 方法,用于根据另一个控件属性的变化设置一个控件的属性。要为 Frame 添加触发器,请将突出显示的 XAML 添加到控件:
<Frame.Triggers>
<DataTrigger TargetType="Frame" Binding="{Binding Source={x:Reference this},Path=IsBusy}" Value="True">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
<DataTrigger TargetType="Frame" Binding="{Binding Source={x:Reference this},Path=IsBusy}" Value="False">
<Setter Property="IsEnabled" Value="True" />
</DataTrigger>
</Frame.Triggers>
这就完成了控件的 XAML 部分。打开ActivityButton.xaml.cs文件,我们可以添加缺失的属性,从CommandParameter开始:
public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(
propertyName: nameof(CommandParameter),
returnType: typeof(object),
declaringType: typeof(ActivityButton),
defaultBindingMode: BindingMode.TwoWay);
public object CommandParameter
{
get => GetValue(CommandParameterProperty);
set { SetValue(CommandParameterProperty, value); }
}
将前面的代码列表添加到ActivityButton类中。除了名称外,这个属性与Command属性没有显著的不同。CommandParameter允许你通过 XAML 指定传递给Command的参数。
Label控件是从Text属性填充的。要添加Text属性,请将以下代码添加到ActivityButton类中:
public static readonly BindableProperty TextProperty = BindableProperty.Create(
propertyName: nameof(Text),
returnType: typeof(string),
declaringType: typeof(ActivityButton),
defaultValue: string.Empty,
defaultBindingMode: BindingMode.TwoWay);
public string Text
{
get => (string)GetValue(TextProperty);
set { SetValue(TextProperty, value); }
}
在Text属性的情况下,returnType已更改为string,但除此之外,它与Command和CommandParameter类似。
我们接下来需要实现的是IsRunning属性,如下所示:
public static readonly BindableProperty IsRunningProperty = BindableProperty.Create(
propertyName: nameof(IsRunning),
returnType: typeof(bool),
declaringType: typeof(ActivityButton),
defaultValue: false);
public bool IsRunning
{
get => (bool)GetValue(IsRunningProperty);
set { SetValue(IsRunningProperty, value); }
}
为了允许更改文本的大小和字体,我们实现了FontSize和FontFamily属性:
public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create(
propertyName: nameof(FontFamily),
returnType: typeof(string),
declaringType: typeof(ActivityButton),
defaultValue: string.Empty,
defaultBindingMode: BindingMode.TwoWay);
public string FontFamily
{
get => (string)GetValue(Label.FontFamilyProperty);
set { SetValue(Label.FontFamilyProperty, value); }
}
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(
nameof(FontSize),
typeof(double),
typeof(ActivityButton),
Device.GetNamedSize(NamedSize.Small, typeof(Label)),
BindingMode.TwoWay);
public double FontSize
{
set { SetValue(FontSizeProperty, value); }
get { return (double)GetValue(FontSizeProperty); }
}
完成了ActivityButton。在我们创建游戏所需的图像之后,我们将立即在创建连接视图 部分使用ActivityButton。
使用 Bing 图片创建器创建图像
游戏中使用了几个图像。它们如下所示:
创建这些图像可能相当耗时,并且根据你的艺术能力,可能并不完全符合你的预期。对于你的应用程序,你可能选择雇佣图形设计师或艺术家为你创建数字资产。最近,出现了一个新的选项,那就是使用 AI 生成图像。在本节中,我们将探讨如何使用Bing Image Creator 来创建游戏所需的图像。
Bing 图片创建器使用你想要看到的场景的英文描述,并尝试创建它。你可以使用一些关键词来指导图片创建器以创建图像的艺术风格,例如游戏艺术 、数字艺术 或逼真 。
让我们通过创建棍子图像开始:
在 Microsoft Edge 或你喜欢的网页浏览器中打开bing.com/create 。
如果需要,请使用你的 Microsoft 账户登录。这可以是你用于在第九章 中登录 Azure 门户的相同账户。
在提示框中,输入以下提示,然后按创建 :
A single wood stick, positioned horizontally, with five stubs where branches would be and no leaves, no background, game art
Image Creator 将根据你的描述生成四张不同的图像。如果你对结果不满意,可以稍微调整描述并再次尝试。描述越详细,结果越好。尽量创建一个几乎垂直或水平的棍子,因为它将更容易旋转和裁剪图像。如果它是在明亮的白色背景上,看起来也会更好。
图 10.7 – Image Creator 的图像样本集
一旦你有一个满意的图片,点击图片以打开它。
点击下载 按钮将图片保存到你的本地计算机。
现在,在您喜欢的图像编辑器中打开下载的文件。Image Creator 创建的图像大约是 1024 x 1024,理想情况下,图像应该是 3:9 的比例,或者大约 300 x 900 像素。使用您的图像编辑器工具,裁剪图像,使其大约为 300 x 900 像素。
将图像保存为 hstick.jpeg,如果棍子是水平放置,或者保存为 vstick.jpeg,如果棍子是垂直放置,在 SticksAndStones.App 项目的 Resources/Images 文件夹中。
使用相同的图像编辑工具,将图像旋转 90 度,使其方向相反,并将图像保存到 Resources/``I``mages 文件夹中,如果棍子现在是水平放置,则保存为 hstick.jpeg,如果是垂直放置,则保存为 vstick.jpeg。
我们几乎完成了所需创建的一半图像。让我们接下来创建石头:
在 Microsoft Edge 或您喜欢的网页浏览器中打开 bing.com/create 。
如果需要,使用您的 Microsoft 账户登录。这可以与您在 第九章 中用于登录 Azure 门户的同一账户相同。
在提示框中,输入以下提示,然后按 创建 :
3 grey stones, arranged closely together, no background, game art
通过处理提示,得到三块石头整齐堆叠在一起,最好是在白色背景上,如图所示:
图 10.8 – 白色背景上的三块石头
当您对生成的图像满意时,点击图像以打开它。
点击 下载 按钮将图像保存到您的本地计算机。
现在,在您喜欢的图像编辑器中打开下载的文件。由于石头应该是方形图像,我们只需将文件保存到 Resources/Images 文件夹中,文件名为 stones.jpeg。
没有图像编辑器?
没有您喜欢的图像编辑器?如果您使用的是 Windows,Paint 可以很好地完成这项工作,或者您可以使用 Visual Studio 编辑图像。在 macOS 上,您可以使用预览。
太好了,我们现在有了玩游戏所需的棍子和石头,这也标志着 Image Creator 生成游戏图像的使用结束。您总是可以返回网站并查看以前的结果,这是一个很好的功能。现在,我们可以继续创建 Connect 视图。
创建连接视图
Connect 视图是用户在应用程序中除了启动画面外将看到的第一个 UI。图 10**.6 提供了最终视图的表示。如果您决定生成自己的图像,图像可能会有所不同。我们将把这个部分分为三个部分。首先,我们将创建包含静态内容的视图顶部部分,然后继续创建包含输入控制的视图中间部分,最后,创建 连接 按钮。让我们通过以下步骤开始创建视图的顶部部分:
在 SticksAndStones.App 项目中创建一个名为 Views 的文件夹。
右键单击 Views 文件夹,选择 添加 ,然后点击 新建项... 。
如果您正在使用 Visual Studio 17.7 或更高版本,请点击弹出对话框中的 显示所有模板 按钮;否则,转到下一步。
在左侧的 C# Items 节点下,选择 .NET MAUI 。
选择 ConnectView.xaml。
点击 添加 创建页面。
参考以下截图查看上述信息:
图 10.9 – 添加新的 .NET MAUI ContentPage (XAML)
将视图的标题更改为 Sticks & Stones。由于 XAML 是 XML 的方言,字符串中的 & 必须转义为 &。
将以下突出显示的命名空间添加到 ContentView 元素中。它们将为我们提供访问 ViewModels、Controls 和 Toolkit 命名空间中的类的能力:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.ConnectView”
Title=“Sticks and Stones”>
为了让 IntelliSense 对我们将要添加的绑定感到满意,通过在 ContentView 元素中添加 x:DataType 属性来定义视图所使用的视图模型,如下所示:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.ConnectView”
x:DataType=“viewModels:ConnectViewModel”
Title=“Sticks and Stones”>
我们不希望用户使用任何导航,例如 Shell 提供的 Back 按钮,除了本页提供的之外,因此请使用以下列表中突出显示的代码禁用它:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.ConnectView”
x:DataType=“viewModels:ConnectViewModel”
Title=“Sticks and Stones”
BackgroundColor of the entire view to White, which will make the images blend better, by adding the following highlighted code:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.ConnectView”
x:DataType=“viewModels:ConnectViewModel”
Title=“Sticks & Stones”
NavigationPage.HasNavigationBar=“False”
定义了四行的 Grid 控制器;在 ContentPage 元素内添加以下代码:
<Grid Margin=“40”>
<Grid.RowDefinitions>
<RowDefinition Height=“8*”/>
<RowDefinition Height=“2*”/>
<RowDefinition Height=“8*”/>
<RowDefinition Height=“1*”/>
</Grid.RowDefinitions>
</Grid>
Grid 使用 40 的 Margin 值来为图像和控制提供足够的空白。第 8 单位的第 1 行将包含应用的标志。第 2 行将包含文本 Sticks & Stones。第 3 行将包含头像图像、电子邮件和游戏标签输入控件。最后一行将包含 Connect 按钮。记住,Height 单位是相对的,所以第 0 行,即第一行,将是第 1 行的四倍高,是第 3 行,即最后一行的八倍高。Height 值中的 * 符号表示如果需要,该行可以扩展。
为了将生成的图像排列成类似盒子的布局,我们使用另一个 Grid 控制器。在 </Grid.RowDefinitions> 和 </Grid> 标签之间添加以下列表:
<Grid Grid.Row=“0” WidthRequest=“150” HeightRequest=“150”>
<Grid.ColumnDefinitions>
<ColumnDefinition Width=“1*” />
<ColumnDefinition Width=“5*” />
<ColumnDefinition Width=“1*” />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height=“1*” />
<RowDefinition Height=“4*” />
<RowDefinition Height=“1*” />
</Grid.RowDefinitions>
<Image Grid.Row=“0” Grid.Column=“1” Source=“hstick.jpeg” Aspect=“Fill”/>
<Image Grid.Row=“1” Grid.Column=“0” Source=“vstick.jpeg” Aspect=“Fill”/>
<Image Grid.Row=“1” Grid.Column=“1” Source=“stones.jpeg” Aspect=“AspectFit”/>
<Image Grid.Row=“1” Grid.Column=“2” Source=“vstick.jpeg” Aspect=“Fill”/>
<Image Grid.Row=“2” Grid.Column=“1” Source=“hstick.jpeg” Aspect=“Fill”/>
</Grid>
这个 Grid 控件定义了三行和三列。Grid 的内容完全由 Image 控件组成。Grid 位于其父 Grid 的行 0。网格的子控件,即 Image 控件,通过在 Image 控件上设置 Grid.Row 和 Grid.Column 属性来定位。棍子图像使用设置为 Fill 的 Aspect 属性。Fill 允许图像缩放以完全填充内容区域;为此,它可能不会在 x 和 y 轴上均匀缩放。石头使用 Aspect 值为 AspectFit。这将均匀缩放图像,直到至少一边适合,这可能会导致信封式显示。Aspect 属性还有两个其他选项:Center,它不进行缩放,和 AspectFill,它将缩放直到两个轴都填满视图,这可能会导致裁剪。
在外部的 Grid 控件中添加一个包含文本 Connect to Sticks & Stones 的 Label 元素,并将其放置在行 1,即 Grid 的第二行。在 步骤 11 中添加的内部 Grid 控件之后,将以下代码添加到外部的 Grid 控件中:
<Label Grid.Row="1" Text="Connect to Sticks & Stones" FontSize="20" TextColor="Black" FontAttributes="Bold" Margin="0,0,0,20" HorizontalOptions="Center"/>
页面的下一部分包含头像图片、游戏标签输入和电子邮件输入控件。使用 HorizontalStackLayout 和 VerticalStackLayout 控件来排列控件。在 步骤 12 中添加的 Label 控件之后,将以下代码片段添加到外部的 Grid 控件中:
<HorizontalStackLayout Grid.Row="2" HorizontalOptions="Center">
<VerticalStackLayout Spacing="10" >
<Image HeightRequest="96" WidthRequest="96" BackgroundColor="LightGrey">
<Image.Source>
<toolkit:GravatarImageSource
Email="{Binding EmailAddress}"
Image="MysteryPerson" />
</Image.Source>
</Image>
</VerticalStackLayout>
<VerticalStackLayout Spacing="10" >
<Entry Placeholder="username" Keyboard="Email" Text="{Binding Username}" HorizontalTextAlignment="Start" HorizontalOptions="FillAndExpand"/>
<Entry Placeholder="user@someaddress.com" Keyboard="Email" Text="{Binding EmailAddress}" HorizontalTextAlignment="Start" HorizontalOptions="FillAndExpand"/>
</VerticalStackLayout>
</HorizontalStackLayout>
HorizontalStackLayout 控件被分配到 Grid 的第二行,即第三行。它在该行内水平居中。第一个 VerticalStackLayout 安排了组成头像的控件。它包含一个 Image 元素,其源设置为 GravatarImageSource 的实例。GravatarImageSource 使用 ConnectViewModel 的 EmailAddress 属性并将其绑定到 GravatarImageSource 的 Email 属性。当 EmailAddress 发生变化时,图片将自动更新。Image 属性使用 MysteryPerson 值在电子邮件地址没有可用的 Gravatar 时提供一个简单的个人资料。第二个 VerticalStackLayout 包含两个 Entry 控件:第一个,用于游戏标签,绑定到 ConnectViewModel 的 Username 属性,第二个绑定到 ConnectViewModel 的 EmailAddress 属性。Keyboard 属性确定当焦点在控件上时显示哪个虚拟键盘。有关自定义键盘的更多信息,请参阅 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/entry#customize-the-keyboard 。
要添加到 ConnectView 的最后一个控件是 Connect 按钮。使用以下代码片段将按钮添加到视图中:
<controls:ActivityButton Grid.Row="3"
IsRunning="{Binding IsConnecting}"
Text="{Binding ConnectStatus}"
BackgroundColor="#e8bc65"
Command="{Binding ConnectCommand}"
HorizontalOptions="Center"
WidthRequest="200"
HeightRequest="48"/>
“连接”按钮使用在 创建 ActivityButton 控件 中创建的 ActivityButton 控件。控件位于第 3 行,第四行,IsRunning 属性绑定到 ConnectViewModel.IsConnecting 方法。按钮的 Text 属性绑定到 ConnectViewModel.ConnectStatus 属性,最后,Command 绑定到 ConnectViewModel.Connect 方法。
在SticksAndStones.App项目中打开 MauiProgram.cs 文件,并将以下高亮行添加以使用依赖注入注册 ConnectView:
builder.Services.AddSingleton<Services.GameService>();
builder.Services.AddTransient<ViewModels.ConnectViewModel>();
builder.Services.AddTransient<Views.ConnectView>();
return builder.Build();
现在,我们需要通过依赖注入消耗 ConnectViewModel 实例并将其设置为绑定对象。打开 ConnectView.Xaml.cs 文件并按以下方式修改:
using SticksAndStones.ViewModels;
namespace SticksAndStones.Views;
public partial class ConnectView : ContentPage
{
public ConnectView(ConnectViewModel viewModel)
{
this.BindingContext = viewModel;
InitializeComponent();
}
}
最后,我们需要将 ConnectView 设置为第一个显示的视图。在 SticksAndStones.App 项目中打开 AppShell.xaml 文件,并更新 Shell 元素的内容,如所示:
<Shell
x:Class="SticksAndStones.App.AppShell"
Shell.FlyoutBehavior="Disabled">
<ShellItem Route="Connect">
<ShellContent ContentTemplate="{DataTemplate views:ConnectView}" />
</ShellItem>
</Shell>
应用程序中的三个视图中的第一个已经完成。要测试它,请按照以下步骤操作:
在 Visual Studio 中,在 解决方案资源管理器 中右键单击 SticksAndStones.Functions 项目,然后选择 调试 | 不调试启动 。
在 SticksAndStones.App 项目中,选择 设置为 启动项目 。
按 F5 启动 SticksAndStones.App 项目,使用调试器。
“大厅”页面,它将允许我们与其他玩家开始游戏。
创建大厅页面
“大厅”页面显示已连接玩家的列表,并允许玩家向其他玩家发起比赛挑战。随着更多玩家连接到服务器,他们将被添加到可用玩家列表中。图 10.10 显示了页面的两个视图,一个包含已连接玩家,另一个在没有其他玩家连接时为空视图。
图 10.10 – 大厅视图
每个玩家都显示在一个卡片上,其中包含他们的头像图像、游戏标签、状态以及一个按钮,允许玩家向其他玩家发起比赛挑战。
此页面由两个 ViewModel 类组成,而不是一个。正如你可能预期的,有一个 LobbyViewModel 类,该类有一个 PlayerViewModel 实例的集合。除了 ViewModel 类之外,还有一个 LobbyView 类。让我们从创建 PlayerViewModel 类开始。
添加 PlayerViewModel
PlayerViewModel 与我们所有的其他 ViewModel 类非常相似,但有一点细微的区别:它没有直接绑定到视图。否则,它具有相同的目的:抽象模型,在这种情况下是 Player,从显示它的 UI 中分离出来。PlayerViewModel 提供了显示每个单个玩家卡片在 LobbyView 中所需的所有绑定属性。要添加 PlayerViewModel,请按照以下步骤操作:
在 SticksAndStones.App 项目中,在 ViewModels 文件夹下创建一个名为 PlayerViewModel 的新类。
将以下命名空间添加到文件顶部:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using SticksAndStones.Models;
using SticksAndStones.Services;
将以下代码添加到类中:
private readonly Player playerModel;
private readonly GameService gameService;
public PlayerViewModel(Player player, GameService gameService)
{
playerModel = player;
this.gameService = gameService;
}
这将为构造函数传入的参数值添加两个 private 字段。与 ConnectViewModel 类似,构造函数的参数由依赖注入提供。
玩家卡片显示玩家的游戏标签、头像和状态。将以下代码添加到 PlayerViewModel 类中,以添加 Id 和 GamerTag 属性:
public Guid Id => playermodel.Id;
public string GamerTag => playerModel.GamerTag;
对于 PlayerViewModel,我们绑定的一些属性并没有使用 ObservablePropertyAttribute 实现。这是因为我们直接从 Player 模型提供它们的值。因此,属性的 get 方法只是返回模型对象的相应属性。没有定义的 set 方法,所以这个属性本质上是一个单向数据绑定。
Status 属性有一点不同,因为它不在我们的 Player 模型上存在。Status 属性是玩家是否在比赛中的文本指示。Player 模型确实有一个 MatchId 属性,所以如果 Player 模型有一个有效的 MatchId(即,不是 Guid.Empty),则状态将是 "In a match";否则,该状态将是 "Waiting for opponent"。将以下代码添加到 PlayerViewModel 以实现 Status 属性:
public bool IsInMatch => !(playerModel.MatchId == Guid.Empty);
public string Status => IsInMatch switch
{
true => "In a match",
false => "Waiting for opponent"
};
IsInMatch 属性用于简化 Status 属性的实现。它也将在类中稍后使用。Status 属性是一个简单的基于 IsInMatch 的开关,并返回适当的 string 值。
要添加一个处理 Challenge 按钮的命令,将以下代码添加到 PlayerViewModel 类中:
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ChallengeStatus))]
private bool isChallenging = false;
public string ChallengeStatus => IsChallenging switch
{
true => "Challenging...",
false => "Challenge"
};
public bool CanChallenge => !IsInMatch && !IsChallenging;
[RelayCommand(CanExecute = nameof(CanChallenge))]
public void Challenge(PlayerViewModel opponent)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
IsChallenging = true;
bool answer = await Shell.Current.CurrentPage.DisplayAlert("Issue Challenge!", $" You are about to challenge {GamerTag} to a match!\nAre you sure?", "Yes", "No");
if (answer)
{
await gameService.IssueChallenge(opponent.Player);
}
IsChallenging = false;
});
return;
}
当命令正在等待挑战响应时,它会阻止执行,这是有道理的——没有必要催促其他玩家。在挑战期间,IsChallenging 属性设置为 true,完成时设置为 false。CanChallenge 属性是 IsInMatch 和 IsChallenging 的组合,这意味着你不能在有现有挑战进行时挑战同一玩家,也不能挑战已经与其他玩家进行比赛的玩家。用作按钮文本的 ChallengeStatus 绑定到 IsChallenging 值,并在该属性更新时更新。你可能已经注意到我们的命令只接受一个参数。这是用来操作正确玩家的。
这完成了 PlayerViewModel 的实现。接下来,使用 LobbyViewModel 来封装 PlayerViewModel 对象集合。
添加 LobbyViewModel
LobbyViewModel 的实现相当直接。它有一个暴露给 UI 的 PlayerViewModel 对象集合,允许用户下拉刷新视图,并处理 ChallengeReceived、MatchStarted 和 ServiceError 消息。按照以下步骤实现 LobbyViewModel:
在 SticksAndStones.App 项目中,在 ViewModels 文件夹内,创建一个名为 LobbyViewModel 的新类。
将以下命名空间添加到文件顶部:
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.Messaging;
using SticksAndStones.Messages;
using SticksAndStones.Models;
using SticksAndStones.Services;
将类声明修改为从 ViewModelBase 继承的 public partial 类,如下所示:
LobbyViewModel class:
private readonly GameService gameService;
public ObservableCollection Players { get; init; }
public LobbyViewModel(GameService gameService)
{
this.gameService = gameService;
Players = new(from p in gameService.Players
where p.Id != gameService.CurrentPlayer.Id
select new PlayerViewModel(p, gameService));
CanRefresh = true;
IsActive = true;
}
`LobbyViewModel` receives an instance of `GameService` via dependency injection. The `gameService` instance is used to initialize the `Players` list. The `Players` property from the `GameService` class is a collection of the `Player` model, whereas `Players` in `LobbyViewModel` is an `ObservableCollection` `instance` of `PlayerViewModel`. We use `ObservableCollection` because it provides support for `INotifyPropertyChanged` and `INotifyCollectionChanged` when it is bound automatically. A LINQ query is used to get all the current players and add them to the `Players` `ObservableCollection`. `CanRefresh` from `ViewModelBase` is set to `true`, which enables `RefreshCommand`. Finally, `IsActive` is set to `true`, which enables the `OnActivated` and `OnDeactivated` events.
随着 GameService.Players 列表中的玩家连接到服务器,该列表将更新。然而,这些更改不会自动传播到 LobbyViewModel.Players 集合。通过实现 GameService.Players 属性的 CollectionChanged 事件的处理程序,我们可以相应地更新 LobbyViewModel.Players 集合。向 LobbyViewModel 类添加以下方法:
private void OnPlayersCollectionChanged(object? sender,
NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
foreach (var player in e.NewItems.Cast<Player>())
{
Players.Add(new PlayerViewModel(player, gameService));
}
}
else if (e.Action == NotifyCollectionChangedAction.Remove)
{
foreach (var player in e.OldItems.Cast<Player>())
{
var toRemove = Players.FirstOrDefault(p => p.Id == player.Id);
Players.Remove(toRemove);
}
}
else if (e.Action == NotifyCollectionChangedAction.Replace)
{
}
else if (e.Action == NotifyCollectionChangedAction.Reset)
{
Players.Clear();
}
}
OnPlayersCollectionChanged 方法是 Notify CollectionChangedEventHandler 的实现。它由 Observable Collection.CollectionChanged 事件调用。每当集合中的项目被添加、删除或更新时,都会调用此事件。当整个集合被清除时,也会调用此事件。此方法处理 NotifyCollectionChangedAction 的 Add、Remove 和 Reset 值。
在 OnActivated 事件处理程序中,将 Players.CollectionChanged 事件分配给 OnPlayers CollectionChanged 方法。使用以下代码添加 OnActivated 和 OnDeactivated 方法:
protected override void OnActivated()
{
gameService.Players.CollectionChanged += OnPlayersCollectionChanged;
// If the player has an in progress match, take them to it.
if (gameService.CurrentPlayer?.MatchId != Guid.Empty)
{
MainThread.InvokeOnMainThreadAsync(async () =>
{
IsActive = false;
await Shell.Current.GoToAsync(Constants.ArgumentNames.MatchId, new Dictionary<string, object>() { { "MatchId", gameService.CurrentPlayer.MatchId } });
});
}
}
protected override void OnDeactivated()
{
gameService.Players.CollectionChanged -= OnPlayersCollectionChanged;
}
在 OnActivated 方法中,将 CollectionChanged 事件分配给 OnPlayersCollectionChanged 方法,并在 OnDeactivated 方法中取消分配。在 OnActivated 中,还有一个检查以查看玩家是否已经在比赛中。如果是,则应用程序立即导航到 Match 视图。在导航到 Match 视图时,我们发送一个 Match 参数。这将要么是 MatchId,要么是 Match 模型。
打开 SticksAndStones.Shared 项目的 Constants.cs 文件,将以下代码片段添加到 Constants 类中:
public class ArgumentNames
{
public static readonly string Match = nameof(Match);
public static readonly string MatchId = nameof(MatchId);
}
在大厅中,有三个消息需要处理:ChallengeReceived、MatchStarted 和 ServerError。将以下代码添加到每个消息的处理程序中:
private void OnChallengeReceived(Guid challengeId, Player opponent)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
bool answer = await Shell.Current.CurrentPage.DisplayAlert("You have been challenged!", $"{opponent.GamerTag} has challenged you to a match of Sticks & Stones, do you accept?", "Yes", "No");
await gameService.SendChallengeResponse(challengeId, answer ? Models.ChallengeResponse.Accepted : Models.ChallengeResponse.Declined);
});
}
private void OnMatchStarted(Match match)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
IsActive = false;
await Shell.Current.GoToAsync($"///Match", new Dictionary<string, object>() { { Constants.ArgumentNames.Match, match } });
});
}
private void OnServiceError(AsyncError error)
{
MainThread.BeginInvokeOnMainThread(async () =>
{
IsActive = false;
await Shell.Current.CurrentPage.DisplayAlert("There is a problem...",error.Message, "Ok");
});
}
在 OnChallengeReceived 中,用户被提示接受或拒绝挑战。然后,他们的响应通过 GameService 类的 SendChallengeResponse 方法发送给挑战者。OnMatchStarted 将用户导航到 Match 视图。最后,OnServiceError 将错误显示给用户。
在 OnActivated 方法的顶部添加以下代码片段以注册接收消息:
Messenger.Register<ChallengeRecieved>(this, (r, m) => OnChallengeReceived(m.Id, m.Value));
Messenger.Register<MatchStarted>(this, (r, m) => OnMatchStarted(m.Value));
Messenger.Register<ServiceError>(this, (r, m) => OnServiceError(m.Value));
在 OnDeactivated 方法的末尾添加以下代码片段以停止接收消息:
Messenger.Unregister<ChallengeRecieved>(this);
Messenger.Unregister<MatchStarted>(this);
Messenger.Unregister<ServiceError>(this);
当用户在 UI 中下拉列表时刷新 Players 列表,请向 LobbyViewModel 类添加以下方法:
protected override async Task RefreshInternal()
{
await gameService.RefreshPlayerList();
return;
}
LobbyViewModel 需要使用依赖注入进行注册,因此打开 MauiProgram.cs 文件并添加以下高亮显示的代码行:
builder.Services.AddTransient<ViewModels.ConnectViewModel>();
builder.Services.AddTransient<ViewModels.LobbyViewModel>();
builder.Services.AddTransient<Views.ConnectView>();
LobbyViewModel 现在已经完成,现在是时候创建视图了!
添加大厅视图
The Lobby 视图简单地显示连接玩家的列表,包括他们的头像、游戏标签和当前状态。要构建 LobbyView,请按照以下步骤操作:
右键单击 SticksAndStone.App 项目的 Views 文件夹,选择 添加 ,然后点击 新建项... 。
如果您正在使用 Visual Studio 17.7 或更高版本,请点击弹出对话框中的 显示所有模板 按钮;否则,转到下一步。
在左侧的 C# 项 节点下,选择 .****NET MAUI 。
选择 LobbyView.xaml。
点击 添加 创建页面。
参考以下截图查看前面的信息:
图 10.11 – 添加新的 .NET MAUI 内容页 (XAML)
打开 LobbyView.xaml.cs 文件并添加以下 using 声明:
using SticksAndStones.ViewModels;
对构造函数进行以下高亮显示的更改:
public LobbyView(LobbyViewModel viewModel)
{
this.BindingContext = viewModel;
InitializeComponent();
}
这些更改允许依赖注入提供 LobbyViewModel 实例给视图,然后将其分配给 BindingContext。
打开 AppShell.xaml 文件并将以下代码片段添加到 ContentPage 元素中:
<ShellItem Route="Lobby">
<ShellContent ContentTemplate="{DataTemplate views:LobbyView}" />
</ShellItem>
这将注册 "Lobby" 路由并将其指向 LobbyView。
打开 MauiProgram.cs 文件并添加以下高亮显示的代码行:
builder.Services.AddTransient<Views.ConnectView>();
builder.Services.AddTransient<Views.LobbyView>();
return builder.Build();
这将使用依赖注入注册 LobbyView,以便 DataTemplate 可以定位它。
打开 LobbyView.xaml 文件并将 ContentPage 元素的 Title 属性更改为 "Lobby"。
将以下高亮显示的命名空间添加到 LobbyView 元素中;它们将为我们提供访问 ViewModels、Controls 和 Toolkit 命名空间中的类:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.LobbyView”>
为了让 IntelliSense 对我们将要添加的绑定感到满意,通过在 LobbyView 元素中添加 x:DataType 属性来定义视图所使用的视图模型,如下所示:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:DataType=“viewModels:LobbyViewModel”
x:Class=“SticksAndStones.Views.LobbyView”>
我们不希望用户使用任何导航,例如此页面上提供的 Shell 提供的 Back 按钮,因此使用以下列表中的高亮代码禁用它:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.LobbyView”
x:DataType=“viewModels:LobbyViewModel”
BackgroundColor value of the entire view to White, which will make the images blend better, by adding the following highlighted code:
<ContentPage xmlns=“http://schemas.microsoft.com/dotnet/2021/maui”
xmlns:x=“http://schemas.microsoft.com/winfx/2009/xaml”
xmlns:viewModels=“clr-namespace:SticksAndStones.ViewModels”
xmlns:controls=“clr-namespace:SticksAndStones.Controls”
xmlns:toolkit=“ http://schemas.microsoft.com/dotnet/2022/maui/toolkit”
x:Class=“SticksAndStones.Views.ConnectView”
x:DataType=“viewModels:ConnectViewModel”
Title=“ConnectView”
NavigationPage.HasNavigationBar=“False”
包含以下代码片段的内容页:
<RefreshView IsRefreshing=“{Binding IsRefreshing}” Command=“{Binding RefreshCommand}”>
<ScrollView Padding=“5”>
<CollectionView ItemsSource=“{Binding Players}” Margin=”5,5,5,0 SelectionMode=“None”>
</CollectionView>
</ScrollView>
</RefreshView>
对于 LobbyView,有一个垂直滚动的玩家列表。根元素是 RefreshView。它的 IsRefreshing 属性绑定到 LobbyViewModel 的 IsRefreshing 属性。RefreshView 的 Command 属性绑定到 RefreshCommand,这将最终执行 LobbyViewModel 的 RefreshInternal 方法。IsRefreshing 和 RefreshCommand 在 BaseViewModel 类中实现。在 RefreshView 内部是 ScrollView,它提供了滚动能力以显示长列表。在 ScrollView 内部是 CollectionView,它将显示每个 Player 实例作为一个单独的项目,因此 ItemsSource 绑定到 LobbyViewModel 的 Players 属性。由于没有真正需要选择单个 Player 项目,SelectionMode 设置为 none。
当列表为空时,向用户显示一些内容是很好的,这样他们就不会感到困惑。CollectionView 有一个 EmptyView 属性,用于配置在没有任何项目时显示的内容。在 ContentPage 开始打开标签后立即添加以下代码片段:
<ContentPage.Resources>
<ContentView x:Key="BasicEmptyView">
<StackLayout>
<Label Text="No players available"
Margin="10,25,10,10"
FontAttributes="Bold"
FontSize="18"
HorizontalOptions="Fill"
HorizontalTextAlignment="Center" />
</StackLayout>
</ContentView>
</ContentPage.Resources>
这定义了一个包含 ContentView 的页面资源,其 Key 值为 "BasicEmptyView"。视图包含 StackLayout,其中有一个 Label 子元素,其文本为 "No players available"。应用适当的样式以确保它足够大,并且有足够的周围空白。
向 CollectionView 元素添加以下属性:
EmptyView="{StaticResource BasicEmptyView}"
这将 BasicEmptyView 绑定到 CollectionView 的 EmptyView 属性。图 10**.12 展示了运行应用并登录后的结果:
图 10.12 – 没有玩家的大厅
玩家卡片也将使用静态资源,这仅仅使文件更容易阅读,并且减少了缩进。在 ContentView.Resources 元素下,在 BasicEmptyView 元素下添加以下代码片段:
<DataTemplate x:Key="PlayerCardViewTemplate" x:DataType="viewModels:PlayerViewModel">
<ContentView>
<Border StrokeShape="RoundRectangle 10,10,10,10" BackgroundColor="AntiqueWhite" Padding="3,3,3,3" Margin="5,5,5,5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="4*" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<toolkit:AvatarView Grid.Column="0" Margin="0" BackgroundColor="LightGrey" HeightRequest="48" WidthRequest="48" CornerRadius="25" VerticalOptions="Center" HorizontalOptions="Center">
<toolkit:AvatarView.ImageSource>
<toolkit:GravatarImageSource
Email="{Binding EmailAddress}"
Image="MysteryPerson" />
</toolkit:AvatarView.ImageSource>
</toolkit:AvatarView>
<VerticalStackLayout Grid.Column="1" Margin="10,0,0,0">
<Label Text="{Binding GamerTag}" HorizontalTextAlignment="Start" FontSize="Large" BackgroundColor="AntiqueWhite" />
<Label Text="{Binding Status}" HorizontalTextAlignment="Start" FontSize="Caption" BackgroundColor="AntiqueWhite"/>
</VerticalStackLayout>
<controls:ActivityButton Grid.Column="2" IsRunning="{Binding IsChallenging}" Text="{Binding ChallengeStatus}" BackgroundColor="#e8bc65" Command="{Binding ChallengeCommand}" CommandParameter="{Binding .}" IsVisible="{Binding CanChallenge}" Margin="5"/>
</Grid>
</Border>
</ContentView>
</DataTemplate>
DataTemplate 将显示玩家的头像。为此,它将使用 Image 控件和 GravatarImageSource,就像在 创建连接视图 部分中所做的那样。需要一个 DataTemplate 元素,因为这是用于 ItemTemplate 的,然后是必选的 ContentView。然后定义 Border。它使用特殊的 Stroke 形状来使矩形的边缘圆润,而不是有直角,并将 AntiqueWhite 作为 BackgroundColor 值应用。可用于 Border 的其他形状包括 Ellipse、Line、Path、Polygon、Polyline 和 Rectangle。有关更多详细信息,请参阅 learn.microsoft.com/en-us/dotnet/maui/user-interface/controls/border 。在 Border 内部有一个 Grid 控件,它定义了三列。第一列包含头像图像,宽度为 50,下一列包含玩家的游戏标签和状态,垂直堆叠,第三列包含 Challenge 按钮。
对于头像,使用来自 CommunityToolkit 的 AvatarView 控件。它提供图像的圆形版本。
Challenge 按钮使用 ActivityButton 控件,并将其绑定到 PlayerViewModel 的 IsChallenging、CanChallenge、ChallengeStatus 和 ChallengeCommand 属性。
要将 PlayerCardViewTemplate 作为 CollectionView 的 ItemTemplate,请向 CollectionView 元素添加以下属性:
ItemTemplate="{StaticResource PlayerCardViewTemplate}"
这样就完成了 Lobby 页面。到这一点,你应该能够启动 SticksAndStone.Functions 并连接到 SticksAndStones.App 项目,以查看 Lobby 视图提供的不同布局。还需要创建一个页面来完成游戏,那就是 Match 页面。
创建比赛页面
Match 页面显示带有玩家和分数的游戏板。它还管理游戏玩法,允许每个玩家轮流放置棍子。每个玩家轮流时,板会更新以显示比赛的当前状态。让我们开始创建 ViewModel 类。
创建 ViewModel 类
在 Match 页面中使用了两个不同的 ViewModel 类,就像在 Lobby 页面中一样,一个用于游戏,另一个用于玩家详细信息。
添加 MatchPlayerViewModel
MatchPlayerViewModel 是 Player 模型和 MatchView 之间的抽象。MatchPlayerViewModel 需要向 MatchView 暴露 Player 模型中的 Id、GamerTag 和 EmailAddress 值。此外,由于每个玩家都有一个分数,因此 Match 模型中玩家的分数也暴露给 MatchView。还需要一些额外的属性:
要创建 MatchPlayerViewModel,请按照以下步骤操作:
在 SticksAndStones.App 项目的 ViewModels 文件夹中创建一个名为 MatchPlayerViewModel 的新类。
将 using 声明修改为文件顶部的以下内容:
using CommunityToolkit.Mvvm.ComponentModel;
using SticksAndStones.Models;
将 public 和 partial 修饰符添加到类中,并使其继承自 ObservableObject,如下所示:
public partial class MatchPlayerViewModel: ObservableObject
{
}
MatchPlayerViewModel 是 Player 和 Match 模型的抽象,将通过构造函数传入。创建字段和构造函数,如下所示列表所示:
private readonly Player playerModel;
private readonly Match matchModel;
public MatchPlayerViewModel(Player player, Match match)
{
this.playerModel = player;
this.matchModel = match;
}
如果 Player 模型在 Match 模型中是 PlayerOne,则 PlayerToken 属性为 1;否则,为 -1。使用以下方式添加 PlayerToken 属性:
public int PlayerToken => playerModel.Id == matchModel.PlayerOneId ? 1 : -1;
如果 Player 模型是 Match 模型的 NextPlayer,则 IsPlayersTurn 属性将返回 true,如下所示:
public bool IsPlayersTurn => playerModel.Id == matchModel.NextPlayerId;
Id、GamerTag 和 EmailAddress 属性都直接映射到 Player 模型中的相应属性。这与在 PlayerViewModel 中用于 Lobby 页面的相同实现。使用以下列表将属性添加到 MatchPlayerViewModel 中:
public Guid Id => playerModel.Id;
public string GamerTag => playerModel.GamerTag;
public string EmailAddress => playerModel.EmailAddress;
MatchPlayerViewModel 需要的最后一个属性是 Score 属性。Score 属性映射到 Match 模型中的 PlayerOneScore 或 PlayerTwoScore 属性,具体取决于 Player 模型是哪个玩家。使用以下列表将 Score 属性添加到 MatchPlayerViewModel 中:
public int Score => playerModel.Id == matchModel.PlayerOneId ? matchModel.PlayerOneScore : matchModel.PlayerTwoScore;
这就是 MatchPlayerViewModel 的全部内容。下一节将指导您创建 MatchViewModel。
添加 MatchViewModel
MatchViewModel 需要提供所有游戏功能。它提供两个用于在页面标题中显示的 MatchPlayerViewModel 对象,以及显示已放置木棍和已捕获石头的棋盘。它还提供了玩家进行回合和选择弃权的所需功能。要实现 MatchViewModel,请按照以下步骤操作:
在 SticksAndStones.App 项目的 ViewModels 文件夹中创建一个名为 MatchViewModel 的新类。
修改页面顶部的 using 声明部分,以匹配以下列表:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using SticksAndStones.Models;
using SticksAndStones.Services;
将 public 和 partial 类修饰符添加到类中,继承自 ViewModelBase 并实现 IQueryAttributable,如下所示:
public partial class MatchViewModel : ViewModelBase, IQueryAttributable
回想一下,在 ConnectViewModel 和 LobbyViewModel 中,当它们导航到 Match 时,会传递一个参数——要么是 MatchId,要么是 Match 实例本身。IQueryAttributable 是如何将这个参数传递给 MatchViewModel 的。IQueryAttributable 的实现将在后续步骤中提供。
MatchViewModel 只有一个依赖项,即 GameService,因此添加一个字段来存储实例,并添加一个接受实例作为参数的构造函数,如下所示:
private readonly GameService gameService;
public MatchViewModel(GameService gameService)
{
this.gameService = gameService;
}
当 MatchViewModel 被加载时,它需要处理参数,无论是 Match 实例还是 MatchId 值。无论哪种参数,最终都会得到一个用于在视图中显示棋盘的 Match 实例,并基于此创建两个 MatchPlayerViewModel 实例,分别用于玩家一和玩家二。将 match、playerOne 和 playerTwo 字段添加到 MatchViewModel 类中,以保存这些实例,如下所示:
[ObservableProperty]
private Match match;
[ObservableProperty]
private MatchPlayerViewModel playerOne;
[ObservableProperty]
private MatchPlayerViewModel playerTwo;
IQueryAttributable 用于处理传递给视图模型的参数。嗯,这是其中一种方法。IQueryAttributable 接口定义了一个方法,即 ApplyQueryAttributes。.NET MAUI 路由系统将自动调用 ApplyQueryAttributes 方法,如果视图模型实现了 IQueryAttributable 接口。要添加 IQueryAttributable 的实现,请使用以下代码列表:
public async Task ApplyQueryAttributes(IDictionary<string, object> query)
{
Match match = null;
if (query.ContainsKey(Constants.ArgumentNames.Match))
{
match = query[Constants.ArgumentNames.Match] as Match;
}
if (query.ContainsKey(Constants.ArgumentNames.MatchId))
{
var matchId = new Guid($"{query[Constants.ArgumentNames.MatchId]}");
if (matchId != Guid.Empty)
{
match = await gameService.GetMatchById(matchId);
}
}
LoadMatch(match);
});
}
private void LoadMatch(Match match)
{
if (match is null) return;
PlayerOne = new MatchPlayerViewModel(gameService.GetPlayerById(match.PlayerOneId), match);
PlayerTwo = new MatchPlayerViewModel(gameService.GetPlayerById(match.PlayerTwoId), match);
this.Match = match;
}
ApplyQueryAttributes 有一个名为 query 的单个参数,它是一个键值对字典,键是一个字符串,值是一个对象。键 ID 是参数的名称,如 "Match" 或 "MatchId"。该方法将检查 "Match" 键是否存在,如果存在,则将其值作为 Match 获取。如果存在 "MatchId" 键,则使用 GameService 从 Id 获取 Match 模型。如果没有 match 的值,则该方法返回;否则,初始化两个 GamePlayerViewModel 实例并将它们和 Match 存储在 ViewModel 属性中。LoadMatch 方法是从 ApplyQueryAttributes 调用的,因为我们将在接收到 UpdateMatch 事件时需要相同的功能。
在允许玩家选择放置棍子的位置之前,必须是他们的回合。使用以下代码创建一个名为 IsCurrentPlayersTurn 的属性:
public bool IsCurrentPlayersTurn => gameService.CurrentPlayer.Id == (Match?.NextPlayerId ?? Guid.Empty);
任何时间 Match 对象被更新时,IsCurrentPlayersTurn 也需要更新,因为它依赖于 Match 属性中的值。为了自动执行此操作,使用来自 CommunityToolkit 的 NotifyPropertyChangedFor 属性。在以下代码列表中添加高亮行:
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCurrentPlayersTurn))]
private Match match;
现在,每当 Match 属性发生变化时,NotfiyPropertyChanged 方法也会被调用以更新 IsCurrentPlayersTurn。参见 第二章 中的 定义 ViewModel 基类 部分,以复习实现 INotifyPropertyChanged 接口。
游戏允许玩家在提交之前尝试不同的棒的位置。如果这是当前玩家的回合,即连接并使用应用的玩家,那么 SelectStick 方法将在用户选择的位置放置一根棒。选择不会发送到服务器,直到用户点击 lastSelectedStick 字段。添加以下代码以实现 SelectStick 方法:
int lastSelectedStick = -1;
[RelayCommand(CanExecute = nameof(IsCurrentPlayersTurn))]
private void SelectStick(string arg)
{
if (gameService.CurrentPlayer is null) return;
if (Match is null) return;
if (int.TryParse(arg, out var pos))
{
pos--; // adjust for 0 based indexes
if (lastSelectedStick != -1 && lastSelectedStick != pos)
Match.Sticks[lastSelectedStick] = 0;
if (Match.Sticks[pos] != 0)
return;
Match.Sticks[pos] = gameService.CurrentPlayer.Id == PlayerOne.Id ? PlayerOne.PlayerToken : PlayerTwo.PlayerToken;
lastSelectedStick = pos;
OnPropertyChanged(nameof(Match));
}
}
lastSelectedStick 的 -1 值用于表示没有棒。SelectStick 方法通过 RelayCommandAttribute 暴露为一个 Command 实例。使用 Is CurrentPlayersTurn 属性来确定命令是否可以执行。回想一下 第九章 ,Sticks 元素将具有三个值之一:玩家一的 -1,空位的 0,以及玩家二的 1。在确定棒的位置是否有效后,该方法会引发 Match 属性的 OnPropertyChanged 事件,这会导致绑定更新。
在考虑将下一根棒放在哪个位置后,玩家有三个选择:将他们的移动发送到服务器并结束他们的回合,犹豫不决并撤销他们的移动,或者放弃并退出比赛。使用以下代码片段将 Play 方法添加到 MatchViewModel:
[RelayCommand]
private async Task Play()
{
if (lastSelectedStick == -1)
{
await Shell.Current.CurrentPage.DisplayAlert("Make a move", "You must make a move before you play.", "Ok");
return;
}
if (await Shell.Current.CurrentPage.DisplayAlert("Make a move", "Are you sure this is the move you want, this can't be undone.", "Yes", "No"))
{
var (newMatch, error) = await gameService.EndTurn(Match.Id, lastSelectedStick);
if (error is not null)
{
await Shell.Current.CurrentPage.DisplayAlert("Error in move", error, "Ok");
return;
}
lastSelectedStick = -1;
}
}
Play 方法被暴露为一个 Command,以便它可以被 UI 元素绑定。
当玩家轻触 lastSelectedStick 位置和 lastSelectedStick 的值时,会调用 Undo 方法。添加 Undo 方法,如下代码所示:
[RelayCommand]
private async Task Undo()
{
if (lastSelectedStick != -1)
{
if (await Shell.Current.CurrentPage.DisplayAlert("Undo your move", "Are you sure you don't want to play this move?", "Yes", "No"))
{
OnPropertyChanging(nameof(Match));
Match.Sticks[lastSelectedStick] = 0;
OnPropertyChanged(nameof(Match));
lastSelectedStick = -1;
return;
}
}
}
再次,将 RelayCommand 属性应用于方法,以便它可以被 UI 元素绑定。
当玩家使用 Forfeit 方法调用 MatchViewModel 类时,会调用 Forfeit 方法:
[RelayCommand]
private async Task Forfeit()
{
var returnToLobby = true;
if (!Match.Completed)
{
returnToLobby = await Shell.Current.CurrentPage.DisplayAlert("W A I T", "Returning to the Lobby will forfeit your match, are you sure you want to do that?", "Yes", "No"))
if (returnToLobby)
{
await Shell.Current.GoToAsync("///Lobby");
}
}
当对手玩家将他们的移动发送到服务器时,它作为来自 SignalR 服务的 MatchUpdated 事件在应用中接收。使用以下代码添加 MatchUpdated 事件的处理器:
void OnMatchUpdated(object r, Messages.MatchUpdated m)
{
LoadMatch(m.Value);
if (Match.WinnerId != Guid.Empty && Match.Completed == true)
{
MainThread.InvokeOnMainThreadAsync(async () =>
{
if (Match.WinnerId == gameService.CurrentPlayer.Id)
{
await Shell.Current.CurrentPage.DisplayAlert("Congratulations!", $"You are victorious!\nPress the back button to return to the lobby.", "Ok");
}
else
{
await Shell.Current.CurrentPage.DisplayAlert("Bummer!", $"You were defeated, better luck next time!\nPress the back button to return to the lobby.", "Ok");
}
});
return;
}
}
要注册 MatchUpdated 事件处理器,从 OnActivated 中调用 Register 方法,从 OnDeactivated 中调用 UnRegister,如下所示:
protected override void OnActivated()
{
Messenger.Register(this, (MessageHandler<object, Messages.MatchUpdated>)OnMatchUpdated);
}
protected override void OnDeactivated()
{
Messenger.Unregister<Messages.MatchUpdated>(this);
}
通过在 MauiProgram.cs 文件中的 CreateMauiApp 方法中添加以下高亮代码行,使用依赖注入注册 MatchViewModel:
builder.Services.AddTransient<ViewModels.ConnectViewModel>();
builder.Services.AddTransient<ViewModels.LobbyViewModel>();
builder.Services.AddTransient<ViewModels.MatchViewModel>();
builder.Services.AddTransient<Views.ConnectView>();
builder.Services.AddTransient<Views.LobbyView>();
为什么叫 IQueryAttributable?这感觉有点尴尬
接口名称背后的原因是命名事物很难。传递参数到视图模型的系统可以是声明性的或不声明性的。声明性方式使用 QueryPropertyAttribute 将查询参数映射到视图模型上的属性。如果你选择不使用属性,而是手动处理映射,你可以声明你的类为 IQueryAttributable,例如,我本可以使用 QueryPropertyAttribute 但我选择不这样做。更多信息,请访问 learn.microsoft.com/en-us/dotnet/maui/fundamentals/shell/navigation#pass-data 。
添加 Match 视图
这个页面很复杂,所以我们将将其分解成更小、更易于管理的块。首先,定义基本页面布局,包括玩家可用的命令:Play、Undo 和 Forfeit。接下来,定义计分板区域,包括玩家的游戏标签、Gravatar 和分数。最后,定义并布局游戏板,形成一个三行三列的网格。让我们开始创建视图和布局。
创建视图
Match 视图与其他创建的视图没有太大区别,除了它比预览视图有更多元素。让我们按照以下步骤开始创建视图和一些基本元素:
右键单击 SticksAndStone.App 项目的 Views 文件夹,选择 Add ,然后点击 New Item... 。
如果你正在使用 Visual Studio 17.7 或更高版本,请点击弹出的对话框中的 Show all Templates 按钮;否则,请跳到下一步。
在左侧的 C# Items 节点下,选择 .NET MAUI 。
选择 MatchView。
点击 Add 以创建页面。
参考以下截图查看上述信息:
图 10.13 – 添加新的 .NET MAUI ContentPage (XAML)
打开 MatchView.xaml.cs 文件,并添加以下 using 声明:
using SticksAndStones.ViewModels;
对构造函数进行以下突出显示的更改:
public MatchView(MatchViewModel viewModel)
{
this.BindingContext = viewModel;
InitializeComponent();
}
这些更改允许依赖注入提供 MatchViewModel 实例给视图,然后将其分配给 BindingContext。
打开 AppShell.xaml 文件,并将以下代码片段添加到 ContentPage 元素中:
<ShellItem Route="Match">
<ShellContent ContentTemplate="{DataTemplate views:MatchView}" />
</ShellItem>
这将注册 "Match" 路由并将其指向 MatchView。
打开 MauiProgram.cs 文件,并添加以下突出显示的代码行:
builder.Services.AddTransient<Views.ConnectView>();
builder.Services.AddTransient<Views.LobbyView>();
builder.Services.AddTransient<Views.MatchView>();
return builder.Build();
这将注册 MatchView 以进行依赖注入,以便 DataTemplate 可以找到它。
打开 MatchView.xaml 文件,并移除 ContentPage 元素的 Title 属性。
将以下突出显示的命名空间添加到 MatchView 元素中。它们将为我们提供访问 ViewModels、Converters 和 Controls 命名空间中的类:
<ContentPage
x:Class="SticksAndStones.Views.GameView">
为了让 IntelliSense 对我们将要添加的绑定感到满意,定义视图所使用的视图模型,通过在 MatchView 元素中添加 x:DataType 属性来实现,如下所示:
<ContentPage
x:DataType="viewModels:GameViewModel"
x:Class="SticksAndStones.Views.GameView">
MatchView 使用了 Font Awesome 字体库中的几个图标,因此我们需要下载并安装这个库,以便在应用中可用。
下载和配置 Font Awesome
Font Awesome 是一个包含在字体中的图像集合。.NET MAUI 对在工具栏、导航栏等地方使用 Font Awesome 提供了出色的支持。虽然制作这个应用不是必需的,但我们认为这额外的往返是值得的,因为你很可能在你的新杀手级应用中需要类似的东西。
下载字体很简单。请注意文件的重命名——这实际上不是必需的,但如果文件名更简单,编辑配置文件等会更容易。按照以下步骤获取并复制字体到每个项目中:
浏览到 fontawesome.com/download 。
点击 Free for Desktop 按钮下载 Font Awesome。
解压下载的文件,然后找到 otfs 文件夹。
将 Font Awesome 5 Free-Solid-900.otf 文件重命名为 FontAwesome.otf(你可以保留原始名称,但如果重命名会少输入一些)。由于 Font Awesome 持续更新,你的文件名可能不同,但应该相似。
将 FontAwesome.otf 复制到 SticksAndStones.App 项目的 Resources/Fonts 文件夹中。
如果只需要将字体文件复制到项目文件夹中就足够了,那就太好了。默认的 .NET MAUI 模板在 News.csproj 文件中包含了所有字体,并在 Resources/Fonts 文件夹中定义了以下项目:
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
这确保了字体文件被处理并自动包含在应用包中。剩下要做的就是将字体注册到 .NET MAUI 运行时中,使其对 XAML 资源可用。为此,将以下高亮行添加到 MauiProgram.cs 文件中:
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
fonts.AddFont("FontAwesome.otf", "FontAwesome");
})
这一行添加了一个别名,我们可以在下一节中使用它来创建静态资源。第一个参数是字体文件的文件名,第二个参数是你可以用于 FontFamily 属性的字体别名。
定义布局
现在 Font Awesome 已安装并配置在 .NET MAUI 中,TitleView 可以使用它。按照以下步骤添加自定义标题区域和主要布局:
首先,覆盖 Shell 元素的 TitleView 并提供一个新容器来存放按钮:
<Shell.TitleView>
<Grid>
<HorizontalStackLayout HorizontalOptions="Start">
</HorizontalStackLayout>
<HorizontalStackLayout HorizontalOptions="End">
</HorizontalStackLayout>
</Grid>
</Shell.TitleView>
按钮分为两个部分,一个对齐到窗口的左侧或起始位置,另一个对齐到右侧或结束位置。
玩家可以在任何时候决定他们不再想继续游戏。要退出比赛,玩家可以使用 TitleView 的 Start 部分,并在 MatchViewModel 中绑定 ForfeitCommand,添加以下片段中的高亮代码:
<HorizontalStackLayout HorizontalOptions="Start">
<ImageButton Command="{Binding ForfeitCommand}" ToolTipProperties.Text="Return to the lobby.">
<ImageButton.Source>
<FontImageSource Glyph="" FontFamily="FontAwesome" Color="White" Size="28" />
</ImageButton.Source>
</ImageButton>
</HorizontalStackLayout>
当轮到玩家时,他们有两个可用的按钮,Play 和 Undo。Play 和 Undo 按钮放置在 .NET MAUI 页面的 TitleView 区域。将以下高亮代码添加到 TitleView 以添加 Play 和 Undo 按钮:
<HorizontalStackLayout HorizontalOptions="End">
<ImageButton Command="{Binding UndoCommand}" IsVisible="{Binding IsCurrentPlayersTurn}" ToolTipProperties.Text="Undo the last stick placement.">
<ImageButton.Source>
<FontImageSource Glyph="" FontFamily="FontAwesome" Color="White" Size="28" />
</ImageButton.Source>
</ImageButton>
<ImageButton Command="{Binding PlayCommand}" IsVisible="{Binding IsCurrentPlayersTurn}" ToolTipProperties.Text="Send the stick placement, and end my turn.">
<ImageButton.Source>
<FontImageSource Glyph="" FontFamily="FontAwesome" Color="White" Size="28" />
</ImageButton.Source>
</ImageButton>
</HorizontalStackLayout>
从 ContentView 中删除默认的 VerticalStackLayout 元素,并添加以下代码:
<ContentView>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="4*" />
<RowDefinition Height="2*" />
<RowDefinition Height="6*" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
</Grid>
</ContentView>
这添加了一个具有四行的 Grid 控件。第一行和第三行将包含计分板和游戏板,而第二行和第四行是填充。
主布局已准备就绪。接下来,将计分板添加到布局的第一行。
创建计分板
计分板包含每个玩家的头像、游戏标签和分数。这些元素绑定到玩家的 MatchPlayerViewModel 对应字段。Match 对象有两个属性,PlayerOne 和 PlayerTwo,每个属性都是一个 MatchPlayerViewModel。要添加计分板,请按照以下步骤操作:
每个玩家都有一个不同的颜色来识别。要将每种颜色添加为资源,打开 SticksAndStones.App 项目的 Resources/Styles 文件夹中的 Colors.xaml 文件,并将以下行添加到 ResourceDictionary 元素中:
<Color x:Key="PlayerOne">#6495ED</Color>
<Color x:Key="PlayerTwo">#CD5C5C</Color>
计分板使用 HorizontalStackLayout 作为外部容器。将以下代码添加到 Grid 元素中:
<HorizontalStackLayout Grid.Row="0" HorizontalOptions="CenterAndExpand" Margin="10" BindableLayout.ItemsSource="{Binding Players}">
</HorizontalStackLayout>
HorizontalStackLayout 被分配到 Grid 的行 0,其内容通过 BindableLayout.ItemsSource 绑定到视图模型的 Players 属性。BindableLayout 是支持所有布局控件(如 AbsoluteLayout 和 FlexLayout)的底层接口。
每个玩家在 HorizontalStackLayout 中都有自己的卡片。由于控件绑定到 Players 属性,该属性是一个 MatchPlayerViewModels 数组,因此 BindableLayout.ItemTemplate 属性提供了在 Players 中显示每个项目的视图。卡片使用 Border 元素和嵌套的 VerticalStackLayout 元素进行布局。将以下高亮代码添加到 HorizontalStackLayout:
<HorizontalStackLayout Grid.Row="0" HorizontalOptions="CenterAndExpand" Margin="10">
<BindableLayout.ItemTemplate>
<DataTemplate>
<Border x:DataType="viewModels:MatchPlayerViewModel" Padding="0" Margin="2" StrokeShape="RoundRectangle 10,10,10,10" HeightRequest="175">
<VerticalStackLayout Padding="2" HorizontalOptions="Center">
</VerticalStackLayout>
</Border>
</DataTemplate>
</BindableLayout.ItemTemplate>
</HorizontalStackLayout>
Border 元素是玩家卡片的最高级容器。要根据 PlayerToken 设置 Border 元素的边框颜色和背景颜色,使用触发器(learn.microsoft.com/en-us/dotnet/maui/fundamentals/triggers )——具体来说,使用 DataTrigger 来根据其他值设置属性值。将以下代码添加到 Border 元素:
<Border.Triggers>
<DataTrigger TargetType="Border" Binding="{Binding PlayerToken}" Value="1" >
<Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
<Setter Property="BackgroundColor" Value="{StaticResource PlayerOne}" />
</DataTrigger>
<DataTrigger TargetType="Border" Binding="{Binding PlayerToken}" Value="-1" >
<Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
<Setter Property="BackgroundColor" Value="{StaticResource PlayerTwo}" />
</DataTrigger>
</Border.Triggers>
DataTrigger 绑定属性与 Value 属性进行比较。如果它们相等,则执行 DataTrigger 的 Setter 元素。在这种情况下,如果 PlayerToken 属性为 -1,则将 Border 的 Stroke 和 BackgroundColor 属性设置为在 步骤 1 中定义的 PlayerOne 颜色。否则,如果 PlayerToken 属性等于 -1,则将 Stroke 和 BackgroundColor 属性设置为 PlayerTwo 颜色。
VerticalStackLayout 包含另一个 VerticalStackLayout 和 Border 元素,如下所示突出显示的代码:
<VerticalStackLayout BackgroundColor="{Binding PlayerToken, Converter={StaticResource PlayerToColor}}" Padding="2" HorizontalOptions="Center">
<VerticalStackLayout>
</VerticalStackLayout>
<Border Padding="0" WidthRequest="96" StrokeShape="RoundRectangle 10,10,10,10" StrokeThickness="0">
<Image IsVisible="{Binding IsPlayersTurn}" Source="hstick.jpeg" Aspect="AspectFit" MaximumHeightRequest="36"/>
</Border>
VerticalStackLayout will be used to hold GamerTag, AvatarImage, and the player’s score, which is added in the next step. Border contains a horizontal stick image whose IsVisible attribute is bound to the IsPlayersTurn property. The stick is used as a visual indicator of which player’s turn it is. If it is not the player’s turn, the image is not displayed.
在第二个 VerticalStackLayout 中有一个 Label 和一个 FlexLayout。添加以下突出显示的代码:
<VerticalStackLayout>
<Label Text="{Binding GamerTag}" HorizontalOptions="FillAndExpand" HorizontalTextAlignment="Center" FontSize="18" FontFamily="OpenSansSemibold"/>
<FlexLayout Margin="3">
</FlexLayout>
FlexLayout contains the visual elements to display AvatarImage and Score. Add the following highlighted code to FlexLayout:
<toolkit:AvatarView FlexLayout.Order="0" Margin="0" BackgroundColor="LightGrey" HeightRequest="85" WidthRequest="85" CornerRadius="50" VerticalOptions="Center" HorizontalOptions="Center">
toolkit:AvatarView.ImageSource
<toolkit:GravatarImageSource
Email="{Binding EmailAddress}"
Image="MysteryPerson" />
</toolkit:AvatarView.ImageSource>
toolkit:AvatarView.Triggers
</toolkit:AvatarView.Triggers>
</toolkit:AvatarView>
<Label.Triggers>
</Label.Triggers>
FlexLayout 控件,FlexLayout 子项显示的顺序由 FlexLayout.Order 属性控制。类似于 Grid 控件,其子项的 Grid.Row 和 Grid.Column 属性,Order 属性设置在子项上。FlexLayout 中子项的顺序通过使用触发器来改变。在 AvatarView 上,如果 PlayerToken 属性等于 -1,即 PlayerTwo,DataTrigger 将 FlexLayout.Order 属性设置为 "1"。在 Label 上,DataTrigger 将 FlexLayout.Order 属性设置为 "0",从而有效地交换了两个元素。
这样就完成了记分板。MatchView 的最后一部分是最大的:棋盘。继续阅读以了解如何创建棋盘视觉效果。
创建游戏棋盘
游戏棋盘由三个不同的元素组成。这些元素是每个方格角落的点、横竖棒(水平和垂直)和石头。这些元素按照以下所示布局:
图 10.14 – 游戏棋盘
棋盘使用 Grid 控件来提供基本布局。使用 7 列和 7 行将为每个元素提供单元格:16 个点、9 个石头和 24 根棒。将以下代码添加到顶级 Grid 元素以提供游戏棋盘的基本布局:
<Grid Grid.Row="2" BackgroundColor="White" Margin="10,40,10,0" MaximumHeightRequest="410" MaximumWidthRequest="400" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="4*" />
<RowDefinition Height="1*" />
<RowDefinition Height="4*" />
<RowDefinition Height="1*" />
<RowDefinition Height="4*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
</Grid>
让我们从添加网格的角落开始,因为它们是最简单的。要定义一个角落,使用带有文本 "⚫" 的 Label,这是点的十六进制字符代码。为了水平垂直居中点,将 HorizontalOptions 和 VerticalOptions 设置为 "Center"。您的基本元素看起来如下所示:
<Label Text="⚫" HorizontalOptions="Center" VerticalOptions="Center" />
没有使用Grid.Row和Grid.Column属性,Label将被放置在行0和列0。网格中有 16 个角落,它们占据了所有偶数单元格,所以(0,0)、(0,2)、(0,4)、(0,6)、(2,0)、(2,2)等等。第一行的完全定义的标签如下所示:
<Label Grid.Row="0" Grid.Column="0" Text="⚫" HorizontalOptions="Center" VerticalOptions="Center" />
<Label Grid.Row="0" Grid.Column="2" Text="⚫" HorizontalOptions="Center" VerticalOptions="Center" />
<Label Grid.Row="0" Grid.Column="4" Text="⚫" HorizontalOptions="Center" VerticalOptions="Center" />
<Label Grid.Row="0" Grid.Column="6" Text="⚫" HorizontalOptions="Center" VerticalOptions="Center" />
当你为所有 16 行都这样做时,就会有很多Text、HorizontalOptions和VerticalOptions属性的重复。通过使用Style元素,可以消除这种重复。Style元素包含Setter元素,如DataTrigger元素。当Style应用于元素时,Setter元素用于更新目标元素的属性。使用以下步骤通过Style将角落元素添加到Grid控件中:
将以下Style元素添加到ContentPage.Resources元素中:
<Style x:Key="dotLabel"
TargetType="Label">
<Setter Property="Text" Value="⚫" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="VerticalOptions" Value="Center" />
</Style>
这个Style元素通过x:Key属性进行标识。
在本节开头创建的Grid控件中添加一个Label。
将Label的Grid.Row属性设置为0。
将Label的Grid.Column属性设置为0。
将Style属性设置为{StaticResource dotLabel}值。Style属性用于指定应用于元素的样式。由于Style在ContentView.Resources元素中定义,它是一个StaticResource。
完成的Label应如下所示:
<Label Grid.Row="0" Grid.Column="0" Style="{StaticResource dotLabel}" />
现在,复制刚刚创建的Label,并将Grid.Column值增加两个,重复此步骤,直到你有四个具有相同Grid.Row值的Label元素。
复制在第 7 步中创建的最后一个Label,并将Grid.Row值增加两个,并将Grid.Column的值重置为0。现在,使用更新的Grid.Row值重复第 7 步,直到有四个具有Grid.Row值为6的标签。
标签应如下所示:
<Label Grid.Row="0" Grid.Column="0" Style="{StaticResource dotLabel}" />
<Label Grid.Row="0" Grid.Column="2" Style="{StaticResource dotLabel}" />
<Label Grid.Row="0" Grid.Column="4" Style="{StaticResource dotLabel}" />
<Label Grid.Row="0" Grid.Column="6" Style="{StaticResource dotLabel}" />
<Label Grid.Row="2" Grid.Column="0" Style="{StaticResource dotLabel}" />
<Label Grid.Row="2" Grid.Column="2" Style="{StaticResource dotLabel}" />
<Label Grid.Row="2" Grid.Column="4" Style="{StaticResource dotLabel}" />
<Label Grid.Row="2" Grid.Column="6" Style="{StaticResource dotLabel}" />
<Label Grid.Row="4" Grid.Column="0" Style="{StaticResource dotLabel}" />
<Label Grid.Row="4" Grid.Column="2" Style="{StaticResource dotLabel}" />
<Label Grid.Row="4" Grid.Column="4" Style="{StaticResource dotLabel}" />
<Label Grid.Row="4" Grid.Column="6" Style="{StaticResource dotLabel}" />
<Label Grid.Row="6" Grid.Column="0" Style="{StaticResource dotLabel}" />
<Label Grid.Row="6" Grid.Column="2" Style="{StaticResource dotLabel}" />
<Label Grid.Row="6" Grid.Column="4" Style="{StaticResource dotLabel}" />
<Label Grid.Row="6" Grid.Column="6" Style="{StaticResource dotLabel}" />
现在角落处理完毕,我们可以开始制作游戏棋子:木棍和石头。由于木棍和石头有一些相似之处,我们可以创建一个通用的控件来显示它们。然而,它们的可视化方式完全不同。所需的是一个通用的接口来定义BindableProperty属性,并在不同的布局中使用它。.NET MAUI使用ControlTemplate资源来允许对构成控件的视觉元素进行自定义,甚至完全替换。在.NET MAUI 中,许多控件可以通过ControlTemplate进行自定义,如果它们从ContentView或ContentPage派生。让我们从添加自定义控件开始,然后按照以下步骤添加木棍和石头的ControlTemplate资源:
在SticksAndStones.App项目的Controls文件夹中创建一个新的类,命名为GamePieceView。
更新类定义以匹配以下列表:
namespace SticksAndStones.Controls;
public partial class GamePieceView : ContentView
{
}
添加一个名为 GamePiecePosition 的 string 属性和一个名为 GamePiecePositionProperty 的 BindableProperty,如下所示:
public static readonly BindableProperty GamePiecePositionProperty = BindableProperty.Create(nameof(GamePiecePosition), typeof(string), typeof(GamePieceView), string.Empty);
public string GamePiecePosition
{
get => (string)GetValue(GamePiecePositionProperty);
set => SetValue(GamePiecePositionProperty, value);
}
GamePiecePosition 用于确定 GameViewModel 上的 Sticks 或 Stones 属性中的数组索引。
添加一个名为 GamePieceState 的 int 属性和一个名为 GamePieceStateProperty 的 BindableProperty,如下所示:
public static readonly BindableProperty GamePieceStateProperty = BindableProperty.Create(nameof(GamePieceState), typeof(int), typeof(GamePieceView), 0, BindingMode.TwoWay);
public int GamePieceState
{
get => (int)GetValue(GamePieceStateProperty);
set => SetValue(GamePieceStateProperty, value);
}
GamePieceState 是棋子的所有者:1 表示 PlayerOne,0 表示无人,-1 表示 PlayerTwo。
添加一个名为 GamePieceDirection 的 string 属性和一个名为 GamePieceDirectionProperty 的 BindableProperty,如下所示:
public static readonly BindableProperty GamePieceDirectionProperty = BindableProperty.Create(nameof(GamePieceDirection), typeof(string), typeof(GamePieceView), null);
public string GamePieceDirection
{
get => (string)GetValue(GamePieceDirectionProperty);
set => SetValue(GamePieceDirectionProperty, value);
}
GamePieceDirection 仅适用于 Sticks,可以是 Horizontal 或 Vertical。
再次打开 MatchView.Xaml 文件,并为所有棍子添加一个控件模板。将以下代码片段添加到 ContentView.Resources 元素中:
<ControlTemplate x:Key="StickViewControlTemplate">
</ControlTemplate>
这定义了一个具有 StickViewControlTemplate 键的 ControlTemplate 元素。键用于将 ControlTemplate 元素应用于控件。
每个棍子视觉元素有两个部分:标签上显示的数字和棍子图像,图像使用 Image 控件在边框内显示,并由放置棍子的玩家着色。另一个有趣的方面是 Label 和 Border 控件需要叠加在一起。为了实现这一点,使用了一个 Grid 控件,并将两个元素放置在同一单元格中。要添加 Grid、Label、Border 和 Image 控件,请使用以下列表,并将它们添加到 ControlTemplate 元素中:
<Grid Margin="0" Padding="0">
<Label Text="{TemplateBinding GamePiecePosition}" IsVisible="False" HorizontalTextAlignment="Center" VerticalTextAlignment="Center" TextColor="Red" FontAttributes="Bold" >
</Label>
<Border Padding="3" BackgroundColor="Transparent" StrokeShape="RoundRectangle 5" Stroke="Transparent">
<Image Aspect="Fill">
</Image>
</Border>
</Grid>
Grid 的 Margin 和 Padding 值为 0,这样它就不会占用任何屏幕空间。Label 控件的 Text 属性使用 TemplateBinding 绑定到 GamePiecePosition 属性。TemplateBinding 与 Binding 有所不同,因为 TemplateBinding 使用应用于此 ControlTemplate 的控件作为 DataContext。由于此 ControlTemplate 将应用于 GamePieceView 的实例,因此它将绑定到这些控件的 Bindable 属性。
检查 第 7 步 中的 Image 控件,你会发现它没有指定显示哪个图像。对于 Sticks,会显示两个图像中的一个:水平棍子显示 hstick.jpeg,垂直棍子显示 vstick.jpeg,如果该位置没有棍子,则控件不应可见。以下列表使用 DataTrigger 通过 TemplateBinding 将 IsVisible 和 Source 的值设置为 Image 控件的 GamePieceState 和 GamePieceDirection 属性。将此代码添加到 ControlTemplate 的 Image 控件中:
<Image.Triggers>
<DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceState}" Value="0">
<Setter Property="IsVisible" Value="False" />
</DataTrigger>
<DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceDirection}" Value="Horizontal">
<Setter Property="Source" Value="hstick.jpeg" />
</DataTrigger>
<DataTrigger TargetType="Image" Binding="{TemplateBinding Path=GamePieceDirection}" Value="Vertical">
<Setter Property="Source" Value="vstick.jpeg" />
</DataTrigger>
</Image.Triggers>
Border 控件也使用 DataTrigger 以放置棍子的玩家的颜色来勾勒出棍子。在 Image 之后添加以下代码到 Border 元素中:
<Border.Triggers>
<DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="1" >
<Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
</DataTrigger>
<DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="-1" >
<Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
</DataTrigger>
</Border.Triggers>
需要两个触发器来在PlayerOne(1)和PlayerTwo(-1)之间切换。Border控制的Stroke属性被设置为玩家的颜色资源。如果两个触发器都不活跃,则使用Border元素的默认Stroke值Transparent。这样,如果没有使用棒子,GamePieceState为0,边界将是透明的。如果GamePieceState为1,则Stroke将具有由名为PlayerOne的资源定义的颜色,如果GamePieceState为-1,则Stroke值将是名为PlayerTwo的资源。
当用户在他们的回合中移动时,他们将通过点击标签来放置他们的棒子在那个位置。为了在发生这种情况时调用SelectStickCommand,Border控制将TapGestureRecognizer绑定到GameViewModel.SelectStickCommand属性,并将GamePiecePosition作为参数传递。将以下列表添加到Border元素之后,在Border.Triggers元素之后:
<Border.GestureRecognizers>
<TapGestureRecognizer Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:GameViewModel}}, Path=SelectStickCommand}" CommandParameter="{TemplateBinding GamePiecePosition}" />
</Border.GestureRecognizers>
最后,仔细看看Label元素;你会看到IsVisible属性被设置为False。如果没有在这个位置放置棒子,我们需要显示位置的标签。这可以通过使用DataTrigger来实现;如果GamePieceState为0,即还没有放置棒子,则可以将标签的IsVisible属性设置为True,使标签可见。将以下列表添加到Label元素中:
<Label.Triggers>
<DataTrigger TargetType="Label" Binding="{TemplateBinding Path=GamePieceState}" Value="0">
<Setter Property="IsVisible" Value="True" />
</DataTrigger>
</Label.Triggers>
这样就完成了棒子的控制模板。接下来,按照以下步骤创建Stones的控制模板:
在为棒子创建的ControlTemplate下方添加以下代码:
<ControlTemplate x:Key="StoneViewControlTemplate">
</ControlTemplate>
就像棒子的控制模板一样,ControlTemplate使用一个键来定位正确的模板。
Stones模板比Sticks模板简单一些。在这里,我们只有一个作为子控件的Border控制和Image控制。再次使用DataTrigger来选择正确的边界颜色,如果石头不存在,则边界不可见。使用以下代码示例并将其添加到在步骤 1 中创建的ControlTemplate中:
<Border Margin="3" Padding="5" HorizontalOptions="Center" VerticalOptions="Center" StrokeShape="RoundRectangle 5" StrokeThickness="3">
<Border.Triggers>
<DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="0">
<Setter Property="IsVisible" Value="False" />
</DataTrigger>
<DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="1" >
<Setter Property="Stroke" Value="{StaticResource PlayerOne}" />
</DataTrigger>
<DataTrigger TargetType="Border" Binding="{TemplateBinding GamePieceState}" Value="-1" >
<Setter Property="Stroke" Value="{StaticResource PlayerTwo}" />
</DataTrigger>
</Border.Triggers>
<Image Source="stones.jpeg" Aspect="Fill" />
</Border>
你可能已经注意到这个列表中的触发器与Sticks控制模板中的触发器有所不同。在Sticks中,IsVisible属性是在Image上设置的,而不是在Border上,你可能想知道为什么是这样。解释很简单;如果边界不可见,它将不会收到TapGuesture事件。Grid元素无法注册GestureRecognizer,因此事件也无法在那里被捕获。
需要用于棍子和石头图像的ControlTemplates已经就位;现在,它们需要与GamePieceView控件元素关联。Style可以设置GamePieceView元素的ControlTemplate属性,但它如何确定这个元素是棍子还是石头?Style元素有一个Class属性,可以用来进一步细化应用于控件的风格。如果控件在其StyleClass属性中列出了匹配的类名,则应用该Style元素。让我们以棍子为例,按照以下步骤进行:
将新的Style元素添加到ContentView.Resources元素中,如下所示列表:
<Style TargetType="controls:GamePieceView"
Class="Stick">
<Setter Property="ControlTemplate"
Value="{StaticResource StickViewControlTemplate}" />
</Style>
此样式仅应用于类型为GamePiece且在StyleClass属性中列出Stick类的元素。匹配的元素可能如下所示:
<controls:GamePieceView Grid.Row="0" Grid.Column="1" StyleClass="Stick"
GamePiecePosition="01" GamePieceState="{Binding Game.Sticks[0]}" GamePieceDirection="Horizontal" />
突出的部分显示了用于匹配Style元素的控件部分。StyleClass可以列出多个名称;只需使用逗号分隔名称。
添加一个新的Style元素。这次,它将应用于StoneViewControlTemplate,如下所示列表:
<Style TargetType="controls:GamePieceView"
Class="Stone">
<Setter Property="ControlTemplate"
Value="{StaticResource StoneViewControlTemplate}" />
</Style>
棒子和石头元素添加到游戏板网格中所需的所有内容都已具备。要添加剩余的元素,请按照以下步骤进行:
有七行棍子:四行三列和三行四列。它们几乎相同,但又不完全相同。定位定义游戏板的Grid;它已经添加了角落的点。在 16 个点元素之后,添加以下列表以添加第一行棍子:
<controls:GamePieceView Grid.Row="0" Grid.Column="1" StyleClass="Stick"
GamePiecePosition="01" GamePieceState="{Binding Game.Sticks[0]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="0" Grid.Column="3" StyleClass="Stick"
GamePiecePosition="02" GamePieceState="{Binding Game.Sticks[1]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="0" Grid.Column="5" StyleClass="Stick"
GamePiecePosition="03" GamePieceState="{Binding Game.Sticks[2]}" GamePieceDirection="Horizontal" />
第一行中的每根棍子都是水平显示的。每根棍子都分配了其在GamePiecePosition属性中的位置,并且GamePieceState绑定到该棍子的Game.Sticks对象。Sticks数组是从零开始的,所以数组的索引比GamePiecePosition少一个。
使用以下列表添加第二行棍子的代码:
<controls:GamePieceView Grid.Row="1" Grid.Column="0" StyleClass="Stick"
GamePiecePosition="04" GamePieceState="{Binding Game.Sticks[3]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="1" Grid.Column="2" StyleClass="Stick"
GamePiecePosition="05" GamePieceState="{Binding Game.Sticks[4]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="1" Grid.Column="4" StyleClass="Stick"
GamePiecePosition="06" GamePieceState="{Binding Game.Sticks[5]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="1" Grid.Column="6" StyleClass="Stick"
GamePiecePosition="07" GamePieceState="{Binding Game.Sticks[6]}" GamePieceDirection="Vertical" />
这些元素都是Vertical而不是Horizontal;否则,它们遵循与上一步相同的模式。继续添加剩余的行。
使用以下列表添加第三行棍子:
<controls:GamePieceView Grid.Row="2" Grid.Column="1" StyleClass="Stick"
GamePiecePosition="08" GamePieceState="{Binding Game.Sticks[7]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="2" Grid.Column="3" StyleClass="Stick"
GamePiecePosition="09" GamePieceState="{Binding Game.Sticks[8]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="2" Grid.Column="5" StyleClass="Stick"
GamePiecePosition="10" GamePieceState="{Binding Game.Sticks[9]}" GamePieceDirection="Horizontal" />
使用以下列表添加第四行棍子:
<controls:GamePieceView Grid.Row="3" Grid.Column="0" StyleClass="Stick"
GamePiecePosition="11" GamePieceState="{Binding Game.Sticks[10]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="3" Grid.Column="2" StyleClass="Stick"
GamePiecePosition="12" GamePieceState="{Binding Game.Sticks[11]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="3" Grid.Column="4" StyleClass="Stick"
GamePiecePosition="13" GamePieceState="{Binding Game.Sticks[12]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="3" Grid.Column="6" StyleClass="Stick"
GamePiecePosition="14" GamePieceState="{Binding Game.Sticks[13]}" GamePieceDirection="Vertical" />
使用以下列表添加第五行棍子:
<controls:GamePieceView Grid.Row="4" Grid.Column="1" StyleClass="Stick"
GamePiecePosition="15" GamePieceState="{Binding Game.Sticks[14]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="4" Grid.Column="3" StyleClass="Stick"
GamePiecePosition="16" GamePieceState="{Binding Game.Sticks[15]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="4" Grid.Column="5" StyleClass="Stick"
GamePiecePosition="17" GamePieceState="{Binding Game.Sticks[16]}" GamePieceDirection="Horizontal" />
使用以下列表添加第六行棍子:
<controls:GamePieceView Grid.Row="5" Grid.Column="0" StyleClass="Stick"
GamePiecePosition="18" GamePieceState="{Binding Game.Sticks[17]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="5" Grid.Column="2" StyleClass="Stick"
GamePiecePosition="19" GamePieceState="{Binding Game.Sticks[18]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="5" Grid.Column="4" StyleClass="Stick"
GamePiecePosition="20" GamePieceState="{Binding Game.Sticks[19]}" GamePieceDirection="Vertical" />
<controls:GamePieceView Grid.Row="5" Grid.Column="6" StyleClass="Stick"
GamePiecePosition="21" GamePieceState="{Binding Game.Sticks[20]}" amePieceDirection="Vertical" />
使用以下列表添加第七行棍子:
<controls:GamePieceView Grid.Row="6" Grid.Column="1" StyleClass="Stick"
GamePiecePosition="22" GamePieceState="{Binding Game.Sticks[21]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="6" Grid.Column="3" StyleClass="Stick"
GamePiecePosition="23" GamePieceState="{Binding Game.Sticks[22]}" GamePieceDirection="Horizontal" />
<controls:GamePieceView Grid.Row="6" Grid.Column="5" StyleClass="Stick"
GamePiecePosition="24" GamePieceState="{Binding Game.Sticks[23]}" GamePieceDirection="Horizontal" />
棍子都已添加,现在我们需要添加石头。使用以下列表将九个Stone元素添加到游戏板Grid控件中,遵循棍子的顺序:
<controls:GamePieceView Grid.Row="1" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[0]}" />
<controls:GamePieceView Grid.Row="1" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[1]}" />
<controls:GamePieceView Grid.Row="1" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[2]}" />
<controls:GamePieceView Grid.Row="3" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[3]}" />
<controls:GamePieceView Grid.Row="3" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[4]}" />
<controls:GamePieceView Grid.Row="3" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[5]}" />
<controls:GamePieceView Grid.Row="5" Grid.Column="1" StyleClass="Stone" GamePieceState="{Binding Game.Stones[6]}" />
<controls:GamePieceView Grid.Row="5" Grid.Column="3" StyleClass="Stone" GamePieceState="{Binding Game.Stones[7]}" />
<controls:GamePieceView Grid.Row="5" Grid.Column="5" StyleClass="Stone" GamePieceState="{Binding Game.Stones[8]}" />
这是对游戏应用的总结。你现在可以在下一节测试项目。
测试完成的项目
这个项目跨越了两个章节,包括第九章 ,使用 Azure 服务设置游戏后端,以及本章,构建实时游戏。由于这是一个两人回合制游戏,正确配置所有组件可能是一个挑战。按照以下步骤在 Windows 上本地测试你的游戏:
第一步是让服务在后台运行。在 Visual Studio 中,右键单击SticksAndStones.Functions项目,然后选择调试 | 不调试启动 或按Ctrl + F5 。
图 10.15 – 启动 Azure Functions 服务
这应该会启动一个包含正在运行的 Azure Functions 服务的终端窗口。
现在,需要两个客户端来玩游戏。在 Windows 上,这意味着 Windows 客户端和 Android 客户端。首先启动 Windows 客户端,并使用与函数相同的方法。确保在调试 选项中选择了 Windows 目标:
图 10.16 – 选择 Windows 作为调试目标
右键单击SticksAndStones.App项目,然后选择调试 | 不调试启动 或按Ctrl + F5 。应该会打开一个新窗口,显示登录页面。
现在,将调试 目标切换到 Android:
图 10.17 – 选择 Android 作为调试目标
现在,要么使用F5 在 Android 模拟器中调试应用,要么使用Ctrl + F5 仅运行应用。
使用不同的电子邮件和游戏标签登录到每个应用。
图 10.18 – 登录到游戏
向其他玩家发起比赛!
图 10.19 – 发出挑战
在一场棍子 和石头 游戏中挑战自己吧!
图 10.20 – 比赛已经开始
Android:不允许到 10.0.2.2 的明文 http 流量
如果你尝试使用 Android 客户端测试游戏,当你尝试向服务器发送移动时,你可能会遇到这个错误。幸运的是,解决方案很简单。在Platforms/Android文件夹中打开MainApplication.cs文件,并将MainApplication类上的Application属性修改为以下内容:
[Application(UsesCleartextTraffic = true)]
如果你遇到任何错误或某些事情没有按预期工作,请返回所有步骤并确保你没有错过任何东西。否则,恭喜你完成了这个项目。
摘要
就这样!做得好!这一章内容丰富,很难将其总结得简短。在这一章中,我们创建了一个连接到我们后端的游戏应用。我们创建了一个服务,用于管理对后端服务的调用并处理错误,所有操作都是异步进行的。我们学习了如何响应 SignalR 的消息,以及如何在应用中使用 IMessenger 接口发送和接收消息。我们创建了自定义控件,并在多个页面中使用它们。我们学习了如何使用 XAML 风格来设计应用,如何使用控件模板,以及如何通过样式来选择它们。我们探讨了路由及其在多页面 .NET MAUI 应用中的工作方式。我们检查了触发器,并了解了如何在不使用 C# 代码和转换器的情况下使用它们来更新界面。
现在,奖励自己,挑战一位朋友在你的新游戏中进行一场比赛。
在下一章中,我们将一起深入研究 Blazor 和 .NET MAUI。
第十一章:使用 .NET MAUI Blazor 构建 Calculator
在本章中,我们将探讨 .NET BlazorWebView。我们还将实现 Blazor 和 .NET MAUI 之间的通信。
在本章中,我们将涵盖以下主题:
什么是 Blazor?
探索 .NET MAUI 项目和 .NET MAUI Blazor 项目的区别
使用 HTML 和 CSS 定义 UI
在 WebView 中使用 XAML 控件与 HTML
编写将与 XAML 控件和 HTML 控件集成的 C# 代码
使用主 .NET MAUI 窗口来调整其大小以适应内容
技术要求
你需要安装 Visual Studio for Mac 或 PC,以及 .NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅 第一章 ,.NET MAUI 简介 。本章的源代码可在本书的 GitHub 仓库中找到:github.com/ PacktPublishing/MAUI-Projects-3rd-Edition。
项目概述
在本章中,你将了解 .NET Blazor 以及如何使用它与 .NET MAUI 开发应用程序的 UI。我们将探讨在 .NET MAUI 应用程序内托管 Blazor 应用程序的不同选项。两个应用程序之间的通信是互操作性的关键部分,本章的项目将向你展示如何从 .NET MAUI 向 Blazor 发送数据,反之亦然。
什么是 Blazor?
.NET Blazor 是一个基于 .NET 的 Web 框架。Blazor 应用程序通过使用 WebAssembly (WASM )在浏览器中运行,或者通过 SignalR 在服务器上运行。Blazor 是整个 ASP.NET 生态系统的一部分,并且它利用 Razor 页面来开发 UI。Blazor 使用 HTML 和 CSS 来渲染丰富的 UI。Blazor 使用基于组件的 UI,其中每个组件都是一个 Razor 标记页。在一个 Razor 页面中,你可以混合使用 HTML、CSS 和 C# 代码。Blazor 应用程序有三种部署模型:
Blazor Server :在 Blazor Server 部署中,应用程序代码在 ASP.NET Core 应用程序的服务器上运行,并通过 SignalR 与在浏览器中运行的 UI 进行通信。
Blazor WebAssembly :对于 Blazor WebAssembly,整个应用程序通过 WASM 在浏览器中运行。它是一个开放的网络标准,使得在浏览器中安全运行 .NET 代码成为可能。WASM 提供了与 JavaScript 的互操作性。
Blazor Hybrid :Blazor Hybrid 是原生 .NET 和 Web 技术的混合体。Blazor Hybrid 应用程序可以托管在 .NET MAUI、WPF 和 Windows Forms 应用程序中。由于所有宿主都是 .NET,Blazor 运行时在同一个 .NET 进程中本地运行,并将 Razor 页面 Web UI 渲染到 WebView 控件中。
现在我们对 Blazor 有了一些基本的了解,让我们看看本章我们将要构建的应用程序吧!
创建计算器应用程序
在本章中,我们将构建一个计算器应用程序。计算器的 UI 使用 Blazor 中的 Razor 页面构建,但计算器的实际机制位于 .NET MAUI 应用程序中。
设置项目
这个项目,就像所有其他项目一样,是一个 文件 | 新建 | 项目... 风格的项目。这意味着我们根本不会导入任何代码。因此,这个第一部分完全是关于创建项目和设置基本项目结构。
创建新项目
第一步是创建一个新的 .NET MAUI 项目。按照以下步骤操作:
打开 Visual Studio 2022 并选择 创建一个 新项目 :
图 11.1 – Visual Studio 2022
这将打开 创建一个新 项目 向导。
在搜索框中输入 blazor 并从列表中选择 .NET MAUI Blazor App 项:
图 11.2 – 创建一个新项目
点击 下一步 。
如下截图所示,将应用名称输入为 Calculator:
图 11.3 – 配置您的全新项目
点击 下一步 。
最后一步将提示您选择要支持的 .NET Core 版本。在撰写本文时,.NET 6 可用为 长期支持 (LTS ),而 .NET 7 可用为 标准期限支持 。对于本书,我们假设您将使用 .NET 7:
图 11.4 – 其他信息
通过点击 创建 并等待 Visual Studio 创建项目来最终完成设置。
项目创建到此结束。
让我们通过回顾应用的结构来继续。
探索 .NET MAUI Blazor 混合项目
如果您运行项目,您将看到一个应用,如图 11.5 所示。它并不像 .NET MAUI 应用模板,而是具有独特的网络感觉。稍微探索一下应用,看看所有视觉元素是如何协同工作的。然后,关闭应用程序,返回 Visual Studio,并继续探索项目:
图 11.5 – 运行 .NET MAUI Blazor 模板项目
.NET MAUI Blazor 应用的结构是 .NET MAUI 应用和 Blazor 应用的混合体。如果您查看通常存在于 .NET MAUI 模板中的 Platforms 和 Resources 文件夹以及 App.xaml、MainPage.xaml 和 MauiProgram.cs 文件。wwwroot、Data、Pages 和 Shared 文件夹都支持 Blazor 应用。此外,您将在项目的根目录下找到 _Imports.razor 和 Main.razor:
图 11.6 – .NET MAUI Blazor 项目的解决方案资源管理器视图
如果您需要复习 .NET MAUI 应用的结构和功能,请参阅 第一章 。暂时忽略 Blazor 应用的功能,让我们看看 Blazor 应用是如何由 .NET MAUI 托管的。
由于所有 .NET MAUI 程序都从 MauiProgram.cs 文件开始,这似乎是一个好的起点。打开 MauiProgram.cs 文件并检查其内容。以下代码片段突出了 .NET MAUI Blazor 应用的差异:
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
第一行高亮显示的行启用了 Blazor 应用的托管服务,特别是 WebView 控制器。第二行高亮显示的行启用了 WebView 控制器内的开发者工具(F12 ),但仅限于调试配置。
App.xaml 和 App.xaml.cs 与 .NET MAUI 模板项目中的基本相同,但 MainPage.xaml 则不同。打开 MainPage.xaml 文件来检查其内容,如下所示:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="Calculator.MainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>
MainPage 有一个单独的控制项,BlazorWebView。这是一个包装原生控件以在应用程序中托管网页的包装器。HostPage 属性指向起始页面 - 在这种情况下,wwwroot/index.html。BlazorWebView.RootComponents 元素标识了 Blazor 应用程序的起始点以及它们在页面上的托管位置。在这种情况下,RootComponent Main 在具有 ID app 的元素中根。
要查看 app 元素的位置,打开 wwwroot 文件夹中的 index.html 文件并检查其内容,如下所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Calculator</title>
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link href="css/app.css" rel="stylesheet" />
<link href="Calculator.styles.css" rel="stylesheet" />
</head>
<body>
<div class="status-bar-safe-area"></div>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="img/blazor.webview.js" autostart="false"></script>
</body>
</html>
代码中突出显示的文件的关键部分。首先,id 设置为 app 的 div 元素是 Main 组件的根或加载位置。在页面的页眉中,识别了样式表。第一个样式表 app.css 位于项目的 wwwroot/css 文件夹中。第二个样式表 Calculator.Styles.css 在构建过程中从隔离的 CSS 文件创建。导入 _framework/blazor.webview.js 文件,该文件负责在页面上正确位置渲染你的 Blazor 组件的所有繁重工作。
在我们继续创建应用程序的其他部分之前,我们需要审查的最后部分是 Blazor 组件。Main.razor 是一个路由文件,它将 Blazor 运行时指向起始组件 MainLayout.razor,如下面的代码所示:
<Router AppAssembly="@typeof(Main).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
MainLayout.razor 文件定义了页面的基本布局,左侧有一个导航栏,主体内容占据了页面的剩余部分,如下面的代码所示:
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
@Body 内容由满足路由的页面提供。对于第一个页面,那将是 /。如果你查看 Pages 文件夹中的文件,Index.razor 文件有一个 @page 指令,其参数为 /。因此,默认情况下,那将是显示的页面。@page 指令是一个 Razor 构造,允许满足路由而无需使用控制器。Shared/NavMenu.razor 文件中的 NavLink 条目使用 href 属性引用路由。该值在 @page 指令列表中查找匹配项。如果没有找到匹配项,则在 Main.razor 文件中的 <NotFound> 元素中渲染内容。
打开 Pages/Counter.razor 页面,看看 Razor 页面是如何工作的:
@page "/counter"
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
在 @page 指令之后,页面由 HTML 和一些 Razor 指令混合组成。然后是 @code 指令,其中包含页面的 C# 代码。HTML 的 button 元素的点击事件通过 @onclick 指令映射到 C# 的 IncrementCount 方法。
了解更多
要了解更多关于 Razor 页面的信息,请查看官方文档:learn.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-7.0 。
现在,让我们开始创建项目。
准备项目
.NET MAUI 和 Blazor 无缝集成 – 集成得如此之好,以至于有时很难区分在哪里执行。这使得在 XAML 和 HTML 中渲染数据变得非常容易。
让我们从为计算器准备项目开始。我们将首先删除大部分我们不会使用的模板代码。按照以下步骤准备模板:
在 Visual Studio 中,使用 Data 文件夹。
在 Pages 文件夹中,删除 Index.razor、Counter.Razor 和 FetchData.razor。
在 Shared 文件夹中,删除 NavMenu.razor、NavMenu.razor.css 和 SurveyPrompt.razor。
右键点击 Pages 文件夹,然后选择 添加 | Razor 组件… ,如图所示:
图 11.7 – 添加新的 Razor 组件
在 Keypad.razor 中点击 添加 :
图 11.8 – Razor 组件
在新的 Keypad.razor 文件中,添加以下突出显示的行:
@page "/"
<h3>Keypad</h3>
<div>Keypad goes here</div>
@code {
}
打开 Shared 文件夹中的 MainLayout.razor 文件,并删除以下突出显示的部分:
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
打开 MauiProgram.cs 并删除突出显示的代码行:
using Calculator.Data;
using Microsoft.Extensions.Logging;
namespace Calculator
{
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<WeatherForecastService>();
return builder.Build();
}
}
}
现在,运行项目以查看更改的效果,如图 图 11.9 所示:
图 11.9 – 在 Windows 上运行空白计算器应用
现在,项目已经准备好成为一个应用,让我们从 Keypad 视图开始。
创建 Keypad 视图
Keypad 视图是计算器的基本功能。它有 0 到 9 的每个数字按钮,小数分隔符 .*,清除按钮 C,清除所有内容 CE,以及最终,加号 +,减号 -,乘号 x,除号 /,等于 *= 来获取结果,和左箭头 < 来删除最后一个字符。视图有三个基本组件 – HTML 和 CSS 用于样式,以及 C# 代码。以下每个部分将指导您为视图添加每个这些组件,从 HTML 开始。
添加 HTML
Razor 文件和 .NET MAUI XAML 文件没有为你提供可视化的设计器。你必须运行你的应用来查看你所做的更改,这通常涉及到构建、部署,然后导航到应用中的视图。.NET 有一个节省时间的功能叫做热重载。它通过将你对 Razor、XAML、CSS 和 C# 文件所做的更改应用到正在运行的应用程序中,而无需你停止并重新启动应用来实现。在完成下一步操作时尝试使用热重载 。要应用更改,请使用调试 工具栏中的热重载 按钮。它很容易找到——它是一个火焰图标:
图 11.10 – 热重载工具栏按钮
如果在任何时候你收到类似于以下“粗鲁编辑”对话框,这仅仅意味着更改不能通过热重载应用,因此你需要停止、重新构建并重新启动调试:
图 11.11 – 热重载对话框
热重载一直在不断改进,所以将来这些情况会越来越少。
要添加将为你提供与密钥盘交互的 UI 的 HTML,请按照以下步骤操作:
通过使用调试器或按F5 键启动应用。
如果你想在 Visual Studio 中查看你的应用,你可以打开XAML 实时预览 窗格。要打开XAML 实时预览 窗格,请使用 Visual Studio 菜单并选择调试 |窗口 |XAML 实时预览 。我通常将其固定打开,以便始终可用。
在Pages文件夹中打开Keypad.razor文件。
删除以下代码块中显示的突出内容:
@page "/"
<h3>Keypad</h3>
<div>Keypad goes here</div>
@code {
}
在@page之后但在@代码指令之前添加以下 HTML:
<div class="keypad">
<div class="keypad-body">
<div class="keypad-screen">
<div class="keypad-typed"></div>
</div>
<div class="keypad-row">
<div class="keypad-button wide command">C</div>
<div class="keypad-button command">CE</div>
<div class="keypad-button operator">/</div>
</div>
<div class="keypad-row">
<div class="keypad-button">7</div>
<div class="keypad-button">8</div>
<div class="keypad-button">9</div>
<div class="keypad-button operator">X</div>
</div>
<div class="keypad-row">
<div class="keypad-button">4</div>
<div class="keypad-button">5</div>
<div class="keypad-button">6</div>
<div class="keypad-button operator">−</div>
</div>
<div class="keypad-row">
<div class="keypad-button">1</div>
<div class="keypad-button">2</div>
<div class="keypad-button">3</div>
<div class="keypad-button operator">+</div>
</div>
<div class="keypad-row">
<div class="keypad-button">.</div>
<div class="keypad-button">0</div>
<div class="keypad-button"><</div>
<div class="keypad-button operator">=</div>
</div>
</div>
</div>
不要忘记按div而不是table,因为比table元素更容易为div元素添加样式。密钥盘按四按钮一行排列,除了前两行。第一行是显示表达式或结果的显示屏。第二行只有三个按钮。类名已经添加到 HTML 元素中,但由于它们还不存在,它们不会改变外观。
密钥盘看起来还不完全像密钥盘,但一旦我们给它添加一些样式,它就会变得如此。
为 HTML 添加样式
CSS 已经存在很长时间了,是使你的 HTML 看起来最好的最佳方式。按照以下步骤添加我们在上一节中使用的样式:
确保你的应用仍在运行,并在Pages文件夹中使用Keypad.razor.css。如果 Visual Studio 默认不显示它们,请点击显示所有模板 按钮:
图 11.12 – 添加新的 CSS 文件
如果你查看Pages文件夹,你会注意到你的新文件现在位于Keypad.razor文件下:
图 11.13 – 带有独立 CSS 文件的 Razor 页面
Visual Studio 自动识别您想要添加一个Keypad.razor文件。
将keypad样式添加到Keypad.razor.css文件中:
.keypad {
width: 300px;
margin: auto;
margin-top: -1.1em;
}
这个样式将元素的宽度设置为300px,除了顶部,其值为-1.1em。-1.1em将键盘的顶部边缘直接移动到网页视图控制的顶部。
现在,使用以下代码添加keypad-body样式:
.keypad-body {
border: solid 1px #3A4655;
}
这个样式只是给整个元素添加了一个一像素宽的深灰色边框。
我们将最后保存keypad-screen和keypad-typed样式,所以添加以下代码中显示的keypad-row样式:
.keypad-row {
width: 100%;
background: #3C4857;
}
这个样式将元素宽度设置为父元素的 100%,即keypad-body,并将背景设置为令人愉悦的深灰色。
接下来要添加的样式是keypad-button。使用以下代码添加样式:
.keypad-button {
width: 25%;
background: #425062;
color: #fff;
padding: 20px;
display: inline-block;
font-size: 25px;
text-align: center;
vertical-align: middle;
margin-right: -4px;
border-right: solid 2px #3C4857;
border-bottom: solid 2px #3C4857;
transition: all 0.2s ease-in-out;
}
这个样式是所有按键按钮的基础,因此它具有最多的属性。应用了这个样式的元素在其右侧和底部有 2 像素宽的边框,并且使用与行背景相同的颜色。按钮的background属性比边框颜色略暗,这提供了一点深度。文本在垂直和水平方向上居中对齐,并使用 25 像素的字体大小。宽度设置为 25%,因为每行通常有四个按钮。transition属性使用ease-in-out进行 200 毫秒的过渡,这从开始加速到中间,然后从中间减速到结束。transition应用于所有属性,所以每当这个样式的属性发生变化时,它都会从起始值缓慢变化到结束值。
如果按钮是动作按钮,例如运算符,则应用一个额外的样式,称为operator。这个样式的定义与迄今为止创建的其他样式略有不同。这个样式不仅被命名为operator,而是命名为keypad-button.operator。在 CSS 中,.是一个选择器;它用于定位要应用哪些属性。在这种情况下,我们想要所有同时应用了keypad-button类和operator类的元素。要添加keypad-button.operator类,请使用以下代码:
.keypad-button.operator {
color: #AEB3BA;
background: #404D5E;
}
这些按钮将以略暗的背景和略少的白色文字显示。
清除(C )和清除所有(CE )按钮也有它们自己的类,如下所示:
.keypad-button.command {
color: #D95D4E;
background: #404D5E;
}
这些按钮将以略暗的背景和红色文字显示。
现在,对于桌面,我们可以通过使用:hover伪选择器来添加悬停高亮。使用以下代码添加悬停样式:
.keypad-button:hover {
background: #E0B612;
}
.keypad-button.command:hover,
.keypad-button.operator:hover {
background: #E0B612;
color: #fff;
}
背景被改为橙色。由于keypad-button样式中存在transition属性,所以变化不会立即发生,它将在两十分之一秒内从深灰色过渡到橙色。
最后一个与按钮相关的样式是宽的,或者 keypad-button.wide。这种样式使按钮的宽度是普通按钮的两倍。要添加此样式,请使用以下代码:
.keypad-button.wide {
width: 50%;
}
最后两个样式,keypad-screen 和 keypad-typed,用于显示表达式和结果。使用以下代码添加剩余的两个样式:
.keypad-screen {
background: #3A4655;
width: 100%;
height: 75px;
padding: 20px;
}
.keypad-typed {
font-size: 45px;
text-align: right;
color: #fff;
}
现在,键盘看起来像真正的计算器键盘;请参阅 图 11**.14 以获取示例。你是否能够在不重新启动应用程序的情况下继续添加样式并看到更改?请记住点击 热重载 按钮,或在 热重载 按钮菜单下设置 在文件保存时热重载 选项;Visual Studio 将在您保存文件时尝试应用更改。接下来,我们将添加使按钮能够工作的代码:
图 11.14 – 带样式的 HTML 键盘
连接控件
在大多数网页中,您会使用 Keypad.razor 文件,按照以下步骤操作:
在 @code 指令块内,添加以下内容:
string inputDisplay = string.Empty;
bool clearInputBeforeAppend = false;
这声明了一个名为 inputDisplay 的 string 字段,并将其初始化为空字符串。它还声明了一个 bool 字段,并将其初始化为 false。clearInputBeforeAppend 是一个标志,用于保持 inputDisplay 的清洁。在显示结果后,当用户轻触按钮时,应在将字符添加到屏幕之前清除 inputDisplay。
更新具有 keypad-typed 类的元素,如下所示:
<div class="keypad-typed">@inputDisplay</div>
这将渲染 inputDisplay 变量的内容到 div 元素中。注意使用 @ 来引用 C# 字段。
为了帮助验证输入,请添加以下内容:
readonly char[] symbols = { '/', 'X', '+', '-', '.' };
当按下任何数字(0 到 9)或操作按钮时,inputDisplay 将通过向显示中添加一个字符来更新。使用以下代码添加 AppendInput 方法:
void AppendInput(string inputValue)
{
double numValue;
if (clearInputBeforeAppend)
{
inputDisplay = string.Empty;
}
if (string.IsNullOrEmpty(inputDisplay) && inputValue.IndexOfAny(symbols) != -1)
{
return;
}
if (!double.TryParse(inputValue, out numValue) && !string.IsNullOrEmpty(inputDisplay) && $"{inputDisplay[¹]}".IndexOfAny(symbols) != -1)
{
return;
}
if (inputDisplay.Trim() == "0" && inputValue == "0")
{
return;
}
clearInputBeforeAppend = false;
inputDisplay += inputValue;
}
让我们回顾一下代码。首先检查 inputDisplay 是否需要清除;如果是,则清除。然后进行操作符的检查。下一个检查更复杂,因为它不允许一行中有多个操作符。此检查使用 ¹ 的 Range 语法来指示最后一个字符。使用字符串插值将最后一个字符转换回字符串,以便 IndexOfAny 可以在符号数组中找到该字符。检查并拒绝多个前导 0。如果所有检查都通过,则将输入追加到 inputDisplay,并将 clearInputBeforeAppend 标志重置为 false。
当用户使用以下代码按下 Undo 方法时:
void Undo()
{
if (!clearInputBeforeAppend && inputDisplay.Length > 0)
{
inputDisplay = inputDisplay[0..¹];
return;
}
}
此方法再次使用 Range 语法,以及字符串能够像数组一样索引的能力。它使用数组语法从索引 0 到下一个最后一个索引获取元素,并返回它。
当用户使用以下代码按下 ClearInput 方法时:
void ClearInput()
{
inputDisplay = string.Empty;
}
当用户按下 ClearAll 方法时:
void ClearAll()
{
ClearInput();
}
最后,EvaluateExpression 方法:
void EvaluateExpression()
{
var expression = inputDisplay;
clearInputBeforeAppend = true;
}
此方法尚未评估输入的表达式;这将在 创建计算 服务 部分中发生。
在 Keypad.razor 文件中的下一步是连接刚刚定义的方法,以便在用户点击或触摸该元素时调用它们。就像在标准 HTML 中一样,事件通过引用代码的元素属性连接起来,无论是内联还是方法。Razor 页面中的属性使用页面指令与事件名称相关联。例如,处理触摸或点击的 DOM 事件是 click,因此 Razor 事件名称将是 @onclick。该属性随后被分配给一个代表,它可以是任何方法。完整的属性可能看起来像 @onclick="DoSomething",其中 DoSomething 是在页面的 @code 指令中定义的 C# 方法。
AppendInput 方法接受一个字符串参数,因此代表不能只是 AppendInput – 它必须被包裹在一个表达式中,以便可以将参数传递下去。Razor 页面中的表达式包含在 @(…) 指令中。所有从事件指令对 AppendInput 的调用都将类似于 @onclick="@(AppendInput("0"))"。
使用以下代码中高亮显示的行来更新 Keypad.razor 文件中的 HTML:
<div class="keypad">
<div class="keypad-body">
<div class="keypad-screen">
<div class="keypad-typed">@inputDisplay</div>
</div>
<div class="keypad-row">
<div class="keypad-button wide command" @onclick="ClearInput">C</div>
<div class="keypad-button command" @onclick="ClearAll">CE</div>
<div class="keypad-button operator" @onclick="@(()=>AppendInput("/"))">/</div>
</div>
<div class="keypad-row">
<div class="keypad-button" @onclick="@(()=>AppendInput("7"))">7</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("8"))">8</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("9"))">9</div>
<div class="keypad-button operator" @onclick="@(()=>AppendInput("X"))">X</div>
</div>
<div class="keypad-row">
<div class="keypad-button" @onclick="@(()=>AppendInput("4"))">4</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("5"))">5</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("6"))">6</div>
<div class="keypad-button operator" @onclick="@(()=>AppendInput("-"))">−</div>
</div>
<div class="keypad-row">
<div class="keypad-button" @onclick="@(()=>AppendInput("1"))">1</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("2"))">2</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("3"))">3</div>
<div class="keypad-button operator" @onclick="@(()=>AppendInput("+"))">+</div>
</div>
<div class="keypad-row">
<div class="keypad-button" @onclick="@(()=>AppendInput("."))">.</div>
<div class="keypad-button" @onclick="@(()=>AppendInput("0"))">0</div>
<div class="keypad-button" @onclick="Undo"><</div>
<div class="keypad-button operator" @onclick="EvaluateExpression">=</div>
</div>
</div>
</div>
更多关于 Razor 事件处理的信息
要了解更多关于 Razor 事件处理的信息,请访问 https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-7.0。
键盘几乎完成了。在此阶段,你应该能够输入一个完整的表达式进行评估,然后清除显示屏。在下一节中,你将创建一个用于评估表达式的服务。
创建计算服务
Compute 服务评估表达式并返回结果。为了说明 .NET MAUI 和 Blazor 应用程序如何相互交互,此服务将从 .NET MAUI 依赖注入容器注入到 Blazor 页面中。要实现 Compute 服务,请按照以下步骤操作:
在项目的根目录下创建一个名为 Services 的新文件夹。
在 Services 文件夹中,添加一个名为 Compute 的新 C# 类文件。
修改 Compute.cs 文件,使其与以下代码匹配:
namespace Calculator.Services;
internal class Compute
{
public string Evaluate(string expression)
{
System.Data.DataTable dataTable = new System.Data.DataTable();
var finalResult = dataTable.Compute(expression, string.Empty);
return finalResult.ToString();
}
}
这段代码可能比你预期的要短。而不是编写大量代码来解析表达式并构建一个用于评估的表达式树,已经存在一种内置的方式来评估简单的表达式:DataTable。DataTable.Compute 方法可以评估从计算器构建的所有表达式。
打开 MauiProgram.cs 文件,并添加以下高亮显示的代码行以使用依赖注入注册类:
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<Compute>();
return builder.Build();
为了允许 Keypad.razor 页面使用 Compute 类型,它需要使用 Razor 声明。打开 _Imports.razor 文件,并在文件末尾添加以下高亮显示的代码行:
@using Calculator.Services
没有此行,你仍然可以使用该类型,但必须完全限定它为Calculator.Services.Compute。这是 Razor 中 C#文件中global using指令的等效项。
现在,打开Keypad.razor文件,在@page指令之后添加以下代码行:
@inject Compute compute
@inject指令将使用.NET MAUI 依赖注入容器解析第一个参数提供的类型,并将其分配给第二个参数定义的变量。
在EvaluateExpression方法中,可以使用compute变量来评估inputDisplay中包含的表达式,如下所示突出显示的代码:
void EvaluateExpression()
{
var expression = inputDisplay;
var result = compute.Evaluate(inputDisplay.Replace('X', '*'));
inputDisplay = result;
clearInputBeforeAppend = true;
}
在这里,使用inputDisplay作为参数调用了Evaluate方法。首先将inputDisplay修改为将字符串中的所有X值替换为*,因为这是DataTable期望用于乘法的。然后将结果分配给inputDisplay。
到目前为止,计算器应用程序可以接受数字和运算符的组合输入,并评估结果,将其显示给用户。用户还可以清除显示。在下一节中,我们将通过给计算器添加内存来探索更多的互操作性。
添加内存功能
大多数计算器可以存储以前的计算。在本节中,你将向计算器应用程序添加一个以前计算列表,并将以前的计算召回到键盘的inputDisplay参数。
代码将使用.NET MAUI 控件在WebViewControl旁边渲染。将使用一个名为Calculations的新类来管理列表。
要将内存功能添加到计算器应用程序中,请按照以下步骤操作:
创建一个名为ViewModels的新文件夹。
在ViewModels文件夹中添加一个名为Calculations的新类,并修改文件以匹配以下代码:
using System.Collections.ObjectModel;
namespace Calculator.ViewModels;
public class Calculations : ObservableCollection<Calculation>
{
}
public class Calculation : Tuple<string, string>
{
public Calculation(string expression, string result) : base(expression, result) { }
public string Expression => this.Item1;
public string Result => this.Item2;
}
此代码添加了两个类 - Calculations,它是Observable Collection<Calculation>的简称,以及Calculation,它是Tuple<string, string>,并定义了两个属性来引用Item1作为Expression和Item2作为Result。
添加对CommunityToolkit.Mvvm NuGet 包的引用。
在ViewModels文件夹中添加一个名为MainPageViewModel的新类,并修改文件,如下所示代码:
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
namespace Calculator.ViewModels;
public partial class MainPageViewModel
{
IMessenger messenger;
public MainPageViewModel(Calculations results, IMessenger messenger)
{
Results = results;
this.messenger = messenger;
}
public Calculations Results { get; init; }
[RelayCommand]
public void Recall(Calculation sender)
{
messenger.Send(sender);
}
}
MainViewModel类使用CommunityToolkit的两个功能:RelayCommand和IMessenger。与其他章节一样,RelayCommand用于将方法绑定到 XAML 操作。IMessenger是一个用于在应用程序的不同部分之间发送消息的接口。当你不希望两个类之间有硬依赖时,它非常有用,尤其是如果它创建了一个循环引用。CommunityToolkit提供了一个名为WeakReferenceMessenger的IMessenger的默认实现。
打开MauiProgram.cs文件,在文件顶部添加以下using声明:
using Calculator.ViewModels;
using CommunityToolkit.Mvvm.Messaging;
在CreateMauiApp方法中,进行以下突出显示的更改:
builder.Services.AddSingleton<Compute>();
builder.Services.AddSingleton<Calculations>();
builder.Services.AddSingleton<MainPage>();
builder.Services.AddSingleton<MainPageViewModel>();
builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
return builder.Build();
这会将 MainPage、MainPageViewModel 和 WeakReferenceMessenger 的默认实例添加到依赖注入容器中。接下来的几个步骤将使 MainPage 能够通过依赖注入进行初始化。
打开 MainView.xaml.cs 文件,并进行以下高亮显示的更改:
using Calculator.ViewModels;
namespace Calculator;
public partial class MainPage : ContentPage
{
public MainPage(MainPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
如同其他章节,视图的构造函数被更新以接受视图模型作为参数,并将其分配为 BindingContext。
打开 App.xaml.cs 文件,修改构造函数,并添加 OnHandlerChanging 事件处理程序,如下所示:
public App()
{
InitializeComponent();
}
protected override void OnHandlerChanging(HandlerChangingEventArgs args)
{
base.OnHandlerChanging(args);
MainPage = args.NewHandler.MauiContext.Services.GetService<MainPage>();
}
由于 .NET MAUI Blazor 应用程序默认不使用 Shell,因此视图不能像使用 Shell 那样通过依赖注入进行初始化。相反,在设置 Handler 之后,将获取 MainPage 的实例。使用 OnHandlerChanging 事件来获取新 Handler 的引用,然后它为依赖注入容器提供 MauiContext。
打开 _Imports.razor 文件,并将以下行添加到文件末尾:
@using Calculator.ViewModels
@using CommunityToolkit.Mvvm.Messaging
打开 Keypad.razor 文件,并添加以下高亮显示的行:
@inject Compute compute
@inject Calculations calculations
@inject IMessenger messenger
<div class="keypad">
这将从 .NET MAUI 依赖注入容器中注入 Calculations 实例作为 calculations 和 WeakReference 信使 作为 messenger。
修改 ClearAll 和 EvaluateExpression 方法,并添加一个 OnAfter RenderAsync 方法,如下所示:
void ClearAll()
{
ClearInput();
calculations.Clear();
}
void EvaluateExpression()
{
var expression = inputDisplay;
var result = compute.Evaluate(inputDisplay.Replace('X', '*'));
calculations.Add(new(expression, result));
inputDisplay = result;
clearInputBeforeAppend = true;
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
messenger.Register<Calculation>(this, (sender, er) =>
{
inputDisplay = er.Expression;
clearInputBeforeAppend = true;
StateHasChanged();
});
}
return base.OnAfterRenderAsync(firstRender);
ClearAll will just clear the collection, and EvaluateExpression will add the new Calulation to the collection. OnAfterRenderAsync is used to register this class to receive messages for any Calculation objects. When a message is received, inputDisplay is set to the Expression value of Calculation, and StateHasChanged is called to force the UI to refresh with the updated value.
打开 Shared/MainLayout.razor.css 文件,并将以下行添加到 page 类中:
background-color: black;
这只是为了美观,使得计算器周围区域变为黑色。
打开 MainPage.xaml 文件,并修改它以匹配以下代码:
<ContentPage
x:Class="Calculator.MainPage"
x:DataType="viewModels:MainPageViewModel">
<Grid BackgroundColor="Black">
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<ScrollView Grid.Row="0" BackgroundColor="Bisque" WidthRequest="400" VerticalScrollBarVisibility="Always">
<CollectionView ItemsSource="{Binding Results}" ItemsUpdatingScrollMode="KeepLastItemInView">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="viewModels:ExpressionResult">
<SwipeView>
<SwipeView.LeftItems>
<SwipeItems Mode="Execute">
<SwipeItem
Text="Recall" BackgroundColor="LightPink" Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:MainPageViewModel}}, Path=RecallCommand}" CommandParameter="{Binding}"/>
</SwipeItems>
</SwipeView.LeftItems>
<VerticalStackLayout>
<HorizontalStackLayout Padding="10" HorizontalOptions="EndAndExpand">
<Label Text="{Binding Expression}" FontSize="Large" TextColor="Black" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
<Label Text="=" TextColor="Blue" FontSize="Large" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
<Label Text="{Binding Result}" FontSize="Large" TextColor="Black" HorizontalTextAlignment="End" HorizontalOptions="EndAndExpand"/>
</HorizontalStackLayout>
<Line Stroke="LightSlateGray" X2="400" />
<Line Stroke="Black" X2="400" />
</VerticalStackLayout>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ScrollView>
<BlazorWebView Grid.Row="1" x:Name="blazorWebView" HostPage="wwwroot/index.html" HeightRequest="540">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</Grid>
</ContentPage>
使用定义了两行的 Grid 元素来包含新的显示区域,用于之前的计算和原始的 BlazorWebView 控件。计算使用 ScrollView 渲染,其中包含 CollectionView。CollectionView.ItemTemplate 属性包含每个 Calculation 的 DataTemplate。SwipeView 控件允许用户向上、向下、向左或向右滑动以显示额外的命令。每个方向都有一个元素来定义这些操作。对于 Calculation 项目,当用户向右滑动时,它会显示一个 Recall 项目,该项目绑定到 MainPageViewModel 的 Recall 命令。Calculation 的显示使用水平和垂直 StackLayout 控件的组合来堆叠 Expression 在 Result 之上,并用 = 对齐,所有内容都向左对齐。
这就完成了我们计算器应用的主要功能。下一节将处理在桌面操作系统(如 Windows 或 macOS)上运行时的主窗口的一些美学问题。
调整主窗口大小
计算器应用被定义为固定大小。本书中的大多数项目都允许控件随着窗口大小的变化而增长或缩小。对于我们的计算器应用,主窗口应该是固定的,以便以最佳方式显示。要在应用启动时固定窗口大小,打开 App.xaml.cs 文件,并将以下方法添加到 App 类中:
protected override Window CreateWindow(IActivationState activationState)
{
var window = base.CreateWindow(activationState);
if (OperatingSystem.IsWindows() || OperatingSystem.IsMacCatalyst())
{
window.Created += Window_Created;
}
return window;
}
private async void Window_Created(object sender, EventArgs e)
{
const int defaultWidth = 450;
const int defaultHeight = 800;
var window = (Window)sender;
window.Width = defaultWidth;
window.Height = defaultHeight;
window.X = -defaultWidth;
window.Y = -defaultHeight;
await window.Dispatcher.DispatchAsync(() => { });
var displayInfo = DeviceDisplay.Current.MainDisplayInfo;
window.X = (displayInfo.Width / displayInfo.Density - window.Width) / 2;
window.Y = (displayInfo.Height / displayInfo.Density - window.Height) / 2;
window.Created -= Window_Created;
}
CreateWindow 方法被重写,以便当应用在 Windows 或 macOS 上运行时,可以给 Window.Created 事件附加一个自定义处理程序。调整窗口大小的操作在 Window_Created 方法中完成。它使用 defaultHeight 和 defaultWidth 常量来设置新窗口的高度、宽度和屏幕上的位置。然后,该方法等待所有线程完成后再再次更改窗口的 X 和 Y 属性,但这次要考虑到屏幕像素密度。最后,它断开事件处理程序
图 11.15 – 完成的计算器项目
摘要
优秀的工作!在本章中,你完成了一个使用 .NET MAUI Blazor 模板的项目。你使用 HTML 创建了一个 UI,并用 C# 代码更新它,然后实现了一个由 .NET MAUI 管理并注入到 Razor 页面中的服务。然后,你使用 CollectionView 显示之前计算列表。在 CollectionView 的 ItemTemplate 中,使用了 SwipeView 控件来将之前的计算召回键盘进行额外的编辑和重新评估。
要进一步扩展此项目,请考虑以下:
为集合添加一个额外的滑动操作以删除一个计算
为科学计算添加一个额外的键盘布局
在下一章——也是最后一章中,你将随着构建一个物体识别应用而发现人工智能的世界。
第十二章:热狗还是非热狗:使用机器学习
在本章中,我们将学习如何使用机器学习创建一个模型,我们可以用它来进行图像分类。我们将导出为Onnx 模型,这样我们就可以在所有平台上使用它——也就是说,Android、iOS、macOS 和 Windows。为了训练和导出模型,我们将使用 Azure 认知服务和自定义视觉服务。
一旦我们导出了模型,我们将学习如何在.NET MAUI 应用程序中使用它们。
本章将涵盖以下主题:
技术要求
为了能够完成这个项目,你需要安装 Visual Studio for Mac 或 PC,以及.NET MAUI 组件。有关如何设置环境的更多详细信息,请参阅第一章 ,.NET MAUI 简介 。你还需要一个 Azure 账户。如果你有 Visual Studio 订阅,每个月包含一定数量的 Azure 积分。要激活你的 Azure 福利,请访问my.visualstudio.com 。
你也可以创建一个免费账户,在那里你可以免费使用选定的服务长达 12 个月。你将获得价值 200 美元的积分,用于探索任何 Azure 服务 30 天,你还可以随时使用免费服务。更多信息请参阅azure.microsoft.com/en-us/free/ 。
如果你没有并且不想注册免费的 Azure 账户,本章源代码中提供了训练好的模型。你可以下载并使用预训练模型。
本章的源代码可在本书的 GitHub 仓库github.com/PacktPublishing/MAUI-Projects-3rd-Edition 中找到。
机器学习
术语机器学习 由 1959 年的美国人工智能先驱 Arthur Samuel 提出。美国计算机科学家 Tom M. Mitchell 后来提供了以下更正式的机器学习定义:
“如果一个计算机程序在任务 T 和性能度量 P 方面从经验 E 中学习,那么它的性能随着经验 E 的提高而提高。”
用更简单的话说,这句话描述了一个可以不经过明确编程就能学习的计算机程序。在机器学习中,算法用于构建样本数据或训练数据的数学模型。这些模型用于计算机程序,以便在没有为特定任务明确编程的情况下做出预测和决策。
在本节中,我们将了解一些不同的机器学习服务和 API,这些服务和 API 在开发.NET MAUI 应用程序时可用。一些 API 仅适用于特定平台,如 Core ML,而其他则是跨平台的。
Azure 认知服务 – 定制视觉
定制视觉是一个工具或服务,可以用来训练图像分类模型和检测图像中的对象。使用定制视觉,我们可以上传自己的图像并对其进行标记,以便进行图像分类训练。如果我们为对象检测训练一个模型,我们还可以标记图像的特定区域。因为模型已经为基本图像识别进行了预训练,所以我们不需要大量的数据就能得到很好的结果。建议每个标签至少有 30 张图像。
当我们训练了一个模型后,我们可以使用 API 来使用它,这是定制视觉服务的一部分。我们还可以导出模型用于 Core ML (iOS )、TensorFlow (Android )、Open Neural Network Exchange (ONNX )以及 Dockerfile (Azure IoT Edge 、Azure Functions 和 Azure ML )。这些模型可以在不连接到定制视觉服务的情况下执行分类或对象检测。
使用它需要 Azure 订阅 - 请访问 azure.com/free 创建一个免费订阅,这应该足以完成此项目。
Core ML
Core ML 是在 iOS 11 中引入的一个框架。Core ML 使得将机器学习模型集成到 iOS 应用中成为可能。在 Core ML 的基础上,我们还有以下高级 API:
更多信息
更多关于 Core ML 的信息可以在苹果官方文档中找到,请访问 developer.apple.com/documentation/coreml 。
TensorFlow
TensorFlow 是一个开源的机器学习框架。然而,TensorFlow 不仅可以用在移动设备上运行模型,还可以用来训练模型。要在移动设备上运行它,我们使用 TensorFlow Lite。从 Azure 认知服务导出的模型是为 TensorFlow Lite 设计的。还有 TensorFlow Lite 的 C# 绑定,它作为一个 NuGet 包提供。
更多信息
更多关于 TensorFlow 的信息可以在官方文档中找到,请访问 www.tensorflow.org/ 。
ML.Net
ML.Net 是一个开源且跨平台的机器学习框架,支持 iOS、macOS、Android 和 Windows,所有这些都可以在熟悉的环境中完成 - C#。ML.Net 提供了 AutoML ,一套生产力工具,使构建、训练和部署自定义模型变得简单。ML.Net 可以用于以下场景和更多:
情感分析和产品推荐
目标检测和图像分类
价格预测、销售峰值检测和预测
欺诈检测
客户细分
现在我们对正在使用的技术的概览已经比较广泛了,我们将专注于使用 ML.NET,因为它是一个跨平台框架,专为 C# 构建。让我们看看我们接下来要构建的项目。
项目概览
如果你看过电视剧 硅谷 ,你可能已经听说过 Not Hotdog 应用程序。在本章中,我们将学习如何构建这个应用程序。本章的第一部分将涉及收集我们将用于创建一个能够检测照片中是否包含热狗的机器学习模型的数据。
在本章的第二部分,我们将使用 .NET MAUI 和 ML.NET 构建一个应用程序,用户可以拍摄一张新照片或从照片库中选择一张照片,分析它以查看是否包含热狗。完成此项目的估计时间为 120 分钟。
开始
我们可以使用安装在 PC 上的 Visual Studio 2022 或 Visual Studio for Mac 来完成这个项目。如果你想在 PC 上使用 Visual Studio 构建 iOS 应用程序,你必须连接一台 Mac。如果你根本无法访问 Mac,你可以选择只完成这个项目的 Android 和 Windows 部分。
同样,如果你只有 Mac,你也可以选择只完成这个项目的 iOS 和 macOS 或 Android 部分。
使用机器学习构建热狗或非热狗应用程序
让我们开始吧!我们将首先训练一个用于图像分类的模型,我们可以在本章的后面使用它来判断照片中是否包含热狗。
注意
如果你不想费心训练模型,你可以从以下网址下载一个预训练的模型:github.com/PacktPublishing/MAUI-Projects-3rd-Edition/tree/main/Chapter12/HotdogOrNot/Resources/Raw 。
训练模型
要训练一个用于图像分类的模型,我们需要收集热狗的照片以及不是热狗的照片。由于世界上大多数物品都不是热狗,我们需要更多不包含热狗的照片。如果热狗的照片涵盖了多种不同的热狗场景——比如有面包、番茄酱或芥末,那就更好了。这样,模型就能在不同的情境中识别出热狗。当我们收集不是热狗的照片时,我们也需要有一大批照片,这些照片既包含类似热狗的物品,也完全不同于热狗。
GitHub 上的解决方案中的模型是用 240 张照片训练的,其中 60 张是热狗的照片,180 张不是。
一旦我们收集了所有照片,我们就可以开始按照以下步骤训练模型:
前往 customvision.ai .
登录并创建一个新的项目。
给项目起一个名字——在我们的例子中,HotDogOrNot。
通过点击 创建新资源 选择一个资源或创建一个新的资源。填写对话框,并在 类型 下拉菜单中选择 CustomVision.Training 。
项目类型应该是分类 ,分类类型应该是多类(每张图片一个标签 )。
将域选择为通用(紧凑) 。如果我们想导出模型并在移动设备上运行,我们使用紧凑域。
点击创建项目 继续,如下截图所示:
图 12.1 – 创建新的 AI 项目
创建项目后,我们可以开始上传图片并标记它们。
标记图片
获取图片最简单的方法是去谷歌搜索。我们将通过以下步骤添加热狗的照片:
点击添加图片 。
选择应该上传的热狗照片。
标记照片为hotdog,如下截图所示:
图 12.2 – 上传热狗的图片
上传所有热狗照片后,就是时候按照以下步骤上传非热狗的照片了。为了获得最佳结果,我们还应该包括看起来像热狗但实际上不是的照片:
点击上传图片画廊上方的添加图片 按钮。
选择那些不是热狗的照片。
用Negative标签标记照片。
使用Negative标签标记不包含我们创建的其他标签的任何对象的图片。在这种情况下,我们上传的图片中没有任何热狗,如下截图所示:
图 12.3 – 上传非热狗的图片
上传照片后,就是时候训练一个模型了。
训练模型
我们上传的并非所有照片都会用于训练;其中一些将用于验证,以给我们一个关于模型好坏的评分。如果我们分批上传照片并在每批之后训练模型,我们将能够看到我们的评分在提高。要训练一个模型,请点击页面顶部的绿色训练 按钮,如下截图所示:
图 12.4 – 训练模型
下面的截图显示了训练迭代的结果,其中模型的精确度为91.7% :
图 12.5 – 模型验证结果
训练好模型后,我们将导出它,以便在设备上使用。
导出模型
如果需要,我们可以使用 API,但为了快速分类并能够离线操作,我们将模型添加到应用包中。点击导出 按钮,然后选择ONNX 下载模型,如下截图所示:
图 12.6 – 导出模型
下载了 ONNX 模型之后,就是时候构建应用了。
构建应用
我们的应用程序将使用训练好的模型来分类照片,根据它们是否是热狗的照片。我们将使用相同的 ONNX 模型在 .NET MAUI 应用程序的所有平台上。
创建新项目
让我们开始,如下所示。
第一步是创建一个新的 .NET MAUI 项目:
打开 Visual Studio 2022,并选择 创建新项目 :
图 12.7 – Visual Studio 2022
这将打开 创建新项目 向导。
在搜索框中输入 maui,并从列表中选择 .NET MAUI 应用 项:
图 12.8 – 创建新项目
点击 下一步 。
通过命名项目来完成向导的下一步。在这种情况下,我们将应用程序命名为 HotdogOrNot。通过点击 下一步 ,如图所示继续到下一个对话框:
图 12.9 – 配置您的项目
最后一步将提示您选择支持 .NET Core 的版本。在撰写本文时,.NET 6 可用作为 长期支持 (LTS ),而 .NET 7 可用作为 标准期限支持 。在本书中,我们假设您正在使用 .NET 7。
图 12.10 – 其他信息
通过点击 创建 完成设置,并等待 Visual Studio 创建项目。
如果现在运行应用程序,您应该会看到以下类似的内容:
图 12.11 – HotdogOrNot 应用程序
就这样,应用程序就创建完成了。接下来,让我们开始创建图像分类器。
使用机器学习进行图像分类
我们将首先通过以下步骤将 ONNX ML 模型添加到项目中:
从 Custom Vision 服务中获取的 .zip 文件进行解压。
找到 .onnx 文件,并将其重命名为 hotdog-or-not.onnx。
将其添加到项目中的 Resources/Raw 文件夹。
一旦我们将文件添加到项目中,我们就可以开始创建图像分类器的实现。我们将用于图像分类的代码将在 .NET MAUI 支持的平台上共享。我们可以通过以下步骤创建分类器的接口:
创建一个名为 ImageClassifier 的新文件夹。
在 ImageClassifier 文件夹中创建一个名为 ClassifierOutput 的新类。
修改 ClassifierOutput 类,使其看起来如下:
namespace HotdogOrNot.ImageClassifier;
internal sealed class ClassifierOutput
{
ClassifierOutput() { }
}
在 ImageClassifier 文件夹中创建一个名为 IClassifier 的新接口。
添加一个名为 Classify 的方法,该方法返回 ClassifierOutput 并接受 byte[] 作为参数。
您的界面应该看起来像以下代码块:
namespace HotdogOrNot.ImageClassifier;
public interface IClassifier
{
ClassifierOutput Classify(byte[] bytes);
}
现在我们有了分类器的接口,我们可以继续到实现部分。
使用 ML.NET 进行图像分类
我们现在可以创建IClassifier接口的实现。在我们直接进入实现之前,让我们看看需要发生的高级步骤,以便我们更好地理解流程。
我们的训练模型hotdog-or-not.onnx具有特定的输入和输出参数,在将其提交给 ML.NET 框架之前,我们需要将我们想要分类的图像转换为输入格式。此外,我们还需要确保在提交之前图像的形状是正确的。图像的形状由大小、宽度、高度和颜色格式定义。如果图像与输入格式不匹配,那么在提交之前需要对其进行调整和转换,否则您将面临图像被错误分类的风险。对于由 Custom Vision 服务生成的图像分类模型,例如hotdog-or-not 模型,其输入和输出如下所示:
图 12.12 – Netron 中的模型输入和输出
模型的输入被格式化为一个名为data的多维数组。该数组由四个维度组成:
图像 :该格式允许您一次性提交多个图像;然而,对于此应用程序,我们将一次只提交一个图像
0是蓝色,1是绿色,2是红色
高度 :每个索引代表图像的y 轴或垂直轴上的一个位置,范围在 0 到 223 之间
宽度 :每个索引代表图像的x 轴或水平轴上的一个位置,范围在 0 到 223 之间
该值是该特定图像、颜色以及x 和y 位置的颜色值。例如,data[0,2,64,64]将是第一张图像中从左侧 64 像素和从底部 64 像素位置上的绿色通道的值。
为了减少错误分类的数量,我们需要将所有提交的图像缩放到 224 x 224 像素,并正确排序颜色通道。
我们可以通过以下步骤来完成:
在项目的ImageClassifier文件夹中创建一个名为MLNetClassifier的新类。
添加IClassifier接口。
实现接口中的Classify方法,如下面的代码块所示:
namespace HotdogOrNot.ImageClassifier;
internal class MLNetClassifier : Iclassifier
{
public MLNetClassifier(byte[] model)
{
// Initialize Model here
}
public ClassifierOutput Classify(byte[] imageBytes)
{
// Code will be added here
}
}
到目前为止,我们还没有从 ML.NET 引用任何类。要使用 ML.NET API,我们需要按照以下步骤添加对 NuGet 包的引用:
在项目中安装Microsoft.ML.OnnxRuntime NuGet 包。
接受任何许可对话框。
这将安装相关的 NuGet 包。
现在我们正在引用 ML.NET 包,我们可以按照以下步骤编译 ONNX ML 模型。
在MLNetClassifier文件顶部添加using Microsoft.ML.Onnx.Runtime;声明。
在MLNetClassifier类中添加以下字段:
readonly InferenceSession session;
readonly bool isBgr;
readonly bool isRange255;
readonly string inputName;
readonly int inputSize;
在 MLNetClassifier 构造函数中,添加以下代码行以初始化 OnnxRuntime 会话,替换 // Initialize Model 这里 注释:
Session = new InferenceSession(model);
isBgr = session.ModelMetadata.CustomMetadataMap["Image.BitmapPixelFormat"] == "Bgr8";
isRange255 = session.ModelMetadata.CustomMetadataMap["Image.NominalPixelRange"] == "NominalRange_0_255";
inputName = session.InputMetadata.Keys.First();
inputSize = session.InputMetadata[inputName].Dimensions[2];
在继续之前,让我们讨论一下前面的代码。MLNetClassifier 类的构造函数接受 byte[] 作为参数。这代表 ML 模型文件。byte[] 然后被传递到一个新的 InferenceSession 实例中,这是 ML.NET API 的主要入口点。一旦模型被加载到会话中,我们就可以检查模型的一些属性,例如图像格式(isBGR)、颜色值范围(isRange255)、输入名称和输入大小。我们将这些值缓存在类字段中,以便在分类期间使用。现在,你的 MLNetClassifier 类应该看起来像以下这样:
using Microsoft.ML.OnnxRuntime;
namespace HotdogOrNot.ImageClassifier;
internal class MLNetClassifier : Iclassifier
{
readonly InferenceSession session;
readonly bool isBgr;
readonly bool isRange255;
readonly string inputName;
readonly int inputSize;
public MLNetClassifier(byte[] model)
{
session = new InferenceSession(model);
isBgr = session.ModelMetadata.CustomMetadataMap["Image.BitmapPixelFormat"] == "Bgr8";
isRange255 = session.ModelMetadata.CustomMetadataMap["Image.NominalPixelRange"] == "NominalRange_0_255";
inputName = session.InputMetadata.Keys.First();
inputSize = session.InputMetadata[inputName].Dimensions[2];
}
public ClassifierOutput Classify(byte[] imageBytes)
{
// Code will be added here
}
}
我们现在可以继续实现 MLNetClassifier 类的 Classify 方法。
运行分类的第一步是将输入转换为正确的格式。对于图像分类,这意味着将图像调整到正确的尺寸,并将颜色值组织成预期的格式。然后,图像数据被加载到 Tensor 中,这是我们向 ML.NET 模型传递数据的方式。以下步骤将创建一个名为 LoadInputTensor 的方法来完成这项工作:
在 MLNetClassifier 类中的 Classify 方法之后添加一个名为 LoadInputTensor 的新方法。此方法将接受四个参数,byte[]、int 和两个布尔值,并返回一个 Tensor<float> 和 byte[] 的元组。你的方法应该看起来像以下这样:
static (Tensor<float>, byte[] resizedImage) LoadInputTensor(byte[] imageBytes, int imageSize, bool isBgr, bool isRange255)
{
}
在 LoadInputTensor 内部,我们将创建 return 对象并添加以下突出显示的代码行:
{
var input = new DenseTensor<float>(new[] { 1, 3, imageSize, imageSize });
byte[] pixelBytes;
// Add code here
return (input, pixelBytes);
}
下一步是调整图像大小;我们将使用 ImageSharp NuGet 库使这一过程变得非常简单。
将 ImageSharp NuGet 包添加到项目中。
添加以下代码行以调整图像大小,替换 \\ Add code 这里 注释:
using (var image = Image.Load<Rgb24>(imageBytes))
{
image.Mutate(x => x.Resize(imageSize, imageSize));
pixelBytes = new byte[image.Width * image.Height * Unsafe.SizeOf<Rgba32>()];
image.ProcessPixelRows(source =>
{
// Add Code here
});
}
此代码使用 ImageSharp 库从 byte[] 加载图像。然后,图像被调整到模型所需的大小。我们使用 imageSize 字段,其值从构造函数中捕获模型要求。最后,我们设置对 ProcessPixelRows 方法的调用,这将允许我们操作图像中的单个像素。
由于 .NET MAUI 和 ImageSharp 之间的命名冲突,我们必须在文件顶部添加一个声明,告诉编译器我们真正想要使用哪个类:
using Image = SixLabors.ImageSharp.Image;
下一段代码也将需要以下突出显示的声明:
using Microsoft.ML.OnnxRuntime;
using SixLabors.ImageSharp.Formats.Png
using Microsoft.ML.OnnxRuntime.Tensors;
using Image = SixLabors.ImageSharp.Image;
为了将输入图像转换为模型所需的正确颜色格式,我们使用 ImageSharp 库中的 ProcessPixelRows 方法。此方法为我们提供了一个可写缓冲区,我们可以对其进行操作。使用以下突出显示的代码,替换 // Add Code here 注释,来遍历调整大小的图像数据,将颜色值放入正确的顺序,并在需要时将值夹在 0 和 255 之间:
image.ProcessPixelRows(source =>
{
for (int y = 0; y < image.Height; y++)
{
Span<Rgb24> pixelSpan = source.GetRowSpan(y);
for (int x = 0; x < image.Width; x++)
{
if (isBgr)
{
input[0, 0, y, x] = pixelSpan[x].B;
input[0, 1, y, x] = pixelSpan[x].G;
input[0, 2, y, x] = pixelSpan[x].R;
}
else
{
input[0, 0, y, x] = pixelSpan[x].R;
input[0, 1, y, x] = pixelSpan[x].G;
input[0, 2, y, x] = pixelSpan[x].B;
}
if (!isRange255)
{
input[0, 0, y, x] = input[0, 0, y, x] / 255;
input[0, 1, y, x] = input[0, 1, y, x] / 255;
input[0, 2, y, x] = input[0, 2, y, x] / 255;
}
}
}
});
这段代码所做的是简单的——使用提供的源变量,它遍历图像中的每一行,以及行中的每个像素。如果模型期望颜色以蓝色、绿色和红色的顺序出现,isBGR为true,那么提取的颜色值将按照该顺序放置在输入张量中;否则,它们将以红色、绿色和蓝色的顺序添加到输入张量中。这里棘手的部分是访问每个像素的正确元素。张量组织成四个维度,如前所述。对于这个模型,第一个元素始终为零,因为我们一次只处理一张图像。第二个维度是颜色通道,所以你会看到红色、绿色和蓝色颜色值的变化。
最后,如果模型期望颜色值在 0 到 255 的范围内,isRange255,则每个颜色通道都会被限制在该范围内。
我们将要做的最后一件事是将调整大小后的图像内容复制到pixelBytes数组中,这样我们就可以向用户显示图像。添加以下高亮代码来完成此操作;注意,为了简洁,之前的代码已被省略:
});
var outStream = new MemoryStream();
image.Save(outStream, new PngEncoder());
pixelBytes = outStream.ToArray();
}
return (input, pixelBytes);
现在我们已经编写了处理图像并填充输入张量的代码,我们可以通过以下步骤完成Classify方法:
将// Code will be added here注释替换为对LoadInputTensor方法的调用:
public ClassifierOutput Classify(byte[] imageBytes)
{
(Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
}
接下来,我们可以运行会话,传入新创建的输入张量并捕获结果:
public ClassifierOutput Classify(byte[] imageBytes)
{
(Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
var resultsCollection = session.Run(new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
});
}
我们从输出结果中获取标签,这将用来确定这张图像是否包含热狗:
public ClassifierOutput Classify(byte[] imageBytes)
{
(Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
var resultsCollection = session.Run(new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
});
var topLabel = resultsCollection
?.FirstOrDefault(i => i.Name == "classLabel")
?.AsTensor<string>()
?.First();
}
然后,我们可以获取结果的置信度水平,这告诉我们模型对分类有多确定。这将在我们显示结果时使用:
public ClassifierOutput Classify(byte[] imageBytes)
{
(Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
var resultsCollection = session.Run(new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
});
var topLabel = resultsCollection
?.FirstOrDefault(i => i.Name == "classLabel")
?.AsTensor<string>()
?.First();
var labelScores = resultsCollection
?.FirstOrDefault(i => i.Name == "loss")
?.AsEnumerable<NamedOnnxValue>()
?.First()
?.AsDictionary<string, float>();
}
最后,我们可以使用ClassifierOutput类返回分类的结果:
public ClassifierOutput Classify(byte[] imageBytes)
{
(Tensor<float> tensor, byte[] resizedImage) = LoadInputTensor(imageBytes, inputSize, isBgr, isRange255);
var resultsCollection = session.Run(new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor<float>(inputName, tensor)
});
var topLabel = resultsCollection
?.FirstOrDefault(i => i.Name == "classLabel")
?.AsTensor<string>()
?.First();
var labelScores = resultsCollection
?.FirstOrDefault(i => i.Name == "loss")
?.AsEnumerable<NamedOnnxValue>()
?.First()
?.AsDictionary<string, float>();
return ClassifierOutput.Create(topLabel, labelScores, resizedImage);
}
最后一步是完成MLNetClassifier的实现,通过实现ClassifierOutput类。通过添加以下高亮代码来更新你的ClassifierOutput类:
internal sealed class ClassifierOutput
{
public string TopResultLabel { get; private set; }
public float TopResultScore { get; private set; }
public IDictionary<string, float> LabelScores { get; private set; }
public byte[] Image { get; private set; }
ClassifierOutput() { }
public static ClassifierOutput Create(string topLabel, IDictionary<string, float> labelScores, byte[] image)
{
var topLabelValue = topLabel ?? throw new ArgumentException(nameof(topLabel));
var labelScoresValue = labelScores ?? throw new ArgumentException(nameof(labelScores));
return new ClassifierOutput
{
TopResultLabel = topLabelValue,
TopResultScore = labelScoresValue.First(i => i.Key == topLabelValue).Value,
LabelScores = labelScoresValue,
Image = image,
};
}
}
ClassifierOutput类用于封装将在 UI 中使用的四个值,并将它们作为公共属性公开。Create静态方法用于创建类的实例。Create方法验证提供的参数,并适当地设置公共属性以供 UI 使用。
我们现在已经编写了识别图像中热狗的代码。
现在,我们可以构建应用程序的用户界面并调用MLNetClasssifier来对图像进行分类。
请求应用权限
在我们深入构建应用的其他功能之前,我们需要处理权限问题。这个应用将有两个按钮供用户使用,一个用于拍照,另一个用于从设备中选择照片。这与我们在 第六章 中看到的 使用 CollectionView 和 CarouselView 构建照片库应用 的功能类似,在那里我们需要在访问相机或设备存储之前请求用户的权限。然而,我们将以与该章节不同的方式实现权限。由于访问相机和访问用户设备上的照片需要不同的权限,我们将从每个按钮处理程序中请求它们。
按照以下步骤添加一个类来帮助我们进行权限检查:
在项目中创建一个名为 AppPermissions 的新类。
修改类定义以添加 partial 修饰符,并移除默认构造函数:
namespace HotdogOrNot;
internal partial class AppPermissions
{
}
向 AppPermissions 类添加以下方法:
public static async Task<PermissionStatus> CheckRequiredPermission<TPermission>() where TPermission : Permissions.BasePermission, new() => await Permissions.CheckStatusAsync<TPermission>();
CheckRequiredPermission 方法用于确保在我们尝试任何可能会因为权限不足而失败的操作之前,我们的应用已经拥有了正确的权限。它的实现是通过调用 .NET MAUI 的 CheckSyncStatus 方法,并使用在 TPermission 中提供的权限类型。它返回 PermissionStatus,这是一个枚举类型。我们主要关注的是 Denied 和 Granted 这两个值。
向 AppPermissions 类添加 CheckAndRequestRequiredPermission 方法:
public static async Task<PermissionStatus> CheckAndRequestRequiredPermission() <TPermission>() where TPermission : Permissions.BasePermission, new()
{
PermissionStatus status = await Permissions.CheckStatusAsync< TPermission >();
if (status == PermissionStatus.Granted)
return status;
if (status == PermissionStatus.Denied && DeviceInfo.Platform == DevicePlatform.iOS)
{
// Prompt the user to turn on in settings
// On iOS once a permission has been denied it may not be requested again from the application
await App.Current.MainPage.DisplayAlert("Required App Permissions", "Please enable all permissions in Settings for this App, it is useless without them.", "Ok");
}
if (Permissions.ShouldShowRationale< TPermission >())
{
// Prompt the user with additional information as to why the permission is needed
await App.Current.MainPage.DisplayAlert("Required App Permissions", "This app uses photos, without these permissions it is useless.", "Ok");
}
status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<TPermission>);
return status;
}
}
CheckAndRequestRequiredPermission 方法处理请求用户访问的复杂性。第一步是简单地检查权限是否已经被授予,如果是,则返回状态。接下来,如果我们是在 iOS 上,并且权限已经被拒绝,那么它不能再次请求,因此你必须指导用户如何通过设置面板授予应用权限。Android 在请求行为中包括了一个如果用户拒绝了访问,可以不断提醒用户的功能。这个行为通过 .NET MAUI 的 ShouldShowRationale 方法暴露出来。对于不支持此行为的任何平台,它将返回 false,在 Android 上,如果用户第一次拒绝访问,它将返回 true,如果用户第二次拒绝,它将返回 false。最后,我们请求用户访问权限。同样,.NET MAUI 隐藏了所有平台实现细节,使得检查和请求访问某些资源变得非常直接。
看起来熟悉吗?
如果前面的代码看起来熟悉,那么你是对的。它是基于在 .NET MAUI 文档中描述的实现。你可以在 learn.microsoft.com/en-us/dotnet/maui/platform-integration/appmodel/permissions 找到它。
现在,我们已经设置了共享的 AppPermissions,我们可以开始进行平台配置。然而,在我们可以使用媒体选择器之前,我们需要为每个平台进行一些配置。我们将从 Android 开始。
在 Android API 版本 33 中,增加了三个新权限以启用对媒体文件的读取访问 – ReadMediaImages、ReadMediaVideos 和 ReadMediaAudio。在 API 版本 33 之前,只需要 ReadExternalStorage 权限。要访问相机,我们需要 Camera 和 WriteExternalStorage 权限。为了正确请求设备的 API 版本的正确权限,请打开 Platform/Android 文件夹中的 MauiApplication.cs 并将其修改如下:
using Android.App;
using Android.Runtime;
// Needed for Picking photo/video
[assembly: UsesPermission(Android.Manifest.Permission.ReadExternalStorage, MaxSdkVersion = 32)]
[assembly: UsesPermission(Android.Manifest.Permission.ReadMediaImages)]
// Needed for Taking photo/video
[assembly: UsesPermission(Android.Manifest.Permission.Camera)]
IMAGE_CAPTURE intent as follows in the AndroidManifest.xml file:
For iOS and Mac Catalyst, the only thing we need to do is add the following four usage descriptions to the `info.plist` file in the `platform/ios` and `platform/maccatalyst` folders:
NSCameraUsageDescription
此应用需要访问相机以拍照。
NSPhotoLibraryUsageDescription
此应用需要访问照片。
NSMicrophoneUsageDescription
此应用需要访问麦克风。
NSPhotoLibraryAddUsageDescription
此应用需要访问照片库。
For Windows, we need to add the following highlighted code to the `Capabilities` section of the `package.appxmanifest` file in the `platforms/windows` folder:
<rescap:Capability Name="runFullTrust" />
Now that we have declared the permissions we need for each platform, we can implement the remaining functionality to take a photo or pick an existing image.
Building the first view
The first view in this app will be a simple view with two buttons. One button will be to start the camera so that users can take a photo of something to determine whether it is a hot dog. The other button will be to pick a photo from the photo library of the device. We will continue to use the MVVM pattern in this chapter, so we will split the view into two classes, `MainView` for the UI visible to the user and `MainViewModel` for the actual implementation.
Building the ViewModel class
We will start by creating the `MainViewModel` class, which will handle what will happen when a user taps one of the buttons. Let’s set this up by going through the following steps:
1. Create a new folder called `ViewModels`.
2. Add a NuGet reference to `CommunityToolkit.Mvvm`; we use `CommunityToolkit.Mvvm` to implement the `INotifyPropertyChanged` interface and commands, as we did in other chapters.
3. Create a new partial class called `MainViewModel` in the `ViewModels` folder, using `ObservableObject` from the `CommunityToolkit.Mvvm.ComponentModel` namespace as a base class.
4. Create a private field of the `IClassifier` type and call it `classifier`, as shown in the following code block:
```
using CommunityToolkit.Mvvm.ComponentModel;
using HotdogOrNot.ImageClassifier;
命名空间 HotdogOrNot.ViewModels;
public partial class MainViewModel : ObservableObject
{
private IClassifier classifier;
public MainViewModel()
{
}
}
```cs
Initializing the ONNX model requires the use of asynchronous methods, so we need to handle them carefully, since we will be calling them from the constructor and the button handlers. The following steps will create the model initializer:
1. Create an `InitTask` property that is of the `Task` type.
2. Use a property initializer to set it to a new `Task`, using `Task.Run`.
3. Initialize the model from the raw resources of the .NET MAUI app. The method should look like the following code:
```
Task InitTask() => Task.Run(async () =>
{
using var modelStream = await FileSystem.OpenAppPackageFileAsync("hotdog-or-not.onnx");
using var modelMemoryStream = new MemoryStream();
modelStream.CopyTo(modelMemoryStream);
var model = modelMemoryStream.ToArray();
_classifier = new MLNetClassifier(model);
});
```cs
The `InitTask` property holds a reference to `Task` that does the following:
* Loads the `hotdog-or-not.onnx` file into `Stream`
* Copies the bytes from the original stream to an array of bytes so that the original stream can be closed and any native resources, such as file handles, can be released.
* Creates and returns a new instance of the `MLNetClassifier` class using the loaded model. 4. To ensure that `InitTask` will only run successfully once, add the following highlighted code:
```
public partial class MainViewModel : ObservableObject
{
IClassifier _classifier;
Task initTask;
public MainViewModel()
{
_ = InitAsync();
}
public Task InitAsync()
{
if (initTask == null || initTask.IsFaulted)
initTask = InitTask();
return initTask;
}
// 省略代码以节省篇幅
}
```cs
In `InitAsync`, the initialization task is captured by a field only if the field is `null` or its value has faulted. This ensures that we only run the initialization successfully once. The value of the field is then returned to the caller, which, in this case, is the constructor. Unwinding this, the constructor calls `InitAsync` and throws away the return value. `InitAsync`, meanwhile, captures the value returned by the `InitTask` property, which is `Task` that has already been queued for execution. Since `InitAsync` and `InitTask` and their closure are all asynchronous, they complete sometime after the constructor completes.
Now that we have initialized the `hotdog-or-not` ONNX model, we can now implement the two buttons, one that takes a photo and another that allows the user to pick a photo from their device storage. Let’s start by implementing a couple of helper methods to use in both use cases.
The first helper method is used to convert `FileResult` to `byte[]`. To implement `ConvertPhotoToBytes`, follow these steps:
1. Open the `MainViewModel.cs` file.
2. Add a new method named `ConvertPhotoToBytes`, which takes `FileResult` as a parameter and returns `byte []`. Since the method is `async`, you’ll need to return `Task` and use the `async` modifier.
3. In the method, check whether `FileResult` is `null` and that it returns an empty array.
4. Next, open a stream from `FileResult` using the `OpenStreamAsync` method.
5. Create a new variable of the `MemoryStream` type and initialize it using the default constructor.
6. Use the `Copy` method to copy `stream` to `MemoryStream`.
7. Finally, return `MemoryStream` as `byte[]`; your method should look like the following:
```
私有异步任务 ConvertPhotoToBytes(FileResult photo)
{
if (photo == null) return Array.Empty<byte>();
using var stream = await photo.OpenReadAsync();
using MemoryStream memoryStream = new();
stream.CopyTo(memoryStream);
return memoryStream.ToArray();
}
```cs
The other helper method we will need is to use our classification model to get the results of a photo and return the results. We will need a new type to return the results. Follow these steps to implement the new class:
1. Create a new folder named `Models` in the project.
2. In the `Models` folder, create a new class, `Result`, in a file named `Result.cs`.
3. Add a public property, `IsHotdog`, as `bool`.
4. Add a public property, `Confidence`, as `float`.
5. Add a public property, `PhotoBytes`, as `byte[]`; the class should now look like the following:
```
命名空间 HotdogOrNot.Models;
public class Result
{
public bool IsHotdog { get; set; }
public float Confidence { get; set; }
public byte[] PhotoBytes { get; set; }
}
```cs
The `IsHotdog` property is used to capture whether the label returned from the model is “hotdog.” `Confidence` is a score of how sure the model is that this is a hotdog or not. Finally, since we transform the image prior to processing, we store the transformed image in the `PhotoBytes` property.
Now, we can implement the method that will run and process the classification result, by following these steps:
1. Open the `MainViewModel.cs` file.
2. In the `MainViewModel` class, add a new field, `isClassifying`, with a `bool` type.
3. Add the `ObservableAttribute` attribute to the field; it should look like the following:
```
[ObservableProperty]
private bool isClassifying;
```cs
4. Add a new method to the `MainViewModel` class, named `RunClassificationAsync`. The method will accept a `byte[]` parameter and return `Result`, wrapped in `Task`, since it is `async`:
```
async Task<Result> RunClassificationAsync(byte[] imageToClassify)
{
}
```cs
5. In the method, the first thing we do is set the `IsClassifying` property to `true`; this will be used to disable the buttons later in the chapter.
6. Add a `try..catch..finally` statement.
7. Inside the `try` statement, ensure the model is initialized by calling `InitAsync`.
8. Then, call `Classify` on the `classifier` field passing `byte[]`, representing the image as a parameter and storing the result.
9. The last statement in the `try` statement block is to return a new `Result`, setting `IsHotdog` to `true` only if the classification result’s `TopResultLabel` property is “hotdog,” `Confidence` is set to the classification result’s `TopResultScore` property, and `PhotoBytes` is set to the classification result’s `Image` property. The `try` portion should look like the following:
```
try
{
await InitAsync().ConfigureAwait(false);
var result = _classifier.Classify(imageToClassify);
return new Result()
{
IsHotdog = result.TopResultLabel == "hotdog",
Confidence = result.TopResultScore,
PhotoBytes = result.Image
};
}
catch
```cs
10. Now, in the `catch` statement block, return a new `Result`, setting the `IsHotdog` property to `false`, `Confidence` to `0.0f`, and the `PhotoBytes` property to the bytes passed into the method. The `catch` block should look like the following:
```
catch
{
return new Result
{
IsHotdog = false,
Confidence = 0.0f,
PhotoBytes = imageToClassify
};
}
finally
```cs
11. Lastly, for the `finally` block, we want to set the `IsClassiying` property back to `false`; however, we will need to do this on the main UI thread using the `MainThread.BeginInvokeOnMainThread` method from .NET MAUI, as shown in the following code:
```
finally
{
MainThread.BeginInvokeOnMainThread(() => IsClassifying = false);
}
```cs
Now that we have written the helper methods, we can create two methods, one to handle capturing an image from the camera and another to pick a photo from user storage. We will start with the camera capture method.
Let’s set this up by following these steps:
1. Open the `MainViewModel.cs` file.
2. Create a public async void method called `TakePhoto`.
3. Add the `RelayCommand` attribute to make the method bindable.
4. Add an `if` statement to check whether the `MediaPicker.Default.IsCaptureSupported` parameter is `true`.
5. In the `true` statement block of `if`, get the status of the `Camera` permission using the `CheckAndRequestPermission` method.
6. If the status is `Granted`, then use `CheckAndRequestMethod` again to check the `WriteExternalStorage` permission.
7. If the status is `Granted`, use `MediaPicker` to capture a photo using the `Capture``PhotoAsync` method.
8. Call a method named `ConvertPhotoToBytes`, passing in the file returned from `MediaPicker`.
9. Pass the photo bytes to the `RunClassificationAsync` method.
10. Finally, we will dynamically navigate to the `Result` view, which we will create in the next section, passing the result from `RunClassificationAsync` as a parameter. We do this by using `Shell.Current.GotoAsync` and ensuring that the app uses the main thread to do so, as shown in the following code block:
```
[RelayCommand()]
public async void TakePhoto()
{
if (MediaPicker.Default.IsCaptureSupported)
{
var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.Camera>();
if (状态 == PermissionStatus.Granted) {
状态 = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.StorageWrite>();
}
if (状态 == PermissionStatus.Granted)
{
FileResult photo = await MediaPicker.Default.CapturePhotoAsync(new MediaPickerOptions() { Title = "热狗或不是热狗?" });
var imageToClassify = await ConvertPhotoToBytes(photo);
var result = await RunClassificationAsync(imageToClassify);
await MainThread.InvokeOnMainThreadAsync(async () => await
Shell.Current.GoToAsync("Result", new Dictionary<string, object>() { { "result", result } })
);
}
}
}
```cs
`Shell.Current.GotoAsync` takes two parameters – the first is the route that `Shell` is to navigate to, and the second is a dictionary of key-value pairs to send to the destination view. Later in this chapter, we will see how to configure a route to a view without using XAML and, when we create the `Result` view, how to access the parameters passed to it.
We will now create the `PickPhoto` method to allow a user to use an image from their device. Use the following steps to create the method:
1. Create a public async void method called `PickPhoto`.
2. Add the `RelayCommand` attribute to make the method bindable.
3. Grant the status of the `Photos` permission using the `CheckAndRequestPermission` method.
4. If the status is `Granted`, use `MediaPicker` to capture a photo using the `Pick``PhotoAsync` method.
5. Call a method named `ConvertPhotoToBytes`, passing in the file returned from `MediaPicker`.
6. Pass the photo bytes to the `RunClassificationAsync` method.
7. Finally, we will dynamically navigate to the `Result` view, which we will create in the next section, passing the result from `RunClassificationAsync` as a parameter. We will do this by using `Shell.Current.GotoAsync` and ensuring that the app uses the main thread to do so, as shown in the following code block:
```
[RelayCommand()]
public async void PickPhoto()
{
var status = await AppPermissions.CheckAndRequestRequiredPermissionAsync<Permissions.Photos>();
if (状态 == PermissionStatus.Granted)
{
FileResult photo = await MediaPicker.Default.PickPhotoAsync();
var imageToClassify = await ConvertPhotoToBytes(photo);
var result = await RunClassificationAsync(imageToClassify);
await MainThread.InvokeOnMainThreadAsync(async () => await
Shell.Current.GoToAsync("Result", new Dictionary<string, object>() { { "result", result } })
);
}
}
```cs
When a user clicks on a button, the classification could take a noticeable amount of time. To prevent the user from clicking the button again because they think it’s not working, we will disable the buttons until the operation completes. The `IsClassifying` property is already set; we just need to use that value to restrict `RelayCommands`, by following these steps:
1. Add a new method that returns a Boolean named `CanExecuteClassification`, and return the inverse of the `IsClassifying` property, as shown in the following code:
```
private bool CanExecuteClassification() => !IsClassifying;
```cs
2. Update the `RelayCommand` attribute for the `TakePhoto` method, as highlighted here:
```
[RelayCommand(CanExecute = nameof(CanExecuteClassification))]
public async void TakePhoto()
```cs
3. Update the `RelayCommand` attribute for the `PickPhoto` method, as highlighted here:
```
[RelayCommand(CanExecute = nameof(CanExecuteClassification))]
public async void PickPhoto()
```cs
Now that ViewModel for the main page is complete, we can build View for the main page.
Building the view
Now, once we have created the `MainViewModel` class, it is time to create the code for the `MainView` view:
1. Create a new folder called `Views`.
2. Add a new `MainView`.
3. Set the `Title` property of `ContentPage` as `Hotdog or` `Not hotdog`.
4. Add `HorizontalStackLayout` to the page, and set its `VerticalOptions` property to `Center` and its `HorizontalOptions` property to `CenterAndExpand`.
5. Add `Button` to the `HorizontalStackLayout`, with the text `Take Photo`. For the `Command` property, add a binding to the `TakePhoto` property in the `MainViewModel` class.
6. Add `Button` to `HorizontalStackLayout`, with the text `Pick Photo`. For the `Command` property, add a binding to the `PickPhoto` property in the `MainViewModel` class, as shown in the following code block:
```
<ContentPage
x:Class="HotdogOrNot.Views.MainView"
x:DataType="viewModels:MainViewModel"
Title="热狗或不是热狗">
<HorizontalStackLayout VerticalOptions="Center" HorizontalOptions="CenterAndExpand">
<Button Text="拍摄照片" Command="{Binding TakePhotoCommand}" WidthRequest="150" HeightRequest="150" Margin="20" FontSize="Large"/>
<Button Text="选择照片" Command="{Binding PickPhotoCommand}" WidthRequest="150" HeightRequest="150" Margin="20" FontSize="Large"/>
</HorizontalStackLayout>
</ContentPage>
```cs
In the code-behind `MainView.xaml.cs` file, we will set the binding context of the view by following these steps:
1. Add `MainViewModel` as a parameter of the constructor.
2. After the `InitialComponent` method call, set the `BindingContext` property of the view to the `MainViewModel` parameter.
3. Use the `SetBackButtonTitle` static method on the `NavigationPage` class so that an arrow to navigate back to this view will be shown in the navigation bar on the result view, as shown in the following code block:
```
public MainView(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel; NavigationPage.SetBackButtonTitle(this, string.Empty);
}
```cs
Building the result view
The last thing we need to do in this project is to create the result view. This view will show the input photo and the classification of a hot dog or not.
Building the ResultViewModel class
Before we create the view, we will create a `ResultViewModel` class that will handle all the logic for the view, by following these steps:
1. Create a `partial` class called `ResultViewModel` in `ViewModels`.
2. Add `ObservableObject` as a base class to the `ResultViewModel` class.
3. Create a `private` field of the `string` type, called `title`. Add the `ObservableProperty` attribute to the field to make it a bindable property.
4. Create a `private` field of the `string` type, called `description`. Add the `ObservableProperty` attribute to the field to make it a bindable property.
5. Create a `private` field of the `string` type, called `Title`. Add the `ObservableProperty` attribute to the field to make it a bindable property, as shown in the following code block:
```
使用 CommunityToolkit.Mvvm.ComponentModel;
using HotdogOrNot.Models;
namespace HotdogOrNot.ViewModels;
public partial class ResultViewModel : ObservableObject
{
[ObservableProperty]
private string title;
[ObservableProperty]
private string description;
[ObservableProperty]
byte[] photoBytes;
public ResultViewModel()
{
}
}
```cs
The next thing we will do in `ResultViewModel` is to create an `Initialize` method that will have the result as a parameter. Let’s set this up by following these steps:
1. Add a `private` method named `Initialize` to the `ResultViewModel` class that accepts a parameter of the `Result` type, named `result`, and returns `void`.
2. In the `Initialize` method, set the `PhotoBytes` property to the value of the `PhotoBytes` property of the `result` parameter.
3. Add an `if` statement that checks whether the `IsHotDog` property of the `result` parameter is `true` and whether `Confidence` is higher than `90%`. If this is the case, set `Title` to `"Hot dog"` and `Description` to `"This is for sure` `a hotdog"`.
4. Add an `else if` statement to check whether the `IsHotdog` property of the `result` parameter is `true`. If this is the case, set `Title` to `"Maybe"` and `Description` to `"This is maybe` `a hotdog"`.
5. Add an `else` statement that sets `Title` to `"Not a hot dog"` and `Description` to `"This is not a hot dog"`, as shown in the following code block:
```
public void Initialize(Result result)
{
PhotoBytes = result.PhotoBytes;
if (result.IsHotdog && result.Confidence > 0.9)
{
Title = "热狗";
Description = "这肯定是一条热狗";
}
else if (result.IsHotdog)
{
Title = "可能";
Description = "这可能是一条热狗";
}
else
{
Title = "不是热狗";
Description = "This is not a hot dog";
}
}
```cs
The final thing we need to do is call the `Initialize` method with the result. If you recall from the previous section on building the main view, we navigated to the `Result` view and passed the `Result` object as a parameter. To access the parameter and call the `Initialize` method properly, follow these steps:
1. Add the `IQueryAttributable` interface to the list of inherited interfaces:
```
public partial class ResultViewModel : ObservableObjectvoid method, ApplyQueryAttributes, that accepts a parameter named query of the IDictionary<string, object> type:
```cs
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
}
```
```cs
2. Now, in the method, call the `Initialize` method, passing the `“result”` object from the query dictionary and casting it to a `Result` type, as shown in the following code:
```
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
Initialize(query["result"] as Result);
}/
```cs
`ViewModel` is now complete, and we are ready to create `View`.
Building the view
Because we want to show the input photo in the result view, we need to convert it from `byte[]` to `Microsft.Maui.Controls.ImageSource`. We will do this in a value converter that we can use together with the binding in the **XAML**, by following these steps:
1. Create a new folder called `Converters`.
2. Create a new class called `BytesToImageConverter` in the `Converters` folder.
3. Add and implement the `IValueConverter` interface, as shown in the following code block:
```
using System.Globalization;
namespace HotdogOrNot.Converters;
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();
}
}
```cs
The `Convert` method will be used when `ViewModel` updates a view. The `ConvertBack` method will be used in two-way bindings when `View` updates `ViewModel`. In this case, we only need to write code for the `Convert` method, by following these steps:
1. First, check whether the `value` parameter is `null`. If so, we should return `null`.
2. If the value is not `null`, cast it as `byte[]`.
3. Create a `MemoryStream` object from the `byte` array.
4. Return the result of the `ImageSource.FromStream` method to which we will pass the stream, as shown in the following code block:
```
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);
}
```cs
The view will contain the photo, which will take up two-thirds of the screen. Under the photo, we will add a description of the result. Let’s set this up by going through the following steps:
1. In the `Views` folder, create a new file using the .NET MAUI ContentPage (XAML) file template, and name it `ResultView`.
2. Import the namespace for the converter.
3. Add `BytesToImageConverter` to `Resources` for the page and give it `“``ToImage”` key.
4. Bind the `Title` property of `ContentPage` as the `Title` property of `ViewModel`.
5. Add `Grid` to the page with two rows. The `Height` value for the first `RowDefinition` should be `2*`. The height of the second row should be `*`. These are relative values that mean that the first row will take up two-thirds of `Grid`, while the second row will take up one-third of `Grid`.
6. Add `Image` to `Grid`, and bind the `Source` property to the `PhotoBytes` property in `ViewModel`. Use the converter to convert the bytes to an `ImageSource` object and set the `Source` property.
7. Add `Label`, and bind the `Text` property to the `Description` property of `ViewModel`, as shown in the following code block:
```
<ContentPage 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>
```cs
We also need to set `BindingContext` of the view. We will do this in the same way as we did in `MainView` – in the code-behind file (`ResultView.xaml.cs`), as shown in the following code snippet:
public ResultView (ResultViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
We are now ready to write the initialization code for the app.
Initializing the app
We will set up `Shell`.
Open `App.xaml.cs`, and set `MainPage` to `MainView` by following these steps:
1. Delete the `MainPage.xaml` and `MainPage.xaml.cs` files from the root of the project, since we won’t be needing those.
2. Open the `AppShell.xaml` file in the root of the project, and modify it to look like the following code:
```
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="HotdogOrNot.AppShell"
Shell.FlyoutBehavior="Disabled">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:MainView}"
Route="MainView" />
</Shell>
```cs
Now, configure the `View` and `ViewModel` classes in the IoC container by following these steps:
1. Open the `MauiProgram.cs` file.
2. In the `CreateMauiApp` method before the `return` statement, add the following highlighted lines of code:
```
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddTransient<Views.MainView>();
builder.Services.AddTransient<Views.ResultView>();
builder.Services.AddTransient<ViewModels.MainViewModel>();
builder.Services.AddTransient<ViewModels.ResultViewModel>();
return builder.Build();
```cs
The very last thing we need to do is add the route to `ResultView` to enable navigation from `MainView`. We will do this by adding the following highlighted code to the constructor of `AppShell` in `AppShell.xaml.cs`:
public AppShell()
{
Routing.RegisterRoute("Result", typeof(HotdogOrNot.Views.ResultView));
InitializeComponent();
}
Now, we are ready to run the app. If we use the simulator/emulator, we can just drag and drop photos to it if we need photos to test with. When the app has started, we can now pick a photo and run it against the model. The following screenshot shows how the app will look if we upload a photo of a hot dog:

Figure 12.13 – HotdogOrNot running in an Android emulator
Note
The prediction result for Android may not be as accurate as the web portal at [`github.com/Azure-Samples/cognitive-services-android-customvision-sample/issues/12`](https://github.com/Azure-Samples/cognitive-services-android-customvision-sample/issues/12). If you desire better, more consistent results, you can use the REST APIs.
Summary
In this chapter, we built an app that can recognize whether a photo contains a hot dog or not. We accomplished this by training a machine learning model for image classification, using Azure Cognitive Services and the Custom Vision service.
We exported models for ML.NET, and we learned how to use it in an MAUI app that targets iOS, Mac Catalyst, Windows, and Android. In the app, a user can take a photo or pick one from their photo library. This photo will be sent to the model to be classified, and we will get a result that tells us whether the photo is of a hot dog.
Now, we can continue to build other apps and use what we have learned in this chapter regarding machine learning, both on-device and in the cloud using Azure Cognitive Services. Even if we are building other apps, the concept will be the same.
Now, we have completed all the chapters in this book. We have learned the following:
* What .NET MAUI is and how we can get started building apps
* How to use the basic layouts and controls of .NET MAUI
* How to work with navigation
* How to make the user experience better with animations
* How to use sensors such as the **Global Positioning System** (**GPS**) in the background
* How to build apps for multiple form factors
* How to build real-time apps powered by Azure
* How to make apps smarter with machine learning
The next step is to start to build your own apps. To stay up to date and learn more about .NET MAUI, our recommendation is to read the official Microsoft dev blogs and watch live streams on Twitch and YouTube videos from the .NET MAUI team.
Thank you for reading the book!