Cómo Uber reescribió la aplicación de iOS con Swift

Entonces, amigos, siéntense en círculo y escuchen la historia del mayor desastre de ingeniería en el que participé. Es una historia sobre política, arquitectura y la falacia lógica de los costos hundidos (lo siento, solo estoy bebiendo Aberlour Cask Strength Single Malt Scotch en este momento).





Era 2016. Trump aún no ha sido elegido presidente, por lo que el movimiento #DeleteUber aún no ha comenzado. Travis Kalanick siguió siendo un género, estábamos experimentando una fase de crecimiento hiperactivo con la apertura de sucursales en otros países, la opinión pública es generalmente positiva, todos están felices, Uber está en su mejor momento.



Pero el hipercrecimiento no estuvo exento de problemas y la aplicación en sí comenzó a funcionar mal. Antes de eso, la cantidad de desarrolladores se duplicó casi todos los años, y cuando creces tan rápido, obtienes una increíble variedad de habilidades. Combinado con la mentalidad de hacker que llamamos "Let builder's build", esto significó una arquitectura de aplicación compleja y frágil. En ese momento, la aplicación Uber tenía una lógica extremadamente pesada, por lo que a menudo fallaba. Lanzamos constantemente hotfixes, parches, lanzamientos no planificados, etc. Además, la arquitectura no escalaba bien.



Como resultado de todos estos problemas, comenzó un movimiento creciente en todos los niveles de la organización que se unió a la idea de "reescribir la aplicación desde cero". Se formó un equipo para crear una nueva arquitectura móvil para la nueva aplicación. Se pretendía crear una arquitectura que "respaldaría el desarrollo móvil de Uber durante los próximos cinco años". Desarrollamos para ambas plataformas a la vez. Todo el ciclo de desarrollo comenzó de nuevo.



El departamento de iOS aprovechó esta oportunidad para implementar Swift (entonces en la versión 2.x). Uber había probado Swift en el pasado, pero como tantos otros en esa etapa inicial del desarrollo de la tecnología, experimentó muchos problemas y retrasó la implementación.



Sin embargo, la sensación general era que la mayoría de los problemas de Swift en ese momento se debían a una mala interoperabilidad con Objective-C. Y si escribimos una aplicación Swift pura, podríamos evitar los principales problemas.



También surgió la idea de utilizar los mismos patrones arquitectónicos básicos tanto en Android como en iOS. Los desarrolladores de Android eran grandes fanáticos de RxJava en ese momento. La biblioteca RxSwift correspondiente aprovechó el paradigma de programación funcional en Swift. Todo parecía sencillo.



Entonces, un pequeño equipo de desarrollo (Diseño, Producto y Arquitectura) se lanzó de lleno a nuevos patrones funcionales / reactivos, un nuevo lenguaje y una nueva aplicación durante varios meses. Todo iba bien. La arquitectura se basó en gran medida en las capacidades de lenguaje avanzadas de Swift.



La interfaz de usuario podía escalar a una gran cantidad de aplicaciones de Uber, el paradigma de programación funcional parecía poderoso (aunque un poco difícil de aprender) y la arquitectura se basaba en un nuevo protocolo de red de transmisión en tiempo real (escribí esta parte).



Después de un par de meses y varias demostraciones llamativas, el movimiento ganó impulso. El proyecto parecía exitoso. Con un pequeño número de ingenieros, fue posible desarrollar una excelente funcionalidad en poco tiempo. La mayor parte del producto está listo. La guía es bonita.



Entonces comenzó el despliegue a toda la empresa. Varios equipos han comenzado a agregar sus propias funciones a la nueva aplicación. Al principio, la emoción de lo nuevo creó una oleada de motivación y productividad. La arquitectura proporcionó el aislamiento de funciones, lo que permitió un rápido avance.



Pero tan pronto como más de diez ingenieros dominaron Swift, el mecanismo bien coordinado comenzó a desmoronarse. El compilador Swift todavía es significativamente más lento que Objective-C hoy, pero entonces era prácticamente inutilizable. El tiempo de montaje se salió de escala. La depuración se ha detenido por completo.



