回文章列表
C#16 分鐘閱讀2026年7月23日

C# MVP 架構(下):把商品管理程式重構成 Model-View-Presenter

#C##MVP#WinForms#架構#單元測試#xUnit

文章目錄

上一篇我們做了一支能動的商品管理程式,然後指出它的四個問題:不能測試、換 UI 要重寫、功能一多就爆炸、多人協作必衝突。根源是同一個——MainForm 同時扮演了畫面、資料、業務規則三種角色。

這篇把它拆開。功能完全不變,跑起來一模一樣,但結束時你會得到一件上一篇做不到的事:在完全不開啟視窗的情況下,用程式驗證「價格輸入負數會被擋下來」。

想邊看邊對照的話,完整原始碼可以先抓下來:👉 下載 MvpDemo 完整原始碼(.zip)。我已經實際解壓縮到另一個資料夾重新建置並跑過測試,確認下載就能用;文章最後也會再放一次連結與執行說明。

先看完成後的樣子

專案結構長這樣,三個專案:

MvpDemo/
├── MvpDemo.slnx                       方案檔
├── MvpDemo.Core/                      Model + Presenter(不含任何 UI)
│   ├── Models/
│   │   ├── Product.cs                 一筆商品
│   │   ├── AddResult.cs               新增結果(成功/失敗+原因)
│   │   └── ProductService.cs          驗證規則、儲存、統計
│   ├── Views/
│   │   └── IProductView.cs            畫面必須提供的能力
│   └── Presenters/
│       └── ProductPresenter.cs        把流程串起來
├── MvpDemo.App/                       WinForms 視窗程式
│   ├── MainForm.cs                    實作 IProductView
│   └── Program.cs                     組裝三個角色
└── MvpDemo.Tests/                     xUnit 測試
    ├── FakeProductView.cs             假的畫面
    └── ProductPresenterTests.cs       六個測試

上一篇說會拆成四個檔案,這裡多做了一步:把 Model 和 Presenter 放進一個獨立的類別庫 MvpDemo.Core。

這一步值得解釋,因為它是整篇最有力的部分。看一下三個專案的 target framework:

<!-- MvpDemo.Core.csproj -->
<TargetFramework>net10.0</TargetFramework>

<!-- MvpDemo.App.csproj -->
<TargetFramework>net10.0-windows</TargetFramework>

<!-- MvpDemo.Tests.csproj -->
<TargetFramework>net10.0</TargetFramework>

MvpDemo.Core 是 net10.0,不是 net10.0-windows——它在編譯層級就無法引用 WinForms。你就算想在 ProductService 裡偷寫一行 MessageBox.Show,編譯器也會直接拒絕你。

這把「Model 不該知道畫面存在」從一句口號變成編譯器強制執行的規則。同樣的道理,測試專案也是 net10.0——它連 WinForms 都沒引用,卻能完整測試你的業務邏輯。這就是分層真正的價值。

建立方式(在空資料夾裡依序執行):

dotnet new sln -n MvpDemo
dotnet new classlib -o MvpDemo.Core -n MvpDemo.Core
dotnet new winforms -o MvpDemo.App -n MvpDemo.App
dotnet new xunit -o MvpDemo.Tests -n MvpDemo.Tests
dotnet sln add MvpDemo.Core MvpDemo.App MvpDemo.Tests
cd MvpDemo.App && dotnet add reference ../MvpDemo.Core && cd ..
cd MvpDemo.Tests && dotnet add reference ../MvpDemo.Core && cd ..

注意箭頭都是單向的:App 和 Tests 都依賴 Core,Core 誰也不依賴。這個方向不能反過來——Core 一旦引用了 App,整個設計就白做了。

Model 第一層:Product 與 AddResult

先是最單純的一筆商品資料:

namespace MvpDemo.Core.Models;

public class Product
{
    public string Name { get; }
    public int Price { get; }

    public Product(string name, int price)
    {
        Name = name;
        Price = price;
    }
}

兩個屬性都只有 get 沒有 set,只能在建構式裡指定。這叫唯讀(immutable)——建立之後就不能改,要改就建立新的一筆。好處是你永遠不必擔心「這筆商品在別的地方被偷偷改掉了」,追 bug 時可以少排除一整類可能性。

