La finition de CocoaDU

Dans cet article, nous allons voir comment on fait de CocoaDU une vraie application intégrée dans le système Mac OS X.
 

Rappel des épisodes précédents

Nous nous somme attelés à la création d'une application graphique : CocoaDU. Elle permet de visualiser récursivement le contenu d'un répertoire. Elle est programmé dans un mélange de C et d'Objective-C (pour exploiter la librairie graphique Cocoa). Dans cet article, nous allons nous occuper des fignolages qui font passer notre création du statut de simple programme à celui de logiciel...

Multitâche

Quand on ouvre un gros répertoire dans CocoaDU, ça peut durer plusieurs secondes (voire minutes) avant qu'il ait terminé l'exploration. Il convient dans ce cas d'afficher une barre de progression pour que l'utilisateur ne s'impatiente pas.

On met la barre de progression, ainsi que le nom du répertoire courant dans une fenêtre à part (un panel), qui s'affiche pendant l'exploration et est cachée le reste du temps. On peut dessiner ça rapidement avec Interface Builder :
 

panel


Maintenant, il faut faire l'animation. Il y a plusieurs techniques :

  1. Dans la fonction d'exploration (explore_tree), on appelle à intervalles plus ou moins réguliers une fonction callback qui met à jour la barre de progression (en  bidouillant pour faire des flush graphiques). Le problème est qu'on bloque ainsi le traitement des événements : par exemple, on ne peut plus redimensionner les fenêtres.
  2. On crée une tâche (processus léger) différente pour l'exploration. La fonction explore_tree retrourne immédiatement après avoir crée cette tâche, qui fait le travail le plus long. Pour mettre à jour la barre de progression, elle envoie de temps à autre un message à la boucle d'événements principale (sous X11 on utiliserait un pipe). Le problème est que la création et le postage d'un événement Cocoa est très difficile (surtout depuis C) ; de plus, la chaîne de traitement des événements est assez sinueuse...
  3. Ici aussi, nous utilisons deux tâches. La fonction d'exploration met en permanence à jour une variabale partagée qui indique où en est l'exploration. La tâche d'affichage met périodiquement (toutes les 0.2 secondes) à jour le panel, grâce aux informations de la variable partagée.
Implantons la solution 3. Cocoa propose des objets s'occupant du mutitâche (à base de NSThread), mais elle n'est accessible que de Objective-C, et l'application passe dans un « mode mutithread » qui est potentiellement plus lent. Nous nous rabattons donc sur la librairie standard POSIX Threads.

Le corps de la fonction explore_tree devient donc :

FileNode *explore_tree(
    char *root,
    ProgressSpy *spy) {

  /* spy contient les données partagées sur l'exploration :
   * le pourcentage de complétion et le répertoire courant
   * ainsi qu'un mutex qui permet de les protéger
   * et une veriable condition pour mentionner qu'ils ont été mis
   * à jour */

  FileNode *root_node;

  /* ... remplissage de l'élément racine de l'arborescence */

  if(spy) {
    /* version mutitâche */
    pthread_t thr;
    explore_dir_params *params = malloc(sizeof(explore_dir_params));
    /* il faut passer les paramètres par l'intermédiaire d'une
     * structure, et appeller explore_dir avec un wrapper :
     * call_explore_dir */
    /* ... remplissage de *params */
    pthread_create(&thr, NULL, &call_explore_dir, params);
    pthread_detach(thr);
    /* sans faire le detach, on risque d'avoir des tâches
     * zombies (en attente d'un pthread_join) */
  } else {
    /* version monotâche */
    explore_dir(name_buffer, root_node, device, NULL);
  }
  return root_node;
}

La méthode d'ouverture du widget DUView, appelle explore_tree. Pour mettre à jour la barre de progression, nous créons un objet NSTimer, qui s'introduit dans la boucle d'événements, et qui appelle périodiquement un callback :

