tva
← Artículos

Desarrollo de una app de preparación para exámenes con miles de preguntas: decisiones de arquitectura

El problema de la mayoría de las apps de preparación para exámenes no es el contenido de las preguntas, sino la arquitectura que lo rodea. Unos cientos de preguntas almacenadas en un archivo JSON plano funcionan bien para una sola certificación en un solo mercado. Pero, al añadir variantes regionales, varios niveles de dificultad, tipos de preguntas distintos según la entidad examinadora y una clasificación en la que compiten simultáneamente usuarios de distintas zonas horarias, las decisiones de la primera semana empiezan a causar problemas en la octava.

Desarrollamos una app de preparación para exámenes que abarca varias certificaciones profesionales en distintas regiones. Las decisiones de arquitectura que tomamos —en particular sobre el modelado de datos, la modularidad con Swift Package Manager, el backend de Supabase y el funcionamiento sin conexión— no eran evidentes al principio. Algunas cambiaron considerablemente entre el primer prototipo y la versión publicada.

El problema del modelado de datos

El modelo más simple para una pregunta de examen es una tabla con un enunciado, cuatro opciones de respuesta y el índice de la respuesta correcta. Funciona hasta que hay que admitir preguntas con imágenes, bloques de código, un número variable de opciones o varias respuestas correctas. Cambiar el esquema una vez cargado el contenido resulta costoso, así que conviene diseñarlo desde el principio para toda la variedad de contenido prevista.

El esquema que adoptamos almacena las preguntas con un campo type (single-choice, multi-choice, ordering, code-snippet) y una columna JSONB options que contiene el conjunto de respuestas. Esto permite que cada tipo de pregunta incluya los datos que necesita sin exigir una tabla independiente por tipo. Las respuestas correctas se almacenan como un arreglo JSONB de índices en lugar de un único entero, lo que permite admitir varias respuestas correctas sin cambiar el esquema.

Las preguntas pertenecen a domains, que pertenecen a exams, que pertenecen a regions. Esta jerarquía de cuatro niveles parece excesiva hasta que hay que admitir que una misma entidad certificadora publique temarios distintos para distintos mercados, o que una sola app ofrezca itinerarios de certificación japoneses y alemanes con contenido compartido. La jerarquía permite consultar «todas las preguntas de este examen en esta región con este nivel de dificultad» mediante una única consulta parametrizada, sin filtrar en la capa de aplicación.

La dificultad se almacena como un entero (1–5) en lugar de un enum. Los enum requieren migraciones para añadir un nivel; los enteros, no. El frontend convierte los enteros en etiquetas.

Swift Package Manager para una arquitectura modular

Un único target de Xcode para una base de código de este tamaño crea un problema concreto: aumentan los tiempos de compilación y resulta difícil imponer límites entre capas. Un módulo de red no debería acceder a componentes de interfaz. Al motor de preguntas debería darle igual ejecutarse dentro de una app de iOS o de un entorno de pruebas. Swift Package Manager hace explícitos estos límites y permite que el compilador los imponga.

El grafo de paquetes que adoptamos tiene cuatro paquetes principales:

  • QuestionEngine — Lógica pura: selección de preguntas, evaluación de respuestas, puntuación de sesiones y programación de la repetición espaciada. Sin UIKit, SwiftUI ni dependencia de Supabase. Se puede probar íntegramente de forma aislada.
  • ExamData — Modelos de datos y envoltorios del cliente de Supabase. Define los tipos Question, Exam, UserProgress y LeaderboardEntry. Importa el cliente Swift de Supabase.
  • OfflineStore — Stack de Core Data y lógica de sincronización. Depende de ExamData para las definiciones de tipos. Gestiona la caché local y el seguimiento de cambios para la sincronización.
  • AppUI — Vistas y modelos de vista de SwiftUI. Importa QuestionEngine y ExamData. No conoce directamente OfflineStore.

El target principal de la app combina los cuatro paquetes. Esta estructura permite ejecutar toda la batería de pruebas de QuestionEngine sin iniciar un simulador, porque el paquete no depende de la plataforma. También permitió limitar a ExamData el impacto de un cambio incompatible en una versión menor del cliente Swift de Supabase: actualizamos un paquete y el resto compiló sin cambios.

La contrapartida es que la resolución de paquetes de SPM y su caché pueden comportarse de manera imprevisible en Xcode, especialmente después de compilaciones desde cero. Un target de Makefile que ejecute xcodebuild -resolvePackageDependencies antes de las compilaciones de CI elimina la mayoría de esas sorpresas.

El backend de Supabase

Supabase fue la opción adecuada para este backend por dos razones: la seguridad a nivel de fila (Row Level Security) gestiona el acceso a datos multiinquilino sin controles en la capa de aplicación, y el esquema es íntegramente PostgreSQL, por lo que todas sus capacidades de consulta están disponibles al construir el modelo de lectura de la clasificación y la capa de generación de informes.

El progreso del usuario se almacena por pregunta y por sesión. Cada fila registra el ID de la pregunta, la respuesta dada, si fue correcta, el tiempo empleado y la marca de tiempo de la sesión. Este detalle supera lo que necesitan la mayoría de las apps, pero permite programar la repetición espaciada: el algoritmo dispone del historial completo, no solo de una puntuación acumulada. Las vistas agregadas de esta tabla alimentan las estadísticas del panel sin que la aplicación tenga que calcularlas.

