前言
之前我们已经讨论并实现了两种实现滑动的方案,最终第二种实现了我们想要的效果,今天我们对方案二优化一下,让我们的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的功能扩展。