Pular para o conteúdo
Chandler Nguyen
IA7 min de leitura

IA

O modo offline do meu app de IA não precisava de IA. Precisava do entediante.

Quase construí um modo offline esperto e específico para IA no app iOS do DIALØGUE. Aí fiz uma pergunta mais entediante — o que o iOS já me dá de fábrica? — e o trabalho de verdade acabou sendo resistir à vontade de ser esperto. Aqui está a maquinaria padrão e nada glamourosa por trás de "baixe um episódio e ouça no avião", e por que o entediante era a escolha mais madura.

Recentemente lancei os downloads offline do app iOS do DIALØGUE — exatamente a parte que, no meu último post, eu despachei com um aceno de mão chamando de "offline resiliente" sem nunca explicar o que isso queria dizer. Este post é a explicação. E começa comigo quase construindo a coisa errada.

Aqui vai a confissão. O DIALØGUE é um app de podcasts com IA: você dá um tema ou um PDF, e ele gera uma conversa entre dois apresentadores. Então quando comecei a mexer no modo offline, minha cabeça pulou direto para algo em formato de IA. Talvez "offline" significasse pré-gerar o áudio no próprio dispositivo. Talvez cachear a saída do modelo, ou alguma pipeline esperta capaz de remontar um episódio sem rede. Tinha uma arquitetura inteira se formando na minha cabeça. Quase escrevi o design doc desse jeito.

Aí parei e fiz uma pergunta mais entediante: o que o iOS já me dá para isso?

A resposta, descobri, era "quase tudo". E o trabalho de verdade dessa funcionalidade não era construir algo inteligente. Era resistir à vontade de fazer isso.

A pergunta entediante me economizou um mês

Essa é a coisa que eu fico reaprendendo, post após post: complexidade não é sua amiga. Ainda mais quando você é uma pessoa só, construindo à noite e nos fins de semana.

Um podcast com IA, uma vez gerado, é só um arquivo de áudio parado numa URL. "Offline" não significa gerá-lo de novo. Significa baixar esse arquivo e tocá-lo depois. Isso não é um problema de IA. É um problema que a Apple resolve desde antes de a App Store existir.

Então, em vez de uma pipeline esperta, peguei a ferramenta mais entediante e mais testada da caixa: uma background URLSession. Se você nunca esbarrou nela, a ideia é simples — você entrega o download para o sistema operacional, e ele continua mesmo quando seu app está em segundo plano ou foi encerrado, e depois reabre seu app para te entregar o arquivo pronto.

let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true

Umas duas linhas, e agora o sistema operacional é dono das partes genuinamente difíceis. Não escrevi um motor de downloads. Peguei emprestado o da Apple.

A mesma história se repetia onde quer que eu olhasse:

  • Download interrompido no meio? O iOS te entrega "resume data" — um pequeno blob que deixa um download continuar de onde parou em vez de começar do zero. Eu só escrevo isso no disco (na pasta Caches, já que é descartável) e passo de volta na próxima vez.
  • Não quer queimar os dados móveis do usuário? Uma linha: request.allowsCellularAccess = !wifiOnly. Depois o sistema operacional espera o WiFi sozinho. Eu não monitoro a conexão. Não faço polling de nada.
  • Precisa mostrar quanto espaço os downloads estão ocupando? O ByteCountFormatter transforma uma contagem de bytes em "23,4 MB" com as unidades corretamente localizadas. Isso também não fui eu que escrevi.

Quero ser honesto sobre como isso é gostoso, porque vai contra o instinto. Olhando para trás, a jogada certa não era um código mais esperto. Era admitir que isso era um problema já resolvido e pegar o trabalho emprestado em vez de tentar ser mais esperto que ele.

Mas "entediante" não significava "fácil"

Agora eu preciso admitir uma coisa, porque se eu te dissesse que as práticas padrão tornaram isso trivial, eu estaria mentindo — e qualquer desenvolvedor iOS lendo isto me pegaria em uns dez segundos.

Padrão não é a mesma coisa que trivial. As ferramentas entediantes são entediantes porque são surradas, não porque não têm atrito. Aqui estão as coisas que de fato me custaram tempo, caso a lista te poupe um pouco:

1. O arquivo temporário some enquanto você está olhando para ele. Quando um download em segundo plano termina, o iOS te chama de volta e aponta para o arquivo num local temporário. A pegadinha que ninguém menciona: esse arquivo é apagado no instante em que seu callback retorna. Você tem que movê-lo para um lugar permanente de forma síncrona, ali mesmo dentro da função, antes de fazer qualquer outra coisa. Fica mais esquisito ainda, porque esse callback roda fora da thread principal e só sabe qual download terminou, não qual episódio ele é nos termos do meu app. Então o código faz um passo duplo feio: mover o arquivo para um nome temporário na hora, e depois pular para a thread principal para renomeá-lo assim que souber o que ele é. Não é bonito. Mas a alternativa é um arquivo que sumiu antes de você conseguir salvá-lo.

