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

C# MVP 架構(上):為什麼不要把程式全部寫在 Form 裡

#C##MVP#WinForms#架構#設計模式

文章目錄

WinForms 學到會拉控制項之後,大部分人的寫法都是這樣:拖一個按鈕、按兩下、在 button1_Click 裡把事情做完。程式能跑,作業能交,看起來沒什麼問題。

問題會在三個月後出現——當這支程式長到八百行,你想加一個「刪除商品」功能,卻發現不知道該從哪裡下手;或是老闆說「這個功能改做成網頁版」,你才發現所有邏輯都黏在 Form 上,一行都搬不走。

這篇先不談 MVP 怎麼寫。我們先做一支確實能動的程式,然後誠實地看它有什麼毛病——理解問題之後,MVP 的每個設計才會變得理所當然。下一篇再把這支程式重構成 MVP。

先做一個能動的版本

一支商品管理小程式:輸入商品名稱與價格,按「新增」加入清單,下方即時顯示總數量與總價。

為了讓你可以直接複製執行,這裡不使用設計工具,控制項全部用程式碼建立。新增一個 WinForms 專案,把 Program.cs 換成下面這段就能跑:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

public class MainForm : Form
{
    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();

    // 資料就直接放在畫面裡
    private readonly List<string> names = new List<string>();
    private readonly List<int> prices = new List<int>();

    public MainForm()
    {
        Text = "商品管理";
        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 += BtnAdd_Click;

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

        lblSummary.Location = new Point(20, 275);
        lblSummary.AutoSize = true;
        lblSummary.Text = "共 0 項,總價 0 元";

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

    private void BtnAdd_Click(object? sender, EventArgs e)
    {
        // 驗證輸入
        if (string.IsNullOrWhiteSpace(txtName.Text))
        {
            MessageBox.Show("請輸入商品名稱");
            return;
        }
        if (!int.TryParse(txtPrice.Text, out int price))
        {
            MessageBox.Show("價格必須是數字");
            return;
        }
        if (price <= 0)
        {
            MessageBox.Show("價格必須大於 0");
            return;
        }
        if (names.Contains(txtName.Text))
        {
            MessageBox.Show("這個商品已經存在");
            return;
        }

        // 存資料
        names.Add(txtName.Text);
        prices.Add(price);

        // 更新畫面
        lstItems.Items.Add($"{txtName.Text}  {price} 元");

        int total = 0;
        foreach (int p in prices)
        {
            total += p;
        }
        lblSummary.Text = $"共 {names.Count} 項,總價 {total} 元";

        txtName.Clear();
        txtPrice.Clear();
        txtName.Focus();
    }
}

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();
        Application.Run(new MainForm());
    }
}

依序輸入無線滑鼠 450、機械鍵盤 1200、27吋螢幕 5990,跑出來是這樣:

全部寫在 Form 裡的商品管理程式:三筆商品,下方顯示共 3 項、總價 7640 元
全部寫在 Form 裡的商品管理程式:三筆商品,下方顯示共 3 項、總價 7640 元

這支程式沒有任何 bug,功能完全正確。請先記住這一點——接下來要談的問題,跟「程式對不對」無關。

這支程式現在有什麼問題

打開 BtnAdd_Click,數一下它做了幾件事:

1. 從畫面控制項讀取使用者輸入 2. 驗證輸入(四種規則) 3. 把資料存進 List 4. 計算總價 5. 更新三個控制項的畫面

五件本質完全不同的事,全部擠在同一個方法裡。 這帶來四個具體的麻煩——不是「不夠優雅」這種抽象說法,是每一項都會真的咬到你。

第一,你沒辦法測試它。 「價格是負數要被擋下來」這條規則,你要怎麼寫自動化測試?做不到。那段判斷寫在一個按鈕的事件處理裡,要執行它就得先開起視窗、找到那個按鈕、模擬點擊。實務上大家的做法是「手動點一次看看」,然後每次改動都重點一次——功能一多,就沒有人真的每次都點完。

第二,換 UI 等於整份重寫。 老闆說要做成網頁版,或是你想改用 WPF、MAUI。理論上「驗證價格是不是正整數」「加總所有商品價格」這些規則跟畫面完全無關,應該可以原封不動搬過去。但實際上它們和 txtPrice.Text、lblSummary.Text、MessageBox.Show 綁死了,離開 WinForms 一行都活不了。

第三,功能一多就爆炸。 現在只有「新增」。加上刪除、修改、查詢、排序、存檔、讀檔之後,這個 Form 會有七八個事件處理方法,每個都同時在碰畫面和資料。到那時候你想改「總價的計算方式」,得先找出所有算過總價的地方——它們散落在不同的按鈕事件裡。

第四,多人協作一定衝突。 兩個人分別做「新增」和「刪除」,改的是同一個 Form1.cs,Git 合併時撞在一起。而且因為畫面和邏輯混在一起,衝突的內容會很難判斷該留哪一邊。

這四件事有同一個根源:這個類別同時扮演了三種角色——它是畫面、是資料、也是業務規則。MVP 要做的就是把這三個角色分開。

