L'écoconception d'une application Android

L’écoconception consistant à intégrer la protection de l’environnement dès la conception de biens ou de services, vise à réduire l’impact environnemental tout au long du cycle de vie du produit. La protection de l’environnement n’étant pas directement le but recherché, on peut effectuer un parallèle avec l’ensemble de règles et d’outils qui permettent d’optimiser la performance d’un site web.

En effet, la performance est un levier direct pour améliorer le taux de conversion d’un site e-commerce, mais aussi son référencement naturel. Selon une étude de KISSMetrics, une page qui met plus de 3 secondes à charger fait perdre 40 % des visiteurs. Les grands noms du web l’ont bien compris ; Amazon a calculé qu’une baisse de 1 seconde de la vitesse de chargement de sa plateforme entraînerait une perte de 1,6 milliards de dollars. La performance a aussi un impact réel sur le SEO puisque Google prend en compte cette notion de rapidité dans la prise en compte de son algorithme d’indexation. De nombreux outils existent pour mesurer l’efficacité et la charge d’un site web : Pingdom Tools, Google PageSpeed Insights, WebPageTest, GTMetrix … Et on recense énormément d’articles, de tutoriels ou de plugins pour améliorer les performances et donc le coût énergétique d’une page web.

Si l’optimisation web est bien connue et les outils pour le quantifier nombreux, l’optimisation d’application mobile l’est beaucoup moins, et les outils très rares. Après un état de lieux de la consommation énergétique liée à l’utilisation des applications mobiles dans le monde, je vous donnerai un ensemble de règles à respecter pour réduire l’impact des applications mobiles que vous seriez amenés à développer. Étant Ingénieur concepteur développeur Android depuis 9 ans, c’est tout naturellement sur cette plateforme, et en Kotlin, que sont basés les exemples de code de cet article. Les stratégies mises en place sont indépendantes du langage. Il y a peu de différence de performance entre Java et Kotlin, seule la bonne pratique fera la différence.

Découvrez notre expertise en termes de performance
abstract

Qu’en est-il de l’impact environnemental des applications mobiles ?

Aujourd’hui, le numérique représente près de 4 % des gaz à effets de serre, selon le think tank The Shift Project. On estime que ce chiffre montera de 7 % à 8,5 % en 2025. Dans un contexte où les GES doivent être réduits, le numérique affiche une croissance forte de +9 % par an.

Cette consommation croissante s’explique en partie par le nombre de smartphones utilisés : 2,7 milliards de personnes possédaient un smartphone en 2019, et la prévision pour 2025 est de 5 milliards.

Si on projette la consommation des applications mobiles sur l’ensemble des utilisateurs de smartphone, on obtient une consommation totale de 20,3 TWh, soit un peu moins que l’équivalent de la consommation annuelle en électricité d’un pays comme l’Irlande. Cet impact très important peut être réduit en optimisant les applications.
 

Les stratégies à mettre en place

Nous allons voir comment optimiser les applications suivant deux axes : la réduction de la consommation de la batterie et la réduction des appels réseaux.
 

Différer les tâches pendant le chargement de la batterie si possible

Si nous effectuons des tâches pendant que le téléphone est en charge, cela ne comptera pas pour la décharge de la batterie. Sauf si quelque chose doit être fait immédiatement ou rapidement, nous devons le différer jusqu’à ce que le téléphone soit branché. Un bon exemple de cette application différée est la synchronisation de contacts ou de photos avec les différents services web.

Pour ce faire, nous avons besoin de connaître le statut de la batterie du téléphone, voici comment le détecter :

  • La création d’un BroadcastReceiver va nous permettre de recevoir les événements du changement de statut de la batterie. À la réception de l’événement, nous allons passer par un Observable pour notifier les vues.
class BatteryStatusReceiver: BroadcastReceiver() { 

override fun onReceive(context: Context?, intent: Intent?) { 
intent?.let { 
// Appel à la méthode pour notifier les observers 
ObservableObject.getInstance().updateValue(it) 
} 
} 
}
  • Un Observable va nous permettre l’ajout et la suppression d’observers. Nous allons créer ici un singleton que l’on va appeler à partir du BroadcastReceiver. La méthode updateValue va notifier les observers.
class ObservableObject : Observable() { 

companion object { 
private val instance = ObservableObject() 
fun getInstance(): ObservableObject = instance 
} 

fun updateValue(data: Any) { 
synchronized(this) { 
setChanged() 
// Notifie les observers 
notifyObservers(data) 
} 
} 
}
  • Pour terminer, il faut écouter les événements sur la vue, que ça soit une Activity ou un Fragment, en utilisant l’observer :
