EF Core mit Xamarin

Entity Framework Core unter Xamarin.Forms

Versionsgeschichte
Version Aktualisierungsdatum Änderungen
0.2 09.12.2017 VS 2017 15.5 Anpassungen
0.1 05.11.2017 (Erste Fassung)

Mit Xamarin.Forms 2.4 ist Xamarin nun .Net Core kompatibel (sogar zu Version 2.0). Das größte Manko, meiner Meinung nach, war bis jetzt die Handhabung der lokalen Daten in einer Datenbank (meist SQLite). Die vorhandenen Bibliotheken (SQLite.Net-PCL und sqlite-net-pcl) sind leider alles andere als entwicklerfreundlich. Zumindest für C#-Entwickler, die vom Desktop-System kommen und Entity Framework 6, NHibernate und weitere kennen.

Nun gibt es mit Entity Framework Core eine Alternative für Xamarin. Der Einstieg ist zwar nicht ganz so einfach, wie mit Entity Framework 6, aber es lohnt sich. Vor allem fehlen bei .Net Core aktuell die Tools (insbesondere visuelle) für die Generierung / Visualisierung und Migration. Das meiste muss über die Kommandozeile gelöst werden.

Als Basis nutze ich mein anderes Tutorial, der bereits Xamarin.Forms und .Net Standard behandelt (Xamarin.Forms mit .Net Standard 2.0).

1. Schritt - Pfad zu der Datenbank definieren

Jedes Betriebssystem speichert seine privaten Daten an einer anderen Stelle. Aus diesem Grund kann diese Information, wo die Datenbank gespeichert wird, nur vom App-Projekt kommen, und nicht aus dem geteilten .net Standard Projekt.

Dazu legen wir ein Interface in unserem Core-Projekt, der in den jeweiligen App-Projekten umgesetzt wird (Umsetzung entsprich dem Standard bei Xamarin).

IAppPathService.cs

namespace XamarinFormsEfCore.Core.Services
{
    public interface IAppPathService
    {
        string GetDatabasePath(string databaseName);
    }
}

AndroidPathService.cs

using System.IO;
using XamarinFormsEfCore.Core.Services;

[assembly: Xamarin.Forms.Dependency(typeof(XamarinFormsEfCore.Android.Services.AndroidPathService))]
namespace XamarinFormsEfCore.Android.Services
{
    class AndroidPathService : IAppPathService
    {
        private string _dbPath;

        public string GetDatabasePath(string databaseName)
        {
            if (string.IsNullOrEmpty(_dbPath))
            {
                string path = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal);
                _dbPath = Path.Combine(path, "databases");

                if (!Directory.Exists(_dbPath))
                {
                    Directory.CreateDirectory(_dbPath);
                }
            }

            return Path.Combine(_dbPath, databaseName);
        }
    }
}

AndroidinItializer

using Prism;
using Prism.Ioc;
using XamarinFormsEfCore.Android.Services;
using XamarinFormsEfCore.Core.Services;

namespace XamarinFormsEfCore.Android
{
    class AndroidInitializer : IPlatformInitializer
    {
        public void RegisterTypes(IContainerRegistry containerRegistry)
        {
            // Register OS spezific services here
            containerRegistry.RegisterSingleton<IAppPathService, AndroidPathService>();
        }
    }
}

IosPathService.cs

using System;
using System.IO;
using XamarinFormsEfCore.Core.Services;

[assembly: Xamarin.Forms.Dependency(typeof(XamarinFormsEfCore.iOS.Services.IosPathService))]
namespace XamarinFormsEfCore.iOS.Services
{
    class IosPathService : IAppPathService
    {
        private string _dbPath;

        public string GetDatabasePath(string databaseName)
        {
            if (string.IsNullOrEmpty(_dbPath))
            {
                var docFolder = Environment.GetFolderPath(Environment.SpecialFolder.Personal);
                _dbPath = Path.Combine(docFolder, "..", "Library", "Databases");

                if (!Directory.Exists(_dbPath))
                {
                    Directory.CreateDirectory(_dbPath);
                }
            }

            return Path.Combine(_dbPath, databaseName);
        }
    }
}

