Zum Inhalt springen

8. Desktop und mobile Endgeräte

Zu Zen-Modus wechseln

Drei Wochen, das längste Kapitel des Jahres. Aus Ihrer Webanwendung wird etwas, das man installiert, das ohne Netz noch etwas kann und das auf Kamera, Standort oder Dateien zugreift.

Der erste Teil ist eine Entscheidung, nicht eine Technik. „Wir machen eine App” ist keine Aussage über Anforderungen, sondern über eine Erwartung, und die Wege dorthin unterscheiden sich um den Faktor fünf im Aufwand. Beginnen Sie deshalb mit der nüchternen Frage: Was genau soll auf dem Gerät möglich sein, das im Browser nicht geht?

WegReichweiteGeräteschnittstellenVerteilungAufwand
Responsive WebüberalleingeschränktAdresse aufrufenkeiner zusätzlich
Progressive Web Appüberall, auf iOS mit Abstrichendie meisten üblicheninstallierbar aus dem Browsergering bis mittel
Plattformübergreifend (Expo, React Native, Flutter)iOS, Android, teils Webfast alleStore oder Testkanalmittel bis hoch
Desktop-Hülle (Tauri, Electron)Windows, macOS, LinuxDateisystem, SystemintegrationInstallationsdateimittel
Nativ (Swift, Kotlin)je Plattform einzelnalle, zuerstStorehoch, pro Plattform

Die Entscheidungskriterien:

  • Welche Geräteschnittstellen brauchen Sie wirklich? Kamera und Standort kann der Browser. Hintergrundortung, Bluetooth-Zubehör und tiefe Systemintegration nicht.
  • Wie soll verteilt werden? Ein Store bedeutet Prüfung, Konto, Kosten und Wartezeit. Eine Adresse bedeutet: neu ausliefern und fertig.
  • Wie schnell müssen Korrekturen bei den Benutzern sein? Im Web sofort, im Store nach Prüfung und Installation.
  • Wie viele Leute sind Sie, und wie lange soll das gewartet werden?
  • Was verlangt der Store? Datenschutzangaben, Alterseinstufung, Symbole, Beschreibungen, und für iOS ein Mac zum Bauen.

Bauen Sie daraus eine Matrix mit gewichteten Kriterien für Ihr Projekt, entscheiden Sie, und schreiben Sie beides nach docs/entscheidungen.md. Für die meisten Schulprojekte ist die Antwort eine PWA plus, falls eine Geräteschnittstelle es verlangt, ein plattformübergreifender Prototyp.

{
"id": "/",
"name": "Werkstatt Villach",
"short_name": "Werkstatt",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1d4ed8",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable.png", "sizes": "512x512", "type": "image/png",
"purpose": "maskable" }
]
}

Installierbar wird eine Anwendung mit Manifest, Service Worker und HTTPS. Das Symbol mit purpose: maskable ist der Unterschied zwischen einem sauberen Startbildschirmsymbol und einem weißen Quadrat mit Ihrem Logo in der Mitte.

Android bietet die Installation von selbst an, unter iOS geht sie über „Zum Home-Bildschirm”, und das weiß kaum jemand. Wenn die Installation für Ihren Anwendungsfall wichtig ist, brauchen Sie einen Hinweis in der Anwendung, der das erklärt.

Ein Service Worker ist ein Skript, das zwischen Ihrer Anwendung und dem Netz sitzt. Er läuft auch dann, wenn keine Seite offen ist, und er hat einen eigenen Lebenszyklus: installiert, dann wartend, dann aktiv.

public/sw.js
const VERSION = "v7";
const CACHE = `shell-${VERSION}`;
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((c) => c.addAll(["/", "/offline", "/icons/192.png"])),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))),
),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return; // never intercept mutations
if (request.headers.get("accept")?.includes("text/html")) {
event.respondWith(fetch(request).catch(() => caches.match("/offline")));
return;
}
event.respondWith(caches.match(request).then((hit) => hit ?? fetch(request)));
});

Der wichtigste Absatz dieses Abschnitts. Ein neuer Service Worker installiert sich, bleibt aber wartend, solange noch ein Fenster vom alten gesteuert wird. Bei einer Anwendung, die Leute in einem Tab offen lassen, kann das Tage dauern. Ergebnis: Sie liefern eine Korrektur aus, und niemand bekommt sie.