[NSTimer   // constructeur = méthode statique
      scheduledTimerWithTimeInterval: 0.2
           // appellé toutes les 0.2 secondes
      target: self
           // callback = un objet + ..
      selector: @selector(handleTimer:)
           // .. un sélecteur (identificateur de méthode)
      userInfo: nil    // paramètre de la méthode (rien)
      repeats:TRUE];

La méthode handleTimer:, met à jour le panel. Elle regarde aussi si l'exploration est terminée, auquel cas elle affiche le résultat et détruit le timer.

On peut voir si ça marche grâce à l'application Thread Viewer. Celle-ci affiche les différents threads d'un processus, et leur évolution au cours du temps. Voici ce qu'elle affiche pour le CocoaDU monotâche (sans barre de progression) :

Thread viewer -- mono threading
Chaque barre horizontale représente une tâche. La couleur code son état, échantillonné à une fréquence déterminée par l'utilisateur :
 
blanc tâche pas encore créee
vert en cours d'exécution
vert foncé en cours d'exécution, non interruptible
vert clair attente dans la boucle d'exécution
gris attente ou tâche terminée
jaune tâche en cours d'exécution récemment

En cliquant sur une barre à un moment donné, on affiche un snapshot de la pile d'appels à ce moment. Dans l'exemple, on voit que la méthode [DUView open:] appelle explore_tree, puis les appels récursifs de explore_dir qui se terminent en lstat. Il y a une deuxième tâche (la barre de dessus), qui est crée par Cocoa quand on utilise certains widgets qui nécessitent une animation indépendante, par exemple un NSOpenPanel, ou un NSProgressIndicator. L'application est donc multitâche, mais c'est transparent pour le programmeur.

Voici la vraie version mutitâche :
 

multi thread in thread viewer
La tâche de recherche (thr 0f82b) est la plus active (le plus souvent en vert). Les deux autres le sont à chaque fois qu'il faut rafraîchir la barre de progression. La solution est satisfaisante parce que : Nous résolvons ainsi de manière élégante le problème de la barre de progression, en utilisant les bonnes vieilles librairies Unix !

Documents multiples

Sur Mac, on ne peut pas ouvrir plusieurs fois la même application graphique. Par contre, toutes les applications peuvent ouvrir plusieurs documents. Il convient que CocoaDU suive cette règle. Dans notre cas, les documents sont les répertoires.

Il faut donc revoir l'architecture de l'application sous Interface Builder. Elle comprend le menu, la fenêtre principale et le panel qui contient la barre de progression. Il y a des liens qui représentent les messages entre éléments. En revanche, il n'y a aucun moyen d'implanter le concept de mutiples instances d'un type de fenêtre. La solution à ce problème est de créer deux fichiers NIB avec Interface Builder. Le premier (MainMenu.nib) contient les éléments globaux de l'application (le menu principal). Le second (DUWindow.nib) décrit les fenêtres propres à un répertoire exploré (la fenêtre d'affichage et le panel de chargement).

Dans cette architecture, le problème est d'envoyer des messages entre les éléments des deux fichiers NIB. À cet usage, Interface Builder met deux éléments particuliers dans chaque NIB :

multiple NIB files in Interface Builder
Jusqu'à maintenant, tout le code de l'interface était dans la classe DUView, le widget d'affichage principal. Il faut maintenant ajouter quelques nouvelles classes.

Nous créons une classe Manager, dans MainMenu.nib (donc chargée au lancement), qui répond aux messages New et Open du menu principal, sur ce modèle :

@implementation Manager

-(void) new:(id)sender {
  DUWindowController *duwc = [DUWindowController alloc];
  // DUWindowController descend de WindowController.
  // Il sert de "proxy" entre les deux NIB

  [duwc setManager: self];
  // pour répondre aux messages internes de DUWindow.nib,
  // le DUWindowController besoin d'une référence au Manager

  [duwc initWithWindowNibName: @"DUWindow"];
  // charge et instancie les éléments de DUWindow.nib,
  // et positionne le File's Owner à duwc
}

Pour faire passer un message d'un élément de DUWindow au Manager (par exemple weAreClosing:, qui indique que la fenêtre du document est sur le point de se fermer), nous connectons ce message au File's Owner. Nous l'implantons ensuite dans DUWindowController, qui peut le faire passer au Manager parce qu'il a un référence dessus.

Un autre problème typique est de faire passer des messages du menu vers la fenêtre de document sélectionnée (par exemple Open In Window). Là, il n'y a pas besoin d'écrire de code : dans MainMenu.nib, nous connectons le message au First Responder. C'est une référence au widget séléctionné, et le début de la responder chain. Celle-ci comprend :

  1. Le First Responder,
  2. Les widgets qui englobent le First Responder,
  3. La fenêtre dans laquelle est le First Responder,
  4. Le delegate (un outlet) de cette fenêtre. En général, pour répondre aux messages destinés à une fenêtre, on préfère utiliser un delegate plutôt que de faire une sous-classe de NSWindow.
Quand on envoie un message au First Responder, le système parcourt la responder chain dans l'ordre jusqu'à ce qu'il trouve une implantation du message. S'il n'y en a pas, le message est ignoré (en réalité, l'option sera grisée dans le menu). Dans notre cas, nous voulons que le message arrive au DUView, même si un autre bouton de la fenêtre est sélectionné. Pour cela, il suffit que le DUView soit un delegate de sa fenêtre englobante.

