Pokédex – Kleines Xamarin.Forms Projekt – Teil 2

Im letzten Beitrag haben wir bereits begonnen unsere Pokédex-App zu erstellen und in diesem Beitrag geht es nun mit der Entwicklung weiter. In diesem Beitrag wollen wir nun die ersten Services und auch die ersten Calls in Richtung der öffentliche API machen und diese Informationen in der App anzeigen.

Den bisherigen Stand könnt ihr über den folgenden Download-Button herunterladen.

Wir öffnen nun unser Projekt und erstellen eine neuen Ordner mit dem Namen Common. Darin legen wir eine statische Klasse mit dem Namen Statics an. In dieser Datei wollen wir Informationen, wie zum Beispiel den Endpunkt zur API oder auch verschiedene magische Strings, welche wir an verschiedenen Stellen in der App nutzen können.

public static class Statics
{
    public static string BaseUrl = "https://pokeapi.co/api/v2/";

    public static string TypeUri = "type";


    public static string PokemonTypesId = nameof(PokemonTypesId);
    public static string PokemonTypeCollectionName = nameof(PokemonTypeCollectionName);
}

In diesem Beitrag wollen wir die verschiedenen Typen von Pokémons abrufen. Dazu erstellen wir zunächst einen neuen Ordner Interfaces, wo wir für unsere verschiedenen Services die passen Interfaces ablegen. Beginnen wollen wir mit dem Interface für einen NetworkService mit dem Namen INetworkService. Dieser soll uns zurückgeben, ob wir über eine aktive Internetverbindung verfügen. Daher stellt der Service eine Property HasInternetAccess zur Verfügung, welche abgerufen werden kann.

public interface INetworkService
{
    bool HasInternetAccess { get; }
}

Nun erstellen wir noch den Ordner Services und darin die Klasse NetworkService, welche das gerade angelegte INetworkService-Interface implementiert. Wir nutzen hier die Connectivity-Klasse aus dem Xamarin Essentials Package, welche uns Informationen über die bestehende Netzwerkverbindung liefert.

public class NetworkService : INetworkService
{
    public bool HasInternetAccess 
            => Connectivity.NetworkAccess == NetworkAccess.Internet;
}

Der nächste Service soll der UriBuilderService werden, welcher uns für die verschiedenen Endpunkte die passende URL liefert. Dafür erstellen wir das Interface IUriBuilderService im Ordner Interfaces. Auch dieses Interface ist zum jetzigen Zeitpunkt noch recht übersichtlich und beinhaltet nur eine Methode, um den Endpunkt zum Abrufen der Pokémon Typen zu liefern.

public interface IUriBuilderService
{
    string GetPokemonTypesUri();
}

Kümmern wir uns jetzt gleich um die passende Implementierung. Dazu erstellen wir die Klasse UriBuilderService im Ordner Services. Wir nutzen hier die beiden „magischen“ Strings aus der Statics-Klasse, um den passenden Endpunkt zu erstellen.

public class UriBuilderService : IUriBuilderService
{
    public string GetPokemonTypesUri()
    {
        return $"{Statics.BaseUrl}{Statics.TypeUri}";
    }
}

Ich habe euch ja bereits verraten, dass ich die Daten von der API gerne in einer lokalen Datenbank speichern möchte. Dafür haben wir ja auch schon das NuGet-Package LiteDB zu unserem Projekt hinzugefügt. Daher wollen wir jetzt noch einen DatabaseService schreiben. Dieser stellt zum jetzigen Zeitpunkt drei Methoden bereit. Eine Methode zum Abrufen der Daten, eine Methode zum Löschen der Daten und eine dritte Methode um einen Eintrag in der Datenbank abzulegen. Wir erstellen das Interface IDatabaseService im Ordner Interfaces.

public interface IDatabaseService
{
    T GetById<T>(string id, string collectionName);
    bool DeleteById<T>(string id, string collectionName);
    bool Upsert<T>(T item, string collectionName);
}

Die Implementierung erstellen wir in der Klasse DatabaseService im Ordner Services. Hierfür nutzen wir das LiteDB-Package, um eine Datenbank zu erstellen und entsprechend mit den Daten zu arbeiten.

public class DatabaseService : IDatabaseService
{
    protected LiteDatabase _database;

    public T GetById<T>(string id, string collectionName)
    {
        Init();

        // get collection
        var collection = _database.GetCollection<T>(collectionName);

        // get item
        return collection.FindById(id);
    }

    public bool DeleteById<T>(string id, string collectionName)
    {
        Init();

        // get collection
        var collection = _database.GetCollection<T>(collectionName);

        // delete items
        var result = collection.Delete(id);

        return result;
    }

    public bool Upsert<T>(T item, string collectionName)
    {
        try
        {
            Init();

            // get collection
            var collection = _database.GetCollection<T>(collectionName);

            // insert or update items
            return collection.Upsert(item);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(Upsert)} | {ex}");
        }

        return false;
    }

    private void Init()
    {
        try
        {
            if (_database == null)
                _database = new LiteDatabase($"Filename={GetDatabasePath()}");
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(Init)} | {ex}");
        }
    }

    private string GetDatabasePath()
    {
        return Path.Combine(FileSystem.AppDataDirectory, "PokemonDatabase.db");
    }
}