class MainActivity : AppCompatActivity(), Observer { 

private val batteryStatusReceiver = BatteryStatusReceiver()

// A appeler lorsque la vue est active (onCreate, OnResume)

private fun initReceiver() {

// On va écouter les action de charge connecté et déconnecté 
IntentFilter().let { 
it.addAction(ACTION_POWER_DISCONNECTED) 
it.addAction(ACTION_POWER_CONNECTED) 
registerReceiver(batteryStatusReceiver, it) 
}

// On ajoute l’activité à la liste des observer

ObservableObject.getInstance().addObserver(this) 
}

// A appeler lorsque la vue est inactive (onPause, onDestroy)

private fun deleteReceiver() { 
unregisterReceiver(batteryStatusReceiver)

// On supprime l’activité de la liste des observers 
ObservableObject.getInstance().deleteObserver(this) 
}

override fun update(observable: Observable?, any: Any?) { 
val intent = any as Intent 
when (intent.action) { 
ACTION_POWER_CONNECTED -> // EFFECTUER traitement de synchronisation 
ACTION_POWER_DISCONNECTED -> // TERMINER traitement de synchronisation

} 
}
}

Avec cette mise en place, vous allez pouvoir détecter lorsque le téléphone est en charge ou non, et effectuer des traitements qui peuvent attendre que le téléphone soit en charge !
 

Concaténation de caractères

Le deuxième conseil que je peux donner, c’est d’utiliser la classe StringBuilder pour la concaténation de chaîne de caractères. En effet, elle est beaucoup plus économe en ressources que la simple concaténation.

Simple concaténation :

var string = "hello" 
for (i in 0..9_999) { 
    string += " world" 
}

Concaténation avec StringBuilder :

var string = "hello"

for (i in 0..9_999) { 
sb.append(" world") 
} 
string = sb.toString()

La première méthode prend 1,5 secondes sur mon device Android de test, quant à la seconde, elle se termine en 8 ms. En termes de charge CPU, là aussi la première prend un peu moins de 15 %, quant à la seconde elle frôle le 0 % pour le même résultat !
 

Utilisation du GPS

Il existe deux API qui permettent de récupérer la position de l’utilisateur dans une application mobile Android : l’API Android Location et l’API Google Play Location Service. La deuxième a plusieurs avantages dont ne bénéficie pas la première :

  • Choix automatiquement duprovider (GPS, Réseau) en fonction de la précision demandée de l’utilisation de la batterie…
  • Plus rapide
  • Meilleure précision
  • Fonctionnalités et paramétrage plus avancés (Geofencing)
  • Économie de batterie

L’utilisation de l’API nécessite d’utiliser 3 composants :

  • FusedLocationProviderClient : c’est le point d’entrée pour interagir avec l’API
  • LocationCallback : à utiliser pour recevoir les notifications de l’API lorsque l’emplacement du téléphone change
  • LocationRequest : contient les paramètres de l’API.

C’est sur ce dernier objet que nous allons configurer les requêtes du GPS et donc optimiser l’API pour économiser la batterie du téléphone.

Définir la période d’expiration de la requête :

Cette méthode configure un délai d’expiration pour les requêtes de géolocalisation. Le client sera automatiquement stoppé après l’expiration des requêtes.

Gérer la fréquence des requêtes, à adapter au cas d’utilisation :

Définit la fréquence de la mise à jour de la localisation. Attention ! Si d’autres applications ont des mises à jour plus fréquentes, vous les recevrez.

Définit la fréquence à laquelle on reçoit les mises à jour.

Mises à jour basées sur le déplacement

Cette méthode permet de modifier la distance minimum en mètres à laquelle on va notifier le client. Cette distance est donc à adapter au cas d’utilisation. Dans le cas d’une application pour détecter les restaurants aux alentours, on définira une distance entre 10 et 30 mètres, pour une application de météo, cette distance pourra être plus importante (entre 5 et 10 Km).

Définir la priorité de la requête 

La priorité des requêtes à une forte influence sur la consommation de batterie. Quatre priorités sont définies :

  • PRIORITY_HIGH_ACCURACY : la précision est fine et la consommation importante 
  • PRIORITY_BALANCED_POWER_ACCURACY : précision limitée à 100 mètres. La consommation batterie est moyenne
  • PRIORITY_LOW_POWER : précision de 10 km maximum. La consommation batterie est basse 
  • PRIORITY_NO_POWER : l’API est en écoute passive à l’attention d’autres clients. La sollicitation de composant matériel étant inactive, la consommation batterie pour la récupération d’information de localisation est donc nulle.

