Pular para o conteúdo
Development

A configuração que funcionava perfeitamente e parecia completamente quebrada

Por Victor Da Luz
iosswiftswiftuiuxdev-logdeep-cut-atlas

Essa semana eu adicionei uma tela de Configurações ao Deep Cut Atlas. Uma seção permite definir o filtro padrão da aba Discover - mostrar só álbuns, esconder singles, esse tipo de coisa. Eu construí, os testes unitários passaram, os toggles mudavam de estado, o UserDefaults salvava. Pronto.

Depois passei mentalmente por como eu de fato demonstraria isso: “mude o padrão nas Configurações, volte para o Discover, e você vai ver aplicado.” Só que não. Ao rastrear a sequência real de toques, percebi que isso ia parecer um bug.

Levei um segundo para entender o motivo, porque o código estava correto. Mas “correto” e “não quebrado” acabaram sendo coisas diferentes.

Por que nada acontecia

Veja como cada aba se configura:

struct DiscoverView: View {
    @State private var viewModel: DiscoverViewModel?
    var body: some View { /* ... */ }
        .task {
            if viewModel == nil {
                viewModel = DiscoverViewModel(defaultTypes: settings.filterTypes)
                await viewModel?.load()
            }
        }
}

O view model é criado uma única vez - note o if viewModel == nil - semeando seu filtro a partir do padrão salvo naquele momento. Esse é um padrão normal.

A pegadinha está na palavra “única”. Em uma TabView, as abas não são destruídas e reconstruídas quando você alterna entre elas. O SwiftUI mantém todas vivas para que a troca seja instantânea. Então aquele .task roda uma única vez durante toda a vida do app, e o filtro é semeado exatamente uma vez - na primeira abertura.

O que significa: abra as Configurações, mude o padrão do Discover, volte para o Discover. O view model foi construído há muito tempo. Ele nunca relê a configuração. Nada muda. A única coisa que aplicaria seu novo padrão é fechar o app completamente e reabri-lo.

Então a demonstração é: mude uma configuração, veja que nada acontece, e a única forma de fazer “funcionar” é forçar o fechamento do app. Isso não é um bug no sentido de um crash ou uma saída errada - o código faz exatamente o que diz. Mas é absolutamente um bug no sentido que importa, que é a pessoa usando o app.

A correção foi apagar a ideia, não remendá-la

Meu primeiro instinto foi fazer a aba reler a configuração - ao aparecer, ao fechar uma sheet, em algum lugar. Mas toda versão disso brigava consigo mesma. Ressemear quando a aba reaparece? Aí trocar de aba apaga o filtro que você definiu dentro da aba. Ressemear só quando mudou? Agora estou rastreando tokens de mudança. Cada remendo adicionava um caso especial, que geralmente é o sinal de que o modelo está errado.

O modelo estava errado. Eu tinha dois conceitos - um “padrão” guardado nas Configurações e um “filtro atual” vivendo na aba - e eu vivia tentando sincronizá-los. Então apaguei um deles. Agora existe só o filtro, e ele vive em um único armazenamento compartilhado e persistido:

@MainActor @Observable
final class DiscoverViewModel {
    private let settings: SettingsStore   // shared, injected
    var selectedTypes: Set<RecordingType> {
        get { settings.filterTypes }
        set { settings.filterTypes = newValue }
    }
}

Os chips de filtro da aba e a tela de Configurações agora se ligam à mesma coisa. Mude em qualquer lugar, muda em todo lugar, ao vivo, e fica lembrado entre uma abertura e outra. Não existe mais “padrão versus atual” porque não existem mais duas coisas de nada. Toda aquela classe de bugs de dados desatualizados simplesmente evaporou, porque não sobrou nada para manter sincronizado.

Também acabou sendo o comportamento que as pessoas realmente esperam. “Lembrar o filtro que eu escolhi” é o que quase todo app faz. O modelo de “resetar para um padrão a cada sessão” que eu tinha construído sem querer nem é desejável - era só um artefato de semear o estado na construção.

A parte que eu quero lembrar

Duas coisas ficaram comigo. Primeiro, a específica do SwiftUI: um view model guardado no @State de uma aba sobrevive a toda troca de aba, então qualquer coisa que você semeie nele na criação fica congelada até o app reabrir. Se um valor precisa acompanhar uma fonte que pode mudar, não copie o valor para dentro - leia da fonte compartilhada.

Segundo, a maior: testes passando e um diff limpo não me diziam nada sobre se a funcionalidade era boa. Foi preciso imaginar a sequência real de toques - mudar a configuração, ver que nada acontece, forçar o fechamento - para trazer à tona um problema que o código nunca revelaria sozinho. A revisão mais valiosa dessa funcionalidade não teve nada a ver com o código. Foi imaginar uma pessoa usando o app, e perceber que essa pessoa se sentiria enganada.

Leitura relacionada

Você também pode achar útil

Proton

Proton 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
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
Proton

Proton Drive

Armazenamento em nuvem criptografado, 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 mais