Android应用中平滑的手写效果实现

在信用卡支付流程中,使用手写签名能够提高支付的安全性,并有效降低过程成本。使用Square在手机上进行支付,用户可以用手指在屏幕上签名,无需拿出笔来在收据上签字。

在这里插入图片描述

小窍门:该界面中提供了手机摇一摇清屏的功能

用户在该界面提供的签名,将签署在电子邮件收据中,以帮助Square监测和防止消费欺诈。

下面我们尝试在Android客户端上实现该界面,先尝试从最简单可行的方式开始:生成一个自定义View,能够监听触屏事件,并根据触摸路径画出点。

Java代码  收藏代码
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类的方法获取

·getHistorySize()

·getHistoricalX(int)

·getHistoricalY(int)

下面我们利用这些方法,将中间点包含进SignatureView的绘制:

Java代码  收藏代码
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的最终完成代码,我们去掉了一些无关的方法(如摇动检测)

Java代码  收藏代码
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计算机课程讲义。

完成了从线性插值到曲线插值,乍看差异很细微,但整体的圆滑效果提升却相当明显。

在这里插入图片描述

笔划粗细变化
如果你仔细研究下写在纸上的手写签名,不难发现笔划的粗细并不是一成不变的。笔划的粗细是随着笔的速度和用力程度而改变的。尽管Android提供了一个跟踪触屏力度的API,但其效果并没有达到我们用于签名所需的灵敏度与连贯性。还好,跟踪笔划速度是可以实现的,我们仅需要将每个触点的采集时间作tag标记,然后就可以计算点到点之间的速度了。

Java代码  收藏代码
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);  
  }  
}  

由于我们的绘制了签名的每个Bezier曲线,笔划的粗细依据可为每段曲线的起止点间的速度。

Java代码  收藏代码
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;  
}  

当我们动手实现的时候,却碰到了一个棘手的问题——Android的canvas API没有绘制曲线宽度可变的Bezier曲线的相关方法。这意味着我们必需以点成线,自己点画出目标曲线。

Java代码  收藏代码
/** 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);  
}  

可以看到,笔划粗细变化的签名,更加接近真实的手写效果。

在这里插入图片描述

响应能力
影响一个签名过程愉悦程度的另外一个重要因素是对输入的响应能力。使用纸笔签名时,笔的移动与纸上笔划出现是没有任何延迟的。而在触摸屏设备上,出现响应延迟在所难免。我们要做的是尽可能地减少这种延迟感,缩短用户手指在屏幕上滑动与签名笔划出现之间的时间间隔。
一种简单渲染策略是将所有的Bezier曲线在我们signatureView的onDraw()方法中绘制。

Java代码  收藏代码
@Override protected void onDraw(Canvas canvas) {  
  for (Bezier curve : signature) {  
    curve.draw(canvas, paint, curve.startWidth(), curve.endWidth());  
  }  
}  

之前提到,我们绘制Bezierq曲线的方法是多次调用canvas.drawPoint(…)方法来以点成线。每个曲线重绘,对于笔划简单的签名还算可行,但对笔划较为复杂的签名则明显感觉到很慢。即使采用指定区域刷新的方法,绘制重叠线段仍然会严重拖慢签名响应。
解决方法是当签名每增加一个曲线时,将相应的Bezier曲线绘制到一个内存中的Bitmap中。之后只需要在onDraw()方法中画出该bitmap,而不需要在整个签名过程中对每条曲线重复运行Bezier曲线绘制算法。

Java代码  收藏代码
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缓存使得绘制响应得到优化。最终的成果是用户能够得到一个愉悦的签名体验和一个漂亮的签名。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值