昨日,乐视体育确认终止对北京五棵松体育馆的冠名。在乐视体育与场馆所有方的联合声明中,双方表示此次冠名的终止是进行“友好协商”的结果。声明中还称,“尽管冠名合作已经终止,双方仍将保持良好的沟通和联系,期待在未来合适时机展开新的合作”。
本篇来自 linjiang 的投稿, 分享了自己的线性图标开源库,效果非常不错,希望大家喜欢。
linjiang 的博客地址:
http://linjiang.tech
如果项目中需要用到图表,我们第一时间可能想到的就是 Github 上叫作 MPAndroidChart 的库,它拥有丰富的定制化属性并同时支持各种图表,包括线性图、饼状图、雷达图等,炫酷得不行。但是我们大多时候可能只是需要其中一种,例如线性图,集成 MPAndroidChart 就变得得不偿失了,即使进行抽离,那复杂的层级关系和属性配置估计也够时间让自己实现一波了。
本文基于以上原因并结合实际开发需求打造的一款小巧且高效的线性图表。小巧体现在整个库的所有实现集中于一个java文件,非常便于集成并自定义修改;而高效则是相对而言,综合对Github上一些同类库的分析,大部分库对于数据量的支持以及性能方面没有很好地进行优化,停留在展示和学习层面,在实际应用方面还有待完善。
闲话不多说,先展示我们最终实现的效果:
该项目已经托管到了Github上,地址是
https://github.com/whataa/SuitLines
欢迎star、学习和使用。
下面我们基于以上效果图,一步步来分析如何实现。
很明显,与平常所见的最明显的区别就是本图表支持多条线。对于一条线,我们定义为一组点集合,用 List 表示,那么多条线我们这里采用的是 Map,key为线的索引,value为线的数据集合,通过限定传入数据的方式来达到 KEY 的连续性,KEY 的连续性有助于之后的遍历。
除开 View 本身的 padding 外,整个视图分为了3个区域:x轴、y轴 以及 lines所在区域,三个区域的大小和位置都是相对的,唯一需要变化的就是y轴,因为其刻度文字的长短需要根据传入的数据改变,因此我们定义方法 calcAreas 来保证每次重新填充数据后,相应的三个区域可以适配:
xArea、yArea 和 linesArea 分别表示 x轴区域、y轴区域 以及 画线区域,其中 baseY 就是根据传入的数据计算得到的最大值。这样,三个区域的大小也就确定了。
首先要明确一点,坐标都是相对该 View 的 Canvas 而不是我们上一步得到的 linesArea,以防稍后理解混淆。
对于X:首先需要知道可见区域最多可显示的点数,我们定义为 maxOfVisible,当然,如果传入的数据没有超过限定那么是多少就是多少,这样两个点之间的间距就是 linesArea 的宽度与 (maxOfVisible-1) 的均分,最后每个点的x就为:
linesArea.left + realBetween * i
对于Y:由于View坐标系的Y默认是从上往下递增的,因此对于每个点,其值越大,y坐标应该越小,约定y最大时就是坐标0,基于以上原则得到的最终计算公式为:
linesArea.top + linesArea.height() * (1 - 当前点的值 / maxValueOfY)))
即当前点的值与最大值的占比乘上实际 lineArea 的高度,就是其y相对最大值的y的距离。
各个点的坐标确认后就需要在 onDraw 中利用 Canvas 来画线了。我们先来看看需要支持的线的类型有哪些:
忽略最后的坐标轴的变化,可以看到我们需要支持的类型包括:曲线、线段、虚线以及是否填充这几种类型,但是其中虚线和是否填充是可用通过修改Paint的属性来实现,同时 Canvas 包含有直接 DrawLine 的方法,因此,这里的难点便是如何画曲线。
曲线就必须要了解一下贝塞尔曲线的概念了,但限于篇幅,需要具体了解的请自行搜索。Path 提供了基于贝塞尔曲线公式的 cubicTo方法 来连接下一个点,但是需要提供两个控制点,控制点的不同,最终的构建的曲线区别是非常大的,因此这是一个仁者见仁的选择。
我们这里的两个点选取的是x位于起始和终止点的中点,y分别与起始和终止平行的两个点,如果假设 a(x0,y0),b(x1,y1) 分别代表起始和终止点,那么两个控制点的坐标就分别是:((x0+x1)/2,y0),((x0+x1)/2,y1),这样一种类似于正弦波的曲线就构造出来了。
同时,虽然前面说到线段可以通过 Canvas 的 drawLine 方式实现,但是为了和曲线统一,方便修改 line 的颜色、大小、是否填充等,我们这里把线段也修改为 path 方案。
如果我们运行代码,我们应该会看到,绘制出来的 line 居然超出了 linesArea 区域,在我们排除的 View 的 padding 区域也有,这显然不符合预期,因此我们需要对其进行裁剪,Canvas 的 clipRect方法 使得指定区域以外的所有操作不会生效,我们可以通过该方法实现,但是如果这样,我们的x和y轴区域岂不是也看不到了?所以我们采用 Canvas 的分层原理,将 linesArea 区域的所有相关操作保留在一层,就不会影响到其它区域了:
到这儿离最终效果就不远了,我们还需要处理过多点的情况,这时候剩余的点虽然已经 draw 出来了但是用户无法看见,因此就需要支持手势左右拖动了。注意,这里的拖动千万不要理解为不断跟随手指移动的距离来修改每个点的坐标,我们需要转换思维理解,伟大的相对论提到,如果你想进一步,让别人退一步也是可以的,所以我们可以利用 Canvas 的 translate方法 来平移画布,使我们想要看到的部分始终位于屏幕区域:
canvas.translate(offset, 0);
这里的offset即手指移动的距离,需要在onTouchEvent中计算,很简单就不贴源码了,如果想在多手指方面尽善尽美,请参见我的另一篇笔记:
《多手指Touch变化处理原则基础》
http://linjiang.tech/2016/09/26/%E5%A4%9A%E6%89%8B%E6%8C%87Touch%E5%8F%98%E5%8C%96%E5%A4%84%E7%90%86%E5%8E%9F%E5%88%99%E5%9F%BA%E7%A1%80/
另外,在手指释放的一刻,为了符合物理习惯预期,还需要通过 Scroller 进行 fling 处理,相关的知识点还包括了 VelocityTracker,由于并非本文重点,所以请自定了解或直接参见源码。现在,手指的处理应该符合预期了,但是也不能让它一直滚啊,毕竟数据是有限的,所以我们还需要为它约束滚动的区间,当 Canvas 的最右边刚好是最后一个点,那么我们就不能让 Canvas 继续滚动了,这期间 Canvas 平移的总距离就应该是:
maxOffset = 最后一个点的x坐标 - Canvas的宽度
完善好这一切后,就达到了如下效果图:
我们注意到,上图中我们还没实现的包括 边缘拖动效果、动画已经点击事件效果,这里我们分别简单讲解一下。
拖动效果
这是利用 EdgeEffect类 来实现的,该类的具体用法似乎很少,更不用说源码分析,因此这里参考的是 ScrollView 内部的实现,有2个注意的地方,第一是效果绘制的区域及方向,默认是基于 Canvas 的左上角,向下回弹的效果,所以我们需要将其进行旋转,同时平移到可见区域的左右两边;第二点是当 fling 到 Canvas边缘 时的情况,由于不是我们手动的拖动,所以 EdgeEffect 提供了 onAbsorb方法,只需要传入速度就可以模拟出同样的效果,但是该方法正确的做法是只传入到达边缘时的那一刻的速度就行了,具体实现如下:
动画
这种动画类似我们提着绳子的一头摆动的常见,这种情况下会形成一个向前传递的波动效果。也就是说,每个点的动画效果是一样的——上下运动(用 OvershootInterpolator 插值器模拟),但是启动这个动画的时间不同,整体来看,是一个从左向右的先后顺序,因此我们最终的实现过程是启动一个线性动画依次遍历线上的每个点(用 LinearInterpolator 插值器模拟),每到一个点就启动其对应的上下运动的动画,这样就完成了整个动画的过程:
点击效果
点击肯定是不能使用 setOnClickListener 的方式了,我们仍是在 onTouchEvent 中进行处理,先思考一下,我们如何区分点击和拖动?其实很简单,虽然没有什么 onIntercept 之类的方法,也没有 focus 之类的状态,但是我们知道,点击与拖动的最大不同就是按下和抬起的位置是大约一致的,这样就可以区分了;其次,如何判定我们点击的是哪一条线上的哪一个点呢,这还是得利用到我们手指的坐标和当前偏移位置两个变量。
由于手指的坐标始终是相对 View 的左上角原点而言的,所以需要与 Canvas 保持一致进行偏移:
另外,假设两点间的距离是 a,当前手指落下的x坐标是 b,如果我们需要得到当前按下的点的索引是t,那么有如下结论:a*t = b,也就是说,t 就约为 b / a,我们只需根据小数点来进行判定即可;当存在多条线的情况时,我们还需要确定按下的是哪条线,这个就相对简单,由于 Y轴 并不存在偏移,所以只需要进行简单的比较运算,手指的y例哪条线近,点击的就是哪条,当然前提是在阈值范围内,代码较长,具体请参见源码`onTap`方法。
如果现在我们直接将源码集成到项目中,我们会发现效果并不理想,有卡顿甚至 Crash 的情况出现,这其实是因为我们在此之前一直都是将整条线都完全绘制到了 Canvas 上,即使是不可见的部分,那么当数据量非常多,甚至是多条线的情况,那么随着拖动,CPU和PU的负荷是很大的,因此我们需要优化,其实大家已经猜到了,方案就是仅绘制可见区域的部分。
仅绘制可见区域
如何确定可见区域相应的点区间可以参考上面如何确定点击的是线上的哪个点的方案,我们将可见区域的左边缘视为手指所点击的x坐标,那么具体是哪个点我们就知道了,同理可见区域的右边缘对应的点,但是这里需要注意的是,我们计算得到的点是在两个边缘以内,那么我们吧他们之间的点连接起来形成的部分就与两个边缘存在部分空白,因此我们还需要额外增加一个边缘以外的点。
确定如何计算点区间的方案后,只需要每次在 onDraw 中重绘时调用即可,但是这样也无形中增加了CPU的压力,因为每次重绘都要计算,所以我们还需要优化一下,仔细观察其实只要计算出来的点区间在下一次没有出现跨越可见区域的两个边界,就不需要重新计算的,因此我们可以增加判定来减少:
但是由于手指滑动或 fling 计算出的 offset 不是连续按1px递增/减的,所以该判定的有效率在速度非常快的时候是比较低的。
离屏绘制
其实有一种更优的方案来解决性能问题,便是离屏,具体请自行搜索,简单概括就是将需要的部分先绘制到一个某个 Canvas 上(内部即Bitmap),该 Canvas 并不是当前 onDraw 这个 Canvas,然后当需要呈现的时候就直接将之前离屏的这个 Bitmap 绘制即可,这比绘制 Path 要高效得多,但是由于我们的计算的点区间是不断变化的,且可能存在大量的数据,所以不可用此方案,但是我们回到坐标轴上看,y轴区域其实在滚动或重绘时,都是不变的,我们就可以将这部分进行优化来减少负荷:
到此,整个步骤完成,实现了一个小巧且高效的线性图表,代码可以通过前文的 github地址取得。简单一句话:
compile 'tech.linjiang:suitlines:1.0.0'
就可以集成,如果有疑问或者建议,欢迎在Github上提交issue,一起讨论。
每天学习累了,看些搞笑的段子放松一下吧。关注最具娱乐精神的公众号,每天都有好心情。
如果你有好的技术文章想和大家分享,欢迎向我的公众号投稿,投稿具体细节请在公众号主页点击“投稿”菜单查看。
欢迎长按下图 -> 识别图中二维码或者扫一扫关注我的公众号: