Android custom View- draw a real-time ECG measurement

Overview

This time, I will talk about the drawing of electrocardiogram, which is also used in the project. ECG inherits from View. To summarize, the main contents are as follows: ** Real-time display of dynamic ECG measurement data, left and right sliding of ECG waveform, inertial sliding, and two-finger sliding and zooming in the X-axis and Y-axis directions of the waveform. **Let's take a look at the renderings, the upload size of the image is limited, so it is divided into two:

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

ECG_2.gif

Below we will disassemble the function and implement it step by step:

  • draw background green grid lines
  • Draw real-time Holter curve
  • Realize one-finger curve left and right translation
  • Implement curve inertial sliding
  • Realize the two-finger sliding zoom of the curve in the X-axis and Y-axis directions (multi-touch to change the curve gain)
  • The upper left corner shows the current gain
1. Draw grid lines

This is simpler. First determine the side length of each cell, and then obtain the width and height of the control. In this way, the number of small cells in the horizontal and vertical directions can be calculated respectively, that is, the total number of horizontal and vertical lines to be drawn can be determined. Then you can use a loop to draw all the lines, in which every 5 lines are thickened, and solid lines are drawn, thus forming a large grid of solid lines. Let's look at the implementation first:

// 画 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);
    }

复制代码

What I want to mention here is that the grid lines are not drawn directly on the Canvas of the control's onDraw method. Instead, when the control is initialized, all lines of the grid are drawn on a Bitmap in advance, and then the Bitmap is drawn directly when drawing. This way, you don't have to calculate the position of the line every time you draw it.

There is also note 1 above, the position of the left edge of the grid Bitmap is getScrollX(). Because the curve will slide left and right later, but the grid should be fixed.

2. Draw dynamic real-time ECG curve

This is the main realization of the electrocardiogram. ECG will transmit the voltage value in real time when measuring, we need to store the voltage value in the array in real time. Then convert the voltage value into a Y coordinate value , and then determine the coordinate of each voltage value in the X-axis direction according to the pre-determined distance between the two data points in the X-axis direction. Then determine the path Path of the curve from left to right, and then draw the Path on the 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吧 :心电图

Guess you like

Origin juejin.im/post/7084158192497721381