self.skipWaiting() blind aufzurufen ist die naheliegende und falsche Antwort, weil dann eine laufende Seite plötzlich Dateien einer anderen Version nachlädt. Der saubere Weg fragt den Benutzer:

const reg = await navigator.serviceWorker.register("/sw.js");
reg.addEventListener("updatefound", () => {
const newWorker = reg.installing;
newWorker?.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
showNotice("New version available", () =>
newWorker.postMessage({ type: "SKIP_WAITING" }),
);
}
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => location.reload());

Im Service Worker dazu:

self.addEventListener("message", (e) => {
if (e.data?.type === "SKIP_WAITING") self.skipWaiting();
});

Damit entscheidet die Person, wann neu geladen wird, und die Version wechselt vollständig statt halb.

ArtStrategieBegründung
Programmdateien mit Hash im NamenSpeicher zuerständern sich nie, neue Datei heißt anders
HTML-SeitenNetz zuerst, Ausweichseiteveraltete Seite verwirrt mehr, als sie nützt
Stammdaten, selten geändertSpeicher zuerst, im Hintergrund erneuernschnell und meistens aktuell
Persönliche DatenNetz zuerstfremde Daten im Speicher wären ein Datenschutzproblem
Schreibende Aufrufeniemals aus dem Speichersiehe unten

Offline lesen ist einfach: Daten liegen in IndexedDB, die Ansicht liest von dort. Offline schreiben ist der schwierige Teil, und er zerfällt in drei Fragen.

Wie merke ich mir den Vorgang? In einer Warteschlange in IndexedDB, mit allen nötigen Angaben und einer Kennung, die der Client selbst erzeugt. Diese Kennung ist zugleich der Idempotenzschlüssel aus dem Vorjahr: Wenn die Übertragung zweimal ankommt, entsteht trotzdem nur eine Buchung.

Wann wird gesendet? Beim nächsten erfolgreichen Netzzugang, ausgelöst über das online-Ereignis oder über Background Sync, wo verfügbar.

Was passiert bei einem Konflikt? Die Buchung, die offline angelegt wurde, kann in der Zwischenzeit belegt worden sein. Der Server entscheidet, nicht das Gerät: Er prüft die Version wie beim optimistischen Sperren im Vorjahr und antwortet gegebenenfalls mit 409. Ihre Anwendung muss darauf eine Antwort haben, und diese Antwort gehört entworfen: verwerfen, überschreiben, zusammenführen oder nachfragen. Was nicht geht, ist stilles Verschlucken.

Und die Oberfläche muss den Zustand zeigen. Ein Eintrag, der „wird gesendet” ist, sieht anders aus als einer, der bestätigt wurde. Ohne diese Kennzeichnung glauben Ihre Benutzer, alles sei erledigt.

Ablauf: Berechtigung erfragen, Abonnement beim Push-Dienst des Browsers anlegen, Abonnement auf Ihrem Server speichern, von dort signiert (VAPID) senden, Service Worker zeigt die Nachricht an.

Zwei Regeln. Fragen Sie im Zusammenhang, also dann, wenn jemand gerade eine Erinnerung eingerichtet hat, nicht beim ersten Seitenaufruf. Eine abgelehnte Berechtigung bekommen Sie praktisch nicht zurück. Und Abonnements sind personenbezogen, gehören also in Ihr Verarbeitungsverzeichnis aus Kapitel 4 und werden beim Löschen des Kontos mitgelöscht.

PunktAndroid und DesktopiOS
Installationwird angebotennur über „Zum Home-Bildschirm”
Pushjanur wenn installiert
Hintergrundsynchronisierungjanein
Speichergroßzügigknapper, kann bei längerer Nichtnutzung gelöscht werden

Der letzte Punkt hat eine Konsequenz für den Entwurf: Behandeln Sie den lokalen Speicher als Zwischenspeicher, nie als einzigen Ort Ihrer Daten.

Der Reiz von React Native und Expo ist, dass Ihr Wissen und ein Teil Ihres Codes weiterverwendbar sind. Der Teil ist kleiner, als Werbeversprechen nahelegen, und liegt genau dort, wo Sie im Vorjahr sauber getrennt haben.