En somme, il faut donc :

Le système de description d'interface de Cocoa (hérité de NeXT) permet ainsi de définir tous les passages de messages graphiquement, même quand l'application a une structure relativement élaborée. C'est intéressant parce que les liens graphiques sont plus facilement compréhesibles et maintenables que du code.

Cocoa propose aussi un ensemble de classes intéressant pour faire des application basées-document (Document Based Applications). Il permet, en plus d'automatiser ce que nous avons fait à la main ci-dessus, de déclarer au système quels sont les types de documents que l'application édite ou visualise, quels sont les icônes associées, etc. Cependant, cette structure est un peu figée. Elle n'est en particulier pas adaptée à notre cas, puisque nous ne sauvegardons pas de fichiers.

Drag'n'drop

Le glisser-déposer est beaucoup utilisé sous Mac OS X (par opposition à X11) parce que le copier-coller est assez pénible ; pour récupérer un texte dans une autre application, il faut :
  1. basculer vers l'autre application
  2. sélectionner le texte
  3. appuyer sur pomme-C
  4. re-basculer vers l'application courante
  5. appuyer sur pomme-V
Les formats standard qu'on peut échanger par copier-coller et glisser-déposer sont : On peut ajouter ses propres types de données, et la source peut proposer plusieurs formats en même temps.

On envoie ce qu'on glisse (représenté par une image semi-transparente) vers un widget de destination, qui peut appartenir à l'application d'origine ou pas. Quand on passe dessus, il change d'aspect pour indiquer s'il accepte ce qu'on envoie (s'il refuse, il ne change pas). Apple précise que le glisser-déposer ne doit pas être le seul moyen d'exécuter une action donnée : ça doit rester un « plus ».

CocoaDU n'est pas un file manager : il ne permet pas d'effacer ou d'ouvrir les fichiers. Pour cette raison, on aimerait avoir une interface vers le Finder et la console, d'où on peut effacer des fichiers ou les déplacer.

Pour faire ça en Cocoa, il faut implanter quelques méthodes pour que le widget soit compatible avec les protocoles (équivalent en moins contraignant d'une interface Java) NSDraggingSource et NSDraggingDestination. Il faut aussi choisir la sémantique du glisser-déposer : est-ce une copie ? un déplacement ?

Les méthodes de NSDraggingDestination permettent de :

DUView accepte les glisser-déposer entrants à condition qu'ils représentent un nom de fichier (qu'on présume être un répertoire). Si c'est le cas, il affiche ce répertoire.

Les méthodes de NSDraggingDestination servent à :

L'image qu'on utilise comme icône pour le glisser-déposer sera simplement le nom du fichier. La négociation du type de transfert ne marche pas toujours bien, donc on laisse donc l'application de destination choisir. Voici comment on fait glisser un répertoire de CocoaDU vers la console :
Drag'n'drop
Le problème est avec le Finder. Quand il reçoit un nom de fichier dans une fenêtre, il croit qu'il faut le déplacer le copier, il ne veut pas simplement l'afficher. Pour contourner cette difficulté assez classique, les application Mac ont souvent un bouton Reveal In Finder qui lance le Finder avec le fichier sélectionné. C'est exactement ce qu'il nous faut, et c'est aussi efficace que le glisser-déposer.

En somme, même si l'implantation du glisser-déposer fait un peu « recette de cuisine » (la documentation est insuffisante : il faut s'appuyer sur un exemple), elle est assez simple et suffisament extensible.

