阅读 166
基于蓝牙串口通信,实现实时脉象采集(项目总结与思路梳理)

基于蓝牙串口通信,实现实时脉象采集(项目总结与思路梳理)

写在前面

本项目用到的 主要知识点: 手机蓝牙 (动态权限申请,蓝牙打开,连接,配对,基于2.0蓝牙串口 Socket 通信),自定义View SurfaceView(实时绘制采集到的脉象波形)。本人为 一年工作经验小白,希望大家再阅读过程中有好的见解和思路,还望多多指点。 温馨提示: 阅读完 本文 大约需要 5 到十分钟。

1.蓝牙相关

1.1蓝牙申请

需要获取蓝牙权限,都是要在 AndroidManifest 清单文件中 添加权限。


<uses-permission android:name="android.permission.BLUETOOTH"/>
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
复制代码

需要配置6.0 及以上系统手机,添加动态权限申请。在本人查阅文档后,6.0蓝牙使用也是需要申请 位置权限ActivityCompat.checkSelfPermission (主要通过该方法申请,在本文中不详细做解释,其他人动态权限申请工具类:www.jianshu.com/p/5f24c14ea…

1.2 蓝牙打开连接

获取 蓝牙适配器。蓝牙适配器是我们操作蓝牙的主要对象,可以从中获得配对过的蓝牙集合,可以获得蓝牙传输对象等等


 BluetoothAdapter _bluetooth =BluetoothAdapter.getDefaultAdapter();

        if (_bluetooth == null) {
            appUtils.e("该设备不支持蓝牙");
            return;
        }
        if (!_bluetooth.isEnabled()) {
            new Thread() {
                public void run() {
                    if (!_bluetooth.isEnabled()) {
 // 打开蓝牙
                        _bluetooth.enable();
                    }
                }
            }.start();
        }

      关闭蓝牙 
      if (mBtAdapter.isDiscovering()) {
            mBtAdapter.cancelDiscovery();
        }
复制代码

动态 注册蓝牙广播


filter = new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
        this.registerReceiver(mReceiver, filter);
复制代码

广播接收


private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            if (BluetoothDevice.ACTION_FOUND.equals(action)) {
                BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
                if (device.getBondState() != BluetoothDevice.BOND_BONDED) {
                    String str = device.getName() + "\n" + device.getAddress();
                    if (mNewDevicesArrayAdapter.getPosition(str) == -1)
                        mNewDevicesArrayAdapter.add(str);
                }
            } else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) {
                setProgressBarIndeterminateVisibility(false);
                titleNewDevices.setText("查找完毕");
                if (mNewDevicesArrayAdapter.getCount() == 0) {
                    titleNewDevices.setText("查找完毕");
                }
            }
        }
    };

复制代码

通过蓝牙的连接 搜索附近设备,我们可以获得到 设备的 地址,此时我们就可以进行蓝牙的Socket 连接和通信了。

1.3 蓝牙 Socket连接

以下给出程序中本人使用的代码。 这里着重看一下 Android 2.0串口通信 获取socket 办法。(不用反射方法获取设备 通信连接很不稳定)


