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

英雄之旅:Xamarin,带有 ASP.NET 后端

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2023年11月18日

CPOL

4分钟阅读

viewsIcon

6301

一系列文章比较了程序员在使用 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) 后端进行通信。要查找同一系列的其他文章,请在我的文章中搜索“Heroes”。系列文章的最后,将讨论一些程序员体验的技术因素。

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

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

参考文献

引言

本文重点介绍 Xamarin

开发平台

  1. ASP.NET 和 .NET Framework 4.8 上的 Web API
  2. Xamarin Forms

演示存储库

请在 GitHub 上查看 WebApiClientGenExamples,并关注以下领域。

DemoWebApi

ASP.NET Web API csproj 也包含一些 MVC 内容。

移动

此文件夹包含一个 Android 应用项目和一个 iOS 应用项目,两者都重新实现了“英雄之旅”的功能。这两个应用项目共享以下组件项目:

  1. Fonlow.Heroes.ViewModels
  2. Fonlow.Heroes.View
  3. DemoWebApi.ClientApiStandard

备注

WebApiClientGenExamples 是为测试 WebApiClientGen for .NET Framework and .NET standard 的 NuGet 包而建立的,并演示了如何在实际项目中使用的库。

Using the Code

必备组件

  1. DemoWebApi.csproj 导入了 NuGet 包 Fonlow.WebApiClientGen
  2. CodeGenController.cs 添加到 DemoWebApi.csproj
  3. DemoWebApi.csproj 包含 CodeGen.json。这是可选的,仅为方便运行某些 PowerShell 脚本生成客户端 API。
  4. CreateClientApi.ps1。这是可选的。此脚本将启动 IIS Express 上的 Web API 并发布 CodeGen.json 中的数据。

备注

根据您的 CI/CD 流程,您可以调整上述第 3 项和第 4 项。有关更多详细信息,请查看

生成客户端 API

运行 CreateClientApi.ps1,生成的代码将写入 DemoWebApi.ClientApiShared,该共享项目由 .NET Framework 库 DemoWebApi.ClientApi 和 .NET 标准库 DemoWebapi.ClientApiStandard 共享。

数据模型和 API 函数

namespace DemoWebApi.Controllers.Client
{       
    /// <summary>
    /// Complex hero type
    /// </summary>
    public class Hero : object
    {
        
        public long Id { get; set; }
        
        public string Name { get; set; }
    }
}

Xamarin 客户端应用中从生成的客户端 API 库 DemoWebApi.ClientApiStandard 使用的大多数数据模型。

    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://xamarin.com/schemas/2014/forms"
             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://xamarin.com/schemas/2014/forms" 
             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 库和框架中通常称为路由。

Xamarin.Forms 提供分层导航以及一些预定义导航体验

[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 及其 Reactive Forms 来实现的。

历史

  • 2023 年 11 月 18 日:初始版本
© . All rights reserved.