tva
← Insights

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 autour de lui. Quelques centaines de questions stockées dans un fichier JSON plat fonctionnent bien pour une seule certification dans 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 en semaine un commencent à poser des problèmes en semaine huit.

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, la modularité de Swift Package Manager, le backend Supabase, et le comportement hors ligne – n'étaient pas évidentes au départ. Certaines d'entre elles ont changé significativement 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 une invite, quatre options de réponse et un index de 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 concevoir pour l'enveloppe de contenu complète 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 flexibilité de porter les données dont il a besoin sans nécessiter une table séparée par type. Les bonnes réponses sont stockées comme un tableau JSONB d'indices plutôt qu'un entier unique, ce qui gère les questions à réponses multiples correctes sans changement de schéma.

Les questions appartiennent à des domains, qui appartiennent à des exams, qui appartiennent à des regions. Cette hiérarchie à quatre niveaux semble être une sur-ingénierie jusqu'à ce que vous deviez prendre en charge le même organisme de certification émettant des syllabus différents pour différents marchés, ou une seule application servant à la fois les filières de certification japonaises et allemandes avec des contenus se chevauchant. 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 non. Le front-end fait correspondre les entiers aux étiquettes.

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 build 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 harnais 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, scoring 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 wrappers du client Supabase. Définit les types Question, Exam, UserProgress et LeaderboardEntry. 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 eu un changement incompatible dans une version mineure, le rayon de souffle était contenu à ExamData – nous avons mis à jour un package, le reste a compilé sans changement.

Le coût est que la résolution et la mise en cache des packages SPM peuvent être capricieuses dans Xcode, en particulier après des builds propres. Avoir une cible Makefile qui exécute xcodebuild -resolvePackageDependencies avant les builds CI é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 multi-tenant sans protections au niveau de l'application, et le système d'abonnement en temps réel rend les mises à jour du classement simples. Le schéma est entièrement PostgreSQL, ce qui signifie que toutes les capacités de requête sont disponibles lors de la construction de la couche de reporting.

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 pris 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 avec lequel travailler, pas seulement d'un score courant. 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 le cas de 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 petits changements de lignes à haute fréquence, 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 de polling planifiée 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 hors ligne d'abord

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 est la distribution de contenu sur Wi-Fi combinée à 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 se plaindre. La progression de l'utilisateur est écrite localement en premier et synchronisée avec Supabase lorsqu'une connexion est disponible.

Le modèle de conflit de synchronisation est simple par conception : 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 sont en ajout uniquement – une session terminée n'est jamais mise à jour, seules de nouvelles sessions sont ajoutées – ce qui signifie que la logique de synchronisation est un simple “télécharger les lignes avec des horodatages plus récents que la dernière synchronisation réussie.”

Un détail qui compte : le store hors ligne devrait indiquer sa fraîcheur à l'utilisateur. Une bannière qui dit “Contenu mis à jour il y a 3 jours” établit des attentes appropriées. Servir silencieusement du contenu périmé lorsque de nouvelles questions ont été ajoutées au pool d'examens crée de la confusion chez les utilisateurs lorsque leurs scores diffèrent de ceux des pairs qui se sont synchronisés plus récemment.

Classements sous charge concurrente

Les classements sont simples à lire mais étonnamment subtils à écrire correctement sous des 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é est de traiter le classement comme un modèle de lecture dérivé de l'historique des sessions, pas une colonne de score maintenue. Une vue matérialisée PostgreSQL agrège les scores de session par utilisateur sur 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 éventuellement cohérent plutôt qu'en temps réel – un compromis acceptable pour une application d'étude. Les utilisateurs voient leur classement mis à jour dans quelques minutes après avoir terminé une session, ce qui satisfait chaque cas d'utilisation sauf les événements d'examen compétitifs 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 flux d'étude standard, l'approche de vue matérialisée est plus simple, moins coûteuse et correcte sous n'importe quelle charge d'écriture concurrente.

Ce que nous ferions différemment

La colonne JSONB options pour les données de réponse était le bon choix, mais nous avons sous-spécifié le schéma des objets options tôt. 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 avaient ni l'un ni l'autre. Sans une forme validée appliqué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 forme des données JSONB à la frontière, 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 rendaient des données en cache ou des données en direct, et cette information se trouvait dans OfflineStore. Nous avons fini par passer un protocole à travers AppUI que OfflineStore pouvait satisfaire – 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 le rétrofiter.

Ressources connexes

Articles connexes