本文讲述一个画图板应用程序的设计,屏幕抓图如下。这篇文章带有三个附件,其中两个jar文件都是j2sdk1.4.2_08编译打包,包含源代码,可执行,如下表:

附件名称及链接

详情

jDraw_basic.jar

本文是基于这个基本版本的,屏幕抓图显示的也是这个基本版本的界面。

jDraw_extended.jar

在基础版本上稍加扩展,加入文件读存功能,即可将所画的图存入一个模型文件(特定的格式,见下)或者从文件中读取,也可以将其导出到一个PNG格式的文件。由于扩展功能不是本文的重点,并且也不复杂,所以文中就不在对其进行阐述。它的源代码只是在基本版本上增加了一些内容。

jdraw_demo.zip

屏幕抓图中的图形的模型文件,属于纯文本格式,为了节省空间,将其压缩了一下,解压缩取出其中的jdraw_demo.jdw文件后再使用。按理说,SGML/XML的格式才是正途,不过这只是个简单的应用,不用那么大动干戈了,就走个“邪道”吧:)

『IShape』

这是所有图形类(此后称作模型类)都应该实现接口,外部的控制类,比如画图板类就通过这个接口跟模型类“交流”。名字开头的I表示它是一个接口(Interface),这是eclipse用的一个命名法则,觉得挺有用的,就借鉴来了。这个接口定义了两个方法:


public void draw(java.awt.Graphics2D g); 每个实现IShape的类都在这个方法里面指定它的图形显示代码。 public void processCursorEvent(java.awt.event.MouseEvent evt, int type); 这个方法是在图形(被用户)绘制过程中,发生相关的鼠标点击和移动事件时调用的。 第一个参数就是所发生的鼠标事件对象; 第二个参数取值于IShape所定义的三个常数:RIGHT_PRESSED, LEFT_RELEASED,和CURSOR_DRAGGED。


下面这个class diagram显示了所有图形类的结构图。FreeShape, RectBoundedShape,和PolyGon这三个类直接实现了IShape接口。其中,FreeShape和RectBoundedShape是抽象类,分别代表不规则图形(比如铅笔画图)和以一个长方形为边界的规则图形,由于分属于这两个类别的图形对于鼠标事件的处理基本上都是一致的,所以就抽象出来这两个父类,避免重复代码。PolyGon是一个具体类,它的命名没有采用Polygon是为了避免同java.awt.Polygon重名。它代表的图形是多边形,由于它独特的鼠标处理方式,它不属于上面两种类型图形的任何一种,所以它直接实现了IShape接口。

IShape接口所定义的两个方法到底是怎么被用到的呢?这个问题现在还不能立刻解答。在下面的部分,我们先讲述FreeShape所定义的不规则图形及其两个具体子类PolyLine和Eraser,然后在这个基础上讲述一个缩略版的画图板类,到那个时候,上面问题的答案也就自然揭晓了。之后,我们再继续讲述其他的图形类。

『FreeShape』

讲到FreeShape,我们不得不先说一下PointsSet这个类。这是一个util类,被FreeShape和PolyGon用到,代表一个有序的点集合,并提供方便的方法来加入新的点和读取点坐标。为了方便对模型类代码的理解,这里列出PointsSet类的API。


public PointsSet(); 用默认的初始容量(10)创建一个对象。 public PointsSet(int initCap); 用指定的初始容量(initCap)创建一个对象。 public void addPoint(int x, int y); 加入一个新的点到这个集合的末端;如果旧的末端点跟新的点重合,则不重复加入。 public int[][] getPoints(); 将所有点以一个二维数组(int[2][n])返回。第一行是x坐标,第二行是y坐标。 public int[][] getPoints(int x, int y); 类似上一个方法,只是最后将参数指定的点加在末尾(无论是否跟集合末端的点重合); 这个方法只被PolyGon用到。


好了,来看下面代码中FreeShape对IShape接口的实现。FreeShape有三个属性变量:color, stroke,和pointsSet。权限设成protected当然是给子类用啦。color就是色彩了,stroke用来指定使用线条的粗细(当然,Stroke类的对象还可以指定交接点形状之类的属性,不过这里都使用其默认值了),pointsSet当然就是包含了所有控制点(这里叫控制点似乎不太恰当,因为其实无法利用这些点来“控制”的,不过也想不到其他恰当的名字,就这么叫吧)集合。值得注意的是构造函数里面包含了起始点的坐标,这个点在函数里面被加到了控制点集中。

