Cyril Mottier

“It’s the little details that are vital. Little things make big things happen.” – John Wooden

Peaufiner Son UI Grâce Au Tramage/Dithering

Comme vous l'avez probablement remarqué, je suis quelqu'un d'assez perfectionniste (en tout cas sur les UIs, moins sur les fautes d'orthographes de mes articles …) qui aime utiliser et surtout concevoir des applications aux interfaces graphiques abouties ou comme j'aime le dire polished. Pour considérer une interface comme “parfaite” encore faut-il avoir une liste de l'ensemble des points gageant de la qualité d'une interface : fluidité, esthétique, ergonomie, etc. Au cours de précédents articles, j'ai déjà mentionné beaucoup de ces points “limitants” en donnant des techniques permettant de minimiser leurs impacts ou tout simplement de les supprimer :

Une UI est le produit de plusieurs acteurs. Ces acteurs peuvent être séparés en 4 grands groupes : les ergonomes (qui s'attachent à reproduire/coller voire améliorer la logique des applications Android), les graphistes (qui tentent également de suivre le design/look ‘n 'feel Android), des intégrateurs (les développeurs utilisant le travail des 2 précédents acteurs pour concevoir l'application finale) et les testeurs (qui sont, je le déplore, souvent les utilisateurs finaux sur Android). En qualité d'ingénieur d'études sur plateformes mobiles (et donc d'intégrateur), je suis quotidiennement amené à concevoir des applications pour la plateforme Android. Il m'arrive donc de devoir intégrer des chartes graphiques dans certaines applications. Je m'aperçois, malheureusement, que les graphistes n'ont très souvent aucune ou peu de connaissances particulières concernant les contraintes inhérentes à Android et au mobile en général. Voici le “top 5” des points sur lesquels je me bats :

  • Boutons à glossy multiples dans tous les sens. Cela donne généralement de jolis boutons mais rend totalement impossible sa transformation en 9-patches. Les graphistes ne pensent pas que le contenu peut s'agrandir (suite à l'internationalisation par exemple). En conséquence, les images sont étirées “salement” ou leur contenu déborde lorsqu'il est trop important (dans le contexte d'un bouton, je considère que l'option setEllipsize de la classe TextView n'est pas une bonne solution)

  • Utilisation abusive de la transparence. Il est vrai que les designs à la “Web 2.0” utilisant énormément la transparence sont assez bien adaptés au mobile en terme d'esthétique. Malheureusement, qui dit transparence dit “alpha blending”. Ce processus qui consiste à déterminer la couleur finale d'un pixel transparent en fonction des couleurs des pixels sous-jacents, coûte assez cher et n'est donc pas réellement adapté à des designs en mouvement continuel (ListView par exemple).

  • Positionnement absolu des objets graphiques. Ce problème récurrent démontre tout simplement que les graphistes n'ont pas connaissance des possibilités d'Android en matière d'adaptation aux différentes tailles d'écran. Je me retrouve très souvent confronté à des designs spécialement conçus pour des résolutions de 480x320 pixels en mdpi (160dpi) et en orientation portrait. Que faire de ces designs lorsque l'écran a une résolution/densité différente ou tout simplement lorsqu'il passe en mode paysage ?

  • Manque d'états sur les boutons. Les ergonomes le crient haut et fort : un retour utilisateur est indispensable lorsque ce dernier interagit avec le terminal : appui sur l'écran, utilisation de la trackball, etc. J'ai souvent accès à des chartes graphiques n'ayant qu'un seul et unique état pour chaque “contrôle” (ce que j'entends par contrôle c'est un élément graphique sur lequel l'utilisateur peut agir : EditText, Button, etc.). Comment montrer à l'utilisateur que l'appui sur le bouton a bien été pris en compte ? C'est tout simplement impossible. N'oubliez donc pas de définir l'ensemble des états qui peuvent être utilisés (notion de StateListDrawable).

  • Utilisation de dégradés. Une belle charte graphique est généralement composée de dégradés. Bien que le rendu soit parfait sur l'écran de votre ordinateur, il en va bien souvent autrement lorsque le design est intégré à une application Android. Je vous laisse regarder les images ci-dessous pour bien comprendre de quoi je parle et ce que nous allons essayer de contourner dans cet article :