2. As sessões em segundo plano não fazem nada, em silêncio, no simulador. Passei um trecho vergonhoso convencido de que meu código estava quebrado. Não estava. A background URLSession simplesmente não se comporta de forma confiável no Simulador do iOS — sem erro, sem crash, nada baixa. A correção é feia e eu assumo:

#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif

Esse #if DEBUG não é elegante. Mas fingir que o simulador se comporta como um aparelho de verdade teria me custado muito mais do que admitir que ele não se comporta.

3. O Swift 6 me obrigou a dizer exatamente quem roda em qual thread. Os callbacks de download chegam numa thread de segundo plano. Meu estado de UI — os anelzinhos de progresso, o tique verde de "Baixado" — precisa atualizar na thread principal. A checagem de concorrência mais rígida do Swift 6 se recusou a me deixar ser desleixado nessa fronteira. Acabei com um commit de limpeza com o título, literalmente, "resolve Swift 6 actor-isolation warnings." Irritante no momento, certo em retrospecto. O compilador estava certo e eu errado.

4. O servidor nem sempre nomeia o arquivo de forma limpa. O áudio às vezes volta como audio/mpeg, às vezes como m4a, de vez em quando como outra coisa. Então tem uma funçãozinha sem graça que deduz a extensão do arquivo a partir do content type da resposta, recorre à URL se falhar, e usa .mp3 por padrão. Nada glamouroso. Necessário.

Nenhum desses é um problema de IA. Cada um é um problema de "como os computadores realmente funcionam". Esse é o imposto do caminho entediante. Ele é real — mas eu o pagaria toda vez, em vez do custo de manter algo esperto que eu mesmo inventei.

O que eu deliberadamente NÃO construí

Acho que a parte mais útil dessa funcionalidade toda é a lista das coisas que eu não fiz:

  • Não escrevi um motor de downloads. (Background URLSession.)
  • Não escrevi um monitor de conectividade. (allowsCellularAccess.)
  • Não inventei um protocolo de retomada. (Resume data do sistema operacional.)
  • Não construí nada específico de IA. (É um arquivo. Ele baixa.)

No que eu de fato investi cuidado foi algo pequeno e sem glamour: uma filinha para que não mais do que três downloads rodem ao mesmo tempo e o resto espere a vez; persistir os títulos dos episódios para que a tela de armazenamento mostre nomes reais depois de um cold launch em vez de "Episódio, Episódio, Episódio"; ordenar essa tela pelo maior arquivo primeiro, para que, quando você está tentando liberar espaço, os piores culpados fiquem no topo. E puxei as decisões de verdade — quantos espaços estão livres, como ordenar a lista — para funções simples sem nenhuma URLSession grudada, para poder testá-las unitariamente direito. Entediante. Testável. Entediante porque testável.

A lição, pela centésima vez mais ou menos

A esta altura eu já escrevi alguma versão deste post sobre reconstruir meu site, sobre uma conta de API de 54 dólares, sobre reconstruir este mesmo app. A lição continua sendo a mesma, e eu continuo precisando reaprendê-la: a solução esperta normalmente é a cara. "O que a plataforma já me dá?" — essa é a pergunta mais valiosa que eu posso fazer antes de escrever uma única linha de código.

Para um solo builder principalmente: cada coisa esperta que você inventa é uma coisa que só você vai ter que manter quando ela quebrar no pior momento possível. Cada coisa entediante e padrão que você pega emprestada é mantida por uma empresa com milhares de engenheiros por trás. Pela minha experiência, essa troca quase sempre vale a pena — e, honestamente, a disciplina de fazê-la é mais difícil do que a esperteza que ela substitui. Embora eu possa estar errado sobre onde exatamente a linha passa. Com certeza existem problemas em que a plataforma não te dá nada e você tem que construir a parte difícil sozinho — eu só não paro de descobrir que isso é mais raro do que meu instinto insiste.

Algumas das pessoas que usam o DIALØGUE agora conseguem baixar um episódio, entrar num avião e ouvir sem sinal. Parece uma funcionalidade que importa. O código por trás é quase agressivamente sem graça. Já fiz as pazes com isso. Acho que é esse o trabalho.

Você também faz isso — se flagra esticando a mão para a versão esperta quando a entediante estava bem ali? Ou sou só eu, lento para aprender a mesma lição de novo e de novo? Eu ia gostar de verdade de saber como outras pessoas lutam contra esse instinto.

Abraços, Chandler