Android自定义View- 画一幅实时心电测量图

概述

这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自View,概括一下主要有以下内容要实现:**实时显示动态心电测量数据、心电波形左右滑动、惯性滑动及波形 X轴和 Y轴方向双指滑动缩放。**下面我们来看看效果图,图片上传大小有限制,所以分两张:

Screenrecorder-2021-08-09-18-44-54-1282021891847387.gif

ECG_2.gif

下面我们将功能拆解,分步实现:

  • 画背景绿色网格线
  • 绘制实时动态心电曲线
  • 实现单指曲线左右平移
  • 实现曲线惯性滑动
  • 实现 X轴及 Y轴方向上曲线的双指滑动缩放(多点触控改变曲线增益)
  • 左上角显示当前增益
1、画网格线

这个就比较简单了。首先确定每一小格的边长,然后获取控件宽高。这样就能分别计算出水平方向及竖直方向有多少小格,也就是可以确定横线和竖线一共要画多少条。然后就可以用循环画出所有的线条,其中每隔5条进行线条加粗,而且画实线,这样就形成了实线大格。下面先看实现:

// 画 Bitmap
    protected Bitmap gridBitmap;
 // 画 Canvas
    protected Canvas bitmapCanvas;
// 控件宽高
    protected int viewWidth, viewHeight;
 @Override
    protected void onSizeChange() {
        // 获取控件宽高
        viewWidth = mBaseChart.getWidth();
        viewHeight = mBaseChart.getHeight();
        // 初始化网格 Bitmap
        gridBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(gridBitmap);
        Log.d(TAG, "onSizeChange - " + "-- width = " +
                mBaseChart.getWidth() + "-- height = " + mBaseChart.getHeight());
    }

 /**
     * 准备好画网格的 Bitmap
     */
    private void initBitmap(){
        // 计算横线和竖线条数
        hLineCount = (int) (viewHeight / gridSpace) + 2;
        vLineCount = (int) (viewWidth / gridSpace) + 2;
        // 画横线
        for (int h = 0; h < hLineCount; h ++){
            float startX = 0f;
            float startY = gridSpace * h;
            float stopX = viewWidth;
            float stopY = gridSpace * h;
            // 每个 5根画一条粗实线
            if (h % 5 != 0){
                linePaint.setPathEffect(pathEffect);
                linePaint.setStrokeWidth(1.5f);
            }else {
                linePaint.setPathEffect(null);
                linePaint.setStrokeWidth(3f);
            }
            // 画线
            bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
        }
        // 画竖线
        for (int v = 0; v < vLineCount; v ++){
            float startX = gridSpace * v;
            float startY = 0f;
            float stopX = gridSpace * v;
            float stopY = viewHeight;
            // 每隔 5根画一条粗实线
            if (v % 5 != 0){
                linePaint.setPathEffect(pathEffect);
                linePaint.setStrokeWidth(1.5f);
            }else {
                linePaint.setPathEffect(null);
                linePaint.setStrokeWidth(3f);
                Log.d(TAG, "v = " + v);
            }
            // 画线
            bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
        }
    }

 @Override
    protected void onDraw(Canvas canvas) {
         // 注释 1,Bitmap左边缘位置为getScrollX(),防止网格滑动
        canvas.drawBitmap(gridBitmap, mBaseChart.getScrollX(), 0, null);
    }

复制代码

这里想提一下的是,这里网格线并不是直接画在控件 onDraw方法的 Canvas上的。而是在控件初始化时,事先将网格所有线条画在一张 Bitmap上,然后绘制时直接绘制 Bitmap。这样搞就不用每次绘制时都计算一遍线条的位置了。

还有就是上面注释 1处,绘制网格 Bitmap的左边缘的位置是 getScrollX()。因为后面要实现曲线左右滑动,但网格要固定不动。

2、绘制动态实时心电曲线

这就是心电图最主要的实现了。心电在测量的时候会实时传递电压值,我们需要把电压值实时存进数组里。然后把电压值换算成 Y坐标值,再根据事先确定好的 X轴方向两个数据点的距离来确定每个电压值在 X轴方向的坐标。然后从左到右确定曲线的路径Path,再将Path绘制到Canvas上就可以了。

我们观察上面效果图会发现,这里的实现是最后一个到达的数据的显示不会超过控件右边缘。也就是当曲线 X方向的长度不超过控件宽度时,曲线第一个点的横坐标 x = 0。当曲线 X方向长度大于控件宽度时,曲线 Path的第一个点的横坐标就向左移,也就是 x为负的了。这样就实现上面效果中,测量实时心电时,曲线会向左移。这样新来的数据就显示在控件可见范围内,早来的数据逐步向左移出控件可见范围。下面画个草图吧,草图大概就这么个意思:

心电.png

