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

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 ! 对的,就是这样!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容