美文网首页Android
Android自定义View(12)- 画一幅实时心电测量图

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

作者: 碧云天EthanLee | 来源:发表于2021-08-10 01:07 被阅读0次
    概述

    这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自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吧 :心电图

    相关文章

      网友评论

        本文标题:Android自定义View(12)- 画一幅实时心电测量图

        本文链接:https://www.haomeiwen.com/subject/nnzzvltx.html