This post is available in english.
Télécharger ici le code source de cet article.
Lorsque l’on essaye d’appliquer le modèle MVC aux WinForms, on peut se faciliter la vie en utilisant l’interface INotifyPropertyChanged pour faire du DataBinding entre le contrôleur et le formulaire.
On peut donc écrire un contrôleur qui ressemble à ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public class MyController : INotifyPropertyChanged { // Register a default handler to avoid having to test for null public event PropertyChangedEventHandler PropertyChanged = delegate { };
public void ChangeStatus() { Status = DateTime.Now.ToString(); }
private string _status;
public string Status { get { return _status; } set { _status = value;
// Notify that the property has changed PropertyChanged(this, new PropertyChangedEventArgs("Status")); } } } |
Le formulaire est defini comme ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public partial class MyForm : Form { private MyController _controller = new MyController();
public MyForm() { InitializeComponent();
// Make a link between labelStatus.Text and _controller.Status labelStatus.DataBindings.Add("Text", _controller, "Status"); }
private void buttonChangeStatus_Click(object sender, EventArgs e) { _controller.ChangeStatus(); } } |
Le formulaire va donc mettre à jour le label “labelStatus” lorsque la propriété “Status” du contrôleur change.
Tout ce code est executé dans la thread principale, celle où la pompe à messages du formulaire principal est placée.
Une touche d’asynchronisme
Imaginons maintenant que le contrôleur va effectuer un certain nombre d’opérations en asynchrone, par l’intermédiaire d’un timer par exemple.
Modifions le contrôleur en ajoutant ceci :
1 2 3 4 5 6 7 8 9 10 11 | private System.Threading.Timer _timer;
public MyController() { _timer = new Timer( d => ChangeStatus(), null, TimeSpan.FromSeconds(1), // Start in one second TimeSpan.FromSeconds(1) // Every second ); } |
En modifiant le contrôleur de cette manière, on va faire en sorte que la propriété Status soit mise à jour régulièrement.
Le modèle de fonctionnement de System.Threading.Timer fait en sorte que la méthode ChangeStatus est appelée depuis une thread différente de celle qui a créé le formulaire. Ainsi, lorsque le code s’exécute, la mise à jour du label se fait arrêter avec l’exception suivante :
Cross-thread operation not valid: Control 'labelStatus' accessed from a thread other than the thread it was created on.
La solution est assez simple, il faut faire en sorte que la mise à jour se fasse sur la Thread principale en utilisant la méthode Control.Invoke().
Cela dit dans notre contexte, c'est l'engin de DataBinding qui s'attache à l'événement PropertyChanged. Il faut donc faire en sorte d’appeler l’événement PropertyChanged en le “décorant” d’un appel à la méthode Control.Invoke().
On pourrait modifier le contrôleur pour appeler l’évènement sur la Thread principale :
1 2 3 4 5 6 7 8 | set { _status = value;
// Notify that the property has changed Action action = () => PropertyChanged(this, new PropertyChangedEventArgs("Status")); _form.Invoke(action); } |
Mais cela impliquerait d’ajouter du code dépendant des WinForms dans le contrôleur, ce qui n’est pas acceptable. Comme on veut être capable de placer ce contrôleur dans un test unitaire, appeler la méthode Control.Invoke() poserait problème, puisqu’il faudrait une instance de formulaire, que l’on aurait pas dans ce contexte.
La délégation par interface
L’idée est donc déléguer à la vue (donc au formulaire) la responsabilité de placer l’appel de l’évènement sur la thread principale. On peut le faire par l’intermédiaire d’une interface passée en paramètre du constructeur du contrôleur. Une interface comme ceci :
1 2 3 4 | public interface ISynchronousCall { void Invoke(Action a); } |
On fait en sorte que le formulaire l'implémente :
1 2 3 4 5 | void ISynchronousCall.Invoke(Action action) { // Call the provided action on the UI Thread using Control.Invoke() Invoke(action); } |
Puis on va lever l’évènement comme ceci :
1 2 3 | _synchronousInvoker.Invoke( () => PropertyChanged(this, new PropertyChangedEventArgs("Status")) ); |
Mais comme tout bon programmeur efficace (entendez paresseux), on veut éviter d'écrire une interface.
La délégation par lambda
On va donc tenter d’utiliser les lambda pour appeler la méthode Control.Invoke(). Pour faire cela, on va modifier le constructeur du contrôleur, et plutôt que de prendre une interface en paramètre, on va écrire :
1 2 3 4 5 | public MyController(Action<Action> synchronousInvoker) { _synchronousInvoker = synchronousInvoker; ... } |
En clair, on donne au constructeur une action qui a pour responsabilité d’appeler une action qui lui est passée en paramètre.
Cela permet de construire le contrôleur de cette manière :
1 | _controller = new MyController(a => Invoke(a)); |
Ici, pas d'interface à implémenter, juste une petite lambda qui fait l'invocation d'une Action sur la Thread GUI. Et on l’utilise comme ceci :
1 2 3 | _synchronousInvoker( () => PropertyChanged(this, new PropertyChangedEventArgs("Status")) ); |
Cela veut donc dire que la lambda spécifiée en paramètre sera appelée par la Thread UI, donc dans le bon contexte pour mettre à jour le label associé.
Le contrôleur est effectivement toujours isolé de la vue, mais adopte malgré tout le comportement de la vue pour les changement de propriétés.
Si l’on voulait utiliser le contrôleur dans un cadre de test unitaire, on le construirait de cette manière :
1 | _controller = new MyController(a => a()); |
La lambda spécifiée ne se contenterait alors que d'appeler directement l'action en paramètre.
Bonus: Faciliter l’écriture du code de notification
Un désavantage de passer INotifyPropertyChanged est qu’il est nécessaire d’écrire le nom de la propriété sous forme de chaine de caractères. C’est assez gênant pour plusieurs raisons, notamment lors de l’utilisation d’outils de refactoring ou d’obfuscation.
C# 3.0 apporte les arbres d’expressions, une fonctionnalité intéressante qui peut être utilisée dans ce contexte. L’idée est d’utiliser les arbres d’expressions pour faire une sorte d’hypothétique “memberof” qui permettrait d’obtenir le MemberInfo d’une propriété, de la même manière que typeof permet d’obtenir le System.Type d’un type.
Voici une petite méthode d’aide à la levée d’évènements :
1 2 3 4 5 6 7 8 9 | private void InvokePropertyChanged<T>(Expression<Func<T>> expr) { var body = expr.Body as MemberExpression;
if (body != null) { PropertyChanged(this, new PropertyChangedEventArgs(body.Member.Name)); } } |
Méthode que l'on va utiliser comme ceci :
1 2 3 | _synchronousInvoker( () => InvokePropertyChanged(() => Status) ); |
Ainsi la propriété “Status” est utilisée en tant que propriété dans le code, et non en tant que chaine de caractère. Il devient alors facile de la renommer via un outil de refactoring sans casser la logique du code.
Il faut noter que la lambda () => Status n’est jamais exécutée. Elle est simplement analysée par la méthode InvokePropertyChanged comme étant capable de fournir le nom d’une propriété.
Le contrôleur en un seul morceau
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 | public class MyController : INotifyPropertyChanged { // Register a default handler to avoid having to test for null public event PropertyChangedEventHandler PropertyChanged = delegate { };
private System.Threading.Timer _timer; private readonly Action<Action> _synchronousInvoker;
public MyController(Action<Action> synchronousInvoker) { _synchronousInvoker = synchronousInvoker
_timer = new Timer( d => Status = DateTime.Now.ToString(), null, 1000, // Start in one second 1000 // Every second ); }
public void ChangeStatus() { Status = DateTime.Now.ToString(); }
private string _status;
public string Status { get { return _status; } set { _status = value;
// Notify that the property has changed _synchronousInvoker( () => InvokePropertyChanged(() => Status) ); } }
/// <summary> /// Raise the PropertyChanged event for the property “get” specified in the expression /// </summary> /// <typeparam name="T">The type of the property</typeparam> /// <param name="expr">The expression to get the property from</param> private void InvokePropertyChanged<T>(Expression<Func<T>> expr) { var body = expr.Body as MemberExpression;
if (body != null) { PropertyChanged(this, new PropertyChangedEventArgs(body.Member.Name)); } } } |