这类图形对鼠标事件的处理很简单,它只对IShape.CURSOR_DRAGGED类型的事件感兴趣,每当发生这类事件的时候,就把鼠标拖拽到的新的点加入到控制点集中。当然了,根据上面看到的PointsSet.addPoint(int,int)这个方法的“个性”,这个点是否真的被加入还要看它是否跟旧的末端点重合。

import java.awt.*;
import java.awt.event.MouseEvent;

public abstract class FreeShape implements IShape {
    
    protected Color color;
    protected Stroke stroke;
    protected PointsSet pointsSet;
  
    protected FreeShape(Color c, Stroke s, int x, int y) {
        pointsSet = new PointsSet(50);
        color = c;
        stroke = s;
        pointsSet.addPoint(x, y);
    }
    
    public void processCursorEvent(MouseEvent e, int t) {
        if (t != IShape.CURSOR_DRAGGED)
            return;
        pointsSet.addPoint(e.getX(), e.getY());
    }

}


FreeShape类没有实现IShape接口的draw(Graphics2D)方法,很明显,这个方法是留给子类来完成的。PolyLine和Eraser继承了FreeShape,分别代表铅笔绘出的图形和橡皮擦。其中PolyLine的构造函数结构跟其父类相似,直接调用父类的super方法来完成;相比之下,Eraser类就有点“叛逆”了,它的参数里面用一个JComponent替换了Color。Eraser类是通过画出跟画图板背景色彩一致的线条来掩盖原有图形而实现橡皮擦的效果的,但由于画图板的背景色是可以调的(见抓图的Color Settings部分),直接给Eraser的构造函数一个色彩对象不太合适,所以干脆将画图板自己(JComponent)传了进来,这样,每次Eraser设定图形色彩时,都直接问画图板要它的背景色。来看一下PolyLine对draw(Graphics2D)方法的实现:

public void draw(Graphics2D g) {
        g.setColor(color);
        g.setStroke(stroke);
        int[][] points = pointsSet.getPoints();
        int s = points[0].length;
        if (s == 1) {
            int x = points[0][0];
            int y = points[1][0];
            g.drawLine(x, y, x, y);
        } else {
            g.drawPolyline(points[0], points[1], s);
        }
    }


这个方法里面有一个if-else结构,由于构造函数里面已经将起始点加入控制点集中,所以pointsSet.getPoints()会至少返回一个点。利用Graphics.drawPolyline(int[],int[],int)画图时,如果只有一个点,它是不会画出来东西的,所以检查一下点数,如果只有一个,则改用Graphics.drawLine(int,int,int,int)将这个点画出来。Eraser的draw(Graphics2D)方法跟上面基本上完全一样,只是传给Graphics.setColor(Color)的参数是通过JComponent.getBackground()得到的。

『TestBoard』

现在就来看一个精简版的画图板类:TestBoard。下面的代码,是通过代码注释进行解释的。需要注意的是,TestBoard本身还不能直接运行,需要把它放到一个JFrame里面才行。同时画图工具的切换也需要外部的控件来处理。不过这些都比较简单了,就不多说了。

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.ArrayList;

