Création d'une application graphique sous Mac OS X

Un exemple de développement d'un logiciel graphique. On utilise pour cela les primitives Unix, la bibliothèque graphique Cocoa et le langage Objective-C.

Motivation : un problème récurrent

Vous est-il déjà arrivé de vouloir installer un logiciel sur votre ordinateur, et de vous rendre compte qu'il n'y a pas assez de place sur le disque ? Pourtant, celui-ci fait bien 120 GB !

Alors il faut faire le ménage. Mais c'est toujours difficile de voir quels répertoires prennent réellement une place excessive. Sous Mac OS X, on peut utiliser le Finder qui affiche l'espace utilisé par le contenu d'un répertoire en utilisant le menu contextuel « Lire les informations ». Un utilisateur de Unix ferait quelque chose du genre du -k | awk '{if ( $1 > 5000 ) {print $0 ; } } '. Cependant, aucune des deux solutions ne donne vraiment une vue d'ensemble de l'espace occuppé sur le disque par l'arborescence.

Ici s'impose donc d'écrire soi-même une petite application pour accomplir cette tâche. Elle devra afficher la structure des répertoires à partir d'un répertoire racine indiqué par l'utilisateur, comme sur la figure 1. Elle servira donc à visualiser en priorité les gros consommateurs de place (comme test.eps dans l'exemple), et elle tournera sous Mac OS X, sans grand espoir de portabilité.

Pour faire vous-même les pas décrits dans la suite de l'article, il vous faut :
  • un mac
  • Mac OS X
  • le « developer package » de Apple
Nous supposons aussi que vous avez quelque connaissance de la programmation en C, du développement d'applications graphiques et des langages orientés-objet.

CocoaDU

Commençons par créer un nouveau projet sous Project Builder. C'est un projet de type « Cocoa Application » que nous appelons « CocoaDU ». L'application par défaut affiche juste une fenêtre vide et une barre de menus fournie, mais qui ne fait rien.

Project Builder est un Environnement de Développement Intégré classique. Par défaut, elle présente (figure 2) :
  • à droite une fenêtre qui permet de naviguer entre les fichiers, les classes, et les paramètres de compilation ;
  • à gauche en haut : les messages de compilation, la fenêtre du débugger et les sorties stdout et stderr ;
  • à gauche en bas : l'éditeur. 
Une application Cocoa est constiuée d'un ensemble de d'objets (graphiques ou non) qui communiquent entre eux par des messages. Théoriquement, si on a tout bien préparé sur le papier, la création d'une application peut se faire en deux étapes :
  1. Avec Interface Builder : on crée les classes, on les instantie, et on relie les instances entre elles pour qu'elles puissent s'envoyer des messages.
  2. Avec Project Builder : on ajoute les implantations des méthodes, on compile et on débugge
Dans notre cas, avant de créer l'application graphique, nous allons commencer par écrire le code qui lit le contenu des répertoires.

Exploration de l'arborescence des fichiers

Il faut une fonction qui explore une arborescence à partir d'un répertoire racine donné et qui la renvoie sous forme d'une structure d'arbre. Chaque nœud correspond à un fichier ou un répertoire et contient l'information dont on a besoin pour l'afficher (son nom, sa taille, son type, etc.)

Pour cela, nous créons un module en C, composé de deux fichiers : fileops.h et fileops.c. Un nœud est défini par la structure C suivante :

struct FileNode {
  int flags; /* error ? répertoire ? fichier spécial (pipe ou symlink) ? */
 
  unsigned long size;  
  /* en blocs de 512 octets, inclut la taille des sous-fichiers pour
   * les répertoires */
 
  unsigned long d_size; /* taille des données, en octets */
  unsigned long r_size; /* taille des ressources, en octets */  
  int depth;            /* profondeur du répertoire, 0 pour un fichier */

  /* chaînage */
  struct FileNode *next;
  struct FileNode *children;
 
  char name[1];        
  /* le nom du fichier/répertoire est collé derrière la structure,
   * comme ça on n'utilise qu'un malloc par nœud */
};

La fonction d'exploration a donc le prototype suivant :

struct FileNode *explore_tree(char *root_name);

L'implantation est en C, avec des appels système Unix standards. Apple propose aussi une classe qui manipule des fichiers (NSFileManager, dans le framework Foundation), mais elle est d'une lourdeur injustifiée dans notre cas. Pour chaque fichier exploré, on utilise la fonction lstat pour récupérer ses propriétés. Si c'est un répertoire, on l'explore récursivement avec readdir

La taille occupée par le fichier est 512 fois le champ st_blocks du struct stat renvoyé par lstat. Les fichiers Macintosh ont la particularité d'être composés de deux forks :
  • le data fork contient les données du fichier, comme sous Windows ou Unix ;
  • le ressource fork contient des méta-informations sur le fichier, comme l'application qui l'a crée, son icône. Parfois le ressource fork est indispensable pour ouvrir le fichier (par exemple les exécutables Classic). Les primitives Unix peuvent accéder aux ressources du fichier toto.txt sous le nom toto.txt/..namedfork/rsrc, ce qui permet de remplir le champ r_size de notre structure de nœud.
Il faut jongler avec la taille des données dans la structure, parce que le type long long de 64 bits n'est pas bien supporté par Objective-C. Cette structure de données est à l'aise avec des fichiers de moins de 4 GB et des répertroires de moins de 2 TB.

Ce module prêt et débuggé, nous pouvons commencer à construire l'interface.

Le constructeur d'interfaces

Le système de création d'interfaces graphiques Cocoa, hérité de NextStep, est d'une efficacité déconcertante.

En cliquant sur le fichier MainMenu.nib dans le paneau de droite de l'IDE, nous lançons Interface Builder. Celui-ci affiche quatre fenêtres (figure 3) :
  • une fenêtre principale qui récapitule le contenu du fichier MainMenu.nib : classes, fenêtres, et objets non-graphiques ;
  • la barre de menus de l'application, qui contient les options par défaut : File, Edit, Window, Help ;
  • la fenêtre principale de CocoaDU, vide pour le moment ;
  • une palette de Widgets (WInDow gadGETs, composants graphiques) prêts à l'emploi.
D'abord, il nous faut un bouton qui permet de choisir un répertoire. Pour cela, on fait un glisser-déposer d'un bouton de la palette vers la fenêtre principale. Redimensionnons le bouton et affectons lui un titre : « open » (figure 4).

On affche les propriétés du bouton (et de toutes les autres instances et classes) avec shift-option-I. Dans l'onglet size, on obtient un schéma du genre de la figure 5. Les ressorts déterminent dans quelle direction le widget bouge si la fenêtre englobante est redimensionnée. Dans cet exemple, le widget peut changer de hauteur, mais pas de largeur, et restera toujours à même distance du bord gauche de la fenêtre. Ce système, ainsi que les règles magnétiques, permettent de positionner précisément et sans douleur les widgets.

Ajoutons de la même manière un label (NSTextField) qui affiche le nom du répertoire qu'on explore.

Un nouveau type de widget

Faisons ensuite le widget qui contient et affiche la structure FileNode. Il faut pour cela créer un nouveau type de widget, donc une nouvelle classe (appellée DUView), et ensuite l'instancier en le mettant sur la fenêtre.

Pour créer la classe, on clique sur l'onglet «  Classes » de la fenêtre principale. Ceci affiche la hiérarchie de classes. Tous les objets de Cocoa descendent de NSObject, et les widgets sont des objets descendant de NSView . Un control-clic sur cette classe affiche un menu contextuel dans lequel on choisit « subclass NSView » pour créér notre classe DUView (figure 6).

Pour ajouter un widget de classe DUView, on glisse-dépose un widget « Cutstom View » à la fenêtre, et on précise dans ses attributs que c'est un DUView.

Maintenant, nous avons trois widgets : le bouton (NSButton), le label (NSTextField), et le widget central (DUView). Si l'utilisateur appuie sur le bouton, il faut que que celui-ci dise au DUView d'ouvrir un répertoire, et il faut que le DUView affiche le nom de ce répertoire dans le label. C'est ici qu'interviennent les messages.

Le modèle de messages (actions)

Les messages sont des appels de méthode (il n'y a pas de distinction entre les deux termes en Objective-C). Interface Buider gère un type particuler de messages : les actions. Ce sont des méthodes qui prennent en paramètre un pointeur sur l'objet émetteur.

Seuls les widgets descendant de NSControl (comme NSButton) peuvent être connectés à une action. Ces widgets contrôlent une valeur, comme une chaîne de caractères, un nombre, un booléen ou un indice de radio button. Quand cette valeur change, le widget envoie un message à un target, qui est une action d'un autre objet. On connecte les targets aux actions correspondantes depuis Interface Builder.

Nous rajoutons donc une action open à la classe DUView dans le paneau attributes de sa fenêtre de propriétés. On connecte ensuite le target du bouton à l'action open: de l'instance de DUView, en faisant un glisser-déposer pendant lequel on maintient appuyée la touche control (figure 7). Maintenant, quand l'utilisateur clique sur le bouton open, ça appelle automatiquement la méthode open: de l'objet DUView.

Références entre widgets (outlets)

La méthode open: que nous allons implanter va afficher le nom du répertoire dans le label. Pour cela, il faut que le DUView puisse faire référence au label (widget NSTextField).

Interface Builder permet de définir un type particuler d'attributs pour les objets : les outlets. Ce sont des pointeurs initialisés vers un autre objet géré par Interface Builer. Dans notre cas, ce pointeur permet en d'accéder au label depuis le code du DUView.

Nous créons donc le outlet directoryNameDisplayer à peu près de la même manière qu'une action, on peut (ou non) contraindre son type (figure 8). Nous établissons le lien avec un ctrl-glisser-déposer du DUView vers le NSTextField.

Finalement

La hiérarchie de fichiers que nous allons afficher va être assez grande, et va souvent dépasser la surface d'affichage disponible. La solution est de mettre des barres de défilement autour du widget. Cela se fait en encapsulant le DUView dans un NSScrolView (menu Layout > Make subviews of > Scroll View) . La liaison entre les deux widgets est automatique, nous ne nous en occupons plus.

Toutes les informations que nous avons définies dans Interface Builder (création de classe, instanciation et liens) sont stockées dans le fichier MainMenu.nib. Celui-ci est chargé automatiquement lors du lancement de l'application, ce qui permet d'initialiser les objets que nous utilisons. Tout ceci peut aussi se faire dans le programme, mais c'est beaucoup plus lourd...

Maintenant, il faut écrire le code pour DUView. Commençons par choisir le langage : Objective-C ou Java. Nous prenons le premier parce qu'il est plus facile à interfacer avec le module fileops (il n'y a rien de spécial à faire pour appeller du C),  plus rapide, et plus original :-). On génère les fichiers de code de notre classe DUView depuis son menu contextuel. Ils apparaissent dans Project Builder (en-tête : DUView.h, implantation :  DUView.m).

