Publié par

Il y a 5 mois -

Temps de lecture 13 minutes

Optimiser le temps de build incrémental avec Gradle Profiler

Gradle build scan d'une application Android

Sommaire

Introduction

Votre build est trop long. Quand vous modifiez le code de votre application, vous attendez plusieurs minutes avant de pouvoir tester vos changements. Plusieurs dizaines de fois par jour.

Au fil du développement, vous intégrez de nouvelles bibliothèques d’analytics, des outils de développement, des processeurs d’annotation, un framework d’injection de dépendances… Et la durée du build augmente.

Dans le quotidien du développeur, il y a plusieurs scénarios de build : recompilation suite au changement d’un fichier de ressource ou à la modification d’une classe, ajout d’une méthode, compilation à partir de zéro…

Dans cet article, nous verrons comment profiler le temps de build d’une application pour corriger un problème de performance dans ce type de scénario.

Pré-requis

Une application

Récupérons le code de l’application Sunflower.

Sunflower est une application publiée par Google pour illustrer les bonnes pratiques du développement Android avec JetPack.

L’application n’est pas énorme mais elle contient déjà un nombre respectable de dépendances et son temps de build est significatif. Pour les besoins de cet article, j’ai créé un fork de Sunflower avec un changement qui augmente le temps de build incrémental.

Gradle Profiler

Après avoir clôné le dépôt de Gradle Profiler, installons l’exécutable gradle-profiler :

$ cd gradle-profiler

$ ./gradlew installDist
[...]
BUILD SUCCESSFUL in 890ms
11 actionable tasks: 11 up-to-date

$ sudo ln -s `pwd`/build/install/gradle-profiler/bin/gradle-profiler /usr/local/bin/

A propos de Gradle Profiler

Gradle Profiler est un outil qui automatise la collecte d’informations de durée et de profilage pour les builds Gradle. En d’autres termes, c’est un moyen de chronométrer le build Gradle de votre application et de diagnostiquer les problèmes de performance.

L’outil se présente sous la forme d’un exécutable gradle-profiler qu’on peut installer à partir du code source.

Il permet de définir plusieurs scénarios de compilation pour se rapprocher de la réalité quotidienne du développeur, par exemple :

  • Changement d’un fichier de ressources
  • Changement du code d’une méthode
  • Changement de l’interface d’une méthode
  • Recompilation complète de l’application
  • Changement d’un fichier de configuration transverse comme le manifeste de l’application

Profiler le build

Profiler la compilation à partir de zéro

Pour commencer, nous allons utiliser le cas d’usage le plus simple de Gradle Profiler en profilant un build complet de l’application.

Plaçons-nous à la racine du dépôt de Sunflower et lançons la commande :

$ gradle-profiler --profile buildscan assembleDebug

Sur la sortie standard, les messages se décomposent comme ceci. J’ai noté quelques commentaires entre crochets.

* Writing results to /Users/christobal/Workspaces/sunflower/profile-out-3

* Settings                                                                 
[Rappel de la configuration : répertoire de sortie, mode profilage/benchmark...]

* Inspecting the build using its default Gradle version

* Stopping daemons                                                         

* Scenarios
[Rappel de la configuration des scénarios...]

* Running scenario using Gradle 5.4.1 (scenario 1/1)

* Stopping daemons

* Running warm-up build #N
Execution time XXX ms

