英雄之旅:MAUI,带 ASP.NET Core 8 后端
一系列文章比较了程序员在使用 Angular、Aurelia、React、Vue、Xamarin 和 MAUI 时的体验
背景
“英雄之旅”是 Angular 2+ 的官方教程应用程序。该应用程序包含一些功能特性和技术特性,这些特性在构建实际业务应用程序时很常见
- 几个屏幕展示了表格和嵌套数据
- 数据绑定
- 导航
- 对后端进行 CRUD 操作,并且可以选择通过生成的客户端 API 进行操作
- 单元测试和集成测试
在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个与后端通信的胖客户端。
Angular、Aurelia、React、Vue、Xamarin 和 MAUI 的前端应用都通过生成的客户端 API 与同一个 ASP.NET (Core) 后端进行通信。要查找同一系列的其他文章,请在我的文章中搜索“英雄之旅”。在本系列文章的最后,将讨论一些程序员体验的技术因素。
- 计算机科学
- 软件工程
- 学习曲线
- 构建大小
- 运行时性能
- 调试
选择开发平台涉及许多非技术因素,本系列文章将不讨论这些因素。
参考文献
引言
本文重点介绍 MAUI。
开发平台
- ASP.NET Core 8
- .NET 多平台应用 UI
演示存储库
在 GitHub 上查看 DemoCoreWeb ,并关注以下领域
Core3WebApi
ASP.NET Core Web API csproj 只提供 Web API。
移动
此文件夹包含一个 MAUI 应用程序 (Fonlow.Heroes.Maui.csproj
),它重新实现了“英雄之旅”的功能。
Fonlow.Heroes.Maui
Fonlow.Heroes.ViewModels
Fonlow.Heroes.View
CoreWebApi.ClientApi
备注
DemoCoreWeb 是为测试 WebApiClientGen for .NET 的 NuGet 包而创建的,并演示如何在实际项目中使用这些库。
Using the Code
必备组件
- Core3WebApi.csproj 导入了 NuGet 包
Fonlow.WebApiClientGenCore
。 - 将 CodeGenController.cs 添加到 Core3WebApi.csproj。
- Core3WebApi.csproj 包含 CodeGen.json。这是可选的,仅为方便运行一些 PowerShell 脚本来生成客户端 API。
- CreateWebApiClientApi3.ps1。这是可选的。此脚本将启动 IIS Express 上的 Web API 并发布 CodeGen.json 中的数据。
备注
根据您的 CI/CD 流程,您可以调整上述第 3 项和第 4 项。有关更多详细信息,请查看
生成客户端 API
运行 CreateWebApiClientApi3.ps1,生成的代码将写入 CoreWebApi.ClientApi。
数据模型和 API 函数
namespace DemoWebApi.Controllers.Client
{
/// <summary>
/// Complex hero type
/// </summary>
public class Hero : object
{
public long Id { get; set; }
public string Name { get; set; }
}
}
MAUI 客户端应用从生成的客户端 API 库中使用的大部分数据模型。
public partial class Heroes
{
private System.Net.Http.HttpClient client;
private JsonSerializerSettings jsonSerializerSettings;
public Heroes(System.Net.Http.HttpClient client,
JsonSerializerSettings jsonSerializerSettings=null)
{
if (client == null)
throw new ArgumentNullException("Null HttpClient.", "client");
if (client.BaseAddress == null)
throw new ArgumentNullException
("HttpClient has no BaseAddress", "client");
this.client = client;
this.jsonSerializerSettings = jsonSerializerSettings;
}
/// <summary>
/// DELETE api/Heroes/{id}
/// </summary>
public async Task DeleteAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Delete, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Get all heroes.
/// GET api/Heroes
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero[]> GetAsync()
{
var requestUri = "api/Heroes";
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Get a hero.
/// GET api/Heroes/{id}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> GetAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// POST api/Heroes?name={name}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> PostAsync(string name)
{
var requestUri = "api/Heroes?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Add a hero
/// POST api/Heroes/q?name={name}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero>
PostWithQueryAsync(string name)
{
var requestUri = "api/Heroes/q?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Update hero.
/// PUT api/Heroes
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> PutAsync
(DemoWebApi.Controllers.Client.Hero hero)
{
var requestUri = "api/Heroes";
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Put, requestUri))
{
using (var requestWriter = new System.IO.StringWriter())
{
var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
requestSerializer.Serialize(requestWriter, hero);
var content = new StringContent
(requestWriter.ToString(), System.Text.Encoding.UTF8, "application/json");
httpRequestMessage.Content = content;
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
/// <summary>
/// Search heroes
/// GET api/Heroes/search?name={name}
/// </summary>
/// <param name="name">keyword contained in hero name.</param>
/// <returns>Hero array matching the keyword.</returns>
public async Task<DemoWebApi.Controllers.Client.Hero[]> SearchAsync(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Search heroes
/// GET api/Heroes/search?name={name}
/// </summary>
/// <param name="name">keyword contained in hero name.</param>
/// <returns>Hero array matching the keyword.</returns>
public DemoWebApi.Controllers.Client.Hero[] Search(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = client.SendAsync(httpRequestMessage).Result;
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = responseMessage.Content.ReadAsStreamAsync().Result;
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
视图模型
视图模型包含在 Fonlow.Heroes.ViewModels.csproj 中。
Fonlow.HeroesVM.HeroesVM
将被多个视图使用。
namespace Fonlow.Heroes.VM
{
public class HeroesVM : INotifyPropertyChanged
{
public HeroesVM()
{
DeleteCommand = new Command<long>(DeleteHero);
SearchCommand = new Command<string>(Search);
}
public void Load(IEnumerable<Hero> items)
{
Items = new ObservableCollection<Hero>(items);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<Hero> Items { get; private set; }
public IEnumerable<Hero> Top4
{
get
{
if (Items == null)
{
return null;
}
return Items.Take(4);
}
}
Hero selected;
public Hero Selected
{
get { return selected; }
set
{
selected = value;
NotifyPropertyChanged("Selected");
NotifyPropertyChanged("AllowEdit");
}
}
public int Count
{
get
{
if (Items == null)
{
return 0;
}
return Items.Count;
}
}
public void NotifyPropertyChanged
([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public ICommand DeleteCommand { get; private set; }
public ICommand SearchCommand { get; private set; }
async void DeleteHero(long id)
{
var first = Items.FirstOrDefault(d => d.Id == id);
if (first != null)
{
if (first.Id == Selected?.Id)
{
Selected = null;
}
await HeroesFunctions.DeleteAsync(id);
Items.Remove(first);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
public bool AllowEdit
{
get
{
return Selected != null;
}
}
async void Search(string keyword)
{
var r = await HeroesFunctions.SearchAsync(keyword);
Items = new ObservableCollection<Hero>(r);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
}
视图
编辑
HeroDetailPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroDetailPage">
<ContentPage.Content>
<StackLayout>
<Label Text="{Binding Name, StringFormat='{0} Details'}"
VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" />
<Label Text="ID:"></Label>
<Entry Text="{Binding Id}" Placeholder="ID"></Entry>
<Label Text="Name:"></Label>
<Entry Text="{Binding Name}" Placeholder="Name"></Entry>
<Button Text="Save" Clicked="Save_Clicked"/>
</StackLayout>
</ContentPage.Content>
</ContentPage>
HeroDetailPage.xaml.cs (代码后台)
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroDetailPage : ContentPage
{
public HeroDetailPage(long heroId)
{
InitializeComponent();
BindingContext = VM.HeroesFunctions.LoadHero(heroId);
}
Hero Model
{
get
{
return BindingContext as Hero;
}
}
private async void Save_Clicked(object sender, EventArgs e)
{
await VM.HeroesFunctions.SaveAsync(Model);
}
}
}
英雄列表
HeroesView.xaml
<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroesView"
xmlns:vmNS="clr-namespace:Fonlow.Heroes.VM;
assembly=Fonlow.Heroes.ViewModels"
x:Name="heroesView"
>
<ContentView.BindingContext>
<vmNS:HeroesVM/>
</ContentView.BindingContext>
<ContentView.Content>
<StackLayout>
<Label Text="My Heroes"/>
<Entry Placeholder="New Hero Name" Completed="Entry_Completed"/>
<ListView x:Name="HeroesListView" ItemsSource="{Binding Items}"
Header="Selected Heroes" Footer="{Binding Count, StringFormat='Total: {0}'}"
SelectedItem="{Binding Selected}"
ItemSelected="HeroesListView_ItemSelected"
>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<Label Text="{Binding Id}" Grid.Column="0"
TextColor="Yellow" BackgroundColor="SkyBlue"/>
<Label Text="{Binding Name}" Grid.Column="1"/>
<Button x:Name="DeleteButton" Text="X" Grid.Column="2"
Command="{Binding Source={x:Reference heroesView},
Path=BindingContext.DeleteCommand}"
CommandParameter="{Binding Id}"
/>
</Grid>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Label Text="{Binding Selected.Name, StringFormat='{0} is my hero'}"/>
<Button Text="View Details" Clicked="Edit_Clicked"
IsEnabled="{Binding AllowEdit}"></Button>
</StackLayout>
</ContentView.Content>
</ContentView>
该视图绑定到视图模型 Fonlow.Heroes.VM.HeroesVM
,并且视觉组件绑定到视图模型的相应数据和函数。
HeroesView.xaml.cs
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
HeroesVM Model
{
get
{
return BindingContext as HeroesVM;
}
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
private void HeroesListView_ItemSelected
(object sender, SelectedItemChangedEventArgs e)
{
System.Diagnostics.Debug.WriteLine(e.SelectedItem == null);
}
private async void Entry_Completed(object sender, EventArgs e)
{
var text = ((Entry)sender).Text;
var hero= await HeroesFunctions.AddAsync(text);
Model.Items.Add(hero);
}
}
}
代码后台也可以访问视图模型。
导航
在 JavaScript SPA 库和框架中,导航通常被称为路由。
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
导航通常在代码后台中实现。
集成测试
由于“英雄之旅”的前端是一个胖客户端,因此大部分集成测试都是针对后端进行的。
using Fonlow.Testing;
using Xunit;
namespace IntegrationTests
{
public class HeroesFixture : DefaultHttpClient
{
public HeroesFixture()
{
Api = new DemoWebApi.Controllers.Client.Heroes(base.HttpClient);
}
public DemoWebApi.Controllers.Client.Heroes Api { get; private set; }
}
[Collection(TestConstants.IisExpressAndInit)]
public partial class HeroesApiIntegration : IClassFixture<HeroesFixture>
{
public HeroesApiIntegration(HeroesFixture fixture)
{
api = fixture.Api;
}
readonly DemoWebApi.Controllers.Client.Heroes api;
[Fact]
public async void TestGetAsyncHeroes()
{
var array = await api.GetAsync();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroes()
{
var array = api.Get();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroNotExists()
{
DemoWebApi.Controllers.Client.Hero h = api.Get(99999);
Assert.Null(h);
}
[Fact]
public void TestPost()
{
var hero = api.Post("Abc");
Assert.Equal("Abc", hero.Name);
}
[Fact]
public void TestPostWithQuery()
{
var hero = api.PostWithQuery("Xyz");
Assert.Equal("Xyz", hero.Name);
}
}
}
建议将生成的客户端 API 代码保留在其自己的 csproj 项目中,因为它具有以下优点:
- 便于为前端代码的不同层编写集成测试。
- 便于服务和客户端 API 代码的版本控制。
- 排除生成的代码免受领域特定静态代码分析的影响。
- 将生成的代码与手工编写的代码隔离开,这样您可以更准确地了解应用程序代码的大小和复杂性。
关注点
通过 WebApiClientGen
,客户端数据模型几乎 100% 与服务数据模型一一对应,因此您作为应用程序程序员将享受到 .NET 提供的丰富数据类型约束。例如,sbyte、byte、short、ushort、int、uint、long、ulong、nint 和 nuint 等整型也被映射到客户端数据类型。这对于构建企业应用程序来说是一大福音,因为 .NET 的设计时和运行时可以保护您。
在 .NET 编程中,WPF、Xamarin 和 MAUI 通过内置的 MVVM 架构提供了良好的程序员体验。在 Web 前端开发中,尤其是在 SPA 中,最接近的程序员体验就是通过 Angular 及其响应式表单。
Xamarin 与 MAUI
Xamarin 的支持将于 2024 年 5 月 1 日结束,涵盖所有 Xamarin SDK,包括 Xamarin.Forms。 Android API 34 和 Xcode 15 SDK(iOS 和 iPadOS 17、macOS 14)将是 Xamarin 从现有 Xamarin SDK 支持的最终版本(即,不计划支持新 API)。
该示例是通过将一个 Xamarin 应用迁移到 MAUI 来创建的。
区别
- 在 Xamarin 上,您需要为每个平台创建一个特定于平台的应用程序项目:Android、iOS 或 Windows。在 MAUI 上,通常只需要一个应用程序项目。
- 在
Xamarin.Forms
上,XAML 的默认命名空间是“http://xamarin.com/schemas/2014/forms
”。在 MAUI 上,是“http://schemas.microsoft.com/dotnet/2021/maui
”。但是,升级向导应该能够为您进行替换。 - 在 Xamarin 上,平台中立库应基于 .NET Standard 构建。在 MAUI 上,它是 .NET (Core)。但是,如果您有一些第三方组件仍然使用 .NET Standard,MAUI 可以很好地链接这些库。
历史
- 2023 年 12 月 9 日:初始版本