En algún lugar hay un video de una de las demostraciones, donde un ingeniero de Uber escribe una declaración de una línea en Xcode, y luego espera 45 segundos para que las letras aparezcan lentamente, una por una, en el editor.



Luego chocamos contra una pared con un enlazador dinámico. En ese momento, las bibliotecas Swift solo podían vincularse dinámicamente. Desafortunadamente, el enlazador se ejecutó en tiempo polinomial, por lo que el número máximo recomendado de bibliotecas en un solo binario por Apple era 6. Teníamos 92 y el número siguió creciendo ...



Como resultado, después de hacer clic en el ícono de la aplicación, pasaron de 8 a 12 segundos antes incluso de llamar a main. Nuestra nueva y brillante aplicación resultó ser más lenta que la incómoda anterior. Luego estaba el problema del tamaño del binario.



Desafortunadamente, cuando los problemas comenzaron a manifestarse seriamente, ya habíamos pasado el punto sin retorno. Ésta es la falacia lógica de la falacia del costo hundido. En ese momento, toda la empresa estaba poniendo toda su energía en la nueva aplicación.



Miles de personas de diferentes direcciones, millones y millones de dólares (no puedo dar el número real, pero mucho más de uno). Toda la dirección es unánime en el apoyo al proyecto. Tuve una conversación privada con mi jefe sobre la necesidad de parar.



Dijo que si este proyecto fallaba, tendría que empacar. Lo mismo ocurrió con su jefe hasta el vicepresidente. No hubo salida.



Así que nos pusimos manos a la obra y conseguimos que los mejores desarrolladores abordaran cada uno de los problemas, priorizamos los problemas críticos (vinculación dinámica, tamaño binario). Me asignaron tanto el enlace dinámico como el tamaño del binario, en ese orden.



Rápidamente descubrimos que el problema de vinculación al inicio de la aplicación podría resolverse colocando todo el código en el ejecutable principal. Pero como todos sabemos, Swift combina espacios de nombres con marcos; por lo tanto, se requerirían grandes cambios de código, incluidas innumerables verificaciones de espacios de nombres.



Fue entonces cuando el brillante Richard Howell examinó la salida de compilación de Xcode y descubrió que, una vez completada la compilación, podía tomar todos los archivos de objetos intermedios y volver a vincularlos al binario principal utilizando un script personalizado.



Dado que Swift distorsiona el espacio de nombres de los objetos durante la compilación, significa que puede operar en él. Esto nos permitió vincular de manera eficiente y estática nuestras bibliotecas y reducir el tiempo de inicio de main de 10 segundos a casi cero.



El siguiente problema es el tamaño. En ese momento, como red de seguridad, planeamos empaquetar la nueva aplicación con la anterior e implementarla cuidadosamente en tiempo de ejecución. Para reducir el tamaño, lo primero que hicimos fue desinstalar la aplicación anterior. Llamamos a esta estrategia "Yolo". Travis dio personalmente el visto bueno.



También reemplazamos todas las estructuras Swift con clases . Los tipos de valor generalmente generan una gran sobrecarga debido a la alineación de objetos y el código de máquina adicional que se requiere para el comportamiento de copia, inicializadores automáticos, etc. Esto ahorró espacio.



Pero la aplicación siguió creciendo. Pronto, alcanzamos el límite de descarga (100 MB) de binarios en iOS 8 y versiones anteriores. Esto se traduce en una cantidad significativa de instalaciones perdidas ($ 10 + millones en ingresos perdidos debido a que muchos usuarios de iOS aún no se han actualizado).



En este punto, hubo varias semanas antes del lanzamiento público. Tuvimos que volver a Objective-C o eliminar el soporte para iOS 8. Dado que iOS 9 introdujo la capacidad de dividir la arquitectura, esta versión era en realidad la mitad del tamaño (más o menos). Cuando solo quedaba una semana, decidimos tirar decenas de millones de dólares y eliminar el soporte para iOS 8.



La opinión general era que cuando el tamaño se reducía a la mitad, teníamos mucho espacio para maniobrar y el problema con el tamaño podría resolverse en algún momento en el futuro. cuando rastrillamos el resto. Desafortunadamente, estábamos muy equivocados.