public class TestBoard extends JPanel 
                         implements MouseListener, MouseMotionListener {
    
    //定义一些常量
    public static final int TOOL_PENCIL = 1;
    public static final int TOOL_ERASER = 2;
    public static final Stroke STROKE = new BasicStroke(1.0f);
    public static final Stroke ERASER_STROKE = new BasicStroke(15.0f);

    private ArrayList shapes;     //保存所有的图形对象(IShape)
    private IShape currentShape;  //指向当前还未完成的图形
    private int tool; //代表当前使用的画图工具(TOOL_PENCIL或TOOL_ERASER)

    public TestBoard() {
        //进行一些初始化
        shapes = new ArrayList();
        tool = TOOL_PENCIL;
        currentShape = null;
        
        //安装鼠标监听器
        addMouseListener(this);
        addMouseMotionListener(this);
    }
    
    //外部的控制界面可以通过这个方法切换画图工具
    public void setTool(int t) {
        tool = t;
    }
    
    //override JPanel的方法。通过调用IShape.draw(Graphics2D)方法来显示图形
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        int size = shapes.size();
        Graphics2D g2d = (Graphics2D) g;
        for (int i=0; i<size; i++) {
            ((IShape) shapes.get(i)).draw(g2d);
        }
    }
    
    public void mousePressed(MouseEvent e) {
        /* 当左键点击时,currentShape肯定指向null。根据当前画图工具创建相应图形对象,
           将currentShape指向它,并把这个对象加入到对象集合(shapes)中。另外,调用
           repaint()方法将画图板的画面更新一下。 */
        if (e.getButton() == MouseEvent.BUTTON1) {
            switch (tool) {
            case TOOL_PENCIL:
                currentShape = new PolyLine(getForeground(), 
                                               STROKE, e.getX(), e.getY());
                break;
            case TOOL_ERASER:
                currentShape = new Eraser(this, ERASER_STROKE, 
                                               e.getX(), e.getY());
                break;
            }
            shapes.add(currentShape);
            repaint();
        /* 当右键点击并且currentShape不指向null时,调用currentShape的
          processCursorEvent(MouseEvent,int)方法,类型参数是
      IShape.RIGHT_PRESSED。 repaint()*/
        } else if (e.getButton() == MouseEvent.BUTTON3 && currentShape != null) {
            currentShape.processCursorEvent(e, IShape.RIGHT_PRESSED);
            repaint();
        }
    }
    
    public void mouseDragged(MouseEvent e) {
        /* 当鼠标拖拽并且currentShape不指向null时(这种情况下,左键肯定处于
          按下状态),调用currentShape的processCursorEvent(MouseEvent,int)方法,
          类型参数是IShape.CURSOR_DRAGGED。 repaint()*/
        if (currentShape != null) {
            currentShape.processCursorEvent(e, IShape.CURSOR_DRAGGED);
            repaint();
        }
    }
    
    public void mouseReleased(MouseEvent e) {
        /* 当左键被松开并且currentShape不指向null时(这个时候,currentShape
          肯定不会指向null的,多检查一次,保险),调用currentShape的
          processCursorEvent(MouseEvent,int)方法,类型参数是
          IShape.CURSOR_DRAGGED。 repaint()*/
        if (e.getButton() == MouseEvent.BUTTON1 && currentShape != null) {
            currentShape.processCursorEvent(e, IShape.LEFT_RELEASED);
            currentShape = null;
            repaint();
        }
    }
    
    //对下面这些事件不感兴趣


至此,整个程序的流程就很清楚了,文章开头部分的问题也被解开了。接下来,就继续来看其他的模型类。

『RectBoundedShape』

RectBoundedShape构造函数的结构跟FreeShape一样,在色彩和线条的运用上也是一样的,也只对鼠标拖拽事件感兴趣。不过,它只有两个控制点,起始点和结束点,所以,不需要用到PointsSet。本来,RectBoundedShape这个类是比FreeShape简单的,在处理鼠标拖拽事件时只要将结束点设置到新拖拽到的点就可以了。不过,这里我们多加入一个的功能,就是在shift键按下的情况下,让图形的边界是个正方形(取原边界中较短的那条边)。这个功能是由regulateShape(int,int)这个方法来完成的,它的代码相当简短,就不多做解释了 。

import java.awt.*;
import java.awt.event.MouseEvent;

public abstract class RectBoundedShape implements IShape {
    
    protected Color color;
    protected Stroke stroke;
  
    protected int startX, startY, endX, endY;
    
    protected RectBoundedShape(Color c, Stroke s, int x, int y) {
        color = c;

        stroke = s;
        startX = endX = x;
        startY = endY = y;
    }
    
    public void processCursorEvent(MouseEvent e, int t) {
        if (t != IShape.CURSOR_DRAGGED)
            return;
        int x = e.getX();
        int y = e.getY();
        if (e.isShiftDown()) {
            regulateShape(x, y);
        } else {
            endX = x;
            endY = y;
        }
    }
    
    protected void regulateShape(int x, int y) {
        int w = x - startX;
        int h = y - startY;
        int s = Math.min(Math.abs(w), Math.abs(h));
        if (s == 0) {
            endX = startX;
            endY = startY;
        } else {
            endX = startX + s * (w / Math.abs(w));
            endY = startY + s * (h / Math.abs(h));
        }
    }
    
}


有了RectBoundedShape这个父类打下的基础,它下面的子类所要做的事情就是画图啦。所有子类的构造函数跟父类都是一样的结构,基本上也都是直接调用super的构造函数,只是Diamond这个类为了提高画图效率,“私下”定义了一个数组。RectBoundedShape的子类包括Line, Rect, Oval, 和Diamond。除了Diamond需要根据边界长方形进行稍微计算求得菱形的四个点外,它们的图形都可以直接利用Graphics类提供的方法很方便的画出来,详情可以参看源代码,就不多说了。现在看一下Line这个类。不同于其它几个类,在shift键按下的情况下,根据角度不同,我们想画出45度线,水平线,或者竖直线。所以,Line这个类不使用其父类定义的processCursorEvent(MouseEvent,int)方法,而是自己定义了一套。父类中regulateShape(int,int)方法的权限设成protected也是为了给Line用的。代码如下:

public void processCursorEvent(MouseEvent e, int t) {
        if (t != IShape.CURSOR_DRAGGED)
            return;
        int x = e.getX();
        int y = e.getY();

        if (e.isShiftDown()) {
            //这个情况单独处理,不然就要除以0了
            if (x - startX == 0) { //竖直
                endX = startX;
                endY = y;
            } else {
                //由于对称性,只要算斜率的绝对值
                float slope = Math.abs(((float) (y - startY)) / (x - startX));
                //小于30度,变成水平的
                if (slope < 0.577) {
                    endX = x;
                    endY = startY;
                //介于30度跟60度中间的,变成45度,利用父类的regulateShape(int,int)完成
                } else if (slope < 1.155) {
                    regulateShape(x, y);
                //大于60度,变成竖直的
                } else {
                    endX = startX;
                    endY = y;
                }
            }
        //如果shift键没有按下,跟父类一样处理


『PolyGon』

用户画多边形的步骤是这样的,先在一点按下鼠标左键,定义一个顶点,然后将鼠标拖拽到多边形的下一个顶点,点鼠标右键将这个点记录,之后重复这个步骤直到所有顶点都记录,松开左键,多边形完成。在多边形完成前,显示出来的不是闭合图形,当左键松开时,图形自动闭合。对于最后一个顶点,用户不用点右键也会被自动记录的。好了,来看一下这个过程是怎么来完成的。方便起见,直接用注释在代码上解释了。

import java.awt.*;
import java.awt.event.MouseEvent;

public class PolyGon implements IShape {
    
    //类似于FreeShape和RectBoundedShape的变量
    private Color color;
    private Stroke stroke;

    //记录所有顶点坐标,姑且称之为顶点集
    private PointsSet pointsSet;
  
    //记录多边形是否完成。true表示完成
    private boolean finalized;
    
    //记录画图过程中鼠标被拖拽到的点,姑且称之为浮点吧^_^
    private int currX, currY;
    
    public PolyGon(Color c, Stroke s, int x, int y) {
        pointsSet = new PointsSet();
        color = c;
        stroke = s;
        pointsSet.addPoint(x, y);
        //刚开始先把浮点设置到起始顶点
        currX = x;
        currY = y;
        finalized = false;
    }
    
    public void processCursorEvent(MouseEvent e, int t) {
        //首先更新浮点坐标

        currX = e.getX();
        currY = e.getY();
        //右键按下时,将浮点加入到顶点集里
        if (t == IShape.RIGHT_PRESSED) {
            pointsSet.addPoint(currX, currY);
        //左键按下时,设置多边形到完成状态,并且将浮点加入顶点集中
        } else if (t == IShape.LEFT_RELEASED) {
            finalized = true;
            pointsSet.addPoint(currX, currY);
        }
        /* 注意:上面的if-else结构只包含了RIGHT_PRESSED和LEFT_RELEASED两种情况,
           不过,这个方法也处理了CURSOR_DRAGGED这种情况,就是更新浮点坐标 */

    }
    
    public void draw(Graphics2D g) {
        g.setColor(color);
        g.setStroke(stroke);
        if (finalized) {
            //一旦图形完成,浮点就不再用到了
            int[][] points = pointsSet.getPoints();
            int s = points[0].length;
            //这部分跟PolyLine类似
            if (s == 1) {
                int x = points[0][0];
                int y = points[1][0];
                g.drawLine(x, y, x, y);
            } else {
                g.drawPolygon(points[0], points[1], s);
            }
        } else { //图形没完成的情况下,显示的时候要用到浮点


『其他』

DrawingBoard(extends JPanel)是附件程序中用的画图板类,它是在TestBoard类上的一个扩展,加入了其他的模型类。另外,它提供了一些方法让外部控制界面来设置绘图色,画图板背景色,画图线条,橡皮擦大小(也是通过改变线条实现的)。这些就不再一一赘述了。

AppFrame(extends JFrame)用来放画图板和控制面板。

此外,在稍微变动代码的情况下,还可以加入新的图形类,当然这些类要实现IShape接口,比如,直接继承RectBoundedShape,定义新的图形显示代码。