Optimisation native de React

Black Friday Promotion


par Nick Cherry, ingénieur logiciel d'usine

Au cours des huit derniers mois, Coinbase a réécrit son application Android à partir de zéro avec React Native. Découvrez quelques-uns des défis de performance que nous avons rencontrés et surmontés en cours de route.

Si vous êtes intéressé par des défis techniques comme celui-ci, veuillez jetez un œil à nos positions ouvertes et postuler pour un poste.

Au cours des huit derniers mois, Coinbase a réécrit son application Android à partir de zéro en utilisant React Native. Depuis la semaine dernière, la nouvelle application repensée a été mise à la disposition de 100% des utilisateurs. Nous sommes fiers de ce que notre petite équipe a pu accomplir en peu de temps, et nous restons très optimistes à propos de React Native en tant que technologie, nous attendons à ce qu'elle rapporte des dividendes continus en ce qui concerne la vitesse d'ingénierie et la qualité du produit. .

Cela dit, tout n'a pas été facile. Un domaine dans lequel nous avons été confrontés à des défis notables est celui des performances, en particulier sur les appareils Android. Au cours des prochains mois, nous prévoyons de publier une série d'articles de blog documentant divers problèmes que nous avons rencontrés et comment nous les avons atténués. Aujourd'hui, nous allons nous concentrer sur celui qui nous a le plus touché: les rendus inutiles.

Les choses étaient super jusqu'à ce qu'elles ne le soient pas

Au début du projet, les performances de l'application étaient bonnes. Il était presque impossible de le distinguer d'un produit entièrement natif, même si nous n'avions pas passé de temps à optimiser notre code. Nous savions que d'autres équipes étaient confrontées (et surmontées) à des défis de performance avec React Native, mais aucun de nos benchmarks préliminaires ne nous a donné de raison d'être alarmé. Après tout, l'application que nous prévoyions de créer était principalement en lecture seule, elle n'avait pas besoin d'afficher des listes massives et elle ne nécessitait pas d'animations qui ne pouvaient pas être téléchargées sur le pilote natif.

Cependant, à mesure que de nouvelles fonctionnalités étaient ajoutées, nous avons commencé à remarquer une diminution des performances. Au début, les déclassements étaient subtils. Par exemple, même avec notre version de production, la navigation vers de nouveaux écrans pourrait être lente et les mises à jour de l'interface utilisateur seraient un peu en retard. Mais bientôt, cela prenait une seconde pour basculer entre les onglets, et après avoir atterri sur un nouvel écran, l'interface utilisateur pouvait ne plus répondre pendant une longue période. L'expérience utilisateur s'était détériorée au point de bloquer le lancement.

Identifier le problème

Il ne nous a pas fallu longtemps pour reconnaître la corrélation entre le jank de l'interface utilisateur et la fréquence d'images JavaScript. Après une interaction de l'utilisateur, nous observons généralement le JS FPS tomber à un chiffre bas (ou négatif!) Pendant plusieurs secondes. Ce qui n’était pas si évident pour nous, c’était pourquoi. À peine un mois plus tôt, les performances de l'application étaient relativement bonnes et aucune des fonctionnalités que nous avions ajoutées ne semblait être particulièrement onéreuse en soi. Nous avons utilisé le profileur de React pour comparer des composants volumineux que nous supposions être lents et avons constaté que beaucoup rendaient plus que nécessaire. Nous avons pu réduire les répétitions de rendu pour ces composants plus volumineux avec la mémorisation, mais nos améliorations n'ont pas déplacé l'aiguille. Nous avons également examiné les temps de rendu pour certains composants atomiques et moléculaires, dont aucun ne semblait trop coûteux.

Pour obtenir une vue plus holistique de l'endroit où le re-rendu était plus cher, nous avons écrit un plugin Babel personnalisé qui enveloppait chaque élément JSX de l'application avec un Profiler. Chaque Profiler s'est vu attribuer une fonction onRender qui a été signalée à un fournisseur de contexte en haut de l'arborescence React. Ce fournisseur de contexte de niveau supérieur ajouterait des nombres et des durées de rendu, un regroupement par type de composant, puis consignait les pires contrevenants toutes les quelques secondes. Voici une capture d'écran du résultat de notre implémentation initiale:

Comme nous l'avons observé dans nos précédents benchmarks, les temps de rendu moyens pour la plupart de nos composants atomiques / moléculaires étaient adéquats. Par exemple, notre composant PortfolioListCell a pris environ 2 ms à traiter. Mais lorsqu'il y a 11 instances de PortfolioListCell et que chacune est rendue 17 fois, ces rendus de 2 ms s'additionnent. Notre problème n'était pas que les composants individuels étaient si lents, mais que nous étions en train de refaire le rendu tout aussi.

