Como o Uber reescreveu o aplicativo iOS com Swift

Então, amigos, sentem-se em círculo e ouçam a história do maior desastre da engenharia do qual participei. É uma história sobre política, arquitetura e a falácia lógica dos custos irrecuperáveis ​​(desculpe, estou apenas bebendo o Aberlour Cask Strength Single Malt Scotch agora).





Era 2016. Trump ainda não foi eleito presidente, então o movimento #DeleteUber ainda não começou. Travis Kalanick continuou sendo um gênero, vivíamos uma fase de crescimento hiperativo com a abertura de filiais em outros países, o sentimento público é geralmente positivo, todos estão felizes, o Uber está no seu melhor.



Mas o hipercrescimento teve problemas e o próprio aplicativo começou a funcionar mal. Antes disso, o número de desenvolvedores dobrava quase todos os anos e, quando você cresce tão rápido, obtém uma gama incrível de habilidades. Combinado com a mentalidade do hacker, que chamamos de "Vamos construir o construtor", isso significava uma arquitetura de aplicativo complexa e frágil. Naquela época, o aplicativo Uber tinha uma lógica extremamente pesada, por isso costumava travar. Estávamos constantemente lançando hotfixes, patches, lançamentos não planejados, etc. Além disso, a arquitetura não escalava bem.



Como resultado de todos esses problemas, um movimento crescente começou em todos os níveis da organização que se reuniu em torno da ideia de "reescrever o aplicativo do zero". Uma equipe foi formada para criar uma nova arquitetura móvel para o novo aplicativo. O objetivo era criar uma arquitetura que "daria suporte ao desenvolvimento móvel do Uber nos próximos cinco anos". Desenvolvemos para ambas as plataformas ao mesmo tempo. Todo o ciclo de desenvolvimento foi reiniciado.



O departamento de iOS aproveitou a oportunidade para implementar o Swift (então na versão 2.x). O Uber já havia experimentado o Swift no passado, mas, como tantos outros naquele estágio inicial de desenvolvimento da tecnologia, ele passou por muitos problemas e atrasou a implementação.



No entanto, o sentimento geral era que a maioria dos problemas do Swift na época eram devido à interoperabilidade pobre com Objective-C. E se escrevermos um aplicativo Swift puro, poderemos evitar problemas básicos.



Também houve uma ideia de usar os mesmos padrões básicos de arquitetura no Android e no iOS. Os desenvolvedores do Android eram grandes fãs do RxJava na época. A biblioteca RxSwift correspondente tirou proveito do paradigma de programação funcional em Swift. Tudo parecia simples.



Assim, uma pequena equipe de desenvolvimento (Design, Produto e Arquitetura) mergulhou de cabeça em novos padrões funcionais / reativos, uma nova linguagem e um novo aplicativo por vários meses. Tudo estava indo bem. A arquitetura dependia muito dos recursos avançados de linguagem do Swift.



A IU pode ser dimensionada para um grande número de aplicativos Uber, o paradigma de programação funcional parecia poderoso (embora um pouco difícil de aprender) e a arquitetura era baseada em um novo protocolo de rede de streaming em tempo real (eu escrevi esta parte).



Depois de alguns meses e várias demos marcantes, o movimento ganhou força. O projeto parecia bem-sucedido. Com um pequeno número de engenheiros, foi possível desenvolver excelentes funcionalidades em pouco tempo. A maior parte do produto está pronta. O guia é lindo.



Então começou a implantação para toda a empresa. Várias equipes começaram a adicionar seus próprios recursos ao novo aplicativo. No início, a empolgação com o novo criou uma onda de motivação e produtividade. A arquitetura previa o isolamento de funções, o que permitia um rápido avanço.



Mas assim que mais de dez engenheiros dominaram o Swift, o mecanismo bem coordenado começou a desmoronar. O compilador Swift ainda é significativamente mais lento do que Objective-C hoje, mas era praticamente inutilizável. O tempo de montagem disparou. A depuração foi completamente interrompida.



