Réduire la durée d’un build Android

le 29/01/2016 par Rémi Pradal
Tags: Software Engineering

La durée d’exécution d’un projet est une métrique que tout développeur Android devrait surveiller de près. En effet, même si celui-ci a une grande confiance dans le code qu’il produit, il sera amené à réexécuter le projet plusieurs fois par jour. Lors du développement d’un projet, il est important pour le développeur de pouvoir constater rapidement le résultat de ses modifications. Dans le cas contraire, il peut se produire deux choses : soit le développeur se déconcentre (parce qu’il regarde ses mails par exemple), soit il revient à son code en oubliant de suivre les effets de sa dernière exécution.

Cette problématique peut paraître exagérée dans le cas d’un "petit" projet, compilable en moins de 30 secondes. Mais lorsque le nombre de lignes de code est élevé, elle devient bien réelle.

Nous pouvons découper l’exécution en deux étapes : la compilation et le déploiement. Puisqu’il est difficile de réduire la durée du déploiement (sauf à exécuter l’application dans un émulateur), cet article se concentre sur les leviers actionnables pour réduire la durée de compilation.

Diagnostiquer le temps de compilation

Nous pouvons définir deux types de durée de compilation :

  • La durée de compilation "à partir de zéro". Il s’agit de la durée nécessaire pour exécuter un projet pour la première fois (ou lorsqu’on lance un gradlew clean avant d’exécuter le projet).
  • La durée de compilation "incrémentale". Il s’agit de la durée minimale de compilation après une très petite modification du code, par exemple commenter une ligne de code.

Le but est de réduire la durée de compilation exécution après exécution : nous viserons donc une diminution de la durée de compilation "incrémentale". Les prochaines mentions de la durée de compilation feront référence à la durée de compilation "incrémentale" telle que définie précédemment. Réduire la durée d’une compilation "à partir de zéro" présente certes un intérêt, mais ce n’est pas un genre de compilation que nous lançons fréquemment (par exemple, nous nettoierons le projet ou changerons de branche une à deux fois par jour). Au contraire, l’expérience développeur sera grandement améliorée en réduisant la durée de compilation "incrémentale", puisque le développeur relance cette opération plusieurs dizaines de fois par jour.

Pour mesurer la durée de compilation nous pouvons utiliser la très utile option –profile de Gradle. Cette option génère un rapport des durées de chaque sous-tâche exécutée. Par exemple, si vous exécutez la ligne de commande gradlew assembleDebug –profile, Gradle va générer un rapport dans build/reports/profile-[date-de-la-build]. La capture d’écran qui suit montre un exemple de rapport généré sur un « gros » projet.

Exemple de rapport Gradle

Nous y distinguons les principales étapes de compilation d’une application :

  • Configuration : d’ordinaire très rapide (quelques secondes), sa durée dépend de la complexité de votre script Gradle.
  • Résolution des dépendances : presque toujours instantanée car les dépendances sont mises en cache sur votre ordinateur, même si vous avez lancé un « clean » avant. Elle peut prendre plus de temps si c’est la toute première compilation du projet ou si vous ajoutez/modifiez une dépendance dans votre build.gradle. Dans ce cas, la durée ne dépendra que du nombre de librairies appelées et du débit de votre connexion internet.
  • Exécution des tâches : l’étape la plus longue. Elle comprend les tâches de compilation, de dexing et de nombreuses de sous-tâches dépendant des tâches principales et du contexte. Certaines sous-tâches sont bien plus longues que d’autres : la précédente capture d’écran montre que celles relatives au multidexing sont les plus chronophages.

Dans la majorité des cas, l’exécution des tâches représente une part très élevée de la durée totale. Par exemple, dans le process de compilation dont est extraite la capture, l’exécution des différentes taches de la troisième étape prend 42 secondes sur un total de 50. Nous en concluons que c’est en travaillant sur ce point que nous obtiendrons un gain substantiel.

