英雄之旅:Xamarin,带有 ASP.NET 后端
一系列文章比较了程序员在使用 Angular、Aurelia、React、Vue、Xamarin 和 MAUI 时的体验
背景
“英雄之旅”是 Angular 2+ 的官方教程应用程序。该应用程序包含一些功能特性和技术特性,这些特性在构建实际业务应用程序时很常见
- 几个屏幕展示了表格和嵌套数据
- 数据绑定
- 导航
- 对后端进行 CRUD 操作,并且可以选择通过生成的客户端 API 进行操作
- 单元测试和集成测试
在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个与后端通信的胖客户端。
Angular、Aurelia、React、Vue、Xamarin 和 MAUI 上的前端应用程序通过生成的客户端 API 与同一个 ASP.NET (Core) 后端进行通信。要查找同一系列的其他文章,请在我的文章中搜索“Heroes”。系列文章的最后,将讨论一些程序员体验的技术因素。
- 计算机科学
- 软件工程
- 学习曲线
- 构建大小
- 运行时性能
- 调试
选择开发平台涉及许多非技术因素,本系列文章将不讨论这些因素。
参考文献
引言
本文重点介绍 Xamarin。
开发平台
- ASP.NET 和 .NET Framework 4.8 上的 Web API
- Xamarin Forms
演示存储库
请在 GitHub 上查看 WebApiClientGenExamples,并关注以下领域。
DemoWebApi
ASP.NET Web API csproj 也包含一些 MVC 内容。
移动
此文件夹包含一个 Android 应用项目和一个 iOS 应用项目,两者都重新实现了“英雄之旅”的功能。这两个应用项目共享以下组件项目:
- Fonlow.Heroes.ViewModels
- Fonlow.Heroes.View
- DemoWebApi.ClientApiStandard
备注
WebApiClientGenExamples 是为测试 WebApiClientGen for .NET Framework and .NET standard 的 NuGet 包而建立的,并演示了如何在实际项目中使用的库。
Using the Code
必备组件
- DemoWebApi.csproj 导入了 NuGet 包 Fonlow.WebApiClientGen。
- 将CodeGenController.cs 添加到 DemoWebApi.csproj。
- DemoWebApi.csproj 包含 CodeGen.json。这是可选的,仅为方便运行某些 PowerShell 脚本生成客户端 API。
- CreateClientApi.ps1。这是可选的。此脚本将启动 IIS Express 上的 Web API 并发布 CodeGen.json 中的数据。
备注
根据您的 CI/CD 流程,您可以调整上述第 3 项和第 4 项。有关更多详细信息,请查看
- 为 ASP.NET Web API 生成 C# 客户端 API,如果您仍在使用 .NET Framework。
- 为 ASP.NET Core Web API 生成 C# 客户端 API,如果您正在使用 ASP.NET Core。
生成客户端 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 项目中,因为这样做有好处:
- 方便为前端代码的不同层编写集成测试。
- 方便服务和客户端 API 代码的版本管理。
- 将生成的代码排除在领域特定的静态代码分析之外。
- 将生成的代码与您手工编写的代码隔离,这样您就可以更准确地了解应用程序代码的大小和复杂性。
关注点
通过 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 日:初始版本


