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,跑出來是這樣:

這支程式沒有任何 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,我們會把這支程式拆成三層,並寫出六個完全不開視窗的單元測試,最後附完整原始碼下載。