站長阿川

站長阿川私房教材:
學 JavaScript 前端,帶作品集去面試!

站長精心設計,帶你實作 63 個小專案,得到作品集!

立即開始免費試讀!

這篇文章是WPFSheepCalendar第5天的文章。

依賴AI的無能程式員的大家,您好。我也是。
難以抵抗使用便利工具的誘惑😢

雖然隨便就叫自己「工程師」,但這樣的說法已經變成了日式英語。
在那邊可能根本無法理解。

那麼,這次是我個人總結的 針對C#程序員的有用文章參考列表(2012年~) 的系列文章第一彈!掌聲👏。

@matarillo 先生本人也是非常有實力的程式員。

雖然是2012年的相當舊文章,但在Qiita中找不到其他類似的文章!而且也沒有人按讚!明明是很棒的文章,真可惜。

因此,基於這篇文章,我們將會實作WPF的DataGrid的UndoRedo功能!

🎷非常感謝大家的讚和收藏!🎉

  • 超過3個收藏將實作以單元格編輯為單位的UndoRedo。
  • 超過5個收藏將發布WinUI3版或Winform版中的任意一個。(如果沒有指定,將隨心所欲決定)
  • 超過3個讚將僅在一年內發布GitHub的連結。

一年內沒有收到讚,這篇文章將會消失。

什麼是ConsCell?

※為了讓女性和羊也能理解,因此我會這樣寫!

ConsCell(cons cell)是編程語言Lisp及其衍生語言中使用的基本數據結構。簡單來說,它就像一個擁有兩個元素的「箱子」。這個箱子有兩個「插槽」,可以各自存放數據或其他ConsCell。

  • 左側的口袋(car):存放第一個數據
  • 右側的口袋(cdr):存放第二個數據
  • 女性視角:ConsCell就像是一個可以放配飾的珠寶盒。左邊放「耳環」,右邊放「項鍊」。右邊的口袋也可以放另一個珠寶盒,可以不斷串接下去!
  • 羊的視角:ConsCell就是決定羊吃草的順序列表。將「第一根草」放入左側口袋,而「下一根草的列表」放入右側口袋。這樣便可以一直串接記住吃草的順序!

如何使用?

ConsCell對於管理數據對或創建列表非常方便!

例如:

  • 建立樹狀結構
  • 按順序排列數據(列表)
  • 在程式中整理複雜的信息

也就是想要將這個機制應用於UndoRedo。

執行以下代碼會得到以下結果。

執行結果

ChangeGrid類

傳遞給DataGrid的Cell的輔助類

IList<Person> _itemsSource

是DataGrid的數據本體,並在Undo/Redo時負責元素的添加、刪除或屬性的更新。

ChangeGrid.cs

using System.Windows.Controls;
using WPF_UndoDataGrid;

/// <summary>
/// 表示對DataGrid的「一次更改」的類。
/// 
/// 【責任】
/// - 保存單元格的更改、行的添加或刪除等單位的「變更」
/// - 記錄修改前的值(OldValue)和修改後的值(NewValue)
/// - 保持特定單元格的DataGridCellInfo
/// - 擁有對ItemsSource(IList<Person>)的參考,以支持行的添加/刪除的Undo
/// - Revert()來撤銷更改(Undo)
/// - Apply()重新執行更改(Redo)
///
/// 【非責任】
/// - Undo/Redo的歷史管理(這由UndoManager負責)
/// - DataGrid的UI更新和繫結控制
/// - 多個變更的匯總(事務管理)
/// </summary>
public class ChangeGrid
{
    public DataGridCellInfo Cell { get; }
    public object? OldValue { get; }
    public object? NewValue { get; }

    public IList<Person> _itemsSource { get; set; }

    public ChangeGrid(DataGridCellInfo cell, object? oldValue, object? newValue, IList<Person> itemsorece)
    {
        Cell = cell;
        OldValue = oldValue;
        NewValue = newValue;

        _itemsSource = itemsorece;
    }