Después del lanzamiento de la aplicación, tuvimos una gran fiesta. La aplicación fue bien recibida por los usuarios y la prensa. Fue rápido, con un nuevo diseño atrevido.



Mucha gente fue ascendida. Todos respiramos aliviados. Después de 90 semanas continuas de trabajo, los chicos finalmente consiguieron un descanso.



Pero luego la opinión pública comenzó a cambiar. La nueva aplicación se centró en calcular el precio exacto de un viaje para una ruta específica (en los viejos tiempos, solo veía la tarifa y el multiplicador actual). Para calcular el precio, tenía que ingresar su ubicación actual.



Para comodidad de los usuarios, también hemos instalado la determinación automática de ubicación, lo que permite la recopilación de datos de ubicación en segundo plano para que el conductor pueda ver exactamente dónde recoger al pasajero en el momento actual. La gente empezó a volverse loca. Algunos de mis antiguos colegas en Twitter me instaron a dejar la empresa malvada que rastrea a personas como esta.



Como resultado de estos disturbios, la gente comenzó a deshabilitar el permiso de ubicación en iOS. Pero la nueva aplicación no proporcionó este caso de uso.



Así que hicimos todo lo posible para devolver la versión estándar. Discutimos que es posible desactivar el rastreo de ubicación en segundo plano, pero eso nuevamente arruina la usabilidad antes de subir a un taxi.



Luego Trump llegó al poder (esto sucedió unos tres meses después del lanzamiento de la nueva aplicación), lo que desencadenó una reacción en cadena que condujo al movimiento #DeleteUber .



Durante todo este tiempo, el código base de Swift ha crecido rápidamente. Los problemas continuos y un IDE lento han generado dos facciones en guerra entre nuestros desarrolladores de iOS. Los llamaré fanáticos de Swift y nerds de Objective-C.



La suma de la presión externa e interna llevó la tensión al máximo. Los fanáticos negaron los problemas de Swift. Los nerds se quejaron de todo lo imaginable sin ofrecer ninguna solución especial.



Alrededor de este tiempo, nos vimos afectados por un problema con el tamaño del binario. Estaba de guardia cuando el equipo tuvo problemas con el lanzamiento. Resulta que nuestra brillante solución al problema de la vinculación dinámica creó un ejecutable que era demasiado grande para algunas arquitecturas.



Habiendo resuelto el problema de estas arquitecturas, mi colega @aqua_geek y yoInvestigué un poco y descubrió que el tamaño del código compilado está creciendo a un ritmo de 1,3 MB por semana. Di la alarma. Si no se hace nada, a esa velocidad, llegaremos al límite de descarga a través de la red celular en tres semanas.



Pero la tensión interna llegó a tal punto que los fanáticos lo negaron todo. Uno de los líderes tecnológicos del campo Swift escribió un artículo de dos páginas sobre cómo los límites de descarga celular no importan (Facebook, después de todo, lo ha excedido hace mucho tiempo) Nosotros mismos estamos cansados ​​de apagar incendios.



Entonces, uno de nuestros científicos de datos desarrolló una prueba que movió artificialmente una de las capas arquitectónicas fuera del límite y midió el impacto en el desempeño comercial. La semana siguiente retiramos esta capa y sacamos otra del límite (para controlar las arquitecturas).



El efecto fue desastroso. El impacto negativo en el negocio resultó ser varios órdenes de magnitud mayor que todos los costos de la implementación anual de Swift. Resulta que muchas personas están fuera del alcance de WiFi cuando descargan la aplicación Uber por primera vez (¿quién lo hubiera pensado?)



Entonces formamos otro grupo de ataque. Comenzamos a descompilar los archivos de objetos y examinar línea por línea para determinar por qué el código Swift ha crecido tanto. Funciones no utilizadas eliminadas. Tyler tuvo que volver a escribir la aplicación watchOS en objc.



(La aplicación del reloj tenía solo 4400 líneas, pero debido a la diferente arquitectura del procesador y la falta de compatibilidad con ABI, todo el tiempo de ejecución de Swift debería incluirse en la aplicación).



