App startup time is a critical metric for users, as it's their first interaction with the app, and even minor improvements can have significant benefits for the user experience. First impressions are a big driver in consumer conversion, and startup times often indicate the app's overall quality. Furthermore, other companies found that an increase in latency equals a decrease in sales.
Chez DoorDash, nous prenons très au sérieux la vitesse de démarrage des applications. Nous sommes obsédés par l'optimisation de l'expérience de nos clients et par les améliorations continues.
Dans cet article, nous allons explorer trois optimisations distinctes qui ont permis de réduire de 60 % le temps nécessaire au lancement de notre application iOS grand public. Nous avons identifié ces opportunités à l'aide d'outils de performance propriétaires, mais les instruments Xcode ou DTrace pourraient également constituer des alternatives appropriées.
Changer String(describing :) en ObjectIdentifier()
In early 2022, our app startup optimization journey began with visualizing top bottlenecks using Emerge Tools' Performance Analysis tool, as seen in Figure 1.
Cet outil de performance a permis de mettre en évidence les branches non optimisées à la fois d'un point de vue général et d'un point de vue détaillé. L'une des principales constatations immédiates est le temps que nous avons consacré aux vérifications de conformité au protocole Swift (vérification de la conformité d'un type à un protocole), mais pourquoi ?
Les principes architecturaux tels que le principe de responsabilité unique, la séparation des préoccupations, et d'autres, sont essentiels à la façon dont nous écrivons le code chez DoorDash. Les services et les dépendances sont souvent injectés et décrits par leur type. Le problème est que nous avons utilisé String(describing :) pour identifier les services, ce qui s'accompagne d'une pénalité de performance à l'exécution pour vérifier si le type est conforme à divers autres protocoles. La trace de pile de la figure 2 est tirée directement du lancement de notre application pour illustrer ce point.
The first question we asked ourselves was: "Do we really need a string to identify a type?" Eliminating the string requirement and switching to identifying types using ObjectIdentifier instead, which is a mere pointer to the type, yielded 11% faster app startup times. We also applied this technique to other areas where a pointer sufficed instead of a raw string, which yielded an additional 11% improvement.
If it's possible to use a raw pointer to the type instead of using String(describing:) We recommend making the same change to save on the latency penalty.
Arrêtez de convertir les objets inutiles en AnyHashable
Chez DoorDash, nous encapsulons les actions des utilisateurs, les demandes de réseau, les mutations de données et d'autres charges de travail informatiques dans ce que nous appelons des commandes. Par exemple, lorsque nous chargeons un menu de magasin, nous le soumettons en tant que demande au moteur d'exécution des commandes. Le moteur stocke alors la commande dans un tableau de traitement et exécute les commandes entrantes de manière séquentielle. Structurer nos opérations de cette manière est un élément clé de notre nouvelle architecture, dans laquelle nous isolons délibérément les mutations directes et observons plutôt les résultats des actions attendues.
Cette optimisation a commencé par un réexamen de la manière dont nous identifions les commandes et générons leur valeur de hachage. Notre tableau de traitement, et d'autres dépendances, s'appuient sur une valeur de hachage unique pour identifier et séparer les commandes respectives. Historiquement, nous avons contourné le besoin de penser au hachage en utilisant AnyHashable. Cependant, comme l'indique la norme Swift, il était dangereux de procéder ainsi, car les valeurs de hachage fournies par AnyHashable pouvaient changer d'une version à l'autre.
Nous aurions pu choisir d'optimiser notre stratégie de hachage de plusieurs façons, mais nous avons commencé par repenser nos restrictions et limites initiales. À l'origine, la valeur de hachage d'une commande était une combinaison de ses membres associés. Cette décision avait été prise délibérément car nous voulions conserver une abstraction flexible et puissante des commandes. Mais après l'adoption de la nouvelle architecture par l'ensemble de l'application, nous avons remarqué que ce choix était prématuré et qu'il n'avait pas été utilisé. La modification de cette exigence pour identifier les commandes par leur type a permis d'accélérer le lancement de l'application de 29 %, l'exécution des commandes de 55 % et l'enregistrement des commandes de 20 %.
Audit des initialisateurs de cadres tiers
Chez DoorDash, nous nous efforçons de ne pas dépendre de tiers dans la mesure du possible. Cependant, il arrive que l'expérience d'un consommateur bénéficie grandement de l'intégration d'une tierce partie. Quoi qu'il en soit, nous menons plusieurs audits rigoureux sur l'impact des dépendances tierces sur notre service et la qualité que nous maintenons.
Un audit récent a révélé qu'un certain framework tiers ralentissait le lancement de notre application iOS d'environ 200 ms. Ce framework occupait à lui seul environ 40 % ( !) du temps de lancement de notre application, comme le montre la figure 3.
Pour compliquer les choses, le cadre en question était un élément clé pour garantir une expérience positive au consommateur. Que pouvons-nous donc faire ? Comment concilier un aspect de l'expérience client avec des délais de lancement d'application rapides ?
En règle générale, une bonne approche consiste à commencer par déplacer les fonctions de démarrage coûteuses en calcul vers une partie ultérieure du processus de lancement, puis à réévaluer la situation à partir de là. Dans notre cas, nous n'avons appelé ou référencé des classes dans le framework que bien plus tard dans le processus, mais le framework bloquait toujours notre temps de lancement ; pourquoi ?
When an application starts up and loads into memory, the dynamic linker (dyld) is responsible for getting it ready. One of the steps of dyld is scanning through dynamically linked frameworks and calling any module initialization functions that it may have. dyld does this by looking for section types marked with 0x9 (S_MOD_INIT_FUNC_POINTERS), typically located in the "__DATA" segment.
Une fois trouvé, dyld définit une variable booléenne à true et appelle les initialisateurs dans une autre phase peu de temps après.
Le framework tiers en question avait un total de neuf initialisateurs de modules qui, à cause de dyld, ont tous été autorisés à s'exécuter avant que notre application n'exécute main(). Ces neuf initialisateurs ont contribué au coût total qui a retardé le lancement de notre application. Comment résoudre ce problème ?
Nous aurions pu remédier à ce retard de plusieurs manières. Une option populaire consiste à utiliser dlopen et à écrire une interface pour les fonctions qui n'ont pas encore été résolues. Cette méthode implique cependant de perdre la sécurité de la compilation, puisque le compilateur ne peut plus garantir qu'une certaine fonction existera dans le framework au moment de la compilation. Cette option présente d'autres inconvénients, mais c'est la sécurité de la compilation qui nous importait le plus.
Nous avons également contacté les développeurs tiers et leur avons demandé de convertir l'initialisateur du module en une simple fonction que nous pourrions appeler à notre guise. Malheureusement, ils ne nous ont pas encore répondu.
Au lieu de cela, nous avons opté pour une approche légèrement différente des méthodes publiquement connues. L'idée était de tromper dyld en lui faisant croire qu'il regardait une section régulière et donc de ne pas appeler les initialisateurs du module. Plus tard, au moment de l'exécution, nous récupérerions l'adresse de base du framework avec dladdr, et appellerions les initialisateurs à un décalage statique connu. Nous imposerions ce décalage en validant le hachage du cadre lors de la compilation, en vérifiant les sections lors de l'exécution et en vérifiant que le drapeau de section a bien été remplacé. Avec ces garde-fous et un plan global en tête, nous avons déployé avec succès cette optimisation et obtenu un démarrage d'application 36 % plus rapide.
Conclusion
L'identification précise des goulets d'étranglement et des opportunités en matière de performances est souvent la partie la plus difficile de toute optimisation. L'erreur la plus fréquente est de mesurer A, d'optimiser B et de conclure C. C'est là que de bons outils de performance aident à mettre en évidence les goulets d'étranglement et à les faire remonter à la surface. Les instruments Xcode, qui font partie de Xcode, sont fournis avec plusieurs modèles pour aider à identifier les différents problèmes potentiels dans une application macOS/iOS. Mais pour plus de granularité et de facilité d'utilisation, Emerge Tools fournit une vue simplifiée des performances de l'application avec ses outils de performance.