MVP 是什麼:三個角色各做一件事

MVP 是 Model-View-Presenter 的縮寫,是把上面那團東西拆成三塊的做法。

Model(模型) 負責資料與業務規則。以這支程式來說,就是「商品有名稱和價格」「價格必須是正整數」「同名商品不能重複」「總價是所有價格相加」。這一層完全不知道畫面的存在——它裡面不會出現 TextBox、Label、MessageBox 這些字。正因為如此,它可以被自動化測試,也可以整包搬到網頁版去用。

View(視圖) 只負責顯示與收集輸入。它變得很笨——笨是刻意的:使用者按了按鈕,它不自己判斷任何事,只轉頭跟 Presenter 說「有人按了新增」。Presenter 叫它顯示錯誤訊息,它就顯示,不問為什麼。在 MVP 裡,View 通常會被抽成一個介面(interface),例如 IProductView,Form 去實作它。

Presenter(主持人) 是中間的協調者,也是 MVP 的核心。它從 View 拿到輸入、請 Model 做判斷與計算、再把結果交回 View 顯示。它是唯一知道「流程」的角色——但它同樣不直接碰任何控制項,只透過 View 的介面溝通。

流程長這樣:

使用者按下「新增」
   ↓
View(Form):跟 Presenter 說「有人按了新增」,並提供輸入的文字
   ↓
Presenter:跟 Model 說「幫我加一筆商品」
   ↓
Model:檢查規則 → 合法就存起來、回報結果;不合法就回報哪裡錯了
   ↓
Presenter:依結果決定要 View 做什麼(更新清單/顯示錯誤)
   ↓
View:照做,顯示畫面

關鍵在那個 interface。因為 Presenter 只依賴 IProductView 而不是具體的 MainForm,測試的時候你可以塞一個假的 View 進去,用純程式驗證「輸入負數時,Presenter 有沒有叫 View 顯示錯誤訊息」——完全不需要開啟任何視窗。這是 MVP 最實際的好處。

跟 MVC、MVVM 差在哪

這三個名字常常被混用,但它們適用的場景不一樣。

MVC(Model-View-Controller) 是最早的那個,現在主要活在網頁後端(ASP.NET Core MVC、Laravel 等)。它的流程是「使用者的請求先到 Controller,Controller 處理完再選一個 View 來呈現」——View 是被 Controller 挑出來的。這在網頁很自然,因為每次請求本來就是「送一個網址、回一頁 HTML」。但桌面程式不是這樣運作的:視窗一直在那裡,使用者直接跟控制項互動,事件從 View 發起。所以 MVC 套到 WinForms 上會很彆扭。

MVP 就是為了這種「View 先收到事件」的情況調整出來的。差別在於 View 和 Presenter 是一對一綁在一起,事件從 View 進來,Presenter 再回頭指揮 View。它最適合 WinForms 這種以事件驅動、又沒有資料繫結機制的框架。

MVVM(Model-View-ViewModel) 是 WPF、MAUI 的主流,它靠框架的資料繫結(data binding)運作——ViewModel 上的屬性變了,畫面自動跟著變,不需要有人寫 lblSummary.Text = ...。這很強大,但前提是框架要支援繫結。WinForms 的繫結能力有限,硬套 MVVM 通常得不償失。

一句話總結:網頁後端用 MVC、WPF/MAUI 用 MVVM、WinForms 用 MVP。三者的共同精神其實一樣——把「資料與規則」跟「畫面」分開,差別只在中間那層叫什麼、以及事件從哪個方向流動。理解了這個共同精神,換到哪個框架你都能很快進入狀況。

下一篇要做的事

下一篇會把這支商品管理程式原封不動的功能重構成 MVP,你會看到同樣的四個檔案結構:

  • Product.cs:一個商品該有什麼(Model)
  • ProductService.cs:驗證規則與總價計算(Model)
  • IProductView.cs:View 必須提供哪些能力(介面)
  • ProductPresenter.cs:把流程串起來(Presenter)

而 MainForm.cs 會瘦下來——它只剩下建立控制項、把事件轉給 Presenter、以及照 Presenter 的指示更新畫面。那個塞了五件事的 BtnAdd_Click,會變成兩行。

最後我們會做一件這篇做不到的事:寫一個測試,在完全不開啟視窗的情況下,驗證「價格輸入負數會被擋下來」。那一刻你會真正理解,前面這些拆分到底換到了什麼。

在那之前,建議你先把上面這支程式實際跑一次,並試著自己加一個「刪除商品」按鈕。你會親身感受到第三個問題——你得在 BtnDelete_Click 裡再寫一次總價計算,而且很可能會複製貼上。那個「想複製貼上」的瞬間,就是這篇文章想讓你注意到的信號。

準備好了就往下走:C# MVP 架構(下):把商品管理程式重構成 Model-View-Presenter,我們會把這支程式拆成三層,並寫出六個完全不開視窗的單元測試,最後附完整原始碼下載。

相關課程

C# 物件導向與桌面程式

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

查看課程內容