下面看一下实现:

    /**
     * 创建曲线
     */
    private boolean createPath() {
        // 曲线长度超过控件宽度,曲线起点往左移
        // 根据控件宽度和数组长度以及 X增益算出数组第一个数的 X坐标
        float startX = (this.data.size() * dataSpaceX > viewWidth) ?
                (viewWidth - (this.data.size() * dataSpaceX)) : 0f;
        // 曲线复位
        dataPath.reset();
        for (int i = 0; i < this.data.size(); i++) {
            // 确定 X轴坐标
            float x = startX + i * this.dataSpaceX;
            // 确定 Y轴坐标
            float y = getVisibleY(this.data.get(i));
            // 绘制曲线
            if (i == 0) {
                dataPath.moveTo(x, y);
            } else {
                dataPath.lineTo(x, y);
            }
        }
        return true;
    }
    /**
     * 电压 mv(毫伏)在 Y轴方向的换算
     * 屏幕向上往下是 Y 轴正方向,所以电压值要乘以 -1进行翻转
     * 目前默认每一大格代表 1000 mv,而真正一大格的宽度只有 150,所以 data要以两数换算
     * Y == 0,是在 View的上边缘,所以要向下偏移将波形显示在中间
     *
     * @param data
     * @return
     */
   // 注释 2
    private float getVisibleY(int data) {
        // 电压值换算成 Y值
        float visibleY = -smallGridSpace * 5 / mvPerLargeGrid * data;
        // 向下偏移
        visibleY = visibleY + smallGridSpace * 5 * offset;
        return visibleY;
    }

 @Override
    protected void onDraw(Canvas canvas) {
        // 绘制心电曲线
        canvas.drawPath(dataPath, linePaint);
    }

复制代码

上面有一点需要注意的,就是我们的 Y值的换算。我们知道Android屏幕自上而下是 Y轴正方向,所以我们如果直接把电压值画在屏幕上它是倒挂的。另外,这里默认的一大格代表1000mv电压值(可设),而真正一大格的边长是150。所以我们需要将电压值换算成屏幕像素。具体看上面注释 2的getVisibleY方法上面注释。

3、实现曲线左右平移

当心电测量完之后,我们需要实现曲线随手指滑动平移。这样才能看到心电图的全部内容。这个实现原理也简单,也就是监听onTouch事件,根据手指位移使用View的scrollBy方法来实现内容平移就可以了:

 /**
     * @param event 单指事件
     */
    private void singlePoint(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = event.getX();
                break;
            case MotionEvent.ACTION_MOVE:
                float deltaX = event.getX() - lastX;
                delWithActionMove(deltaX);
                lastX = event.getX();
                break;
            case MotionEvent.ACTION_UP:
                // 计算滑动速度
                computeVelocity();
                break;
        }
    }

 /**
     * @param deltaX 处理 MOVE事件
     */
    private void delWithActionMove(float deltaX) {
        if (this.data.size() * dataSpaceX <= viewWidth) return;
        int leftBorder = getLeftBorder(); // 左边界
        int rightBorder = getRightBorder(); // 右边界
        int scrollX = mBaseChart.getScrollX(); // X轴滑动偏移量

        if ((scrollX <= leftBorder) && (deltaX > 0)) {
            mBaseChart.scrollTo((int) (viewWidth - this.data.size() * dataSpaceX), 0);
        } else if ((scrollX >= rightBorder) && (deltaX < 0)) {
            mBaseChart.scrollTo(0, 0);
        } else {
            // 内容平移
            mBaseChart.scrollBy((int) -deltaX, 0);
        }
    }

复制代码

注意上面左右边界的设定,别让曲线划出屏幕了。

4、惯性滑动

惯性滑动的实现,这里使用的套路是 VelocityTracker。先追踪手指滑动速度,然后使用 Scroller并结合 View的 computeScroll()方法和 scrollTo方法,实现手指离开屏幕后的惯性滑动。这部分内容在我上一篇文章画一个FM调频收音机刻度表
有讲,这里不再重复。

5、实现双指滑动,在横纵坐标方向缩放曲线

在实现双指滑动曲线缩放功能之前,我们先讲讲一小部分 MotionEvent的基础知识。为什么说只讲一小部分呢?因为 MotionEvent这个事件体系还蛮大。我们只讲一下这次用到的部分。

onTou.png

onTouch2.png