À partir de l’ensemble des méthodes définies précédemment, nous allons pouvoir définir un exemple d’utilisation de l’API optimisée, pour une application d’affichage de météo qui économisera au mieux la batterie.

locationRequest = LocationRequest().apply { 
// Expiration de la requête : 10 minutes 
setExpirationDuration(10 * 1_000L) 
priority = LocationRequest.PRIORITY_LOW_POWER 
// Distance minimum pour notifier le callback : 3 km 
smallestDisplacement = 3_000F 
// 10 minutes 
interval = 10 * 60 * 1_000 
maxWaitTime = interval 
fastestInterval = interval 
}

L’objet LocationRequest est donc configuré avec une requête d’expiration de 10 minutes, une priorité LOW_POWER, un espace minimum de déplacement de 3 km et un intervalle minimum de notification pour le client de 10 minutes. Cette configuration permet donc de créer une application météo entièrement fonctionnelle tout en économisant la batterie, en sollicitant au minimum le composant GPS et le CPU.
 

Repository pattern

Effectuer des appels réseaux a un coût important sur la batterie, mais aussi sur l’ensemble du système dont il est dépendant : routeurs et datacenters. Pour éviter d’effectuer les appels sur des données que nous avons déjà téléchargées, je mets souvent en place dans mes projets le Repository Pattern. Le repository délivre les données du cache si elles sont disponibles, sinon il les récupère depuis l’API. Ce pattern est d’ailleurs très utile dans le cas du développement d’une application avec gestion online/offline. Il possède de multiples avantages :

  • Dissocie l’application des sources de données.
  • Fournit des données provenant de plusieurs sources (BDD, API, cache mémoire) sans que le client s’en préoccupe.
  • Isole la couche de données.
  • Accès unique et centralisé des données.
  • Logique métier testable via des tests unitaires.
  • Ajout facile de nouvelles sources.

Voyons maintenant comment implémenter ce pattern. Nous allons dans cet exemple utiliser RX, les LiveData et une architecture MVVM (Modèle–Vue/Vue–Modèle) avec des ViewModel dans un contexte où on récupère une liste de Post.

Création d’une interface : en effet l’ensemble des différentes sources de données et le repository, vont partager les mêmes méthodes définies dans l’interface.

interface RepositoryInterface { 

fun getPosts(): Single<List<Post>?> 
}

Création du Repository : ici le Repository porte l’instance des différentes sources de données (RepositoryAPIDelegate pour les appels API, RepositoryCacheDelegate pour les accès à une base de données). Son rôle est d’arbitrer la source de données à récupérer.

private val apiRepo: RepositoryAPIDelegate()

private val cacheRepo: RepositoryCacheDelegate()

override fun getPost(): Single<List<Post>?> = Single.create { emitter ->

// Dans un premier temps on vérifie si les données sont présente en cache 
cacheRepo.getPost().doOnSuccess { postsCache -> 
if (!postsCache.isNullOrEmpty()) {

// Il existe des données en cache 
emitter.onSuccess(postsCache) 
} 
}.doAfterSuccess { postsCache -> 
if (postsCache.isNullOrEmpty()) {

// Si des données ne sont pas présentes en cache nous faisons appel à l’API apiRepo.getPost().doOnSuccess { postsAPI -> 
if (!postsAPI.isNullOrEmpty()) {

// Stockage des données dans le cache 
cacheRepo.storeList(postsAPI) 
emitter.onSuccess(postsAPI) 
} 
}.subscribe() 
} 
}.subscribe() 
}

Dans cet exemple, nous créons un Single qui va émettre les données du cache ou de l’API. Dans un premier temps, nous souscrivons à la méthode de récupération des données du cache. En cas de succès et de données non–vides, nous retournons les données, auquel cas, nous faisons appel à l’API qui en cas de succès retournera les données mais aussi fera appel au cache pour y stocker les données.

 

Stratégies réseau

Comme indiqué dans cette machine à état, le composant réseau (3G) comprend 3 différents états. Si des données sont envoyées ou reçues, l’appareil est en haute consommation. Si les données n’ont pas été envoyées et reçues pendant un certain temps, l’appareil commencera à se mettre en veille. L’appareil passera de la haute consommation à un mode basse consommation pour passer en mode veille.