Em algum lugar, há um vídeo de uma das demos, onde um engenheiro do Uber digita uma instrução de uma linha no Xcode e espera 45 segundos para que as letras lentamente, uma a uma, apareçam no editor.



Então batemos em uma parede com um vinculador dinâmico. Na época, as bibliotecas do Swift só podiam ser vinculadas dinamicamente. Infelizmente, o linker foi executado em tempo polinomial, então o número máximo recomendado de bibliotecas da Apple em um único arquivo binário foi 6. Tínhamos 92, e o número continuou crescendo ...



Como resultado, depois de clicar no ícone do aplicativo, demorou 8-12 segundos antes mesmo de chamar o principal. Nosso aplicativo novinho em folha acabou sendo mais lento do que o antigo e desajeitado. Depois, havia o problema do tamanho do binário.



Infelizmente, quando os problemas começaram a se manifestar seriamente, já havíamos passado do ponto sem volta. Essa é a falácia lógica da falácia de custos irrecuperáveis. Nesse ponto, toda a empresa estava colocando toda a sua energia no novo aplicativo.



Milhares de pessoas de diferentes direções, milhões e milhões de dólares (não posso dar o número real, mas muito mais do que um). Toda a gestão é unânime em apoiar o projeto. Tive uma conversa particular com meu chefe sobre a necessidade de parar.



Ele disse que se o projeto falhasse, ele teria que fazer as malas. O mesmo acontecia com seu chefe até o vice-presidente. Não havia saída.



Então, arregaçamos as mangas e conseguimos os melhores desenvolvedores para resolver cada problema, priorizando questões críticas (link dinâmico, tamanho binário). Recebi a vinculação dinâmica e o tamanho do binário, nessa ordem.



Descobrimos rapidamente que o problema de vinculação na inicialização do aplicativo poderia ser resolvido colocando todo o código no executável principal. Mas, como todos sabemos, o Swift combina namespaces com frameworks; portanto, grandes mudanças de código seriam necessárias, incluindo incontáveis ​​verificações de namespace.



Foi então que o brilhante Richard Howell examinou a saída da compilação do Xcode e descobriu que depois que a compilação fosse concluída, ele poderia pegar todos os arquivos de objetos intermediários e vinculá-los novamente ao binário principal usando um script personalizado.



Como o Swift distorce o namespace dos objetos durante a compilação, isso significa que ele pode operar nele. Isso nos permitiu vincular estaticamente nossas bibliotecas de forma eficiente e reduzir o tempo de inicialização do main de 10 segundos para quase zero.



O próximo problema é o tamanho. Naquela época, como uma rede de segurança, planejamos empacotar o novo aplicativo com o antigo - e implantá-lo cuidadosamente em tempo de execução. Para reduzir o tamanho, a primeira coisa que fizemos foi desinstalar o aplicativo antigo. Chamamos essa estratégia de "Yolo". Travis deu luz verde pessoalmente.



Também substituímos todas as estruturas do Swift por classes . Os tipos de valor geralmente fornecem uma grande sobrecarga devido ao alinhamento do objeto e código de máquina adicional que é necessário para o comportamento de cópia, inicializadores automáticos e assim por diante.



Mas o aplicativo continuou a crescer. Logo atingimos o limite de download (100 MB) de binários no iOS 8 e anteriores. Isso se traduz em um número significativo de instalações perdidas (mais de US $ 10 milhões em receita perdida devido a muitos usuários de iOS ainda não atualizados).



Nesse ponto, faltavam várias semanas para o lançamento público. Tivemos que retornar ao Objective-C ou descartar o suporte para iOS 8. Desde que o iOS 9 introduziu a capacidade de dividir a arquitetura, esta versão tinha metade do tamanho (mais ou menos). Quando faltou apenas uma semana, decidimos jogar fora dezenas de milhões de dólares - e descartar o suporte para iOS 8.



A opinião geral era que, quando o tamanho era reduzido pela metade, tínhamos muito espaço de manobra e o problema com o tamanho poderia ser resolvido em algum momento no futuro. quando ajuntamos o resto. Infelizmente, estávamos muito errados.



