安卓上实现的自定义动态心电波形控件

1、前言

大概前年的样子,实验室要做一个智能睡眠床垫的项目,需要用安卓手机 端进行心电和呼吸波形的展示,当时由于时间紧,波形的展示用的是第三方控件achartengine。虽然能运行,但是有些信息不能显示,总之 没有自己实现的方便,想加什么功能就加什么功能。直到今年,项目组又要做一个智能背心的项目,还是需要 显示心电和呼吸波形,趁这次时间比较充裕,就自己实现了一个。

2、控件功能

image
image

根据项目要求,结合自己的实现,本控件具有以下功能和特点:

1、控件能显示单元格的时间和幅度信息,从而可以根据波形的背景和单位,可以从图中得到波形的真实幅度大小和周期,以便于专业人士从波形中获取有效信息(尤其是时间信息);

2、波形能自适应调整幅值大小,可以通过手势对时间轴进行放大或者缩小;

3、波形以扫屏方式进行;
4、可以通过单手上拉或者下拉快速实现波形的基线调整,通过双手上下扩张或者收缩完成波形的倍数放大和缩小。

3、实现

控件一共用到了一个类EcgWaveView和一个接口EcgViewInterface。
其中,接口包含两个函数

public interface EcgViewInterface{

        void onError(Exception e);

        void onShowMessage(String t,inti);

}

接口主要用于接口回调,通知父窗体关于背景单元格的时间轴和Y轴的单位变化。

类EcgWaveView继承View,整个控件的实现机制大体可以概括为:控件提供了两个bitmap:machineBitmap和cacheBitmap,cacheBitmap用于更新波形,machineBitmap是最终画到设备的画图区。 类提供了一个供外部调用的方法drawWave(fy),fy即为需要画的点的Y值(并不是像素值)。控件将此Y值转为像素值后,在machineBitmap进行drawline操作,然后用cacheBitmap对machineBitmap画完点后的[10,20]的像素区域进行刷新,然后调用canvas.drawBitmap(machineBitmap,0,0,bmpPaint);将machineBitmap滑到屏幕上。

下面根据功能分别介绍其实现原理。

3.1、背景图

  private void drawBackGrid()
    {
        //绘制网格 图片
        int m, n;
        backBitmap=Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
        canvasBackground=new Canvas();
        canvasBackground.setBitmap(backBitmap);
        Paint paint1=new Paint();

        paint1.setColor(Color.WHITE);
        paint1.setStyle(Paint.Style.FILL);
        paint1.setStrokeWidth(2);
        paint1.setAntiAlias(true);
        paint1.setDither(true);
        paint1.setStrokeJoin(Paint.Join.ROUND);

        canvasBackground.drawRect(0, 0, bx, by, paint1);
        paint1.setStyle(Paint.Style.STROKE);
        paint1.setColor(Color.argb(128, 220, 190, 50));
        for (m = 0; m < bx; m = m + 10)
            for (n = 0; n < by; n = n + 10)
                canvasBackground.drawPoint(m, n, paint1);

        for (m = 0; m < bx; m = m + 50)
            canvasBackground.drawLine(m, 0, m, by-5, paint1);
        for (n = 0; n < by; n = n + 50)
            canvasBackground.drawLine(0, n, bx, n,paint1);

    }

背景图没啥好说的,基本就是定义好背景bitmap和用于画背景的canvas,然后每50个像素分别画横的和竖的直线,每10*10的范围内画一个点。
绘制好的backBitmap,用作给上文提到的machineBitmap和cacheBitmap做背景图:

 machineBitmap =Bitmap.createBitmap(backBitmap,0,0,width,height);
 cacheBitmap =Bitmap.createBitmap(backBitmap,0,0,width,height);

3.2 画曲线

 public void drawWave( int y)  {

        int ecgy_new=changeOut(y);
        machineCanvas.drawLine(n_ecgx, ecgy, n_ecgx + n_step, ecgy_new, paint);
        n_ecgx=n_ecgx+n_step;
        ScreenResh();
        invalidate();
        ecgy=ecgy_new;

    }

画曲线的思路就是先将输入的Y值转为手机屏幕对应的像素值,然后在machineCanvas进行描点操作,再进行扫屏的更新操作,最后将machineCanvas所对应的bitmap投向手机屏幕。
其中changeout是将Y值转为像素的方法

 private int changeOut(int temp)
    {
        float a;
        int b;
        //temp-欲转换的数值
        //a表示放大之前电压的真实范围。SampleV 是输入的电压范围:比如[0,10]mv,则SampleV 就是10,而[0,10]所对应的Y值是[0,4096],则SampleR就是4096
        a = (float)SampleV * temp / SampleR;
        //这个公式的意思是(真实电压-基线电压)/每格代表的电压*每格所拥有的50个像素
        b = (short)(change_h - (a - change_y) * change_50n / change_nV);
// y_max 和y_min 用于记录像素最大值和最小值,当当前的像素点操过了最大值,表明曲线将要超出屏幕,需要进行波形的自适应调整。
        if (b < y_min)
            y_min = b;
        if (b > y_max)
            y_max = b;

        if (b > by)
        {
            b = (short)by - 1;
            b_autoResize = true;
            n_aStep = 1;
        }

        if (b <= 0)
        {
            b = 1;
            b_autoResize = true;
            n_aStep = 1;
        }
        return b;
    }