接著是一個很多人不會想到要建立的類別:用來表達「新增成功或失敗」的結果。

namespace MvpDemo.Core.Models;

public class AddResult
{
    public bool Success { get; }
    public string ErrorMessage { get; }

    private AddResult(bool success, string errorMessage)
    {
        Success = success;
        ErrorMessage = errorMessage;
    }

    public static AddResult Ok() => new AddResult(true, string.Empty);

    public static AddResult Fail(string message) => new AddResult(false, message);
}

為什麼需要它?因為 ProductService.Add() 必須回答兩件事:「成功了嗎」以及「如果失敗,是為什麼」。

你可能會想用其他做法,但都有問題。回傳 bool 只知道成功與否,不知道原因;直接在 Service 裡 MessageBox.Show 就違反了「Model 不碰 UI」;丟 Exception 也不對——使用者打錯字是正常流程的一部分,不是程式出錯,例外應該留給真正意外的狀況。

建構式設成 private、只能透過 AddResult.Ok() 和 AddResult.Fail("...") 建立,是為了讓呼叫端讀起來像句子,也避免有人建立出 Success = true 卻帶著錯誤訊息這種矛盾的東西。

Model 第二層:ProductService

這是整支程式的心臟——所有業務規則集中在這裡:

namespace MvpDemo.Core.Models;

public class ProductService
{
    private readonly List<Product> _products = new List<Product>();

    public IReadOnlyList<Product> Products => _products;

    public int Count => _products.Count;

    public int TotalPrice
    {
        get
        {
            int total = 0;
            foreach (Product p in _products)
            {
                total += p.Price;
            }
            return total;
        }
    }

    public AddResult Add(string name, string priceText)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return AddResult.Fail("請輸入商品名稱");
        }

        if (!int.TryParse(priceText, out int price))
        {
            return AddResult.Fail("價格必須是數字");
        }

        if (price <= 0)
        {
            return AddResult.Fail("價格必須大於 0");
        }

        foreach (Product p in _products)
        {
            if (p.Name == name)
            {
                return AddResult.Fail("這個商品已經存在");
            }
        }

        _products.Add(new Product(name, price));
        return AddResult.Ok();
    }

    public bool Remove(string name)
    {
        for (int i = 0; i < _products.Count; i++)
        {
            if (_products[i].Name == name)
            {
                _products.RemoveAt(i);
                return true;
            }
        }
        return false;
    }
}

拿它跟上一篇的 BtnAdd_Click 對照,你會發現四條驗證規則一模一樣——只是從按鈕事件裡搬了出來,而且每一條失敗都回傳原因而不是跳訊息框。

三個地方值得說明。

Products 的型別是 IReadOnlyList<Product> 而不是 List<Product>。這是刻意的:外面拿得到清單來讀取,但不能繞過 Add() 直接塞東西進去。如果開放成 List,別人就能寫 service.Products.Add(new Product("", -999)),你的四條規則就形同虛設。

TotalPrice 寫成唯讀屬性而不是欄位,所以它永遠是「當下算出來的」,不會有「加了商品但忘了更新總價」這種不同步的 bug。上一篇那份程式碼裡,總價是在按鈕事件裡臨時算的——你如果加了「刪除商品」功能而忘記重算,畫面就會顯示錯誤的總價,而且沒有任何警告。

Add() 的價格參數收 string 而不是 int,這點可能有點反直覺。理由是「使用者打的不是數字」本身就是一條需要驗證的規則,如果 Service 只收 int,那 int.TryParse 就得寫在 Presenter 或 View 裡——規則就散開了。全部收字串,四條規則才能真的集中在一個地方,也才能一起被測試。

Remove() 這篇還沒用到,先留著給你做文章最後的練習。

View 介面:Presenter 眼中的畫面

namespace MvpDemo.Core.Views;

public interface IProductView
{
    string ProductNameText { get; }
    string PriceText { get; }

    event EventHandler AddClicked;

    void AddProductToList(string display);
    void ShowSummary(string text);
    void ShowError(string message);
    void ClearInputs();
}

