美文网首页ios学习iOS的UI学问源码学习
[Swift]如何造一个图表类的轮子——Charts源码解读

[Swift]如何造一个图表类的轮子——Charts源码解读

作者: pingpong_龘 | 来源:发表于2017-06-19 14:54 被阅读1004次

    1. 背景

    最近基于业务需求,需要在两个星期内,做出十几个数据分析类的图表,包括折线图,柱状图,散点图,饼图等,用以对用户的比赛数据做一个汇总统计和分析。
    产品经理说,这个功能是我们准备作为付费用户的特享有功能,所以务必做到数据准确,界面酷炫,体验流畅...(边说他边陷入了美好想幻想中...)
    听完我当场就想回去写辞职信了,两个星期,其中给测试和发版有一个星期,所以纯开发基本就一个星期时间,莫非你在搞笑? 一个星期,这个全部做出来?
    底层结构搭建,比赛的数据封装,和后台的API定义与调试,图表异常场景的处理方案,老版本数据的兼容性等等,左估右算显然是做不出来了,所以只能先找个靠谱点的轮子,再进一步扩展了。

    2. Charts

    因为这期项目,组内决定逐渐从Objc转到swift,所以,也直接就从swift中找了个靠谱的图表库--Charts
    Github:https://github.com/danielgindi/Charts
    至于为什么选择他,原因很简单:star最多!

    这个项目的作者是danielgindi,他自己介绍说这个项目是MPAndroidChart对应的Apple平台的版本(iOS/tvOS/OSX均支持)

    3. 话不多说,直接看效果

    屏幕快照 2017-06-18 下午8.20.22.png
    屏幕快照 2017-06-18 下午8.20.30.png

    4. 如何引入

    引入很简单,不详细说了

    4.1 通过cocoapods

    pod 'Charts'
    

    4.2 通过charthage

    github "danielgindi/Charts" == 3.0.2
    

    4.3 通过project方式引入

    把这个charts的project通过target的方式引入,他们自己提供的demo就是通过这种方式。(个人不是很推荐这种方式)


    屏幕快照 2017-06-18 下午8.26.00.png

    4.4 其他,比如直接拖源码

    总之随便你以任何你习惯的方式,但是切记他们是遵循Apache Licence,你也要对应做出相应的规范。

    5. 如何使用

    5.1 准备

    这个库实际的实现是有点复杂的,而且每个类的继承和成员变量都错综复杂,一开始都没搞太明白,后来做了些测试加上他们提供的源码,大概摸清楚了他们的一些道道,后面主要用他们的demo做举例:

    备注: 他们的demo是用OC写的,源码使用swfit写的

    5.2 绘制坐标轴

        _chartView = [[LineChartView alloc] init];
        _chartView.delegate = self;
    
        // x-axis limit line
        ChartLimitLine *llXAxis = [[ChartLimitLine alloc] initWithLimit:10.0 label:@"Index 10"];
        llXAxis.lineWidth = 4.0;
        [_chartView.xAxis addLimitLine:llXAxis];
            
        ChartYAxis *leftAxis = _chartView.leftAxis;
        leftAxis.axisMaximum = 200.0;
        leftAxis.axisMinimum = -50.0;
        
        _chartView.rightAxis.enabled = NO;
        [_chartView animateWithXAxisDuration:2.5];
    }
    

    使用charts绘图,其实很简单,构造一个chartView,然后设置他的坐标轴的上限和下限,然后设置data,就好了,如果想以动画的方式展开,再加一句[_chartView animateWithXAxisDuration:2.5];就好.
    像上面这段代码,就是做了3件事:

    • a. 构造一个LineChartView
    • b. 加一条限制线,[_chartView.xAxis addLimitLine:llXAxis]; ,无非就是在x=10的位置上画了一条垂直线,线宽为4.0
    • c. 设置左侧Y轴(leftAxis)的上下界[-50,200]

    OK,看到这里,大概了解了Charts的基本用法,然后想知道,如何给他填充我们的数据,从而画出那些图表线来?

    5.3 绘制数据

    如果看ChartsView的基类,会看到ChartViewBase,每一个ChartViewBase都有个data属性,

        internal var _data: ChartData?
    

    你所需要做的就是给这个data赋值,赋值成功,图表自会生成,上代码:

    set1 = [[LineChartDataSet alloc] initWithValues:yVals1 label:@"DataSet 1"];
    set1.axisDependency = AxisDependencyLeft;
    [set1 setColor:[UIColor colorWithRed:51/255.f green:181/255.f blue:229/255.f alpha:1.f]];
    [set1 setCircleColor:UIColor.whiteColor];
    
    set2 = [[LineChartDataSet alloc] initWithValues:yVals2 label:@"DataSet 2"];
    set2.axisDependency = AxisDependencyRight;
    [set2 setColor:UIColor.redColor];
    [set2 setCircleColor:UIColor.whiteColor];
    set2.drawCircleHoleEnabled = NO;
    
    NSMutableArray *dataSets = [[NSMutableArray alloc] init];
    [dataSets addObject:set1];
    [dataSets addObject:set2];
    
    LineChartData *data = [[LineChartData alloc] initWithDataSets:dataSets];
    [data setValueTextColor:UIColor.whiteColor];
    [data setValueFont:[UIFont systemFontOfSize:9.f]];
    
    _chartView.data = data;
    

    可以看到我们将数据源转化为一个个ChartDataSet,然后组合成ChartDataSets(数组),然后可构造出ChartData,这个就行chartView需要的data. 这样,图表就赋值成功了。

    6. 深入研究

    其实走到5这一步,基本就可以进行开发了,产品经历的需求基本也可以分分钟完成了(当然我自己在开发中也遇到了很多的坑,后面会细说)。
    但是
    既然偷懒用的第三方的东西,虽然省了时间,但是出于安全性和稳定性的考虑,还行需要了解这个库具体是怎么实现了,一方面可以作为自己学习的一个积累;另一方面,如果以后有一些定制化的图层需要修改,也可以对该库进行修改和扩展,而且如果其内部有些待优化或者处理不当的地方,也好提前做好预防工作。

    6.1 首先看一下Charts的ChartView家族

    Charts提供了7种基本的类型图表,具体看下图:

    Notes:此图为本人独家提供

    屏幕快照 2017-06-18 下午9.42.37.png

    柱状图和线形图一类(BarLineChartViewBase),饼图和雷达图一类(PieRadarChartViewBase),
    二者的共同点是都有xAxis(横坐标)
    二者的区别是:
    BarLineChartViewBase有YAxis(纵坐标)

        /// the object representing the left y-axis
        internal var _leftAxis: YAxis!
        
        /// the object representing the right y-axis
        internal var _rightAxis: YAxis!
    

    PieRadarChartViewBase没有YAxis(本身也不需要,对吧),只有一些旋转相关的属性(选择角度等)

        /// holds the normalized version of the current rotation angle of the chart
        fileprivate var _rotationAngle = CGFloat(270.0)
        
        /// holds the raw version of the current rotation angle of the chart
        fileprivate var _rawRotationAngle = CGFloat(270.0)
    

    当然还有个CombinedChartView,我没列出来,就是可以随意的组合BarLineChartViewBase的子类。

    比较常用的是BarChartView和LineChartView,如果是做数据分析可能会用到ScatterChartView散点图或者BubbleChartView气泡图,如果是做金融行业的,那必然是要用到CandelStickChartView(常说的K线图)

    6.2 Charts的数据模型

    Charts的数据model为ChartData,整理了下ChartData的结构图如下:


    屏幕快照 2017-06-18 下午11.01.49.png

    我们的数据源就是(x,y), 一个个数据点构成了ChartDataEntry,大部分ChartDataEntry只有一个x,一个y,但是类似BarChartDataEntry,可以有多个y值(柱状图可以由多段组成),所以BarChartDataEntry持有的是_yVals

        /// the values the stacked barchart holds
        fileprivate var _yVals: [Double]?
    

    ChartDataEntry组成ChartDataSet,每一个ChartData都是由多组ChartDataSet构成。

    不同的ChartDataSet可以理解为,将数据“分组”

    比如实际应用中,我们以每个季度为一组,展示用户的统计数据,每个季度又包含每个月的数据,那么就可以组建4个ChartDataSet,每个ChartDataSet包含3个ChartDataEntry,这样,不同的季度,对应各自的ChartDataSet可以设置不同的展示模式和效果。

    6.3 如何渲染数据

    6.3.1 渲染的英文是什么?

    Render!!! (这个是专有词汇,不是romance,dramatize或lender color)

    6.3.2 Render

    所以Charts中当你看到xxxRender的时候,就知道它负责视图的界面渲染工作.

        /// object responsible for rendering the data
        open var renderer: DataRenderer?
    

    这个类名是DataRenderer, 变量名是renderer,下面统一简称为Render

    Render负责drawData(context: CGContext),

    我们知道,iOS中UIView的绘制渲染工作是在func draw(_ rect: CGRect)中进行的

    当drawRect调用的时候,我们通过Render去执行drawData进行视图绘制,包括横纵坐标,包括Image,Text,Path等.
    简单说就是如下图:


    屏幕快照 2017-06-19 上午12.19.46.png

    Render负责拿到ChartData的具体数据,然后在ChartView进行图层绘制. Render扮演了数据加工处理的角色,如果将这种设计架构理解为MVVM,那么Render这个模块,在我的理解就是ViewModel.


    MVVM.png

    另外,需要补充的一点是,如果看Render的实现源码,会看到,他在很多地方,用到了ChartDataProvider和IChartDataSet,这两个又是什么东西?


    dataProvider IBarChartDataSet

    仔细看代码的话,发现是2个protocol

    public protocol IChartDataSet { ... }
    public protocol ChartDataProvider { ... }
    

    他们是干嘛用的呢? 首先看ChartDataProvider:

    i>. ChartDataProvider是谁?
    答: ChartDataProvider本质就是ChartView,Render在代码用弱引用(weak)了一个ChartView,但从概念上,他就是Render的数据提供者。
    对Render而言:我持有数据和数据的提供者,在处理数据的时候,有时候需要问询一下数据提供者,这些数据是否有限制,是否合法等等.
    ii>. ChartDataProvider能做甚?
    答:这个是提供了一些横纵坐标轴的边界值和data的get方法. 每个子类的ChartView实现ChartDataProvider这个protocol所需要的function,然Render进行绘制图层的时候,会调用这些function,进行数据有效性的判定和逻辑的处理。

    那么再看IChartDataSet:

    他是ChartDataSet需要遵循的一个协议.
    在Render处理ChartDataSet的时候,需要IChartDataSet中要求的function来进行逻辑处理。

    基于以上的分析,我们知道了Render的实现需要ChartDataProvider和IChartDataSet的支撑。
    那么,我们的Render的结构图应该更新为:


    Render.png

    6.3.3 底层的实现

    那么问题来了,每个图层,每个Text都是Renderer徒手画上去的?上面谈到了,我们有7个类型的ChartView,再加上CombinedView,所以对应有8个Render,每个都徒手画图层?
    听上去工作量有点大啊?
    当然,肯定本着复用和工厂模式的原则,我们得对这些东西做一些封装,于是有了ChartUtils.
    具体的绘制都会在ChartUtils中实现,截一些ChartUtils的代码来看:

        open class func drawText(context: CGContext, text: String, point: CGPoint, align: NSTextAlignment, attributes: [String : AnyObject]?)
    
        open class func drawImage(
            context: CGContext,
            image: NSUIImage,
            x: CGFloat,
            y: CGFloat,
            size: CGSize)
    
    ChartUtils.png
    可以看到Charts绘制的本质就是获取当前的CGContext,然后通过ChartData获取到绘制点的CGPoint,然后进行Image或者Text的绘制
    iOS做多了,图形绘制,有各种各样的实现方式:
    • 最简单就是画个UIView,画个UIButton (所以之前会有人吐槽自己就是个UIButton工程师,每天画了各种各样的Button).
    • 稍微玩的嗨一点,就是从layer上,绘制个贝塞尔曲线UIBezierPath,然后加点Animation

    当然,画UIView和Layer,本质上是一样的,都是话了Layer,UIView只是系统对Layer的一个封装罢了

    • 再进一步就是直接通过CoreGraphics,在CGContext直接渲染了,绘制路径,和填充效果以及边界值,明确path和坐标点,基本也是可以搞下来的.

    既然谈到了,顺便补充下基础知识:


    屏幕快照 2017-06-19 上午12.57.03.png

    CoreGraphics也称为Quartz 2D 是UIKit下的主要绘图系统,频繁的用于绘制自定义视图。Core Graphics是高度集成于UIView和其他UIKit部分的。Core Graphics数据结构和函数可以通过前缀CG来识别。


    屏幕快照 2016-07-07 下午2.57.26.png
    OpenGL ES是OpenGL的子类(其实就是OpenGL为iOS做的服务).用于渲染2D和3D的图形数据。
    但是这个库是C语言写的,过于底层,对于iOS开发者来说不太友好(意思就是太难了,他们写起来太累)。于是Apple提供了一套高层的接口:
    • Sprite Kit 这个是2D游戏开发会用的库,专业制作各种吊炸天的特效
    • Core Image图像处理库,用于图像处理(比如滤镜之类的,“美图秀秀”这类软件比用此库)
    • Core Animation 这个比较常用了,大家平时基本所有的动画交互都是基于此完成的

    UIKit是在Cocoa Touch层的,其底层仍然是通过Core Animation实现的。

    6.4 Charts的数据流是怎样的

    6.4.1 chartView.data赋值

    对于最上层的使用来说,我们写出chartView.data = pieChartData这行代码的时候,感觉一切都搞定了

        let chartView = PieChartView.init(frame: self.bounds)
        var yValues: [PieChartDataEntry] = []
        ...
        let dataSet = PieChartDataSet.init(values: yValues, label: "")
        let pieChartData = PieChartData.init(dataSets: [dataSet])
        chartView.data = pieChartData
    

    因为啥也不用干,我们的界面就呈现出来了,但问题是chartView.data被赋值成功的那一刻,背后发生了些什么东西呢?

    6.4.2 数据流程

    简单整理了下他们的数据流大概是这样:

    屏幕快照 2017-06-19 下午12.51.58.png
    当我们设置了chartView的data之后,他在set方法里调用了dataChanged的通知notifyDataSetChanged(),这个方法的具体实现是在每个子类的ChartView中自己实现,主要做的事情是:

    i>. 重新计算边界值和offset偏移值
    ii>. 调用setNeedsDisplay(),触发视图界面的刷新

    之后在ChartView的几类和子类的drawRect方法中进行界面渲染:

    i>. 获取当前的CGContext
    ii>. 绘制横纵坐标轴
    iii>. 绘制数据(我们提供的数据源)
    iv>. 绘制额外的补充数据
    v>. 绘制图例(实际场景中饼图必须,其他图按需)
    vi>. 绘制description (其实就是右下角的一句描述话语,基本不太用,可能部分场景需求,写个“数据援引自...”之类)

    6.5 坐标轴转换是怎么实现的

    为什么会突然扯到这个问题,是因为在实际开发中,发现有些场景,我需要在ChartView上添加一些我自定义的东西,这些东西原生Charts库不支持,我只能自己直接添加。
    我现在是知道自己添加点的数据源(x,y),怎么去知道他们对应的坐标轴?

    这个如果是自己写的代码,那么就知道横纵坐标的宽度,对应按照比例,既可以算出来,但是Charts不告诉你他坐标轴的宽度,我们怎么算呢?

    很简单,他已经提供了更好的方式:Transformer
    Transformer包含一个数据点与坐标点的转换矩阵,你只需要传入数据点或者坐标点,他会帮你转换为对应的值:


    屏幕快照 2017-06-19 上午1.05.56.png

    简书的代码高亮做的太差了,所以部分代码直接截图了

    啥也别说 ,直接拿去用!

    但是如果想知道背后实现的逻辑,其实倒也简单:
    本质就是个矩阵转换:
    首先你得知道这个东西:

    struct CGAffineTransform {
      CGFloat a, b, c, d;
      CGFloat tx, ty;
    };
    

    知道了CGAffineTransform,那么他对应的数学变换就是:


    屏幕快照 2017-06-19 上午1.12.24.png 屏幕快照 2017-06-19 上午1.16.23.png

    我们假设知道数据点x,y,想求出来对应的坐标点PixelX或者PixelY,
    那么

    scaleX = xAxisWidth / (xAxisMax - xAxisMin)
    pixelX = scaleX * x - xAxisMin
    

    如果能看懂这一步的话,倒着退我们的transformer矩阵大概长这个样子:

    |   scaleX        0          0   |
    |     0        -scaleY       0   |
    |  -xAxisMin  -yAxisMin      0   |
    
    

    如果到这里你还是能懂的话,那就很厉害了,应该能看懂我们的transformer矩阵是怎么生成的了:
    (就是先做scaled,然后做translated)

      _matrixValueToPx = CGAffineTransform.identity
      _matrixValueToPx = _matrixValueToPx.scaledBy(x: scaleX, y: -scaleY)
      _matrixValueToPx = _matrixValueToPx.translatedBy(x: CGFloat(-chartXMin), y: CGFloat(-chartYMin))
    

    完整代码见截图:


    屏幕快照 2017-06-19 上午1.26.35.png
    1. 如果不懂的话,先去这里补一下基础:Core Animation编程指南
    1. 有人问为什么非要采用这种方式,我直接封装一个函数是不是更简单?
      答:如果是纯粹的简单数学转换,那么写个函数更简单。
      但是Charts是支持滑动和缩放的,当放大或者缩小后,那么这个转换函数的逻辑就会越来越复杂,要考虑的分支结构会越来越多,这种场景下用矩阵计算效率最高,且最简单。

    6.6 遗留环节

    因为目前还没用到手势和缩放,所以Charts相关的手势处理和缩放转换,没有做深入的研究,如果想了解的话,先搞明白ViewPortHandler这个模块,应该是可以作为一个好的切入点,方便快速理解。
    这里就不做进一步的讨论了。


    7. 结尾

    以上是在Charts使用中,对Charts的一些基本的学习和了解。

    实际使用中,发现还是有很多坑需要填充:

    • 比如:饼图的数据,不支持动态调位置 (Charts是写死在半径的1/3处),但我们的UI需要数据写在几何中心的位置,所以继承了一个子类,重新封装的饼图的绘制功能
    • 比如:ScatterChartView,虽然支持各种各样的散点图,大小随你设。但是我们的UI很奇葩的设置了“矩形+圆角”的散点,这个在Charts是不支持的(圆角是支持的,但是宽高不行。因为他的散点图宽高是写死相等的),所以不得已重新实现了SquareShapeRenderer
    • 比如 ...

    当然,坑可以慢慢填,Charts很多设计优秀的地方还是不可以被掩盖的👍

    总结下来: 如何造一个图表类的轮子?


    屏幕快照 2017-06-19 下午2.48.46.png

    答: View - Render - Data ! 对的,就是这样!

    相关文章

      网友评论

      • CoveyZ:佩服:+1:
      • 麦哲文:还做了个PPT吗...... 完美主义者呀
        麦哲文:我告诉你你吸粉了...:stuck_out_tongue_winking_eye:
      • 羙嚴:求楼主教一下饼图画法OAQ
        羙嚴:@pingpong_龘 画是画出来了。可是没有百分比没有每个色块的注释,并且还是随机颜色
        pingpong_龘:@美严 他们的demo挺详细的,你看下demo,画出来应该不难的
      • Sonoface:感谢楼主

      本文标题:[Swift]如何造一个图表类的轮子——Charts源码解读

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