美文网首页Android开发Android自定义ViewAndroid技术知识
图表CanvasChartView(四):基于方案二的优化

图表CanvasChartView(四):基于方案二的优化

作者: 珠穆朗玛小王子 | 来源:发表于2018-06-06 17:45 被阅读20次

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

    相关文章

      网友评论

        本文标题:图表CanvasChartView(四):基于方案二的优化

        本文链接:https://www.haomeiwen.com/subject/dscisftx.html