Configurer son IDE et son Gradle

Il existe plusieurs astuces à connaître pour réduire notre durée de compilation Gradle, nous les retrouvons facilement sur StackOverflow. Voici une compilation des meilleures qui précise pour chacune : en quoi l’astuce est utile et les cas où elle n’apporte aucun gain.

Gradle daemon

Ajouter org.gradle.daemon=true dans le fichier gradle.properties.

Vous rencontrerez souvent ce conseil. Ceci permet de lancer Gradle avant d’exécuter la première commande Gradle. Le gain sur la compilation peut s’élever à quelques secondes.

Cette solution n’est utile que si nous utilisons la ligne de commande pour toutes les compilations lancées. Android Studio utilise nativement le Gradle daemon [6] depuis longtemps ce qui rend cette astuce inutile dans la plupart des cas.

Compilation parallèle

Ajouter org.gradle.parallel=true dans le fichier gradle.properties.

Ce paramètre Gradle permet la compilation de modules en parallèle. Il n’est donc utile que si votre projet est composé de plusieurs modules. Plus la répartition des durées de compilation entre les modules est homogène, meilleur sera le gain sur la durée globale de compilation.

Notons que cette fonctionnalité est encore expérimentale : en l’activant, vous vous exposez donc à des comportements inattendus.

Configuration à la demande

Ajouter org.gradle.configureondemand=true dans le fichier gradle.properties.

Cette option aura un impact sur l’étape de « configuration » mentionnée dans la première partie de cet article. Si nous l’activons, l’étape de configuration d’un module donné ne sera faite que si le module joue un rôle dans la tâche Gradle que vous souhaitez exécuter. De la même manière que l’option de compilation en parallèle, cette option ne sera utile que si notre projet est scindé en plusieurs. À moins que nos scripts Gradle ne soient très complexes (ou qu’ils n’exécutent des tâches consommatrices comme un appel réseau), nous ne gagnerons que quelques secondes sur notre durée de compilation.

Travail hors-ligne

Dans Android Studio, cliquer sur le menu « Android Studio », puis sur "Preferences". Dans l’arborescence, aller dans "Build, Execution, Deployment" -> "Build tools" -> "Gradle". Cocher "Offline work".

Le nom de cette option parle d’elle-même ! Le gain peut être fort si nous utilisons une mauvaise connexion internet. Suivant la configuration de notre build.gradle, la compilation peut faire des appels réseaux (vérifier si une bibliothèque dispose d’une nouvelle version). En mode hors-ligne, Gradle utilisera les versions mises en cache. Pour ajouter une nouvelle dépendance, nous devrons désactiver l’option.

Régler sa cible minimale à 21

Le contexte dans lequel cette astuce est la plus utile est probablement celui d’un gros projet nécessitant du multidexing et dont la minSdkVersion est strictement inférieure à 21.

Une des évolutions intéressantes dans le processus de compilation apporté par la version 21 du SDK Android (Lollipop) a été l’introduction de Android Runtime (ART). ART et son précurseur Dalvik sont les Machines Java Virtuelles (JVM) sur mesure d’Android. Elles sont compatibles l’une avec l’autre : si votre application a une minSdkVersion de 15 (par exemple), alors les fichiers dex générés pourront être exécutés autant sur ART que sur Dalvik. D’autre part, si votre application a une version minimale de SDK de 21, des optimisations spécifiques à ART peuvent être employées lors de la compilation de l’app. L’une d’entre elles, très utile, est la suivante : ART ne requiert pas un fichier dex principal contenant toutes les classes appelées avant le MultiDex.install(). Ainsi, on peut sauter l’étape, très coûteuse en temps, d’identification des classes à inclure dans le fichier dex principal. La page de documentation MultiDex d’Android developers [1] donne de nombreuses explications sur toutes les optimisations apportées par ART.