這個介面是整個 MVP 的樞紐,請仔細看它「沒有」什麼:沒有 TextBox、沒有 ListBox、沒有 Label、沒有 MessageBox。它描述的全是能力——「能提供使用者輸入的名稱」「能顯示一則錯誤」——而不是長相。

正因為只描述能力,任何東西都能實作它。WinForms 的 MainForm 可以,等一下測試裡的 FakeProductView 也可以;未來如果改用 WPF、MAUI、甚至做成主控台程式,也都只要實作這七個成員就好,Presenter 一行都不用改。

AddClicked 是 event,這是方向的關鍵:事件從 View 往外送,指令從 Presenter 往內下。View 不知道誰在聽這個事件,它只負責喊一聲。

Presenter:把流程串起來

using MvpDemo.Core.Models;
using MvpDemo.Core.Views;

namespace MvpDemo.Core.Presenters;

public class ProductPresenter
{
    private readonly IProductView _view;
    private readonly ProductService _service;

    public ProductPresenter(IProductView view, ProductService service)
    {
        _view = view;
        _service = service;

        _view.AddClicked += OnAddClicked;

        _view.ShowSummary(BuildSummary());
    }

    private void OnAddClicked(object? sender, EventArgs e)
    {
        AddProduct();
    }

    public void AddProduct()
    {
        AddResult result = _service.Add(_view.ProductNameText, _view.PriceText);

        if (!result.Success)
        {
            _view.ShowError(result.ErrorMessage);
            return;
        }

        Product added = _service.Products[_service.Count - 1];
        _view.AddProductToList($"{added.Name}  {added.Price} 元");
        _view.ShowSummary(BuildSummary());
        _view.ClearInputs();
    }

    private string BuildSummary()
    {
        return $"共 {_service.Count} 項,總價 {_service.TotalPrice} 元";
    }
}

Presenter 的兩個欄位型別很值得注意:IProductView(介面)和 ProductService(實體類別)。它依賴的是介面而不是 MainForm,所以它根本不知道自己正在跟一個 WinForms 視窗說話——這正是等一下能塞假畫面進去測試的原因。

建構式做兩件事:訂閱 View 的 AddClicked 事件,然後立刻叫 View 顯示一次初始統計(「共 0 項,總價 0 元」)。上一篇那份程式碼是把這行初始文字寫死在 Form 裡的,現在改由 Presenter 提供——畫面上所有會變的文字都來自 Presenter,Form 裡不再有任何寫死的狀態。

AddProduct() 設成 public 而不是 private,是為了讓測試能直接呼叫,不必為了測試而去模擬點擊按鈕。這是一個實用的小取捨。

整個方法讀起來就是一句話:問 Model 能不能加 → 不能就叫 View 顯示錯誤 → 能就叫 View 更新三個地方。沒有任何判斷邏輯,判斷全在 Model;沒有任何控制項操作,操作全在 View。

View 實作:MainForm 瘦下來了

using MvpDemo.Core.Views;

namespace MvpDemo.App;

public class MainForm : Form, IProductView
{
    private readonly TextBox txtName = new TextBox();
    private readonly TextBox txtPrice = new TextBox();
    private readonly Button btnAdd = new Button();
    private readonly ListBox lstItems = new ListBox();
    private readonly Label lblSummary = new Label();

    public MainForm()
    {
        Text = "商品管理(MVP 版)";
        ClientSize = new Size(380, 330);

        var lblName = new Label { Text = "商品名稱", Location = new Point(20, 20), AutoSize = true };
        txtName.Location = new Point(100, 17);
        txtName.Width = 250;

        var lblPrice = new Label { Text = "價格", Location = new Point(20, 55), AutoSize = true };
        txtPrice.Location = new Point(100, 52);
        txtPrice.Width = 250;

        btnAdd.Text = "新增";
        btnAdd.Location = new Point(100, 88);
        btnAdd.Width = 250;
        btnAdd.Click += (s, e) => AddClicked?.Invoke(this, EventArgs.Empty);

        lstItems.Location = new Point(20, 130);
        lstItems.Size = new Size(330, 130);

        lblSummary.Location = new Point(20, 275);
        lblSummary.AutoSize = true;

        Controls.AddRange(new Control[] { lblName, txtName, lblPrice, txtPrice, btnAdd, lstItems, lblSummary });
    }