    /// <summary>
    /// 將新值應用到單元格
    /// 引發:Redo
    /// </summary>
    /// <exception cref="ArgumentNullException"></exception>
    public void Apply()
    {
        if (NewValue is null) throw new ArgumentNullException(nameof(NewValue));
        SetCellValue(Cell, (Person)NewValue);
    }

    // 將單元格恢復到原始值
    public void Revert()
    {
        if (OldValue == null)
        {
            if (NewValue is null)
                throw new Exception("NewValue is null");
            _itemsSource.Remove((Person)NewValue); // Undo行添加
        }
        else
        {
            SetCellValue(Cell, OldValue);
        }
    }

    /// <summary>
    /// 設置DataGrid的指定單元格的值的處理。
    /// 
    /// 【處理流程】
    /// 1. 從cellInfo獲取目標項目(Item)和列(Column)。
    /// 2. 如果列是DataGridBoundColumn,則提取其綁定信息。
    /// 3. 通過反射從Binding.Path中確定目標屬性。
    /// 4. 本來應該將值設置到該屬性上。
    ///
    /// 由於值被類型轉換為Person型,因此如果傳遞的值與單元格的類型不符(例如string或int),將會引發ArgumentException。
    ///
    /// 【改善點】
    /// - 本來應使用prop.SetValue(cellInfo.Item, value)將值賦給DataGrid綁定的屬性。
    /// - _itemsSource.Add(...)應該獨立為「行添加」的專用處理。
    /// </summary>
    public void SetCellValue(DataGridCellInfo cellInfo, object? value)
    {
        if (cellInfo.Item == null || cellInfo.Column == null)
            return;

        if (cellInfo.Column is DataGridBoundColumn boundColumn)
        {
            var binding = boundColumn.Binding as System.Windows.Data.Binding;
            if (binding == null) return;

            var prop = cellInfo.Item.GetType().GetProperty(binding.Path.Path);
            if (prop == null) return;

            if (value is null)
                throw new Exception("Value is null");

            _itemsSource.Add((Person)value);
        }
    }
}

ConsCell類

基本結構

  • 表示構成列表的最小單位的單元(節點)。
    保持head(元素)tail(下一個單元)isTerminal(是否為終端)

通過遞歸結構表示整個列表。

private readonly T head;
private readonly ConsCell<T> tail;
private readonly bool isTerminal;

主要方法

  • Push public ConsCell<T> Push(T head)
    返回一個新實例,其中包含在開頭添加的新元素。(原始列表保持不變)

  • Concat
    連接兩個列表,進行遞歸處理。

  • Contains
    逐一搜索元素。

  • Reverse
    構建新的反向列表。

  • GetEnumerator
    支持foreach迭代。

由於實現了ICollection<T>,因此可以使用foreach或Linq,使測試變得容易。
但會隨之帶來額外的繼承。

ConsCell.cs

/// <summary>
/// 表示遞歸定義的「鏈表(ConsList)」的類。
/// 
/// 【責任】
/// - 擁有首個元素(Head)和餘下列表(Tail)的單向列表的基本結構
/// - 區分空列表(終端單元)和有元素的單元
/// - 提供元素添加(Push)和連接(Concat)等操作
///
/// 【特徵】
/// - 不可變設計(不修改現有單元,創建新單元)
/// - 能夠判斷是否為空列表(IsEmpty)
/// - 易於用於堆疊/UndoRedo的歷史結構等
///
/// 【非責任】
/// - 標準集合的修改操作(Add、Clear、Remove未實現)
/// </summary>
public class ConsCell<T> : ICollection<T>
{
    private readonly T head;
    private readonly ConsCell<T>? tail;
    private readonly bool isTerminal;

    /// <summary>
    /// 創建空列表(終端單元)
    /// </summary>
    public ConsCell()
    {
        this.isTerminal = true;
    }