La localisation

Jusqu'à maintenant, tout le code et l'interface de CocoaDU était en anglais, langue neutre de la programmation. Cependant, l'utlisateur préfère probablement que son application favorite lui parle dans sa langue maternelle, ou simplement que l'application soit dans la même langue que le reste du système.

L'application est un répertoires qui contiennent toutes les données nécessaires à son exécution sous forme de fichiers. S'il y a des versions en différentes langues de certains fichiers, elles sont dans des sous-répetroires (par exemple English.lproj, fr.lproj, etc.)

Pour localiser l'interface graphique, on crée un deuxième fichier NIB traduit en français. Pour cela, sous Project Builder, on va dans l'inspector du fichier (menu contextuel), et on choisit l'option Make Locale. Comme nom de locale, on précise fr (=français).

Il crée alors une version indépendante du fichier qui sera utilisée automatiquement au lancement de l'application. Pour la traduire, on l'ouvre sous Interface Builder et on change les textes qui apparaissent à l'utilisateur (boutons, menus, fenêtre à propos) en redimensionnant les éléments si nécessaire.

Pour les textes qui apparaissent dans le code source, il faut préciser qu'il doit être traduit. Par exemple, le code qui affiche un message d'erreur :

 NSRunAlertPanel(
        @"Alert!",
        @"Could not explore dir...",
        @"Ok",nil,nil,nil);

Est remplacé par

 NSRunAlertPanel(
        NSLocalizedString(@"Alert!",nil),
        NSLocalizedString(@"Could not explore dir...",nil),
        @"Ok",nil,nil,nil);

Ensuite, il faut rajouter fichier Localizable.strings au projet, dans le groupe ressources. C'est un fichier texte au format UTF-16. Ensuite, on le localise en français (c'est-à-dire qu'on génère le fichier dans fr.lproj) et on y met les chaînes de caractères correspondantes :

"Alert!"="Alerte !";
"Could not explore dir..."="Impossible d'explorer le répertoire...";

Le format d'affichage des nombres est localisé automatiquement. Voici le résultat avec le menu, les unités de taille (Mo au lieu de MB) et le format des nombres localisés :

version localisée de CocoaDU

Une icône

Les icônes sous Mac OS X sont définies dans un fichier .icns. Il contient l'image en plusieurs résolutions et avec un canal de transparence. Apple recommande de faire une icône qui représente ce que fait l'application par une métaphore de la vie courante.

Pour CocoaDU, nous avons simplement ajouté un canal transparent à une capture d'écran de l'application (avec GIMP). Nous l'avons ensuite importé dans IconComposer qui a généré automatiquement les versions réduites. Nous avons saugegardé le fichier sous le nom AppIcon.icns.

éditeur d'icônes
Pour utiliser l'icône, on importe le fichier dans le groupe ressources de l'application sous Project Builder. Ensuite, dans l'onglet Targets > Info.plist entries > Application Icon on complète le champ avec le nom du fichier AppIcon.icns.

Un paquet bien ficelé