    // ---- IProductView 實作 ----

    public string ProductNameText => txtName.Text;

    public string PriceText => txtPrice.Text;

    public event EventHandler? AddClicked;

    public void AddProductToList(string display) => lstItems.Items.Add(display);

    public void ShowSummary(string text) => lblSummary.Text = text;

    public void ShowError(string message) => MessageBox.Show(message);

    public void ClearInputs()
    {
        txtName.Clear();
        txtPrice.Clear();
        txtName.Focus();
    }
}

上一篇塞了五件事、三十幾行的 BtnAdd_Click,現在變成這一行:

btnAdd.Click += (s, e) => AddClicked?.Invoke(this, EventArgs.Empty);

「按鈕被按了,我喊一聲」——就這樣,不判斷任何事。

IProductView 的實作也全是一行方法:AddProductToList 就是 lstItems.Items.Add(...),ShowError 就是 MessageBox.Show(...)。整個檔案裡找不到任何一條業務規則——你搜尋「價格」「必須」「大於」都搜不到。這就是目標達成的樣子。

?.Invoke 的問號不能省。如果沒有人訂閱 AddClicked(例如你忘了建立 Presenter),AddClicked 會是 null,直接呼叫就會炸掉。

一個真實踩到的雷:屬性名稱撞到 Control

我第一次寫這個介面時,屬性名稱取的是 ProductName,編譯出現警告:

warning CS0108: 'MainForm.ProductName' 會隱藏繼承的成員 'Control.ProductName'。
若本意即為要隱藏,請使用 new 關鍵字。

原來 Control 本身就有一個 ProductName 屬性(回傳組件的產品名稱)。MainForm 繼承自 Form、Form 繼承自 Control,所以撞名了。

程式仍然能跑,但這種隱藏是純粹的地雷——哪天有人透過 Control 型別的變數去讀 ProductName,拿到的會是組件資訊而不是使用者輸入的商品名稱,而且不會有任何錯誤。我把它改成 ProductNameText 就乾淨了。

這是 View 繼承自框架類別時的通用陷阱:你的介面成員名稱可能跟框架既有成員撞到。遇到 CS0108 警告不要無視,改個名字最省事。

組裝:Program.cs

三個角色都寫好了,總要有人把它們接起來:

using MvpDemo.Core.Models;
using MvpDemo.Core.Presenters;

namespace MvpDemo.App;

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();

        var view = new MainForm();
        var service = new ProductService();
        _ = new ProductPresenter(view, service);

        Application.Run(view);
    }
}

建立 View、建立 Model,把兩個交給 Presenter。Presenter 的建構式會自動訂閱事件,之後整個程式就靠事件運轉,不需要有人再呼叫它。

_ = new ProductPresenter(...) 的底線是「我知道我沒有把它存進變數,這是故意的」。你也可以寫成 var presenter = new ProductPresenter(view, service);,但那會讓編譯器提示變數未使用。Presenter 被 View 的事件參考著,不會被垃圾回收。

跑起來是這樣:

MVP 版的商品管理程式:三筆商品,共 3 項、總價 7640 元,與重構前完全相同
MVP 版的商品管理程式:三筆商品,共 3 項、總價 7640 元,與重構前完全相同

跟上一篇那張圖對照——除了標題多了「(MVP 版)」,畫面與數字完全一樣。使用者感受不到任何差別,這正是重構的定義:改變結構,不改變行為。

現在來寫測試——完全不開視窗

這是整個系列的重點。先做一個假的畫面:

using MvpDemo.Core.Views;

namespace MvpDemo.Tests;

public class FakeProductView : IProductView
{
    public string ProductNameText { get; set; } = string.Empty;
    public string PriceText { get; set; } = string.Empty;

    public event EventHandler? AddClicked;

    public List<string> ListItems { get; } = new List<string>();
    public List<string> Errors { get; } = new List<string>();
    public string Summary { get; private set; } = string.Empty;
    public int ClearInputsCallCount { get; private set; }