    /// <summary>
    /// 通過指定值和下一個單元來創建新的ConsCell
    /// </summary>
    public ConsCell(T value, ConsCell<T> tail)
    {
        this.head = value;
        this.tail = tail;
    }

    /// <summary>
    /// 通過IEnumerable構建ConsCell
    /// </summary>
    public ConsCell(IEnumerable<T> source) : this(EnsureNotNull(source).GetEnumerator())
    {
    }

    private static IEnumerable<T> EnsureNotNull(IEnumerable<T> source)
    {
        if (source == null)
            throw new ArgumentNullException(nameof(source));
        return source;
    }

    private ConsCell(IEnumerator<T> itor)
    {
        if (itor.MoveNext())
        {
            this.head = itor.Current;
            this.tail = new ConsCell<T>(itor);
        }
        else
        {
            this.isTerminal = true;
        }
    }

    /// <summary>
    /// 判斷是否為空列表
    /// </summary>
    public bool IsEmpty => this.isTerminal;

    /// <summary>
    /// 獲取首個元素(如果是空列表則引發例外)
    /// </summary>
    public T Head
    {
        get
        {
            ErrorIfEmpty();
            return this.head;
        }
    }

    /// <summary>
    /// 獲取餘下的列表(如果是空列表則引發例外)
    /// </summary>
    public ConsCell<T> Tail
    {
        get
        {
            ErrorIfEmpty();
            return this.tail!;
        }
    }

    private void ErrorIfEmpty()
    {
        if (this.isTerminal)
            throw new InvalidOperationException("this is empty.");
    }

    /// <summary>
    /// 將新元素添加到開頭,返回新的ConsCell
    /// </summary>
    public ConsCell<T> Push(T head) => new ConsCell<T>(head, this);

    /// <summary>
    /// 將另一個列表連接到這個列表的末尾
    /// </summary>
    public ConsCell<T> Concat(ConsCell<T> second)
    {
        if (this.isTerminal)
            return second;
        return this.tail!.Concat(second).Push(this.head);
    }

    /// <summary>
    /// 判斷是否包含某個元素
    /// </summary>
    public bool Contains(T item)
    {
        for (ConsCell<T> p = this; !p.isTerminal; p = p.tail!)
        {
            if (p.head == null && item == null) return true;
            if (p.head != null && p.head.Equals(item)) return true;
        }
        return false;
    }

    /// <summary>
    /// 返回元素數量
    /// </summary>
    public int Count
    {
        get
        {
            int c = 0;
            for (ConsCell<T> p = this; !p.isTerminal; p = p.tail!)
            {
                c++;
            }
            return c;
        }
    }

    /// <summary>
    /// 創建並返回逆序的ConsCell
    /// </summary>
    public ConsCell<T> Reverse()
    {
        ConsCell<T> rev = new ConsCell<T>();
        for (ConsCell<T> p = this; !p.isTerminal; p = p.tail!)
        {
            rev = rev.Push(p.head);
        }
        return rev;
    }

    /// <summary>
    /// 支持foreach
    /// </summary>
    public IEnumerator<T> GetEnumerator()
    {
        for (ConsCell<T> p = this; !p.isTerminal; p = p.tail!)
            yield return p.head;
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator();

    #region ICollection<T> 实现(只读)

    bool ICollection<T>.IsReadOnly => true;

    void ICollection<T>.CopyTo(T[] array, int arrayIndex)
    {
        for (ConsCell<T> p = this; !p.isTerminal; p = p.tail!)
        {
            if (array.Length <= arrayIndex)
                throw new ArgumentOutOfRangeException(nameof(arrayIndex));
            array[arrayIndex++] = p.head;
        }
    }

    void ICollection<T>.Add(T item) => throw new NotSupportedException();
    void ICollection<T>.Clear() => throw new NotSupportedException();
    bool ICollection<T>.Remove(T item) => throw new NotSupportedException();

    #endregion
}

UndoManager類

實作UndoRedo的類。

UndoManager.cs

public class UndoManager
{
    private ConsCell<ChangeGrid> undoStack = new ConsCell<ChangeGrid>();
    private ConsCell<ChangeGrid> redoStack = new ConsCell<ChangeGrid>();