IosInitializer

using Prism;
using Prism.Ioc;
using XamarinFormsEfCore.Core.Services;
using XamarinFormsEfCore.iOS.Services;

namespace XamarinFormsEfCore.iOS
{
    class IosInitializer : IPlatformInitializer
    {
        public void RegisterTypes(IContainerRegistry containerRegistry)
        {
            // Register OS spezific services here
            containerRegistry.RegisterSingleton<IAppPathService, IosPathService>();
        }
    }
}

UwpPathService.cs

using System.IO;
using Windows.Storage;
using XamarinFormsEfCore.Core.Services;

[assembly: Xamarin.Forms.Dependency(typeof(XamarinFormsEfCore.UWP.Services.UwpPathService))]
namespace XamarinFormsEfCore.UWP.Services
{
    public class UwpPathService : IAppPathService
    {
        private string _dbPath;

        public string GetDatabasePath(string databaseName)
        {
            if (string.IsNullOrEmpty(_dbPath))
            {
                var privatePath = ApplicationData.Current.LocalFolder.Path;
                _dbPath = Path.Combine(privatePath, "databases");

                if (!Directory.Exists(_dbPath))
                {
                    Directory.CreateDirectory(_dbPath);
                }
            }

            return Path.Combine(_dbPath, databaseName);
        }
    }
}

UwpInitializer

using Prism;
using Prism.Ioc;
using XamarinFormsEfCore.Core.Services;
using XamarinFormsEfCore.UWP.Services;

namespace XamarinFormsEfCore.UWP
{
    class UwpInitializer : IPlatformInitializer
    {
        public void RegisterTypes(IContainerRegistry containerRegistry)
        {
            // Register OS spezific services here
            containerRegistry.RegisterSingleton<IAppPathService, UwpPathService>();
        }
    }
}

MainViewModel.cs

using Prism.Commands;
using Prism.Mvvm;
using Prism.Navigation;
using System.Threading.Tasks;
using System.Windows.Input;
using XamarinFormsEfCore.Core.Services;
using XamarinFormsEfCore.UI.Views;

namespace XamarinFormsEfCore.UI.ViewModels
{
    public class MainViewModel : BindableBase
    {
        private readonly INavigationService _navService;
        private readonly IAppPathService _pathService;

        public MainViewModel(INavigationService navigationService, IAppPathService pathService)
        {
            _navService = navigationService;
            _pathService = pathService;

            // Commands
            NextPageCommand = new DelegateCommand(async () => await NextPage());

            Title = _pathService.GetDatabasePath("list.db");
        }

        private string _title = "Hallo from Prism.Forms!";
        public string Title
        {
            get => _title;
            set => SetProperty(ref _title, value);
        }

        public ICommand NextPageCommand { get; }
        private async Task NextPage()
        {
            await _navService.NavigateAsync(nameof(SecondPage));
        }
    }
}

Nach dem Start der App, können Sie bereits den App spezifischen Ordner für die Datenbanken sehen. Durch die Abstraktion mit einem Service-Interface, ist später auch ein UnitTest auf dem Entwicklerrechner unproblematisch möglich.

2. Schritt - Definition des Datenbank-Kontext

Legen Sie in dem Core-Projekt drei Modelle an, die eine m-n Beziehung beschreiben:

  • Category: Kategorie mit
    • Id
    • Name
  • Note: Notitz mit
    • Id
    • Title
    • Message
    • AssignedCategories
  • AssignedCategory: Dient für m-n Beziehung, da EF Core das (noch) nicht kann
    • NoteId
    • Note
    • CategoryId
    • Category

Category.cs

namespace XamarinFormsEfCore.Core.Models
{
    public class Category
    {
        public long Id { get; set; }
        public string Name { get; set; }
    }
}

Note.cs

using System.Collections.Generic;

namespace XamarinFormsEfCore.Core.Models
{
    public class Note
    {
        public long Id { get; set; }
        public string Title { get; set; }
        public string Message { get; set; }
        public ICollection<AssignedCategory> AssignedCategories { get; set; } = new List<AssignedCategory>();
    }
}