Objective-C

Objective-C est une extension orientée-objet de C. C'est un langage plus simple que C++, et il fait à l'exécution beaucoup de choses que C++ fait à la compilation : toutes les méthodes sont virtuelles, et la réflexion est constamment utilisée. En fait, le typage à la compilation est optionnel, on peut donner à tous les objets le type id (pointeur sur objet).

Les classes de Cocoa, notamment les widgets sont regroupés dans un framework appellé Application Kit. Un framework regroupe les en-têtes, les librairies dynamiques et la documentation d'une bibliothèque dans une arborescence standardisée qui inclut aussi le numéro de version. C'est la solution de Apple aux éternels problèmes de chemins d'accès pendant la compilation et l'exécution.

Nous allons donner les éléments les plus utiles de la syntaxe du langage. Voici l'en-tête d'une classe :

// MonFramework n'est pas un répertoire, mais un framework
// import est comme include, mais il protège automatiquement de la
// double inclusion
#import <MonFramework/Pere.h>

// Fils hérite de Pere
@interface Fils: Pere
{
  // un attribut entier
  int champ;
}

// constructeur, pas de paramètre, renvoie un id
- (id) init;

// une méthode de classe (+), renvoie un pointeur sur Fils
+ (Fils *)unFilsSpecial;

// une méthode qui prend deux paramètres.
// Les ':' font partie du nom de la méthoude,
// qui est donc "
ajouterChampA:et:"
- (int)ajouterChampA:(int)val1 et:(int)val2;

