Ir al contenido
9064

Blog


Cómo redujimos el tiempo de lanzamiento de nuestra aplicación iOS en un 60

31 de enero de 2023

|

Filip Busic

El tiempo de inicio de una aplicación es un parámetro crítico para los usuarios, ya que es su primera interacción con la aplicación, e incluso pequeñas mejoras pueden tener beneficios significativos para la experiencia del usuario. La primera impresión es un factor importante en la conversión de los consumidores, y los tiempos de inicio suelen indicar la calidad general de la aplicación. Además, otras empresas descubrieron que un aumento de la latencia equivale a una disminución de las ventas.

En DoorDash, nos tomamos muy en serio la velocidad de inicio de la aplicación. Estamos obsesionados con optimizar la experiencia de nuestros clientes y realizar mejoras continuas.

En este artículo, exploraremos tres optimizaciones distintas que redujeron en un 60% el tiempo necesario para lanzar nuestra aplicación de consumo para iOS. Identificamos estas oportunidades utilizando herramientas de rendimiento propias, pero los instrumentos de Xcode o DTrace también podrían ser alternativas adecuadas.

Cambio de String(describing:) a ObjectIdentifier()

A principios de 2022, nuestro viaje hacia la optimización de la puesta en marcha de la aplicación comenzó con la visualización de los principales cuellos de botella mediante la herramienta de análisis de rendimiento de Emerge Tools, como se muestra en la Figura 1.

Figura 1: Trazado de pila que muestra las tres oportunidades de optimización del rendimiento
Figura 1: Trazado de pila que muestra las tres oportunidades de optimización del rendimiento

Esta herramienta de rendimiento ayudó a mostrar las ramas no optimizadas tanto a vista de pájaro como desde un punto de vista detallado. Uno de los aspectos más destacados de inmediato fue el tiempo que dedicamos a las comprobaciones de conformidad con el protocolo Swift (comprobar si un tipo se ajusta a un protocolo), pero ¿por qué?

Los principios arquitectónicos como el principio de responsabilidad única, la separación de preocupaciones, y otros, son clave para la forma en que escribimos código en DoorDash. Los servicios y las dependencias suelen inyectarse y describirse por su tipo. El problema es que utilizábamos String(describing:) para identificar los servicios, lo que conllevaba una penalización de rendimiento en tiempo de ejecución al comprobar si el tipo se ajustaba a otros protocolos. El seguimiento de la pila en la Figura 2 se toma directamente del lanzamiento de nuestra aplicación para mostrar esto.

Figura 2: Seguimiento de lo que ocurre entre bastidores de la API String(describing:)
Figura 2: Seguimiento de lo que ocurre entre bastidores de la API String(describing:)

La primera pregunta que nos hicimos fue: "Eliminar el requisito de la cadena y pasar a identificar los tipos mediante ObjectIdentifier, que es un mero puntero al tipo, aceleró el arranque de la aplicación en un 11%. También aplicamos esta técnica a otras áreas en las que bastaba con un puntero en lugar de una cadena sin procesar, lo que supuso una mejora adicional del 11% .

Si es posible utilizar un puntero sin procesar al tipo en lugar de utilizar String(describing:) Recomendamos hacer el mismo cambio para ahorrar en la penalización por latencia.

Deja de convertir objetos innecesarios a AnyHashable

En DoorDash, encapsulamos las acciones de los usuarios, las solicitudes de red, las mutaciones de datos y otras cargas de trabajo computacionales en (lo que llamamos) comandos. Por ejemplo, cuando cargamos un menú de tienda, lo enviamos como solicitud al motor de ejecución de comandos. El motor almacenará la orden en una matriz de procesamiento y ejecutará las órdenes entrantes de forma secuencial. Estructurar nuestras operaciones de esta manera es una parte clave de nuestra nueva arquitectura, en la que aislamos a propósito las mutaciones directas y observamos en su lugar los resultados de las acciones esperadas.

Esta optimización comenzó con un replanteamiento de cómo identificamos los comandos y generamos su valor hash. Nuestra matriz de procesamiento, y otras dependencias, dependen de un valor hash único para identificar y separar los respectivos comandos. Históricamente, hemos eludido la necesidad de tener que pensar en el hash mediante el uso de AnyHashable. Sin embargo, como se señala en el estándar Swift, hacerlo era peligroso porque confiar en los valores hash dados por AnyHashable podría cambiar entre versiones.

Podríamos haber optado por optimizar nuestra estrategia de hash de varias maneras, pero empezamos por replantearnos nuestras restricciones y límites originales. Originalmente, el valor hash de un comando era una combinación de sus miembros asociados. Esta decisión se había tomado deliberadamente, ya que queríamos mantener una abstracción flexible y potente de los comandos. Pero tras la adopción de la nueva arquitectura en toda la aplicación, nos dimos cuenta de que la elección de diseño era prematura y, en general, no se utilizaba. Cambiar este requisito para identificar los comandos por su tipo hizo que el lanzamiento de la aplicación fuera un 29% más rápido, la ejecución de comandos un 55% más rápida y el registro de comandos un 20% más rápido.

