tva
← Insights

Criando um Aplicativo de Preparação para Exames com Milhares de Perguntas: Decisões de Arquitetura

O problema com a maioria dos aplicativos de preparação para exames não é o conteúdo das perguntas – é a arquitetura ao redor. Algumas centenas de perguntas armazenadas em um arquivo JSON plano funcionam bem para uma única certificação em um único mercado. Mas na realidade, uma vez que você adiciona variantes regionais, múltiplos níveis de dificuldade, tipos de perguntas que diferem por entidade certificadora e um ranking em que usuários de diferentes fusos horários competem simultaneamente, as escolhas feitas na primeira semana começam a quebrar coisas na oitava semana.

Construímos um aplicativo de preparação para exames abrangendo múltiplas certificações profissionais em várias regiões. As decisões de arquitetura que tomamos – particularmente em relação à modelagem de dados, modularidade com Swift Package Manager, o backend Supabase e o comportamento offline – não eram óbvias no início. Algumas delas mudaram significativamente entre o primeiro protótipo e a versão que foi lançada.

O Problema da Modelagem de Dados

O modelo ingênuo para uma pergunta de exame é uma tabela com um enunciado, quatro opções de resposta e um índice de resposta correta. Isso funciona até que você precise suportar perguntas com imagens, perguntas com blocos de código, perguntas com números variáveis de opções ou perguntas onde múltiplas respostas são corretas. Mudar o esquema após ter o conteúdo carregado é custoso, então vale a pena projetar para o envelope de conteúdo completo desde o início.

O esquema em que chegamos armazena perguntas com um campo type (escolha única, múltipla escolha, ordenação, trecho de código) e uma coluna JSONB options que contém o conjunto de respostas. Isso dá a cada tipo de pergunta a flexibilidade de carregar os dados de que precisa sem exigir uma tabela separada por tipo. As respostas corretas são armazenadas como um array JSONB de índices em vez de um único inteiro, o que lida com perguntas de múltiplas respostas corretas sem mudança de esquema.

As perguntas pertencem a domains, que pertencem a exams, que pertencem a regions. Essa hierarquia de quatro níveis parece engenharia excessiva até que você precise suportar o mesmo órgão certificador emitindo diferentes currículos para diferentes mercados, ou um único aplicativo atendendo trilhas de certificação japonesas e alemãs com conteúdo sobrepostos. A hierarquia torna possível consultar “todas as perguntas para este exame nesta região neste nível de dificuldade” com uma única consulta parametrizada em vez de filtragem na camada de aplicação.

A dificuldade é armazenada como um inteiro (1–5) em vez de um enum. Enums exigem migrações quando você deseja adicionar um nível; inteiros não. O front-end mapeia inteiros para rótulos.

Swift Package Manager para Arquitetura Modular

Um alvo Xcode monolítico para uma base de código desse tamanho cria um tipo específico de problema: os tempos de build crescem e torna-se difícil impor fronteiras entre camadas. Um módulo de rede não deve ter acesso a componentes de UI. O mecanismo de perguntas não deve se importar se está rodando dentro de um aplicativo iOS ou em um harness de teste. O Swift Package Manager torna essas fronteiras explícitas e impostas pelo compilador.

O grafo de pacotes em que chegamos tem quatro pacotes principais:

  • QuestionEngine – Lógica pura: seleção de perguntas, avaliação de respostas, pontuação de sessão, agendamento de repetição espaçada. Sem UIKit, sem SwiftUI, sem dependência do Supabase. Totalmente testável isoladamente.
  • ExamData – Modelos de dados e wrappers do cliente Supabase. Define os tipos Question, Exam, UserProgress e LeaderboardEntry. Importa o cliente Swift do Supabase.
  • OfflineStore – Stack do Core Data e lógica de sincronização. Depende de ExamData para definições de tipo. Gerencia o cache local e o rastreamento de alterações para sincronização.
  • AppUI – Views e view models SwiftUI. Importa QuestionEngine e ExamData. Não tem conhecimento direto do OfflineStore.

O alvo principal do aplicativo compõe todos os quatro pacotes. Essa estrutura significa que você pode executar o conjunto completo de testes do QuestionEngine sem iniciar um simulador, porque o pacote não tem dependências de plataforma. Também significa que quando o cliente Swift do Supabase teve uma mudança incompatível em uma versão menor, o raio de explosão foi contido no ExamData – atualizamos um pacote e o restante compilou sem alterações.

O custo é que a resolução e o cache de pacotes SPM podem ser temperamentais no Xcode, particularmente após builds limpos. Ter um alvo Makefile que executa xcodebuild -resolvePackageDependencies antes dos builds de CI elimina a maioria dessas surpresas.

O Backend Supabase

O Supabase foi a escolha certa para esse backend por dois motivos: a Row Level Security lida com o acesso a dados multilocatários sem guardas na camada de aplicação, e o sistema de assinatura em tempo real torna as atualizações do ranking diretas. O esquema é inteiramente PostgreSQL, o que significa que toda a gama de capacidades de consulta está disponível ao construir a camada de relatórios.