Lässt sich teilenLässt sich nicht teilen
Typen und Zod-SchemataKomponenten mit HTML-Elementen
Erzeugter API-ClientCSS und Tailwind-Klassen
Fachlogik und BerechnungenRouting des Webs
Abfrageschicht (TanStack Query läuft auch dort)Browser-Schnittstellen
Gestaltungstoken aus Kapitel 6die Komponenten selbst
projekt/
├── packages/
│ ├── domaene/ Typen, Schemata, Regeln
│ └── api-client/ erzeugt aus OpenAPI
├── apps/
│ ├── web/ Next.js
│ └── mobil/ Expo

Diese Aufteilung ist der eigentliche Ertrag des Kapitels, auch wenn Sie am Ende nur einen kleinen mobilen Teil bauen: Sie zwingt dazu, Fachlogik von Darstellung zu trennen.

Was nicht funktioniert, ist die Weboberfläche eins zu eins zu übernehmen. Eine App hat eine Navigationsleiste unten, ein Zurück-Verhalten, das das System vorgibt, native Auswahlfelder und Gesten. Ihre Token kommen mit, Ihre Komponenten nicht.

Navigation. Expo Router arbeitet mit dateibasierten Routen und fühlt sich dadurch an wie Next.js, führt aber zu anderen Bausteinen: ein Stapel für Detailansichten, Reiter für die Hauptbereiche, ein Schubladenmenü, wo es passt. Anders als im Web hat jeder Bildschirm eine Position im Stapel, und das Zurückgehen ist eine Systemhandlung, keine Adressänderung.

Serverzustand. TanStack Query aus dem Vorjahr läuft unverändert, samt Abfrageschlüsseln und Invalidierung. Zwei Dinge kommen dazu: eine dauerhafte Ablage des Zwischenspeichers, damit die App nach dem Neustart sofort etwas zeigen kann, und ein Zustandsanzeiger für das Netz, weil ein Ladekreis ohne Verbindung nie endet.

Formulare. Hier fällt am meisten weg. Es gibt kein form-Element, kein label, keine eingebaute Abschickmechanik und keine Browservalidierung. Ihre Zod-Schemata bleiben, die Anbindung bauen Sie neu. Dazu kommen Punkte, die es im Web nicht gibt: Die Bildschirmtastatur verdeckt Eingabefelder, wenn kein ausweichender Container darum liegt; die Eingabetaste soll je Feld etwas anderes tun; und die Barrierefreiheit läuft über eigene Eigenschaften, weil die HTML-Semantik fehlt, die im Vorjahr die halbe Arbeit erledigt hat.

Designsystem. Die Token aus Kapitel 6 kommen als gemeinsames Paket in beide Anwendungen. Farben, Abstände, Radien und Typografieskala sind Werte und plattformunabhängig; die Komponenten daraus sind es nicht. Wenn Ihre Token in einem eigenen Paket liegen, ist das ein Import; wenn sie in Ihrem Tailwind-Konfigurat stecken, ist es eine Abschreibübung mit Fehlerquellen.

TauriElectron
OberflächeWebansicht des Systemsmitgeliefertes Chromium
Größe der Auslieferungwenige Megabyteüber hundert Megabyte
Speicherbedarfgeringhoch
HintergrundlogikRustNode.js
Reife und Beispielejüngersehr verbreitet

Für beide gilt: Eine Desktopanwendung, die verteilt wird, sollte signiert sein, sonst warnen Windows und macOS die Benutzer beim Start. Zertifikate dafür kosten Geld, und für ein Schulprojekt ist eine unsignierte Auslieferung mit einer Anleitung ein vertretbarer Weg, wenn Sie ihn benennen.

FähigkeitIm BrowserIn ExpoBerechtigung
Kamera, FotogetUserMedia, input captureexpo-cameraja
Standortnavigator.geolocationexpo-locationja
Dateieninput type=file, File System Accessexpo-document-pickerteils
BenachrichtigungenNotification APIexpo-notificationsja
TeilenWeb Share APISharenein
ZwischenablageClipboard APIexpo-clipboardteils
SensorenDeviceOrientationexpo-sensorsteils
BiometrieWebAuthnexpo-local-authenticationja