Auditoría de inicializadores de marcos de terceros

En DoorDash, nos esforzamos por no depender de terceros siempre que sea posible. Sin embargo, hay ocasiones en las que la experiencia de un consumidor podría beneficiarse enormemente de la integración de terceros. En cualquier caso, llevamos a cabo varias auditorías rigurosas de cómo las dependencias de terceros afectan a nuestro servicio y a la calidad que mantenemos.

Una auditoría reciente descubrió que un framework de terceros provocaba que nuestra aplicación iOS se iniciara unos 200 ms más lenta. Solo este framework ocupaba aproximadamente el 40 % del tiempo de inicio de nuestra aplicación, como se muestra en la figura 3.

Figura 3: Gráfico de llama que muestra que aproximadamente 200 ms del tiempo de inicio de nuestra aplicación se debieron a un framework de terceros que iteraba sobre nuestro NSBundle.
Figura 3: Gráfico de llama que muestra que aproximadamente 200 ms del tiempo de inicio de nuestra aplicación se debieron a un framework de terceros que iteraba sobre nuestro NSBundle.

Para complicar más las cosas, el marco en cuestión era una pieza clave para garantizar una experiencia positiva al consumidor. Entonces, ¿qué podemos hacer? ¿Cómo equilibrar un aspecto de la experiencia del cliente con unos tiempos de lanzamiento de aplicaciones rápidos?

Normalmente, un buen enfoque es empezar por mover cualquier función de inicio costosa desde el punto de vista computacional a una parte posterior del proceso de lanzamiento y reevaluar a partir de ahí. En nuestro caso, sólo llamamos o referenciamos clases en el framework mucho más tarde en el proceso, pero el framework seguía bloqueando nuestro tiempo de lanzamiento; ¿por qué?

Cuando una aplicación arranca y se carga en memoria, el enlazador dinámico (dyld) se encarga de prepararla. Uno de los pasos de dyld es escanear a través de los frameworks enlazados dinámicamente y llamar a cualquier función de inicialización de módulos que pueda tener. dyld hace esto buscando tipos de sección marcados con 0x9(S_MOD_INIT_FUNC_POINTERS), típicamente localizados en el segmento "__DATA".

Una vez encontrado, dyld establece una variable booleana en true y llama a los inicializadores en otra fase poco después.

El framework de terceros en cuestión tenía un total de nueve inicializadores de módulo que, debido a dyld, se ejecutaron antes de que nuestra aplicación ejecutara main(). Esos nueve inicializadores contribuyeron al coste total que retrasó el lanzamiento de nuestra aplicación. Entonces, ¿cómo lo arreglamos?

Hay varias formas de solucionar el retraso. Una opción popular es utilizar dlopen y escribir una interfaz envolvente para las funciones que aún no se han resuelto. Sin embargo, este método significaba perder la seguridad de compilación, ya que el compilador ya no podía asegurar que una determinada función existiría en el framework en tiempo de compilación. Esta opción tiene otros inconvenientes, pero la seguridad de compilación era lo más importante para nosotros.

También nos pusimos en contacto con los desarrolladores de terceros y les pedimos que convirtieran el inicializador del módulo en una simple función que pudiéramos llamar a nuestro antojo. Lamentablemente, aún no nos han respondido.

En su lugar, optamos por un enfoque ligeramente diferente a los métodos conocidos públicamente. La idea era engañar a dyld para que pensara que estaba mirando una sección normal y por lo tanto omitir la llamada a los inicializadores del módulo. Luego, en tiempo de ejecución, tomaríamos la dirección base del framework con dladdr, y llamaríamos a los inicializadores en un offset estático conocido. Aplicaríamos este desplazamiento validando el hash de la estructura en tiempo de compilación, verificando las secciones en tiempo de ejecución y comprobando que el indicador de sección ha sido sustituido. Con estas medidas de seguridad y un plan general en mente, implementamos con éxito esta optimización y conseguimos que el inicio de la aplicación fuera un 36% más rápido.

Conclusión

Identificar con precisión los cuellos de botella y las oportunidades de rendimiento suele ser la parte más difícil de cualquier optimización. Notoriamente, un error común es medir A, optimizar B y concluir C. Ahí es donde las buenas herramientas de rendimiento ayudan a resaltar los cuellos de botella y sacarlos a la superficie. Los instrumentos de Xcode, una parte de Xcode, vienen con varias plantillas para ayudar a señalar varios problemas potenciales en una aplicación macOS/iOS. Pero para una mayor granularidad y facilidad de uso, Emerge Tools ofrece una visión simplificada del rendimiento de la aplicación con sus herramientas de rendimiento.

About the Author

  • Filip Busic

    Filip Busic is a software engineer at DoorDash, since March 2020, where he now works on iOS performance, app binary size reduction, and other stability improvements. He is also an avid part of the developer community and enjoys setting up home labs in his free time.

Trabajos relacionados

Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA; Seattle, WA
Departamento
Ingeniería
Ubicación
San Francisco, CA
Departamento
Ingeniería