Voilà, CocoaDU commence à être une  application présentable. Il faut maintenant  que les utilisateurs puissent l'installer en 3 clics de souris (au plus). Quand on compile l'application, Project Builder crée un répertoire (CocoaDU.app) qui contient tout ce qu'il faut. On peut donc archiver ce répertoire en .tar.gz ou .zip. Quand on clique dessus, Stuffit Expander le décompresse, et l'ultilisateur n'a qu'à le copier où il veut (par exemple dans ~/Applications).

En général, on préfère cependant faire une image disque, qui conserve les ressource forks des fichiers, même ceux-ci vont disparaître peu à peu.

Enfin, la solution de luxe est de faire un paquetage qui sera utilisé par l'installeur de Mac OS X (à l'aide de Package Maker). Cependant, Apple recommande de les utiliser uniquement pour les logiciels complexes qui doivent être installés par parties, ce qui n'est pas le cas de CocoaDU.

Nous allons donc faire une simple image disque. Mac OS X (Darwin) est plus clair que Linux pour ce qui est la gestion des devices et des disques. Avant le montage, il faut déclarer le device : le fichier dans /dev n'existe que s'il y a un programme qui a chargé le driver. Pour les images disques, c'est l'utilitaire hdid qui s'en occupe.

Les images disque sont crées par hdiutil, une commande à tiroirs qui permet de créer des disques aux formats HFS(+), DOS, et ISO9660 (éventuellement bootable), de changer leur système de fichiers, de les redimentionner, et de les graver. Les disques au format HFS+ sont éventuellement compressés, ils ne peuvent alors être montés qu'en lecture seule. L'applicaion graphique correspondante est Disk Copy.

Quand on clique sur un fichier .dmg (extension des images disque) dans le Finder, il est monté automatiquement. Pour créer une image disque (que ce soit avec hdiutil ou Disk Copy), on procède ainsi :

  1. on crée une image disque de capacité suffisante
  2. on la monte
  3. on copie les fichiers nécessaires
  4. on la démonte
  5. on la convertit en HFS+ compressé.
L'image en .dmg peut ensuite être distribuée.

Conclusion

Nous pourions encore continuer d'améliorer l'application : sélectionner plusieurs fichiers à la fois, ajouter des fonctionnalités, gérer des préférences, etc. En lisant les Aqua User Interface Guidelines, on découvre tous les détails qui font que l'intégration d'un programme dans le système devient impeccable. L'ouvrage peut sembler excessivement pointilleux, mais il est très difficile de faire une application consistante au point que l'utilisateur se sent tout de suite à l'aise.

D'une manière générale, c'est très agréable de développer sous Mac OS X. Les outils graphiques sont bien conçus, il y a ce qui est nécessaire et suffisant. La librairie Cocoa est parfois un peu bizarre, mais elle est bien documentée, ce qui compense presque le fait qu'elle ne soit pas open-source. Je pense si vous n'avez pas de contraintes par ailleurs en ce qui concerne la plateforme, le Mac est un bon choix pour le développement.

Par contre, quand on vient du monde Linux, il faut un temps d'adaptation pour supporter la politique commerciale de Apple. Ainsi, la publicité est toujours mensongère. Quand ils disent que les outils de développement sont gratuits, ça ne concerne pas les dernières versions. Ils sont prêts à vous faire payer 170 EUR  la version 10.2 du système, alors que vous avez acheté la version précédente il y a moins d'un an. Les prix sont beaucoup plus élevés en Europe qu'aux USA. C'est une grosse entreprise qui n'a pas honte de grapiller 20 $ par-ci, 100 $ par là quand elle réussit à coincer ses utilisateurs.

Références

Doc de POSIX Threads : man pthread
Doc de Cocoa : /Developer/Documentation/Cocoa/CocoaTopics.html
Aqua Human Interface Guidelines : http://developer.apple.com/documentation/UserExperience/Conceptual/OSXHIGuidelines/index.html
Le livre de O'Reilly sur Cocoa : Learning Cocoa, by Apple engeneers, O'Reilly,ISBN: 0-596-00160-6
CocoaDU : http://www.enseeiht.fr/~douze/CocoaDU