tva
← Insights

Eine Prüfungsvorbereitungs-App mit Tausenden von Fragen aufbauen: Architekturentscheidungen

Das Problem der meisten Apps zur Prüfungsvorbereitung ist nicht der Frageninhalt, sondern die Architektur darum herum. Einige Hundert Fragen in einer flachen JSON-Datei funktionieren für eine einzelne Zertifizierung in einem einzelnen Markt gut. Sobald aber regionale Varianten, mehrere Schwierigkeitsstufen, je nach Prüfungsstelle unterschiedliche Fragetypen und eine Bestenliste hinzukommen, auf der Nutzer über Zeitzonen hinweg gleichzeitig konkurrieren, führen die Entscheidungen aus Woche eins in Woche acht zu Fehlern.

Wir haben eine App zur Prüfungsvorbereitung für mehrere berufliche Zertifizierungen in verschiedenen Regionen entwickelt. Unsere Architekturentscheidungen – insbesondere zur Datenmodellierung, Modularität mit Swift Package Manager, zum Supabase-Backend und zum Offline-Verhalten – waren anfangs nicht offensichtlich. Einige änderten sich zwischen dem ersten Prototyp und der veröffentlichten Version erheblich.

Das Problem der Datenmodellierung

Das einfachste Modell für eine Prüfungsfrage ist eine Tabelle mit Fragetext, vier Antwortmöglichkeiten und dem Index der richtigen Antwort. Das funktioniert, bis Fragen mit Bildern, Codeblöcken, unterschiedlich vielen Antwortmöglichkeiten oder mehreren richtigen Antworten unterstützt werden müssen. Das Schema nach dem Laden der Inhalte zu ändern ist aufwendig. Deshalb lohnt es sich, von Anfang an den gesamten vorgesehenen Inhaltsumfang zu berücksichtigen.

Unser endgültiges Schema speichert Fragen mit einem Feld type (single-choice, multi-choice, ordering, code-snippet) und einer JSONB-Spalte options für die Antwortmöglichkeiten. So kann jeder Fragetyp die benötigten Daten flexibel enthalten, ohne eine eigene Tabelle zu erfordern. Richtige Antworten werden als JSONB-Array von Indizes statt als einzelne Ganzzahl gespeichert. Dadurch lassen sich Fragen mit mehreren richtigen Antworten ohne Schemaänderung abbilden.

Fragen gehören zu domains, diese zu exams und diese wiederum zu regions. Die vierstufige Hierarchie wirkt überdimensioniert, bis dieselbe Zertifizierungsstelle unterschiedliche Lehrpläne für verschiedene Märkte herausgibt oder eine einzige App japanische und deutsche Zertifizierungspfade mit überlappenden Inhalten abdecken muss. Die Hierarchie ermöglicht es, „alle Fragen für diese Prüfung in dieser Region mit dieser Schwierigkeitsstufe“ durch eine einzige parametrisierte Abfrage abzurufen, statt auf Anwendungsebene zu filtern.

Die Schwierigkeit wird als Ganzzahl (1–5) statt als Enum gespeichert. Eine zusätzliche Stufe erfordert bei Enums eine Migration, bei Ganzzahlen nicht. Das Frontend ordnet den Ganzzahlen die entsprechenden Bezeichnungen zu.

Swift Package Manager für eine modulare Architektur

Ein monolithisches Xcode-Target schafft bei einer Codebasis dieser Größe ein bestimmtes Problem: Die Build-Zeiten steigen, und Grenzen zwischen Schichten lassen sich schwer durchsetzen. Ein Netzwerkmodul sollte keinen Zugriff auf UI-Komponenten haben. Für die Fragen-Engine sollte es keine Rolle spielen, ob sie in einer iOS-App oder einer Testumgebung läuft. Swift Package Manager macht diese Grenzen ausdrücklich und lässt sie vom Compiler durchsetzen.

Unser Paketgraph besteht aus vier Hauptpaketen:

  • QuestionEngine – Reine Logik: Fragenauswahl, Antwortauswertung, Bewertung von Lernsitzungen und Planung zeitlich verteilter Wiederholungen. Keine Abhängigkeit von UIKit, SwiftUI oder Supabase. Vollständig isoliert testbar.
  • ExamData – Datenmodelle und Wrapper für den Supabase-Client. Definiert die Typen Question, Exam, UserProgress und LeaderboardEntry. Importiert den Supabase-Swift-Client.
  • OfflineStore – Core-Data-Stack und Synchronisierungslogik. Verwendet ExamData für Typdefinitionen. Verwaltet den lokalen Cache und die Änderungsverfolgung für die Synchronisierung.
  • AppUI – SwiftUI-Ansichten und View-Modelle. Importiert QuestionEngine und ExamData. Hat keine direkte Kenntnis von OfflineStore.