    public void AddProductToList(string display) => ListItems.Add(display);

    public void ShowSummary(string text) => Summary = text;

    public void ShowError(string message) => Errors.Add(message);

    public void ClearInputs() => ClearInputsCallCount++;

    public void ClickAdd() => AddClicked?.Invoke(this, EventArgs.Empty);
}

它實作了 IProductView,所以在 Presenter 眼中它就是一個合法的畫面——但它不是視窗,它是一台錄音機。「顯示錯誤」對它來說就是把訊息記進 Errors 清單,「清空欄位」就是把計數器加一。這樣測試就能事後檢查 Presenter 到底對畫面下了哪些指令。

兩個輸入屬性有 set(介面只要求 get,實作可以多給),這樣測試才能「假裝使用者打了什麼字」。ClickAdd() 則是「假裝使用者按了按鈕」。

接著是測試本體:

using MvpDemo.Core.Models;
using MvpDemo.Core.Presenters;

namespace MvpDemo.Tests;

public class ProductPresenterTests
{
    private static (FakeProductView view, ProductPresenter presenter) Build()
    {
        var view = new FakeProductView();
        var presenter = new ProductPresenter(view, new ProductService());
        return (view, presenter);
    }

    [Fact]
    public void 價格是負數_應該顯示錯誤而且不加進清單()
    {
        var (view, _) = Build();
        view.ProductNameText = "無線滑鼠";
        view.PriceText = "-450";

        view.ClickAdd();

        Assert.Equal("價格必須大於 0", Assert.Single(view.Errors));
        Assert.Empty(view.ListItems);
    }

    [Fact]
    public void 價格不是數字_應該顯示錯誤()
    {
        var (view, _) = Build();
        view.ProductNameText = "無線滑鼠";
        view.PriceText = "四百五";

        view.ClickAdd();

        Assert.Equal("價格必須是數字", Assert.Single(view.Errors));
        Assert.Empty(view.ListItems);
    }

    [Fact]
    public void 名稱空白_應該顯示錯誤()
    {
        var (view, _) = Build();
        view.ProductNameText = "   ";
        view.PriceText = "450";

        view.ClickAdd();

        Assert.Equal("請輸入商品名稱", Assert.Single(view.Errors));
    }

    [Fact]
    public void 輸入合法_應該加進清單並清空欄位()
    {
        var (view, _) = Build();
        view.ProductNameText = "無線滑鼠";
        view.PriceText = "450";

        view.ClickAdd();

        Assert.Empty(view.Errors);
        Assert.Equal("無線滑鼠  450 元", Assert.Single(view.ListItems));
        Assert.Equal(1, view.ClearInputsCallCount);
    }

    [Fact]
    public void 新增多筆_統計文字應該正確累加()
    {
        var (view, _) = Build();

        view.ProductNameText = "無線滑鼠";
        view.PriceText = "450";
        view.ClickAdd();

        view.ProductNameText = "機械鍵盤";
        view.PriceText = "1200";
        view.ClickAdd();

        view.ProductNameText = "27吋螢幕";
        view.PriceText = "5990";
        view.ClickAdd();

        Assert.Equal(3, view.ListItems.Count);
        Assert.Equal("共 3 項,總價 7640 元", view.Summary);
    }

    [Fact]
    public void 同名商品_第二次應該被擋下來()
    {
        var (view, _) = Build();

        view.ProductNameText = "無線滑鼠";
        view.PriceText = "450";
        view.ClickAdd();

        view.ProductNameText = "無線滑鼠";
        view.PriceText = "999";
        view.ClickAdd();

        Assert.Equal("這個商品已經存在", Assert.Single(view.Errors));
        Assert.Single(view.ListItems);
    }
}

每個測試都是同樣的三段式:準備(設定假輸入)→ 動作(ClickAdd())→ 驗證(檢查 Presenter 對畫面做了什麼)。這在測試領域叫 Arrange-Act-Assert,是最常見的寫法。

方法名稱用中文加底線是刻意的——測試名稱的讀者是人不是編譯器,看到「價格是負數_應該顯示錯誤而且不加進清單」就知道這條在保護什麼規則,失敗時報表上顯示的也是這行字。