Vous n'arrivez pas à voir le problème qui me chagrine dans de nombreuses applications Android ? Regardons de plus près ! A un tel niveau de zoom vous ne pouvez pas le rater :

Avec les deux images ci-dessus, le problème saute aux yeux : le dégradé est discontinu et un phénomène de bandes apparait. Ce phénomène (qui n'est absolument pas voulu, à mon avis, sur les copies d'écran précédentes) est aussi appelé gradient banding en anglais.

Explication du phénomène

En informatique, une couleur est représentée sous la forme d'un ensemble de 4 valeurs représentant respectivement le pourcentage d'alpha, de rouge, de vert et de bleu. Ainsi une couleur telle que #ff0000 représente le rouge parfait alors que #770000ff indique un bleu semi transparent

La plateforme Android dispose de plusieurs modes de configuration pour représenter les couleurs. Ces modes de configuration sont plus communément appelés “palettes de couleur” (cf android.graphics.Bitmap.Config) et permettent au système de comprendre la représentation des couleurs en mémoire (nombre de bits pour chaque couleur) :

  • ARGB_8888 : Chacune des 4 composantes est codée sur 8 bits. Une couleur de la palette ARGB_8888 occupe donc 4x8 = 32 bits en mémoire. Dans cette palette de couleur, il y a 232 = 4 294 967 296 couleurs différentes si on compte l'alpha et 23x8 = 16 777 216 si l'alpha n'est pas compté (ce qui est généralement le cas)

  • ARGB_4444 : Cette palette permet d'obtenir 23x4 = 4 096 couleurs différentes

  • RGB_565 : Cette palette ne gère pas la couche alpha et permet de représenter 25+6+5 = 216 = 65535 couleurs différentes

  • ALPHA_8 : Ne représente que la couche alpha. Cette configuration ne nous intéresse pas dans le cadre de cet article puisqu'elle ne contient que les informations du canal alpha.

Malgré les possibilités d'Android, le matériel limite souvent les possibilités de rendu graphique. En effet, les screenshots ci-dessus montrent des images affichées en mode ARGB_8888. Pourquoi de telles “bandes” sur les dégradés? Ce problème n'est, en réalité, pas inhérent à Android mais aux terminaux mobiles qui utilisent très souvent une palette de 16 bits pour représenter les couleurs à l'écran. Notre émulateur adoré reproduit très bien ce phénomène puisqu'il utilise également une palette restreinte (16 bits). Ainsi, une image affichée dans le mode ARGB_888 n'aura à l'écran qu'un maximum de 216 = 65536 couleurs différentes. C'est ce faible nombre de couleurs disponibles qui provoque le gradient banding

Comment contourner le problème ?

Il existe évidemment une méthode permettant de contourner le problème. L'astuce passe par un principe vieux comme l'informatique : le tramage (ou dithering en anglais). Cette technique consiste à “mélanger” les pixels proches les uns des autres pour faire croire à un dégradé plus linéaire.

Activer le dithering par XML

Comme vous le savez, Android permet d'instancier des Drawables par l'intermédiaire de fichiers XML. Imaginons que nous disposions d'une image bitmap_to_dither.png dans res/drawable. Le code XML ci-dessous montre comment créer un BitmapDrawable avec tramage activé (grâce à la propriété android:dither) :

1
2
3
4
<?xml version="1.0" encoding="UTF-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
    android:dither="true"
    android:src="@drawable/bitmap_to_dither" />

De la même façon, une image nine_patch_to_dither.9.png pourra être utilisée et tramée dans un NinePatchDrawable :