Estábamos en nuestro límite. Tan cansado. Pero se juntaron. Fue entonces cuando se mostraron ingenieros verdaderamente brillantes. Uno de los desarrolladores de Ámsterdam descubrió cómo reorganizar las pasadas de optimización del compilador. Para aquellos que no son expertos en compiladores, les explicaré.



Los compiladores modernos hacen un montón de pases. Por ejemplo, se pueden integrar funciones. Otro es reemplazar expresiones constantes con sus valores. Dependiendo del orden de ejecución, el código de máquina puede ser más pequeño o más grande.



Si las funciones en línea pasan un valor, el compilador puede reconocerlo y reemplazar todo el bloque. Por ejemplo:



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





se convierte en una constante 7 si el compilador pasa primero por las funciones en línea (lo que significa mucho menos código).



Si este paso del compilador es el segundo, es posible que no reconozca tales funciones y obtendrá más código. Todo esto, por supuesto, depende completamente de cómo se vea el código específico, por lo que es difícil optimizar el orden de los pases en general.



Así lo dijo un ingeniero brillante de Ámsterdam que incorporó el algoritmo en la versión de lanzamiento para reordenar los pases de optimización y minimizar el tamaño. Esto redujo la friolera de 11 MB del tamaño total del código de la máquina y nos dio un poco de tiempo para seguir desarrollando.



Pero este enfoque aterrorizó a los especialistas en compiladores de Swift, temían que las pasadas de compilador no verificadas revelarían errores no probados (aunque cada pasada debería ser intrínsecamente segura, es difícil razonar sobre posibles combinaciones de pasadas). Sin embargo, no hemos experimentado ningún problema importante.



También aplicamos un montón de otras soluciones (linting para plantillas de código especialmente caras). Medimos a cada uno de ellos en el número de semanas de desarrollo que nos dan. Pero el verdadero problema fue la curva de crecimiento. Al final, todas las ganancias siempre fueron devoradas.



Al final, tuvimos tiempo suficiente para esperar el movimiento de Apple, que elevó el límite de descarga sobre la comunicación celular a 150 MB. También agregaron una serie de funciones del compilador para ayudar con la optimización del tamaño (-Osize). Por su propia admisión, Swift nunca será tan pequeño después de la compilación como Objective-C.



Pero a partir de este año, optimizamos Swift a 1.5 veces el tamaño del código de máquina de Objective-C y, finalmente, Apple elevó el límite opcional a 200 MB nuevamente. Eso es suficiente para mantenernos en marcha durante unos años más.



Pero casi fallamos. Si Apple no hubiera aumentado el límite, la aplicación Uber habría tenido que volver a escribirse en ObjC. Al final, también pudimos resolver otros problemas. Brillante @alanzeinoy su equipo se aseguró de incluir el soporte de Swift en la herramienta de construcción Buck, lo que redujo significativamente los tiempos de construcción.



Perdimos a un montón de personas quemadas en el camino. Gasté una tonelada de dinero y aprendí lecciones difíciles. Sorprendentemente, hasta el día de hoy, la mayoría insiste en que la reescritura valió la pena. La coherencia arquitectónica es popular entre los nuevos ingenieros que llegan a la empresa. Ni siquiera saben cuánto dolor se necesitó para lograrlo.



La comunidad se ha beneficiado de nuestro conocimiento. @ ellsk1 organizó una presentación increíble y realizó una gira de conferencias para compartir sus conocimientos. Yo también he podido aprovechar esta experiencia para ayudar a nuevas empresas y equipos de desarrollo a tomar mejores decisiones.



Así que aquí tienes un consejo. Todo en la programación tiene que ver con el compromiso. No existe un idioma mejor universalmente. Hagas lo que hagas, comprende cuál es el compromiso y por qué lo estás haciendo. Evite la guerra política entre facciones obstinadas dentro de la empresa.



Esfuércese en los puntos de falla. Averigüe cómo identificar las compensaciones y dejar un retiro si llega a un punto y se da cuenta de que cometió un error. Mucho esfuerzo tiene un costo, pero cuanto más tarde se dé cuenta del compromiso incorrecto, mayor será el costo.



No seas un aburrido que solo se queja y no contribuye. No sea un fanático que crea grandes problemas para todos. Los mejores ingenieros no caen en ninguna de estas trampas.



All Articles