Fragen Sie nach der Fähigkeit, nicht nach der Plattform:

if ("geolocation" in navigator) {
// offer it
} else {
// let the address be entered manually
}

Eine Abfrage auf den Browsernamen ist immer falsch, weil sie beim nächsten Update oder auf dem nächsten Gerät nicht mehr stimmt.

  1. Im Zusammenhang fragen. Nicht beim Start, sondern wenn die Person etwas tut, wofür die Berechtigung nötig ist.

  2. Vorher erklären. Ein eigener kleiner Hinweis vor dem Systemdialog, der sagt, wozu. Das kostet nichts und hebt die Zustimmungsquote deutlich.

  3. Ablehnung behandeln. Es gibt immer einen zweiten Weg: Adresse tippen statt orten, Datei auswählen statt fotografieren. Eine Anwendung, die nach einer Ablehnung nur noch einen leeren Bildschirm zeigt, ist kaputt.

  4. Nicht noch einmal fragen. Nach einer Ablehnung ist der Systemdialog meistens verbraucht. Was bleibt, ist ein Hinweis, wo man es in den Einstellungen ändern kann.

Web und PWAApp im Store
Veröffentlichenausliefern, fertigeinreichen, Prüfung abwarten
Korrektur beim Benutzerbeim nächsten Ladennach Freigabe und Installation
Kostenkeine zusätzlichenEntwicklerprogramm, bei Apple jährlich
PflichtenkeineDatenschutzangaben, Alterseinstufung, Beschreibung, Symbole
Rücknahmesofortüber den Store

Was ein Store verlangt, ist umfangreicher als erwartet, und einiges davon betrifft nicht die Technik:

  • Datenschutzangaben darüber, welche Daten die App erhebt und wofür. Sie müssen zu Ihrem Verarbeitungsverzeichnis aus Kapitel 4 passen, und sie werden stichprobenartig geprüft.
  • Löschmöglichkeit für das Konto innerhalb der App, sobald sich Benutzer registrieren können. Beide großen Stores verlangen das, und es ist ohnehin Ihre Pflicht aus Kapitel 4.
  • Begründungen für jede Berechtigung, hinterlegt in den Projektangaben. Ohne den Text zur Kamera wird die App zurückgewiesen.
  • Testzugang für die Prüfung, also ein Konto mit Beispieldaten, sonst kommt die App mit „nicht prüfbar” zurück.
  • Symbole, Bildschirmfotos je Gerätegröße, Beschreibung, Alterseinstufung.

Für das Schuljahr ist die realistische Erwartung eine Testverteilung: TestFlight bei Apple, interner Testkanal bei Google, oder ein Entwicklungs-Build über Expo. Eine Store-Veröffentlichung ist Kür und scheitert selten an der Technik, sondern an Fristen, Konten und Formalien.

Ein eigener Punkt sind Aktualisierungen über die Luft. Expo kann den JavaScript-Teil einer App direkt aktualisieren, ohne Store. Das ist sehr bequem und hat zwei Grenzen: Alles, was nativen Code betrifft, braucht trotzdem einen neuen Build, und die Store-Regeln erlauben solche Aktualisierungen nur, solange sich Zweck und Funktionsumfang nicht wesentlich ändern.

Und weil alte Versionen im Umlauf bleiben: Bauen Sie einen Weg ein, mit dem Ihr Server einer zu alten App mitteilen kann, dass sie aktualisiert werden muss. Ein Endpunkt mit einer Mindestversion genügt.

  • Zurück. Android hat eine Systemgeste, iOS wischt am Rand, das Web hat den Verlauf. Ihre Navigation muss mit allen dreien zurechtkommen.
  • Zustandsverlust. Eine App im Hintergrund kann jederzeit beendet werden. Was nicht gesichert ist, ist weg; ein halb ausgefülltes Formular gehört also zwischengespeichert.
  • Netzwechsel. Vom WLAN ins Mobilfunknetz und zurück, Funklöcher, Roaming. Anfragen brechen mittendrin ab, und Ihre Wiederholung aus Kapitel 3 wird hier zur Alltagsfunktion.
  • Energie und Daten. Dauerhafte Ortung, häufiges Abfragen und große Bilder kosten Akku und Datenvolumen. Beides bemerken Benutzer, und beides führt zur Deinstallation.
  • Eingabe und Fläche. Bildschirmtastatur, sichere Bereiche, Berührungsziele, ein Daumen statt zweier Hände.

