Apprenez à dessiner

Ce contenu est obsolète. Il peut contenir des informations intéressantes mais soyez prudent avec celles-ci.

Je vous propose d'approfondir nos connaissances du dessin sous Android. Même si dessiner quand on programme peut sembler trivial à beaucoup d'entre vous, il faut que vous compreniez que c'est un élément qu'on retrouve dans énormément de domaines de l'informatique. Par exemple, quand on veut faire sa propre vue, on a besoin de la dessiner. De même, dessiner est une étape essentielle pour faire un jeu.

Enfin, ne vous emballez pas parce que je parle de jeu. En effet, un jeu est bien plus que des graphismes, il faut créer différents moteurs pour gérer le gameplay, il faut travailler sur l'aspect sonore, etc. De plus, la méthode présentée ici est assez peu adaptée au jeu. Mais elle va quand même nous permettre de faire des choses plutôt sympa.

La toile

Non, non, je ne parle pas d'internet ou d'un écran de cinéma, mais bien d'une vraie toile. Pas en lin ni en coton, mais une toile de pixels. C'est sur cette toile que s'effectuent nos dessins. Et vous l'avez déjà rencontrée, cette toile ! Mais oui, quand nous dessinions nos propres vues, nous avons vu un objet de type Canvas sur lequel dessiner !

Pour être tout à fait franc, ce n'était pas exactement la réalité. En effet, on ne dessine pas sur un Canvas, ce n'est pas un objet graphique, mais une interface qui va dessiner sur un objet graphique. Le dessin est en fait effectué sur un Bitmap. Ainsi, il ne suffit pas de créer un Canvas pour pouvoir dessiner, il faut lui attribuer un Bitmap.

La plupart du temps, vous n'aurez pas besoin de donner de Bitmap à un Canvas puisque les Canvas qu'on vous donnera auront déjà un Bitmap associé. Les seuls moments où vous devrez le faire manuellement sont les moments où vous créerez vous-mêmes un Canvas.

Ainsi, un Canvas est un objet qui réalise un dessin et un Bitmap est une surface sur laquelle dessiner. Pour raisonner par analogie, on peut se dire qu'un Canvas est un peintre et un Bitmap une toile. Cependant, que serait un peintre sans son fidèle pinceau ? Un pinceau est représenté par un objet Paint et permet de définir la couleur du trait, sa taille, etc. Alors quel est votre rôle à vous ? Eh bien, imaginez-vous en tant que client qui demande au peintre (Canvas) de dessiner ce que vous voulez, avec la couleur que vous voulez et sur la surface que vous voulez. C'est donc au Canvas que vous donnerez des ordres pour dessiner.

La toile

Il n'y a pas grand-chose à savoir sur les Bitmap. Tout d'abord, il n'y a pas de constructeur dans la classe Bitmap. Le moyen le plus simple de créer un Bitmap est de passer par la méthode statique Bitmap createBitmap(int width, int height, Bitmap.Config config) avec width la largeur de la surface, height sa hauteur et config un objet permettant de déterminer comment les pixels seront stockés dans le Bitmap.