好吧,还是直接画表格吧。这样也直观一点,不用解释那么多。上面红色圈圈圈出来的几个哥们是我们这次要用到的。

  • event.getActionMasked() :上面也有解释,这个方法和 getAction()类似。只不过我们这次要处理多点触控,所以一定要用 getActionMasked() 来获取事件类型。

  • event.getPointerCount() :上面也有解释,获取屏幕上手指个数。因为我们这次要处理双指滑动,所以要用 (getPointerCount() == 2)进行判断。两根手指以外的事件我们不做缩放处理。

  • ACTION_POINTER_DOWN :上面又有解释,第一根手指之后,按下的其他手指。如果结合 (getPointerCount() == 2)这个前提条件,那么我们可以认为这次ACTION_POINTER_DOWN 就是第二根手指按下所触发的事件。

  • event.getX(int pointerIndex):上面也有介绍,获取某个手指当前的 X坐标。我们在获取到两个手指当前的 X坐标之后,就可以算出两指当前在 X轴方向的距离。然后再结合 ACTION_POINTER_DOWN 时所记录的坐标值,就可以计算出两个手指在 X方向上是靠近了还是疏远了(收缩了还是放大了)。getY(int pointerIndex) 方法同理,不做解释了。

  • ACTION_MOVE :两指滑动当然也要用到 MOVE事件,只不过这里 ACTION_MOVE 和单指的使用方法一样,就不做解释了。

好了,我们再看看 X轴方向缩放具体实现吧:

  /**
     * 处理onTouch事件
     *
     * @param event 事件
     * @return 拦截
     */
    @Override
    protected boolean onTouchEvent(MotionEvent event) {
        Log.d(TAG, "pointerCount = " + event.getPointerCount());
        if (event.getPointerCount() == 1) {  // 单指平滑
            singlePoint(event);
        }
        if (event.getPointerCount() == 2) { // 双指缩放
            doublePoint(event);
        }
        return true;
    }

  /**
     * @param event 双指事件
     */
    private void doublePoint(MotionEvent event) {
        if (pointOne == null) pointOne = new PointF();
        if (pointTwo == null) pointTwo = new PointF();

        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_POINTER_DOWN:  // 第二根手指按下
                Log.d(TAG, "ACTION_POINTER_DOWN");
               // 记录第二根手指按下时,两指的坐标点
                saveLastPoint(event);
                numbersPerLargeGridOnThisTime = getDataNumbersPerGrid();
                mvPerLargeGridOnThisTime = getMvPerLargeGrid();
                break;
            case MotionEvent.ACTION_MOVE:  // 双指拉伸
                Log.d(TAG, "ACTION_MOVE");
                // 计算 X方向缩放量
                getScaleX(event);
               // 计算 Y轴方向所放量
                getScaleY(event);
                break;
            case MotionEvent.ACTION_POINTER_UP:  // 先离开的手指
                Log.d(TAG, "ACTION_POINTER_UP");
                break;
        }
    }

    /**
     * 处理 X方向的缩放
     *
     * @param event 事件
     * @return 拉伸量
     */
    private float getScaleX(MotionEvent event) {
        float pointOneX = event.getX(0);
        float pointTwoX = event.getX(1);
        // 算出 X轴方向的拉伸量
        float deltaScaleX = Math.abs(pointOneX - pointTwoX) - Math.abs(pointOne.x - pointTwo.x);
        // 设置拉伸敏感度
        int inDevi = mBaseChart.getWidth() / 54;
        // 计算拉伸时增益偏移量
        int inDe = (int) deltaScaleX / inDevi;
        // 算出最终增益
        int perNumber = numbersPerLargeGridOnThisTime - inDe;
        // 设置增益
        setDataNumbersPerGrid(perNumber);
        return deltaScaleX;
    }

复制代码

好了,该解释的原理上面都做了解释。上面代码要解释的无非就是缩放敏感度调节的问题,代码里做了解释。缩放量计算出来之后,我们就可以改变心电曲线的增益了。比如说 X方向两点数据之间的距离做了调整、Y方向心电数值计算因子做了调整,然后重新算出曲线 Path再重绘,也就可以了。

6、左上角显示当前增益

最后我们要把当前增益显示出来,比如说 X轴方向一大格绘制了多少点数据、Y轴方向一大格代表多少毫伏。这两个参数都是在上一步双指缩放时动态改变的,所以要留一个对外接口让外界获取到这两个参数。

 /**
     * 获取每大格显示的数据个数,再结合医疗版的采样率,就可以算出一格显示了多长时间的数据
     *
     * @return
     */
    public int getDataNumbersPerGrid() {
        return this.dataNumbersPerGrid;
    }
 /**
     * @return 获取每大格代表多少毫伏
     */
    public float getMvPerLargeGrid() {
        return this.mvPerLargeGrid;
    }

复制代码

因为这次心电图的绘制比以往的文章都涉及到更多的细节,所以之前文章里讲过的一些实现细节这里就没重复讲。另外,这次自定义 View使用了 Base模板设计模式,用好几个类来实现了这幅心电图,所以没把完整代码贴在这里。代码还是直接放Github吧 :心电图

猜你喜欢

转载自juejin.im/post/7084158192497721381