Propriétés, accesseurs, et évènements d''une classe (3/3) - Gérer le undo/redo dans les applications

Introduction

Gérer une historique des actions à effectuer peut s'avérer complexe si on ne s'y prend pas bien. Ce tutoriel propose une méthode de gestion des actions effectuées sur les objets. Concrètement : Comment on fait pour gérer le annuler/refaire dans une application.

Ce tutoriel est la suite d'une série dont vous pouvez retrouver les liens ici :
1ère partie
2e partie

Tutoriel

Pour rappel, notre cas d'étude est un logiciel de gestion de comptes personnels. Nous nous intéressons principalement à deux classes :

La classe Compte, qui contient entre autres une liste d'opération bancaires, et une solde (solde = somme des opérations bancaires),
La classe Operation, qui a pour propriétés un montant, une date, un libellé, ...

Dans ce tutoriel, je vais tenter de vous expliquer comment utiliser un design patern permettant de gérer les fonctions annuler/refaire d'un logiciel. Accrochez-vous, c'est parti...

Pour gérer les undo/redo, nous allons utiliser une classe singleton qui permettra de stocker les actions effectuées sur les objets. Pour rappel, un singleton est une classe qui ne peut être instanciée qu'une fois. Ce n'est pas le sujet de ce tutoriel, je vous conseille donc de demander à votre ami google ce qu'est un singleton si vous ne le savez pas.

Notre classe "HistoryManager" va permettre de stocker une pile (stack) pour les actions que l'on peut défaire, et une autre pour les actions que l'on peut défaire. Le principe, c'est que sur l'accesseur Montant de notre classe Operation, nous allons ajouter un appel vers notre HistoryManager pour ajouter une action signifiant que le montant a été changé.

Tout d'abord, le code (simplifié) de notre classe HistoryManager :

public sealed class HistoryManager
{
    private Stack<IHistoryModification> m_UndoModifications;
    private Stack<IHistoryModification> m_RedoModifications;
    private static HistoryManager m_SingleInstance = new HistoryManager();

    private HistoryManager()
    {
        m_UndoModifications = new Stack<IHistoryModification>();
        m_RedoModifications = new Stack<IHistoryModification>();
    }

    public static HistoryManager getInstance()
    {
        return m_SingleInstance;
    }

    public void Clear()
    {
        m_UndoModifications.Clear();
        m_RedoModifications.Clear();
    }

    public void AddCommand(IHistoryModification command)
    {
        [...]
    }

    public bool CanUndo
    {
        get { return m_UndoModifications.Count > 0; }
    }

    public bool CanRedo
    {
        get { return m_RedoModifications.Count > 0; }
    }

    public void Undo()
    {
        if (m_UndoModifications.Count == 0) return;

        IHistoryModification LastModification = m_UndoModifications.Pop();
        LastModification.Undo();
        m_RedoModifications.Push(LastModification);
    }

    public void Redo()
    {
        if (m_RedoModifications.Count == 0) return;

        IHistoryModification FutureModification = m_RedoModifications.Pop();
        FutureModification.Redo();
        m_UndoModifications.Push(FutureModification);
    }
}

Vous remarquerez que nos stacks stockent des objets implémentant l'interface IHistoryModification. Voici le code de cette interface :

public interface IHistoryModification
{
    void Redo();
    void Undo();
}

La gestion côté interface graphique est on ne peut plus simple, puisque sur le bouton annuler, il suffit d'appeler la méthode suivante :

HistoryManager.getInstance().Undo();

... Je vous laisse deviner l'instruction derrière le bouton redo

Nous allons maintenant implémenter une classe HistoryFieldModification qui permettra de stocker quelle classe a été modifiée, et quelle propriété a été impactée. Nous stockons aussi l'ancienne et la nouvelle valeur de la propriété :

public sealed class HistoryFieldModification : IHistoryModification
{
    private object m_ModifiedObject = null;

    private PropertyInfo m_ModifiedProperty = null;

    private object m_OldValue = null;

    private object m_NewValue = null;

    public object OldValue
    {
        get
        {
            return m_OldValue;
        }
    }

    public object NewValue
    {
        get
        {
            return m_NewValue;
        }
    }


    public object ModifiedObject
    {
        get
        {
            return m_ModifiedObject;
        }
    }

    public PropertyInfo ModifiedProperty
    {
        get
        {
            return m_ModifiedProperty;
        }
    }

    public HistoryFieldModification(object modifiedObject, PropertyInfo modifiedProperty, object oldValue, object newValue)
    {
        m_ModifiedObject = modifiedObject;
        m_ModifiedProperty = modifiedProperty;
        m_OldValue = oldValue;
        m_NewValue = newValue;
    }

    public void Redo()
    {
        m_ModifiedProperty.SetValue(m_ModifiedObject, m_NewValue, null);
    }