En fait, le paramètre config permet de décrire quel espace de couleur sera utilisé. En effet, les couleurs peuvent être représentées d'au moins trois manières :

  • Pour que chaque pixel ne puisse être qu'une couleur, utilisez Bitmap.Config.RGB_565.
  • Pour que chaque pixel puisse être soit une couleur, soit transparent (c'est-à-dire qu'il n'affiche pas de couleur), utilisez Bitmap.Config.ARGB_8888.
  • Enfin, si vous voulez que seul le canal qui représente des pixels transparents soit disponible, donc pour n'avoir que des pixels transparents, utilisez Bitmap.Config.ALPHA_8.

Par exemple :

1
Bitmap b = Bitmap.createBitmap(128, 128, Config.ARGB_8888);

Il existe aussi une classe dédiée à la construction de Bitmap : BitmapFactory. Ainsi, pour créer un Bitmap depuis un fichier d'image, on fait BitmapFactory.decodeFile("Chemin vers le fichier"). Pour le faire depuis un fichier de ressource, on utilise la méthode statique decodeResource(Resources ressources, int id) avec le fichier qui permet l'accès aux ressources et l'identifiant de la ressource dans id. Par exemple :

1
Bitmap b = BitmapFactory.decodeResource(getResources(), R.drawable.ic_action_search);

N'oubliez pas qu'on peut récupérer un fichier de type Resources sur n'importe quel Context avec la méthode getResources().

Enfin, et surtout, vous pouvez récupérer un Bitmap avec BitmapFactory.decodeStream(InputStream).

À l'opposé, au moment où l'on n'a plus besoin de Bitmap, on utilise dessus la méthode void recycle(). En effet, ça semble une habitude mais Bitmap n'est aussi qu'une interface et recycle() permet de libérer toutes les références à certains objets de manière à ce qu'ils puissent être ramassés par le garbage collector.

Après cette opération, le Bitmap n'est plus valide, vous ne pourrez plus l'utiliser ou faire d'opération dessus.

Le pinceau

Pour être tout à fait exact, Paint représente à la fois le pinceau et la palette. On peut créer un objet simplement sans passer de paramètre, mais il est possible d'être plus précis en indiquant des fanions. Par exemple, pour avoir des dessins plus nets (mais peut-être plus gourmands en ressources), on ajoute les fanions Paint.ANTI_ALIAS_FLAG et Paint.DITHER_FLAG :

1
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

La première chose est de déterminer ce qu'on veut dessiner : les contours d'une figure sans son intérieur, ou uniquement l'intérieur, ou bien même les contours et l'intérieur ? Afin d'assigner une valeur, on utilise void setStyle(Paint.Style style) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Paint p = new Paint();

// Dessiner l'intérieur d'une figure
p.setStyle(Paint.Style.FILL);

// Dessiner ses contours
p.setStyle(Paint.Style.STROKE);

// Dessiner les deux
p.setStyle(Paint.Style.FILL_AND_STROKE);

On peut ensuite assigner une couleur avec void setColor(int color). Comme vous pouvez le voir, cette méthode prend un entier, mais quelles valeurs peut-on lui donner ? Eh bien, pour vous aider dans cette tâche, Android fournit la classe Color qui va calculer pour vous la couleur en fonction de certains paramètres que vous passerez. Je pense particulièrement à static int argb(int alpha, int red, int green, int blue) qui dépend de la valeur de chaque composante (respectivement la transparence, le rouge, le vert et le bleu). On peut aussi penser à static int parseColor(String colorString) qui prend une chaîne de caractères comme on pourrait les trouver sur internet :

1
p.setColor(Color.parseColor("#12345678"));

Le peintre

Enfin, on va pouvoir peindre ! Ici, rien de formidable, il existe surtout des méthodes qui expriment la forme à représenter. Tout d'abord, n'oubliez pas de donner un Bitmap au Canvas, sinon il n'aura pas de surface sur laquelle dessiner :

1
2
Bitmap b = Bitmap.createBitmap(128, 128, Config.ARGB_8888);
Canvas c = new Canvas(b);

C'est tout ! Ensuite, pour dessiner une figure, il suffit d'appeler la méthode appropriée. Par exemple :

  • void drawColor(int color) pour remplir la surface du Bitmap d'une couleur.
  • void drawRect(Rect r, Paint paint) pour dessiner un rectangle.
  • void drawText(String text, float x, float y, Paint paint) afin de dessiner… du texte. Eh oui, le texte se dessine aussi.

Vous trouverez plus de méthodes sur la page qui y est consacrée sur le site d'Android Developers.

Afficher notre toile

Il existe deux manières pour afficher nos œuvres d'art : sur n'importe quelle vue, ou sur une surface dédiée à cette tâche.

Sur une vue standard

Cette solution est la plus intéressante si votre surface de dessin n'a pas besoin d'être rapide ou fluide. C'est le cas quand on veut faire une banale vue personnalisée, mais pas quand on veut faire un jeu.

Il n'y a pas grand-chose à dire ici que vous ne sachiez déjà. Les dessins seront à effectuer dans la méthode de callback void onDraw (Canvas canvas) qui vous fournit le Canvas sur lequel dessiner. Ce Canvas contient déjà un Bitmap qui représente le dessin de la vue.

Cette méthode onDraw(Canvas) sera appelée à chaque fois que la vue juge que c'est nécessaire. Si vous voulez indiquer manuellement à la vue qu'elle doit se redessiner le plus vite possible, on peut le faire en utilisant la méthode void invalidate().

Un appel à la méthode invalidate() n'est pas nécessairement instantané, il se peut qu'elle prenne un peu de temps puisque cet appel doit se faire dans le thread UI et passera par conséquent après toutes les actions en cours. Il est d'ailleurs possible d'invalider une vue depuis un autre thread avec la méthode void postInvalidate().

Sur une surface dédiée à ce travail

Cette solution est déjà plus intéressante dès qu'il s'agit de faire un jeu, parce qu'elle permet de dessiner dans des threads différents du thread UI. Ainsi, au lieu d'avoir à attendre qu'Android déclare à notre vue qu'elle peut se redessiner, on aura notre propre thread dédié à cette tâche, donc sans encombrer le thread UI. Mais ce n'est pas tout ! En plus d'être plus rapide, cette surface peut être prise en charge par OpenGL si vous voulez effectuer des opérations graphiques encore plus compliquées.

Techniquement, la classe sur laquelle nous allons dessiner s'appelle SurfaceView. Cependant, nous n'allons pas la manipuler directement, nous allons passer par une couche d'abstraction représentée par la classe SurfaceHolder. Afin de récupérer un SurfaceHolder depuis un SurfaceView, il suffit d'appeler SurfaceHolder getHolder(). De plus, pour gérer correctement le cycle de vie de notre SurfaceView, on aura besoin d'implémenter l'interface SurfaceHolder.Callback, qui permet au SurfaceView de recevoir des informations sur les différentes phases et modifications qu'elle expérimente. Pour associer un SurfaceView à un SurfaceHolder.Callback, on utilise la méthode void addCallback(SurfaceHolder.Callback callback) sur le SurfaceHolder associé au SurfaceView. Cette opération doit être effectuée dès la création du SurfaceView afin de pouvoir prendre en compte son commencement.

N'ayez toujours qu'un thread au maximum qui manipule une SurfaceView, sinon gare aux soucis !

 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
import android.content.Context;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class ExampleSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
  private SurfaceHolder mHolder = null;

  /**
  * Utilisé pour construire la vue en Java
  * @param context le contexte qui héberge la vue
  */
  public ExampleSurfaceView(Context context) {
    super(context);
    init();
  }

  /**
  * Utilisé pour construire la vue depuis XML sans style
  * @param context le contexte qui héberge la vue
  * @param attrs les attributs définis en XML
  */
  public ExampleSurfaceView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
  }

  /**
  * Utilisé pour construire la vue depuis XML avec un style
  * @param context le contexte qui héberge la vue
  * @param attrs les attributs définis en XML
  * @param defStyle référence au style associé
  */
  public ExampleSurfaceView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    init();
  }

  public void init() {
    mHolder = getHolder();
    mHolder.addCallback(this);
  }

}