Il est très courant d’être dans un modèle où nous demandons un peu de données, attendons un moment, puis demandons plus de données. Le problème avec cette approche est que nous passons constamment d’un mode veille à un mode réveil et qu’un coût d’énergie est associé à cela. Chaque appel réseau entraîne le réveil du composant, puis reste éveillé pendant un certain temps pour obtenir une réponse. Dans le pire des cas, on réveille le composant dès qu’il se rendort.

La meilleure façon d’éviter ce modèle est de faire la distinction entre ce qui doit arriver maintenant et ce que l’on veut faire plus tard. On souhaitera donc regrouper autant d’opérations réseau que possible. Cela permettra au composant d’entrer une fois à pleine puissance aussi longtemps que nécessaire. Comme il n’est pas possible de prédire ce que l’utilisateur va vouloir faire, il est judicieux de pré-extraire autant de données que possible afin d’éviter les demandes inutiles.
 

Téléchargement volumineux vs plus petits

Il est préférable d’effectuer un téléchargement volumineux de données plutôt que plusieurs téléchargements plus petits. La raison est qu’un téléchargement volumineux permet au composant réseau de passer en mode basse consommation tandis que plusieurs téléchargements plus petits vous maintiendront en pleine puissance ce qui sera plus impactant en matière de consommation de batterie.

Réduction du poids de l’application

La réduction du poids des ressources de l’application (images, fichiers XML) est aussi un facteur de réduction de l’empreinte de celle-ci. Cela peut être effectué de plusieurs manières :

  • Utiliser des ressources XML Drawables plutôt que des fichiers images si possible
  • Supprimer les ressources non utilisées
  • Compresser les images PNG / JPG avec un outil de compression 
  • L’outil aapt peut optimiser les images durant le processus de compilation. Par exemple un PNG qui ne requiert pas plus de 256 couleurs pourra être converti en 8 bits. En faisant ça, le résultat visuel sera le même tout en limitant son empreinte de mémoire
  • L’outil compressera uniquement les images du dossier res/drawable 
  • Les fichiers images doivent utiliser moins de 256 couleurs pour être optimisées
  • Compresser les assets en activant shrinkResources dans le fichier gradle
android { 
    // Other settings 

    buildTypes { 
        release { 
           shrinkResources true 
        } 
    } 
}

Mieux cibler les terminaux et les versions d’OS

Publier une application au format Android App Bundle permet de réduire la taille, de simplifier les releases et de proposer des fonctionnalités à la demande.

Les packages Android App Bundle utilisent un nouveau modèle de diffusion d’applications, appelé « Google Play Dynamic Delivery » afin de créer et de diffuser des APK optimisés pour chaque configuration d’appareil. Comme il supprime le code et les ressources inutilisées pour les autres appareils, ce modèle de publication permet aux utilisateurs d’installer une application plus petite et plus efficace.

Format texte vs binaire

Il est extrêmement courant d’utiliser JSON ou XML pour le transfert de données, même si les données seront compressées à l’aide de GZIP, elles seront toujours beaucoup plus grandes qu’un format binaire sérialisé. En utilisant un format binaire sérialisé, tel que la librairie FlatBuffers, vous pouvez transférer la même quantité de données en réduisant la taille de façon drastique. En outre, l’analyse (sérialisation/désérialisation) des formats de texte tels que JSON est plus gourmande en processeur que l’analyse des formats binaires.

En passant du côté obscur

L’affichage est une des causes de la consommation de batterie sur un smartphone, mais elle mineure (on estime celle-ci à environ 3%). Dans les écrans OLED/AMOLED, les pixels émettent de la lumière de façon indépendante. Cela signifie que l’utilisation d’un fond sombre peut vous faire économiser de l’énergie : un pixel noir sera un pixel éteint. Le LCD quant à lui consomme plus d’énergie car il fait fonctionner le rétro éclairage en permanence. Sur les écrans LCD, l’utilisation de pixels sombre n’entrainera aucune économie.

 

En résumé, il existe plusieurs axes pour optimiser la consommation d’une application mobile Android. Il est bien sur intéressant en tant qu’architecte, ou simple développeur exécutant de les connaître, ne serait-ce que par souci de qualité de code et de produit. Mais les connaître et les appliquer, c’est aussi contribuer à la réduction de la pollution numérique inhérente à la consommation croissante actuelle.

Article publié dans le n°239 de Programmez!

Ce que nous proposons en termes de responsabilité numérique
abstract