原文章地址:http://blog.csdn.net/ekeuy/article/details/37962845#comments
在信用卡支付流程中,使用手寫簽名能夠提高支付的安全性,並有效降低過程成本。使用Square在手機上進行支付,用戶可以用手指在屏幕上簽名,無需拿出筆來在收據上簽字。
小竅門:該界面中提供了手機搖一搖清屏的功能
用戶在該界面提供的簽名,將簽署在電子郵件收據中,以幫助Square監測和防止消費欺詐。
下面我們嘗試在Android客戶端上實現該界面,先嚐試從最簡單可行的方式開始:生成一個自定義View,能夠監聽觸屏事件,並根據觸摸路徑畫出點。
- public class SignatureView extends View {
- private Paint paint = new Paint();
- private Path path = new Path();
- public SignatureView(Context context, AttributeSet attrs) {
- super(context, attrs);
- paint.setAntiAlias(true);
- paint.setColor(Color.BLACK);
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeJoin(Paint.Join.ROUND);
- paint.setStrokeWidth(5f);
- }
- @Override
- protected void onDraw(Canvas canvas) {
- canvas.drawPath(path, paint);
- }
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- float eventX = event.getX();
- float eventY = event.getY();
- switch (event.getAction()) {
- case MotionEvent.ACTION_DOWN:
- path.moveTo(eventX, eventY);
- return true;
- case MotionEvent.ACTION_MOVE:
- case MotionEvent.ACTION_UP:
- path.lineTo(eventX, eventY);
- break;
- default:
- return false;
- }
- // Schedules a repaint.
- invalidate();
- return true;
- }
- }
可以看到實現出來的效果與預期有一定的差距——簽名的筆畫呈硬邦邦的鋸齒狀,而且與用戶交互遲鈍。
下面我們嘗試從兩個不同的途徑解決這個問題。
觸屏事件丟失
該實現效果的問題之一是,自定義View的響應與繪製未能跟上用戶手指的觸屏動作。我們一開始的顧慮是:
1.Android對觸屏事件的採樣率過低
2.繪製事件阻塞了觸屏事件的採樣
幸運的是,經過實驗考證,這兩個顧慮都沒有發生。同時,我們發現Android對觸屏事件進行批量處理。傳遞給onTouchEvent()的每一個MotionEvent都包含上至前一個onTouchEvent()調用之間捕獲的若干個座標點。如果將這些點都加入到繪製中,可使簽名效果更加平滑。
隱藏的座標數組可以通過以下MotionEvent類的方法獲取
下面我們利用這些方法,將中間點包含進SignatureView的繪製:
- public class SignatureView extends View {
- public boolean onTouchEvent(MotionEvent event) {
- ...
- switch (event.getAction()) {
- case MotionEvent.ACTION_MOVE:
- case MotionEvent.ACTION_UP:
- // When the hardware tracks events faster than they are delivered,
- // the event will contain a history of those skipped points.
- int historySize = event.getHistorySize();
- for (int i = 0; i < historySize; i++) {
- float historicalX = event.getHistoricalX(i);
- float historicalY = event.getHistoricalY(i);
- path.lineTo(historicalX, historicalY);
- }
- // After replaying history, connect the line to the touch point.
- path.lineTo(eventX, eventY);
- break;
- ...
- }
- }
這個簡單的改進,使簽名效果外觀有了顯著的提升。但該View對用戶觸屏的響應能力仍然不足。
局部刷新
我們的SignatureView在每一次調用onTouchEvent()時,會在觸屏座標之間畫線,並進行全屏刷新——即使只是很小的像素級變動,也需要全屏重繪。
顯然,全屏重繪效率低下且沒有必要。我們可以使用 View.invalidate(Rect) 方法,選擇性地對新添畫線的矩形區域進行局部刷新,可以顯著提高繪製性能。
採用的算法思路如下:
1.創建一個代表髒區域的矩形;
2.獲得 ACTION_DOWN 事件的 X 與 Y 座標,用來設置矩形的頂點;
3.獲得 ACTION_MOVE 和 ACTION_UP 事件的新座標點,加入到矩形中使之拓展開來(別忘了上文說過的歷史座標點);
4.刷新髒區域。
採用該算法後,我們能夠明顯感覺到觸屏響應性能的大幅提升。
出爐
以上我們對SignatureView進行了兩方面的改造提升:將觸屏事件的中間點加入繪製,使筆畫更加流暢逼真;以局部刷新取代全屏刷新,提高繪圖性能,使觸屏響應更加迅速。
最終出爐的效果:
下面是SignatureView的最終完成代碼,我們去掉了一些無關的方法(如搖動檢測)
- public class SignatureView extends View {
- private static final float STROKE_WIDTH = 5f;
- /** Need to track this so the dirty region can accommodate the stroke. **/
- private static final float HALF_STROKE_WIDTH = STROKE_WIDTH / 2;
- private Paint paint = new Paint();
- private Path path = new Path();
- /**
- * Optimizes painting by invalidating the smallest possible area.
- */
- private float lastTouchX;
- private float lastTouchY;
- private final RectF dirtyRect = new RectF();
- public SignatureView(Context context, AttributeSet attrs) {
- super(context, attrs);
- paint.setAntiAlias(true);
- paint.setColor(Color.BLACK);
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeJoin(Paint.Join.ROUND);
- paint.setStrokeWidth(STROKE_WIDTH);
- }
- /**
- * Erases the signature.
- */
- public void clear() {
- path.reset();
- // Repaints the entire view.
- invalidate();
- }
- @Override
- protected void onDraw(Canvas canvas) {
- canvas.drawPath(path, paint);
- }
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- float eventX = event.getX();
- float eventY = event.getY();
- switch (event.getAction()) {
- case MotionEvent.ACTION_DOWN:
- path.moveTo(eventX, eventY);
- lastTouchX = eventX;
- lastTouchY = eventY;
- // There is no end point yet, so don't waste cycles invalidating.
- return true;
- case MotionEvent.ACTION_MOVE:
- case MotionEvent.ACTION_UP:
- // Start tracking the dirty region.
- resetDirtyRect(eventX, eventY);
- // When the hardware tracks events faster than they are delivered, the
- // event will contain a history of those skipped points.
- int historySize = event.getHistorySize();
- for (int i = 0; i < historySize; i++) {
- float historicalX = event.getHistoricalX(i);
- float historicalY = event.getHistoricalY(i);
- expandDirtyRect(historicalX, historicalY);
- path.lineTo(historicalX, historicalY);
- }
- // After replaying history, connect the line to the touch point.
- path.lineTo(eventX, eventY);
- break;
- default:
- debug("Ignored touch event: " + event.toString());
- return false;
- }
- // Include half the stroke width to avoid clipping.
- invalidate(
- (int) (dirtyRect.left - HALF_STROKE_WIDTH),
- (int) (dirtyRect.top - HALF_STROKE_WIDTH),
- (int) (dirtyRect.right + HALF_STROKE_WIDTH),
- (int) (dirtyRect.bottom + HALF_STROKE_WIDTH));
- lastTouchX = eventX;
- lastTouchY = eventY;
- return true;
- }
- /**
- * Called when replaying history to ensure the dirty region includes all
- * points.
- */
- private void expandDirtyRect(float historicalX, float historicalY) {
- if (historicalX < dirtyRect.left) {
- dirtyRect.left = historicalX;
- } else if (historicalX > dirtyRect.right) {
- dirtyRect.right = historicalX;
- }
- if (historicalY < dirtyRect.top) {
- dirtyRect.top = historicalY;
- } else if (historicalY > dirtyRect.bottom) {
- dirtyRect.bottom = historicalY;
- }
- }
- /**
- * Resets the dirty region when the motion event occurs.
- */
- private void resetDirtyRect(float eventX, float eventY) {
- // The lastTouchX and lastTouchY were set when the ACTION_DOWN
- // motion event occurred.
- dirtyRect.left = Math.min(lastTouchX, eventX);
- dirtyRect.right = Math.max(lastTouchX, eventX);
- dirtyRect.top = Math.min(lastTouchY, eventY);
- dirtyRect.bottom = Math.max(lastTouchY, eventY);
- }
- }
在上文中,我們討論了Square如何在Android設備上把簽名效果做的平滑。在最新發布的Android版Square Card Reader應用中,我們將簽名效果更上一層樓,更平滑,更美觀,響應更靈敏!改進主要來自於以下三個方面:使用改進的曲線算法、筆劃粗細變化、以bitmap緩存提升響應能力。
曲弧之美
當用戶在屏幕滑動手指進行簽名時,Android將一序列的觸屏事件傳遞給Square客戶端,每個觸屏事件都包含獨立的 (x,y) 座標。要創建出一個簽名的圖像,客戶端需要重建這些採樣觸點之間的連接線段。計算連接一序列離散點之間連接線段的過程,稱爲樣條插值。
最簡單的樣條插值策略是直線連接每一對觸點。這也是之前版本的Square客戶端採用的策略。
可以看到,即使有足夠多的觸點去模擬簽名的曲線,線性插值方法呈現的效果仍顯得又硬又挫。仔細觀察圖中的簽名曲線,可以發現連接線在觸點處出現了硬角,原本應該是外圓弧狀的地方呈現出難看的扁平狀。
問題原因在於,用戶簽名時手指並不是直愣愣地作點到點直線划動,更多情況下是曲線式的移動。但我們的SignatureView只能捕捉到簽名過程中的採樣點,再通過猜測採樣點間連線來模擬用戶簽名的完整軌跡。顯然,直線連接並不是很好的模擬。
這裏較爲合適的一個插值方法是曲線擬合。我們發現三次Bezier插值曲線是最理想的插值算法。我們能夠利用Bezier控制點精確地確定曲線形狀,更讚的是我們能夠在網上輕鬆地找到很多高效的Bezier曲線繪製算法。
Bezier曲線繪製算法需要輸入一組用於生成曲線的控制點,但我們目前得到的只有在曲線上的採樣點本身,沒有Bezier控制點。由此,我們的樣條插值計算歸結爲,利用現有的採樣觸點,計算出一組用來作爲Bezier繪製算法輸入的控制點,畫出目標曲線。
這裏對平滑的三次方曲線繪製的相關數學知識不作詳細討論。有興趣的朋友可以閱讀Kirby Baker的UCLA計算機課程講義。
完成了從線性插值到曲線插值,乍看差異很細微,但整體的圓滑效果提升卻相當明顯。
- public class Point {
- private final float x;
- private final float y;
- private final long timestamp;
- // ...
- public float velocityFrom(Point start) {
- return distanceTo(start) / (this.time - start.time);
- }
- }
- lastVelocity = initialVelocity;
- lastWidth = intialStrokeWidth;
- public void addPoint(Point newPoint) {
- points.add(newPoint);
- Point lastPoint = points.get(points.size() - 1);
- Bezier bezier = new Bezier(lastPoint, newPoint);
- float velocity = newPoint.velocityFrom(lastPoint);
- // A simple lowpass filter to mitigate velocity aberrations.
- velocity = VELOCITY_FILTER_WEIGHT * velocity
- + (1 - VELOCITY_FILTER_WEIGHT) * lastVelocity;
- // The new width is a function of the velocity. Higher velocities
- // correspond to thinner strokes.
- float newWidth = strokeWidth(velocity);
- // The Bezier's width starts out as last curve's final width, and
- // gradually changes to the stroke width just calculated. The new
- // width calculation is based on the velocity between the Bezier's
- // start and end points.
- addBezier(bezier, lastWidth, newWidth);
- lastVelocity = velocity;
- lastWidth = strokeWidth;
- }
- /** Draws a variable-width Bezier curve. */
- public void draw(Canvas canvas, Paint paint, float startWidth, float endWidth) {
- float originalWidth = paint.getStrokeWidth();
- float widthDelta = endWidth - startWidth;
- for (int i = 0; i < drawSteps; i++) {
- // Calculate the Bezier (x, y) coordinate for this step.
- float t = ((float) i) / drawSteps;
- float tt = t * t;
- float ttt = tt * t;
- float u = 1 - t;
- float uu = u * u;
- float uuu = uu * u;
- float x = uuu * startPoint.x;
- x += 3 * uu * t * control1.x;
- x += 3 * u * tt * control2.x;
- x += ttt * endPoint.x;
- float y = uuu * startPoint.y;
- y += 3 * uu * t * control1.y;
- y += 3 * u * tt * control2.y;
- y += ttt * endPoint.y;
- // Set the incremental stroke width and draw.
- paint.setStrokeWidth(startWidth + ttt * widthDelta);
- canvas.drawPoint(x, y, paint);
- }
- paint.setStrokeWidth(originalWidth);
- }
- @Override protected void onDraw(Canvas canvas) {
- for (Bezier curve : signature) {
- curve.draw(canvas, paint, curve.startWidth(), curve.endWidth());
- }
- }
- Bitmap bitmap = null;
- Canvas bitmapCanvas = null;
- private void addBezier(Bezier curve, float startWidth, float endWidth) {
- if (bitmap == null) {
- bitmap = Bitmap.createBitmap(getWidth(), getHeight(),
- Bitmap.Config.ARGB_8888);
- bitmapCanvas = new Canvas(bitmap);
- }
- curve.draw(bitmapCanvas, paint, startWidth, endWidth);
- }
- @Override protected void onDraw(Canvas canvas) {
- canvas.drawBitmap(bitmap, 0, 0, paint);
- }
綜上所述,我們採用了三次樣條插值來使簽名效果更平滑,基於筆劃速度的筆劃粗細可變效果使簽名更真實,bitmap緩存使得繪製響應得到優化。最終的成果是用戶能夠得到一個愉悅的簽名體驗和一個漂亮的簽名。