本文講述一個畫圖板應用程序的設計,屏幕抓圖如下。這篇文章帶有三個附件,其中兩個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(); } } //對下面這些事件不感興趣 public void mouseClicked(MouseEvent e) {} public void mouseEntered(MouseEvent e) {} public void mouseExited(MouseEvent e) {} public void mouseMoved(MouseEvent e) {} }
至此,整個程序的流程就很清楚了,文章開頭部分的問題也被解開了。接下來,就繼續來看其他的模型類。
『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鍵沒有按下,跟父類一樣處理 } else { endX = x; endY = y; } }
『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 { //圖形沒完成的情況下,顯示的時候要用到浮點 int[][] points = pointsSet.getPoints(currX, currY); g.drawPolyline(points[0], points[1], points[0].length); } } }
『其他』
DrawingBoard(extends JPanel)是附件程序中用的畫圖板類,它是在TestBoard類上的一個擴展,加入了其他的模型類。另外,它提供了一些方法讓外部控制界面來設置繪圖色,畫圖板背景色,畫圖線條,橡皮擦大小(也是通過改變線條實現的)。這些就不再一一贅述了。
AppFrame(extends JFrame)用來放畫圖板和控制面板。
此外,在稍微變動代碼的情況下,還可以加入新的圖形類,當然這些類要實現IShape接口,比如,直接繼承RectBoundedShape,定義新的圖形顯示代碼。