tva
← Artigos

Criando um app de preparação para exames com milhares de questões: 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 que sustenta o aplicativo. Algumas centenas de perguntas armazenadas em um arquivo JSON simples funcionam bem para uma única certificação em um único mercado. Mas, na realidade, quando 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 causar falhas 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 toda a variedade de conteúdo desde o início.

O esquema a 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údos 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 (de 1 a 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 target do 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 questões não deve depender de estar sendo executado dentro de um aplicativo iOS ou em um ambiente de teste. O Swift Package Manager torna essas fronteiras explícitas e impostas pelo compilador.

O grafo de pacotes a 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 — Infraestrutura 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 do SwiftUI. Importa QuestionEngine e ExamData. Não tem conhecimento direto do OfflineStore.

O target 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 alcance do impacto ficou restrito ao 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 no 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 de múltiplos clientes sem verificações de acesso na camada de aplicação, e o esquema é inteiramente PostgreSQL, o que significa que toda a gama de capacidades de consulta está disponível ao construir o modelo de leitura do ranking e 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. Visões agregadas sobre esta tabela alimentam as estatísticas do painel sem exigir que o aplicativo as calcule.

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 alterações pequenas e frequentes em linhas, não para sincronizar milhares de linhas de conteúdo relativamente estático. Substituímos a subscrição do Realtime na tabela de perguntas por uma tarefa agendada de consulta periódica (polling) que verifica um timestamp content_updated_at no registro do exame e baixa um conjunto de alterações apenas quando o conteúdo foi alterado. Isso é menos elegante, mas muito mais confiável na escala da biblioteca de conteúdo.

Projeto com prioridade para uso offline

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. A prioridade para uso offline não é opcional aqui — é o principal caso de uso.

A abordagem que adotamos é envio de conteúdo via Wi-Fi combinado com um cache local do Core Data que é a fonte principal de dados para o mecanismo de questões. 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 conjunto de alterações e o escreve no Core Data. Se não há conexão de rede, lê do Core Data normalmente. 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 definição: o servidor é a referência para o conteúdo das perguntas, e o cliente é a referência 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 de 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.” Essa regra só continua simples se cada linha tiver um ID único, para que uma nova tentativa de upload não crie duplicatas, e se o relógio do dispositivo for usado de forma consistente tanto nos timestamps das linhas quanto no marcador de sincronização.

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 banco de questões do exame cria confusão para o usuário quando suas pontuações diferem das de outros usuários que sincronizaram mais recentemente.

Rankings sob carga concorrente

Consultar rankings é simples, mas atualizá-los corretamente sob concorrência exige cuidados inesperados. 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 atualizada a cada escrita. 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 é de consistência eventual em vez de em tempo real — uma compensação 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 competitivos de exames 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 evita as condições de corrida que causam perda de atualizações ao manter uma coluna de pontuação atualizada.

O que faríamos de outra forma

A coluna JSONB options para dados de resposta foi a escolha certa, mas não especificamos suficientemente 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 estrutura validada e exigida no momento da inserção, o conteúdo se tornou 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. Valide a estrutura dos dados JSONB no ponto de entrada, não depois.

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 atuais do servidor, 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.

Artigos relacionados

Leitura complementar