1
2
3
4
<?xml version="1.0" encoding="UTF-8"?>
<nine-patch xmlns:android="http://schemas.android.com/apk/res/android"
    android:dither="true"
    android:src="@drawable/nine_patch_to_dither" />

Activer le dithering en Java

Instancier les objets par XML est une fonctionnalité très importante d'Android. Cela permet de clarifier le code Java et surtout de séparer la logique de l'aspect UI. Les exemples disponibles dans le SDK encouragent très fortement l'utilisation de fichiers XML lorsqu'il s'agit de créer un layout, un drawable, une animation, etc. Malheureusement, il arrive parfois (c'est assez rare) que certaines fonctionnalités accessibles en Java ne le soient pas en XML. Dans un tel cas, il est nécessaire de passer directement par le code Java :

1
Drawable d = getResources().getDrawable(R.drawable.drawable_to_dither).setDither(true);

Le code donné précédemment récupère le Drawable drawable_to_dither puis active le tramage sur ce dernier. Si on considère que d est un Drawable de type GradientDrawable (instancié par XML grâce à la balise <shape />), on s'aperçoit que c'est la seule et unique façon d'activer le dithering sur le gradient. En effet, la classe GradientDrawable ne gère pas l'attribut XML android:dither. Je ne sais pas si cette option a tout simplement été oubliée (ce qui me parait bizarre puisqu'Android existe déjà - publiquement - depuis plus de 2 ans) ou si c'est une volonté de la part de la “team Android” (dans ce cas je ne comprends pas réellement l'utilité puisque l'activation via code est possible et que le tramage ne me semble pas être une fonctionnalité très consommatrice de ressources).

Note : Android (depuis la build Eclair - 2.0) facilite grandement la tâche des développeurs puisque de nombreux Drawable ont l'option “dither” activée par défaut : BitmapDrawable, NinePatchDrawable, etc. Malheureusement, lorsqu'on considère les parts de marchés des systèmes Android à la date de rédaction de cet article (13 janvier 2010), on ne peut pas considérer l'option comme “automatique”. Un bon développeur (c'est à dire vous !) se doit de faire en sorte que l'application fonctionne parfaitement sur la totalité des systèmes postérieurs à Cupcake - 1.5. L'activation de l'option “dither” doit toujours se faire de façon manuelle.

Pré-tramer vos images

La dernière possibilité qui s'offre à vous est de pré-tramer vos images à l'aide de votre éditeur graphique préféré… Cette méthode a l'avantage de soulager légèrement (le tramage à la volée n'est pas une opération extrêmement coûteuse vu la puissance des terminaux actuels) le terminal lors du rendu. L'inconvénient réside dans la difficulté d'effectuer ce pré-tramage. J'ai longtemps cherché des méthodes performantes pour pré-tramer mes images et je pense avoir trouvé deux solutions :

  • La première consiste à séparer les 3 couches rouge, vert et bleu de l'image à tramer. Pour chacune des couches on réduit (avec l'option tramage activée) le nombre de bits autorisés pour représenter les différentes couches (en RVB_565 cela on obtient 5 bits pour le rouge, 6 bits pour le vert et 5 bits pour le bleu). On recombine enfin les 3 couches pour obtenir une image parfaitement tramée. Cette démarche est parfaitement expliquée sur ce site anglophone

  • La seconde technique est beaucoup plus simple puisqu'il s'agit d'utiliser un simple plugin Photoshop. Ce plugin ne permet pas, contrairement la méthode précédente, de choisir avec précision le nombre de bits pour chaque couche de couleur. Le plugin est téléchargeable sur le site de Telegraphics

Le tramage par l'exemple

Enfin me direz-vous ! Vous avez raison ! Fini de discuter. Passons à la pratique avec un exemple concret. L'objectif est de réaliser un splash screen qui s'adapte parfaitement à différentes résolutions, différentes orientations … en clair, un splash screen parfaitement conçu pour Android !

