Java 面向对象 · 交互课程

MVC 模式

MVC 模式

目标:理解 MVC(Model-View-Controller)的职责分离思想——为什么要把「状态 + 规则」「渲染」「输入处理」拆成三层;掌握三者的职责边界与单向依赖方向;以 Erase-MVC(双人对战擦除方块)为例,看懂 Model/View/Controller 在真实代码里的落点、组合根装配方式,以及 MVC 带来的可测试性与可替换性。

一、为什么要拆:一个类扛不住「状态 + 规则 + 渲染」

前面几章我们用单机版 Erase 认识了 Swing。它的核心类 GameBox extends JPanel 把游戏状态、游戏规则、画面渲染、计时驱动全塞在一个类里(约 374 行),Shape 这个纯数据类也背着 Graphics 绘制方法。这种「大杂烩」写起来快,但很快暴露出一批结构性问题:

#问题后果
P1Model 与 View 混在 GameBox:规则类直接持有 JPanel,状态变更后直接 repaint()模型层强依赖 Swing,无法脱离窗口单独验证规则
P2Model 与 Controller 混在 GameBox:计时器驱动、按键处理写在面板类里「谁触发状态变化」没有独立归属,逻辑散落
P3纯数据类 Shape 携带绘制代码(import Graphics/Color)数据无法脱离 AWT 单独测试
P45 个监听器类碎片化定义在 Game.java 尾部,直接写模型字段(gameBox.start=true)命令处理绕过封装,任意地方都能改状态
P7任何规则验证都必须启动 Swing 窗口人工观察无法自动化回归测试

根因是职责没有分离:同一个类既「拥有状态」、又「计算规则」、还「负责画出来」、并「接收输入」。一旦要改渲染(换个皮肤)、换输入方式(双人变联网)、或写自动化测试,就得在一大坨代码里小心翼翼地找——牵一发动全身。MVC 就是针对这种耦合的标准解法。

二、MVC 三角色与单向依赖

MVC(Model-View-Controller)把程序按变化原因拆成三层:

角色职责明确不做
Model(模型)拥有业务状态与业务规则;状态变化后主动通知「有视图在关心我」不渲染、不知道界面长什么样、不 import 任何 UI 库
View(视图)把 Model 的状态画出来;接收用户输入并转成「命令」不修改任何业务状态(只读 Model)
Controller(控制器)把用户输入/定时事件翻译成对 Model 的调用;是唯一能触发状态修改的入口不持有视图、不做渲染、不自己存状态

三者之间的依赖方向是单向的,这是 MVC 可审计、可测试的关键:

注意最后一行: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.GameBoardstick、score/level/speed/layer/begin、canmove/canrotate/gameover/addscore、play→handleKey
GameBox.paint* 系列view.GameViewpaintComponent、paintStick、paintMessage、paintScore、bar3D…
GameBox.TimerListenercontroller.GameController200ms Timer 与 start/pause 判定(tick→board.tick)
Shape 数据/规则model.Shapes/b/l/c、init/getnextsh/move/rotate(去掉 Graphics 绘制)
Shape.paint/paintnextview.GameView.paintShape/paintNextShapeGraphics 绘制
Game 窗口部分 / 5 个监听器view.GameFrame + controller.GameController.*Listener()JFrame/菜单/布局;命令封装为 board 方法调用
EraseAppmvc.EraseApp组合根装配

下面这张交互时序图,把「一次按键」如何在四层之间单向流动完整走一遍:

交互实验:MVC 单向数据流时序图(MVCDataFlow)

架构 · 职责分离

Erase-MVC 把旧版「状态 + 规则 + 渲染混在一个类」拆成 Model / View / Controller 三层。 下面按「输入 → 命令分发 → 状态更新 → 通知重绘」逐步高亮每条消息, 看一次按键如何在各层之间单向流动——Model 始终不 import 任何 AWT/Swing 类。

① 输入② 命令分发③ 状态更新④ 通知重绘
第 1 / 8 步 · ① 输入
keyPressed(e)—— 玩家按键:GameFrame 把 KeyListener 挂到窗口,回调进 Controller
输入源键盘 / 菜单 / Timer(200ms)GameControllerController · 唯一状态修改入口GameBoardModel · 状态 + 规则(零 Swing 依赖)GameViewView · 只读模型重绘keyPressed

MVC 三角色职责(Erase-MVC 落点)

角色类职责明确不做
ModelGameBoard / Shape游戏状态 + 规则(移动/旋转校验、消行计分、结束判定);状态变更 fireStateChanged()不含任何 AWT/Swing import,不渲染
ViewGameView / GameFrame只读 Model 重绘(JPanel.paintComponent);窗口/菜单/布局不修改任何模型状态
ControllerGameController按键分发 + 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 的价值:可测试 + 可替换

呼应课程主线:MVC 不是凭空来的——它把模块 3 学的接口 + 多态(GameStateListener 是接口,View 是实现)、模块 4 学的事件委托(Listener 注册/回调)用在了「架构」尺度上。理解了 MVC,后面学 SSM/Spring 的「控制器-服务-实体」分层时会发现是同一种思想。

思考题

  1. GameBoard 为什么能 headless 单元测试?它「通知 View」时依赖了什么、避免了什么依赖?
  2. 「Controller 是唯一的状态修改入口」这句话在代码里如何体现?View 想改状态有没有通道?
  3. 把 Erase-MVC 的 Swing 窗口换成 HTML5 前端,主要要重写哪一层?Model 和 Controller 需要动吗?
  4. fireStateChanged() 用「监听器列表 + 接口回调」而不是直接 view.repaint(),好处是什么?

学习小测验

学习小测验

Q1

在 Erase-MVC 中,玩家按键、菜单命令(开始/暂停/继续)与 200ms Timer 心跳,最终都经哪一层进入 Model?

Q2

Erase-MVC 的 GameBoard(Model)为什么能 headless(不开窗口)做单元测试?

Q3

GameBoard 状态变化(如消行计分)后,GameView 是如何被通知去重绘的?

Q4

下列哪一项最准确地描述了 GameView 与 GameBoard 的关系?

Q5

关于 Erase-MVC 的依赖方向,下列说法正确的是?

Q6

若要把 Erase-MVC 的 Swing 窗口换成 HTML5 Web 前端(游戏规则不变),主要需要重写哪一层?