@end

Et voici son implantation :

@implementation Fils

// par convention, un constructeur s'écrit :
- (id) init {
  [super init]; // constructeur du père
  champ = 10;
  return self;  // il se retourne lui-même
}


+ (Fils *) unFilsSpecial {
  id a;       // id : objet quelconque
  // allocation d'un Fils, tous les champs sont
  // forcés à 0

  a = [ Fils alloc ];
  [a init];   // appel du constructeur
  // on écrit souvent : a=[[Fils alloc] init];
  [a ajouterChampA:5 et:10];
  return a;
}


-
(int)ajouterChampA:(int)val1 et:(int)val2 {
  champ = val1 + val2;
  return champ;
}


@end

La classe racine de Application Kit, NSObject, inclut un compteur de références qui fait que dans la plupart des cas, il n'est pas nécessaire de déallouer explicitement les objets. Quand on a un peu trop abusé de cette possibilité, l'application Malloc Debug permet de traquer les fuites de mémoire...

Implantation

Rajoutons un champ contenant le répertoire exploré dans la classe DUView, et la méthode permettant de dessiner le widget (le texte en gras a été généré par Interface Builder, le reste a été rajouté) :

#import <Cocoa/Cocoa.h>

@interface DUView : NSView
{
  IBOutlet NSTextField *directoryNameDisplayer; // un outlet
  char *root_name;
  struct FileNode *root_node;
}
- (IBAction)open:(id)sender; // une action
- (void)drawRect:(NSRect)rect;
// méthode appellée par le système graphique pour afficher le widget
@end