Bien entendu, il ne serait pas admissible de passer tous nos gros projets à un SDK min de 21 ! Nous devons donc trouver un moyen d’avoir plusieurs configurations de compilation différentes. Nous pourrions en avoir une première réglée sur notre SDK min de base, que nous utiliserions pour générer l’APK à déployer en production ; et une autre utilisée par les développeurs lorsqu’ils souhaitent lancer des compilations incrémentales rapides.

Une solution évidente serait d’employer une fonction de Gradle bien connue des développeurs : buildTypes. Malheureusement, il n’est pas (encore) possible de préciser un SDK min pour un certain type de compilation [2]. Nous devons donc utiliser une autre fonction puissante du plugin Android Gradle : les flavors.

Ciblage de SDK min spécifiques grâce aux flavors

Les flavors permettent aux développeurs d’avoir différentes configurations de compilation, de ressources ou même de code, s’ils veulent être capables de générer des APK aux caractéristiques différentes. Ceci peut être utile si nous souhaitons générer facilement deux applications partageant le même code source mais sous des marques différentes.

La syntaxe d’un fichier build.gradle utilisant des flavors pour avoir deux configurations de compilation, une de type incrémentale rapide et une normale, est la suivante :

Voir le lien github

En conséquence de quoi, le numéro de notre variante de compilation est dupliqué. En choisissant la variante fastBuildDebug, le temps de compilation s’en trouvera significativement réduit. Si nous souhaitons voir à quoi ressemble votre app sur un appareil pre-Lollipop, c’est toujours possible, il nous suffit de choisir la variante regularDebug. De même, la variante que nous avons choisie pour notre application de production est désormais regularProd.

Gérer un projet ayant déjà plusieurs flavors

La méthode présentée n’est pas adaptée à une application qui a déjà différentes flavors. Pour la suite, imaginons que les flavors déjà présentes dans votre projet sont brandA et brandB. Si nous utilisons le code ci-dessus, nous ne pouvons pas avoir une compilation rapide présentant des caractéristiques identiques à celles spécifiées par brandA.

Par chance, il existe une solution à ce problème : utiliser les flavors multidimensionnelles [3]. Les flavors multidimensionnelles permettent de créer plusieurs jeux distincts de flavors. Lors de la génération de la variante de compilation, Gradle ne réalisera pas la substitution de deux flavors présentant un id de dimension différent ; à la place, il fera une juxtaposition.

Le script Gradle ci-dessous montre comment utiliser les flavors multidimensionnelles :

Voir le lien github

En considérant que nous avons deux types de compilation, debug et prod, nous nous retrouvons avec 8 (=2x2x2) variantes de compilation différentes traduisant les combinaisons possibles de configuration.

Résultats et limites de cette méthode

Changer le SDK min réduit considérablement la durée de compilation. Pour un gros projet, comptant environ 150k lignes de code, cette méthode réduit le temps de compilation incrémentale de 2:30 à 1:20. Cette réduction peut être constatée sur toutes les machines : nous avons fait la même analyse avec un autre ordinateur et obtenu un résultat similaire, passant de 3:00 à 1:45.

Pour autant, cette méthode présente quelques inconvénients : elle nous oblige à complexifier la configuration de la compilation. De fait, si nous employons un nom de tâche explicite dans nos scripts Gradle ou dans notre intégration continue (CI), nous devrons répercuter les changements dans chacune de ces références.

De plus, cette méthode double le nombre de tâches. Ainsi, si nous utilisons des tâches agnostiques aux variantes de compilation dans notre CI, telles que gradlew test, la durée de ce job sera plus ou moins doublée. Et pour cause, ces commandes sont lancées pour chaque variante de compilation… Ceci peut se résoudre facilement en n’appelant pas les tâches agnostiques aux variantes de compilation, mais nous devrons alors modifier la configuration de votre CI, ce qui peut s’avérer douloureux.

Fonctionnalités à venir : système de compilation Jack & Jill, Android Studio instant run