Das Haupt-Target der App setzt alle vier Pakete zusammen. Dank dieser Struktur lässt sich die gesamte QuestionEngine-Testsuite ohne gestarteten Simulator ausführen, weil das Paket keine Plattformabhängigkeiten hat. Als eine Minor-Version des Supabase-Swift-Clients eine inkompatible Änderung brachte, blieben deren Auswirkungen zudem auf ExamData begrenzt: Wir aktualisierten ein Paket, der Rest kompilierte unverändert.

Der Nachteil ist, dass Paketauflösung und Caching von SPM in Xcode unzuverlässig sein können, insbesondere nach sauberen Neubuilds. Ein Makefile-Target, das vor CI-Builds xcodebuild -resolvePackageDependencies ausführt, beseitigt die meisten dieser Überraschungen.

Das Supabase-Backend

Supabase war aus zwei Gründen die richtige Wahl für dieses Backend: Row Level Security regelt mandantenfähigen Datenzugriff ohne zusätzliche Schutzprüfungen auf Anwendungsebene, und das Schema basiert vollständig auf PostgreSQL. Für den Aufbau des Lesemodells für die Bestenliste und der Berichtsschicht stehen damit alle Abfragemöglichkeiten zur Verfügung.

Der Lernfortschritt wird pro Frage und Sitzung gespeichert. Jede Zeile enthält die Fragen-ID, die gegebene Antwort, deren Richtigkeit, die benötigte Zeit und den Zeitstempel der Sitzung. Das ist detaillierter, als die meisten Apps benötigen, ermöglicht aber die Planung zeitlich verteilter Wiederholungen: Der Algorithmus hat die vollständige Historie statt nur eines laufenden Punktestands. Aggregierte Sichten über diese Tabelle liefern die Dashboard-Statistiken, ohne dass die Anwendung sie selbst berechnen muss.

Richtlinien für Row Level Security stellen sicher, dass Nutzer nur ihre eigenen Fortschrittszeilen lesen und schreiben können. Die Tabellen für Fragen, Prüfungen, Themenbereiche und Regionen sind öffentlich lesbar. Der Inhaltszugriff erfordert keine Authentifizierung, was die Offline-Synchronisierung vereinfacht. Nur die nutzerspezifischen Tabellen benötigen eine authentifizierte Sitzung.

Ein anfänglicher Fehler war der Versuch, Frageninhalte mit Supabase Realtime zu synchronisieren. Realtime ist für kleine, häufige Zeilenänderungen ausgelegt, nicht für die Synchronisierung Tausender Zeilen relativ statischer Inhalte. Wir ersetzten das Realtime-Abonnement der Fragentabelle durch einen geplanten Polling-Job. Dieser prüft den Zeitstempel content_updated_at im Prüfungsdatensatz und lädt nur bei geänderten Inhalten die Differenz herunter. Das ist weniger elegant, bei diesem Umfang der Inhaltsbibliothek aber deutlich zuverlässiger.

Offline-First-Design

Eine App zur Prüfungsvorbereitung mit zwingender Netzwerkverbindung hilft einem Prüfungskandidaten, der während eines Flugs lernt, nicht weiter. Offline-First ist hier keine optionale Funktion, sondern der zentrale Anwendungsfall.

Unser Ansatz kombiniert die Übertragung von Inhalten über WLAN mit einem lokalen Core-Data-Cache als primärer Datenquelle für die Fragen-Engine. Startet die App mit Netzwerkverbindung, prüft sie, ob Prüfungsinhalte seit der letzten Synchronisierung aktualisiert wurden. Falls ja, lädt sie die Differenz herunter und schreibt sie in Core Data. Ohne Netzwerkverbindung liest sie problemlos aus Core Data. Der Lernfortschritt wird zuerst lokal gespeichert und mit Supabase synchronisiert, sobald eine Verbindung verfügbar ist.

