Costruire un’app di preparazione agli esami con migliaia di domande: scelte architetturali
Il problema della maggior parte delle app di preparazione agli esami non sono le domande, ma l’architettura che le gestisce. Qualche centinaio di domande in un semplice file JSON funziona bene per una sola certificazione in un solo mercato. Quando però si aggiungono varianti regionali, più livelli di difficoltà, tipi di domanda diversi per ente certificatore e una classifica nella quale utenti di fusi orari diversi competono contemporaneamente, le scelte della prima settimana iniziano a causare problemi nell’ottava.
Abbiamo realizzato un’app di preparazione agli esami che copre più certificazioni professionali in diverse regioni. Le scelte architetturali, soprattutto su modellazione dei dati, modularità con Swift Package Manager, backend Supabase e comportamento offline, non erano ovvie all’inizio. Alcune sono cambiate sensibilmente tra il primo prototipo e la versione distribuita.
Il problema della modellazione dei dati
Il modello più semplice per una domanda d’esame è una tabella con il testo della domanda, quattro opzioni di risposta e l’indice della risposta corretta. Funziona finché non servono domande con immagini, blocchi di codice, un numero variabile di opzioni o più risposte corrette. Modificare lo schema dopo aver caricato i contenuti è costoso: conviene progettare fin dall’inizio per l’intera gamma di contenuti prevista.
Lo schema adottato archivia le domande con un campo type (single-choice, multi-choice, ordering, code-snippet) e una colonna JSONB options che contiene le risposte disponibili. Ogni tipo di domanda può così includere i dati necessari senza una tabella separata. Le risposte corrette sono archiviate come array JSONB di indici anziché come singolo intero, gestendo le domande con più risposte corrette senza modificare lo schema.
Le domande appartengono a domains, che appartengono a exams, a loro volta appartenenti a regions. Questa gerarchia a quattro livelli sembra eccessiva finché lo stesso ente certificatore non propone programmi diversi per mercati diversi, oppure una sola app deve gestire percorsi di certificazione giapponesi e tedeschi con contenuti sovrapposti. Permette di cercare «tutte le domande per questo esame in questa regione a questo livello di difficoltà» con una sola query parametrizzata, anziché filtrare nell’applicazione.
La difficoltà è archiviata come intero (1–5), anziché come enum. Gli enum richiedono migrazioni per aggiungere un livello; gli interi no. Il frontend associa gli interi alle etichette.
Swift Package Manager per un’architettura modulare
Un target Xcode monolitico per una codebase di queste dimensioni crea un problema specifico: i tempi di compilazione crescono e diventa difficile far rispettare i confini tra i livelli. Un modulo di rete non dovrebbe accedere ai componenti dell’interfaccia. Il motore delle domande non dovrebbe dipendere dal fatto che venga eseguito in un’app iOS o in un ambiente di test. Swift Package Manager rende questi confini espliciti e ne affida il rispetto al compilatore.
Il grafo dei pacchetti che abbiamo adottato comprende quattro pacchetti principali:
- QuestionEngine – Logica pura: selezione delle domande, valutazione delle risposte, calcolo dei punteggi delle sessioni e pianificazione della ripetizione dilazionata. Nessuna dipendenza da UIKit, SwiftUI o Supabase. Interamente testabile in isolamento.
- ExamData – Modelli dati e wrapper del client Supabase. Definisce i tipi
Question,Exam,UserProgresseLeaderboardEntry. Importa il client Swift di Supabase. - OfflineStore – Stack Core Data e logica di sincronizzazione. Dipende da ExamData per le definizioni dei tipi. Gestisce la cache locale e il tracciamento delle modifiche da sincronizzare.
- AppUI – Viste SwiftUI e view model. Importa QuestionEngine ed ExamData. Non conosce direttamente OfflineStore.
Il target principale dell’app compone tutti e quattro i pacchetti. Questa struttura permette di eseguire l’intera suite di test di QuestionEngine senza avviare un simulatore, perché il pacchetto non ha dipendenze dalla piattaforma. Quando il client Swift di Supabase ha introdotto una modifica incompatibile in una versione minore, l’impatto è rimasto circoscritto a ExamData: abbiamo aggiornato un pacchetto e il resto ha continuato a compilare senza modifiche.
Il costo è che la risoluzione e la cache dei pacchetti SPM possono essere imprevedibili in Xcode, soprattutto dopo compilazioni da zero. Un target nel Makefile che esegue xcodebuild -resolvePackageDependencies prima delle compilazioni CI elimina la maggior parte di queste sorprese.
Il backend Supabase
Supabase era la scelta giusta per due motivi: Row Level Security gestisce l’accesso ai dati multi-tenant senza controlli nel livello applicativo e lo schema è interamente PostgreSQL, quindi per costruire il modello di lettura delle classifiche e il livello di reportistica è disponibile l’intera gamma di funzionalità di query.
I progressi degli utenti sono archiviati per domanda e per sessione. Ogni riga registra l’ID della domanda, la risposta data, la sua correttezza, il tempo impiegato e il timestamp della sessione. È una granularità maggiore di quella necessaria alla maggior parte delle app, ma rende possibile pianificare la ripetizione dilazionata: l’algoritmo dispone dell’intera cronologia, non solo di un punteggio cumulativo. Viste aggregate su questa tabella alimentano le statistiche della dashboard senza richiedere calcoli nell’applicazione.
Le policy Row Level Security consentono agli utenti di leggere e scrivere solo le proprie righe di progresso. Le tabelle delle domande, degli esami, dei domini e delle regioni sono leggibili pubblicamente: non serve autenticazione per accedere ai contenuti, semplificando la sincronizzazione offline. Solo le tabelle specifiche degli utenti richiedono una sessione autenticata.
Un errore iniziale è stato tentare di usare Supabase Realtime per sincronizzare le domande. Realtime è progettato per piccole modifiche frequenti alle righe, non per sincronizzare migliaia di righe di contenuti relativamente statici. Abbiamo sostituito la sottoscrizione Realtime sulla tabella delle domande con un’attività pianificata di polling che controlla il timestamp content_updated_at nel record dell’esame e scarica solo le differenze quando i contenuti cambiano. È meno elegante, ma molto più affidabile con una raccolta di queste dimensioni.
Progettazione con priorità al funzionamento offline
Un’app di preparazione agli esami che richiede una connessione non è utile a chi studia in aereo. Il funzionamento offline non è opzionale: è il caso d’uso principale.
Abbiamo combinato l’invio dei contenuti tramite Wi-Fi con una cache Core Data locale, che è la fonte dati principale del motore delle domande. Quando l’app si avvia con una connessione, verifica se i contenuti sono cambiati dall’ultima sincronizzazione. In tal caso scarica le differenze e le scrive in Core Data. Senza connessione, legge da Core Data senza interruzioni. I progressi dell’utente vengono prima scritti localmente e poi sincronizzati con Supabase quando è disponibile una connessione.
Il modello dei conflitti di sincronizzazione è volutamente semplice: il server è la fonte autorevole per le domande, il client per i progressi dell’utente. Non ci sono modifiche bidirezionali ai contenuti che possano entrare in conflitto. Le righe dei progressi vengono solo aggiunte: una sessione completata non viene mai aggiornata, si aggiungono soltanto nuove sessioni. La logica di sincronizzazione consiste quindi nel «caricare le righe con timestamp successivi all’ultima sincronizzazione riuscita». Questa regola resta semplice solo se ogni riga ha un ID univoco, così che un nuovo tentativo di caricamento non possa creare duplicati, e se l’orologio del dispositivo viene usato in modo coerente sia per i timestamp delle righe sia per il marcatore di sincronizzazione.
Un dettaglio importante: l’archivio offline deve indicare all’utente quanto sono aggiornati i dati. Un avviso come «Contenuti aggiornati l’ultima volta 3 giorni fa» crea aspettative corrette. Mostrare silenziosamente contenuti non aggiornati quando sono state aggiunte nuove domande confonde gli utenti, i cui punteggi possono differire da quelli di chi si è sincronizzato più di recente.
Classifiche con aggiornamenti concorrenti
Leggere le classifiche è semplice, ma scriverle correttamente con aggiornamenti concorrenti richiede più attenzione del previsto. L’approccio ingenuo, una colonna score per utente aggiornata dopo ogni sessione, genera contesa in scrittura quando molti utenti terminano contemporaneamente ed è vulnerabile a condizioni di competizione, in cui due aggiornamenti concorrenti si sovrascrivono.
L’approccio efficace consiste nel trattare la classifica come un modello di lettura derivato dalla cronologia delle sessioni, anziché mantenere una colonna del punteggio. Una vista materializzata PostgreSQL aggrega i punteggi per utente con aggiornamenti pianificati ogni pochi minuti, non a ogni scrittura. La classifica raggiunge quindi la coerenza dopo un breve intervallo anziché in tempo reale: un compromesso accettabile per un’app di studio. Gli utenti vedono la posizione aggiornata entro pochi minuti dal termine della sessione, coprendo tutti i casi d’uso tranne gli eventi competitivi d’esame dal vivo.
Per gli eventi competitivi che richiedono classifiche in tempo reale usiamo un contatore separato basato su Redis, esterno a Supabase. Per il normale studio, però, la vista materializzata è più semplice, economica e evita le condizioni di competizione (race condition) che causano la perdita di aggiornamenti quando si mantiene aggiornata una colonna del punteggio.
Che cosa faremmo diversamente
La colonna JSONB options per le risposte era la scelta giusta, ma inizialmente non abbiamo definito lo schema degli oggetti opzione in modo sufficientemente preciso. Nel tempo sviluppatori diversi hanno aggiunto campi leggermente differenti: alcune opzioni avevano un campo explanation, altre un imageUrl, altre nessuno dei due. Senza una struttura validata e imposta all’inserimento, i contenuti sono diventati incoerenti. Uno schema Zod nel codice TypeScript dello strumento di amministrazione e un vincolo CHECK sulla colonna Postgres avrebbero rilevato il problema prima. La struttura dei dati JSONB va verificata al punto di ingresso, non dopo.
Il confine tra i pacchetti SPM AppUI e OfflineStore era troppo rigido. Diverse viste SwiftUI dovevano sapere se mostravano dati in cache o dati correnti, e questa informazione risiedeva in OfflineStore. Abbiamo finito per passare ad AppUI un protocollo implementabile da OfflineStore, l’astrazione corretta, ma sarebbe stato più pulito progettarlo fin dall’inizio anziché aggiungerlo in seguito.
Approfondimenti correlati
- Concorrenza in Swift 6: guida pratica alla migrazione – Affrontare in migrazione i problemi ai confini degli actor che emergono proprio in questo tipo di architettura SwiftUI con più pacchetti.
- Da un’idea SwiftUI all’App Store in poche settimane – Come definire l’ambito e distribuire la versione MVP di un’app come questa prima che l’architettura completa sia pronta.
- Risolvere i rifiuti delle icone dell’App Store – I dettagli finali dell’invio che sorprendono i team dopo il completamento del lavoro tecnico.