Android 项目:画图白板APP开发(二)——历史点、数学方式推导点

发布于:2025-08-15 ⋅ 阅读:(12) ⋅ 点赞:(0)

        上一章我们讲解了如何绘制顺滑、优美的曲线,为本项目的绘图功能打下了基础。本章我们将深入探讨两个关键功能的实现:历史点数学方式推导点。这些功能将大幅提升我们白板应用的专业性和用户体验。

一、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")
    @Override
    public 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);
                    //判断是锐角还是钝角x
                    if(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)));
    }

    @Override
    protected 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;

  • 最终的结果其实只是为了让模拟点突出来一下,没想到实际效果还不错!!!

流程详解:

  1. 在onTouchEvent中获取基础点,当达到3个时开始计算目标点。
  2. 当距离大于固定长度时,开始计算。
  3. 当三个点组成的角为钝角时才开始计算,因为当为锐角的时,此时速度不快,没必要去计算新增点。
  4. 使用方法二,根据第三个点来预测出前两个点的新增点。当然你也可以根据这个方法来计算后两个点的新增点

网站公告

今日签到

点亮在社区的每一天
去签到