O progresso do usuário é armazenado por pergunta e por sessão. Cada linha registra o ID da pergunta, a resposta fornecida, se estava correta, o tempo gasto e o timestamp da sessão. Isso é mais granular do que a maioria dos aplicativos precisa, mas torna possível o agendamento de repetição espaçada: o algoritmo tem o histórico completo para trabalhar, não apenas uma pontuação acumulada. Visualizações agregadas sobre esta tabela alimentam as estatísticas do painel sem exigir que o aplicativo as compute.

As políticas de Row Level Security garantem que os usuários possam apenas ler e escrever suas próprias linhas de progresso. As tabelas de perguntas, exames, domínios e regiões são publicamente legíveis – sem autenticação necessária para acesso ao conteúdo, o que simplifica o caso de sincronização offline. Apenas as tabelas específicas do usuário requerem uma sessão autenticada.

Uma coisa que erramos inicialmente: tentar usar o Supabase Realtime para sincronização de conteúdo de perguntas. O Realtime é projetado para pequenas mudanças de linha de alta frequência, não para sincronizar milhares de linhas de conteúdo relativamente estático. Substituímos a assinatura do Realtime na tabela de perguntas por um job de polling agendado que verifica um timestamp content_updated_at no registro do exame e baixa um delta apenas quando o conteúdo foi alterado. Isso é menos elegante, mas muito mais confiável na escala da biblioteca de conteúdo.

Design Offline-First

Um aplicativo de preparação para exames que requer conexão de rede não é útil para o candidato que está estudando em um voo. Offline-first não é opcional aqui – é o caso de uso primário.

A abordagem que adotamos é conteúdo-push-no-wifi combinado com um cache local do Core Data que é a fonte de dados primária para o mecanismo de perguntas. Quando o aplicativo é iniciado com uma conexão de rede, ele verifica se o conteúdo do exame foi atualizado desde a última sincronização. Se foi, baixa o delta e o escreve no Core Data. Se não há conexão de rede, lê do Core Data sem reclamações. O progresso do usuário é escrito localmente primeiro e sincronizado com o Supabase quando uma conexão está disponível.

O modelo de conflito de sincronização é simples por design: o servidor é autoritário para o conteúdo das perguntas, e o cliente é autoritário para o progresso do usuário. Não há edições de conteúdo bidirecionais que possam conflitar. As linhas de progresso do usuário são somente para acréscimo – uma sessão concluída nunca é atualizada, apenas novas sessões são adicionadas – o que significa que a lógica de sincronização é um simples “fazer upload de linhas com timestamps mais recentes do que a última sincronização bem-sucedida.”

Um detalhe que importa: o armazenamento offline deve indicar sua atualidade ao usuário. Um banner que diz “Conteúdo atualizado pela última vez há 3 dias” define expectativas adequadas. Servir silenciosamente conteúdo desatualizado quando novas perguntas foram adicionadas ao pool do exame cria confusão para o usuário quando suas pontuações diferem de pares que sincronizaram mais recentemente.

Rankings Sob Carga Concorrente

Os rankings são diretos para ler, mas surpreendentemente sutis para escrever corretamente sob atualizações concorrentes. A abordagem ingênua – manter uma coluna score por usuário que é atualizada após cada sessão – cria contenção de escrita quando muitos usuários terminam sessões simultaneamente, e é vulnerável a condições de corrida onde duas atualizações concorrentes se sobrescrevem.

A abordagem que funcionou é tratar o ranking como um modelo de leitura derivado do histórico de sessões, não como uma coluna de pontuação mantida. Uma visão materializada do PostgreSQL agrega pontuações de sessão por usuário em uma atualização agendada. A atualização ocorre a cada poucos minutos em vez de em cada escrita, o que significa que o ranking é eventualmente consistente em vez de em tempo real – uma troca aceitável para um aplicativo de estudo. Os usuários veem seu ranking atualizado em poucos minutos após concluir uma sessão, o que atende a todos os casos de uso, exceto eventos de exame competitivo ao vivo.

Para eventos competitivos onde os rankings em tempo real importam, usamos um contador baseado em Redis fora do Supabase. Mas para o fluxo de estudo padrão, a abordagem de visão materializada é mais simples, mais barata e correta sob qualquer carga de escrita concorrente.

O Que Faríamos Diferente

A coluna JSONB options para dados de resposta foi a escolha certa, mas subespecificamos o esquema para os objetos de opções no início. Diferentes desenvolvedores adicionaram campos levemente diferentes ao longo do tempo – algumas opções tinham um campo explanation, algumas tinham um imageUrl, algumas não tinham nenhum. Sem uma forma validada imposta no momento da inserção, o conteúdo cresceu inconsistente. Adicionar um esquema Zod no lado TypeScript da ferramenta de administração e uma restrição CHECK na coluna do Postgres teria detectado isso mais cedo. Imponha a forma dos dados JSONB na fronteira, não depois do fato.

A fronteira do pacote SPM entre AppUI e OfflineStore foi muito estrita. Várias views SwiftUI precisavam saber se estavam renderizando a partir de dados em cache ou dados ao vivo, e essa informação estava no OfflineStore. Acabamos passando um protocolo pelo AppUI que o OfflineStore poderia satisfazer – que é a abstração correta – mas teria sido mais limpo projetar esse protocolo desde o início em vez de adaptá-lo depois.

Insights Relacionados

Artigos relacionados