    // 添加新的操作
    public void AddChange(ChangeGrid change)
    {
        undoStack = undoStack.Push(change);
        redoStack = new ConsCell<ChangeGrid>(); // 新操作會清空Redo
    }

    // Undo: 將最新的更改恢復
    public void Undo()
    {
        if (undoStack.IsEmpty)
            return;

        ChangeGrid change = undoStack.Head;
        change.Revert();
        undoStack = undoStack.Tail;
        redoStack = redoStack.Push(change);
    }

    // Redo: 重新應用Undo的更改
    public void Redo()
    {
        if (redoStack.IsEmpty)
            return;

        ChangeGrid change = redoStack.Head;
        change.Apply();
        redoStack = redoStack.Tail;
        undoStack = undoStack.Push(change);
    }
}

調用方法

  • 外觀(XAML)

直接複製整個<Grid>就可以了。

image.png

<Window x:Class="WPF_UndoDataGrid.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WPF_UndoDataGrid"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <DockPanel LastChildFill="True">
            <DataGrid DockPanel.Dock="Top" Height="300" x:Name="datagrid1"
                      AutoGenerateColumns="False" CanUserAddRows="False" ItemsSource="{Binding People}"
                      CellEditEnding="OnCellEditEnding">
                <DataGrid.Columns>
                    <DataGridTextColumn Header="Name" Binding="{Binding Name, UpdateSourceTrigger=LostFocus}" Width="2*"/>
                    <DataGridTextColumn Header="Age" Binding="{Binding Age,  UpdateSourceTrigger=LostFocus}" Width="*"/>
                    <DataGridTextColumn Header="City" Binding="{Binding City, UpdateSourceTrigger=LostFocus}" Width="2*"/>
                </DataGrid.Columns>
            </DataGrid>
            <StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
                <Button x:Name="AddButton" Content="Add" FontSize="30" Click="OnAddRowClick" Height="50" Width="200"/>
                <StackPanel>
                    <Button x:Name="UndoButton" Height="50" Width="200" Content="Undo" FontSize="30" Click="UndoButton_Click" DockPanel.Dock="Bottom"/>
                    <Button x:Name="RedoButton" Height="50" Width="200" Content="Redo" FontSize="30" Click="RedoButton_Click" DockPanel.Dock="Bottom"/>
                </StackPanel>
            </StackPanel>
        </DockPanel>
    </Grid>
</Window>
  • 代碼後台
    最麻煩的部分。

MainWindow.xaml.cs

using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using WPF_UndoDataGrid.classes;

namespace WPF_UndoDataGrid
{
    /// <summary>
    /// MainWindow.xaml的交互邏輯
    /// </summary>
    public partial class MainWindow : Window
    {
        // ---- Undo用的1條記錄 ----
        private readonly UndoManager _undoManager = new UndoManager();

        IList<Person> _itemsorce;

        public MainWindow()
        {
            InitializeComponent();

            _itemsorce = People;            
        }

        public ObservableCollection<Person> People { get; } = new()
        {
            new Person { Name = "Alice", Age = 28, City = "Tokyo" },
            new Person { Name = "Bob", Age = 34, City = "Osaka" },
            new Person { Name = "Cathy", Age = 22, City = "Nagoya" },
        };

        private void OnAddRowClick(object sender, RoutedEventArgs e)
        {
            var newPerson = RandomPerson();

            People.Add(newPerson);

            datagrid1.ItemsSource = People;

            // 創建變更(行添加時oldValue = null)
            var change = new ChangeGrid(
                new DataGridCellInfo(newPerson, datagrid1.Columns[0]),
                oldValue: null,
                newValue: newPerson,
                itemsorece: People // 傳遞IList
            );

            _undoManager.AddChange(change);
        }