[Autre builds d'échauffement...]

* Running measured build #N
Execution time XXX ms

[Autre builds mesurés...]

* Stopping daemons

* Results written to /Users/christobal/Workspaces/sunflower/profile-out-3
  Scenario using Gradle 5.4.1
  - Build scan for measured build #1: https://gradle.com/s/f6s77kg2trvms
  [Scans des autres builds...]

Description de la commande

L’option --profile buildscan passe Gradle Profiler en mode profilage. Ca signifie que la compilation de l’application est lancée 3 fois (2 fois pour faire « chauffer » Gradle et 1 fois pour lancer le build à proprement parler). Ca veut dire aussi que Gradle observe les différentes étapes du build pour générer un rapport. L’URL du rapport est fournie à la fin.

La valeur buildscan utilise le profileur fourni en standard avec Gradle. C’est l’équivalent de lancer la commande ./gradlew --scan. Gradle Profiler est compatible avec d’autres profileurs, mais celui-ci est le plus facile à utiliser.

La cible assembleDebug compile l’application en variante debug. C’est la variante utilisée par défaut sur un poste de développement avec Android Studio.

Interprétation du résultat

Sur la sortie standard, Gradle Profiler a affiché le chronométrage du profilage :

[...]

* Stopping daemons

* Running warm-up build #1
Execution time 35667 ms

* Running warm-up build #2
Execution time 15717 ms
Using build scan plugin 2.0.2

* Running measured build #1
Execution time 14116 ms

[...]

Ici on peut voir que le démon Gradle est stoppé en début d’exécution, puis que 3 builds ont été lancés :

  1. Un premier build de warm-up qui relance le démon
  2. Un deuxième build de warm-up
  3. Un measured build, celui qui est profilé

On voit que le premier build est le plus long, et les deux suivants on la même durée.

En fin d’exécution, on voit les liens vers les rapports générés par Gradle Build Scan. Il suffit de les ouvrir dans un navigateur pour les analyser.

[...]

  - Build scan for measured build #1: https://gradle.com/s/f6s77kg2trvms

[...]

Voici à quoi ressemble un rapport du profileur :

Build scan Gradle pour un build complet

Ouvrons la vue Timeline. L’information la plus importante dans le cadre de cet article est le statut de chaque tâche.

Les tâches grisées sont celles qui ont bénéficié de la compilation incrémentale. Ce sont les tâches dont le résultat avait pu être mis en cache lors d’un build précédent et qui n’ont donc pas été relancées.

C’est ce qui devrait permettre à votre build de prendre quelques secondes au lieu de plusieurs minutes au cas où vous n’auriez modifié qu’un seul fichier source.

Profiler la compilation incrémentale

Pour profiler la compilation incrémentale dans des conditions similaires au quotidien du développeur, nous allons configurer plusieurs scénarios de build avec Gradle Profiler :

  1. Build complet
  2. Rebuild sans modification
  3. Incrémental après modification du manifeste
  4. Incrémental après modification de l’interface d’une méthode
  5. Incrémental après modification du code d’une méthode
  6. Incrémental après modification d’un layout
  7. Incrémental après modification d’un fichier de ressources

Gradle Profiler va les exécuter l’une après l’autre et en observant leur durée, nous verrons si une optimisation peut être faite.

Pour spécifier les scénarios, créons un fichier profile.conf :

default-scenarios = [
    "clean_build",
    "just_rebuild",
    "abi_change"
    "non_abi_change",
    "change_resource",
    "change_resource_value",
    "change_manifest",
    "change_layout"
]

clean_build {
    tasks = ["clean", "assembleDebug"]
}

just_rebuild {
    tasks = ["assembleDebug"]
}

abi_change {
    tasks = ["assembleDebug"]
    apply-abi-change-to = [
        "app/src/main/java/com/google/samples/apps/sunflower/data/GardenPlantingDao.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/data/repo/GardenPlantingRepository.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/viewmodels/GardenPlantingListViewModel.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/GardenFragment.kt"
    ]
}

non_abi_change {
    tasks = ["assembleDebug"]
    apply-non-abi-change-to = [
        "app/src/main/java/com/google/samples/apps/sunflower/GardenFragment.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/viewmodels/GardenPlantingListViewModel.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/data/repo/GardenPlantingRepository.kt",
        "app/src/main/java/com/google/samples/apps/sunflower/data/GardenPlantingDao.kt"
    ]
}

change_resource {
    tasks = ["assembleDebug"]
    apply-android-resource-change-to = "app/src/main/res/values/strings.xml"
}

change_resource_value {
    tasks = ["assembleDebug"]
    apply-android-resource-value-change-to = "app/src/main/res/values/strings.xml"
}

change_layout {
    tasks = ["assembleDebug"]
    apply-android-layout-change-to = "app/src/main/res/layout/fragment_garden.xml"
}

change_manifest {
    tasks = ["assembleDebug"]
    apply-android-manifest-change-to = "app/src/main/AndroidManifest.xml"
}

Le fichier de configuration respecte le format Typesafe config. La configuration se compose principalement d’un bloc de configuration par scénario et d’une propriété default-scenarios pour faire la liste des scénarios à lancer.

Chaque scénario permet de spécifier des propriétés indiquant plusieurs fichiers à modifier avant d’exécuter un build, comme par exemple apply-abi-change-to.

Maintenant, lançons la commande :

$ gradle-profiler --profile buildscan --scenario-file profile.conf

* Writing results to /Users/christobal/Workspaces/sunflower/profile-out-6

* Settings
[...]

* Inspecting the build using its default Gradle version

* Stopping daemons

* Scenarios
[...]

* Running scenario clean_build using Gradle 5.6.4 (scenario 1/8)

* Stopping daemons

* Running warm-up build #1
Execution time 57115 ms

* Running warm-up build #2
Execution time 15041 ms
Using build scan plugin 2.0.2

* Running measured build #1
Execution time 13262 ms

* Stopping daemons

* Running scenario just_rebuild using Gradle 5.6.4 (scenario 2/8)

* Stopping daemons

* Running warm-up build #1
Execution time 12146 ms

* Running warm-up build #2
Execution time 2597 ms
Using build scan plugin 2.0.2

* Running measured build #1
Execution time 5556 ms

* Stopping daemons

* Running scenario abi_change using Gradle 5.6.4 (scenario 3/8)
[...]

* Running scenario non_abi_change using Gradle 5.6.4 (scenario 4/8)
[...]

* Running scenario change_resource using Gradle 5.6.4 (scenario 5/8)
[...]

* Running scenario change_resource_value using Gradle 5.6.4 (scenario 6/8)
[...]

* Running scenario change_manifest using Gradle 5.6.4 (scenario 7/8)
[...]

* Running scenario change_layout using Gradle 5.6.4 (scenario 8/8)
[...]

* Results written to /Users/christobal/Workspaces/sunflower/profile-out-6
  Scenario clean_build using Gradle 5.6.4
  - Build scan for measured build #1: https://gradle.com/s/r7ff2bjcoioee
  Scenario just_rebuild using Gradle 5.6.4
  - Build scan for measured build #1: https://gradle.com/s/23mqq6wqv6pyu
  [...]

Pour interpréter le résultat, le plus simple est d’examiner le profilage du rebuild sans modification just_rebuild. Ce scénario ne devrait provoquer aucune recompilation Gradle puisqu’aucun fichier n’a été modifié. Tous les résultats de toutes les étapes du build devraient avoir été mises en cache.

Voyons ce que dit le scan :

Build scan Gradle pour une recompilation sans modification

Dans la vue Timeline, on s’attend à ce que toutes les tâches Gradle apparaissent grisées pour indiquer que l’étape est déjà à jour. Mais on voit que certaines étapes comme processDebugResources, mergeDebugResources ou packageDebug ne sont pas grisées.

Dans le scan, on peut cliquer sur un bouton Détails pour savoir pourquoi une étape n’est pas à jour :

Détail d'une étape du build scan

L’étape packageDebug n’est pas à jour parce qu’un fichier crashlytics-build.properties a été modifé. Ce n’était pas un changement délibéré de notre part, il y a donc un problème dans notre build. Le résultat de cette étape devrait être mis en cache, ce qui devrait permettre à Gradle de sauter l’étape et de gagner en temps de compilation.

Et d’après le scan, ça semble venir de la dépendance Crashlytics : une propriété du build a changé automatiquement, ce qui a causé une exécution complète de cette étape.

Optimiser le build

Cette partie dépendra toujours de la nature du problème mais je vais vous montrer comment le régler dans notre cas.

La documentation d’Android mentionne une propriété de build Crashlytics (le build ID). Le correctif proposé dans la documentation consiste à désactiver l’option enableCrashlytics dans le build.gradle :

android {
  ...
  buildTypes {
    debug {
      ext.enableCrashlytics = false
    }
}

Nous pouvons maintenant relancer gradle-profiler en spécifiant le build just_rebuild pour vérifier dans le build scan que le problème a disparu :

$ gradle-profiler --profile buildscan --scenario-file profile.conf just_rebuild

* Writing results to /Users/christobal/Workspaces/sunflower/profile-out-7

* Settings
[...]

* Inspecting the build using its default Gradle version

* Stopping daemons

* Scenarios
[...]

* Running scenario just_rebuild using Gradle 5.6.4 (scenario 1/1)

* Stopping daemons

* Running warm-up build #1
Execution time 36169 ms

* Running warm-up build #2
Execution time 1883 ms
Using build scan plugin 2.0.2

* Running measured build #1
Execution time 2313 ms

* Stopping daemons

* Results written to /Users/christobal/Workspaces/sunflower/profile-out-7
  Scenario just_rebuild using Gradle 5.6.4
  - Build scan for measured build #1: https://gradle.com/s/5c4hel5ykc3dc

Et voilà ! Dans la vue Timeline, nous voyons que toutes les étapes sont grisées, notamment packageDebug. Ca signifie que le résultat de cette étape a été mise en cache et que Gradle n’a pas eu besoin de l’exécuter.

Build scan Gradle après optimisation

Qualifier l’optimisation

Pour vérifier que notre optimisation a bien raccourci le temps de build, nous allons lancer le scénario avec l’option --benchmark. Cette option permet de lancer le build plusieurs fois (10 par défaut) afin d’observer une durée de build plus stable et aussi plus significative, par exemple en calculant une moyenne.

Avant l’optimisation, nous avions :

$ gradle-profiler --benchmark --scenario-file profile.conf just_rebuild

* Writing results to /Users/christobal/Workspaces/sunflower/profile-out-11

* Settings
[...]

* Inspecting the build using its default Gradle version

* Stopping daemons

* Scenarios
Scenario: just_rebuild using Gradle 5.6.4
  [...]
  Warm-ups: 6
  Builds: 10

* Running scenario just_rebuild using Gradle 5.6.4 (scenario 1/1)

* Stopping daemons

* Running warm-up build #1
Execution time 12734 ms

[...]

* Running warm-up build #6
Execution time 1910 ms

* Running measured build #1
Execution time 2458 ms

* Running measured build #2
Execution time 1972 ms

* Running measured build #3
Execution time 2470 ms

* Running measured build #4
Execution time 2127 ms

* Running measured build #5
Execution time 1983 ms

* Running measured build #6
Execution time 1770 ms

* Running measured build #7
Execution time 1997 ms

* Running measured build #8
Execution time 2081 ms

* Running measured build #9
Execution time 1927 ms

* Running measured build #10
Execution time 2102 ms

* Stopping daemons

* Results written to /Users/christobal/Workspaces/sunflower/profile-out-11
  /Users/christobal/Workspaces/sunflower/profile-out-11/benchmark.csv
  /Users/christobal/Workspaces/sunflower/profile-out-11/benchmark.html

Et après l’optimisation :

$ gradle-profiler --benchmark --scenario-file profile.conf just_rebuild

* Writing results to /Users/christobal/Workspaces/sunflower/profile-out-10

* Settings
[...]

* Inspecting the build using its default Gradle version

* Stopping daemons

* Scenarios
Scenario: just_rebuild using Gradle 5.6.4
  [...]
  Warm-ups: 6
  Builds: 10

* Running scenario just_rebuild using Gradle 5.6.4 (scenario 1/1)

* Stopping daemons

* Running warm-up build #1
Execution time 35899 ms

[...]

* Running warm-up build #6
Execution time 2030 ms

* Running measured build #1
Execution time 1987 ms

* Running measured build #2
Execution time 1991 ms

* Running measured build #3
Execution time 1841 ms

* Running measured build #4
Execution time 1850 ms

* Running measured build #5
Execution time 1936 ms

* Running measured build #6
Execution time 1845 ms

* Running measured build #7
Execution time 1797 ms

* Running measured build #8
Execution time 1749 ms

* Running measured build #9
Execution time 1869 ms

* Running measured build #10
Execution time 1884 ms

* Stopping daemons

* Results written to /Users/christobal/Workspaces/sunflower/profile-out-10
  /Users/christobal/Workspaces/sunflower/profile-out-10/benchmark.csv
  /Users/christobal/Workspaces/sunflower/profile-out-10/benchmark.html

Verdict

En faisant la moyenne du temps d’exécution des measured build, on peut évaluer l’efficacité de l’optimisation.

Avant l’optimisation, le build prenait en moyenne 2,088 secondes. Après l’optimisation, il prend 1,874 secondes. Ca fait un gain de 10 % environ. C’est toujours ça de pris !

Conclusion

Nous avons vu comment diagnostiquer un problème de durée du build Gradle de notre application grâce à Gradle Profiler, comment le corriger et comment vérifier le gain apporté par la correction.

Notre problème était simple et l’optimisation était documentée. Ce type de problème est causé par l’intégration de SDKs tiers, et il est relativement courant. On peut par exemple le rencontrer après l’intégration de Crashlytics ou de Firebase Performance Plugin.

On peut aussi le rencontrer quand on intègre des frameworks comme Dagger ou des bibliothèques comme Room, qui s’appuient sur l’usage d’annotations. En fonction de la version intégrée et des options de configuration, Gradle peut désactiver la compilation incrémentale, ce qui augmente le temps de build.

Par exemple, Gradle Profiler nous a montré que la version 2.1 de Room empêchait la compilation incrémentale sur l’application d’un de nos clients. En passant à la version 2.2 avec l’option room.incremental, nous avons constaté un gain de 40 % sur le temps de build lors d’une recompilation partielle.

Morale : lisez bien la documentation des SDK, frameworks et bibliothèques que vous intégrez dans vos applications. Et profilez votre build régulièrement pour détecter les problèmes de performance.

Autre piste pour l’optimisation de votre build : Android Studio 4 apporte une fonctionnalité de diagnostic et d’optimisation du temps de build.

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.