Créer une application de préparation aux examens avec des milliers de questions : décisions d’architecture
Le problème avec la plupart des applications de préparation aux examens n’est pas le contenu des questions – c’est l’architecture qui l’entoure. Quelques centaines de questions stockées dans un fichier JSON plat fonctionnent bien pour une seule certification sur un seul marché. Mais en réalité, une fois que vous ajoutez des variantes régionales, plusieurs niveaux de difficulté, des types de questions qui diffèrent selon l’organisme d’examen, et un classement sur lequel des utilisateurs de plusieurs fuseaux horaires sont en compétition simultanément, les choix faits la première semaine commencent à poser des problèmes la huitième semaine.
Nous avons construit une application de préparation aux examens couvrant plusieurs certifications professionnelles dans plusieurs régions. Les décisions d’architecture prises – notamment autour de la modélisation des données, de la modularité avec Swift Package Manager, du backend Supabase et du fonctionnement hors ligne – n’étaient pas évidentes au départ. Certaines d’entre elles ont nettement évolué entre le premier prototype et la version livrée.
Le problème de modélisation des données
Le modèle naïf pour une question d’examen est une table avec un énoncé, quatre options de réponse et l’indice de la bonne réponse. Cela fonctionne jusqu’à ce que vous deviez prendre en charge des questions avec des images, des questions avec des blocs de code, des questions avec un nombre variable d’options, ou des questions où plusieurs réponses sont correctes. Changer le schéma après avoir chargé du contenu est coûteux, il vaut donc la peine de prévoir tous les formats de contenu dès le départ.
Le schéma sur lequel nous nous sommes arrêtés stocke les questions avec un champ type (choix unique, choix multiple, ordonnancement, extrait de code) et une colonne JSONB options qui contient l’ensemble des réponses. Cela donne à chaque type de question la souplesse nécessaire pour contenir ses propres données sans nécessiter une table séparée par type. Les bonnes réponses sont stockées sous forme de tableau JSONB d’indices plutôt que sous forme d’un entier unique, ce qui gère les questions comportant plusieurs bonnes réponses sans changement de schéma.
Les questions appartiennent à des domaines (domains), qui appartiennent à des examens (exams), qui appartiennent à des régions (regions). Cette hiérarchie à quatre niveaux semble être une complexité excessive jusqu’à ce que vous deviez prendre en charge le même organisme de certification proposant des programmes différents pour différents marchés, ou une seule application couvrant à la fois les filières de certification japonaises et allemandes dont les contenus se recoupent. La hiérarchie permet d’interroger « toutes les questions pour cet examen dans cette région à ce niveau de difficulté » avec une seule requête paramétrée plutôt qu’un filtrage au niveau de l’application.
La difficulté est stockée comme un entier (1–5) plutôt que comme une énumération. Les énumérations nécessitent des migrations lorsque vous voulez ajouter un niveau ; les entiers n’en nécessitent pas. Le frontend associe les entiers à des libellés.
Swift Package Manager pour une architecture modulaire
Une cible Xcode monolithique pour une base de code de cette taille crée un problème spécifique : les temps de compilation augmentent, et il devient difficile d’imposer des frontières entre les couches. Un module réseau ne devrait pas avoir accès aux composants d’interface. Le moteur de questions ne devrait pas se soucier de savoir s’il tourne dans une application iOS ou dans un environnement de test. Swift Package Manager rend ces frontières explicites et imposées par le compilateur.
Le graphe de packages sur lequel nous nous sommes arrêtés comporte quatre packages principaux :
- QuestionEngine – Logique pure : sélection de questions, évaluation des réponses, calcul des scores de session, planification par répétition espacée. Aucun UIKit, aucun SwiftUI, aucune dépendance Supabase. Entièrement testable de manière isolée.
- ExamData – Modèles de données et couches d’encapsulation du client Supabase. Définit les types
Question,Exam,UserProgressetLeaderboardEntry. Importe le client Swift Supabase. - OfflineStore – Pile Core Data et logique de synchronisation. Dépend d’ExamData pour les définitions de types. Gère le cache local et le suivi des modifications pour la synchronisation.
- AppUI – Vues SwiftUI et modèles de vue. Importe QuestionEngine et ExamData. N’a pas de connaissance directe d’OfflineStore.
La cible principale de l’application compose les quatre packages. Cette structure signifie que vous pouvez exécuter la suite de tests complète de QuestionEngine sans lancer un simulateur, car le package n’a pas de dépendances de plateforme. Cela signifie également que lorsque le client Swift Supabase a introduit une modification incompatible dans une version mineure, le périmètre touché se limitait à ExamData – nous avons mis à jour un package, le reste a compilé sans modification.
En contrepartie, la résolution et la mise en cache des packages SPM peuvent être capricieuses dans Xcode, en particulier après des compilations à partir de zéro. Avoir une cible Makefile qui exécute xcodebuild -resolvePackageDependencies avant les compilations en intégration continue élimine la plupart de ces surprises.
Le backend Supabase
Supabase était le bon choix pour ce backend pour deux raisons : la sécurité au niveau des lignes gère l’accès aux données dans une architecture mutualisée sans protections au niveau de l’application, et le schéma est entièrement PostgreSQL, ce qui signifie que toutes les capacités de requête sont disponibles lors de la construction du modèle de lecture du classement et de la couche de production de rapports.
La progression des utilisateurs est stockée par question, par session. Chaque ligne enregistre l’identifiant de la question, la réponse donnée, si elle était correcte, le temps nécessaire et l’horodatage de la session. C’est plus granulaire que la plupart des applications en ont besoin, mais cela rend la planification par répétition espacée possible : l’algorithme dispose de l’historique complet sur lequel s’appuyer, pas seulement d’un score cumulé. Des vues agrégées sur cette table alimentent les statistiques du tableau de bord sans que l’application ait besoin de les calculer.
Les politiques de sécurité au niveau des lignes garantissent que les utilisateurs ne peuvent lire et écrire que leurs propres lignes de progression. Les tables de questions, examens, domaines et régions sont publiquement lisibles – aucune authentification n’est requise pour l’accès au contenu, ce qui simplifie la synchronisation hors ligne. Seules les tables spécifiques aux utilisateurs nécessitent une session authentifiée.
Une chose que nous avons mal gérée initialement : essayer d’utiliser Supabase Realtime pour la synchronisation du contenu des questions. Realtime est conçu pour les petites modifications de lignes fréquentes, pas pour synchroniser des milliers de lignes de contenu relativement statique. Nous avons remplacé l’abonnement Realtime sur la table des questions par une tâche planifiée d’interrogation périodique qui vérifie un horodatage content_updated_at sur l’enregistrement d’examen et télécharge un delta uniquement lorsque le contenu a changé. C’est moins élégant mais bien plus fiable à l’échelle de la bibliothèque de contenu.
Conception privilégiant le fonctionnement hors ligne
Une application de préparation aux examens qui nécessite une connexion réseau n’est pas utile au candidat qui étudie dans un avion. Le mode hors ligne n’est pas optionnel ici – c’est le cas d’utilisation principal.
L’approche que nous avons adoptée combine l’envoi de contenu via Wi-Fi et un cache Core Data local qui est la source de données principale pour le moteur de questions. Lorsque l’application se lance avec une connexion réseau, elle vérifie si le contenu de l’examen a été mis à jour depuis la dernière synchronisation. Si c’est le cas, elle télécharge le delta et l’écrit dans Core Data. S’il n’y a pas de connexion réseau, elle lit depuis Core Data sans difficulté. La progression de l’utilisateur est écrite localement en premier et synchronisée avec Supabase lorsqu’une connexion est disponible.
Le modèle de gestion des conflits de synchronisation est volontairement simple : le serveur fait autorité pour le contenu des questions, et le client fait autorité pour la progression des utilisateurs. Il n’y a pas de modifications de contenu bidirectionnelles qui pourraient entrer en conflit. Les lignes de progression des utilisateurs ne peuvent être qu’ajoutées – une session terminée n’est jamais mise à jour, seules de nouvelles sessions sont ajoutées – ce qui signifie que la logique de synchronisation se résume à ceci : « Envoyer au serveur les lignes dont les horodatages sont plus récents que la dernière synchronisation réussie. » Cette règle ne reste simple que si chaque ligne possède un identifiant unique, afin qu’une nouvelle tentative d’envoi ne puisse pas créer de doublons, et si l’horloge de l’appareil est utilisée de manière cohérente pour les horodatages des lignes comme pour le marqueur de synchronisation.
Un détail qui compte : le stockage hors ligne devrait indiquer la date de mise à jour de son contenu à l’utilisateur. Une bannière qui dit « Contenu mis à jour il y a 3 jours » permet d’ajuster les attentes. Servir silencieusement du contenu périmé lorsque de nouvelles questions ont été ajoutées à l’ensemble de questions de l’examen crée de la confusion chez les utilisateurs lorsque leurs scores diffèrent de ceux des autres utilisateurs qui se sont synchronisés plus récemment.
Classements sous charge concurrente
Les classements sont simples à lire mais étonnamment subtils à écrire correctement en présence de mises à jour concurrentes. L’approche naïve – maintenir une colonne score par utilisateur qui est mise à jour après chaque session – crée une contention d’écriture lorsque de nombreux utilisateurs terminent des sessions simultanément, et elle est vulnérable aux conditions de concurrence où deux mises à jour simultanées s’écrasent mutuellement.
L’approche qui a fonctionné consiste à traiter le classement comme un modèle de lecture dérivé de l’historique des sessions, plutôt que comme une colonne de score mise à jour en continu. Une vue matérialisée PostgreSQL agrège les scores de session par utilisateur lors d’un rafraîchissement planifié. Le rafraîchissement s’exécute toutes les quelques minutes plutôt qu’à chaque écriture, ce qui signifie que le classement est cohérent à terme plutôt qu’en temps réel – un compromis acceptable pour une application de révision. Les utilisateurs voient leur classement mis à jour quelques minutes après avoir terminé une session, ce qui répond à tous les cas d’utilisation sauf les compétitions d’examen en direct.
Pour les événements compétitifs où les classements en temps réel comptent, nous utilisons un compteur Redis externe à Supabase. Mais pour le parcours de révision habituel, l’approche de vue matérialisée est plus simple, moins coûteuse et évite les conditions de concurrence entraînant la perte de mises à jour lorsqu’une colonne de score est maintenue à jour.
Ce que nous ferions différemment
La colonne JSONB options pour les données de réponse était le bon choix, mais nous avons insuffisamment défini le schéma des objets options au départ. Différents développeurs ont ajouté des champs légèrement différents au fil du temps – certaines options avaient un champ explanation, certaines avaient un imageUrl, d’autres n’avaient ni l’un ni l’autre. Sans une structure validée imposée au moment de l’insertion, le contenu est devenu incohérent. Ajouter un schéma Zod côté TypeScript de l’outil d’administration et une contrainte CHECK sur la colonne Postgres aurait détecté cela plus tôt. Imposez la structure des données JSONB au point d’entrée, pas après coup.
La frontière de package SPM entre AppUI et OfflineStore était trop stricte. Plusieurs vues SwiftUI avaient besoin de savoir si elles affichaient des données en cache ou des données actualisées en direct, et cette information se trouvait dans OfflineStore. Nous avons fini par transmettre à AppUI un protocole auquel OfflineStore pouvait se conformer – ce qui est la bonne abstraction – mais il aurait été plus propre de concevoir ce protocole dès le début plutôt que de l’ajouter après coup.
Analyses connexes
- Concurrence Swift 6 : guide de migration pratique – Résolution, lors de la migration, des problèmes de frontières entre acteurs qui apparaissent exactement dans ce type d’architecture SwiftUI multi-packages.
- De la conception SwiftUI à l’App Store en quelques semaines – Comment la version MVP d’une application comme celle-ci voit son périmètre défini et est livrée avant que l’architecture complète ne soit finalisée.
- Corriger les rejets d’icônes App Store – Les détails de soumission de dernière minute qui surprennent les équipes une fois le travail d’ingénierie terminé.