        Person RandomPerson()
        {
            var random = new Random();
            string[] prefectures =
            {
                "東京", "大阪", "福岡", "北海道", "京都",
                "愛知", "沖縄", "広島", "宮城", "長野"
            };
            // 名字候選(「羊○○」)
            string[] nameSuffix =
            {
                "太郎", "花子", "次郎", "美咲", "健一", "真央", "翔", "未来", "一郎", "優子"
            };

            var people = new List<Person>();

            var person = new Person
            {
                Name = "羊" + nameSuffix[random.Next(nameSuffix.Length)],
                Age = random.Next(20, 41), // 20~40的範圍
                City = prefectures[random.Next(prefectures.Length)]
            };

            return person;
        }

        private void UndoButton_Click(object sender, RoutedEventArgs e)
        {
            _undoManager.Undo();
        }

        private void RedoButton_Click(object sender, RoutedEventArgs e)
        {
            _undoManager.Redo();
        }
    }
}

ConsCell的UndoRedo實作上優點

  • 每個操作必須生成新的ConsCell節點,因此已存在的歷史不會破壞。
  • 不容易發生「Undo/Redo過程中歷史破壞」或「引用被覆蓋」的典型bug。
  • 更易於調試和測試。
  • 如果像ChangeGrid這樣的差異數據處理類存在,則UndoRedo可直接實作,也就是說具有靈活性。
  • 無需專門為業務邏輯或UI創建「專用Undo類」,提升了代碼的重用性。
  • 除歷史管理外,還可以應用於「版本控制」「狀態轉移追蹤」等。
  • 每個操作增加的僅是「新節點+差異對象」。
  • 由於列表結構的共享,因此不需要「完整複製過去狀態」。
  • 特別是在GUI輸入歷史或業務應用程式的程度上,與基於List的Undo相比,內存效率並沒有太大差別。

命令模式型UndoRedo的例子

與命令模式型UndoRedo類的比較

比較項目 ConsCell(不可變) Command+雙向列表 (History.cs)
歷史的不變性 高(不會破壞Push中的節點) 可變(更新CurrentState)
分支歷史的表示 自然支持(可以通過結構表達分支) 更容易破壞現有歷史(單一路徑)
事務 自行實現(聚合設計不明確) TransactionCommand提供標準支持
Undo/Redo操作 基於堆棧操作。簡單但需要Redo用的堆棧 直接指針移動+即時執行
內存效率 通過結構共享相對輕量 可能因command對象過多而膨脹
實施成本 簡單(通用歷史結構) 設計完善且功能多樣,但伴隨複雜化

處理流程比較

基本流程幾乎相同
使用Mermaid繪製,請在PC版上查看。
在手機版上無法顯示。

  • ConsCell

<iframe id="qiita-embed-content__58420f26bb407a4324d05bf2de540d63"></iframe>

  • 命令模式型

<iframe id="qiita-embed-content__850eee49db3763731978180a3bc8dda9"></iframe>

相關文章

C#中實作Undo/Redo
命令模式的UndoRedo類在Git上可程式。

<iframe id="qiita-embed-content__03c018f2f88dc0b5bed1e262a1f3c90e"></iframe>

後記

這有點複雜。
ConsCell結構可能不太常見。

針對C#程序員的有用文章參考列表(2012年~)
我想寫這篇文章是因為認為從舊文章中閱讀能加深理解,但考慮到AI時代,這樣的再評價趨勢可能會加速。

非常感謝您閱讀到這裡。如您覺得不錯請按好評,若覺得不妥請按差評👇或者瀏覽器後退,並請訂閱我們的頻道!


原文出處:https://qiita.com/EndOfData/items/6966012efc2c8a11b953


共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
站長阿川

站長阿川私房教材:
學 JavaScript 前端,帶作品集去面試!

站長精心設計,帶你實作 63 個小專案,得到作品集!

立即開始免費試讀!