    public void Undo()
    {
        m_ModifiedProperty.SetValue(m_ModifiedObject, m_OldValue, null);
    }
}

L'accesseur de notre montant sur la classe Operation devra maintenant ressembler à ceci :

public Single Montant
{
    get { return m_montant; }
    set {
        Single oldValue = m_Value;
        m_montant = value;
        HistoryManager.getInstance().AddCommand(new HistoryFieldModification(this, this.GetType().GetProperty("Montant"), oldValue, value));
    }
}

La cinématique lorsque le montant d'une opération change :

  • La valeur du montant de l'opération est mise à jour,
  • Une action est ajoutée dans l'HistoryManager.

La cinématique en cas de clic sur le bouton annuler :

La dernière action est éjectée de la pile des actions que l'on peut défaire.
On appelle la méthode Undo sur l'objet HistoryFieldModification, ce qui a pour effet de remettre l'ancienne valeur pour notre montant d'opération.
L'action que nous venons de dépiler de notre stack "actions que l'on peut défaire" est empilée dans notre stack "actions que l'on peut refaire".

... Tout ceci est bien joli, mais n'est pas tout à fait juste...

En effet, lorsque l'on appelle le Undo sur HistoryFieldModification, nous appelons notre accesseur Montant de notre classe compte pour remettre l'ancienne valeur. Souvenez-vous : lorsque l'on appelle cet accesseur, nous ajoutons une entrée dans les actions undo. La cinématique n'est donc pas bonne.

Pour remédier à ce problème, nous devons bloquer les ajouts d'entrées dans le stack des actions à défaire lors d'une opération d'undo.

Je créé donc une nouvelle classe qui va me permettre d'appeler les méthodes LockAdd et UnLockAdd de mon objet HistoryManager (ces méthodes sont décrites plus loin) :

public class HistoryLock : IDisposable
{
    public HistoryLock()
    {
        HistoryManager.getInstance().LockAdd();
    }

    #region IDisposable Members

    public void Dispose()
    {
        HistoryManager.getInstance().UnLockAdd();
    }
    #endregion
}

Ceci signifie que dans mon HistoryManager, je dois avoir une variable privée :

private bool m_LockAdd = false;

Ceci signifie aussi que ma méthode AddCommand doit être modifiée pour ne pas enregistrer les nouvelles actions en cas de lock :

public void AddCommand(IHistoryModification command)
{
    if (m_LockAdd) return;

    IHistoryModification LastCommand = m_UndoModifications.Peek();
    if (LastCommand is HistoryFieldModification && ((HistoryFieldModification)LastCommand).ModifiedObject == ((HistoryFieldModification)command).ModifiedObject && ((HistoryFieldModification)LastCommand).ModifiedProperty == ((HistoryFieldModification)command).ModifiedProperty)
        m_UndoModifications.Pop();
    m_UndoModifications.Push(command);
    m_RedoModifications.Clear();
}

La dernière étape est d'implémenter ma gestion du lock lorsque l'on fait un undo :

public void Undo()
{
    if (m_UndoModifications.Count == 0) return;
    using (new HistoryLock())
    {
        IHistoryModification LastModification = m_UndoModifications.Pop();
        LastModification.Undo();
        m_RedoModifications.Push(LastModification);

        FireUndoRedoEvents();
        ChangesSinceLastRecord--;
    }
}

On aurait très bien pu se passer d'une nouvelle classe ici, mais l'avantage d'utiliser le HistoryLock, c'est que c'est un objet Disposable. Donc à la sortie de la méthode, lorsque l'objet est détruit, on sait que notre lock est automatiquement levé. Une gestion try/catch/finaly aurait eu le même effet... libre à vous de modifier cela si vous le souhaitez.

Cerise sur le gâteau, je vous donne le code complet de la gestion de l'historique, avec en plus des évènements spécifiant qu'un nouvel élément a été ajouté à la liste des actions à défaire/refaire. Si je mets des évènements ici, c'est purement pour l'interface graphique :

Au lancement, les boutons Annuler/Refaire sont grisés. Dès qu'une action est ajoutée à la liste des actions à défaire, un évènement est lancé par le HistoryManager. Il est rattrapé par l'interface graphique qui dégrise le bouton « défaire ». C'est le même principe pour le bouton « refaire ».

public sealed class HistoryManager
{
    private Stack<IHistoryModification> m_UndoModifications;
    private Stack<IHistoryModification> m_RedoModifications;
    private static HistoryManager m_SingleInstance = new HistoryManager();
    private bool m_LockAdd = false;
    public delegate void UndoUpdatedDelegate(HistoryUndoRedoActionEventArgs args);
    public delegate void RedoUpdatedDelegate(HistoryUndoRedoActionEventArgs args);