在方案根目錄執行:

dotnet test
已通過! - 失敗:     0,通過:     6,略過:     0,總計:     6,持續時間: 94 ms

94 毫秒,六條規則全部驗證完畢,沒有任何視窗被打開。

回想上一篇的做法:要驗證這六件事,你得手動開啟程式六次、打六組不同的字、看六次訊息框。現在按一次指令就好,而且以後每次改動都能重跑——這才是「可測試」的實際意義。

前後對照:到底換到了什麼

回頭檢查上一篇提出的四個問題。

能測試了。 剛才那六個測試就是答案。而且注意測試專案的 target framework 是 net10.0——它完全沒有引用 WinForms,卻能測試你所有的業務規則。

換 UI 不用重寫。 今天要做網頁版,MvpDemo.Core 整包搬過去就能用,一行都不用改,因為它裡面沒有任何 WinForms 的痕跡。要重寫的只有 MainForm——那本來就該重寫,因為畫面本來就不一樣。

功能變多不會爆炸。 加「刪除商品」時,規則寫進 ProductService.Remove()(已經幫你留好了),流程寫進 ProductPresenter,畫面加一個按鈕與一個 RemoveProductFromList 方法。三件事各自待在自己該在的地方,MainForm 不會再膨脹。

協作衝突變少。 做畫面的人改 MvpDemo.App,做邏輯的人改 MvpDemo.Core,兩邊碰的是不同檔案甚至不同專案。

代價也要誠實講:檔案從 1 個變成 8 個,程式碼總行數變多了。 對一支只有「新增」功能的程式來說,MVP 確實是殺雞用牛刀——如果你要寫的只是一個五分鐘的小工具,全部塞在 Form 裡沒有錯,不必為了架構而架構。

MVP 值得的時機是:這支程式會活很久、會一直加功能、會有人接手,或者你希望邏輯能被測試。 三個條件中一個成立,拆分就開始回本。

動手練習:加上「刪除商品」

ProductService.Remove() 已經幫你寫好了。你需要動的是另外三個地方:

1. IProductView:加一個 SelectedProductName(使用者在清單裡選了哪一項)、一個 RemoveClicked 事件、一個 RemoveProductFromList(string display) 方法 2. ProductPresenter:訂閱 RemoveClicked,呼叫 _service.Remove(...),成功就叫 View 移除該項並更新統計,失敗就顯示錯誤 3. MainForm:加一個「刪除」按鈕、實作那三個新成員

寫完之後,先別急著開視窗測試——在 FakeProductView 加上對應的假實作,然後寫一個測試:

[Fact]
public void 刪除商品_統計應該正確減少()
{
    // 新增兩筆、刪除一筆,驗證 Summary 變成「共 1 項,總價 ... 元」
}

如果這個測試過了,你的邏輯就是對的,開視窗只是確認按鈕位置有沒有擺好。這個「先測試、後開視窗」的順序,就是這兩篇文章想帶你抵達的地方。

下載完整程式碼

上面所有程式碼都在這個壓縮檔裡,13 個檔案、三個專案:

👉 下載 MvpDemo 完整原始碼(.zip)

解壓縮之後:

cd MvpDemo
dotnet build       # 建置三個專案
dotnet test        # 跑六個測試
cd MvpDemo.App
dotnet run         # 開啟視窗

需要 .NET SDK 10(用 dotnet --version 確認)。這份程式碼我實際解壓縮到另一個乾淨的資料夾重新建置過,dotnet build 零錯誤零警告、dotnet test 六個測試全數通過,確認下載後可以直接使用。

如果你是從上一篇一路看下來的,建議把兩個版本並排打開比較——同樣的功能、同樣的畫面,一個是三十行擠在按鈕事件裡,一個是八個檔案各司其職。哪一種比較好,取決於這支程式接下來要活多久。

相關課程

C# 物件導向與桌面程式

練透物件導向四大支柱:類別與物件、封裝、繼承與多型、介面,再進入 WinForms 桌面應用開發,學會用控件與 DataGridView 打造圖形化介面,最後補上檔案讀寫與 CSV 存讀,讓程式能保存資料。

查看課程內容