Ainsi, il nous faudra implémenter trois méthodes de callback qui réagiront à trois évènements différents :

  • void surfaceChanged(SurfaceHolder holder, int format, int width, int height) sera enclenché à chaque fois que la surface est modifiée, c'est donc ici qu'on mettra à jour notre image. Mis à part certains paramètres que vous connaissez déjà tels que la largeur width, la hauteur height et le format PixelFormat, on trouve un nouvel objet de type SurfaceHolder. Un SurfaceHolder est une interface qui représente la surface sur laquelle dessiner. Mais ce n'est pas avec lui qu'on dessine, c'est bien avec un Canvas, de façon à ne pas manipuler directement le SurfaceView.
  • void surfaceCreated(SurfaceHolder holder) sera quant à elle déclenchée uniquement à la création de la surface. On peut donc commencer à dessiner ici.
  • Enfin, void surfaceDestroyed(SurfaceHolder holder) est déclenchée dès que la surface est détruite, de façon à ce que vous sachiez quand arrêter votre thread. Après que cette méthode a été appelée, la surface n'est plus disponible du tout.

Passons maintenant au dessin en tant que tel. Comme d'habitude, il faudra dessiner à l'aide d'un Canvas, sachant qu'il a déjà un Bitmap attribué. Comme notre dessin est dynamique, il faut d'abord bloquer le Canvas, c'est-à-dire immobiliser l'image actuelle pour pouvoir dessiner dessus. Pour bloquer le Canvas, il suffit d'utiliser la méthode Canvas lockCanvas(). Puis, une fois votre dessin terminé, vous pouvez le remettre en route avec void unlockCanvasAndPost(Canvas canvas). C'est indispensable, sinon votre téléphone restera bloqué.

