Android 项目:画图白板APP开发(二)——历史点、数学方式推导点
上一章我们讲解了如何绘制顺滑、优美的曲线,为本项目的绘图功能打下了基础。本章我们将深入探讨两个关键功能的实现:历史点和数学方式推导点。这些功能将大幅提升我们白板应用的专业性和用户体验。
一、History点
之前在onTouchEvent中获取的MotionEvent,其实不是一个点的信息,而是一个触摸事件的封装。
(1)基本概念
在 Android 中,当用户触摸屏幕时,系统会生成一系列 MotionEvent 对象。为了提高效率,系统不会为每一个微小的移动都生成一个新事件,而是会将多个触摸点"打包"在一个 MotionEvent 中。
(2)代码示例
@Override
public boolean onTouchEvent(MotionEvent event) {final int action = event.getAction();//返回当前 MotionEvent 中包含的历史触摸点数量final int historySize = event.getHistorySize();for (int h = 0; h < historySize; h++) {float historicalX = event.getHistoricalX(h);float historicalY = event.getHistoricalY(h);long historicalTime = event.getHistoricalEventTime(h);// 处理历史点processPoint(historicalX, historicalY, historicalTime);}// 处理当前点float currentX = event.getX();float currentY = event.getY();processPoint(currentX, currentY, event.getEventTime());return true;
}
(3)差异展示
1.手写无history效果
点跟点之间的间隔很大,速度快了之后显得越发不密集
2.手写有history效果
其中黑色的为原始点,红色的为history点。这个对比上面就密集了很多,可是黑点和红点虽然轨迹一样,但是两者的分布间隔又长又短,甚至有的黑点和红点重叠了。这个对吗?我们再把红的单独点显示下
3.手写有history效果(无原始点)
这样看着就顺眼多了啊。
问题1:为什么将原始点和history点两者叠加显示会参差不齐。但是两者分开又各自的轨迹是连贯平滑的。
解答1:history事件存在的本质是因为屏幕刷新率一般比触摸屏刷新率要小,触摸的move事件处理又是要跟随
VSYNC
(由自身帧率决定)即刷新率一起的,所以导致在一个VSYNC
周期时间内,就会有多个触摸事件产生,如果不使用history那么相当于绘制的轨迹采样率就是屏幕刷新率。
触摸屏采样率(通常 100-1000Hz)
硬件以高频率上报触摸点坐标(如每 1-10ms 一个点)。屏幕刷新率(通常 60-120Hz,即每 8.3-16.6ms 一帧)
Android 的 UI 渲染和事件处理依赖VSYNC
信号,MotionEvent
的分发会被对齐到最近的VSYNC
。
问题2:历史点的本质?
解答2:在两次
VSYNC
之间(即一个屏幕刷新周期内),触摸屏可能产生多个数据点,但系统只会合成一个MotionEvent
上报。所以history上面保存的是触摸屏采样率所采集的点。
以为这样就结束了吗?我们接下来看看笔触的效果
4.笔写有history效果
使用高采样率的电子笔就可以达到近乎这种完美的效果
5.笔写有history效果(原始点宽度缩小2倍)
当将原始点的宽度缩小后发现:原始点和历史点有的会重合;有的只显示原始点。从现象中表现:历史点丢了。这个咱就不探究了,可能跟硬件、系统、底层代码逻辑有关。
(4)小结
综合上面的效果目前可以定下来方案
- 手写:使用history点即可。
- 笔写:使用原始点和history点相结合达到最好效果。(以具体情况为主)
如果感觉手写点的数量跟笔写点的数量差距很大,那有没有办法提升手写点的数量???
有的、有的、兄弟包有的!!!接下来介绍通过数学方式新增点的方法
二、数学方式推导点
目标:用户绘制过程中根据特定条件自动添加额外的控制点,从而改变原始的绘制路径。
效果图:
其中红色的点是原始点,黑色的点是推导新增的点。
View代码:
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.view.MotionEvent;
import android.view.View;@SuppressLint("ViewConstructor")
public class DrawView_EventPoints_New extends View {////这个记录第一个点private float pre1X = -1f;private float pre1Y = -1f;//记录第二个点private float pre2X = -1f;private float pre2Y = -1f;//用于保存新算的点private float newX = -1f;private float newY = -1f;//垂足点和中点private float footX = -1f;private float footY = -1f;private float centerX = -1f;private float centerY = -1f;//用于保存两点之间的距离private float distance = -1f;//获取屏幕的宽度(在此只适用于横屏)int viewWidth ;private Path path = new Path();Paint paint = new Paint(Paint.DITHER_FLAG);private Bitmap cacheBitmap;//定义cacheBitmap上的Canvas对象private Canvas cacheCanvas = new Canvas();private Paint bmpPaint = new Paint();DrawView_EventPoints_New(Context context , int width, int height){super(context);//创建一个与该View具有相同大小的图片缓冲区cacheBitmap = Bitmap.createBitmap(width,height,Bitmap.Config.ARGB_8888);//设置cacheCanva将会绘制到内存中的cacheBitmap上cacheCanvas.setBitmap(cacheBitmap);//设置画笔的颜色paint.setColor(Color.RED);//设置画笔的风格paint.setStyle(Paint.Style.STROKE);paint.setStrokeJoin(Paint.Join.ROUND);paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeWidth(12);//反锯齿paint.setAntiAlias(true);paint.setDither(true);viewWidth = width;}@SuppressLint("ClickableViewAccessibility")@Overridepublic boolean onTouchEvent(MotionEvent event) {//获取拖动事件发生的位置float x = event.getX();float y = event.getY();//初步的思路://1.判断两点之间的距离是否大于 屏幕(宽或高)的一个百分比值// 感觉只需要一层判断就够了,手写的速度没有没有鼠标那么快,每个点的距离没有那么开//2.需要三个点才能开始添加点,同时需要三个缓存(两个缓存)// 开启条件:当前两个点满足大于的条件,等待第三个点的到来(第三个比较重要,每次使用完都赋值成-1)+ 三点之间形成的角要大于90度// 第三个点假如超出了屏幕范围之外,就丢弃。// 当第三个点两种情况:(1)取到了:按照公式计算// (2)没取到:收尾的两个点大于条件//3.开始画点:重要的是算法//算法思路:首先根据开始的两个点,确定添加的点在那个垂直线上//一点和三点//缺点:显示的点,在距离过大时会稍晚两个点的显示(只要跟手的速度给力,应该也不影响)switch (event.getAction()){case MotionEvent.ACTION_DOWN://第一个点path.moveTo(x,y);//DOWN的时候保存第一个点if(pre1X == -1f && pre1Y == -1f){pre1X = x;pre1Y = y;}//System.out.println("DOWN1 "+pre1X+" "+pre1Y);cacheCanvas.drawPoint(pre1X,pre1Y,paint);break;case MotionEvent.ACTION_MOVE:// 从前一个点绘制到当前点之后,把当前点定义成下次绘制的前一个点//MOVE的时候定义第二个点,并更新第一个点//这个是保存move的第一个点:这个时间段根本没办法获取newX,newY。if(pre2X == -1f && pre2Y == -1f){pre2X = x;pre2Y = y;break;}//判断两个点是否过长distance = CalculatePointsDistance(pre1X,pre1Y,pre2X,pre2Y);if(distance >= (float)(viewWidth/22)){//System.out.println("AAAA distance:"+distance+" viewWidth/10: "+viewWidth/22);//判断是锐角还是钝角xif(isBluntAngle(pre1X,pre1Y,pre2X,pre2Y,x,y)){//根据三点计算新点getNewPoints(pre1X,pre1Y,pre2X,pre2Y,x,y);path.lineTo(newX,newY);paint.setColor(Color.BLACK);cacheCanvas.drawPoint(newX,newY,paint);}}path.lineTo(pre2X,pre2Y);paint.setColor(Color.RED);cacheCanvas.drawPoint(pre2X,pre2Y,paint);//第二个点和第是三个点前移pre1X = pre2X;pre1Y = pre2Y;pre2X = x;pre2Y = y;//这个时候新点是必定更新的(清空)newX = -1f; newY = -1f;break;case MotionEvent.ACTION_UP://因为最后一个一直没有画出来,所以在up的时候显示。path.moveTo(x,y);cacheCanvas.drawPoint(x,y,paint);//不仅如此,还可以对尾部进行一个修饰//cacheCanvas.drawPath(path,paint);path.reset();//全部恢复初始状态pre1X = -1f; pre1Y = -1f;pre2X = -1f; pre2Y = -1f;newX = -1f; newY = -1f;break;}invalidate();return true;}//得到新点private void getNewPoints(float p1X, float p1Y, float p2X, float p2Y, float X, float Y) {//求前两个点的斜率,得到垂直平分线的斜率//根据中点,求得新点的值//---------------方法二------------------//获取高的坐标float dx = p1X - X;float dy = p1Y - Y;float u =(p2X-p1X)*dx +(p2Y-p1Y)*dy;u/=dx*dx+dy*dy;footX = p1X+u*dx;footY = p1Y+u*dy;//根据p1点求中footX = (p1X+footX)/2f;footY = (p1Y+footY)/2f;//高的坐标与第一个点的中点+开始的中点 反推的一个点就是目标点centerX = (p1X+p2X)/2f;centerY = (p1Y+p2Y)/2f;newX = centerX*2f-footX;newY = centerY*2f-footY;newX = (centerX+newX)/2f;newY = (centerY+newY)/2f;}//判断是否为钝角,假如为钝角则开辟新点private boolean isBluntAngle(float p1X, float p1Y, float p2X, float p2Y, float X, float Y) {//转换为求两个向量的夹角float x12 = p1X - p2X;float y12 = p1Y - p2Y;float x23 = X - p2X;float y23 = Y - p2Y;float mul_12_23 = x12*x23 + y12*y23;float dist_12 = (float) Math.sqrt(x12*x12+y12*y12);float dist_23 = (float) Math.sqrt(x23*x23+y23*y23);float cosValue = mul_12_23/(dist_12*dist_23);float angle = (float)((float) 180*Math.acos(cosValue)/Math.PI);//输出一下角度//System.out.println("AAAAA 角度:"+angle);//当角度为180度,也可以不用画了。同时可以确定可以组成一个三角形return angle >= 90f&& angle !=180f;}//计算两点之间的距离private float CalculatePointsDistance(float p1X, float p1Y, float p2X, float p2Y) {return (float) Math.sqrt(Math.abs((p1X-p2X)*(p1X-p2X)+(p1Y-p2Y)*(p1Y-p2Y)));}@Overrideprotected void onDraw(Canvas canvas) {//将cacheBitmap绘制到View上(传入这个bmpPaint一点用都没有)canvas.drawBitmap(cacheBitmap,0f,0f,null);}
}
原理:
只看上面的代码不一定能理解思路,我简单说说我当时的设计思路。
方法1:
通过三个已知点,在其之间添加使其顺滑的新增点,我首先想到的是抛物线。我在抛物线上随便取哪个点,都具有使整体饱满圆润的效果。
我们可以在 P1 和 P2 之间取,也可以在 P2 和 (X,Y) 之间取,如果尝试取他们中点数据,效果应该是最好的,有了思路后接着看下面设计图。
在大多数情况下,三个确定位置的坐标点可以确定两条抛物线(垂直抛物线和水平抛物线)。只有在点的排列限制了某一种形式的抛物线时,才可能只能确定一条。其实确定垂直抛物线就行。
这个方式太复杂了,要用代码操作计算量太大了。我直接pass掉了,不过按照道理来说是可行的,大家有意愿的话可以自己试试。
方法2:
这个方法就是代码中使用的,依旧使用图例讲解:
- 其中 foot点 为 P2 到线段【P1,(X,Y)】的垂足。对应的代码为:
float dx = p1X - X; float dy = p1Y - Y;float u =(p2X-p1X)*dx +(p2Y-p1Y)*dy; u/=dx*dx+dy*dy; footX = p1X+u*dx; footY = p1Y+u*dy;
- 下面代码也好理解,取 foot点 和 P1点 赋值给 foot ;去 P1 和 P2 的中点center。
//根据p1点求中 footX = (p1X+footX)/2f; footY = (p1Y+footY)/2f;centerX = (p1X+p2X)/2f; centerY = (p1Y+p2Y)/2f;
- 之后的代码需要图解,上图片。模拟一个坐标系就很明朗了。
newX = centerX*2f-footX; newY = centerY*2f-footY;newX = (centerX+newX)/2f; newY = (centerY+newY)/2f;
- 最终的结果其实只是为了让模拟点突出来一下,没想到实际效果还不错!!!
流程详解:
- 在onTouchEvent中获取基础点,当达到3个时开始计算目标点。
- 当距离大于固定长度时,开始计算。
- 当三个点组成的角为钝角时才开始计算,因为当为锐角的时,此时速度不快,没必要去计算新增点。
- 使用方法二,根据第三个点来预测出前两个点的新增点。当然你也可以根据这个方法来计算后两个点的新增点。