概述
这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自View,概括一下主要有以下内容要实现:实时显示动态心电测量数据、心电波形左右滑动、惯性滑动及波形 X轴和 Y轴方向双指滑动缩放。下面我们来看看效果图,图片上传大小有限制,所以分两张:
下面我们将功能拆解,分步实现:
- 画背景绿色网格线
- 绘制实时动态心电曲线
- 实现单指曲线左右平移
- 实现曲线惯性滑动
- 实现 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为负的了。这样就实现上面效果中,测量实时心电时,曲线会向左移。这样新来的数据就显示在控件可见范围内,早来的数据逐步向左移出控件可见范围。下面画个草图吧,草图大概就这么个意思:
下面看一下实现:
/**
* 创建曲线
*/
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这个事件体系还蛮大。我们只讲一下这次用到的部分。
好吧,还是直接画表格吧。这样也直观一点,不用解释那么多。上面红色圈圈圈出来的几个哥们是我们这次要用到的。
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吧 :心电图