设计模式综合实战三:基于Java Swing实现绘图工具(命令+状态+备忘录+组合)

说实话,这个题目一看就有点「硬核」。四个模式一起上,还要用Swing做界面。我当年第一次看到这种组合设计时,心里也犯嘀咕:有必要搞这么复杂吗?直到我自己动手做了一个绘图工具,才发现——嗯,真香。

今天我们就来拆解这个综合案例。我会带着你一步步看,这四个模式是怎么在绘图工具里各司其职的。

一、需求场景:我们要做什么?

先明确目标:做一个简单的绘图工具。用户能画矩形、圆形、线条,能撤销重做,能保存和恢复状态。听起来是不是很像Windows画图?对,就是那个意思。

但我们要做得更「设计模式」一点。说白了,就是让代码好扩展、好维护。你想想看,如果以后要加个「画三角形」的功能,总不能把整个代码翻个底朝天吧?

二、四个模式的分工

我习惯先理清每个模式负责什么。就像团队分工一样,各管一摊,互不干扰。

模式 职责 类比
命令模式 封装绘图操作(画一个形状、删除一个形状) 遥控器上的按钮
状态模式 管理工具状态(当前是画矩形模式还是画圆模式) 笔的粗细切换
备忘录模式 保存和恢复画布快照(撤销/重做) 游戏存档
组合模式 管理图形对象(单个形状和组合形状统一处理) 文件夹和文件

你看,每个模式都有自己的一亩三分地。组合模式管「数据」,命令模式管「操作」,状态模式管「上下文」,备忘录模式管「历史」。各司其职,代码就不会乱成一锅粥。

三、核心架构图

我画了一张图,帮你理清它们之间的关系。这张图我反复调整过几次,确保你看一眼就能明白。

绘图工具架构:四个模式协作 状态模式 管理当前工具状态 矩形/圆形/选择模式 命令模式 封装绘图操作 执行/撤销/重做 备忘录模式 保存画布快照 历史记录管理 组合模式 图形对象树形结构 单个/组合统一处理 触发 保存 操作 恢复 选择 状态模式决定「做什么」,命令模式封装「怎么做」 备忘录模式记录「做过什么」,组合模式管理「做的东西」

这张图我画了三遍才满意。箭头方向代表调用关系,不是数据流。你仔细看,状态模式触发命令,命令执行时保存备忘录,同时操作组合对象。环环相扣,但每个环都是独立的。

四、代码实现:从接口开始

我个人习惯先定义接口,再写实现。这样思路清晰,不会写着写着就跑偏了。

4.1 组合模式:图形接口

// 图形接口 - 组合模式的核心
public interface Shape {
    void draw(Graphics g);
    void move(int dx, int dy);
    Rectangle getBounds();
    Shape clone();
}

// 单个图形:矩形
public class RectangleShape implements Shape {
    private int x, y, width, height;
    
    public RectangleShape(int x, int y, int w, int h) {
        this.x = x; this.y = y;
        this.width = w; this.height = h;
    }
    
    @Override
    public void draw(Graphics g) {
        g.drawRect(x, y, width, height);
    }
    
    @Override
    public void move(int dx, int dy) {
        x += dx; y += dy;
    }
    
    @Override
    public Rectangle getBounds() {
        return new Rectangle(x, y, width, height);
    }
    
    @Override
    public Shape clone() {
        return new RectangleShape(x, y, width, height);
    }
}

// 组合图形:可以包含多个子图形
public class CompositeShape implements Shape {
    private List<Shape> children = new ArrayList<>();
    
    public void add(Shape shape) {
        children.add(shape);
    }
    
    public void remove(Shape shape) {
        children.remove(shape);
    }
    
    @Override
    public void draw(Graphics g) {
        for (Shape child : children) {
            child.draw(g);
        }
    }
    
    @Override
    public void move(int dx, int dy) {
        for (Shape child : children) {
            child.move(dx, dy);
        }
    }
    
    @Override
    public Rectangle getBounds() {
        // 计算所有子图形的并集边界
        Rectangle bounds = null;
        for (Shape child : children) {
            Rectangle r = child.getBounds();
            if (bounds == null) bounds = r;
            else bounds = bounds.union(r);
        }
        return bounds;
    }
    
    @Override
    public Shape clone() {
        CompositeShape cloned = new CompositeShape();
        for (Shape child : children) {
            cloned.add(child.clone());
        }
        return cloned;
    }
}

