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

目录

背景

介绍

演示仓库

使用代码

先决条件

生成客户端API

数据模型和API函数

视图模型

视图

编辑

HeroDetailPage.xaml

HeroDetailPage.xaml.cs(代码后面)

英雄列表

HeroesView.xaml

HeroesView.xaml.cs

导航

集成测试

兴趣点

Xamarin与MAUI


背景

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

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

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

AngularAureliaReactVueXamarinMAUI上的前端应用通过生成的客户端API与相同的ASP.NETCore)后端通信。要查找同系列的其他文章,请在我的文章中搜索英雄之旅。在本系列的最后,将讨论程序员体验的一些技术因素:

  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

Mobile

此文件夹包含一个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包,并演示如何在实际项目中使用这些库。

使用代码

先决条件

  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));
    }

导航通常在后面的代码中实现。

集成测试

由于“Tour of Heroes”的前端是一个胖客户端,因此集成测试的很大一部分是针对后端的。

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编程中,WPFXamarinMAUI通过内置的MVVM体系结构提供不错的程序员体验。在Web前端开发中,尤其是使用SPA时,最接近程序员的体验是通过Angular及其Reactive Forms

XamarinMAUI

Xamarin支持将于 2024年5月1日结束,适用于所有Xamarin SDK,包括Xamarin.Forms。Android API 34Xcode 15 SDKiOSiPadOS 17macOS 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可以很好地链接这些库。

https://www.codeproject.com/Articles/5373931/Tour-of-Heroes-MAUI-with-ASP-NET-Core-8-Backend

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值