Führen Sie eine kleine Tabelle darüber, worauf Sie tatsächlich getestet haben, mit Gerät, Betriebssystemversion und Datum. „Läuft auf meinem Telefon” ist keine Aussage über zwei Plattformen.

  1. Service Worker ohne Aktualisierungspfad. Benutzer hängen auf einer alten Version fest, und Sie sehen es nicht.
  2. skipWaiting blind aufgerufen und dadurch halb aktualisierte Seiten.
  3. Alles zwischengespeichert, danach zeigt die App veraltete Daten.
  4. Schreibende Aufrufe abgefangen und dadurch verlorene oder doppelte Vorgänge.
  5. Persönliche Daten im Speicher, die beim nächsten Benutzer noch da sind.
  6. Offline-Schreibvorgänge ohne Konfliktstrategie.
  7. Kein sichtbarer Zustand für ausstehende Vorgänge. Die Person glaubt, es sei erledigt.
  8. Berechtigung beim ersten Start abgefragt, ohne Begründung, und die Ablehnung nicht behandelt.
  9. Auf die Plattform abgefragt statt auf die Fähigkeit.
  10. Weboberfläche unverändert in die App gepackt.
  11. Lokalen Speicher als einzige Datenquelle verwendet.
  12. Store-Anforderungen in der letzten Woche gelesen.
  13. Keine Möglichkeit, alte App-Versionen zur Aktualisierung zu zwingen.
  • Beurteilen: die Plattformstrategie für das eigene Projekt anhand von Kriterien begründen und protokollieren.
  • Anwenden: eine installierbare PWA mit Manifest und Service Worker umsetzen.
  • Erklären: den Lebenszyklus eines Service Workers erklären und die Aktualisierungsfalle beschreiben.
  • Anwenden: einen Aktualisierungspfad bauen, der Benutzer nicht auf alten Versionen zurücklässt.
  • Beurteilen: je Art von Anfrage eine passende Zwischenspeicherstrategie wählen.
  • Anwenden: Offline-Lesen und eine Warteschlange für Schreibvorgänge mit Idempotenz und Konfliktbehandlung umsetzen.
  • Anwenden: eine Anwendung mit einem plattformübergreifenden Framework für ein weiteres Ziel bauen und dabei Fachlogik teilen.
  • Anwenden: Geräteschnittstellen mit Fähigkeitserkennung und sauberer Berechtigungsführung einbinden.
  • Analysieren: Unterschiede und Grenzen der Zielplattformen benennen und im Entwurf berücksichtigen.
  • Anwenden: eine Testverteilung einrichten und den Aktualisierungsweg beschreiben.
  • Plattformentscheidung mit Kriterienmatrix in docs/entscheidungen.md.
  • Installierbare PWA: Manifest, Symbole, Service Worker, Ausweichseite, geprüft auf Android oder Desktop.
  • Aktualisierungspfad mit Hinweis auf eine neue Version, nachgewiesen durch zwei aufeinanderfolgende Auslieferungen.
  • Festgelegter Offline-Umfang: was lesbar bleibt, was schreibbar bleibt, was gesperrt ist, schriftlich in docs/architektur.md.
  • Eine Warteschlange für mindestens einen Schreibvorgang, mit Idempotenzschlüssel und Konfliktbehandlung, vorgeführt im Flugmodus.
  • Mindestens eine Geräteschnittstelle eingebunden, mit Begründungshinweis, Ablehnungsbehandlung und Alternativweg.
  • Ein zweites Ziel: entweder ein plattformübergreifender Build mit geteilter Fachlogik oder eine Desktop-Hülle, mit Testverteilung.
  • Testtabelle: Gerät, Betriebssystemversion, Datum, Ergebnis.
  • Aufgabe - Plattformentscheidung mit Matrix
  • Aufgabe - PWA installierbar und offlinefähig machen
  • Aufgabe - Service-Worker-Aktualisierung ohne hängende Nutzer
  • Aufgabe - Geräteschnittstelle mit Berechtigungsdialog einbinden