    public event UndoUpdatedDelegate UndoUpdated;
    public event RedoUpdatedDelegate RedoUpdated;

    public EventHandler ChangesSinceLastRecordUpdated;

    public int ChangesSinceLastRecord
    {
        set {
            if (m_ChangesSinceLastRecord != value)
            {
                m_ChangesSinceLastRecord = value;
                if (ChangesSinceLastRecordUpdated != null)
                    ChangesSinceLastRecordUpdated(this, EventArgs.Empty);
                }
            }
        get { return m_ChangesSinceLastRecord; }
    }

    private int m_ChangesSinceLastRecord = 0;

    private HistoryManager()
    {
        m_UndoModifications = new Stack<IHistoryModification>();
        m_RedoModifications = new Stack<IHistoryModification>();
    }

    public static HistoryManager getInstance()
    {
        return m_SingleInstance;
    }

    public void Clear()
    {
        m_UndoModifications.Clear();
        m_RedoModifications.Clear();
        ChangesSinceLastRecord = 0;
    }

    public void AddCommand(IHistoryModification command)
    {
        if (m_LockAdd) return;

        IHistoryModification LastCommand = m_UndoModifications.Peek();
        if (LastCommand is HistoryFieldModification &&
            ((HistoryFieldModification)LastCommand).ModifiedObject == ((HistoryFieldModification)command).ModifiedObject &&
            ((HistoryFieldModification)LastCommand).ModifiedProperty == ((HistoryFieldModification)command).ModifiedProperty)
            m_UndoModifications.Pop();
        m_UndoModifications.Push(command);
        m_RedoModifications.Clear();

        FireUndoRedoEvents();
        ChangesSinceLastRecord++;
    }

    public bool CanUndo
    {
        get { return m_UndoModifications.Count > 0; }
    }

    public bool CanRedo
    {
        get { return m_RedoModifications.Count > 0; }
    }

    private void FireUndoRedoEvents()
    {
        if (UndoUpdated != null)
        if (m_UndoModifications.Count > 0)
            UndoUpdated(new HistoryUndoRedoActionEventArgs(m_UndoModifications.Peek()));
        else
            UndoUpdated(new HistoryUndoRedoActionEventArgs(null));

        if (RedoUpdated != null)
            if (m_RedoModifications.Count > 0)
                RedoUpdated(new HistoryUndoRedoActionEventArgs(m_RedoModifications.Peek()));
            else
                RedoUpdated(new HistoryUndoRedoActionEventArgs(null));
    }

    public void Undo()
    {
        Debug.Assert(m_UndoModifications.Count != 0);
        if (m_UndoModifications.Count == 0) return;

        using (new HistoryLock())
        {
            IHistoryModification LastModification = m_UndoModifications.Pop();
            LastModification.Undo();
            m_RedoModifications.Push(LastModification);

            FireUndoRedoEvents();
            ChangesSinceLastRecord--;
        }
    }

    public void Redo()
    {
        Debug.Assert(m_RedoModifications.Count != 0);
        if (m_RedoModifications.Count == 0) return;

        using (new HistoryLock())
        {
            IHistoryModification FutureModification = m_RedoModifications.Pop();
            FutureModification.Redo();
            m_UndoModifications.Push(FutureModification);

            FireUndoRedoEvents();
            ChangesSinceLastRecord++;
        }
    }

    public void LockAdd()
    {
        m_LockAdd = true;
    }

    public void UnLockAdd()
    {
        m_LockAdd = false;
    }
}

Et comme pour le tutoriel précédent, je vous propose le code de mon accesseur sur le montant dans la classe Operation, avec les propositions présentées dans les tutoriels précédents :

public Single Montant
{
    get { return m_montant; }
    set {
        if(m_montant == value) return;
        if (LaunchValueChangingEvent(value, m_montant))
        {
            Single oldValue = m_Value;
            m_montant = value;
            LaunchValueChangedEvent(value, oldValue);
            HistoryManager.getInstance().AddCommand(new HistoryFieldModification(this, this.GetType().GetProperty("Montant"), oldValue, value));
        }
    }
}

Pour information, je ne remets pas les méthodes "LaunchValueChangingEvent" et "LaunchValueChangedEvent", ni les évènements associés; ils sont décrits dans les tutoriels précédents.

Merci d'avoir lu ces tutoriels, et bon coding à tous !

A voir également
Ce document intitulé « Propriétés, accesseurs, et évènements d''une classe (3/3) - Gérer le undo/redo dans les applications » issu de CodeS SourceS (codes-sources.commentcamarche.net) est mis à disposition sous les termes de la licence Creative Commons. Vous pouvez copier, modifier des copies de cette page, dans les conditions fixées par la licence, tant que cette note apparaît clairement.
Rejoignez-nous