AssignedCategory.cs

namespace XamarinFormsEfCore.Core.Models
{
    public class AssignedCategory
    {
        public long NoteId { get; set; }
        public Note Note { get; set; }
        public long CategoryId { get; set; }
        public Category Category { get; set; }
    }
}

Aus diesen Modellen kann nun der Datenbankkontext im Repository-Projekt erstellt werden. Installieren Sie dazu das NuGet-Package Microsoft.EntityFrameworkCore.Sqlite in die App-, Repository- und UI-Projekte (in UI Projekt ist es notwendig, da wir hier die Registrierung der Datentypen für Dependency-Injection vornehmen).

NoteContext.cs

using Microsoft.EntityFrameworkCore;
using XamarinFormsEfCore.Core.Models;
using XamarinFormsEfCore.Core.Services;

namespace XamarinFormsEfCore.Repos
{
    public class NoteContext : DbContext
    {
        private const string _DB_NAME = "notes.db";
        private readonly IAppPathService _pathService;

        public NoteContext(IAppPathService pathService)
        {
            _pathService = pathService;
            // Sicherstellen, dass die Datenbank erstellt wurde
            Database.EnsureCreated();
        }

        public DbSet<Note> Notes { get; set; }
        public DbSet<Category> Categories { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite($"Data Source={_pathService.GetDatabasePath(_DB_NAME)}");
            base.OnConfiguring(optionsBuilder);
        }
    }
}

In dem UI-Projekt kann nun der Kontext registriert und genutzt werden.

Der Start der Apps scheitert aber zuerst. Es fehlen noch folgende Daten:

  1. Für die m-n Klasse fehlt eine Konfiguration, Welche Spalte(n) als Primary Key betrachtet werden sollen. EF benötigt immer eine Key-Spalte und keine unserer Eigenschaften entspricht den Konventionen dazu.
  2. Initialisierung der SQLite-Komponente.

Konfiguration des Context

Legen wir drei neue Klassen, die die Konfiguration für die einzelnen Modelle übernehmen. Es ist zwar möglich, auch alles in der Context-Klasse direkt zu definieren, bei mehreren Tabellen wird es aber sehr unübersichtlich.

NoteConfiguration.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using XamarinFormsEfCore.Core.Models;

namespace XamarinFormsEfCore.Repos.Configurations
{
    class NoteConfiguration : IEntityTypeConfiguration<Note>
    {
        public void Configure(EntityTypeBuilder<Note> builder)
        {
            // Key
            builder.HasKey(t => t.Id);
            builder.Property(t => t.Id)
                .HasColumnName("_id")
                .ValueGeneratedOnAdd();

            // Spalten
            builder.Property(t => t.Title)
                .HasColumnName("title")
                .HasMaxLength(50)
                .IsRequired();
            builder.Property(t => t.Message)
                .HasColumnName("message")
                .IsRequired();
        }
    }
}

CategoryConfiguration.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using XamarinFormsEfCore.Core.Models;

namespace XamarinFormsEfCore.Repos.Configurations
{
    class CategoryConfiguration : IEntityTypeConfiguration<Category>
    {
        public void Configure(EntityTypeBuilder<Category> builder)
        {
            // Key
            builder.HasKey(t => t.Id);
            builder.Property(t => t.Id)
                .HasColumnName("_id")
                .ValueGeneratedOnAdd();

            // Spalten
            builder.Property(t => t.Name)
                .HasColumnName("name")
                .HasMaxLength(25)
                .IsRequired();
            builder.HasIndex(t => t.Name)
                .IsUnique();
        }
    }
}

AssignedCategoryConfiguration.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using XamarinFormsEfCore.Core.Models;

