Click here to Skip to main content
15,887,214 members
Articles / Mobile Apps / Xamarin

Tour of Heroes: Xamarin, with ASP.NET Backend

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
18 Nov 2023CPOL4 min read 5.2K   2  
A series of articles comparing programmer experiences of Angular, Aurelia, React, Vue, Xamarin and MAUI
"Tour of Heroes" on Xamarin, talking to a real backend through generated client API

Background

"Tour of Heroes" is the official tutorial app of Angular 2+. The app contains some functional features and technical features which are common when building a real-world business application:

  1. A few screens presenting tables and nested data
  2. Data binding
  3. Navigation
  4. CRUD operations over a backend, and optionally through a generated client API
  5. Unit testing and integration testing

In this series of articles, I will demonstrate the programmer experiences of various frontend development platforms when building the same functional features: "Tour of Heroes", a fat client talking to a backend.

The frontend apps on Angular, Aurelia, React, Vue, Xamarin and MAUI are talking to the same ASP.NET (Core) backend through generated client APIs. To find the other articles in the same series, please search "Heroes" in my articles. And at the end of the series, some technical factors of programmer experiences will be discussed:

  1. Computing science
  2. Software engineering
  3. Learning curve
  4. Build size
  5. Runtime performance
  6. Debugging

Choosing a development platform involves a lot of non-technical factors which won't be discussed in this series.

References

Introduction

This article is focused on Xamarin.

Development platforms:

  1. Web API on ASP.NET and .NET Framework 4.8
  2. Xamarin Forms

Demo Repository

Checkout WebApiClientGenExamples in GitHub, and focus on the following areas:

DemoWebApi

ASP.NET Web API csproj also contains some MVC stuff.

Mobile

This folder contains an Android app project and an iOS app project, and both reassemble the functionality of "Tour of Heroes". These two app projects share the following component projects:

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

Remarks

WebApiClientGenExamples was established for testing NuGet packages of WebApiClientGen for .NET Framework and .NET standard, and demonstrating how to use the libraries in real world projects.

Using the Code

Prerequisites

  1. DemoWebApi.csproj has NuGet package Fonlow.WebApiClientGen imported.
  2. Add CodeGenController.cs to DemoWebApi.csproj
  3. DemoWebApi.csproj has CodeGen.json. This is optional, just for the convenience of running some PowerShell script to generate client APIs.
  4. CreateClientApi.ps1. This is optional. This script will launch the Web API on IIS Express and post the data in CodeGen.json.

Remarks

Depending on your CI/CD process, you may adjust item 3 and 4 above. For more details, please check:

Generate Client API

Run CreateClientApi.ps1, the generated codes will be written to DemoWebApi.ClientApiShared which is shared by .NET Framework library DemoWebApi.ClientApi and .NET standard library DemoWebapi.ClientApiStandard.

Data Models and API Functions

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

Most data models used in the Xamarin client app from the generated client API library DemoWebApi.ClientApiStandard.

C#
    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();
            }
            }
        }
    }

View Models

View Models are contained in Fonlow.Heroes.ViewModels.csproj.

Fonlow.HeroesVM.HeroesVM will be utilized by multiple views.

C#
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");
        }
    }
}

Views

Editing

HeroDetailPage.xaml

XML
<?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 (Codes Behind)

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

Heroes List

HeroesView.xaml

XML
<?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>

The view is bound to view model Fonlow.Heroes.VM.HeroesVM, and the visual components have bindings to respective the data and the functions of the view model.

HeroesView.xaml.cs

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

And the codes behind can access to the view model too.

Navigation

Navigation is often called routing in JavaScript SPA libraries and frameworks.

Xamarin.Forms provide hierarchical navigation with some predefined navigation experiences.

C#
[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));
    }

Navigations are generally implemented inside codes behind.

Integration Testing

Since the frontend of "Tour of Heroes" is a fat client, significant portion of the integration testing is against the backend.

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

It is recommended the generated client API codes stay in its own csproj project, because of benefits:

  1. Convenient for crafting integration testing for different layers of the frontend codes.
  2. Convenient for versioning of the services and the client API codes.
  3. Exclude the generated codes from domain specific static code analysis.
  4. Isolated the generated codes from your hand-crafted codes, so you may have more accurate idea about the size and the complexity of your application codes.

Points of Interest

Through WebApiClientGen, the client data models are almost 100% one to one mapping from the service data models, thus you as an application programmer will enjoy rich data type constraints provided by .NET. For example, numeric types sbyte, byte, short, ushort, int, uint, long, ulong, nint and nuint are also mapped to client data types. This is a blessing to building enterprise applications, since .NET design time and runtime can guard you.

In .NET programming, WPF, Xamarin and MAUI provide decent programmer experience through built-in MVVM architecture. In Web frontend development, particularly with SPA, the closest programmer experience that you could get is through Angular and its Reactive Forms.

History

  • 18th November, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Australia Australia
I started my IT career in programming on different embedded devices since 1992, such as credit card readers, smart card readers and Palm Pilot.

Since 2000, I have mostly been developing business applications on Windows platforms while also developing some tools for myself and developers around the world, so we developers could focus more on delivering business values rather than repetitive tasks of handling technical details.

Beside technical works, I enjoy reading literatures, playing balls, cooking and gardening.

Comments and Discussions

 
-- There are no messages in this forum --