图表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的缓存集合
     * */
    private val cache = HashSet()

    /**
     * 从缓存中取一个
     * */
    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(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的功能扩展。

你可能感兴趣的:(图表CanvasChartView(四):基于方案二的优化)