Das Modell für Synchronisierungskonflikte ist bewusst einfach: Für Frageninhalte ist der Server maßgeblich, für den Lernfortschritt der Client. Es gibt keine beidseitigen Inhaltsänderungen, die kollidieren könnten. Fortschrittszeilen werden ausschließlich ergänzt: Abgeschlossene Sitzungen werden nie aktualisiert, nur neue Sitzungen hinzugefügt. Die Synchronisierungslogik lautet daher schlicht: „Zeilen mit Zeitstempeln nach der letzten erfolgreichen Synchronisierung hochladen.“ Diese Regel bleibt nur dann so einfach, wenn jede Zeile eine eindeutige ID hat, damit ein erneuter Upload keine Duplikate erzeugt, und wenn sowohl die Zeitstempel der Zeilen als auch die Synchronisierungsmarkierung auf derselben Geräteuhr basieren.

Ein wichtiges Detail: Der Offline-Speicher sollte dem Nutzer anzeigen, wie aktuell er ist. Ein Banner mit „Inhalte zuletzt vor 3 Tagen aktualisiert“ schafft angemessene Erwartungen. Werden nach dem Hinzufügen neuer Fragen unbemerkt veraltete Inhalte angezeigt, sind Nutzer verwirrt, wenn ihre Ergebnisse von denen anderer abweichen, die kürzlich synchronisiert haben.

Bestenlisten bei gleichzeitigen Zugriffen

Bestenlisten lassen sich einfach lesen, sind bei gleichzeitigen Aktualisierungen aber überraschend anspruchsvoll korrekt zu schreiben. Der naive Ansatz, pro Nutzer eine Spalte score nach jeder Sitzung zu aktualisieren, führt zu konkurrierenden Schreibzugriffen, wenn viele Nutzer gleichzeitig Sitzungen abschließen. Außerdem ist er anfällig für Race Conditions, bei denen zwei gleichzeitige Aktualisierungen einander überschreiben.

Bewährt hat sich, die Bestenliste als aus der Sitzungshistorie abgeleitetes Lesemodell zu behandeln statt als laufend gepflegte Punktespalte. Eine materialisierte PostgreSQL-Sicht aggregiert die Sitzungspunkte je Nutzer bei einer geplanten Aktualisierung. Diese läuft alle paar Minuten statt bei jedem Schreibzugriff. Die Bestenliste wird dadurch zeitversetzt konsistent und nicht in Echtzeit aktualisiert – ein akzeptabler Kompromiss für eine Lern-App. Nutzer sehen ihre neue Platzierung innerhalb weniger Minuten nach Sitzungsabschluss. Das deckt alle Anwendungsfälle außer live ausgetragenen Prüfungswettbewerben ab.

Für Wettbewerbe, bei denen Echtzeitplatzierungen wichtig sind, verwenden wir einen separaten Redis-gestützten Zähler außerhalb von Supabase. Für den normalen Lernablauf ist die materialisierte Sicht jedoch einfacher, günstiger und vermeidet Race Conditions, bei denen sich gleichzeitige Aktualisierungen einer fortlaufend aktualisierten Punktespalte gegenseitig überschreiben.

Was wir anders machen würden

Die JSONB-Spalte options für Antwortdaten war die richtige Entscheidung. Das Schema der Antwortobjekte hatten wir anfangs jedoch zu ungenau definiert. Verschiedene Entwickler ergänzten mit der Zeit leicht unterschiedliche Felder: Manche Antwortmöglichkeiten hatten ein Feld explanation, manche ein imageUrl, manche keines von beiden. Ohne eine beim Einfügen erzwungene, validierte Struktur wurden die Inhalte uneinheitlich. Ein Zod-Schema auf der TypeScript-Seite des Administrationswerkzeugs und eine CHECK-Bedingung für die Postgres-Spalte hätten das früher erkannt. Erzwingen Sie die Struktur von JSONB-Daten an der Eingangsgrenze, nicht nachträglich.

Die SPM-Paketgrenze zwischen AppUI und OfflineStore war zu streng. Mehrere SwiftUI-Ansichten mussten wissen, ob sie Daten aus dem Cache oder aktuelle Daten anzeigten. Diese Information lag in OfflineStore. Schließlich reichten wir ein Protokoll durch AppUI, das OfflineStore erfüllen konnte – die richtige Abstraktion. Sauberer wäre jedoch gewesen, dieses Protokoll von Anfang an zu entwerfen statt nachträglich einzubauen.

Weiterführende Beiträge

Weiterführende Beiträge