Os botões não estavam quebrados: a main thread estava ocupada
Esse app foi renomeado depois para Deep Cut Atlas. Abaixo ele aparece como “Discoverer”, porque era esse o nome no dia em que isso aconteceu.
Redesenhei a aba History do meu app de Apple Music para abrir uma folha por faixa: tocar a música, adicionar o álbum a uma playlist, marcar como “sem interesse”, e ver mais do mesmo artista. Passou em 118 testes unitários. Passou numa rodada de automação de interface no simulador onde eu toquei em cada controle. Aí rodei no meu telefone e os botões da folha não responderam por um ou dois segundos depois que ela abriu.
Passei tempo demais chutando. Minha primeira teoria foi um bug de paginação. A segunda foi que mapear algumas centenas de modelos estava lento. As duas estavam erradas, e um revisor ficou me dizendo a mesma coisa: para de chutar, meça. Eu tinha pesquisado as ferramentas certas para isso, o instrumento Hangs do Instruments, o botão de Hang Detection no dispositivo em Ajustes → Developer, e depois tentei raciocinar meu caminho para não precisar delas.
A correção veio de uma frase de feedback real de dispositivo: “os botões de tocar, adicionar e ignorar não respondem até a seção ‘more’ carregar”.
Esse é o bug inteiro. A seção “more from this artist” carrega de forma assíncrona. Para mostrar só os álbuns que você ainda não tem, o serviço buscava sua biblioteca inteira do Apple Music e mapeava ela para chaves de comparação. O serviço é anotado com @MainActor, então esse mapeamento rodava na main thread. O SwiftUI processa toques de botão na main thread também. Enquanto o mapeamento da biblioteca rodava, cada toque só ficava na fila. Os botões estavam bem. A thread estava ocupada.
O .task do SwiftUI e as ações de botão de uma view rodam no main actor por padrão. Um await numa chamada de rede libera a thread, mas o trabalho síncrono entre awaits, como mapear milhares de álbuns, não libera. Com uma biblioteca pequena você nunca notaria. Com uma real é um travamento visível.
A correção foi não fazer esse trabalho nesse caminho. A tela History já tinha calculado as chaves da biblioteca quando carregou. Então passei elas para a folha e filtrei as sugestões no cliente, e disse para a busca do catálogo pular a passada pela biblioteca inteira. Os botões respondem instantaneamente agora porque a main thread não está fazendo nada pesado enquanto a folha está aberta.
A mesma forma explicava um segundo travamento, voltar para a lista relia a biblioteca inteira e rodava uma busca síncrona no banco de dados a cada fechamento. Só precisava reconferir a única playlist que talvez tivesse mudado, e só quando algo realmente mudou.
A lição que eu de fato guardei não foi técnica. Testes unitários e automação de simulador verificam lógica e renderização. Eles não verificam se um toque parece instantâneo, e rodam em dados mock que nunca reproduzem a performance de uma biblioteca real. Então adicionei uma regra ao projeto: nenhuma funcionalidade de interface está “pronta” até uma passada em dispositivo com Hang Detection ligado confirmar que todo toque responde rápido e que a ação de fato persistiu. As ferramentas para pegar isso existiram o tempo todo. Eu só precisava me obrigar a usar elas antes de chamar algo de terminado.
Leitura relacionada
Consertando o load path da History, e me pegando comentando demais o ajuste
Três fetches sequenciais em que um nem era necessário de cara, dois recálculos quadráticos, e um linter que expôs por que eu estava realmente escrevendo todos aqueles comentários.
O scan de 8 segundos escondido em cada refresh
Medir no dispositivo encontrou um scan da biblioteca inteira rodando em cada lote do Discover - e uma armadilha de medição de 16x onde o cache HTTP favoreceu o caminho errado.
A configuração que funcionava perfeitamente e parecia completamente quebrada
Um filtro padrão que só era aplicado ao reabrir o app, porque uma TabView mantém os view models vivos e o estado semeado na construção fica congelado. A correção apagou um conceito.
Você também pode achar útil
eSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba maisProton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais