图表CanvasChartView(四):基于方案二的优化
前言
之前我们已经讨论并实现了两种实现滑动的方案,最终第二种实现了我们想要的效果,今天我们对方案二优化一下,让我们的CanvasChartView体验起来更屌。
都有哪些地方需要优化呢:
Fling效果,惯性滑动是必备的
优化绘制过程中Path对象创建多次的问题,这会造成内存的浪费
文字的测量等计算,滑动的时候还要绘制之前的数据的文字,可以缓存一部分经常使用的文字宽高
整理代码的逻辑,优化部分代码
主要是以上四点,接下来我们就一个一个解决。
正文
优化Fling惯性滑动
之前我们使用Scroller实现滑动的距离的计算,其实Scoller本身就有Fling方法,很多朋友都知道:
scroller.fling(offsetX.toInt(), 0,
-velocityX.toInt(), velocityY.toInt(),
Integer.MIN_VALUE, Integer.MAX_VALUE,
0, 0)
参数1:开始滑动的x坐标,x方向的起始位置;
参数2:开始滑动的y坐标,与方向的起始位置;
参数3:x方向的速度,可能会影响到x方向滑动的距离;
参数4:y方向的速度,可能会影响到y方向滑动的距离;
参数4:x方向滑动的最小距离;
参数5:x方向滑动的最大距离;
参数6:y方向滑动的最小距离;
参数7:y方向滑动的最大距离;
参数还真是多,主要有迷惑的参数是minX/minY和maxX/maxY,有时候不知道该设置什么大小合适,所以直接传int的最大值和最小值就可以了,具体滑动多少距离就交给速度去处理吧,RecyclerView也是这么处理的,这种鸡贼的方式我很喜欢。
替换代码
scroller.startScroll(offsetX.toInt(), 0, dx, 0)
->
scroller.fling(offsetX.toInt(), 0,-velocityX.toInt(), velocityY.toInt(), Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0)
你以为这样就结束了吗?很可惜,当你不停的滑动的时候你会发现fling方法偶尔不会触发
override fun computeScroll()
这样就不能计算滑动的距离,也无法重绘,滑动的效果自然也不会显示了。
没想到Scroller还有这种的坑,为什么RecyclerView没有这样的问题呢?是我的操作哪里出错了吗?
带着问题我们去看RecyclerView的源码,就可以找到答案,因为代码太多了,直接贴出我们模仿RecyclerView解决问题的代码:
/**
* ViewFling滑动辅助类
* */
private inner class ViewFling : Runnable {
override fun run() {
if (scroller.computeScrollOffset()) {
offsetX = scroller.currX.toFloat()
val isBound = checkBounds()
Log.e("lzp", "offsetX is :$offsetX")
invalidate()
if (isBound) {
scroller.abortAnimation()
} else {
postOnAnimation()
}
}
}
/**
* 开始滑动
* */
fun postOnAnimation() {
ViewCompat.postOnAnimation(this@BaseScrollerView, this)
}
/**
* 停止滑动
* */
fun stop() {
removeCallbacks(this)
scroller.abortAnimation()
}
}
RecyclerView并没有通过computeScroll来实现惯性滑动,他使用递归的形式计算滑动的距离,直到Scroller滑动结束,接下来在修改代码:
scroller.fling(offsetX.toInt(), 0,
-velocityX.toInt(), velocityY.toInt(),
Integer.MIN_VALUE, Integer.MAX_VALUE,
0, 0)
viewFling.postOnAnimation()
删除computeScroll方法,到此惯性滑动的问题解决。
优化Path对象的创建造成的内存浪费
在onDraw方法中,我们每次重绘都要创建新的Path,其实只要缓存第一次创建的Path就可以了,之后的绘制都可以复用Path对象。
首先我们创建一个Path的缓存管理类:
package com.lzp.com.canvaschart.view3
import android.graphics.Path
/**
* Created by li.zhipeng on 2018/5/21.
*
* Path缓存的管理器
*/
class PathCacheManager {
/**
* 正在使用的对象集合
* */
private val useSet = HashSet<Path>()
/**
* Path的缓存集合
* */
private val cache = HashSet<Path>()
/**
* 从缓存中取一个
* */
fun get(): Path {
// 如果已经没有可用的缓存Path,创建Path,并添加到useSet
return if (cache.size == 0) {
val path = Path()
useSet.add(path)
path
} else {
// 如果缓存中有空闲的Path,取出第一个
val path = cache.elementAt(0)
// 重置path的设置
path.reset()
// path从缓存中移动到使用中
useSet.add(path)
cache.remove(path)
return path
}
}
/**
* 重置缓存, 把使用中的Path添加到缓存中,并清空缓存
* */
fun resetCache() {
cache.addAll(useSet)
useSet.clear()
}
}
代码不多,我们使用两个HashSet保存创建的Path,每次绘制前先resetCache,把使用中的path移动到缓存中,通过get方法从缓存中取出Path对象,如果已经没有可以复用的Path,再创建Path对象并添加到缓存中。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 保存一下canvas的状态
canvas.save()
// 这里要重置一下缓存,因为要开始绘制新的图标了
pathCacheManager.resetCache()
// 绘制X轴和Y轴
drawXYLine(canvas)
// 从这里开始,我们要对canvas进行偏移
canvas.translate(getCanvasOffset(), 0f)
// 绘制每一条数据之间的间隔虚线
drawDashLine(canvas)
// 绘制数据
drawData(canvas)
// 恢复一下canvas的状态
canvas.restore()
}
其他要使用的Path的地方都修改为PathCacheManager.get方法从缓存中取,这里就不贴代码了。
优化部分计算
因为要文字要以数据的圆点为中心,所以每次我们知道文字的宽度,例如我们向右滑动一个刻度,要绘制很多次,但是文字的内容只变化了一个,而我们仍然计算每一个文字的宽度,这也是一种浪费。
贴出主要的代码:
/**
* 文字宽度的缓存,这里可以考虑直接使用Lruache
* */
private val textWidthLruCache = LruCache<String, Float>(6)
/**
* 从缓冲中获取文字的宽度
* */
private fun getTextWidth(key: String): Float {
var width = textWidthLruCache.get(key)
// 如果缓存中没有这个文字的宽度,先测量,然后添加到缓存中
if (width == null) {
width = paint.measureText(key)
textWidthLruCache.put(key, width)
}
return width
}
我这里缓存了6个文字的宽度,绘制的时候看看最近有没有测量过,就是这么简单。
另外我们还反复计算了markWidth,也就是每一个刻度的宽度,所以我们可以考虑把他提升为全局属性:
/**
* 每个刻度的宽度
* */
private var markWidth: Int = 0
/**
* x轴的刻度间隔
*
* 因为x周是可以滑动的,所以只有刻度的数量这一个属性
* */
var xLineMarkCount: Int = 5
set(value) {
field = value
calculateMaxWidth()
}
/**
*计算最大宽度
* */
private fun calculateMaxWidth() {
// 计算每一个刻度的宽度
markWidth = width / xLineMarkCount
// 得到数据的数量
val count = adapter?.maxDataCount ?: 0
maxWidth = if (count < xLineMarkCount) {
canScroll = false width
}
else {
canScroll = true width / xLineMarkCount * count
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
calculateMaxWidth()
}
每当影响到了刻度宽度的计算,都应该重新计算。
优化代码的逻辑
首先看一下我们之前的手势处理的代码:
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// 如果不能滑动,不处理手势滑动
if (!canScroll) {
return false
}
// 计算滑动的速度
createVelocityTracker(event)
when (event.action) {
// 记录手指按下的坐标
MotionEvent.ACTION_DOWN -> {
xDown = event.rawX
}
//
MotionEvent.ACTION_MOVE -> {
// 更新xDown的坐标
if (xMove != -1f) {
xDown = xMove
}
// 备份偏移的位置
offsetXTemp = offsetX
// 记录当前的x坐标
xMove = event.rawX
// 计算移动的位置
offsetX += (xDown - xMove)
// 对移动的位置进行范围检查
// 如果小于0,那么等于0
if (offsetX < 0) {
offsetX = 0f
}
// 如果已经大于了最右边界
else if (offsetX > maxWidth - width) {
offsetX = maxWidth - width.toFloat()
}
// 检查偏移值是否发生了改变
if (offsetX != offsetXTemp) {
// 重绘
invalidate()
}
}
// 手势抬起
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val dx = calculateFlingDistance()
// startScroll()方法来初始化滚动数据并刷新界面
scroller.startScroll(offsetX.toInt(), 0, dx, 0)
invalidate()
recycleVelocityTracker()
// 重置配置信息
reset()
}
}
return true
}
是不是onTouchEvent看着太长了?看起来就头疼,所以我们考虑优化一下代码,当然细分扩展成一个个功能模块也是一种方案,我这里考虑使用GestureDetector:
/**
* 图表手势处理类
* */
private inner class ChartGesture : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
// 如果scroller正在滑动, 停止滑动
if (!scroller.isFinished) {
viewFling.stop()
}
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
// 计算移动的位置
offsetX += distanceX
// 边界检查
checkBounds()
invalidate()
return true
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return true
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
Log.e("lzp", "velocity is :$velocityX")
scroller.fling(offsetX.toInt(), 0,
-velocityX.toInt(), velocityY.toInt(),
Integer.MIN_VALUE, Integer.MAX_VALUE,
0, 0)
viewFling.postOnAnimation()
return true
}
}
GestureDetector是手势的封装类,它已经帮助我们区别手势都做了哪些动作,例如单击、双击、滑动等等,我们只要在对应的方法中开发我们自己的功能就可以了。
还有一个部分需要我们优化,那就是之前偷懒的计算开始位置和结束位置的计算,看一些新的计算方法:
/**
* 根据偏移值,计算绘制的数据的开始位置
* */
protected fun getDataStartIndex(): Int {
// 计算已经偏移了几个刻度
val index = (offsetX - markWidth / 2) / (markWidth)
return index.toInt()
}
/**
* 根据偏移值,计算绘制的数据的结束位置
* */
protected fun getDataEndIndex(startIndex: Int): Int {
return Math.min(startIndex + xLineMarkCount + 2, adapter!!.maxDataCount)
}
/**
* 计算canvas绘制的偏移值
*
* 偏移值 - 刻度值宽度 * 开始位置,相当于对刻度值宽度取模
* */
protected fun getCanvasOffset(): Float {
// 计算已经偏移了几个刻度
val index = (offsetX - markWidth / 2) / (markWidth)
// 计算与第一个刻度的偏移值
val offset = offsetX % markWidth
return when {
index.toInt() == 0 -> -offsetX
offset >= markWidth / 2 -> -offsetX % markWidth
else -> -offsetX % markWidth - markWidth
}
}
计算开始位置:数据的圆点在刻度的中间,计算已经滑过多少个刻度的时候,先减去半个刻度宽度,再除以刻度的宽度,得到的就是开始位置。
结束的位置:首先我们要明确至少要画的点是6个,例如刚开始第五个点在第五个刻度的中间,就需要画下一个点的连线,所以至少是6个点,但是两头是连线,中间有五个点的时候,最多是7个,如果想要精确的判断到底是6个还是7个,需要判断开始绘制的偏移值是否正好是半个刻度加减圆点的半径,圆点的半径是很小的,所以这里不如快刀斩乱麻,全都返回7个,就是+2。
偏移值:
如果是第一个直接把偏移值取负返回;如果还没滑到一半,
如果第一个刻度已经滑动超过了一半,不需要绘制上一条的连线,取模取负
如果第一个刻度的滑动距离没超过一半,需要绘制上一条连线,所以还得多减一个刻度的宽度;
总结
我们之前列举的优化点,已经全部完成了,个人感觉比以前要流畅多了,接下来应该扩展一下CanvasChartView了,例如:
自定义属性,线条的颜色,粗细等等
增加数据点之间的连线样式为曲线
增加只显示x,y均为正数的情况
增加显示刻度值
下一篇也是这个系列的最后一篇了:CanvasChartView的功能扩展。