L'implantation de la méthode open: consiste à :
  1. afficher une boîte de dialogue (NSOpenPanel) pour que l'utilisateur choisisse un répertoire ;
  2. appeller la fonction explore_tree du module fileops qui transforme ce répertoire en FileNode ;
  3. afficher le nom du répertoire avec [directoryNameDisplayer setStringValue:root_name] ;
  4. forcer un rafraîchissement du widget pour afficher l'arborescence (avec [self setNeedsDisplay: YES]).
La seule difficulté qui apparaît à ce stade est le codage des chaînes de caractères. Toutes les méthodes de Application Kit manipulent des NSStrings (classe qui encapsule une chaîne en unicode), alors que le module en C n'utilise que des char *.  Comme les noms de fichiers sont codés en UTF8, on peut passer du char * au  NSString par le constructeur stringWithUTF8String:, et l'inverse en employant la méthode UTF8String.

Graphismes

Maintenant, nous devons afficher l'arborescence en implantant la méthode drawRect:. Le rect passé en paramètre correspond à la partie visible du widget, déterminée par le NSScrollView.

Le moteur d'affichage

Le moteur d'affichage de Mac OS X, Aqua, est très sophistiqué. Les concepts de base viennent des langages de description de page PostScript et PDF.

Le système de coordonnées de PostScript, en flottants, peut être translaté, mis à l'échelle, ou tourné par rapport au canevas (au wiget englobant dans le cas de Cocoa). Les contextes graphiques sont dans une pile, qu'on peut manipuler avec la classe NSGraphicsContext.

La primitive de dessin est le path. C'est une liste de points qui sont soit des extrémités, soit des points de contrôle de lignes. Le type de données correspondant en Cocoa est NSBezierPath. Pour construire un path, on peut :
  • ajouter un point (commande PostScript moveto, méthode moveToPoint: de NSBezierPath)
  • ajouter une ligne (PS: lineto, NSBezierPathLineToPoint:)
  • ajouter un arc (arc, appendBezierPathWithArcFromPoint:toPoint:radius: )
  • ajouter un spline de Bézier (curveto,  curveToPoint:controlPoint1:controlPoint2: )
  • ajouter un glyph = forme d'un lettre dans une fonte donnée (charpath, appendBezierPathWithGlyph:inFont:
Après avoir construit un path, on peut :
  • le dessiner (stroke, stroke) ;
  • le remplir (fill, fill) ;
  • le définir comme clipping zone pour les opérations de dessin suivantes (clip, setClip) ;
  • le manipuler : accéder aux points, tester si un point est à l'intérieur, etc.