Note : Cet article ne traitera pas de la gestion des différentes densités. Il aurait fallu simplement remplir les dossiers res/drawable-ldpi, res/drawable-mdpi et res/drawable-hdpi avec les images adéquates. Mis à part ce point (non abordé pour faciliter la compréhension) les techniques citées ci-dessous permettent de créer un splash-screen indépendant des densités

Commençons, tout d'abord, par une brève introduction de la scène. Nous sommes en train de réaliser un application qui nécessite d'avoir des design parfait. Nous décidons donc de faire appel à un graphiste freelance qui nous aide à trouver une charte graphique et surtout un splash screen attirant. Le résultat envoyé est une image de 320x480, sorte de design brut comme présenté ci-dessous :

En intégrant de façon “bête et disciplinée” les ressources graphiques reçues à l'aide d'une simple ImageView ayant l'attribut android:scaleType à fitXY, on obtient une image étirée (cela se voit surtout en orientation paysage) et donc totalement non adaptée à une utilisation sur un terminal Android :

Réaliser un bon splash screen passe donc par plusieurs étapes :

  • Commençons par “layouter” le splash screen. Ce que j'entends par “layouter” c'est de découper le splash screen en composants élémentaires qui serviront de vues et seront positionnées grâce à un simple layout (FrameLayout, LinearLayout, etc.). Dans notre exemple, on peut séparer le splash screen en 3 parties : ss_logo, ss_version et ss_author. Le positionnement s'effectue grâce à un FrameLayout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android" >

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|right"
        android:src="@drawable/ss_version" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:orientation="vertical" >

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:src="@drawable/ss_logo" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="@string/loading" />
    </LinearLayout>

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left|bottom"
        android:src="@drawable/ss_author" />

</merge>
  • Pour éviter le phénomène de bandes, il convient de tramer à l'aide d'une des méthodes précédemment citées. Notez qu'il n'est pas toujours nécessaire d'effectuer le tramage de vos images. Les images construites sur une faible palette de couleurs (ss_author par exemple) n'ont pas besoin d'être tramées puisqu'elles ne peuvent pas souffrir de ce fléau de gradient banding. A contrario, ss_logo dispose de plusieurs dégradés (alpha, gris vers blanc) et son rendu est meilleur après tramage

Le fichier ss_background.xml qui permet de définir le dégradé de fond est ajouté au répertoire res/drawable

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle" >

    <gradient
        android:angle="90"
        android:endColor="#31343c"
        android:startColor="#4e525c" />

</shape>

Les images élémentaires du splash screen sont découpées comme suit (le fond des images est en réalité transparent - le gris a été ajouté afin de mieux voir les éléments blancs.

  • Le rendu des dégradés redimensionnables doit être fait en “software”. Dans notre exemple, le fond de notre splash screen s'effectue de façon logicielle en activant l'option android:dither
SplashScreenActivity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.cyrilmottier.android.metromap;

import android.app.Activity;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;

import com.cyrilmottier.android.metromap.util.Config;

public class SplashScreenActivity extends Activity {

    private final Handler mHandler = new Handler();
    private static final int SPLASH_SCREEN_DURATION = 1000;

    private final Runnable mPendingLauncherRunnable = new Runnable() {
        public void run() {
            Intent intent = new Intent(SplashScreenActivity.this, MetroMapActivity.class);
            startActivity(intent);
            finish();
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.splash_screen);

        mHandler.postDelayed(mPendingLauncherRunnable, SPLASH_SCREEN_DURATION);

        // Let's activate dithering for the background to prevent banding
        Drawable d = getResources().getDrawable(R.drawable.ss_background);
        d.setDither(true);
        findViewById(android.R.id.content).setBackgroundDrawable(d);

    }

    @Override
    protected void onPause() {
        super.onPause();
        mHandler.removeCallbacks(mPendingLauncherRunnable);
    }

}

A vos Photoshop, Gimp et autres logiciels exotiques de retouche d'images. Vous avez maintenant toutes les cartes en main pour peaufiner vos UI !