在上述drawWave方法中,调用invalidate()时,会调用View的onDraw()方法:

 public void onDraw(Canvas canvas) {

        canvas.drawBitmap(machineBitmap,0,0,bmpPaint);

    } 

ScreenResh()是扫屏方法,用于更新曲线。其原理就是,对描好点的machineBitmap,需要用cacheBitmap(cacheBitmap就是一张干净的背景图)去更新。比如machineBitmap的描点已经画到第N点,则用cacheBitmap的((N+10,0),(N+20,by))(by是图的高度)区域去更新machineBitmap的((N+10,0),(N+20,by))区域。
当判断曲线到头时,需要判断波形是否需要自动调整,如果需要调整,就调用调整方法。

    private void ScreenResh(){

        if (n_ecgx > bx - 5)    //如果曲线到头
        {
            //判断(ymax-ymin)的值是不是小于高度一半,如果小于,就要自动调整
            if((y_max-y_min)*2<by){
                b_autoResize = true;
                n_aStep = 1;
            }
            n_ecgx = 0;
            Rect rect=new Rect(0, 0, 20, height);    //表示更新的区域,从0到20的X轴
            machineCanvas.drawBitmap(cacheBitmap, rect, rect, bmpPaint);
            if (b_autoResize&&waveAdapter){
                AutoResize();
            }
            checkRange();
        }
        //
        Rect rect=new Rect(n_ecgx + n_step+10, 0, n_ecgx + n_step+20, height);
        machineCanvas.drawBitmap(cacheBitmap, rect, rect, bmpPaint);

    }

自适应方法AutoResize,主要是调整波形的幅度和基线。首先是判断整屏范围内的波形最大值和最小值的绝对值和波形控件的高度进行相比,如果幅度大于高度,则需要将每格所代表的幅度值放大一倍(即波形幅度变小一倍),反之则将每格所代表的幅度值缩小一倍,如此反复,到最后会使波形的高度大于控件高度一半,但会小于控件高度。此时,再调整基线即可保证,波形能完整的在控件中显示。

 //自适应调整
    public void AutoResize()
    {
        if (n_aStep == 1)
        {
            //波形的幅度小于画布高度,并且波形幅度的2倍大于画布高度,说明波形幅度合适,此时只要调整基线
            if ((y_max - y_min) * 2 >= by && (y_max - y_min) <= by)
                n_aStep = 2;
            else
            {
                if (y_max - y_min >= by)       //表示波形范围超过画布高度
                {
                    change_nV = change_nV*2;
                    listener.onShowMessage(change_nV+"",1);

                }
                else if ((y_max - y_min) * 2 <= by)   //如果波形幅度的两倍都小于画布高度,说明波形幅度过小,需要波形像素调整放大
                {
                    change_nV = change_nV/2;
                    listener.onShowMessage(change_nV+"",1);

                }

                y_max = -3*by;
                y_min =3*by;
                return;
            }
        }

        if (n_aStep == 2)
        {
            n_top = (by - (y_max + y_min)) / 2;
            this.change_y += n_top * change_nV/ change_50n;
        }
            b_autoResize = false;
    }

3.3 控件的手势操作

控件可以通过两点进行时间轴的放大或者缩小操作。这里主要用到view的onTouchEvent事件。主要思路是:在第二个手指触摸屏幕事件中,计算两个手指之间的距离,并保存;在第一个点离开屏幕时,再次计算两个手指之间的距离,通过计算这个两个距离的值来进行判断是否需要进行X轴的放大或者缩小操作。

  @Override
    public boolean onTouchEvent( MotionEvent event) {

        switch (event.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                Log.i("tag","手势放下");
                OldY = event.getY();
                mode = 1;
                break;
            case MotionEvent.ACTION_UP:


                mode = 0;
                break;
            case MotionEvent.ACTION_POINTER_UP:
                Log.i("tag","手势拿起"+spacingX(event));
                newDistanceX = spacingX(event);
                mode -= 1;
                if (newDistanceX > oldDistanceX +DISTANCE){
                    n_step=n_step*2;
                    n_Btime = (int)(n_Ptime * change_50n / n_step);
                    listener.onShowMessage(n_Btime+"",0);
                    Log.i("tag","n_step-->"+n_step+";n_Btime-->"+n_Btime);
                }else if (newDistanceX < oldDistanceX -DISTANCE){
                    if (n_step>=2){
                        n_step=n_step/2;
                        n_Btime = (int)(n_Ptime * change_50n / n_step);
                        listener.onShowMessage(n_Btime+"",0);
                        Log.i("tag", "n_step-->" + n_step + ";n_Btime-->" + n_Btime);
                    }
                }

                //这是Y轴方向
                newDistanceY = spacingY(event);
                if (newDistanceY > oldDistanceY +DISTANCE){
                    change_nV = change_nV/2;   //波形放大   //如果波形幅度的两倍都小于画布高度,说明波形幅度过小,需要波形像素调整放大
                    listener.onShowMessage(change_nV+"",1);
                    Log.i("tag","change_nV缩小-->"+change_nV);
                }else if (newDistanceY < oldDistanceY -DISTANCE){    //波形放大
                    change_nV = change_nV*2;
                    listener.onShowMessage(change_nV+"",1);
                    Log.i("tag", "change_nV放大-->" + change_nV);
                }

                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                Log.i("tag","手势放下"+spacingX(event));
                oldDistanceX = spacingX(event);
                oldDistanceY = spacingY(event);

                mode += 1;
                break;

            case MotionEvent.ACTION_MOVE:
                //工具鼠标上下移动距离调整波形上下位置
                this.change_y -= 0.4 * (OldY - event.getY()) * change_nV * 2 / 25;
                OldY = event.getY();
                break;
        }
        return true;
    }