Nous nous sommes fait ça

Pour expliquer pourquoi cela se produisait, nous devons prendre du recul et parler de notre pile. L'application s'appuie fortement sur une bibliothèque de recherche de données appelée rest-hooks, que l'équipe de Coinbase Web utilise avec plaisir depuis plus d'un an. L'adoption de rest-hooks nous a permis de partager une quantité importante de notre code de couche de données avec le Web, y compris des types générés automatiquement pour les points de terminaison d'API. Une caractéristique notable de la bibliothèque est qu'elle utilise un contexte global pour stocker son cache. Une caractéristique notable du contexte, comme décrit dans la documentation React, est que:

Tous les consommateurs qui sont les descendants d'un fournisseur seront rendus à nouveau tant que le fournisseur changements dans la proposition de valeur.

Pour nous, cela signifiait que chaque fois que des données étaient écrites dans le cache (par exemple, lorsque l'application reçoit une réponse de l'API), chaque composant qui accède au magasin est retraité, que le composant ait été mémorisé ou fait référence aux données modifiées. Ce qui a exacerbé le nouveau rendu, c'est le fait que nous avons adopté un modèle de colocalisation des liaisons de données avec les composants. Par exemple, nous utilisons souvent des hooks consommateurs de données comme useLocale () et useNativeCurrency () dans des composants de niveau inférieur qui mettent en forme les informations en fonction des préférences de l'utilisateur. C'était génial pour l'expérience des développeurs, mais cela signifiait également que tous les composants utilisant ces hooks, directement ou indirectement, seraient retraités en écritures mises en cache, même s'ils étaient mémorisés.

Une autre partie de notre pile qui mérite d'être mentionnée ici est react-navigation, qui est actuellement la solution de navigation la plus utilisée dans l'écosystème React Native. Les ingénieurs venant d'un environnement Web peuvent être surpris d'apprendre que son comportement par défaut est que tous les écrans du navigateur restent montés, même si l'utilisateur ne les visualise pas activement. Cela permet aux affichages flous de conserver leur état local et leur position de défilement pour «libre». C'est également pratique dans le contexte des appareils mobiles, où nous voulons généralement montrer plusieurs écrans à l'utilisateur pendant les transitions, par ex. Par exemple, lorsque vous poussez ou sortez des batteries. Malheureusement pour nous, cela signifie également que notre relecture déjà gênante pourrait s'aggraver de manière exponentielle lorsque l'utilisateur navigue dans l'application. Par exemple, si nous avons quatre piles d'onglets et que l'utilisateur a parcouru un écran en profondeur dans chaque pile, nous rendrions la plupart des huit écrans à chaque fois qu'une réponse API est renvoyée!

Composants du conteneur

Une fois que nous avons compris la cause profonde de nos problèmes de performances les plus urgents, nous devions trouver comment y remédier. Notre première ligne de défense contre la répétition était la mémorisation agressive. Comme nous l'avons mentionné précédemment, lorsqu'un composant consomme un contexte, il sera rendu à nouveau lorsque la valeur de ce contexte change, que le composant soit ou non mémorisé. Cela nous a conduit à adopter un modèle de conteneur fonctionnel, dans lequel nous élevions les crochets qui consomment des données en un composant de conteneur léger, puis transmettions les valeurs de retour de ces crochets aux composants de présentation qui pourraient bénéficier de la mémorisation. Considérez l'essence ci-dessous. Chaque fois que le hook useWatchList () déclenche un nouveau rendu (c'est-à-dire chaque fois que le magasin de données est mis à jour), nous devons également rendre à nouveau nos composants Card et AssetSummaryCell, même si la valeur watchList n'a pas changé.

https://medium.com/media/c2ae5f4f5b5a8bb8fd6b4fa563d3d246/href

En appliquant le modèle de conteneur, nous déplaçons l'appel useWatchList () vers son propre composant, puis mémorisons la partie d'affichage de notre vue. Nous continuerons à afficher WatchListContainer chaque fois que la banque de données est mise à jour, mais ce sera relativement peu coûteux car le composant fait très peu.

https://medium.com/media/363287c1a1d57d694b7ffe056a2798e2/href

Supports stabilisants

Le modèle de conteneur était un bon début, mais il y avait quelques écueils que nous devions éviter. Jetez un œil à l'exemple suivant:

https://medium.com/media/32e5d59fba456f6e4fc011b3cbdfd0e0/href

Il peut sembler que nous protégeons l'actif mémorisé des restitutions liées aux données en élevant useAsset (assetId) et useWatchListToggler () en un composant conteneur. Cependant, la mémorisation ne fonctionnera jamais vraiment, car nous transmettons une valeur instable pour toggleWatchList. En d'autres termes, chaque fois qu'AssetContainer effectue à nouveau le rendu, toggleWatchList sera une nouvelle fonction anonyme. Lorsque mémo effectue une comparaison rapide d'égalité entre les anciens accessoires et les accessoires actuels, les valeurs ne seront plus jamais les mêmes et l'actif sera toujours restitué.

Pour tirer parti du stockage des actifs, nous devons stabiliser notre fonction toggleWatchList à l'aide de useCallback. Avec le code mis à jour ci-dessous, l'actif ne sera rendu à nouveau que si l'actif change réellement:

https://medium.com/media/ad42c990241d0c5b6ca9a9b17ee20791/href

Cependant, les rappels ne sont pas le seul moyen de rompre la mémorisation sans s'en rendre compte. Les mêmes principes s'appliquent également aux objets. Prenons un autre exemple:

https://medium.com/media/e198535efb18f344cfb9fd4533136c87/href

Avec le code ci-dessus, même si le composant de recherche a été mémorisé, il serait toujours rendu à nouveau lorsque PriceSearch est affiché. Cela se produit car l'espacement et l'icône seront des objets différents à chaque rendu.

Pour résoudre ce problème, nous nous fierons à useMemo pour mémoriser notre élément icône. N'oubliez pas que chaque balise JSX est compilée dans un appel de React.createElement, qui renvoie un nouvel objet à chaque fois qu'il est appelé. Nous devons mémoriser cet objet pour maintenir l'intégrité référentielle dans toutes les représentations. Puisque l'espacement est vraiment constant, nous pouvons simplement définir la valeur en dehors de notre composant fonctionnel pour le stabiliser.

Après les modifications suivantes, notre composant de recherche peut être efficacement mémorisé:

https://medium.com/media/42aa61dba9ca41ff91e087fbb55fd6b1/href

Rendus de court-circuit sur des écrans non focalisés

La mémorisation a considérablement réduit le nombre / la durée des rendus pour chaque écran. Cependant, parce que la navigation de réaction maintient les écrans non focalisés montés, nous avons continué à gaspiller des ressources précieuses pour restituer une grande quantité de contenu qui n'était pas visible pour l'utilisateur. Cela nous a amenés à commencer à chercher dans la documentation de react-navigation une option qui pourrait atténuer ce problème. Nous étions optimistes lorsque nous avons découvert unmountOnBlur. Le fait de changer l'indicateur sur true a considérablement réduit nos rendus, mais ne s'appliquait qu'aux écrans d'onglets flous, conservant ainsi tout l'écran dans la pile d'onglets actuelle montée. Plus accablant, cela provoquait un scintillement lors du passage d'un onglet à l'autre et perdait la position de défilement de l'écran et l'état local lorsque l'utilisateur s'éloignait.

Notre deuxième tentative consistait à mettre les écrans en attente (retour à une commande rotative de chargement plein écran) en lançant une promesse lorsque l'utilisateur naviguait, puis en résolvant la promesse lorsque l'utilisateur revenait, permettant à l'écran d'être à nouveau présenté. Avec cette approche, nous pourrions éliminer les rendus inutiles et préserver l'état local de tous les écrans non focalisés. Malheureusement, l'expérience a été inconfortable car les utilisateurs ont brièvement vu un indicateur de chargement lorsqu'ils revenaient à un écran précédemment visité. De plus, sans quelques astuces épineuses, votre position de défilement serait perdue.

Enfin, nous avons proposé un correctif généralisé qui empêchait le re-rendu sur tous les écrans flous sans effets secondaires négatifs. Nous y parvenons en enveloppant chaque écran dans un composant qui remplace le contexte spécifié (StateContext de rest-hooks dans ce cas) avec une valeur «gelée» lorsque l'écran est flou. Parce que cette valeur figée (qui est consommée par tous les composants / hooks dans l'écran enfant) reste stable même lorsque le contexte "réel" est mis à jour, nous pouvons court-circuiter toutes les représentations liées au contexte donné. Lorsque l'utilisateur revient à un écran, la valeur figée est remplacée et la valeur de contexte réelle est transmise, déclenchant une relecture initiale pour synchroniser tous les composants abonnés. Pendant que l'écran est au point, vous recevrez toutes les mises à jour de contexte comme vous le feriez normalement. L'essentiel ci-dessous montre comment nous y parvenons avec DeactivateContextOnBlur:

https://medium.com/media/9936dceff26615278ed01c25f8c1af06/href

Et voici une démonstration de la façon dont DeactivateContextOnBlur peut être utilisé:

https://medium.com/media/3fa33dd2b4aee447efd079ad50f68dd6/href

Réduisez les demandes réseau

Avec DeactivateContextOnBlur et toute notre mémorisation en place, nous avions considérablement réduit le coût des rendus inutiles dans notre application. Cependant, il y avait quelques écrans clés (c'est-à-dire Accueil et Actif) qui submergeaient toujours le fil JavaScript lorsqu'ils ont été assemblés pour la première fois. Cela s'expliquait en partie par le fait que chacun de ces écrans avait besoin de faire près d'une douzaine de requêtes réseau. Cela était dû aux limitations de notre API existante, qui dans certains cas nécessitait n + 1 requêtes pour obtenir les données d'actif dont notre interface utilisateur avait besoin. Ces demandes ont non seulement introduit une surcharge de calcul et une latence, mais chaque fois que l'application recevait une réponse de l'API, elle aurait besoin d'actualiser le magasin de données, de déclencher plus de rendus, de réduire notre FPS JavaScript et, finalement, de L'interface utilisateur est moins réactive.

Dans l'esprit de fournir rapidement de la valeur, nous avons opté pour la solution peu coûteuse consistant à ajouter deux nouveaux points de terminaison: l'un pour renvoyer les actifs de la liste de surveillance pour l'écran d'accueil et l'autre pour renvoyer les actifs mappés pour l'écran des actifs. Maintenant que nous intégrions toutes les données pertinentes pour ces composants de l'interface utilisateur dans une seule réponse, il n'était plus nécessaire de faire une demande supplémentaire pour chaque actif de la liste. Ce changement a nettement amélioré le TTI et la fréquence d'images pour les deux écrans concernés.

Bien que les points de terminaison ad hoc aient profité de deux de nos écrans les plus importants, il existe encore plusieurs domaines de l'application qui souffrent de modèles d'accès aux données inefficaces. Notre équipe explore actuellement des solutions plus fondamentales qui peuvent résoudre le problème dans son ensemble, permettant à notre application de récupérer les informations dont elle a besoin avec beaucoup moins de demandes d'API.

résumé

Avec tous les changements décrits dans cet article, nous avons pu réduire notre nombre de rendus et le temps total passé au rendu de plus de 90% (tel que mesuré par notre plugin Babel personnalisé) avant de lancer l'application. Nous voyons également beaucoup moins d'images perdues, comme le montre le moniteur de performances de React. Un point clé de ce travail est que la création d'une application React Native hautes performances est, à bien des égards, la même chose que la création d'une puissante application Web React. Étant donné la puissance comparativement limitée des appareils mobiles et le fait que les applications mobiles natives ont souvent besoin d'en faire plus (par exemple, maintenir un état de navigation complexe qui garde plusieurs écrans en mémoire), il est essentiel de suivre les meilleures pratiques en matière de performances. pour créer une application de haute qualité. Nous avons parcouru un long chemin ces derniers mois, mais nous avons encore beaucoup de travail à faire.

Ce site Web contient des liens vers des sites Web tiers ou d'autres contenus à des fins d'information uniquement («Sites tiers»). Les sites tiers ne sont pas sous le contrôle de Coinbase, Inc. et de ses affiliés («Coinbase»), et Coinbase n'est pas responsable du contenu de tout site tiers, y compris, mais sans s'y limiter, les liens contenus dans un tiers. Site tiers, ou tout changement ou mise à jour d'un site tiers. Coinbase n'est pas responsable de la transmission Internet ou de toute autre forme de transmission reçue d'un site tiers. Coinbase ne vous fournit ces liens qu'à titre de commodité, et l'inclusion de tout lien n'implique pas l'approbation, l'approbation ou la recommandation par Coinbase du site ou une association avec ses opérateurs.

Toutes les images fournies dans ce document proviennent de Coinbase.


Optimizing React Native a été initialement publié sur le blog Coinbase sur Medium, où les gens poursuivent la conversation en mettant en évidence et en répondant à cette histoire.

Black Friday Promotion

Cours Crypto
Logo
Enable registration in settings - general