Las políticas de seguridad a nivel de fila garantizan que los usuarios solo puedan leer y escribir sus propias filas de progreso. Las tablas de preguntas, exámenes, dominios y regiones permiten la lectura pública: el acceso al contenido no requiere autenticación, lo que simplifica la sincronización para el uso sin conexión. Solo las tablas específicas de cada usuario requieren una sesión autenticada.

Un error inicial fue intentar usar Supabase Realtime para sincronizar el contenido de las preguntas. Realtime está diseñado para cambios pequeños y frecuentes en las filas, no para sincronizar miles de filas de contenido relativamente estático. Sustituimos la suscripción de Realtime a la tabla de preguntas por una tarea de consulta periódica que comprueba la marca de tiempo content_updated_at del registro del examen y descarga únicamente los cambios cuando se ha actualizado el contenido. Es menos elegante, pero mucho más fiable con el volumen de nuestra biblioteca de contenido.

Diseño centrado en el uso sin conexión

Una app de preparación para exámenes que exige conexión de red no sirve a quien estudia durante un vuelo. Aquí el funcionamiento sin conexión no es opcional: es el caso de uso principal.

Combinamos la descarga de contenido por wifi con una caché local de Core Data que actúa como fuente principal de datos para el motor de preguntas. Cuando la app se inicia con conexión de red, comprueba si el contenido del examen ha cambiado desde la última sincronización. Si ha cambiado, descarga las diferencias y las escribe en Core Data. Sin conexión, lee directamente de Core Data. El progreso del usuario se escribe primero en local y se sincroniza con Supabase cuando hay conexión.

El modelo de resolución de conflictos de sincronización es sencillo por diseño: el servidor es la fuente de referencia para el contenido de las preguntas y el cliente lo es para el progreso del usuario. No hay ediciones bidireccionales del contenido que puedan entrar en conflicto. Las filas de progreso solo se añaden: una sesión completada nunca se actualiza, solo se agregan nuevas sesiones. Por tanto, la lógica de sincronización consiste simplemente en «cargar las filas con marcas de tiempo posteriores a la última sincronización correcta». Esta regla solo sigue siendo sencilla si cada fila tiene un ID único, de modo que un reintento de carga no pueda crear duplicados, y si se utiliza de forma coherente el reloj del dispositivo tanto para las marcas de tiempo de las filas como para el marcador de sincronización.

Un detalle importante: el almacenamiento sin conexión debe indicar al usuario cuándo se actualizó. Un aviso que diga «Contenido actualizado por última vez hace 3 días» establece expectativas adecuadas. Mostrar contenido desactualizado sin avisar cuando se han añadido preguntas al banco del examen confunde a los usuarios si sus puntuaciones difieren de las de quienes sincronizaron más recientemente.

Clasificaciones con actualizaciones concurrentes

Las clasificaciones son fáciles de leer, pero escribirlas correctamente con actualizaciones concurrentes exige más cuidado del que parece. El enfoque más simple —mantener una columna score por usuario que se actualice después de cada sesión— genera contención de escritura cuando muchos usuarios terminan sesiones a la vez y es vulnerable a condiciones de carrera en las que dos actualizaciones concurrentes se sobrescriben.

El enfoque que funcionó fue tratar la clasificación como un modelo de lectura derivado del historial de sesiones, en lugar de mantener una columna de puntuación. Una vista materializada de PostgreSQL agrega las puntuaciones de las sesiones por usuario mediante actualizaciones programadas. Se actualiza cada pocos minutos, en vez de con cada escritura, por lo que la clasificación tiene consistencia eventual y no se actualiza en tiempo real: una contrapartida aceptable para una app de estudio. Los usuarios ven su posición actualizada pocos minutos después de completar una sesión, lo que cubre todos los casos de uso salvo los eventos competitivos de exámenes en directo.

Para los eventos competitivos donde importa la clasificación en tiempo real, utilizamos un contador independiente respaldado por Redis, fuera de Supabase. Para el flujo habitual de estudio, el enfoque de vistas materializadas es más sencillo, económico y evita las condiciones de carrera con pérdida de actualizaciones que se producen al mantener una columna de puntuación.

Qué haríamos de otra manera

La columna JSONB options para las respuestas fue una buena decisión, pero al principio no definimos suficientemente el esquema de los objetos de opciones. Con el tiempo, distintos desarrolladores añadieron campos ligeramente diferentes: algunas opciones tenían un campo explanation, otras un imageUrl y otras ninguno. Sin una estructura validada al insertar los datos, el contenido se volvió inconsistente. Un esquema Zod en el código TypeScript de la herramienta de administración y una restricción CHECK en la columna de Postgres habrían detectado el problema antes. Valide la estructura de los datos JSONB en el punto de entrada, no después.

El límite entre los paquetes AppUI y OfflineStore era demasiado estricto. Varias vistas de SwiftUI necesitaban saber si mostraban datos de la caché o datos en vivo, y esa información estaba en OfflineStore. Terminamos pasando a AppUI un protocolo que OfflineStore podía implementar —la abstracción adecuada—, pero habría sido más limpio diseñarlo desde el principio en lugar de incorporarlo después.

Artículos relacionados

Artículos relacionados