Method m = _device.getClass().getMethod("createRfcommSocket", int.class);
        _socket = (BluetoothSocket) m.invoke(_device, 1);

       try {
                Method m = _device.getClass().getMethod("createRfcommSocket", int.class);
                _socket = (BluetoothSocket) m.invoke(_device, 1);
            } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
                e.printStackTrace();
            }
            try {
                _socket.connect();
  etResources().getString(R.string.delete), handler);
                IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED);
                MainActivity.this.registerReceiver(mReceiver, filter);
            } catch (IOException e) {
                try {
                    bRun = false;
                    _socket.close();
                    _socket = null;
                    appUtils.e("连接" + _device.getName() + "失败");
                } catch (IOException ignored) {
                }
                return;
            } catch (IOException e) {
                return;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
复制代码

1.4 socket 通信

socket 通过 发送消息 通过 输出流 接收数据 通过输入流。


try {
            OutputStream os = _socket.getOutputStream();
            if (hex) {
                byte[] bos_hex = appUtils.hexStringToBytes(str);
                os.write(bos_hex);
            } else {
                byte[] bos = str.getBytes("GB2312");
                os.write(bos);
            }
        } catch (IOException e) {
        }
复制代码

由于本项目 发送的数据位16进制,传送给 脉象仪 需要传送 byte 二进制数组。所以这个贴出一个 十六进制 字符串 转换为 byte 数组的办法。


 /**
     * 16进制 字符串转换为 byte数组
     */
    public byte[] hexStringToBytes(String hexString) {
        hexString = hexString.replaceAll(" ", "");
        if ((hexString == null) || (hexString.equals(""))) {
            return null;
        }
        hexString = hexString.toUpperCase();
        int length = hexString.length() / 2;
        char[] hexChars = hexString.toCharArray();
        byte[] d = new byte[length];
        for (int i = 0; i < length; ++i) {
            int pos = i * 2;
            d[i] = (byte) (charToByte(hexChars[pos]) << 4 | charToByte(hexChars[(pos + 1)]));
        }
        return d;
    }
复制代码

1.5 接收Socket 数据

这里说明一下 进行socket 发送消息和 接收消息,最好再 子线程中 进行,因为socket相对来说比较耗时。子线程中 接收消息的方法,因为本项目中 有较长的数据量返回,所以这里需要对 is 长度进行判断,好根据长度 设置 数组长度。


int count = 0;
while (count == 0) {
    count = is.available();
}
 byte[] buffer = new byte[count];
 is.read(buffer);
 for (byte b : buffer) {
    // 个人项目需要,讲数据添加到 队列中
    byteQueue.offer(b);
  }
复制代码

获得到 数据后 接下来的事情就 是 我们 开头提到的 SurfaceView实时绘制波形了。

  1. SurfaceView 绘制波形

因为产品需要,对脉搏的展示 需要进行 实时的绘制。所以 这里选择了比较熟悉的 Surfaceview,由于SurfaceView的双缓冲机制处理,单独运行在view的子线程,在这里非常的适合。在进行view 的绘制之前,先说下 我公司的 部分简单的参数(公司是 BAT 旁边的 一家小公司 -,-): 采样率 : 1K /s ,走纸速度:25mm/s 。其他的 命令信息,暂时不方便透漏。

2.1 Surfaceview 浅见

SurfaceView的名称含义

Surface意为表层、表面,顾名思义SurfaceView就是指一个在表层的View对象。为什么说是在表层呢,这是因为它有点特殊跟其他View不一样,其他View是绘制在“表层”的上面,而它就是充当“表层”本身。举个形象的例子,假设要在一个球上画画,那么球的表层就当做你的画布对象,你画的东西会挡住它的表层,默认没使用SurfaceView,那么球的表层就是空白的。如果使用了SurfaceView,我 们可以理解为我们拿来的球本身表面就具有纹路,你是画在纹路之上。SDK的文档 说到:SurfaceView就是在窗口上挖一个洞,它就是显示在这个洞里,其他的View是显示在窗口上,所以View可以显式在 SurfaceView之上,你也可以添加一些层在SurfaceView之上。(Android中SurfaceView的使用详解原文中有一整段是这么介绍sufaceview控制帧数的原理:“ SurfaceView还有其他的特性,上面我们讲了它可以控制帧数,那它是什么控制的呢?这就需要了解它的使用机制。一般在很多游戏设计中,我们都是开辟一个后台线程计算游戏相关的数据,然后根据这些计算完的新数据再刷新视图对象,由于对View执行绘制操作只能在UI线程上, 所以当你在另外一个线程计算完数据后,你需要调用View.invalidate方法通知系统刷新View对象,所以游戏相关的数据也需要让UI线程能访 问到,这样的设计架构比较复杂,要是能让后台计算的线程能直接访问数据,然后更新View对象那该多好。我们知道View的更新只能在UI线程中,所以使用自定义View没办法这么做,但是SurfaceView就可以了。它一个很好用的地方就是允许其他线程(不是UI线程)绘制图形(使用Canvas),根据它这个特性,你就可以控制它的帧数,你如果让这个线程1秒执行50次绘制,那么最后显示的就是50帧。”但我对这段话的来源存疑,各位看官怎么看呢?)

2.2 Surfaceview

首先我们创建 自定义 view ,surfaceview 中主要 包含 网格样式的背景和 一定的绘制频率的波形(这里补充一下 自定义View 比较基础的知识,onMeasure 方法 :view大小的测量OnSizeChange方法: 确定View 的大小,OnLayout 方法,根绝ViewGroup确定view位置 如果有基础比较不好的 看这里 www.gcssloop.com/customview/…

以下为 自定义Surfaceview 全部代码。


 /**
 * 采样率 : 1s/ 1000包数据  ,  走纸速度:1s/25mm
 * Custom electrocardiogram
 * <p>
 * 1. Solve the background grid drawing problem
 * 2. Real-time data padding
 * <p>
 * author Bruce Young
 * 2017年8月7日10:54:01
 */

public class EcgView extends SurfaceView implements SurfaceHolder.Callback {

    private Context mContext;
    private SurfaceHolder surfaceHolder;
    public static boolean isRunning = false;
    public static boolean isRead = false;
    private Canvas mCanvas;

    private String bgColor = "#00000000";
    public static int wave_speed = 25;//波速: 25mm/s   25
    private int sleepTime = 8; //每次锁屏的时间间距 8,单位:ms   8
    private float lockWidth;//每次锁屏需要画的
    private int ecgPerCount = 17;//每次画心电数据的个数,8  17
    private static Queue<Float> ecg0Datas = new LinkedBlockingQueue<>();
    private Paint mPaint;//画波形图的画笔
    private int mWidth;//控件宽度
    private int mHeight;//控件高度
    private float startY0;
    private Rect rect;
    public Thread RunThread = null;
    private boolean isInto = false;  // 是否进入线程绘制点
    private float startX;//每次画线的X坐标起点
    public static double ecgXOffset;//每次X坐标偏移的像素
    private int blankLineWidth = 5;//右侧空白点的宽度
    public static float widthStart = 0f;  // 宽度开始的地方(横屏)
    public static float highStart = 0f;  // 高度开始的地方(横屏)
    public static float ecgSensitivity = 2;  // 1 的时候代表 5g 一大格  2 的时候 10g 一大格
    public static float baseLine = 2f / 4f;

    // 背景 网格 相关属性
    //画笔
    protected Paint mbgPaint;
    //网格颜色
    protected int mGridColor = Color.parseColor("#1b4200");
    //背景颜色
    protected int mBackgroundColor = Color.BLACK;

    // 小格子 个数
    protected int mGridWidths = 40;
    // 横坐标个数
    private int mGridHighs = 0;
    // 表格宽度
    private int latticeWidth;
    // 表格高度
    private int latticeHigh;

    public EcgView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.mContext = context;
        this.surfaceHolder = getHolder();
        this.surfaceHolder.addCallback(this);
        rect = new Rect();
        converXOffset();
    }

    private void init() {
        mbgPaint = new Paint();
        mbgPaint.setAntiAlias(true);
        mbgPaint.setStyle(Paint.Style.STROKE);
        //连接处更加平滑
        mbgPaint.setStrokeJoin(Paint.Join.ROUND);
        mPaint = new Paint();
        mPaint.setColor(Color.WHITE);
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(4);
        //连接处更加平滑
        mPaint.setStrokeJoin(Paint.Join.ROUND);
        DisplayMetrics dm = getResources().getDisplayMetrics();
        float size = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, wave_speed, dm); // 25mm --> px
        ecgXOffset = size / 1000f;
        startY0 = -1;//波1初始Y坐标是控件高度的1/2
    }

    /**
     * 根据波速计算每次X坐标增加的像素
     * <p>
     * 计算出每次锁屏应该画的px值
     */
    private void converXOffset() {
        DisplayMetrics dm = getResources().getDisplayMetrics();
        int width = dm.widthPixels;
        int height = dm.heightPixels;
        //获取屏幕对角线的长度,单位:px
        double diagonalMm = Math.sqrt(width * width + height * height) / dm.densityDpi;//单位:英寸
        diagonalMm = diagonalMm * 2.54 * 10;//转换单位为:毫米
        double diagonalPx = width * width + height * height;
        diagonalPx = Math.sqrt(diagonalPx);
        //每毫米有多少px
        double px1mm = diagonalPx / diagonalMm;
        //每秒画多少px
        double px1s = wave_speed * px1mm;
        //每次锁屏所需画的宽度
        lockWidth = (float) (px1s * (sleepTime / 1000f));
        float widthSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, wave_speed, dm); // 25mm --> px
        widthStart = (width % widthSize) / 2;
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        Canvas canvas = holder.lockCanvas();
        canvas.drawColor(Color.parseColor(bgColor));
        initBackground(canvas);
        holder.unlockCanvasAndPost(canvas);
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        DisplayMetrics dm = getResources().getDisplayMetrics();
        int width = dm.widthPixels;
        int high = dm.heightPixels;
        float widthSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, wave_speed, dm); // 25mm --> px
        widthStart = (width % widthSize) / 2;
        w = floatToInt(w - widthStart);
        // TODO: 2017/11/21 暂时使用固定的 25mm/s 
        mGridWidths = (floatToInt(w / TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, 25, dm)) * 5);
        mWidth = w;

        float highSize = 0f;
        if (high / widthSize >= 3) {
            highSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, wave_speed, dm);
            mGridHighs = (floatToInt(high / highSize) * 5);
            highStart = (high % highSize) / 2;
            h = floatToInt(h - highStart);
        } else {
            highStart = high % 3;
            high = (int) (high - highStart);
            highSize = high / 15;
            mGridHighs = 15;
            h = floatToInt(h - highStart);
        }
        mHeight = h;
        isRunning = false;
        init();
        super.onSizeChanged(w, h, oldw, oldh);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int high = MeasureSpec.getSize(heightMeasureSpec);
        Log.e("ecgview:", "width:" + width + " height:" + high);
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        stopThread();
    }

    public void startThread() {
        isRunning = true;
        RunThread = new Thread(drawRunnable);
        // 每次开始清空画布,重新画
        ClearDraw();
        RunThread.start();
    }

    public void stopThread() {
        if (isRunning) {
            isRunning = false;
            RunThread.interrupt();
            startX = 0;
            startY0 = -1;
        }
    }

    Runnable drawRunnable = new Runnable() {
        @Override
        public void run() {
            while (isRunning) {
                long startTime = System.currentTimeMillis();
                startDrawWave();
                long endTime = System.currentTimeMillis();
                if (endTime - startTime < sleepTime) {
                    try {
                        Thread.sleep(sleepTime - (endTime - startTime));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        break;
                    }
                }
            }
        }
    };

    private void startDrawWave() {
        //锁定画布修改 位置
        rect.set((int) (startX), 0, (int) (startX + lockWidth + blankLineWidth), mHeight);
        mCanvas = surfaceHolder.lockCanvas(rect);
        if (mCanvas == null) return;
        mCanvas.drawColor(Color.parseColor(bgColor));
        drawWave0();
        if (isInto) {
            startX = (float) (startX + ecgXOffset * ecgPerCount);
        }
        if (startX > mWidth) {
            startX = 0;
        }
        surfaceHolder.unlockCanvasAndPost(mCanvas);
    }

    /**
     * 画 脉象
     */
    private void drawWave0() {
        try {
            float mStartX = startX;
            isInto = false;
            initBackground(mCanvas);
            if (ecg0Datas.size() > ecgPerCount) {
                isInto = true;
                for (int i = 0; i < ecgPerCount; i++) {
                    float newX = (float) (mStartX + ecgXOffset);
                    float newY = (mHeight * baseLine) - (ecg0Datas.poll() * (mHeight / mGridHighs) / ecgSensitivity);
                    if (startY0 != -1) {
                        mCanvas.drawLine(mStartX, startY0, newX, newY, mPaint);
                    }
                    mStartX = newX;
                    startY0 = newY;
                }
            } else {
                // 清空画布
                if (isRead) {
                    if (startY0 == -1) {
                        startX = 0;
                    }
                    Paint paint = new Paint();
                    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
                    mCanvas.drawPaint(paint);
                    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
                    initBackground(mCanvas);
                    stopThread();
                }
            }
        } catch (NoSuchElementException e) {
            e.printStackTrace();
        }
    }

    public static boolean addEcgData0(Float data) {
        return ecg0Datas.offer(data);
    }

    public static void clearEcgData0() {
        if (ecg0Datas.size() > 0) {
            ecg0Datas.clear();
        }
    }

    //绘制背景 网格
    private void initBackground(Canvas canvas) {
        canvas.drawColor(mBackgroundColor);
        //小格子的尺寸
        latticeWidth = mWidth / mGridWidths;
        latticeHigh = mHeight / mGridHighs;
//        Log.e("lattice", "initBackground---latticeWidth:" + latticeWidth + "  latticeHigh:" + latticeHigh);

        mbgPaint.setColor(mGridColor);

        for (int k = 0; k <= mWidth / latticeWidth; k++) {
            if (k % 5 == 0) {//每隔5个格子粗体显示
                mbgPaint.setStrokeWidth(2);
                canvas.drawLine(k * latticeWidth, 0, k * latticeWidth, mHeight, mbgPaint);
            } else {
                mbgPaint.setStrokeWidth(1);
                canvas.drawLine(k * latticeWidth, 0, k * latticeWidth, mHeight, mbgPaint);
            }
        }
            /* 宽度 */
        for (int g = 0; g <= mHeight / latticeHigh; g++) {
            if (g % 5 == 0) {
                mbgPaint.setStrokeWidth(2);
                canvas.drawLine(0, g * latticeHigh, mWidth, g * latticeHigh, mbgPaint);
            } else {
                mbgPaint.setStrokeWidth(1);
                canvas.drawLine(0, g * latticeHigh, mWidth, g * latticeHigh, mbgPaint);
            }
        }
    }

    /**
     * 清空 画布
     */
    public void ClearDraw() {
        Canvas canvas = null;
        try {
            canvas = surfaceHolder.lockCanvas(null);
            canvas.drawColor(Color.WHITE);
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.SRC);
            // 绘制网格
            initBackground(canvas);
        } catch (Exception e) {

        } finally {
            if (canvas != null) {
                surfaceHolder.unlockCanvasAndPost(canvas);
            }
        }
    }

    //  float 四舍五入 转换为 int 类型
    public static int floatToInt(float f) {
        int i = 0;
        if (f > 0) {
            i = (int) ((f * 10 + 5) / 10);
        } else if (f < 0) {
            i = (int) ((f * 10 - 5) / 10);
        } else i = 0;
        return i;
    }

}
复制代码

本人GitHub Demo

到这里,主要的绘制 都基本完成,语言能力组织比较差,希望大家多多担待,本人QQ :745612618。加好友 请备注 名称 目的。 非诚勿扰 谢谢了。 本人这里也有 一个 android 技术开发群 (不吹水,不要钱,汉王 美团 bat 大佬 比比皆是) 回答对 入群问题 (Kotlin 问题),方可进入。群号:195135516

文章分类
Android
文章标签