写在前面
本项目用到的 主要知识点: 手机蓝牙 (动态权限申请,蓝牙打开,连接,配对,基于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 (主要通过该方法申请,在本文中不详细做解释,其他人动态权限申请工具类:http://www.jianshu.com/p/5f24c14eae5a)
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实时绘制波形了。
2. 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位置 如果有基础比较不好的 看这里 http://www.gcssloop.com/customview/CustomViewIndex/)
以下为 自定义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;
}
}
到这里,主要的绘制 都基本完成,语言能力组织比较差,希望大家多多担待,本人QQ :745612618。加好友 请备注 名称 目的。 非诚勿扰 谢谢了。 本人这里也有 一个 android 技术开发群 (不吹水,不要钱,汉王 美团 bat 大佬 比比皆是) 回答对 入群问题 (Kotlin 问题),方可进入。群号:195135516