namespace XamarinFormsEfCore.Repos.Configurations
{
    class AssignedCategoryConfiguration : IEntityTypeConfiguration<AssignedCategory>
    {
        public void Configure(EntityTypeBuilder<AssignedCategory> builder)
        {
            // Key
            builder.HasKey(t => new { t.NoteId, t.CategoryId });
            builder.Property(t => t.NoteId)
                .HasColumnName("note_id")
                .IsRequired()
                .ValueGeneratedNever();
            builder.Property(t => t.CategoryId)
                .HasColumnName("category_id")
                .IsRequired()
                .ValueGeneratedNever();

            // Beziehungen
            builder
                .HasOne(t => t.Note)
                .WithMany(t => t.AssignedCategories)
                .HasForeignKey(t => t.NoteId);
            builder
                .HasOne(t => t.Category)
                .WithMany()
                .HasForeignKey(t => t.CategoryId);
        }
    }
}

NoteContext.cs

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new NoteConfiguration());
    modelBuilder.ApplyConfiguration(new CategoryConfiguration());
    modelBuilder.ApplyConfiguration(new AssignedCategoryConfiguration());
}

Initialisierung von SQLite

Die Initialisierung von SQLite-Komponente kann zentral in der App.xaml.cs-Klasse (in unserem Fall XfEfCoreApp.xaml.cs), vor der Nutzung der Datenbank, mit Batteries_V2.Init(), oder in jedem App-Projekt mit Batteries_V2.Init() durchgeführt werden.

XfEfCoreApp.xaml.cs

protected override void OnInitialized()
{
    InitializeComponent();

    // Register View Model resolver
    ViewModelLocationProvider.SetDefaultViewTypeToViewModelTypeResolver(ViewModelResolver
            .PageToViewModel);

    // Initialize database
    SQLitePCL.Batteries_V2.Init();

    // Navigate to first page
    ToEntryPoint();
}

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    // Register services
    containerRegistry.Register<NoteContext>();

    // Register for navigation
    containerRegistry.RegisterForNavigation<NavigationPage>();
    containerRegistry.RegisterForNavigation<Views.MainPage>();
    containerRegistry.RegisterForNavigation<Views.SecondPage>();
}

MainViewModel.cs

public MainViewModel(INavigationService navigationService, IAppPathService pathService,
    NoteContext dbContext)
{
    _navService = navigationService;
    _pathService = pathService;
    _dbContext = dbContext;

    // Test of database
    var notCount = _dbContext.Notes.Count();

    // Commands
    NextPageCommand = new DelegateCommand(async () => await NextPage());

    Title = _pathService.GetDatabasePath("list.db");
}

Sonderfall iOS

Bereits jetzt funktioniert Entity Framework Core auf allen Betriebssystemen. Unter iOS gilt es nur für den Simulator. Wenn Sie versuchen, die App auf einem realen Gerät auszuführen, werden sie mit folgender Meldung scheitern:

System.TypeInitializationException:
The type initializer for 'Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions' threw an exception. --->
System.InvalidOperationException: Sequence contains no matching element
at System.Linq.Enumerable.Single[TSource] (System.Collections.Generic.IEnumerable`1[T] source, System.Func`2[T,TResult] predicate) [0x00070] in <773264786149499a986a13db6a7d46fe>:0
at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.GetMethod (System.String name, System.Int32 parameterCount, System.Func`2[T,TResult] predicate) [0x00029] in <0998bf911f014e7884d2695c95a67016>:0
at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions..cctor () [0x00000] in <0998bf911f014e7884d2695c95a67016>:0

Das Problem liegt in dem Linker, der ungenutzte Bibliotheken wegrationalisiert. Da Entity Framework an einigen Stellen Reflections nutzt, müssen diese Bibliotheken vor entfernen geschützt werden. Das lässt sich am einfachsten über eine XML-Datei regeln (siehe dazu auch Configure Linker in Production-Ready Application Release Build).

LinkDescription.xml

<?xml version="1.0" encoding="utf-8" ?>
<linker>
  <assembly fullname="mscorlib">
    <type fullname="System.String">
      <method name="Compare"></method>
      <method name="CompareTo"></method>
      <method name="ToUpper"></method>
      <method name="ToLower"></method>
    </type>
  </assembly>
  <assembly fullname="System.Core">
    <type fullname="System.Linq.Expressions.Expression`1"></type>
    <type fullname="System.Linq.Queryable"></type>
  </assembly>
</linker>

In den Projekteinstellungen muss nur noch angegeben werden, dass diese Datei von Linker benutzt werden soll. Tragen Sie dazu in iOS-Projekt-Einstellungen im Bereich iOS Build für Additional mtouch arguments den Wert --xml=${ProjectDir}/LinkDescription.xml. Der Eintrag ist nur für die iPhone Konfiguration sinnvoll, Sie könne aber die Einstellung für alle Konfigurationen hinterlegen.

Unter Android 7.0+ kann es mit VS 2017 15.5 zu Problemen kommen, da die NuGet-Abhängigkeiten nun über Referenzen eingebunden werden. Dabei kommt es beim Start zum folgenden Fehler:

Could not load assembly 'System.Runtime.CompilerServices.Unsafe' during startup registration.

Fügen Sie als Lösung das NuGet-Package System.Runtime.CompilerServices.Unsafe in der Version 4.3.0 (am besten direkt in der Projektdatei, da NuGet hier die Version 4.4.0 versucht zu installieren). Löschen Sie danach alle bin und obj Ordner, stellen Sie alle NuGet Packages wieder her und Kompilieren Sie das Projekt neu. Nun sollte auch Android ohne Probleme laufen.

Nähere Informationen zum Fehler auf Projektseite von Entity Framework Core.

3. Schritt - Hinzufügen / Bearbeiten der Kategorien

Nun fehlen uns noch die Oberflächen für die Bearbeitung von Kategorien und Notizen. Hier verweise ich einfach auf den Quellcode, da es wenig mit Entity Framework zu tun hat.

Die Repositories werden in die entsprechenden ViewModels über Constructor injiziert und verwendet. Durch dir Registrierung der Repositories als Transient (Neuerzeugung bei jedem Auflösen), erhält jedes ViewModel eigene Instanz. Das vermeidet Memory Leaks durch Entity Framework Caching, wenn man überall mit derselben Instanz arbeitet, bring aber eventuell andere Probleme mit sich.

Tipps für EF Core Nutzung

  1. Laden Sie Listen von Daten immer mit der Option AsNoTracking(), wenn die einzelnen Einträge nicht direkt bearbeitet werden (wie in dem Beispielprojekt die Listen für Notizen und Kategorien). Dadurch werden die Änderungen an den einzelnen Objekten nicht nachverfolgt (ist in dem Fall ja auch nicht nötig) und auch nicht gecached. Das kann zu deutlicher Performance-Steigerung bei größeren Listen führen.
  2. Versuchen Sie die Repositories als Transient zu registrieren, um Speicherverbrauch zu reduzieren, da der Context jedes Mal neu erzeugt wird, ohne den eventuell sehr großen Cache des Vorgängers zu haben.
  3. Erstellen Sie nur ein Context, dafür aber mehrere Repositories, die die einzelnen Aspekte abbilden (wie im Beispiel explizit für Kategorien und Notitzen jeweils ein Repository).
  4. Ob Sie bei Listen IEnumerable<> oder IQueriable<> zurückgeben, hängt von Ihren Anforderungen. Wenn Sie Filter / Sortierung usw. dynamisch aufbauen, sollten Sie IQueriable<> zurückgeben, um in ViewModel Filter zu setzen, die direkt auf der Datenbank angewendet werden (bei IEnumerable<> werden die Filter im Speicher auf geladenen Objekten durchgeführt. Wenn Sie die Filterung bereits über das Repository regelt, ist eher IEnumerable<> die bessere Wahl.
  5. Nutzen Sie, wie bei der mobilen Entwicklung üblich, so viel wie möglich asynchrone Aufrufe, um den UI-Thread zu entlasten.


Nach dem die Datenbank grundsätzlich funktioniert, werden die nächsten Tutorials folgende Themen bahandeln:

  • Migration der Datenbank
  • Automatisiertes Testen
  • Automatisierte Builds

Screenshots

Android - Category List Android - Create Category Android - Edit Category Android - Edit Note Android - New Note