

Hermes, FlashList, react-native-fast-image, Reanimated UI-thread, offline-first React Query : le guide complet pour tenir 60 fps stable sur un Tecno Spark Go à 2 Go de RAM.
Sur l'app Nexus, 68 % des utilisateurs ont un Android entrée de gamme à moins de 100 €, 2 Go de RAM et un CPU 4 cœurs lent. Voici le guide qui m'a permis de faire tenir 60 fps sur ces appareils.
Quand on développe sur un iPhone Pro à 1 200 €, on ne voit jamais les vrais problèmes. Tout est fluide. Mais la majorité des utilisateurs francophones d'Afrique de l'Ouest, et une part substantielle des marchés émergents en général, tournent sur des appareils Android d'entrée de gamme : Tecno, Itel, Infinix, Xiaomi série A. Ces téléphones ont :
Sur ces conditions, une app React Native par défaut rame. Pas un peu — beaucoup. Frame drops sur le scroll, transitions saccadées, démarrage à froid de 8 secondes. La perception : "cette app est cassée".
L'article : ce que j'ai changé sur l'app mobile Nexus pour passer de 18 fps à 58 fps stable sur un Tecno Spark Go.
Sans mesure, on optimise à l'aveugle. Trois outils ont compté :
Flipper + Performance Monitor : affiche les FPS UI et JS thread en overlay. C'est ma référence. Si je vois le JS thread descendre sous 30 fps, j'ai un problème de logique. Si c'est l'UI thread, c'est un problème de rendu.
Si ce sujet ressemble à un problème réel dans votre produit, je peux intervenir sur le diagnostic, l'architecture, le backend, l'interface et les automatisations qui rendent une plateforme exploitable.
Android Studio Profiler : pour le démarrage à froid et l'usage mémoire. Je profile la phase onCreate jusqu'au premier écran interactif.
Reactotron : pour tracer les re-renders. Si un composant re-render 12 fois pendant une transition, je le sais.
| 1 | // hooks/useRenderCount.ts — utile en dev |
| 2 | import { useRef } from "react"; |
| 3 | |
| 4 | export function useRenderCount(name: string) { |
| 5 | const count = useRef(0); |
| 6 | count.current += 1; |
| 7 | if (__DEV__) { |
| 8 | console.log(`[render] ${name} — ${count.current}`); |
| 9 | } |
| 10 | } |
C'est le premier coup d'œil de l'utilisateur. Sur un Tecno Spark Go, l'app par défaut prenait 7,8 secondes. Cible : sous 3 secondes. Voici ce qui a bougé l'aiguille :
Hermes est le moteur JavaScript optimisé pour React Native sur Android. Il pré-compile le bytecode, réduit la taille du bundle et démarre 30 à 50 % plus vite. Sur Spark Go : -2,1 secondes.
| 1 | // android/app/build.gradle |
| 2 | project.ext.react = [ |
| 3 | enableHermes: true, |
| 4 | ] |
Le splash JS classique attend que React monte. Sur entrée de gamme c'est lent. Le splash natif s'affiche dès le démarrage Android, avant React. Combiné à react-native-bootsplash, ça donne l'illusion d'un démarrage instantané.
Le router charge tous les écrans à l'init par défaut. Mauvais. Avec React.lazy ou un router qui supporte le lazy loading natif (React Navigation 6+ avec @react-navigation/native-stack) :
| 1 | // src/navigation/AppNavigator.tsx |
| 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 3 | import { lazy } from "react"; |
| 4 | |
| 5 | const HomeScreen = lazy(() => import("../screens/HomeScreen")); |
| 6 | const PortfolioScreen = lazy(() => import("../screens/PortfolioScreen")); |
| 7 | // ... 14 autres écrans tous lazy |
| 8 | |
| 9 | const Stack = createNativeStackNavigator(); |
| 10 | |
| 11 | export function AppNavigator() { |
| 12 | return ( |
| 13 | <Stack.Navigator> |
| 14 | <Stack.Screen name="Home" component={HomeScreen} /> |
| 15 | <Stack.Screen name="Portfolio" component={PortfolioScreen} /> |
| 16 | </Stack.Navigator> |
| 17 | ); |
| 18 | } |
Le bundle initial passe de 4,2 Mo à 1,8 Mo. Démarrage : -1,2 secondes.
90 % des problèmes de fluidité viennent des listes. Sur Nexus, l'écran "Mes investissements" affiche jusqu'à 200 lignes. Voici les règles :
FlatList (ou FlashList), jamais ScrollView + mapScrollView rend tous les enfants même hors écran. FlatList virtualise. FlashList de Shopify va encore plus loin en utilisant le recyclage de cellules natif :
| 1 | // ❌ Mauvais — rend les 200 items en mémoire |
| 2 | <ScrollView> |
| 3 | {investments.map((inv) => ( |
| 4 | <InvestmentCard key={inv.id} investment={inv} /> |
| 5 | ))} |
| 6 | </ScrollView> |
| 7 | |
| 8 | // ✅ Bon — virtualise, pré-rend une dizaine |
| 9 | import { FlashList } from "@shopify/flash-list"; |
| 10 | |
| 11 | <FlashList |
| 12 | data={investments} |
| 13 | estimatedItemSize={88} |
| 14 | renderItem={({ item }) => <InvestmentCard investment={item} />} |
| 15 | keyExtractor={(item) => item.id} |
| 16 | /> |
getItemLayout quand la hauteur est connueSi chaque item a la même hauteur, le donner à FlatList évite le mesurage et accélère le scroll :
| 1 | <FlatList |
| 2 | data={investments} |
| 3 | getItemLayout={(_, index) => ({ |
| 4 | length: ITEM_HEIGHT, |
| 5 | offset: ITEM_HEIGHT * index, |
| 6 | index, |
| 7 | })} |
| 8 | renderItem={...} |
| 9 | /> |
React.memo sur l'itemSans memo, chaque scroll re-render tous les items visibles, même si leur prop n'a pas changé :
| 1 | // src/components/InvestmentCard.tsx |
| 2 | export const InvestmentCard = React.memo(function InvestmentCard({ investment }: Props) { |
| 3 | return ( |
| 4 | <View style={styles.card}> |
| 5 | <Text>{investment.label}</Text> |
| 6 | <Text>{investment.amount}</Text> |
| 7 | </View> |
| 8 | ); |
| 9 | }, (prev, next) => prev.investment.id === next.investment.id && prev.investment.amount === next.investment.amount); |
Le comparateur custom évite la comparaison shallow par défaut qui re-render dès qu'une prop change de référence.
Une image 4 Mo téléchargée et décodée bloque le JS thread plusieurs secondes. Sur Spark Go, c'est mortel. Trois règles :
Demander au backend l'image à la bonne taille selon PixelRatio.get(). Pas 1 920 × 1 080 sur un écran 720 p.
| 1 | import { PixelRatio } from "react-native"; |
| 2 | |
| 3 | const width = 360; |
| 4 | const ratio = PixelRatio.get(); // 2 sur entrée de gamme typique |
| 5 | const imageUrl = `${cdn}/photo.jpg?w=${width * ratio}&q=80`; |
react-native-fast-imageLe composant Image de React Native a un cache faible et bloque le décodage. react-native-fast-image utilise SDWebImage (iOS) et Glide (Android) — décodage natif hors thread JS, cache disque et mémoire intelligent.
WebP réduit la taille de 30 à 40 % vs JPEG, et est nativement supporté sur Android 4.0+. AVIF encore plus mais le support est limité aux Android 12+. Faire servir AVIF quand le client l'accepte, fallback WebP.
Animer en JS sur entrée de gamme = drop frames garantis. La règle : toute animation doit tourner sur l'UI thread.
react-native-reanimated v3 permet d'écrire des "worklets" qui s'exécutent sur l'UI thread sans aller-retour vers le JS :
| 1 | import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; |
| 2 | |
| 3 | function SwipeableRow() { |
| 4 | const offset = useSharedValue(0); |
| 5 | |
| 6 | const style = useAnimatedStyle(() => ({ |
| 7 | transform: [{ translateX: offset.value }], |
| 8 | })); |
| 9 | |
| 10 | // Triggered by gesture handler — pure UI thread |
| 11 | const onSwipe = (delta: number) => { |
| 12 | offset.value = withSpring(delta); |
| 13 | }; |
| 14 | |
| 15 | return <Animated.View style={style} />; |
| 16 | } |
Pour les transitions d'écran lourdes (animations + chargement de données), déférer les calculs non critiques avec InteractionManager.runAfterInteractions. Ça laisse le runtime terminer la transition avant de lancer le fetch.
Sur 3G avec 600 ms de latence, attendre une requête réseau pour afficher quelque chose est insupportable. La règle : toujours afficher d'abord, recharger ensuite.
| 1 | // hooks/useInvestments.ts — pattern stale-while-revalidate |
| 2 | import { useQuery } from "@tanstack/react-query"; |
| 3 | |
| 4 | export function useInvestments(userId: string) { |
| 5 | return useQuery({ |
| 6 | queryKey: ["investments", userId], |
| 7 | queryFn: () => api.fetchInvestments(userId), |
| 8 | staleTime: 0, // toujours en arrière-plan |
| 9 | gcTime: 1000 * 60 * 60 * 24, // garde 24 h |
| 10 | refetchOnMount: "always", |
| 11 | }); |
| 12 | } |
Couplé à un persister AsyncStorage de React Query, l'utilisateur ouvre l'app et voit ses dernières données immédiatement, même hors ligne. Le fetch suit en arrière-plan et met à jour.
Sur connexion 3G, 1 Mo = 16 secondes de téléchargement. Le bundle doit être minimal :
source-map-explorer — visualise qui pèse quoi. Souvent, une librairie de 800 ko ajoutée pour une fonction debounce.import { debounce } from "lodash-es") pas globaux.core-js configuré pour la cible Android API 21+, pas Android 4.| Pattern | Quand | Gain |
|---|---|---|
| Compression gzip côté serveur | Toujours | -60 % de payload |
| HTTP/2 multiplexé | Toujours | -300 ms par requête en cascade |
| Pré-fetch sur idle | Avant transition probable | Transition perçue instantanée |
| Connexion keep-alive | Toujours | -100 ms par requête supplémentaire |
| Backoff exponentiel | Sur retry | Évite la surcharge en cas de panne réseau |
Après application de tous ces patterns sur Nexus Mobile :
| Métrique | Avant | Après |
|---|---|---|
| Démarrage à froid | 7,8 s | 2,4 s |
| FPS scroll liste 200 items | 18 | 58 |
| Mémoire au repos | 280 Mo | 145 Mo |
| Bundle initial | 4,2 Mo | 1,8 Mo |
| Time-to-interactive | 9,1 s | 3,2 s |
Le travail le plus rentable a été le passage à FlashList + memo (+30 fps d'un coup) et l'activation de Hermes + lazy loading (-3,2 s au démarrage). Le reste est de la rigueur quotidienne.
React Native sur Android entrée de gamme n'est pas un problème de framework, c'est un problème de discipline. Hermes, listes virtualisées, animations sur UI thread, images natives, bundle audit, offline-first. Aucun de ces patterns n'est révolutionnaire, mais leur application systématique fait la différence entre une app qui fonctionne et une app qu'on garde sur son téléphone.
La métrique à surveiller : time-to-interactive sur un appareil cible défini. Si c'est sous 3 secondes sur le bas du marché, votre app est utilisable partout.
Réactions des lecteurs
Aucun commentaire pour l'instant
Soyez le premier à réagir à cet article.