Comment utiliser MVVM avec Windows Forms
Utiliser les patterns de développement modernes avec Windows Forms est quelque chose d’assez séduisant. Mais ce n’est pas toujours très simple à mettre en place.
Voici donc une implémentation du pattern MVVM adaptée à Windows Forms.
Présentation de MVVM
MVVM est l’acronyme de Model View ViewModel, c’est un pattern semblable à MVP (Model View Presenter) mais adapté à Silverlight et WPF. Ce pattern est très populaire aujourd’hui notamment grâce à MVVM Light de GalaSoft (Laurent Brugnon), un template pour Visual Studio.
Dans ce pattern la Vue (View) ne connait pas le modèle (Model), tous les échanges se font en passant par le ViewModel qui s’occupe des chargements et enregistrements de données, voire des actions. Les données exposées pour le ViewModel sont affichées au moyen du DataBinding dans la Vue. Toutes ces précautions ne sont faites que dans le but de faciliter la maintenance et l’évolution.
Mise en œuvre avec Windows Forms
La réalisation d’une telle architecture en Windows Forms est simple… du moins, au premier abord. Il faut que définir nos 3 couches : soit le Modèle, la Vue et le « ViewModel ».
Le Modèle (Model)
C’est la couche la plus simple à réaliser, puisqu’elle ne contient que des objets métiers, par exemple, une classe Person.
public class Person : Model
{
private string firstname;
private string lastname;
private string email;
private DateTime birthdate;
public string FirstName
{
get { return this.firstname; }
set {
if (this.firstname != value) {
this.firstname = value;
base.OnPropertyChanged(() => this.FirstName);
}
}
}
public string LastName
{
get { return this.lastname; }
set {
if (this.lastname != value) {
this.lastname = value;
base.OnPropertyChanged(() => this.Lastname);
}
}
}
public string Email
{
get { return this.email; }
set {
if (this.email != value) {
this.email = value;
base.OnPropertyChanged(()=>this.Email);
}
}
}
public DateTime BirthDate {
get { return this.birthdate; }
set {
if (this.birthdate != value) {
this.birthdate = value;
base.OnPropertyChanged(() => this.Birthdate);
}
}
}
}
Cet objet hérite d’une classe nommée Model qui sera ma classe de base pour tous mes modèles : elle implémente l’interface INotifyPropertyChanged et les méthodes telles que OnPropertyChanged. Nous aborderons rapidement à la fin de cet article le code de cette classe lorsque nous parlerons Validation.
Le ViewModel
Le ViewModel sert avant tout à exposer les propriétés que nous souhaitons voir dans la View et à gérer les interactions avec la base de données (ou tout autre moyen de gestion des données). Nous allons donc ici retrouver les propriétés que nous avions dans le Modèle.
public class PersonViewModel : ViewModel
{
private Person person;
public PersonViewModel() {
person = new Person();
this.person.BirthDate = DateTime.Now;
}
public override dynamic Model
{
get
{
return this.person;
}
protected set
{
this.person = value;
}
}
public string FirstName {
get {
return this.Model.FirstName;
}
set {
if (value != this.Model.FirstName) {
this.Model.FirstName = value;
OnPropertyChanged(() => this.FirstName);
}
}
}
public string LastName {
get {
return this.Model.LastName;
}
set {
if (value != this.Model.LastName) {
this.Model.LastName = value;
OnPropertyChanged(() => this.LastName);
}
}
}
public string Email {
get {
return this.Model.Email;
}
set {
if (value != this.Model.Email) {
this.Model.Email = value;
OnPropertyChanged(() => this.Email);
}
}
}
public DateTime BirthDate {
get {
return this.Model.BirthDate;
}
set {
if (value != this.Model.BirthDate) {
this.Model.BirthDate = value;
OnPropertyChanged(() => this.BirthDate);
}
}
}
}
Vous pouvez constater que cette classe est la copie conforme de la précédente… à un détail près : celle-ci hérite de ViewModel qui implémente les interfaces INotifyPropertyChanged et IDataErrorInfo et les implémentations associés. La première interface permet comme vous le savez d’être notifié lorsqu’une propriété change tandis que la suivante fournit au formulaire (View) la liste des erreurs de validation.
Comme je l’ai précisé au début de cet article, le ViewModel connait le Modèle, on crée donc une propriété pour le référencer ; le choix de la typer dynamic permet d’utiliser directement ses propriétés, sans être obligé de faire un cast. Attention toutefois, l’IntelliSense ne fonctionne pas pour cet objet (puisqu’il n’est pas connu au moment de la compilation), vous devez donc être très rigoureux dans le nommage de vos propriétés.
Nous avons donc à partir de là, notre Modèle, qui contient les données, et le ViewModel qui gère les chargements / enregistrements de données du Modèle et informe la View des erreurs et autres changements de propriété.
La Vue (View)
La vue expose les données fournies par le ViewModel. Chaque propriété du ViewModel est associée à un contrôle de la Vue par un Binding, par exemple, pour associer la propriété FirstName du ViewModel au TextBox firstNameTextBox, il suffit de procéder ainsi :
firstNameTextBox.DataBindings.Add("Text", this.ViewModel, "FirstName");
Ainsi, à chaque modification de la Vue ou du ViewModel, les données sont synchronisées.
Au final, la création d’une Vue comme celle figurant ci-dessus ne prend que ces quelques lignes.
public partial class Form1 : BaseForm1
{
public Form1() : this(new PersonViewModel()) {
}
public Form1(PersonViewModel viewModel) : base(viewModel)
{
InitializeComponent();
}
protected override void OnInitializeBinding()
{
this.nameTextBox.DataBinds.Add("Text", this.ViewModel, "LastName", false, DataSourceUpdateMode.OnValidation);
this.firstNameTextBox.DataBinds.Add("Text", this.ViewModel, "FirstName", false, DataSourceUpdateMode.OnValidation);
this.emailTextBox.DataBinds.Add("Text", this.ViewModel, "Email", false, DataSourceUpdateMode.OnValidation);
this.birthdateDateTimePicker.DataBinds.Add("Value", this.ViewModel, "BirthDate", false, DataSourceUpdateMode.OnValidation);
}
}
public class BaseForm1 : FormView<PersonViewModel> {
public BaseForm1() : this(new PersonViewModel()) { }
public BaseForm1(PersonViewModel viewModel) : base(viewModel) {
}
}
A noter que la méthode OnInitializeBinding est appelée dans la méthode OnLoad de la classe FormView.
Remarque :
L’utilisation de BaseForm1 est un hack pour que le designer de Visual Studio fonctionne correctement, car malheureusement, celui-ci ne supporte pas que les Form héritent d’une classe générique.
Voilà, jusqu’à présent, nous n’avons fait que survoler l’implémentation. Je vous propose à présent de découvrir comment sont implémentées ces différentes classes de bases.
L’implémentation
Avant de détailler le modèle, je vous propose de créer une classe de base pour tous nos objets implémentant l’interface INotifyPropertyChanged : NotifyPropertyChangedObject.
NotifyPropertyChangedObject et Model
Cette classe doit donc implémenter l’interface INotifyPropertyChanged et proposer un ensemble de méthode permettant d’en simplifier l’utilisation dans les objets en dérivant.
Les méthodes OnPropertyChanged
Création
Je vous propose donc de créer 2 surcharges de la méthode OnPropertyChanged.
La première, s’utilise en passant le nom de la propriété qui est modifiée : C’est la méthode classique !
public void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)
{
this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
A présent, une méthode utilisant les expressions Linq :
public void OnPropertyChanged<T>(Expression<Func<T>> action)
{
var propertyName = GetPropertyName(action);
OnPropertyChanged(propertyName);
}
GetPropertyName étant définie comme ceci :
private static string GetPropertyName<T>(Expression<Func<T>> action)
{
var expression = (MemberExpression)action.Body;
var propertyName = expression.Member.Name;
return propertyName;
}
La méthode GetPropertyName permet d’interpréter l’expression () => this.FirstName afin d’en extraire le nom de la propriété “FirstName”.
Utilisation
Voici un comparatif de ces 2 méthodes pour notifier du changement de la propriété FirstName :
// 1ere implémentation
OnPropertyChanged("FirstName");
// 2nde implémentation
OnPropertyChanged(() => this.FirstName);
À l’utilisation, la seconde méthode procure plus de sécurité car le nom de la propriété n’est pas passé sous forme de chaîne de caractères et est validée au moment de la compilation, pas de surprise à l’exécution !
IValidatableObject
L’interface IValidatableObject fournit la méthode Validate et informe les autres classes que votre modèle « s’autovalide ». Je m’explique : le comportement qui m’intéresse est assez simple : j’aimerai que le ViewModel demande au Model de valider que les données qui lui ont été transmises sont correctes. Pour cela, j’utilise les mécanismes de validation standard du framework .net et notamment les ValidatorAttributes. Pour cela, à l’appel de la méthode Validate de ma classe Model, je vais rechercher toutes les propriétés ornées d’un attribut de validation et ensuite j’exécute ce dernier sur la valeur contenue.
Si l’on décortique le code de notre classe Model ci-dessous :
public class Model : NotifyPropertyChangedBaseObject, IValidatableObject
{
private static Dictionary<string, Delegate> propertyGetters;
private static Dictionary<string, ValidationAttribute[]> validators;
private bool alreadyLoaded = false;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
this.EnsureDataErrorInfoInitialize();
var result = new List<ValidationResult>();
propertyGetters.ToList().ForEach(
pg =>
{
validationContext.MemberName = pg.Key;
var value = pg.Value.DynamicInvoke(this);
for (int i = 0; i < validators[pg.Key].Length; i++)
{
var r = validators[pg.Key][i].GetValidationResult(value, validationContext);
if (r != null)
{
result.Add(r);
}
}
});
return result;
}
public IEnumerable<ValidationResult> Validate() {
return this.Validate(new ValidationContext(this, null, null));
}
private static ValidationAttribute[] GetValidations(PropertyInfo property)
{
return (ValidationAttribute[])property
.GetCustomAttributes(typeof(ValidationAttribute), true);
}
private static Delegate GetValueGetter(Type t, PropertyInfo property)
{
var instance = Expression.Parameter(t, "i");
var cast = Expression.TypeAs(
Expression.Property(instance, property),
typeof(object));
var _t = Expression.Lambda(cast, instance).Compile();
return _t;
}
private void EnsureDataErrorInfoInitialize()
{
if (!this.alreadyLoaded)
{
InitializeDataErrorInfo();
}
}
private void InitializeDataErrorInfo()
{
var modelType = this.GetType();
propertyGetters = modelType.GetProperties()
.Where(p => GetValidations(p).Length != 0)
.ToDictionary(p => p.Name, p => GetValueGetter(modelType, p));
validators = modelType.GetProperties()
.Where(p => GetValidations(p).Length != 0)
.ToDictionary(p => p.Name, p => GetValidations(p));
this.alreadyLoaded = true;
}
}
A l’appel de l’une des méthodes Validate, la méthode InitializeDataErrorInfo est exécutée pour construire un Dictionnaire avec le nom des propriétés et le délégué à appeler pour obtenir la valeur de la propriété. Ce délégué est en fait une expression Linq construite à la volée. Puis un second Dictionnaire est construit avec le nom de la propriété et la collection d’attributs de validation.
Ensuite, de retour dans la méthode Validate, on reprend la liste des propriétés ayant au moins un attribut de validation et on exécute celui-ci… en sortie de méthode, nous obtenons un dernier dictionnaire comprenant l’ensemble des erreurs de validation.
L’avantage de cette méthode, c’est qu’une fois que vous l’avez implémentée pour votre modèle de base, elle est disponible dans tous vos modèles.
Retournons maintenant voir notre modèle Person, une fois agrémenté des attributs de validation, il ressemble à :
public class Person : Model
{
private string firstname;
private string lastname;
private string email;
private DateTime birthdate;
[Required(ErrorMessage = "Name is required")]
public string FirstName
{
get { return this.firstname; }
set {
if (this.firstname != value) {
this.firstname = value;
base.OnPropertyChanged(() => this.FirstName);
}
}
}
[Required(ErrorMessage = "FirstName is required")]
public string LastName
{
get { return this.lastname; }
set {
if (this.lastname != value) {
this.lastname = value;
base.OnPropertyChanged(() => this.LastName);
}
}
}
[Required(ErrorMessage = "Email address is required")]
[RegularExpression(@"^([\w-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$",
ErrorMessage = "Wrong Email address")]
[DataType(DataType.EmailAddress)]
public string Email
{
get { return this.email; }
set {
if (this.email != value) {
this.email = value;
base.OnPropertyChanged(()=>this.Email);
}
}
}
[Required]
public DateTime BirthDate {
get { return this.birthdate; }
set {
if (this.birthdate != value) {
this.birthdate = value;
base.OnPropertyChanged(() => this.BirthDate);
}
}
}
}
Et voilà ! nous avons maintenant un modèle quasi-autonome, qui se valide très simplement !
Passons maintenant à la classe de base de notre ViewModel.
ViewModel
Donc, maintenant que notre modèle se valide automatiquement, il faut en appeler la méthode Validate dans ViewModel. Pour faire simple, nous allons créer une méthode Validate qui alimentera l’accesseur this[string key] de l’interface IDataErrorInfo et fournira la propriété Error. En dehors de la logique qui permet de gérer l’évènement Validating, cette méthode appelle la méthode Validate du modèle et traite le résultat afin de construire un dictionnaire comprenant en clé, la liste des noms des propriétés et en valeur, la liste des messages d’erreur. Puis, la propriété Error est renseignée avec la concaténation de tous les messages du dictionnaire.
public abstract class ViewModel : NotifyPropertyChangedBaseObject, IDataErrorInfo, IViewModel
{
#region IDataErrorInfo
private static ValidationManager validationManager = new ValidationManager();
private static Dictionary<string, ValidationAttribute[]> validators;
private Dictionary<string, string> messages = new Dictionary<string, string>();
private bool isValidating = false;
private dynamic model;
private string error;
private Dictionary<string, IBindableComponent> attachedControls = new Dictionary<string, IBindableComponent>();
public virtual dynamic Model
{
get { return model; }
protected set { this.model = value; }
}
public Dictionary<string, IBindableComponent> AttachedControls {
get { return this.attachedControls; }
}
public Dictionary<string, string> Messages {
get {
return this.messages;
}
}
public bool IsValidating {
get { return this.isValidating; }
set { this.isValidating = value; }
}
public string Error
{
get
{
return error;
}
}
public Dictionary<string, string> Validate()
{
var cancelArgs = new CancelEventArgs(false);
this.OnValidating(this, cancelArgs);
if (!cancelArgs.Cancel && !isValidating)
{
this.isValidating = true;
ValidationContext c = new ValidationContext(this.Model, null, null);
var result = (this.Model as Model).Validate(c);
messages.Clear();
if (result != null && result.Any()) {
result.ToList().ForEach(r => {
if (!messages.ContainsKey(r.MemberNames.First())) {
messages.Add(r.MemberNames.First(), r.ErrorMessage);
} else {
messages[r.MemberNames.First()] += Environment.NewLine + r.ErrorMessage;
}
});
}
}
this.error = string.Join(Environment.NewLine, messages.Select(m=>m.Value));
if (!cancelArgs.Cancel)
{
this.OnValidated(this, EventArgs.Empty);
}
return messages;
}
public event EventHandler Validated;
public event EventHandler<CancelEventArgs> Validating;
protected virtual void OnValidated(object sender, EventArgs e) {
if (this.Validated != null) {
this.Validated(sender, e);
}
this.isValidating = false;
}
protected virtual void OnValidating(object sender, CancelEventArgs e) {
if (this.Validating != null) {
this.Validating(sender, e);
}
}
public string this[string columnName]
{
get
{
if (messages != null && messages.ContainsKey(columnName))
{
return messages[columnName];
}
return string.Empty;
}
}
}
Remarque :
La classe ViewModel contient la liste des contrôles associés aux propriétés. Le but est de pouvoir faire le lien rapidement entre une Propriété et un Contrôle lorsqu’il faudra afficher les notifications d’erreur.
FormView
Voyons à présent la classe de base de notre vue : FormView. Le rôle de cette classe est de référencer le ViewModel, d’associer les DataBindings et de remonter les erreurs de validation.
Afin d’initialiser le Binding, nous allons créer une méthode virtuelle OnInitializeBinding qui sera appelée pendant le chargement de la vue (OnLoad).
public class FormView<TViewModel> : Form
where TViewModel : ViewModel
{
private ErrorProvider errorProvider;
public FormView(TViewModel viewModel) {
this.ViewModel = viewModel;
this.errorProvider = new ErrorProvider(this);
}
protected override void OnLoad(EventArgs e)
{
this.OnInitializeBinding();
this.ViewModel.Validated += new EventHandler(ViewModel_Validated);
base.OnLoad(e);
}
protected void ViewModel_Validated(object sender, EventArgs e)
{
this.ViewModel.AttachedControls.ToList().ForEach(c => this.errorProvider.SetError(c.Value as Control, ""));
if (!string.IsNullOrEmpty(this.ViewModel.Error)) {
this.ViewModel.Messages.ToList().ForEach(message => {
this.errorProvider.SetError(this.ViewModel.AttachedControls[message.Key] as Control, message.Value);
});
}
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
base.OnClosing(e);
this.ViewModel.Validated -= ViewModel_Validated;
this.errorProvider.Dispose();
}
public TViewModel ViewModel { get; private set; }
protected virtual void OnInitializeBinding() { }
}
La Vue s’abonne aussi à l’évènement Validated du ViewModel afin de mettre à jour les notifications d’erreurs. A la réception de l’évènement (dans ViewModel_Validated), on parcourt le dictionnaire contenant les messages d’erreurs et on affiche le message correspondant grâce à la méthode SetError de l’ErrorProvider.
Allons un peu plus loin !
Nous avons toutes les couches de notre pattern : le modèle, le ViewModel et la vue avec pour chacun leur classe de base respective. L’exemple que je vous ai montré lorsque je vous ai présenté la vue fonctionne. Toutefois, je trouve la syntaxe du binding trop approximative pour être efficace ! Je m’explique !
Ne trouvez-vous pas que la ligne suivante est dangereuse ?
firstNameTextBox.DataBindings.Add("Text", this.ViewModel, "FirstName");
Que se passerait-il si l’une des chaines “Text” et “FirstName” était incorrecte ? Et bien, on aurait le droit à une belle exception !
Je vous propose en échange cette écriture :
this.ViewModel.Bind(this.firstNameTextBox, t => t.Text, vm => vm.FirstName);
Ici, plus de raison de se tromper : tout est validé à la compilation et en plus vous bénéficiez de l’IntelliSense !
Bon, ceci n’est pas magique, il va encore falloir écrire un peu de code ! Créons donc une classe statique dans laquelle nous ajouterons une méthode d’extension.
public static class ViewModelExtensions
{
public static Binding Bind<TViewModel, TControl, T1, T2>(
this TViewModel viewModel,
TControl control,
Expression<Func<TControl, T1>> propertyName,
Expression<Func<TViewModel, T2>> dataMember,
bool formattingEnabled = false,
DataSourceUpdateMode updateMode = DataSourceUpdateMode.OnPropertyChanged,
bool autoValidate = true)
where TViewModel : ViewModel
where TControl : IBindableComponent
{
viewModel.AttachedControls.Add(GetPropertyName(dataMember), control);
if (autoValidate)
{
(control as Control).Validating += (s, e) => { viewModel.Validate(); };
}
return control.DataBindings.Add(
propertyName.GetPropertyName(),
viewModel,
dataMember.GetPropertyName(),
formattingEnabled,
updateMode);
}
private static string GetPropertyName<T1, T2>(this Expression<Func<T1, T2>> action)
{
var expression = (MemberExpression)action.Body;
var propertyName = expression.Member.Name;
return propertyName;
}
}
La méthode Bind ci-dessus prend beaucoup de monde en paramètre :
- viewModel : c’est le ViewModel sur lequel la méthode s’appliquera
- control : c’est le contrôle qu’il faudra relier à la donnée
- propertyName : est une expression Linq permettant de sélectionner la propriété du contrôle à relier
- dataMember : est une autre expression Linq permettant de sélectionner la propriété du ViewModel
- formattingEnabled : si true indique que le moteur de binding doit formater la donnée, par défaut la valeur est false
- updateMode : indique quand la valeur est mise à jour, par défaut OnPropertyChanged
- autoValidate : permet de déclencher la validation du modèle à chaque fois qu’une donnée est modifiée
Au début de la méthode, je remplis la collection AttachedControls avec chaque contrôle que je relie… puis, si autovalidate est égal true, je m’abonne à l’évènement Validating et je déclenche la validation du ViewModel.
Enfin, je déclenche le Binding du contrôle en lui fournissant les paramètres standards. Vous remarquez la méthode d’extension GetPropertyName qui retourne le nom de la propriété de l’expression.
La méthode OnInitializeBinding() est donc remplacée par :
protected override void OnInitializeBinding()
{
this.ViewModel.Bind(this.nameTextBox, t => t.Text, vm => vm.LastName);
this.ViewModel.Bind(this.firstNameTextBox, t => t.Text, vm => vm.FirstName);
this.ViewModel.Bind(this.emailTextBox, t => t.Text, vm => vm.Email);
this.ViewModel.Bind(this.birthdateDateTimePicker, t => t.Value, vm => vm.BirthDate);
}
Conclusion
Cet article a fait le point sur la mise en place du pattern MVVM pour Windows Forms… cependant, dans un prochain article je vous expliquerai comment ajouter à ce pattern un EventAggregator et un CommandManager.
En attendant, je vous propose de télécharger le modèle de projet sur CodePlex : https://wftoolkit.codeplex.com