Quelques fonctions pratiques

Cette architecture, alliée à une gestion fine de la transparence, permet d'afficher avec une grande précision tout ce qu'on veut. Ça peut cependant devenir un peu lent. Pour accélérer l'affichage de formes simples, Cocoa propose des fonctions de plus bas niveau : NSRectStroke, NSRectFill (pour les rectangles). L'affichage d'images bitmap ou vectorielles (obtenues à partir d'un fichier) se fait avec la méthode  drawAtPoint:fromRect:operation:fraction: de NSImage.

Pour afficher du texte, nous utilisons une fonction de plus haut niveau : NSString a une méthode (drawInRect:withAttributes:) pour afficher le texte dans un rectangle, qui coupe le texte aux espaces pour passer à la ligne au mieux.

Le menu « print » de l'application fonctionne automatiquement : il positionne un contexte graphique spécifique ; ensuite, il appelle le drawRect: du widget et les instructions graphiques sont envoyées vers un fichier PDF ou une imprimante au lieu d'être exécutées par le moteur d'affichage.

Performances du système graphique

Il faut bien avouer que le widget qu'on obtient en procédant ainsi n'est pas assez rapide. Les première optimisations que nous avons implantées sont :
  • ne pas dessiner les rectangles qui ne sont pas visibles,
  • ne pas descendre dans les répertoires de moins d'un pixel de haut, mais dessiner une ligne de longueur proportionnelle à leur profondeur,
  • ne pas dessiner de texte dans les rectangles de moins de 5 pixels de haut,
  • utiliser une fonte suffisament petite pour ne pas être antialiasée (taille <= 10 pixels).
La vitesse d'affichage n'est toujours pas fameuse (parfois le temps d'affichage atteint 1.5 s), mais c'est utilisable. Les facteurs de ralentissment proviennent principalement du fait que :
  • le tracé de lignes est anti-aliasé 
  • tout est double-bufferisé
  • les widgets peuvent se superposer et être transparents, donc Cocoa dessine tous les widgets, même s'il sait que certains sont cachés.
On peut bricoler ces trois paramètres, mais ce serait au détriment de la qualité d'affichage. La figure 9 présente l'aspect de la sortie qu'on obtient.

Extensions

À ce stade, CocoaDU est une bonne petite application, qui permet de voir rapidement qui occupe indûment le disque. Mais ce serait plus simple si on pouvait faire un drag'n'drop d'un répertoire de CocoaDU vers le Finder ou un terminal, pour le manipuler de là. Le chargement est trop lent pour les gros répertoires, il faudrait une barre de progression qui indique l'avencement de la progression. Et puis il faudrait que CocoaDU soit un paquet facilement installable, et localisé en français.

Dans la deuxième partie de cette série d'articles (et la deuxième verision de CocoaDU), nous allons nous occupper de ces problèmes : les messages entre applications, le multitâche, le packaging et la localisation.

Contact :

Matthijs Douze prépare une thèse dans une école d'ingénieurs à Toulouse (France). Il a fait des graphismes avec QBasic, BGI (Borland) et GRX (DJGPP). Ensuite, il a découvert les environnements fenêtrés avec Delphi, a beaucoup utilisé X, AWT (Java), et TCL/Tk (et Tkinter), et Qt, pour passer à Cocoa et QuickDraw quand il a eu son mac. mailto:douze@enseeiht.fr

Références :

Source de CocoaDU : http://www.enseeiht.fr/~douze/CocoaDU/

Developer Package de Apple (il faut s'inscire) : http://www.apple.com/developer/

Programmation sous Cocoa (doc de référence presque complète) : /Developer/Documentation/Cocoa/CocoaTopics.html (dans le developer package de Apple)

Tutorial Cocoa (très détaillé) : /Developer/Documentation/Cocoa/ObjCTutorial/CurrencyConverterTutorial.pdf (idem)

Appels système Unix : taper man lstat ou man readdir dans une console.

Les langages Postscript et PDF :
http://partners.adobe.com/asn/developer/pdfs/tn/psrefman.pdf
http://partners.adobe.com/asn/tech/pdf/specifications.jsp