Android應用中平滑的手寫效果實現

原文章地址:http://blog.csdn.net/ekeuy/article/details/37962845#comments

在信用卡支付流程中,使用手寫簽名能夠提高支付的安全性,並有效降低過程成本。使用Square在手機上進行支付,用戶可以用手指在屏幕上簽名,無需拿出筆來在收據上簽字。


小竅門:該界面中提供了手機搖一搖清屏的功能

用戶在該界面提供的簽名,將簽署在電子郵件收據中,以幫助Square監測和防止消費欺詐。

下面我們嘗試在Android客戶端上實現該界面,先嚐試從最簡單可行的方式開始:生成一個自定義View,能夠監聽觸屏事件,並根據觸摸路徑畫出點。

 

Java代碼  收藏代碼
  1. public class SignatureView extends View {  
  2.   private Paint paint = new Paint();  
  3.   private Path path = new Path();  
  4.   
  5.   public SignatureView(Context context, AttributeSet attrs) {  
  6.     super(context, attrs);  
  7.   
  8.     paint.setAntiAlias(true);  
  9.     paint.setColor(Color.BLACK);  
  10.     paint.setStyle(Paint.Style.STROKE);  
  11.     paint.setStrokeJoin(Paint.Join.ROUND);  
  12.     paint.setStrokeWidth(5f);  
  13.   }  
  14.   
  15.   @Override  
  16.   protected void onDraw(Canvas canvas) {  
  17.     canvas.drawPath(path, paint);  
  18.   }  
  19.   
  20.   @Override  
  21.   public boolean onTouchEvent(MotionEvent event) {  
  22.     float eventX = event.getX();  
  23.     float eventY = event.getY();  
  24.   
  25.     switch (event.getAction()) {  
  26.       case MotionEvent.ACTION_DOWN:  
  27.         path.moveTo(eventX, eventY);  
  28.         return true;  
  29.       case MotionEvent.ACTION_MOVE:  
  30.       case MotionEvent.ACTION_UP:  
  31.         path.lineTo(eventX, eventY);  
  32.         break;  
  33.       default:  
  34.         return false;  
  35.     }  
  36.   
  37.     // Schedules a repaint.  
  38.     invalidate();  
  39.     return true;  
  40.   }  

可以看到實現出來的效果與預期有一定的差距——簽名的筆畫呈硬邦邦的鋸齒狀,而且與用戶交互遲鈍。

下面我們嘗試從兩個不同的途徑解決這個問題。

觸屏事件丟失

該實現效果的問題之一是,自定義View的響應與繪製未能跟上用戶手指的觸屏動作。我們一開始的顧慮是:

1.Android對觸屏事件的採樣率過低

2.繪製事件阻塞了觸屏事件的採樣

幸運的是,經過實驗考證,這兩個顧慮都沒有發生。同時,我們發現Android對觸屏事件進行批量處理。傳遞給onTouchEvent()的每一個MotionEvent都包含上至前一個onTouchEvent()調用之間捕獲的若干個座標點。如果將這些點都加入到繪製中,可使簽名效果更加平滑。

隱藏的座標數組可以通過以下MotionEvent類的方法獲取

·getHistorySize()

·getHistoricalX(int)

·getHistoricalY(int)

下面我們利用這些方法,將中間點包含進SignatureView的繪製:

 

Java代碼  收藏代碼
  1. public class SignatureView extends View {  
  2.   public boolean onTouchEvent(MotionEvent event) {  
  3.     ...  
  4.     switch (event.getAction()) {  
  5.       case MotionEvent.ACTION_MOVE:  
  6.       case MotionEvent.ACTION_UP:  
  7.   
  8.         // When the hardware tracks events faster than they are delivered,  
  9.         // the event will contain a history of those skipped points.  
  10.         int historySize = event.getHistorySize();  
  11.         for (int i = 0; i < historySize; i++) {  
  12.           float historicalX = event.getHistoricalX(i);  
  13.           float historicalY = event.getHistoricalY(i);  
  14.           path.lineTo(historicalX, historicalY);  
  15.         }  
  16.   
  17.         // After replaying history, connect the line to the touch point.  
  18.         path.lineTo(eventX, eventY);  
  19.         break;  
  20.     ...  
  21.   }  
  22. }  

這個簡單的改進,使簽名效果外觀有了顯著的提升。但該View對用戶觸屏的響應能力仍然不足。

局部刷新

我們的SignatureView在每一次調用onTouchEvent()時,會在觸屏座標之間畫線,並進行全屏刷新——即使只是很小的像素級變動,也需要全屏重繪。

顯然,全屏重繪效率低下且沒有必要。我們可以使用 View.invalidate(Rect) 方法,選擇性地對新添畫線的矩形區域進行局部刷新,可以顯著提高繪製性能。

採用的算法思路如下:

1.創建一個代表髒區域的矩形;

2.獲得 ACTION_DOWN 事件的 X 與 Y 座標,用來設置矩形的頂點;

3.獲得 ACTION_MOVE 和 ACTION_UP 事件的新座標點,加入到矩形中使之拓展開來(別忘了上文說過的歷史座標點);

4.刷新髒區域。

採用該算法後,我們能夠明顯感覺到觸屏響應性能的大幅提升。

出爐

以上我們對SignatureView進行了兩方面的改造提升:將觸屏事件的中間點加入繪製,使筆畫更加流暢逼真;以局部刷新取代全屏刷新,提高繪圖性能,使觸屏響應更加迅速。

最終出爐的效果:


下面是SignatureView的最終完成代碼,我們去掉了一些無關的方法(如搖動檢測)

 

Java代碼  收藏代碼
  1. public class SignatureView extends View {  
  2.   
  3.   private static final float STROKE_WIDTH = 5f;  
  4.   
  5.   /** Need to track this so the dirty region can accommodate the stroke. **/  
  6.   private static final float HALF_STROKE_WIDTH = STROKE_WIDTH / 2;  
  7.   
  8.   private Paint paint = new Paint();  
  9.   private Path path = new Path();  
  10.   
  11.   /** 
  12.    * Optimizes painting by invalidating the smallest possible area. 
  13.    */  
  14.   private float lastTouchX;  
  15.   private float lastTouchY;  
  16.   private final RectF dirtyRect = new RectF();  
  17.   
  18.   public SignatureView(Context context, AttributeSet attrs) {  
  19.     super(context, attrs);  
  20.   
  21.     paint.setAntiAlias(true);  
  22.     paint.setColor(Color.BLACK);  
  23.     paint.setStyle(Paint.Style.STROKE);  
  24.     paint.setStrokeJoin(Paint.Join.ROUND);  
  25.     paint.setStrokeWidth(STROKE_WIDTH);  
  26.   }  
  27.   
  28.   /** 
  29.    * Erases the signature. 
  30.    */  
  31.   public void clear() {  
  32.     path.reset();  
  33.   
  34.     // Repaints the entire view.  
  35.     invalidate();  
  36.   }  
  37.   
  38.   @Override  
  39.   protected void onDraw(Canvas canvas) {  
  40.     canvas.drawPath(path, paint);  
  41.   }  
  42.   
  43.   @Override  
  44.   public boolean onTouchEvent(MotionEvent event) {  
  45.     float eventX = event.getX();  
  46.     float eventY = event.getY();  
  47.   
  48.     switch (event.getAction()) {  
  49.       case MotionEvent.ACTION_DOWN:  
  50.         path.moveTo(eventX, eventY);  
  51.         lastTouchX = eventX;  
  52.         lastTouchY = eventY;  
  53.         // There is no end point yet, so don't waste cycles invalidating.  
  54.         return true;  
  55.   
  56.       case MotionEvent.ACTION_MOVE:  
  57.       case MotionEvent.ACTION_UP:  
  58.         // Start tracking the dirty region.  
  59.         resetDirtyRect(eventX, eventY);  
  60.   
  61.         // When the hardware tracks events faster than they are delivered, the  
  62.         // event will contain a history of those skipped points.  
  63.         int historySize = event.getHistorySize();  
  64.         for (int i = 0; i < historySize; i++) {  
  65.           float historicalX = event.getHistoricalX(i);  
  66.           float historicalY = event.getHistoricalY(i);  
  67.           expandDirtyRect(historicalX, historicalY);  
  68.           path.lineTo(historicalX, historicalY);  
  69.         }  
  70.   
  71.         // After replaying history, connect the line to the touch point.  
  72.         path.lineTo(eventX, eventY);  
  73.         break;  
  74.   
  75.       default:  
  76.         debug("Ignored touch event: " + event.toString());  
  77.         return false;  
  78.     }  
  79.   
  80.     // Include half the stroke width to avoid clipping.  
  81.     invalidate(  
  82.         (int) (dirtyRect.left - HALF_STROKE_WIDTH),  
  83.         (int) (dirtyRect.top - HALF_STROKE_WIDTH),  
  84.         (int) (dirtyRect.right + HALF_STROKE_WIDTH),  
  85.         (int) (dirtyRect.bottom + HALF_STROKE_WIDTH));  
  86.       
  87.     lastTouchX = eventX;  
  88.     lastTouchY = eventY;  
  89.   
  90.     return true;  
  91.   }  
  92.   
  93.   /** 
  94.    * Called when replaying history to ensure the dirty region includes all 
  95.    * points. 
  96.    */  
  97.   private void expandDirtyRect(float historicalX, float historicalY) {  
  98.     if (historicalX < dirtyRect.left) {  
  99.       dirtyRect.left = historicalX;  
  100.     } else if (historicalX > dirtyRect.right) {  
  101.       dirtyRect.right = historicalX;  
  102.     }  
  103.     if (historicalY < dirtyRect.top) {  
  104.       dirtyRect.top = historicalY;  
  105.     } else if (historicalY > dirtyRect.bottom) {  
  106.       dirtyRect.bottom = historicalY;  
  107.     }  
  108.   }  
  109.   
  110.   /** 
  111.    * Resets the dirty region when the motion event occurs. 
  112.    */  
  113.   private void resetDirtyRect(float eventX, float eventY) {  
  114.   
  115.     // The lastTouchX and lastTouchY were set when the ACTION_DOWN  
  116.     // motion event occurred.  
  117.     dirtyRect.left = Math.min(lastTouchX, eventX);  
  118.     dirtyRect.right = Math.max(lastTouchX, eventX);  
  119.     dirtyRect.top = Math.min(lastTouchY, eventY);  
  120.     dirtyRect.bottom = Math.max(lastTouchY, eventY);  
  121.   }  
  122. }  

上文中,我們討論了Square如何在Android設備上把簽名效果做的平滑。在最新發布的Android版Square Card Reader應用中,我們將簽名效果更上一層樓,更平滑,更美觀,響應更靈敏!改進主要來自於以下三個方面:使用改進的曲線算法、筆劃粗細變化、以bitmap緩存提升響應能力。

曲弧之美

當用戶在屏幕滑動手指進行簽名時,Android將一序列的觸屏事件傳遞給Square客戶端,每個觸屏事件都包含獨立的 (x,y) 座標。要創建出一個簽名的圖像,客戶端需要重建這些採樣觸點之間的連接線段。計算連接一序列離散點之間連接線段的過程,稱爲樣條插值

最簡單的樣條插值策略是直線連接每一對觸點。這也是之前版本的Square客戶端採用的策略。

可以看到,即使有足夠多的觸點去模擬簽名的曲線,線性插值方法呈現的效果仍顯得又硬又挫。仔細觀察圖中的簽名曲線,可以發現連接線在觸點處出現了硬角,原本應該是外圓弧狀的地方呈現出難看的扁平狀。

問題原因在於,用戶簽名時手指並不是直愣愣地作點到點直線划動,更多情況下是曲線式的移動。但我們的SignatureView只能捕捉到簽名過程中的採樣點,再通過猜測採樣點間連線來模擬用戶簽名的完整軌跡。顯然,直線連接並不是很好的模擬。

這裏較爲合適的一個插值方法是曲線擬合。我們發現三次Bezier插值曲線是最理想的插值算法。我們能夠利用Bezier控制點精確地確定曲線形狀,更讚的是我們能夠在網上輕鬆地找到很多高效的Bezier曲線繪製算法。

Bezier曲線繪製算法需要輸入一組用於生成曲線的控制點,但我們目前得到的只有在曲線上的採樣點本身,沒有Bezier控制點。由此,我們的樣條插值計算歸結爲,利用現有的採樣觸點,計算出一組用來作爲Bezier繪製算法輸入的控制點,畫出目標曲線。

這裏對平滑的三次方曲線繪製的相關數學知識不作詳細討論。有興趣的朋友可以閱讀Kirby Baker的UCLA計算機課程講義

完成了從線性插值到曲線插值,乍看差異很細微,但整體的圓滑效果提升卻相當明顯。


筆劃粗細變化
如果你仔細研究下寫在紙上的手寫簽名,不難發現筆劃的粗細並不是一成不變的。筆劃的粗細是隨着筆的速度和用力程度而改變的。儘管Android提供了一個跟蹤觸屏力度的API,但其效果並沒有達到我們用於簽名所需的靈敏度與連貫性。還好,跟蹤筆劃速度是可以實現的,我們僅需要將每個觸點的採集時間作tag標記,然後就可以計算點到點之間的速度了。
Java代碼  收藏代碼
  1. public class Point {  
  2.   private final float x;  
  3.   private final float y;  
  4.   private final long timestamp;  
  5.   // ...  
  6.     
  7.   public float velocityFrom(Point start) {  
  8.     return distanceTo(start) / (this.time - start.time);  
  9.   }  
  10. }  
由於我們的繪製了簽名的每個Bezier曲線,筆劃的粗細依據可爲每段曲線的起止點間的速度。
Java代碼  收藏代碼
  1. lastVelocity = initialVelocity;  
  2. lastWidth = intialStrokeWidth;  
  3.   
  4. public void addPoint(Point newPoint) {  
  5.   points.add(newPoint);  
  6.   Point lastPoint = points.get(points.size() - 1);  
  7.   Bezier bezier = new Bezier(lastPoint, newPoint);  
  8.      
  9.   float velocity = newPoint.velocityFrom(lastPoint);  
  10.   
  11.   // A simple lowpass filter to mitigate velocity aberrations.  
  12.   velocity = VELOCITY_FILTER_WEIGHT * velocity   
  13.       + (1 - VELOCITY_FILTER_WEIGHT) * lastVelocity;  
  14.   
  15.   // The new width is a function of the velocity. Higher velocities  
  16.   // correspond to thinner strokes.  
  17.   float newWidth = strokeWidth(velocity);  
  18.   
  19.   // The Bezier's width starts out as last curve's final width, and  
  20.   // gradually changes to the stroke width just calculated. The new  
  21.   // width calculation is based on the velocity between the Bezier's   
  22.   // start and end points.  
  23.   addBezier(bezier, lastWidth, newWidth);  
  24.   
  25.   lastVelocity = velocity;  
  26.   lastWidth = strokeWidth;  
  27. }  
當我們動手實現的時候,卻碰到了一個棘手的問題——Android的canvas API沒有繪製曲線寬度可變的Bezier曲線的相關方法。這意味着我們必需以點成線,自己點畫出目標曲線。
Java代碼  收藏代碼
  1. /** Draws a variable-width Bezier curve. */  
  2. public void draw(Canvas canvas, Paint paint, float startWidth, float endWidth) {  
  3.   float originalWidth = paint.getStrokeWidth();  
  4.   float widthDelta = endWidth - startWidth;  
  5.   
  6.   for (int i = 0; i < drawSteps; i++) {  
  7.     // Calculate the Bezier (x, y) coordinate for this step.  
  8.     float t = ((float) i) / drawSteps;  
  9.     float tt = t * t;  
  10.     float ttt = tt * t;  
  11.     float u = 1 - t;  
  12.     float uu = u * u;  
  13.     float uuu = uu * u;  
  14.   
  15.     float x = uuu * startPoint.x;  
  16.     x += 3 * uu * t * control1.x;  
  17.     x += 3 * u * tt * control2.x;  
  18.     x += ttt * endPoint.x;  
  19.   
  20.     float y = uuu * startPoint.y;  
  21.     y += 3 * uu * t * control1.y;  
  22.     y += 3 * u * tt * control2.y;  
  23.     y += ttt * endPoint.y;  
  24.   
  25.     // Set the incremental stroke width and draw.  
  26.     paint.setStrokeWidth(startWidth + ttt * widthDelta);  
  27.     canvas.drawPoint(x, y, paint);  
  28.   }  
  29.   
  30.   paint.setStrokeWidth(originalWidth);  
  31. }  
可以看到,筆劃粗細變化的簽名,更加接近真實的手寫效果。

響應能力
影響一個簽名過程愉悅程度的另外一個重要因素是對輸入的響應能力。使用紙筆簽名時,筆的移動與紙上筆劃出現是沒有任何延遲的。而在觸摸屏設備上,出現響應延遲在所難免。我們要做的是儘可能地減少這種延遲感,縮短用戶手指在屏幕上滑動與簽名筆劃出現之間的時間間隔。
一種簡單渲染策略是將所有的Bezier曲線在我們signatureView的onDraw()方法中繪製。
Java代碼  收藏代碼
  1. @Override protected void onDraw(Canvas canvas) {  
  2.   for (Bezier curve : signature) {  
  3.     curve.draw(canvas, paint, curve.startWidth(), curve.endWidth());  
  4.   }  
  5. }  
之前提到,我們繪製Bezierq曲線的方法是多次調用canvas.drawPoint(...)方法來以點成線。每個曲線重繪,對於筆劃簡單的簽名還算可行,但對筆劃較爲複雜的簽名則明顯感覺到很慢。即使採用指定區域刷新的方法,繪製重疊線段仍然會嚴重拖慢簽名響應。
解決方法是當簽名每增加一個曲線時,將相應的Bezier曲線繪製到一個內存中的Bitmap中。之後只需要在onDraw()方法中畫出該bitmap,而不需要在整個簽名過程中對每條曲線重複運行Bezier曲線繪製算法。
Java代碼  收藏代碼
  1. Bitmap bitmap = null;  
  2. Canvas bitmapCanvas = null;  
  3.   
  4. private void addBezier(Bezier curve, float startWidth, float endWidth) {  
  5.   if (bitmap == null) {  
  6.     bitmap = Bitmap.createBitmap(getWidth(), getHeight(),   
  7.         Bitmap.Config.ARGB_8888);  
  8.     bitmapCanvas = new Canvas(bitmap);  
  9.   }  
  10.   curve.draw(bitmapCanvas, paint, startWidth, endWidth);  
  11. }  
  12.   
  13. @Override protected void onDraw(Canvas canvas) {  
  14.   canvas.drawBitmap(bitmap, 00, paint);  
  15. }  
使用該方法能保證簽名的繪製響應,不受簽名複雜度的影響。
最終成品
綜上所述,我們採用了三次樣條插值來使簽名效果更平滑,基於筆劃速度的筆劃粗細可變效果使簽名更真實,bitmap緩存使得繪製響應得到優化。最終的成果是用戶能夠得到一個愉悅的簽名體驗和一個漂亮的簽名。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章