La surface sur laquelle se fait le dessin sera supprimée à chaque fois que l'activité se met en pause et sera recréée dès que l'activité reprendra, il faut donc interrompre le dessin à ces moments-là.

Pour économiser un peu notre processeur, on va instaurer une pause dans la boucle principale. En effet, si on ne fait pas de boucle, le thread va dessiner sans cesse le plus vite, alors que l'oeil humain ne sera pas capable de voir la majorité des images qui seront dessinées. C'est pourquoi nous allons rajouter un morceau de code qui impose au thread de ne plus calculer pendant 20 millisecondes. De cette manière, on affichera 50 images par seconde en moyenne, l'illusion sera parfaite pour l'utilisateur et la batterie de vos utilisateurs vous remercie déjà :

1
2
3
try {
    Thread.sleep(20);
} catch (InterruptedException e) {}

Voici un exemple d'implémentation de SurfaceView :

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package sdz.chapitreQuatre.surfaceexample;

import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class ExampleSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
    // Le holder
    SurfaceHolder mSurfaceHolder;
    // Le thread dans lequel le dessin se fera
    DrawingThread mThread;

    public ExampleSurfaceView (Context context) {
        super(context);
        mSurfaceHolder = getHolder();
        mSurfaceHolder.addCallback(this);

        mThread = new DrawingThread();
    }

    @Override
    protected void onDraw(Canvas pCanvas) {
        // Dessinez ici !
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        // Que faire quand le surface change ? (L'utilisateur tourne son téléphone par exemple)
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        mThread.keepDrawing = true;
        mThread.start();
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mThread.keepDrawing = false;

        boolean joined = false;
        while (!joined) {
            try {
                mThread.join();
                joined = true;
            } catch (InterruptedException e) {}
        }
    }

    private class DrawingThread extends Thread {
    // Utilisé pour arrêter le dessin quand il le faut
        boolean keepDrawing = true;

        @Override
        public void run() {

            while (keepDrawing) {
                Canvas canvas = null;

                try {
            // On récupère le canvas pour dessiner dessus
                    canvas = mSurfaceHolder.lockCanvas();
            // On s'assure qu'aucun autre thread n'accède au holder
                    synchronized (mSurfaceHolder) {
            // Et on dessine
                        onDraw(canvas);
                    }
                } finally {
            // Notre dessin fini, on relâche le Canvas pour que le dessin s'affiche
                    if (canvas != null)
                        mSurfaceHolder.unlockCanvasAndPost(canvas);
                }

                // Pour dessiner à 50 fps
                try {
                    Thread.sleep(20);
                } catch (InterruptedException e) {}
            }
        }
    }
}

  • On a besoin de plusieurs éléments pour dessiner : un Canvas, un Bitmap et un Paint.
  • Le Bitmap est l'objet qui contiendra le dessin, on peut le comparer à la toile d'un tableau.
  • Un Paint est tout simplement un objet qui représente un pinceau, on peut lui attribuer une couleur ou une épaisseur par exemple.
  • Pour faire le lien entre une toile et un pinceau, on a besoin d'un peintre ! Ce peintre est un Canvas. Il contient un Bitmap et dessine dessus avec un Paint. Il est quand même assez rare qu'on fournisse un Bitmap à un Canvas, puisqu'en général la vue qui affichera le dessin nous fournira un Canvas qui possède déjà un Bitmap tout configuré.
  • En tant que programmeur, vous êtes un client qui ordonne au peintre d'effectuer un dessin, ce qui fait que vous n'avez pas à vous préoccuper de manipuler le pinceau ou la toile, le peintre le fera pour vous.
  • Pour afficher un dessin, on peut soit le faire avec une vue comme on l'a vu dans la seconde partie, soit créer carrément une surface dédiée au dessin. Cette solution est à privilégier quand on veut créer un jeu par exemple. Le plus gros point faible est qu'on doit utiliser des threads.