MVC 模式
MVC 模式
目标:理解 MVC(Model-View-Controller)的职责分离思想——为什么要把「状态 + 规则」「渲染」「输入处理」拆成三层;掌握三者的职责边界与单向依赖方向;以 Erase-MVC(双人对战擦除方块)为例,看懂 Model/View/Controller 在真实代码里的落点、组合根装配方式,以及 MVC 带来的可测试性与可替换性。
一、为什么要拆:一个类扛不住「状态 + 规则 + 渲染」
前面几章我们用单机版 Erase 认识了 Swing。它的核心类 GameBox extends JPanel 把游戏状态、游戏规则、画面渲染、计时驱动全塞在一个类里(约 374 行),Shape 这个纯数据类也背着 Graphics 绘制方法。这种「大杂烩」写起来快,但很快暴露出一批结构性问题:
| # | 问题 | 后果 |
|---|---|---|
| P1 | Model 与 View 混在 GameBox:规则类直接持有 JPanel,状态变更后直接 repaint() | 模型层强依赖 Swing,无法脱离窗口单独验证规则 |
| P2 | Model 与 Controller 混在 GameBox:计时器驱动、按键处理写在面板类里 | 「谁触发状态变化」没有独立归属,逻辑散落 |
| P3 | 纯数据类 Shape 携带绘制代码(import Graphics/Color) | 数据无法脱离 AWT 单独测试 |
| P4 | 5 个监听器类碎片化定义在 Game.java 尾部,直接写模型字段(gameBox.start=true) | 命令处理绕过封装,任意地方都能改状态 |
| P7 | 任何规则验证都必须启动 Swing 窗口人工观察 | 无法自动化回归测试 |
根因是职责没有分离:同一个类既「拥有状态」、又「计算规则」、还「负责画出来」、并「接收输入」。一旦要改渲染(换个皮肤)、换输入方式(双人变联网)、或写自动化测试,就得在一大坨代码里小心翼翼地找——牵一发动全身。MVC 就是针对这种耦合的标准解法。
二、MVC 三角色与单向依赖
MVC(Model-View-Controller)把程序按变化原因拆成三层:
| 角色 | 职责 | 明确不做 |
|---|---|---|
| Model(模型) | 拥有业务状态与业务规则;状态变化后主动通知「有视图在关心我」 | 不渲染、不知道界面长什么样、不 import 任何 UI 库 |
| View(视图) | 把 Model 的状态画出来;接收用户输入并转成「命令」 | 不修改任何业务状态(只读 Model) |
| Controller(控制器) | 把用户输入/定时事件翻译成对 Model 的调用;是唯一能触发状态修改的入口 | 不持有视图、不做渲染、不自己存状态 |
三者之间的依赖方向是单向的,这是 MVC 可审计、可测试的关键:
Controller → Model:控制器调用模型方法(改状态);View → Model:只读(通过 getter 读状态来重绘);Model → View:仅经接口回调——Model 不持有具体 View,只认识一个监听器接口,状态变了就广播通知。
注意最后一行:Model「通知」View 时不 import 任何 AWT/Swing 类,它只依赖自己定义的接口
GameStateListener。这样 Model 才能彻底与 UI 解耦。
三、Erase-MVC 的包结构与新旧类映射
把单机版 Erase 按 MVC 重构后,得到清晰的包结构(每个角色一个包):
src/
└── mvc/
├── EraseApp.java // 组合根:创建 Model/View/Controller 并装配
├── model/
│ ├── GameBoard.java // 游戏状态 + 规则(原 GameBox 的非渲染部分)
│ ├── Shape.java // 形状数据 + 移动/旋转/生成(原 Shape 去绘制)
│ └── GameStateListener.java // 状态变更通知接口
├── view/
│ ├── GameFrame.java // 主窗口:菜单、布局、按键监听挂载
│ └── GameView.java // 单玩家面板渲染(原 GameBox 的渲染部分)
└── controller/
└── GameController.java // 每玩家一个:按键分发 + Timer 驱动 + 菜单命令
旧类 → 新类的迁移映射(谁拆到了哪一层):
| 旧(erase 包,混杂) | 新(mvc 包,分层) | 迁走的内容 |
|---|---|---|
GameBox 字段/规则方法 | model.GameBoard | stick、score/level/speed/layer/begin、canmove/canrotate/gameover/addscore、play→handleKey |
GameBox.paint* 系列 | view.GameView | paintComponent、paintStick、paintMessage、paintScore、bar3D… |
GameBox.TimerListener | controller.GameController | 200ms Timer 与 start/pause 判定(tick→board.tick) |
Shape 数据/规则 | model.Shape | s/b/l/c、init/getnextsh/move/rotate(去掉 Graphics 绘制) |
Shape.paint/paintnext | view.GameView.paintShape/paintNextShape | Graphics 绘制 |
Game 窗口部分 / 5 个监听器 | view.GameFrame + controller.GameController.*Listener() | JFrame/菜单/布局;命令封装为 board 方法调用 |
EraseApp | mvc.EraseApp | 组合根装配 |
下面这张交互时序图,把「一次按键」如何在四层之间单向流动完整走一遍:
交互实验:MVC 单向数据流时序图(MVCDataFlow)
架构 · 职责分离Erase-MVC 把旧版「状态 + 规则 + 渲染混在一个类」拆成 Model / View / Controller 三层。 下面按「输入 → 命令分发 → 状态更新 → 通知重绘」逐步高亮每条消息, 看一次按键如何在各层之间单向流动——Model 始终不 import 任何 AWT/Swing 类。
MVC 三角色职责(Erase-MVC 落点)
| 角色 | 类 | 职责 | 明确不做 |
|---|---|---|---|
| Model | GameBoard / Shape | 游戏状态 + 规则(移动/旋转校验、消行计分、结束判定);状态变更 fireStateChanged() | 不含任何 AWT/Swing import,不渲染 |
| View | GameView / GameFrame | 只读 Model 重绘(JPanel.paintComponent);窗口/菜单/布局 | 不修改任何模型状态 |
| Controller | GameController | 按键分发 + Timer 驱动 + 菜单命令,是唯一改状态的入口 | 不持有视图、不做渲染 |
组合根:创建并装配 M / V / C
// mvc/EraseApp.java —— 组合根:创建并装配 M / V / C
int[] keya = {87,83,65,68}; // P1: w s a d
int[] keyb = {38,40,37,39}; // P2: ↑ ↓ ← →
GameBoard board = new GameBoard();
board.init(105, 50, 13, 16, 30, keya); // Model:状态 + 规则(零 Swing)
GameView view = new GameView(board, 40); // View:只读 board + 注册为监听器
GameController ctrl = new GameController(board); // Controller:按键 + Timer + 菜单命令
new GameFrame(view, ctrl); // 窗口装配:不修改任何游戏状态MVC 要点
- Model 零 AWT/Swing 依赖:只含状态与规则,可脱离窗口做单元测试(test/ModelTest.java headless 可跑)——这是旧版 GameBox「状态+规则+渲染混一类」无法做到的;
- View 只读模型:GameView 不修改任何游戏规则状态,收到 stateChanged 通知后 repaint();窗口/菜单等纯 UI 动作留在 GameFrame;
- Controller 是唯一的状态修改入口:按键、菜单命令、Timer 心跳全部经它调用 Model 方法;它不持有视图、不做渲染;
- 单向依赖:controller→model、view→model(只读)、model→view(仅经 GameStateListener 接口回调);Model 不 import 任何 AWT/Swing 类,依赖方向可审计;
- 组合根 EraseApp 是 M/V/C 唯一交汇点:创建三者并装配,其余类彼此不直接 new——替换视图(如换成 HTML5 前端)或替换输入(如换成网络指令)都不动 Model。
四、关键代码走读
4.1 Model:状态 + 规则 + 通知(GameBoard)
GameBoard 不含任何 AWT/Swing import。它拥有全部游戏状态与规则,并在每次状态变更后统一广播:
// model/GameStateListener.java —— Model 只认识这个接口,不认识具体 View
public interface GameStateListener {
void stateChanged(GameBoard board);
}
// model/GameBoard.java(节选)
private final ArrayList<GameStateListener> listeners = new ArrayList<>();
// Controller 调用的命令入口
public void startGame() { start = true; pause = false; fireStateChanged(); }
public void handleKey(int key) { /* 配置/移动/旋转/自动下落判定,末尾:*/ fireStateChanged(); }
// 视图只读访问(getter)
public int getScore() { return score; }
public int[][] getStick() { return stick; }
// 状态变更 → 通知所有已注册监听器(Model 不 import 任何 Swing 类)
private void fireStateChanged() {
for (int i = 0; i < listeners.size(); i++)
listeners.get(i).stateChanged(this);
}
要点:Model 不持有 View 引用,只持有「监听器接口列表」;改完状态一律 fireStateChanged()。这是 Model 能与 UI 彻底解耦的核心。
4.2 Controller:唯一的状态修改入口(GameController)
每个玩家一个控制器:按键分发 + 200ms 计时驱动 + 菜单命令,不持有视图、不做渲染:
// controller/GameController.java(节选)
public class GameController implements KeyListener {
private final GameBoard board;
private final Timer timer;
public GameController(GameBoard board) {
this.board = board;
timer = new Timer(200, e -> board.tick()); // 心跳 → Model
timer.start();
}
@Override
public void keyPressed(KeyEvent e) { board.handleKey(e.getKeyCode()); } // 按键 → Model
public ActionListener startListener() { return e -> board.startGame(); } // 菜单 → Model
public ActionListener pauseListener() { return e -> board.pauseGame(); }
public ActionListener resumeListener() { return e -> board.resumeGame(); }
}
要点:按键、菜单、Timer 三条触发路径全部汇聚到 Controller,再由它调用 Model 方法。View 想改状态?没有这个通道——它只能读。
4.3 View:只读模型 + 收到通知后重绘(GameView)
// view/GameView.java(节选)
public class GameView extends JPanel implements GameStateListener {
private final GameBoard board;
public GameView(GameBoard board, int messx) {
this.board = board;
this.pxmess = messx;
board.addListener(this); // 注册自己为监听器
}
@Override
public void stateChanged(GameBoard b) { repaint(); } // 收到通知 → 重绘
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
// 全部通过 getter 只读 board:getPx/getScore/getStick/getCurShape…
// 不修改任何模型字段
}
}
要点:View 在构造时 addListener(this) 订阅 Model;stateChanged 里只做 repaint();paintComponent 里只读 getter。窗口/菜单等纯 UI 动作留在 GameFrame,同样不碰游戏状态。
4.4 组合根:M/V/C 唯一交汇点(EraseApp)
// mvc/EraseApp.java —— 组合根
int[] keya = {87,83,65,68}; // P1: w s a d
int[] keyb = {38,40,37,39}; // P2: ↑ ↓ ← →
GameBoard board = new GameBoard();
board.init(105, 50, 13, 16, 30, keya); // Model:状态 + 规则(零 Swing)
GameView view = new GameView(board, 40); // View:只读 board + 注册监听器
GameController ctrl = new GameController(board); // Controller:按键 + Timer + 菜单命令
new GameFrame(view, ctrl); // 窗口装配:不修改任何游戏状态
要点:只有组合根同时 new 出三层并接线;其余类彼此不直接 new。这保证了依赖关系集中、可审计。
五、MVC 的价值:可测试 + 可替换
- 可测试:因为 Model 零 Swing 依赖,
test/ModelTest.java可以 headless(不开窗口)直接构造GameBoard、调用handleKey/addscore断言规则——旧版必须启动窗口人工看,现在能自动化回归。 - 可替换:要换前端(Swing → HTML5)、换输入(键盘 → 网络指令)、加双人联网,都只动 View 或 Controller 层,Model 的规则一行不改。变化被隔离在单一职责的层里。
呼应课程主线:MVC 不是凭空来的——它把模块 3 学的接口 + 多态(
GameStateListener是接口,View 是实现)、模块 4 学的事件委托(Listener 注册/回调)用在了「架构」尺度上。理解了 MVC,后面学 SSM/Spring 的「控制器-服务-实体」分层时会发现是同一种思想。
思考题
GameBoard为什么能 headless 单元测试?它「通知 View」时依赖了什么、避免了什么依赖?- 「Controller 是唯一的状态修改入口」这句话在代码里如何体现?View 想改状态有没有通道?
- 把 Erase-MVC 的 Swing 窗口换成 HTML5 前端,主要要重写哪一层?Model 和 Controller 需要动吗?
fireStateChanged()用「监听器列表 + 接口回调」而不是直接view.repaint(),好处是什么?
学习小测验
学习小测验
在 Erase-MVC 中,玩家按键、菜单命令(开始/暂停/继续)与 200ms Timer 心跳,最终都经哪一层进入 Model?
Erase-MVC 的 GameBoard(Model)为什么能 headless(不开窗口)做单元测试?
GameBoard 状态变化(如消行计分)后,GameView 是如何被通知去重绘的?
下列哪一项最准确地描述了 GameView 与 GameBoard 的关系?
关于 Erase-MVC 的依赖方向,下列说法正确的是?
若要把 Erase-MVC 的 Swing 窗口换成 HTML5 Web 前端(游戏规则不变),主要需要重写哪一层?