这里有个小细节:clone()方法。为什么要深拷贝?因为备忘录模式需要保存画布快照,如果只是浅拷贝,撤销时就会出问题。我曾经在这个坑里摔过一次——撤销后图形位置没变,排查了半天才发现是浅拷贝惹的祸。

4.2 命令模式:操作封装

// 命令接口
public interface Command {
    void execute();
    void undo();
}

// 具体命令:添加图形
public class AddShapeCommand implements Command {
    private CompositeShape canvas;
    private Shape shape;
    
    public AddShapeCommand(CompositeShape canvas, Shape shape) {
        this.canvas = canvas;
        this.shape = shape;
    }
    
    @Override
    public void execute() {
        canvas.add(shape);
    }
    
    @Override
    public void undo() {
        canvas.remove(shape);
    }
}

// 命令管理器:支持撤销/重做
public class CommandManager {
    private Stack<Command> undoStack = new Stack<>();
    private Stack<Command> redoStack = new Stack<>();
    
    public void executeCommand(Command cmd) {
        cmd.execute();
        undoStack.push(cmd);
        redoStack.clear(); // 新操作清空重做栈
    }
    
    public void undo() {
        if (!undoStack.isEmpty()) {
            Command cmd = undoStack.pop();
            cmd.undo();
            redoStack.push(cmd);
        }
    }
    
    public void redo() {
        if (!redoStack.isEmpty()) {
            Command cmd = redoStack.pop();
            cmd.execute();
            undoStack.push(cmd);
        }
    }
}

你想想看,如果没有命令模式,撤销功能得怎么写?可能每个操作都要写一段撤销逻辑,散落在各个地方。有了命令模式,撤销逻辑就集中在undo()方法里,清爽多了。

4.3 状态模式:工具状态切换

// 状态接口
public interface ToolState {
    void onMouseDown(int x, int y);
    void onMouseUp(int x, int y);
    void onMouseDrag(int x, int y);
}

// 具体状态:画矩形模式
public class RectangleToolState implements ToolState {
    private int startX, startY;
    private CompositeShape canvas;
    private CommandManager cmdManager;
    
    public RectangleToolState(CompositeShape canvas, CommandManager cmdManager) {
        this.canvas = canvas;
        this.cmdManager = cmdManager;
    }
    
    @Override
    public void onMouseDown(int x, int y) {
        startX = x;
        startY = y;
    }
    
    @Override
    public void onMouseUp(int x, int y) {
        int w = Math.abs(x - startX);
        int h = Math.abs(y - startY);
        int minX = Math.min(startX, x);
        int minY = Math.min(startY, y);
        
        Shape rect = new RectangleShape(minX, minY, w, h);
        Command cmd = new AddShapeCommand(canvas, rect);
        cmdManager.executeCommand(cmd);
    }
    
    @Override
    public void onMouseDrag(int x, int y) {
        // 可以在这里绘制预览效果
    }
}

// 上下文:绘图面板
public class DrawingPanel extends JPanel {
    private ToolState currentState;
    private CompositeShape canvas = new CompositeShape();
    private CommandManager cmdManager = new CommandManager();
    
    public void setToolState(ToolState state) {
        this.currentState = state;
    }
    
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        canvas.draw(g);
    }
    
    // 鼠标事件委托给状态对象
    @Override
    public void mousePressed(MouseEvent e) {
        currentState.onMouseDown(e.getX(), e.getY());
    }
    
    @Override
    public void mouseReleased(MouseEvent e) {
        currentState.onMouseUp(e.getX(), e.getY());
        repaint();
    }
}

状态模式的好处是什么?你加一个新工具(比如画三角形),只需要新增一个TriangleToolState类,实现ToolState接口就行。完全不用改现有代码。这就是开闭原则的体现。

4.4 备忘录模式:保存和恢复

// 备忘录:保存画布快照
public class CanvasMemento {
    private List<Shape> shapesSnapshot;
    
    public CanvasMemento(List<Shape> shapes) {
        // 深拷贝:每个Shape都要clone
        this.shapesSnapshot = new ArrayList<>();
        for (Shape s : shapes) {
            this.shapesSnapshot.add(s.clone());
        }
    }
    
    public List<Shape> getShapes() {
        return shapesSnapshot;
    }
}

// 发起人:绘图面板(扩展)
public class DrawingPanelWithMemento extends DrawingPanel {
    private Stack<CanvasMemento> history = new Stack<>();
    
    public void saveState() {
        // 获取当前所有图形
        List<Shape> currentShapes = getCanvasShapes();
        CanvasMemento memento = new CanvasMemento(currentShapes);
        history.push(memento);
    }
    
