Licence Informatique 1 - GTK : conception Modèle-Vue-Contrôleur
Table of Contents
1 Modèle-Vue-Contrôleur
Le Modèle Vue Contrôleur (MVC) est une architecture et une méthode de conception pour le développement d'applications logicielles qui sépare le modèle de données, l'interface utilisateur et la logique de contrôle. Cette méthode a été mise au point en 1979 par Trygve Reenskaug, qui travaillait alors sur Smalltalk dans les laboratoires de recherche Xerox PARC.
Ce modèle d'architecture impose la séparation entre les données, les traitements et la présentation, ce qui donne trois parties fondamentales dans l'application finale : le modèle, la vue et le contrôleur.
- Le Modèle représente le comportement de l'application : traitements des données, interactions avec la base de données, etc. Il décrit les données manipulées par l'application et définit les méthodes d'accès.
- la Vue correspond à l'interface avec laquelle l'utilisateur interagit. Les résultats renvoyés par le modèle sont dénués de toute présentation mais sont présentés par les vues. Plusieurs vues peuvent afficher les informations d'un même modèle. Elle peut être conçue en html, ou tout autre " langage " de présentation. La vue n'effectue aucun traitement, elle se contente d'afficher les résultats des traitements effectués par le modèle, et de permettre à l'utilisateur d'interagir avec elles.
- le Contrôleur prend en charge la gestion des évènements de synchronisation pour mettre à jour la vue ou le modèle. Il n'effectue aucun traitement, ne modifie aucune donnée, il analyse la requête du client et se contente de demander au modèle d'appliquer la requête et de demander à la vue d'afficher le modèle modifié.
En résumé, lorsqu'un client envoie une requête à l'application, celle-ci est analysée par le contrôleur, qui demande au modèle d'effectuer les traitements appropriés, puis renvoie la vue adaptée.
Un avantage apporté par cette architecture est sa clarté. Cela simplifie la tâche du développeur qui tenterait d'effectuer une maintenance ou une amélioration sur le projet. En effet, la modification des traitements ne change en rien la vue. Par exemple, on peut changer les règles d'un jeu de dames pour en faire un jeu de morpion en changeant simplement les traitements dans le modèle, et les vues ne s'en trouvent pas affectées.
On peut aussi facilement proposer plusieurs types de vues pour un même modèle :
- adaptation aux possibilités de la machine : mode console / mode graphique
- travail collaboratif ou jeu en réseau : chaque participant a sa propre vue d'un même modèle
- gestion des préférences de l'utilisateur (par exemple, dans un navigateur de fichiers, affichage des fichiers en liste, liste détaillée, ou icônes)
1.1 Architecture Modèle/Vue/Contrôleur
L'architecture Modèle/Vue/Contrôleur (MVC) est une façon d'organiser un programme. Elle consiste à distinguer trois entités distinctes qui sont le modèle, la vue et le contrôleur ayant chacun un rôle précis dans le programme.
L'organisation globale d'une interface graphique est souvent délicate. Bien que la façon MVC d'organiser une interface ne soit pas la solution miracle, elle fournit souvent une première approche qui peut ensuite être adaptée. Elle offre aussi un cadre pour structurer une application.
Dans l'architecture MVC, les rôles des trois entités sont les suivants~:
- modèle : données (accès et mise à jour)
- vue : interface utilisateur (entrées et sorties)
- contrôleur : gestion des évènements et synchronisation
1.1.1 Rôle du modèle
Le modèle contient les données manipulées par le programme. Il assure la gestion de ces données et garantit leur intégrité. Dans le cas typique d'une base de données, c'est le modèle qui la contient.
Le modèle offre des méthodes pour mettre à jour ces données (insertion, suppression, changement de valeur). Il offre aussi des méthodes pour consulter ces données. Dans le cas de données volumineuses, le modèle peut faciliter des vues partielles. Si, par exemple, le programme manipule une base de données pour les emplois du temps, le modèle peut fournir des méthodes pour récupérer tous les cours d'une salle, tous les cours d'une personne ou tous les cours d'un groupe de TD…
1.1.2 Rôle de la vue
La vue fait l'interface avec l'utilisateur. Sa première tâche est d'afficher les données qu'elle a récupérées auprès du modèle. Sa seconde tâche est de recevoir toutes les actions de l'utilisateur (clic de souris, sélection d'une entrées, boutons, …). Ces différents évènements sont ensuite envoyés au contrôleur.
La vue peut aussi donner plusieurs vues, partielles ou non, des
mêmes données. Par exemple, l'application baobab de gestion de
l'espace disque (sous Linux) affiche une liste de répertoires dans
la partie gauche de sa fenêtre et une représentation graphique des
mêmes répertoires dans sa partie droite. Cette représentation peut
au choix être une vue "camembert" ou une vue sous forme de
rectangles imbriqués dont les surfaces sont proportionnelles aux
tailles des répertoires représentés.
1.1.3 Rôle du contrôleur
Le contrôleur est chargé de la synchronisation du modèle et de la vue. Il reçoit tous les évènements de l'utilisateur et enclenche les actions à effectuer. Si une action nécessite un changement des données, le contrôleur demande la modification des données au modèle et ensuite avertit la vue que les données ont changé pour que celle-ci se mette à jour. Certains événements de l'utilisateur ne concernent pas les données mais la vue. Dans ce cas, le contrôleur demande à la vue de se modifier.
Dans le cas d'une base de données des emplois du temps, une action de l'utilisateur peut être l'entrée (saisie) d'un nouveau cours. Le contrôleur ajoute ce cours au modèle et demande sa prise en compte par la vue. Une action de l'utilisateur peut aussi être de sélectionner une nouvelle personne pour visualiser tous ses cours. Ceci ne modifie pas la base des cours mais nécessite simplement que la vue s'adapte et offre à l'utilisateur une vision des cours de cette personne.
Source : http://www.techno-science.net/?onglet=glossaire&definition=5331
2 Modélisation
2.1 Problématique
Le but est de modéliser un concept du monde réel pour le
représenter sous format informatique. Cette modélisation peut être
initalement motivée par un but d'utilisation précis. Il faut
toujours penser : que ce but peut changer, évoluer, s'enrichir ; et
donc, s'en rendre le plus indépendant possible. Prenons l'exemple
du concept "nombres complexes". Un nombre complexe est la donnée
d'une partie réelle et d'une partie imaginaire. On peut pour le
représenter utiliser la structure "struct s_complexe" suivante :
struct s_complexe { float reel; float img; }; typedef struct s_complexe complexe;
Pour manipuler les nombres complexes, on doit pouvoir (liste non exhaustive) : lire (consulter) la partie réelle / la partie imaginaire d'un nombre complexe, écrire (affecter) la partie réelle / la partie imaginaire d'un nombre complexe, déterminer le module / l'argument / le conjugué d'un nombre complexe, sommer, soustraire, multiplier, diviser deux nombres complexes entre eux, initialiser à 0 un nombre complexe ; soit, on doit disposer de la liste suivante de fonctions :
float complexe_get_reel(complexe z); float complexe_get_img(complexe z); complexe complexe_set_reel(complexe z, float reel); complexe complexe_set_img(complexe z, float img); complexe complexe_set_zero(complexe z); float complexe_get_module(complexe z); float complexe_get_argument(complexe z); complexe complexe_get_conjugue(complexe z); complexe complexe_get_somme(complexe z1, complexe z2); complexe complexe_get_difference(complexe z1, complexe z2); complexe complexe_get_produit(complexe z1, complexe z2); complexe complexe_get_division(complexe z1, complexe z2);
On donne l'exemple de la définition de ces fonctions, pour quelques fonctions seulement :
float complexe_get_reel(complexe z) { return z.reel; } complexe complexe_set_img(complexe z, float img) { z.img = img; return z; } complexe complexe_set_zero(complexe) { z.reel = 0; z.img = 0; return z; } float complexe_get_module(complexe z) { float module; module = sqrt( pow(z.reel, 2) + pow(z.img, 2) ); return z; } complexe complexe_get_difference(complexe z1, complexe z2) { complexe z; z.reel = z1.reel -z2.reel; z.img = z1.img -z2.img; return z; }
2.2 Trois bonne raisons de définir l'interface d'une structure de donnée
- Pour permettre et faciliter la manipulation de la structure par des programmes tierces. Concernant les nombres complexes : on s'imagine que dès lors que l'on manipule des nombres (complexes ou autres), on peut vouloir les sommer ; il semble alors naturel de disposer d'une fonction "somme".
- Pour contrôler l'accès au données et en maintenir la cohérence. Par exemple, si l'on manipule un type de donnée
"personne" implémenté par une structure "
struct s_personne" qui contient un champ "date de naissance" et un champ "âge", et si l'on définit l'accesseur en écriture "personne_set_naissance", alors cette fonction ne doit pas seulement mettre à jour le champ "date de naissance", mais aussi le champ "âge" de la variable du type "personne" passée en paramètre (notons que ce n'est pas forcément une bonne idée que de manipuler un champ "âge", mais passons). - Pour se rendre le plus possible indépendant de
l'implémentation. Concernant les nombres complexes, la structure
utiliséee et la définition des fonctions de manipulation peuvent
changer, sans pour autant que cela impacte les programmes qui
utilisent la bibliothèque "complexe". Voyons ce qui se passe si
l'on remplace la
structure
s_complexepar la structures_complexe_2suivante :struct s_complexe_2 { float module; float argument; }; typedef struct s_complexe_2 complexe;
Et considérons alors les deux programmes suivants (où le fichier "complexe.h" regroupe la spécifiation du type "complexe" et la déclaration des fonctions de manipulation des nombres complexes) :
/* programme_1 */ #include<stdlib.h> /* pour la constante symbolique EXIT_SUCCESS */ #include<stdio.h> /* pour la fonction printf */ #include"complexe.h" /* pour manipuler les nombres complexes */ int main() { complexe z; z.reel=0; z.img=0; printf("Le zero complexe est %f + %f i", z.reel, z.img); return EXIT_SUCCESS; };
/* programme_2 */ #include<stdlib.h> /* pour la constante symbolique EXIT_SUCCESS */ #include<stdio.h> /* pour la fonction printf */ #include"complexe.h" /* pour manipuler les nombres complexes */ int main() { complexe z; z = complexe_set_zero(z); printf("Le zero complexe est %f + %f i", complexe_get_reel(z), complexe_get_img(z)); return EXIT_SUCCESS; };
Le premier programme ne compile pas après la modification apportée à la bibliothèque "complexe", tandis que le second programme, lui, n'a pas besoin d'être modifié.
2.2.1 Quelques règles pour concevoir l'interface d'une structure de donnée
- Règle numéro 0 : définir les règles de cohérence des informations. Par pour l'exemple du cours, si une case a été choisie deux fois par le joueur, elle est forcément dévoilée.
- Règle numéro 1 : en tenant compte de la règle numéro 0, pour
toute information, écrire une méthode "get" et une méthode "set"
permettant d'accéder en lecture et en écriture à cette
information. Pour l'exemple du cours, par exemple, on se rend
compte
que l'état "dévoilé" d'une case ne peut être modifié que
lorsque le joueur sélectionne cette case ou une autre de la
même ligne ou même colonne. Il ne faut pas écrire de méthode
"
mod_case_set_visible", car une telle méthode permettrait d'agir sur la visibilité d'une case sans passer par le contrôle effectué par la méthode "mod_case_jouer". - Règle numéro 2 : moduler la règle numéro 1, en fonction de l'utilisation que l'on imagine qu'il sera faite de la bibliothèque.
- Règle numéro 3 : moduler la règle numéro 2, en cherchant à rester le plus évolutif possible.
2.2.2 Liste de bonnes pratiques
- Bien organiser le code en fichiers, pour le rendre ré-utilisable, évolutif et robuste (programmation modulaire, encapsulation).
- Rendre son code lisible :
- bien nommer (variables, fonctions),
- commenter (pourquoi on fait les choses),
- préciser le comportement attendu (pré- et post-conditions des fonctions, évolution de la valeur des variables au fil du temps, etc.).
- Favoriser les passages par adresse (performances à l'exécution).
- Tester systématiquement et indépendemment.
- Une structure ⇒ fonctions de test associées : chaque accès au champ d'une structure, chaque fonction doit être au moins techniquement testée (tests unitaires).
- Un programme ⇒ des scénarios de test fonctionnels. Il faut s'assurer du maintien de la cohérence des données au fil du temps.
3 Exemple d'implémentation en C
3.1 Description du concept modélisé
- Le modèle est un damier de dix case sur dix.
- Chaque case est "voilée" au démarrage.
- L'utilisateur choisit des cases, une par une.
- Lorsqu'une case est choisie, il ne se passe rien la première fois. Si elle est choisie à nouveau, plus tard, toutes les cases sur la même ligne et celles sur la même colonne sont dévoilées.
- Une case dévoilée ne peut plus être choisie.
- Le "jeu" est terminé lorsque toutes les cases ont été dévoilées.
3.2 Versions
Cinq versions du programme sont disponibles :
3.3 Fichiers
Le modèle, la vue et le contrôleur sont chacun implémentés dans deux fichiers source. Le programme est donc constitué de six fichiers :
modele.h: déclarations des structures et accesseurs du modèlemodele.c: définitions des méthodes du modèlevue.h: déclarations des structures et fonctions de la vuevue.c: définitions des fonctions de la vuectr.h: déclarations des structures et fonctions du contrôleurctr.c: définitions des fonctions du contrôleur
3.4 Nommage des fonctions
Les fichiers d'en-tête modele.h, vue.h et ctr.h sont
destinés à être inclus dans les fichiers source .c à l'aide de
directives #include. Ceci permet par exemple d'intégrer les
déclarations des accesseurs du modèle dans les fichiers
correspondant au contrôleur ou à la vue, et par conséquent ces
fonctions peuvent être appelées depuis d'autres fichiers que
celui où elles sont définies.
Cela peut poser un problème à la lecture d'un code source : le lecteur peut avoir du mal à trouver la définition d'une fonction, ou même à savoir à quoi cette fonction sert (si elle agit sur le contrôleur, la vue ou le modèle).
Pour aider la lecture et la compréhension du programme, il est bon d'adopter des conventions de nommage pour les fonctions et les types (à la façon de GTK, où le préfixe des fonctions indiquent clairement les objets sur lesquelles elles agissent).
Dans les exemples, les fonctions de la vue commencent par le
préfixe vue_, celles du contrôleur par le préfixe ctr_ et
les accesseurs du modèle par le préfixe mod_.
Le modèle possède de plus des accesseurs privés, qui ne sont pas
déclarés dans modele.h car ils ne sont pas destinés à être
utilisés ailleurs que dans modele.c. En effet, ils pourraient
compromettre la cohérence des données et ne sont utilisés que par
des fonctions du modèle, dans lesquelles cette cohérence est
vérifiée. Les noms de ces accesseurs sont préfixés par private_.
3.5 Inclusions et protection de redondance
- Observez les différentes directives
#includedans ces six fichiers. - Remarquez que le fichier
vue.hinclut le fichierctr.h. Ceci est nécessaire pour le compilateur, car la fonctionvue_creationdéclarée dansvue.hprend en paramètre l'adresse d'une structure de typectr_tqui, elle, est déclarée dansctr.h.Or les fichiers
vue.hetctr.hsont tous deux inclus dans le fichierctr.c. Commectr.hest déjà inclus dansvue.h, que va-t-il se passer lorsque l'on va compilerctr.c? Le fichierctr.hva être inclus deux fois, et toutes ses déclarations vont être lues deux fois par le compilateur, qui produira alors une erreur.C'est pour éviter ce type d'erreur que les trois fichiers d'en-tête commencent par les directives de compilation :
#ifndef _NOM_DU_FICHIER_H_ #define _NOM_DU_FICHIER_H_
et se terminent par la directive de compilation :
#endifDe la sorte, le contenu de chaque fichier ne sera lu qu'une fois par le compilateur.
3.6 Déclaration de type sans définition
- L'inclusion d'un fichier d'en-tête dans un autre fichier
d'en-tête peut poser un problème de cyclicité si le second
inclut également le premier.
Dans
ctr.h, en particulier, on déclare la structurectr_t, qui contient un modèle (struct modele) et une vue (struct vue). Mais, pour éviter tout cycle d'inclusion, on ne peut pas inclure le fichiervue.hqui contient la définition destruct vue.La solution adoptée ici est la suivante : on ne définit pas les types dans les fichiers d'en-tête, on ne fait que les y déclarer.
Par exemple, le type structuré
ctr_test déclaré sans être défini en écrivant simplement la ligne :typedef struct ctr ctr_t;
Comme il n'y a que des adresses sur des
ctr_tdans le fichierctr.h, on peut se permettre de ne définir le contenu de la structurectr_tque dans le fichierctr.c, où ce contenu est utilisé.De même, dans
vue.h, on n'utilise que l'adresse d'unctr_tdans la déclaration de la fonctionvue_creation. On peut donc se passer de l'inclusion dectr.het se contenter de répéter la déclaration du typectr_tci-dessus dans le fichiervue.h.
3.7 Interactions entre modèle, vue et contrôleur
- le modèle (i.e. tout ce qui se trouve dans
modele.hetmodele.c) est autonome. Il n'appelle aucune fonction des autres composants du programme. - la vue doit signaler les actions de l'utilisateur au contrôleur. Elle peut donc appeler des fonctions du contrôleur. Elle n'interragit pas avec le modèle.
- le contrôleur se charge de l'interaction entre la vue et le
modèle; il est donc autorisé à appeler les fonctions de tous les
modules du programme. En revanche il ne
doit pas accéder directement aux structures de données des
autres composants : il est forcé de passer par des appels de
fonctions.
En particulier, dans l'exemple, on s'est arrangé pour que le contrôleur soit totalement indépendant de GTK, de manière à ce qu'il soit compatible avec une interface en mode texte. Les fonctions de callback passées à GTK ont donc été définies dans
vue.c.
3.8 retour sur les versions
Lorsque l'architecture est propre et bien définie, il devient beaucoup plus facile de modifier certains éléments. Regardez maintenant les autres versions du programme :
- avec plusieurs interfaces graphiques
- avec interface en mode texte
Seuls le fichier
vue.ca été modifié pour ce changement d'interface. - avec une implémentation différente du modèle
Dans la première version, le modèle était représenté par la
structure suivante :
typedef struct modele { char nbchoix[10][10]; char visible[10][10]; int fini; } modele_t;
Dans cette version, il est maintenant représenté par les structures :
typedef struct { char nbchoix; char visible; } mod_case_t ; typedef struct modele { mod_case_t cases[100]; } modele_t;
Les mêmes informations sont stockées, mais différemment. Pour rendre le programme fonctionnel, il suffit alors de modifier uniquement les définitions des accesseurs du modèle :
mod_case_get_visible,mod_case_get_nb_choix,private_case_initetprivate_case_set_visible. - avec des "règles du jeu" différentes
Dans cette version, il suffit de choisir une case une fois pour
la dévoiler, et cela dévoile également toutes ses cases
adjacentes, plutôt que celles de sa ligne et de sa colonne.
C'est uniquement la fonction du modèle
mod_case_jouerqui a été modifiée pour atteindre ce résultat.
3.9 Compilation
- La compilation se fait en quatre étapes
- chaque fichier
.cest compilé séparément (3 fichiers, donc 3 étapes) sans que le résultat soit exécutable. Pour cela, on utilise l'option-cde gcc. On obtient trois fichiers objets :modele.o,ctr.oetvue.o. - le fichier exécutable est produit à partir des trois fichiers objets et des librairies GTK. Cette étape s'appelle l'édition de liens.
- chaque fichier
- Un fichier
Makefileest présent avec les fichiers source. Ce fichier contient des règles de dépendance et des instructions de compilation, du type :ctr.o: ctr.c ctr.h vue.h modele.h gcc -c ctr.c -Wall -o ctr.oUne règle de ce type signifie :
- si les fichiers
ctr.c,ctr.h,vue.houmodele.hsont plus récents que le fichierctr.o(donc s'ils ont été modifiés depuis la dernière compilation), il faut recréer le fichierctr.o. - pour créer le fichier
ctr.o, il faut lancer la commandegcc -c ctr.c -Wall -o ctr.o
La présence du fichier
Makefiledans le répertoire des sources permet de lancer les quatre étapes de compilation en tapant une seule commande :makeDe plus, seuls les fichiers qui doivent être recompilés le seront : si
modele.hetmodele.cn'ont pas été modifiés depuis la dernière compilation, alorsmodele.on'a pas besoin d'être recompilé. - si les fichiers
- La fonction
mainse trouve dansctr.c.