Il se pourrait que de futures fonctionnalités aient un impact sur la durée de compilation. La plus significative étant probablement la nouvelle chaîne de compilation Android Jack & Jill [4], et Android Studio instant run apportée par la version 2.0 [5].

Système de compilation Jack & Jill

Jack & Jill est la nouvelle chaîne de compilation (toolchain) développé par Google. Le but est de remplacer le système actuel, composé de deux étapes complexes "javac" et "dex" correspondant respectivement à la conversion des fichiers .java en .class, et à la conversion des .class en .dex (le format exécutable que la JVM Android, ART ou Dalvick, saura lire).

Le nouveau compileur Jack est capable de compiler directement les fichiers .class en .dex. Jill est un outil qui convertit des fichiers .jar existants (générés par la toolchain standard) en fichiers directement utilisable par le compilateur Jack.

Le diagramme qui suit synthétise les entrées et sorties de ce nouveau système de compilation.

Système de compilation Jack & Jill

Jack facilite la compilation incrémentale. En conséquence, l’utilisation de cette nouvelle toolchain devrait réduire le temps de compilation. Il est déjà possible d’essayer cette toolchain d’une façon très simple : il nous suffit d’ajouter useJack = true à l’intérieur de notre buildType ou de notre bloc de flavor.

Après quelques tests sur différents projets, nous n’avons pu compiler que quelques fois : dans la plupart des projets, nous avons rencontré des erreurs au cours de la synchronisation Gradle, en particulier sur les gros projets.

Si cette nouvelle toolchain est plutôt prometteuse, elle reste trop expérimentale à ce jour : nous ne pouvons pas encore compter sur elle pour réduire la durée de compilation des projets actuels.

Android Studio instant run

Instant run est une fonctionnalité disponible dans Android Studio 2.0 (encore en preview) [7]. Elle permet de pousser dans un émulateur les modifications d’une application de façon quasi instantanée. Est-ce la solution ultime à nos problèmes de durée de compilation ? Ce pourrait être le cas puisque, sur le papier, cette fonctionnalité ne présente aucun inconvénient par rapport aux techniques expliquées précédemment, et elle est bien plus pratique à l’usage.

Pour autant, instant run ne semble pas encore parfaitement stable : il arrive que le code modifié soit indiqué comme ayant été "poussé" alors que la modification n’a pas réellement été appliquée dans l’émulateur. Cette fonctionnalité est actuellement stable dans le cas d’un échange à chaud de ressources ou de fichier XML. C’est une grande amélioration car en général nous lançons de nombreuses compilations incrémentales lorsque nous modifions les fichiers XML.

Conclusion

La durée de compilation est une métrique que nous avons tout intérêt à suivre avec attention. Il est très facile de la laisser s’allonger au fur et à mesure que grossit l’application, et ce, sans même s’en rendre compte.

Il existe plusieurs manières de réduire la durée de compilation et d’être ainsi plus efficace dans notre développement. Aucune astuce n’est universelle : il convient d’identifier où se produit l’engorgement dans le déroulé de notre compilation, et d’appliquer la méthode la plus adaptée.

Nous avons vu que de futures fonctionnalités pourraient constituer d’excellentes solutions à cette problématique. Si les astuces "stables" ne sont pas suffisantes, gardons un œil sur leur évolution et guettons si elles deviennent suffisamment stables pour pouvoir être utilisées au quotidien.

References

[1] http://developer.android.com/tools/building/multidex.html#dev-build [2] https://code.google.com/p/android/issues/detail?id=80650 [3] http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Multi-flavor-variants [4] http://tools.android.com/tech-docs/jackandjill [5] http://android-developers.blogspot.fr/2015/11/android-studio-20-preview.html [6] https://plus.google.com/+AndroidDevelopers/posts/ECrb9VQW9XP [7] http://android-developers.blogspot.fr/2015/11/android-studio-20-preview.html