Após o lançamento do aplicativo, fizemos uma grande festa. O aplicativo foi bem recebido pelos usuários e pela imprensa. Foi rápido, com um novo design brilhante.



Muitas pessoas foram promovidas. Todos nós suspiramos de alívio. Após 90 semanas contínuas de trabalho, os rapazes finalmente tiveram uma pausa.



Mas então a opinião pública começou a mudar. O novo aplicativo focava em calcular o preço exato de uma viagem para um trajeto específico (antigamente, você só via a tarifa e o multiplicador atual). Para calcular o preço, você tinha que inserir sua localização atual.



Para comodidade dos usuários, também instalamos a determinação automática de localização, permitindo a coleta de dados de localização em segundo plano para que o motorista possa ver exatamente onde pegar o passageiro no momento. As pessoas começaram a enlouquecer. Alguns de meus ex-colegas no Twitter me incentivaram a sair da empresa maligna que rastreia pessoas como essa.



Como resultado dessa agitação, as pessoas começaram a desativar a permissão de localização no iOS. Mas o novo aplicativo não atendia a esse caso de uso.



Portanto, tentamos o nosso melhor para retornar a versão padrão. Discutimos que é possível desligar o rastreamento de localização em segundo plano, mas isso novamente arruína a usabilidade antes de entrar em um táxi.



Então Trump assumiu o poder (isso aconteceu cerca de três meses após o lançamento do novo aplicativo), o que desencadeou uma reação em cadeia que levou ao movimento #DeleteUber .



Todo esse tempo, a base de código Swift cresceu rapidamente. Problemas contínuos e um IDE lento geraram duas facções conflitantes entre nossos desenvolvedores iOS. Vou chamá-los de fanáticos do Swift e nerds do Objective-C.



A soma das pressões externa e interna levou a tensão ao máximo. Fanáticos negaram os problemas de Swift. Os nerds reclamaram de tudo que se possa imaginar, sem oferecer nenhuma solução especial.



Por volta dessa época, fomos atingidos por um problema com o tamanho do binário. Eu estava de plantão quando a equipe teve problemas com o lançamento. Acontece que nossa solução brilhante para o problema de link dinâmico criou um executável que era muito grande para algumas arquiteturas.



Tendo resolvido o problema nessas arquiteturas, meu colega @aqua_geek e eupesquisou um pouco e descobriu que o tamanho do código compilado está crescendo a uma taxa de 1,3 MB por semana. Eu dei o alarme. Se nada for feito, nessa velocidade, atingiremos o limite de download pela rede celular em três semanas.



Mas a tensão interna chegou a tal ponto que os fanáticos negaram tudo. Um dos líderes de tecnologia do campo Swift escreveu um artigo de duas páginas sobre como os limites de download do celular não importam (o Facebook, afinal, já os excedeu há muito tempo). Nós próprios estamos cansados ​​de apagar incêndios.



Portanto, um de nossos cientistas de dados desenvolveu um teste deslocando artificialmente uma das camadas arquitetônicas para fora do limite - e medindo o impacto no desempenho dos negócios. Na semana seguinte, retiramos essa camada de volta e empurramos outra para fora do limite (para controlar as arquiteturas).



O efeito foi desastroso. O impacto negativo no negócio acabou sendo várias ordens de magnitude maior do que todos os custos da implementação anual do Swift. Acontece que muitas pessoas estão fora do alcance do WiFi quando baixam o aplicativo Uber pela primeira vez (quem diria?)



Então, formamos outro grupo de ataque. Começamos a descompilar os arquivos-objeto e examiná-los linha por linha para determinar por que o código Swift cresceu tanto. Funções não utilizadas removidas. Tyler teve que reescrever o app watchOS de volta para objc.



(O aplicativo de relógio tinha apenas 4.400 linhas, mas devido à arquitetura de processador diferente e à falta de compatibilidade com ABI, todo o tempo de execução do Swift teria que ser incluído no aplicativo.)