    public void restoreState() {
        if (!history.isEmpty()) {
            CanvasMemento memento = history.pop();
            List<Shape> restoredShapes = memento.getShapes();
            setCanvasShapes(restoredShapes);
            repaint();
        }
    }
    
    // 辅助方法:获取/设置画布中的图形列表
    private List<Shape> getCanvasShapes() {
        // 从CompositeShape中提取所有子图形
        // 这里需要根据CompositeShape的具体实现来写
        return new ArrayList<>();
    }
    
    private void setCanvasShapes(List<Shape> shapes) {
        // 清空画布,重新添加
    }
}
注意:备忘录模式中的深拷贝非常关键。如果只是浅拷贝,你保存的快照和当前画布会共享同一个Shape对象。修改当前画布时,快照也会被修改。这就失去了「恢复」的意义。我当年第一次实现时,就因为这个bug调试了一整个下午。

五、整合:让一切运转起来

好了,四个模式都实现了。现在把它们组装起来。

public class DrawingApp {
    public static void main(String[] args) {
        JFrame frame = new JFrame("绘图工具 - 设计模式实战");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(800, 600);
        
        // 创建绘图面板
        DrawingPanel panel = new DrawingPanel();
        
        // 创建命令管理器
        CommandManager cmdManager = new CommandManager();
        
        // 创建各种工具状态
        ToolState rectTool = new RectangleToolState(panel.getCanvas(), cmdManager);
        ToolState circleTool = new CircleToolState(panel.getCanvas(), cmdManager);
        
        // 默认使用矩形工具
        panel.setToolState(rectTool);
        
        // 添加工具栏按钮
        JToolBar toolbar = new JToolBar();
        JButton rectBtn = new JButton("矩形");
        rectBtn.addActionListener(e -> panel.setToolState(rectTool));
        
        JButton circleBtn = new JButton("圆形");
        circleBtn.addActionListener(e -> panel.setToolState(circleTool));
        
        JButton undoBtn = new JButton("撤销");
        undoBtn.addActionListener(e -> cmdManager.undo());
        
        JButton redoBtn = new JButton("重做");
        redoBtn.addActionListener(e -> cmdManager.redo());
        
        toolbar.add(rectBtn);
        toolbar.add(circleBtn);
        toolbar.add(undoBtn);
        toolbar.add(redoBtn);
        
        frame.add(toolbar, BorderLayout.NORTH);
        frame.add(panel, BorderLayout.CENTER);
        
        frame.setVisible(true);
    }
}
小技巧:在实际项目中,我习惯把「撤销/重做」和「备忘录模式」结合起来。命令模式负责操作级别的撤销,备忘录模式负责状态级别的恢复。两者并不冲突,反而可以互补。比如,你可以用命令模式撤销最近一次操作,用备忘录模式恢复到10分钟前的状态。

六、避坑指南

做这个综合案例时,有几个坑我踩过,分享给你:

  • 深拷贝问题:备忘录模式一定要做深拷贝。我建议在Shape接口中定义clone()方法,每个子类都实现它。
  • 状态切换时的清理:切换工具状态时,要确保当前状态没有未完成的拖拽操作。否则会出现「鼠标还按着,但工具已经换了」的诡异情况。
  • 组合图形的边界计算CompositeShape.getBounds()要递归计算所有子图形的边界。如果子图形也是组合图形,要一直递归下去。
  • 撤销栈的大小:如果不限制撤销栈的大小,画了一整天后,内存可能会爆。我一般限制最多100步撤销。

七、总结

这个综合案例,说白了就是让四个设计模式各司其职,协同工作。你不需要一次性记住所有代码,但一定要理解它们的分工逻辑:

  • 组合模式管「数据」——图形怎么组织
  • 命令模式管「操作」——怎么画、怎么删
  • 状态模式管「上下文」——当前是什么工具
  • 备忘录模式管「历史」——怎么撤销、怎么恢复

这种分层思想,在大型项目中特别有用。我后来做的一个CAD软件,核心架构就是从这个案例演变而来的。只不过把Swing换成了JavaFX,把矩形圆形换成了更复杂的工程图元。但底层的设计模式组合,几乎一模一样。

好了,今天的实战就到这里。代码你可以直接拿去用,但更重要的是理解背后的设计思路。下次遇到类似的需求,你也能自己搭出这样的架构来。


公众号:蓝海资料掘金营,微信deep3321