Bevor wir den letzten Service in diesem Beitrag erstellen, erstellen wir eine Model-Klasse. Dafür erstellen wir einen neuen Ordner Models und erstellen darin die Klasse PokemonType. Hierbei handelt es sich um eine Repräsentation der JSON-Antwort von der API. Ich habe aber auch noch ein passendes Datenbank-Objekt angelegt, denn jeder Eintrag muss über eine eindeutige ID verfügen.

public class PokemonTypeRootObject
{
    [JsonProperty("results")]
    public PokemonType[] Types { get; set; }
}

public class PokemonType
{
    public string Name { get; set; }
    public string Url { get; set; }
}

public class PokemonTypesDatabaseObject
{
    [BsonId]
    public string Id { get; } = Statics.PokemonTypesId;
    public IList<string> PokemonTypes { get; set; }
}

Nun erstellen wir den PokemonService, welcher die Kommunikation mit der API übernimmt und zu Beginn nur über eine Methode zum Abrufen der Pokémon-Typen. Wir erstellen also das Interface IPokemonService im Ordner Interfaces.

public interface IPokemonService
{
    Task<IList<string>> GetPokemonTypesAsync();
}

Die Implementierung erfolgt als PokemonService im Ordner Services. Dieser Service nutzt nun den DatabaseService, den NetworkService und auch den UriBuilderService per Dependency Injection.

public class PokemonService : IPokemonService
{
    private readonly IDatabaseService _databaseService;
    private readonly INetworkService _networkService;
    private readonly IUriBuilderService _uriBuilderService;

    public PokemonService(IDatabaseService databaseService, INetworkService networkService, IUriBuilderService uriBuilderService)
    {
        _databaseService = databaseService;
        _networkService = networkService;
        _uriBuilderService = uriBuilderService;
    }

    public async Task<IList<string>> GetPokemonTypesAsync()
    {
        try
        {
            var localData = _databaseService.GetById<PokemonTypesDatabaseObject>(Statics.PokemonTypesId, Statics.PokemonTypeCollectionName);
            if (localData != null)
                return localData.PokemonTypes;

            if (!_networkService.HasInternetAccess)
                return null;

            var uri = _uriBuilderService.GetPokemonTypesUri();
            var httpClient = new HttpClient();

            var response = await httpClient.GetAsync(uri);

            if (response.IsSuccessStatusCode)
            {
                var jsonResponse = await response.Content.ReadAsStringAsync();
                var pokemonTypeRootObject = JsonConvert.DeserializeObject<PokemonTypeRootObject>(jsonResponse);

                var pokemonTypes = pokemonTypeRootObject?.Types?.Select(x => x.Name).ToList();
                _databaseService.Upsert(new PokemonTypesDatabaseObject { PokemonTypes = pokemonTypes }, Statics.PokemonTypeCollectionName);
                return pokemonTypes;
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"{GetType().Name} | {nameof(GetPokemonTypesAsync)} | {ex}");
        }

        return null;
    }
}

Für diesen Beitrag sind nun alle notwendigen Services implementiert. Daher kümmern wir uns jetzt um die ViewModels. Beginnen wollen wir mit einem BaseViewModel, welches wir im Ordner ViewModels erstellen wollen. Dieses implementiert das INotifyPropertyChanged-Interface und stellt eine Property IsLoading bereit, da diese in allen ViewModels benötigt werden.

public class BaseViewModel : INotifyPropertyChanged
{
    private bool _isLoading;
    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }


    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual bool SetProperty<T>(ref T backingStore, 
        T value, [CallerMemberName] string propertyName = "")
    {
        if (EqualityComparer<T>.Default.Equals(backingStore, value))
            return false;

        backingStore = value;
        OnPropertyChanged(propertyName);

        return true;
    }

    protected void OnPropertyChanged(
        [CallerMemberName] string propertyName = "")
        => PropertyChanged?.Invoke(this, 
            new PropertyChangedEventArgs(propertyName));
}

Im gleichen Ordner ViewModels erstellen wir nun noch das OverviewPageViewModel. Dieses wollen wir später als BindingContext für unsere im letzten Beitrag angelegte OverviewPage verwenden. Das ViewModel stellt nun eine Property für die verschiedenen Pokémon-Types, sowie einen Command zum Abrufen der Daten bereit.

public class OverviewPageViewModel : BaseViewModel
{
    private readonly IPokemonService _pokemonService;

    private IList<string> _pokemonTypes;
    public IList<string> PokemonTypes
    {
        get => _pokemonTypes;
        set => SetProperty(ref _pokemonTypes, value);
    }

    private IAsyncCommand _getPokemonTypesCommand;
    public IAsyncCommand GetPokemonTypesCommand
        => _getPokemonTypesCommand
        ?? (_getPokemonTypesCommand = new AsyncCommand(GetPokemonTypesAsync, allowsMultipleExecutions: false));


    public OverviewPageViewModel(IPokemonService pokemonService)
    {
        _pokemonService = pokemonService;
    }