Estávamos no nosso limite. Tão cansado. Mas eles ficaram juntos. Foi então que os engenheiros verdadeiramente brilhantes se mostraram. Um dos desenvolvedores em Amsterdã descobriu como reorganizar as etapas de otimização do compilador. Para quem não é especialista em compiladores, vou explicar.



Compiladores modernos fazem uma tonelada de passes. Por exemplo, pode-se usar funções embutidas. Outra é substituir expressões constantes por seus valores. Dependendo da ordem de execução, o código de máquina pode ser menor ou maior.



Se as funções inline passam um valor, o compilador pode reconhecer isso e substituir o bloco inteiro. Por exemplo:



int x = 3
func(x) {
X + 4
}
      
      





torna-se apenas uma constante 7 se o compilador passar pelas funções embutidas primeiro (o que significa muito menos código).



Se esta passagem do compilador for a segunda, então ele pode não reconhecer tais funções e você obterá mais código. Tudo isso, é claro, depende inteiramente da aparência do código específico, portanto, é difícil otimizar a ordem das passagens em geral.



Foi o que disse um brilhante engenheiro de Amsterdã que construiu o algoritmo na versão para reordenar os passes de otimização e minimizar o tamanho. Isso tirou 11 MB do tamanho total do código de máquina e nos deu um pouco de tempo para continuar desenvolvendo.



Mas essa abordagem aterrorizou os especialistas do compilador Swift, eles temiam que as passagens do compilador não verificadas revelassem bugs não testados (embora cada passagem deva ser intrinsecamente segura, é difícil raciocinar sobre as possíveis combinações de passagens). No entanto, não tivemos grandes problemas.



Também aplicamos um monte de outras soluções (linting para modelos de código especialmente caros). Nós medimos cada um deles no número de semanas de desenvolvimento que nos fornecem. Mas o verdadeiro problema era a curva de crescimento. No final, todos os ganhos sempre foram consumidos.



Como resultado, tivemos tempo suficiente para esperar pela mudança da Apple, que aumentou o limite de download da comunicação celular para 150 MB. Eles também adicionaram várias funções do compilador para ajudar na otimização do tamanho (-Osize). Como eles próprios admitiram, o Swift nunca será tão pequeno após a compilação como Objective-C.



Mas a partir deste ano, otimizamos o Swift para 1,5x o tamanho do código de máquina Objective-C e, eventualmente, a Apple aumentou o limite opcional para 200 MB novamente. Isso é o suficiente para nos manter em ação por mais alguns anos.



Mas quase falhamos. Se a Apple não tivesse aumentado o limite, o aplicativo Uber teria que ser reescrito de volta para ObjC. No final, também conseguimos resolver outros problemas. Shiny @alanzeinoe sua equipe tornou possível incluir o suporte Swift na ferramenta de compilação Buck, o que reduziu significativamente os tempos de compilação.



Perdemos um monte de pessoas esgotadas ao longo do caminho. Gastou muito dinheiro e aprendeu lições difíceis. Surpreendentemente, até hoje, a maioria insiste que a reescrita valeu a pena. A consistência arquitetônica é popular entre os novos engenheiros que vêm para a empresa. Eles nem sabem quanta dor foi necessária para alcançá-lo.



A comunidade tem se beneficiado com nosso conhecimento. @ ellsk1 montou uma apresentação incrível e fez um tour de palestras para compartilhar seu conhecimento. Eu também pude aproveitar essa experiência para ajudar novas empresas e equipes de desenvolvimento a tomar melhores decisões.



Então aqui vai uma dica. Tudo na programação é uma questão de compromisso. Não existe linguagem universalmente melhor. Faça o que fizer, entenda qual é o compromisso e por que o está fazendo. Evite guerras políticas entre facções teimosas dentro da empresa.



Esforce-se nos pontos de falha. Descubra como identificar trade-offs e deixar um recuo se você chegar a um ponto e perceber que cometeu um erro. Muito esforço tem um custo, mas quanto mais tarde você perceber o compromisso errado, maior será o custo.



Não seja um chato que apenas resmunga e não contribui. Não seja um fanático que cria grandes problemas para todos. Os melhores engenheiros não caem em nenhuma dessas armadilhas.



All Articles