4、如何使用这个库

这个库我会放在github和csdn上,为了避免资源冲突,整个库都使用java代码编写,没有使用任何第三方库,在android studio下编译为aar文件。至于如何生成aar文件以及如何导入aar文件,我会在另一篇博客给出。

4.1库包含以下公开方法:

方法 参数 功能 备注
init() 用于初始化控件
setWaveAdapter(boolean waveAdapter) waveAdapter:true->波形自适应,false->不自适应 设置波形是否需要自适应调整
setN_frequency(double n_frequency) n_frequency:输入的点的频率 设置需要扫描点的频率 假设一秒钟需要画100个点,则频率就是100
setSampleV(int sampleVa) sampleVa:输入的点的真实电压范围 设置需要扫描点的真实电压范围 假设电压范围是[0,10]mv,则sampleVa=10
setSampleRe(int sampleRe)) sampleRe:输入的点的数值范围 设置需要扫描点的数值范围 控件接收的数据是数字量接收的,假如其范围是[0,4096](代表真实电压[0,10]mv),则sampleRe是4096
setListener(EcgViewInterface ecgViewListener)) ecgViewListener:用于回调的接口 设置回调接口 EcgViewInterface 是 用于回调的接口,用于回传控件单元格的时间单位和幅度单位
drawWave( int y) y):需要花点的y坐标,对应于上述的[0,4096] 画图

4.2使用示例
控件需要一个layout进行加载。假设在布局文件中,我们为这个库设置Linearlayout进行加载,并命命为graph1_father。

  <LinearLayout
            android:id="@+id/graph1_father"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="5"
            android:background="@color/floralwhite"
            android:gravity="center"
            android:orientation="vertical"
            android:paddingLeft="5dp"
            android:paddingRight="5dp"
            android:weightSum="5">

        </LinearLayout>

然后在activity中,使用

 @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        relativeLayout=(RelativeLayout)findViewById(R.id.root);
        view = getWindow().getDecorView();
       relativeLayout.setOnTouchListener(this);
        initView();
    }

    private void initView() {

        view.getViewTreeObserver().addOnGlobalLayoutListener(
                new ViewTreeObserver.OnGlobalLayoutListener() {
                    @Override
                    public void onGlobalLayout() {


                        lLayout3 = (LinearLayout) view.findViewById(R.id.graph1_father);
                        int width = lLayout3.getWidth();
                        int height = lLayout3.getHeight();

                       ecgWaveView3 = new EcgWaveView(getBaseContext(), width, height);
                 
                        ecgWaveView3 .setN_frequency(50);
                        ecgWaveView3 .setSampleRe(4096);
                        ecgWaveView3 .setSampleV(10);
                        ecgWaveView1.setListener(ecgViewListener);  //设置接口回调
                        ecgWaveView1.init();


                        lLayout3.addView(bcgWaveView3);

                        view.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                    }
                });
    }

其中,用于接口回调的实现

    private EcgViewInterface ecgViewListener=new EcgViewInterface() {
        @Override
        public void onError(Exception e) {

        }

        @Override
        public void onShowMessage(String t, int i) {
            Log.i("tag", "心电接口回调--》" + t);
            if (i==0){   
               Toast.makeText(getApplication(),"时间:" + t + "ms/格",Toast.LENGTH_SHORT).show();
            }else if (i==1){
                Toast.makeText(getApplication(),"电压:"+t+"mv/格",Toast.LENGTH_SHORT).show();
               
            }
        }
    };

库文件,示例以及apk演示程序可参考:
https://download.csdn.net/download/andyzhu_2005/10764191。喜欢的朋友可以点赞或者赞赏,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,248评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,681评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,443评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,475评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,458评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,185评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,451评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,112评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,609评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,083评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,163评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,803评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,357评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,357评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,590评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,636评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,925评论 2 344

推荐阅读更多精彩内容