    private async Task GetPokemonTypesAsync()
    {
        IsLoading = true;

        PokemonTypes = await _pokemonService.GetPokemonTypesAsync();

        IsLoading = false;
    }

}

Damit die bereits verwendete Dependency Injection auch funktioniert, benötigen wir noch einen Bootstrapper. Hierfür erstellen wir einen neuen Ordner Init und erstellen darin die statische Klasse Bootstrapper. Dieser greift auf das DependencyInjection-Package von Microsoft zu und wir registrieren hier unserer Services und ViewModels.

public static class Bootstrapper
{
    public static IServiceProvider ServiceProvider { get; set; }

    public static IServiceProvider Init()
    {
        var serviceProvider = new ServiceCollection()
            .ConfigureServices()
            .ConfigureViewModels()
            .BuildServiceProvider();

        ServiceProvider = serviceProvider;

        return serviceProvider;
    }

    public static IServiceCollection ConfigureServices(this IServiceCollection services)
    {
        services.AddSingleton<IDatabaseService, DatabaseService>();
        services.AddSingleton<INetworkService, NetworkService>();
        services.AddSingleton<IPokemonService, PokemonService>();
        services.AddSingleton<IUriBuilderService, UriBuilderService>();

        return services;
    }

    public static IServiceCollection ConfigureViewModels(this IServiceCollection services)
    {
        services.AddSingleton<OverviewPageViewModel>();

        return services;
    }
}

Damit wir die ViewModels im XAML-Code binden können, erstellen wir noch eine Klasse ViewModelLocator, welcher uns das passende ViewModel aus dem ServiceProvider zurückliefert.

public class ViewModelLocator
{
    public OverviewPageViewModel OverviewPageViewModel
        => Bootstrapper.ServiceProvider.GetService<OverviewPageViewModel>();
}

Wir öffnen nun die Datei App.xaml und registrieren hier unseren ViewModelLocator.

<?xml version="1.0" encoding="utf-8" ?>
<Application xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:init="clr-namespace:Pokedex.Init"
             xmlns:styles="clr-namespace:Pokedex.Styles"
             x:Class="Pokedex.App">

    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <styles:Styles />
            </ResourceDictionary.MergedDictionaries>

            <init:ViewModelLocator x:Key="ViewModelLocator" />
        </ResourceDictionary>
    </Application.Resources>

</Application>

Weiter geht es mit der Datei App.xaml.cs. Hier müssen wir nach dem LocalizationResourceManager nun auch noch unseren Bootstrapper initialisieren.

public App()
{
    InitializeComponent();

    LocalizationResourceManager.Current.Init(AppResources.ResourceManager);

    Bootstrapper.Init();

    MainPage = new NavigationPage(new OverviewPage());
}

Die Businesslogik ist nun tatsächlich fertig. Daher kümmern wir uns nun um unsere OverviewPage. Hier wollen wir nun einen Button bereitstellen, über den man die Pokémon-Typen abrufen kann. Außerdem nutzen wir noch eine CollectionView, um das Ergebnis entsprechend anzuzeigen.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:xct="http://xamarin.com/schemas/2020/toolkit"
             x:Class="Pokedex.Views.OverviewPage"
             Title="{xct:Translate OverviewPageTitle}"
             BindingContext="{Binding OverviewPageViewModel, 
                Source={StaticResource ViewModelLocator}}">

    <ContentPage.Content>
        <Grid RowDefinitions="Auto,*">
            <Button Text="Get Pokémon Types"
                    Command="{Binding GetPokemonTypesCommand}"
                    Grid.Row="0" />

            <CollectionView ItemsSource="{Binding PokemonTypes}"
                            Grid.Row="1">
                <CollectionView.ItemsLayout>
                    <GridItemsLayout HorizontalItemSpacing="12"
                                     VerticalItemSpacing="12"
                                     Orientation="Vertical"
                                     Span="2" />
                </CollectionView.ItemsLayout>
            </CollectionView>
        </Grid>
    </ContentPage.Content>

</ContentPage>

Wir öffnen nun noch kurz die Styles-Datei, um einen Style für die ContentPage zu erstellen. Hier wollen wir nämlich das Visual auf Material setzen und das Padding auf 16.

<Style TargetType="ContentPage"
       ApplyToDerivedTypes="True">
    <Setter Property="Visual"
            Value="Material" />
    <Setter Property="Padding"
            Value="16" />
</Style>

Das Ergebnis zeige ich euch hier einmal in Form von der UWP-Version.

Der folgende Screenshot zeigt nun noch einmal den generellen Aufbau unserer Solution.

Damit sind wir auch am Ende des heutigen Beitrags. Wir haben die wichtigsten Services bereits angelegt und werden diese natürlich in den kommenden Beiträgen noch erweitern bzw. weitere Services erstellen. Den aktuellen Stand könnt ihr über den folgenden Download-Button herunterladen.

BindableLayout: Ersatz für die RepeaterView Xamarin.Forms: Per Return zum nächsten Entry wechseln Lokalisierung einer Xamarin.Forms App mit dem Xamarin Community Toolkit