1. 背景
最近基于业务需求,需要在两个星期内,做出十几个数据分析类的图表,包括折线图,柱状图,散点图,饼图等,用以对用户的比赛数据做一个汇总统计和分析。
产品经理说,这个功能是我们准备作为付费用户的特享有功能,所以务必做到数据准确,界面酷炫,体验流畅...(边说他边陷入了美好想幻想中...)
听完我当场就想回去写辞职信了,两个星期,其中给测试和发版有一个星期,所以纯开发基本就一个星期时间,莫非你在搞笑? 一个星期,这个全部做出来?
底层结构搭建,比赛的数据封装,和后台的API定义与调试,图表异常场景的处理方案,老版本数据的兼容性等等,左估右算显然是做不出来了,所以只能先找个靠谱点的轮子,再进一步扩展了。
2. Charts
因为这期项目,组内决定逐渐从Objc转到swift,所以,也直接就从swift中找了个靠谱的图表库--Charts
Github:https://github.com/danielgindi/Charts
至于为什么选择他,原因很简单:star最多!
这个项目的作者是danielgindi,他自己介绍说这个项目是MPAndroidChart对应的Apple平台的版本(iOS/tvOS/OSX均支持)
3. 话不多说,直接看效果
4. 如何引入
引入很简单,不详细说了
4.1 通过cocoapods
pod 'Charts'
4.2 通过charthage
github "danielgindi/Charts" == 3.0.2
4.3 通过project方式引入
把这个charts的project通过target的方式引入,他们自己提供的demo就是通过这种方式。(个人不是很推荐这种方式)
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:此图为本人独家提供
柱状图和线形图一类(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的结构图如下:
我们的数据源就是(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等.
简单说就是如下图:
Render负责拿到ChartData的具体数据,然后在ChartView进行图层绘制. Render扮演了数据加工处理的角色,如果将这种设计架构理解为MVVM,那么Render这个模块,在我的理解就是ViewModel.
另外,需要补充的一点是,如果看Render的实现源码,会看到,他在很多地方,用到了ChartDataProvider和IChartDataSet,这两个又是什么东西?
仔细看代码的话,发现是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的结构图应该更新为:
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)
可以看到Charts绘制的本质就是获取当前的CGContext,然后通过ChartData获取到绘制点的CGPoint,然后进行Image或者Text的绘制。
iOS做多了,图形绘制,有各种各样的实现方式:
- 最简单就是画个UIView,画个UIButton (所以之前会有人吐槽自己就是个UIButton工程师,每天画了各种各样的Button).
- 稍微玩的嗨一点,就是从layer上,绘制个贝塞尔曲线UIBezierPath,然后加点Animation
当然,画UIView和Layer,本质上是一样的,都是画Layer,UIView只是系统对Layer的一个封装罢了
- 再进一步就是直接通过CoreGraphics,在CGContext直接渲染了,绘制路径,和填充效果以及边界值,明确path和坐标点,基本也是可以搞下来的.
既然谈到了,顺便补充下基础知识:
CoreGraphics也称为Quartz 2D 是UIKit下的主要绘图系统,频繁的用于绘制自定义视图。Core Graphics是高度集成于UIView和其他UIKit部分的。Core Graphics数据结构和函数可以通过前缀CG来识别。
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 数据流程
简单整理了下他们的数据流大概是这样:
当我们设置了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包含一个数据点与坐标点的转换矩阵,你只需要传入数据点或者坐标点,他会帮你转换为对应的值:
简书的代码高亮做的太差了,所以部分代码直接截图了
啥也别说 ,直接拿去用!
但是如果想知道背后实现的逻辑,其实倒也简单:
本质就是个矩阵转换:
首先你得知道这个东西:
struct CGAffineTransform {
CGFloat a, b, c, d;
CGFloat tx, ty;
};
知道了CGAffineTransform,那么他对应的数学变换就是:
我们假设知道数据点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))
完整代码见截图:
- 如果不懂的话,先去这里补一下基础:Core Animation编程指南
- 有人问为什么非要采用这种方式,我直接封装一个函数是不是更简单?
答:如果是纯粹的简单数学转换,那么写个函数更简单。
但是Charts是支持滑动和缩放的,当放大或者缩小后,那么这个转换函数的逻辑就会越来越复杂,要考虑的分支结构会越来越多,这种场景下用矩阵计算效率最高,且最简单。
6.6 遗留环节
因为目前还没用到手势和缩放,所以Charts相关的手势处理和缩放转换,没有做深入的研究,如果想了解的话,先搞明白ViewPortHandler
这个模块,应该是可以作为一个好的切入点,方便快速理解。
这里就不做进一步的讨论了。
7. 结尾
以上是在Charts使用中,对Charts的一些基本的学习和了解。
实际使用中,发现还是有很多坑需要填充:
- 比如:饼图的数据,不支持动态调位置 (Charts是写死在半径的1/3处),但我们的UI需要数据写在几何中心的位置,所以继承了一个子类,重新封装的饼图的绘制功能
- 比如:ScatterChartView,虽然支持各种各样的散点图,大小随你设。但是我们的UI很奇葩的设置了“矩形+圆角”的散点,这个在Charts是不支持的(圆角是支持的,但是宽高不行。因为他的散点图宽高是写死相等的),所以不得已重新实现了SquareShapeRenderer
- 比如 ...
当然,坑可以慢慢填,Charts很多设计优秀的地方还是不可以被掩盖的👍
总结下来: 如何造一个图表类的轮子?
答: View - Render - Data ! 对的,就是这样!