65.9K
CodeProject 正在变化。 阅读更多。
Home

英雄之旅:MAUI,带 ASP.NET Core 8 后端

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2023年12月8日

CPOL

4分钟阅读

viewsIcon

7466

一系列文章比较了程序员在使用 Angular、Aurelia、React、Vue、Xamarin 和 MAUI 时的体验

背景

“英雄之旅”是 Angular 2+ 的官方教程应用程序。该应用程序包含一些功能特性和技术特性,这些特性在构建实际业务应用程序时很常见

  1. 几个屏幕展示了表格和嵌套数据
  2. 数据绑定
  3. 导航
  4. 对后端进行 CRUD 操作,并且可以选择通过生成的客户端 API 进行操作
  5. 单元测试和集成测试

在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个与后端通信的胖客户端。

Angular、Aurelia、React、Vue、Xamarin 和 MAUI 的前端应用都通过生成的客户端 API 与同一个 ASP.NET (Core) 后端进行通信。要查找同一系列的其他文章,请在我的文章中搜索“英雄之旅”。在本系列文章的最后,将讨论一些程序员体验的技术因素。

  1. 计算机科学
  2. 软件工程
  3. 学习曲线
  4. 构建大小
  5. 运行时性能
  6. 调试

选择开发平台涉及许多非技术因素,本系列文章将不讨论这些因素。

参考文献

引言

本文重点介绍 MAUI

开发平台

  1. ASP.NET Core 8
  2. .NET 多平台应用 UI

演示存储库

在 GitHub 上查看 DemoCoreWeb ,并关注以下领域

Core3WebApi

ASP.NET Core Web API csproj 只提供 Web API。

移动

此文件夹包含一个 MAUI 应用程序 (Fonlow.Heroes.Maui.csproj),它重新实现了“英雄之旅”的功能。

  1. Fonlow.Heroes.Maui
  2. Fonlow.Heroes.ViewModels
  3. Fonlow.Heroes.View
  4. CoreWebApi.ClientApi

备注

DemoCoreWeb 是为测试 WebApiClientGen for .NET 的 NuGet 包而创建的,并演示如何在实际项目中使用这些库。

Using the Code

必备组件

  1. Core3WebApi.csproj 导入了 NuGet 包 Fonlow.WebApiClientGenCore
  2. CodeGenController.cs 添加到 Core3WebApi.csproj
  3. Core3WebApi.csproj 包含 CodeGen.json。这是可选的,仅为方便运行一些 PowerShell 脚本来生成客户端 API。
  4. 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 项目中,因为它具有以下优点:

  1. 便于为前端代码的不同层编写集成测试。
  2. 便于服务和客户端 API 代码的版本控制。
  3. 排除生成的代码免受领域特定静态代码分析的影响。
  4. 将生成的代码与手工编写的代码隔离开,这样您可以更准确地了解应用程序代码的大小和复杂性。

关注点

通过 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 来创建的。

区别

  1. 在 Xamarin 上,您需要为每个平台创建一个特定于平台的应用程序项目:Android、iOS 或 Windows。在 MAUI 上,通常只需要一个应用程序项目。
  2. Xamarin.Forms 上,XAML 的默认命名空间是“http://xamarin.com/schemas/2014/forms”。在 MAUI 上,是“http://schemas.microsoft.com/dotnet/2021/maui”。但是,升级向导应该能够为您进行替换。
  3. 在 Xamarin 上,平台中立库应基于 .NET Standard 构建。在 MAUI 上,它是 .NET (Core)。但是,如果您有一些第三方组件仍然使用 .NET Standard,MAUI 可以很好地链接这些库。

历史

  • 2023 年 12 月 9 日:初始版本
© . All rights reserved.