Android - 學習撰寫一個手繪的程式
在撰寫WP7(Silverlight)針對想實作一個繪圖的程式,其實是非常容易的,因為有「InkPresenter控制項」可以使用,
它不需要太多的Code就可以完成用戶在操作的筆跡,完成最簡單的繪圖程式。但在Android上,要實作這樣的功能,
相較於WP7而言就需要花一點時間好好研究一下View與SurfaceView之間的差異了。
因此,本篇針對如何撰寫一個繪圖程式,參考<Android- draw a line on touch - error on event>的內容,做一個完整的說明。
〉參考文件<Android- draw a line on touch - error on event>中的範例程式(SignatureView):
由於在撰寫這篇文件的過程裡,我已經試寫過很多相關View與SurfaceView在TouchMove時onDraw繪製線條的範例,
但效果都不好,有時點與點之間不連貫、畫面會抖動、畫出的線不夠快…等,種種原因,讓我花了不少的時間與成本。
但這些問題,到這次參考的該篇文章,已經被解決了,請看「sciutand」提供的範例程式:
[參考 - 範例程式:SignatureView]
1: import java.io.ByteArrayOutputStream;
2:
3: import android.content.Context;
4: import android.content.res.TypedArray;
5: import android.graphics.Bitmap;
6: import android.graphics.Bitmap.CompressFormat;
7: import android.graphics.Canvas;
8: import android.graphics.Color;
9: import android.graphics.Paint;
10: import android.graphics.Path;
11: import android.graphics.Rect;
12: import android.util.AttributeSet;
13: import android.util.Log;
14: import android.view.MotionEvent;
15: import android.view.View;
16:
17: public class SignatureView extends View {
18:
19: private final String LOG_TAG = this.getClass().getSimpleName();
20:
21: //定義繪圖的基本參數:線的width, color;是否擷取狀態;簽名圖示bitmap
22: private float mSignatureWidth = 8f;
23: private int mSignatureColor = Color.YELLOW;
24: private boolean mCapturing = true;
25: private Bitmap mSignature = null;
26:
27: //定義防止線條有鋸齒的常數
28: private static final boolean GESTURE_RENDERING_ANTIALIAS = true;
29: private static final boolean DITHER_FLAG = true;
30:
31: private Paint mPaint = new Paint();
32: private Path mPath = new Path();
33:
34: //矩形
35: private final Rect mInvalidRect = new Rect();
36:
37: private float mX;
38: private float mY;
39:
40: private float mCurveEndX;
41: private float mCurveEndY;
42:
43: private int mInvalidateExtraBorder = 10;
44:
45: public SignatureView(Context context, AttributeSet attrs, int defStyle) {
46: super(context, attrs, defStyle);
47: init();
48: }
49:
50: public SignatureView(Context context) {
51: super(context);
52: init();
53: }
54:
55: public SignatureView(Context context, AttributeSet attrs) {
56: super(context, attrs);
57: init();
58: }
59:
60: private void init() {
61: setWillNotDraw(false);
62:
63: mPaint.setAntiAlias(GESTURE_RENDERING_ANTIALIAS);
64: mPaint.setColor(mSignatureColor);
65: mPaint.setStyle(Paint.Style.STROKE);
66: mPaint.setStrokeJoin(Paint.Join.ROUND);
67: mPaint.setStrokeCap(Paint.Cap.ROUND);
68: mPaint.setStrokeWidth(mSignatureWidth);
69: mPaint.setDither(DITHER_FLAG);
70: mPath.reset();
71: }
72:
73: @Override
74: protected void onDraw(Canvas canvas) {
75: if (mSignature != null) {
76: canvas.drawBitmap(mSignature, null, new Rect(0, 0, getWidth(),
77: getHeight()), null);
78: } else {
79: canvas.drawPath(mPath, mPaint);
80: }
81: }
82:
83: @Override
84: public boolean dispatchTouchEvent(MotionEvent event) {
85: if (mCapturing) {
86: processEvent(event);
87: Log.d(VIEW_LOG_TAG, "dispatchTouchEvent");
88: return true;
89: } else {
90: return false;
91: }
92: }
93:
94: private boolean processEvent(MotionEvent event) {
95: switch (event.getAction()) {
96: case MotionEvent.ACTION_DOWN:
97: touchDown(event);
98: invalidate();
99: return true;
100:
101: case MotionEvent.ACTION_MOVE:
102: Rect rect = touchMove(event);
103: if (rect != null) {
104: invalidate(rect);
105: }
106: return true;
107:
108: case MotionEvent.ACTION_UP:
109: touchUp(event, false);
110: invalidate();
111: return true;
112:
113: case MotionEvent.ACTION_CANCEL:
114: touchUp(event, true);
115: invalidate();
116: return true;
117: }
118: return false;
119: }
120:
121: private void touchUp(MotionEvent event, boolean b) {
122: // TODO Auto-generated method stub
123: }
124:
125: private Rect touchMove(MotionEvent event) {
126: Rect areaToRefresh = null;
127:
128: final float x = event.getX();
129: final float y = event.getY();
130:
131: final float previousX = mX;
132: final float previousY = mY;
133:
134: areaToRefresh = mInvalidRect;
135:
136: // start with the curve end
137: final int border = mInvalidateExtraBorder;
138: areaToRefresh.set((int) mCurveEndX - border, (int) mCurveEndY - border,
139: (int) mCurveEndX + border, (int) mCurveEndY + border);
140:
141: float cX = mCurveEndX = (x + previousX) / 2;
142: float cY = mCurveEndY = (y + previousY) / 2;
143:
144: mPath.quadTo(previousX, previousY, cX, cY);
145:
146: // union with the control point of the new curve
147: areaToRefresh.union((int) previousX - border, (int) previousY - border,
148: (int) previousX + border, (int) previousY + border);
149:
150: // union with the end point of the new curve
151: areaToRefresh.union((int) cX - border, (int) cY - border, (int) cX
152: + border, (int) cY + border);
153:
154: mX = x;
155: mY = y;
156:
157: return areaToRefresh;
158:
159: }
160:
161: private void touchDown(MotionEvent event) {
162: float x = event.getX();
163: float y = event.getY();
164:
165: mX = x;
166: mY = y;
167: mPath.moveTo(x, y);
168:
169: final int border = mInvalidateExtraBorder;
170: mInvalidRect.set((int) x - border, (int) y - border, (int) x + border,
171: (int) y + border);
172:
173: mCurveEndX = x;
174: mCurveEndY = y;
175: }
176:
177: /**
178: * Erases the signature.
179: */
180: public void clear() {
181: mSignature = null;
182: mPath.rewind();
183: // Repaints the entire view.
184: invalidate();
185: }
186:
187: public boolean isCapturing() {
188: return mCapturing;
189: }
190:
191: public void setIsCapturing(boolean mCapturing) {
192: this.mCapturing = mCapturing;
193: }
194:
195: public void setSignatureBitmap(Bitmap signature) {
196: mSignature = signature;
197: invalidate();
198: }
199:
200: public Bitmap getSignatureBitmap() {
201: if (mSignature != null) {
202: return mSignature;
203: } else if (mPath.isEmpty()) {
204: return null;
205: } else {
206: Bitmap bmp = Bitmap.createBitmap(getWidth(), getHeight(),
207: Bitmap.Config.ARGB_8888);
208: Canvas c = new Canvas(bmp);
209: c.drawPath(mPath, mPaint);
210: return bmp;
211: }
212: }
213:
214: public void setSignatureWidth(float width) {
215: mSignatureWidth = width;
216: mPaint.setStrokeWidth(mSignatureWidth);
217: invalidate();
218: }
219:
220: public float getSignatureWidth(){
221: return mPaint.getStrokeWidth();
222: }
223:
224: public void setSignatureColor(int color) {
225: mSignatureColor = color;
226: }
227:
228: /**
229: * @return the byte array representing the signature as a PNG file format
230: */
231: public byte[] getSignaturePNG() {
232: return getSignatureBytes(CompressFormat.PNG, 0);
233: }
234:
235: /**
236: * @param quality Hint to the compressor, 0-100. 0 meaning compress for small
237: * size, 100 meaning compress for max quality.
238: * @return the byte array representing the signature as a JPEG file format
239: */
240: public byte[] getSignatureJPEG(int quality) {
241: return getSignatureBytes(CompressFormat.JPEG, quality);
242: }
243:
244: private byte[] getSignatureBytes(CompressFormat format, int quality) {
245: Log.d(LOG_TAG, "getSignatureBytes() path is empty: " + mPath.isEmpty());
246: Bitmap bmp = getSignatureBitmap();
247: if (bmp == null) {
248: return null;
249: } else {
250: ByteArrayOutputStream stream = new ByteArrayOutputStream();
251:
252: getSignatureBitmap().compress(format, quality, stream);
253:
254: return stream.toByteArray();
255: }
256: }
257: }
上述的程式碼,直接合併至自己現有的專案中,即可以馬上體驗它的效果,如下圖所示:
了解它提供的特性後,我開始思考該怎麼來解釋它裡面設計的方式。因此,針對幾個重點的觀念與元素,加以說明:
A. View:
在畫面呈現的繼承結構上,View是最原始的代表,定義了基礎需要的參數與方法,協助控制與設定UI層的呈現與事件,
有幾個重要方法:
A-1. onDraw(Canvas canvas):
該方法定義View要出現在畫面上時,必需執行的任務。通常在實作View時,均會Override該方法,定義新類別想要
呈現的樣式。而Canvas即是內容要呈現於上面的主要畫布,Canvas是繪圖系列最常見的類別。
A-2. dispatchTouchEvent:
關於dispatchTouchEvent主要分成二種:Activity與View,這二者都有自己的dispatchTouchEvent,最先被觸發的人是
Activity,接著才是View或ViewGroup。然而,dispatchTouchEvent是用於當補捉到TouchEvent事件時,分配給要處理
TouchEvent的對象,例如:ACTION_DOWN等其他對象。然而,在處理完畢後,如果確定下方已經沒有等待的View要接
著執行它們的dispatchTouchEvent的話,可回傳true;或者回傳false,接著往下傳給另一個等待的dispatchTouchEvent。
更詳細的討論可以參考<android中Touch事件的處理邏輯>。
A-3. invalidate:
該方法常用於View有修改內容時,為了讓繪製事件的結果有效的呈現於UI-Thread上,所以從上述範例中,可看到每一個
Touch事件在繪製完圖示後,均會執行該方法。另外,該方法是多載的,可配合Rect類別來使用。
A-4. setWillNotDraw:
根據<Android - View>的定義,主要用於如果View不做任何預設的Drawing動作,可設定該Flag讓未來實作OnDraw事件,
有更好的效果。預設是不設定的,但在ViewGroup部分實作的類別中有時也會設定該項目。
如果實作View時有覆寫OnDraw事件,記得設定該Flag:「setWillNotDraw(false);」。
B. 定義繪圖元件 (Paint)與路徑元件(Path):
B-1. Paint定義繪圖元素;
定義要繪製的Style與Color資訊,包括:如何繪製文字、幾何與圖示。以下介紹幾個範例中提到的方法:
方法 | 說明 |
setAntiAlias(boolean aa) | 用於定義在繪製過呈中,是否要防止線條有鋸齒情形。 |
setColor(int color) | 設定要使用的顏色。 |
setStyle(Paint.Style style) | 設定主要的樣式,使用於控制主要的幾何如何呈現。 |
setStrokeJoin(Paint.Join join) | 設定Paint加入時的方式。
搭配setStyle為Paint.Style.STROKE或StrokeAndFill時使用。 |
setStrokeCap | 設定Paint的線條樣式。
搭配setStyle為Paint.Style.STROKE或StrokeAndFill時使用。 |
setStrokeWidth | 設定Stroke的寬度。
搭配setStyle為Paint.Style.STROKE或StrokeAndFill時使用。 |
setDither | 設定或清除畫面抖動的視覺效果。 |
B-2. Path定義實際要繪製的路徑;
Path物件本身封裝了多種支援的路線與幾何路徑,包括:straight line segments、quadratic curves與cubic curves。
配合Canvas.drawPath(Path, Paint)使用,繪製由Paint定義好Style的路徑,另外,它也可被用於裁剪依據的路徑。
以下介紹這次範例會用的方法:
‧Path.quadTo(float x1, float y1, float x2, float y2):
quadTo代表的是從目前繪製路徑中的最後一個節點畫到下一個路徑節點之間,增加一個加上「quadratic bezier 」,
用以讓二個節點之間增加數個節點,連結起來更平滑,不會有急轉彎的線條出現。
其中:(x1, y1)代表中間要經過的節點;(x2, y2):代表最後要結束的節點;
舉例來說,最後一個繪製點是(10, 100),然後我Move至新的繪製點(11,130),這二點的路徑要連接起來,並增加經過的
節點,讓線條平滑,即可以透過該方法:Path.quadTo((10+11)/2, (100+130)/2, 11, 130);
‧Path.moveTo(float x, float y):移動未來新繪製點的起始座標(x,y)。
‧Path.rewind():用於清除已用完的線條與曲線路徑,以保持較快重用的內部資料結構。
C. Rect類別:
定義一個特定的矩型樣式,通常在建構子時就定義完畢:Rect (int left, int top, int right, int bottom)。常用於繪製一個範圍
的內容物,此次範例也透過它為繪製的主要依據,透過移動的路徑捉出要更新的矩形範圍,進行繪製與刷新容。往下介紹幾個
用到的方法:
‧Rect.set(int left, int top, int right, int bottom):使用特定的left, top , right, bottom定義Rect座標;
‧Rect.union(int left, int top, int right, int bottom):
要求更新Rect去附加上自己或特定的矩型,如果特定的矩型是空白,則不會執行任何動作。如果自己的矩型是空白,
則特定的矩型會取代自己的矩型。
在此範例中,在每一次TouchMove事件時,均使用Union的方法將既有的Rect與新的Rect進行附合,串聯起在整個View上繪製
的內容結果。
D. TouchEvent的處理:
在Touch事件的處理流程,是由:ACTION_DOWN->ACTION_MOVIE->ACTOIN_UP,透過識別MotionEvent參數的值,
加以過濾不同的觸發事件要完成的任務,以下將針對幾個重要類型進行說明:
D-1. TouchDown (MotionEvent.ACTION_DOWN):
定義啟始點的位置與定義Rect的出現樣式;例如範例:
1: private void touchDown(MotionEvent event) {
2: // 取得目前的觸發座標
3: float x = event.getX();
4: float y = event.getY();
5: // 利用全域變數記下目前座標,指定要繪製的路徑
6: mX = x;
7: mY = y;
8: mPath.moveTo(x, y);
9:
10: // 設定全域的Rect的範圍
11: final int border = mInvalidateExtraBorder;
12: mInvalidRect.set((int) x - border, (int) y - border, (int) x + border,
13: (int) y + border);
14:
15: mCurveEndX = x;
16: mCurveEndY = y;
17: }
D-2. TouchMove (MotionEvent.ACTION_MOVE):
取得移動時的座標,重新計算移動需繪製的路徑,並調整Rect的部分;
1: private Rect touchMove(MotionEvent event) {
2: //產生一個要更新的範圍
3: Rect areaToRefresh = null;
4: //取得最新的座標(X,Y)、上一個移動的座標(mX,mY)、上一個更新過的Rect
5: final float x = event.getX();
6: final float y = event.getY();
7:
8: final float previousX = mX;
9: final float previousY = mY;
10:
11: areaToRefresh = mInvalidRect;
12:
13: //重新繪製要更新的範圍
14: // start with the curve end
15: final int border = mInvalidateExtraBorder;
16: areaToRefresh.set((int) mCurveEndX - border, (int) mCurveEndY - border,
17: (int) mCurveEndX + border, (int) mCurveEndY + border);
18:
19: //取得現在的X,Y
20: float cX = mCurveEndX = (x + previousX) / 2;
21: float cY = mCurveEndY = (y + previousY) / 2;
22: //更新路徑
23: mPath.quadTo(previousX, previousY, cX, cY);
24:
25: //整合新更新的範圍
26: // union with the control point of the new curve
27: areaToRefresh.union((int) previousX - border, (int) previousY - border,
28: (int) previousX + border, (int) previousY + border);
29:
30: //整合新更新的範圍
31: // union with the end point of the new curve
32: areaToRefresh.union((int) cX - border, (int) cY - border, (int) cX
33: + border, (int) cY + border);
34:
35: mX = x;
36: mY = y;
37: //回傳areaToRefresh,並且要求畫面重新繪製它
38: return areaToRefresh;
39: }
這一段有幾個地方與方法需要去注意:
D-2-1. 在TouchMove事件啟始時,擷取現在新座標的所有X,Y、舊的X,Y與已更新過的Rect物件;
D-2-2. 定義需重新繪製Rect的範圍;
D-2-3. 計算現在最新的座標,並更新Path整合上一個與最新的座標;
D-2-4. 執行二次更新範圍的動作,以合併最新繪製出來的範圍至上一個範圍;
D-3. TouchUp (MotionEvent.ACTION_UP)與MotionEvent.ACTION_CANCEL:
這二個部分在此處比較沒有運作必要,由於繪製的時候在Down啟動第一個繪製點,Move過程裡又馬上更新與合併最近
的繪製結果,讓繪製過程中沒有間斷的感覺。
以上是針對整個提供的程式片段,對於有一些不清楚的方法與運作的機制,做一個詳細的說明,但其實在Touch事件與繪製畫面,
不是這麼簡單的事情,還有很多需要進行去研讀。
[範例程式]
該範例程式,主要組合<Android- draw a line on touch - error on event>提供的範例程式,試寫一個簡單的功能:
(1) 清除繪圖;
1: @Override
2: public boolean onOptionsItemSelected(MenuItem item) {
3: switch(item.getItemId()) {
4: case 100:
5: //將繪製出來的內容,轉成byte[]
6: byte[] tData = gSignature.getSignaturePNG();
7: //儲存檔案
8: SaveData(tData, this);
9: break;
10: case 101:
11: //清除畫面中的內容
12: gSignature.clear();
13: break;
14: }
15: return super.onOptionsItemSelected(item);
16: }
(2) 儲存繪圖;
1: private void SaveData(byte[] pData, Context pContext) {
2: //取得外部儲存區的路徑
3: String tSDPath = pContext.getExternalFilesDir(null).getAbsolutePath();
4: //定義新的資料夾:UNProj
5: File tFolder = new File(tSDPath, "UNProj");
6: if ( tFolder.exists() == false ) {
7: tFolder.mkdirs();
8: }
9: //儲存檔名為:test.png
10: File tFile = new File(tFolder, "test.png");
11: try {
12: //使用FileOutputStream將byte[]寫入檔案中
13: FileOutputStream tFOStream = new FileOutputStream(tFile, true);
14: tFOStream.write(pData);
15: tFOStream.flush();
16: tFOStream.close();
17: } catch (FileNotFoundException e) {
18: e.printStackTrace();
19: } catch (IOException e) {
20: e.printStackTrace();
21: }
22: }
(3) 設定背景圖並在圖上繪製;
修改上述的範例,增加一個識別用的Flag,識別如果要使用前,有設定為true的話,即使設定的底圖也一樣可以將繪製的內容
,在儲存時一併合併到底層,如下:
(3-1). 修改onDraw方法,讓它可以在有設定mSignature時一樣可以繪圖;
1: @Override
2: protected void onDraw(Canvas canvas) {
3: if (mSignature != null) {
4: canvas.drawBitmap(mSignature, null, new Rect(0, 0, getWidth(),
5: getHeight()), null);
6: //增加識別用的IsNeedBackgrdoundBitmap
7: if (IsNeedBackgroundBitmap == true)
8: canvas.drawPath(mPath, mPaint);
9: } else {
10: canvas.drawPath(mPath, mPaint);
11: }
12: }
(3-2). 修改getSignatureBitmap方法,讓在輸出成byte[]前將設定的背景圖與繪製出來的結果進行合併;
1: public Bitmap getSignatureBitmap() {
2: if (mSignature != null) {
3: //增加識別用的IsNeedBackgroundBitmap
4: if (IsNeedBackgroundBitmap == true) {
5: Bitmap bmp = Bitmap.createBitmap(getWidth(), getHeight(),
6: Bitmap.Config.ARGB_8888);
7: Canvas tC = new Canvas(bmp);
8: //將背景圖加入Canvas
9: tC.drawBitmap(mSignature, null, new Rect(0, 0, getWidth(),
10: getHeight()), null);
11: //將全部繪製的路線加入Canvas
12: tC.drawPath(mPath, mPaint);
13: //回傳
14: return bmp;
15: } else
16: return mSignature;
17: } else if (mPath.isEmpty()) {
18: return null;
19: } else {
20: Bitmap bmp = Bitmap.createBitmap(getWidth(), getHeight(),
21: Bitmap.Config.ARGB_8888);
22: Canvas c = new Canvas(bmp);
23: c.drawPath(mPath, mPaint);
24: return bmp;
25: }
26: }
======
由於我撰寫Android算是半路踏進來的,還在了解一些Android元件與特性,所以特別撰寫該篇來加以介紹初學者如何
撰寫繪圖程式的一些經驗。希望對新學習的人有些幫助,如果有撰寫錯誤的部分,也請大家多多指教。
更感謝在撰寫本篇文件時,阿緯的協助,幫我解釋了很多相關的元件特性與Java的觀念,非常感謝。
References:
‧Android 中的繪圖與動畫 (Graphics) & Android 遊戲開發Canvas和Paint教學 &
‧Android display架构分析(六)& Android游戏开发之旅(四)Canvas和Paint实例
‧Android画图学习总结(五)——Paint & Android绘图具体应用方式总结
‧[多媒体] Native Surface绘图遇到的问题 & Android之SurfaceView学习(一)
‧SurfaceView简单例子 & Android中SurfaceView的使用示例
‧Android- draw a line on touch - error on event (主要參考來源)
‧Android 2D Graphics Example & Android OpenGL ES DrawLine
‧Drawing on SurfaceView & 在SurfaceView上拖动一张小图片 & Drawing with SurfaceView
‧android Draw Rect 坐标图示 & android中绘图的方法
‧How to display a custom dialog in your Android application
‧how to write bytes in a file without overwriting it & Drawable、Bitmap、byte[]之间的转换
‧Working With Images In Android (重要)
‧[UI界面]请教一下dispatchTouchEvent()用法 & android事件处理总结—dispatchTouchEvent & android中Touch事件的處理邏輯