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 duBitmap
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 largeurwidth
, la hauteurheight
et le formatPixelFormat
, on trouve un nouvel objet de typeSurfaceHolder
. UnSurfaceHolder
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 unCanvas
, de façon à ne pas manipuler directement leSurfaceView
.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
, unBitmap
et unPaint
. - 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 unBitmap
et dessine dessus avec unPaint
. Il est quand même assez rare qu'on fournisse unBitmap
à unCanvas
, puisqu'en général la vue qui affichera le dessin nous fournira unCanvas
qui possède déjà unBitmap
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.