分类
联系方式
  1. 新浪微博
  2. E-mail

Maeiee Weekly No.27:如何编程绘制K线图

K线图是日常炒股中最常使用的图表。从软件编程角度,如何从头开发一个 K 线图组件呢?当然,作为开发者,通常不需要从头实现,因为业界不论哪个技术栈,都有成熟的开源库可供使用。但是,如果你和我一样,不满足调用现有的 API,想要从头绘制一个,本文中总结了一些关键原理。

本文站在了巨人的肩膀上,源于我在 Flutter 下找到了一个 K 线图图表库 k_chart。k_chart 绘制效果比较精美,同时依托于 Flutter 的界面渲染能力,也具备较好的体验。但是我发现它在扩展性上有些待完善,于是我打算 fork 一份进行扩展,顺便将底层的绘制原理研究一番。因此,本文也可认为是 k_chart 的源码研究,以及 Flutter 复杂自定义组件绘制的参考资料。

KChartWidget 组件

该库是 k_chart 库对外提供的 Widget,即 K 线图组件,开发者将它插入布局中即可。

属性

该组件中包含大量传入属性,其中一部分:

  1. List<KLineEntity>? datas:K 线图数据
  2. MainState mainState:主图叠加指标,可选 MA 均线、BOOL 布林带
  3. SecondaryState secondaryState:副图指标,可选 MACD、KDJ、RSI、WR、CCI
  4. bool isTapShowInfoDialog:点击 K 线弹出一个详情数据气泡
  5. Function(bool)? onLoadMore:左右边缘加载更多回调
  6. ChartColors chartColors:图表样式
  7. ChartStyle chartStyle:图表样式

其中:

  1. KLineEntity同时包含 K 线数据和指标数据,后文中将介绍。
  2. KChartWidget 只支持一个副图

build 绘制

核心的 K 线部分是用 Flutter 的 CustomPaint(Canvas)绘制出来的,对应的 Painter 是 ChartPainter

布局结构为:

  • GestureDetector:点击、拖拽、缩放、长安手势交互
    • Stack
      • CustomPaint:ChartPainter
      • 详情数据气泡

KLineEntity 行情数据

KLineEntity 表示一天内的股票数据,因此 List<KLineEntity>? datas 表示一段时间内的行情数据。

KLineEntity 的声明如下:

class KEntity
    with
        CandleEntity,
        VolumeEntity,
        KDJEntity,
        RSIEntity,
        WREntity,
        CCIEntity,
        MACDEntity {}

这里使用了 Dart 的 mixin 特性,可以简单理解为将多个类的数据拼装到一起,成为 KEntity。 比如,CandleEntity 为蜡烛图数据,声明如下:

mixin CandleEntity {
  late double open;
  late double high;
  late double low;
  late double close;

  List<double>? maValueList;

  //上轨线
  double? up;

  //中轨线
  double? mb;

  //下轨线
  double? dn;

  double? BOLLMA;
}

VolumeEntity 中包含成交量数据:

mixin VolumeEntity {
  late double open;
  late double close;
  late double vol;
  double? MA5Volume;
  double? MA10Volume;
}

需要有开发者创建 KLineEntity 来拼装数据,在数据拼装时,开发者需要将行情数据、指标数据都传入 KLineEntity 中。

BaseChartPainter 绘制基类

该类是绘图基类,负责图表绘制的通用工作。主图 K 线和副图指标都基于 BaseChartPainter 的框架能力进行扩展,因此线分析这个类。

initRect 功能分区

根据 Canvas 的 Size 将画布分为 3 块(Rect):

  1. K 线区:mainHeight
  2. 成交量区:mVolRect
  3. 副图区:mSecondaryRect

对应的的高度分别为:mainHeight、volHidden、secondaryHeight

calculateValue 数据截取

KChartWidget 中股票数据是允许左右横滑的,可是并没有一个类似 ScrollView 的组件负责滚动。用户看到的滚动效果,实际上是 Canvas 中实时更新绘制的。

该组件会根据用户滑动手势的增量,实时计算出,股票数据应当出现在屏幕上的起止 index。对应的计算方法为 calculateValue。

核心是算出两个 index:

  1. mStartIndex:出现在屏幕上的最少 index
  2. mStopIndex:出现在屏幕上的最大 index

之后遍历数据,取出 KLineEntity 用于计算。

抽象方法

BaseChartPainter 定义了一系列抽象方法,供子类实现,具体包括:

  1. initChartRenderer:初始化
  2. drawBg:绘制背景
  3. drawGrid:绘制网格
  4. drawChart:绘制图表
  5. drawVerticalText:绘制右边值
  6. drawDate:绘制日期
  7. drawText:绘制文本
  8. drawMaxAndMin:绘制最大最小值
  9. drawNowPrice:绘制当前价格
  10. drawCrossLine:绘制交叉线
  11. drawCrossLineText:绘制交叉线的值

ChartPainter 主图绘制

该类是整个库中最核心的类,负责如何在空白画布(Canvas)上使用基本绘图能力(Painter),将 K 线图给画出来。

继承 BaseChartPainter

ChartPainter 采用了一种代理模式,首先 ChartPainter 自身继承自 BaseChartPainter,同时 ChartPainter 中还有三个代理类,分别负责主图、成交量图和副图的绘制,分别是 MainRenderer、VolRenderer、SecondaryRenderer。它们均继承自 BaseChartPainter。

ChartPainter drawChart

ChartPainter 的图表绘制方法,在该方法中,会遍历股票数据,并分发给各个代理负责绘制:

for (int i = mStartIndex; datas != null && i <= mStopIndex; i++) {
  // 当前数据
  KLineEntity? curPoint = datas?[i];
  // 前一交易日数据
  if (curPoint == null) continue;
  KLineEntity lastPoint = i == 0 ? curPoint : datas![i - 1];
  // 当前横坐标
  double curX = getX(i);
  // 前一工作日横坐标
  double lastX = i == 0 ? curX : getX(i - 1);

  // 主图代理绘制
  mMainRenderer.drawChart(lastPoint, curPoint, lastX, curX, size, canvas);
  // 成交量代理绘制
  mVolRenderer?.drawChart(lastPoint, curPoint, lastX, curX, size, canvas);
  // 副图代理绘制
  mSecondaryRenderer?.drawChart(
      lastPoint, curPoint, lastX, curX, size, canvas);
}

主图 MainRenderer

负责主图绘制。

drawChart 绘制主图曲线

drawChart 方法最为重要,蜡烛图、曲线、主图指标都是在这里完成的。

具体代码如下:

@override
void drawChart(
    CandleEntity lastPoint, // 前一交易日数据
    CandleEntity curPoint,  // 当前交易日数据
    double lastX,           // 前一交易日横坐标
    double curX,            // 当前交易日横坐标
    Size size, Canvas canvas) { // 图表尺寸与画布
  // 主图支持两种绘制方式,画线,或者画 K线
  if (isLine) { // 画线模式
    drawPolyline(lastPoint.close, curPoint.close, canvas, lastX, curX);
  } else { // 画K线模式
    drawCandle(curPoint, canvas, curX);
    // 画主图上叠加指标
    if (state == MainState.MA) {
      drawMaLine(lastPoint, curPoint, canvas, lastX, curX);
    } else if (state == MainState.BOLL) {
      drawBollLine(lastPoint, curPoint, canvas, lastX, curX);
    }
  }
}

drawCandle 画 K 线

该方法负责绘制一个交易日的 K 线蜡烛。

具体来说,通过 high、low、open、close,来绘制箱体和影线。

并且根据当日涨跌,填充不同的颜色。

drawMaLine 绘制均线

Entity 中有一个数组 List<double>? maValueList,意思是可以同时绘制多条均线(最多3条)。

不过图标库自己不负责均线绘制,需要由开发者计算好后进行传入。

SecondaryRenderer

负责副图绘制。

drawChart 绘制副图曲线

通过 SecondaryState 枚举定义了支持的类型,根据不同类型画不同的线:

@override
void drawChart(MACDEntity lastPoint, MACDEntity curPoint, double lastX,
    double curX, Size size, Canvas canvas) {
  switch (state) {
    case SecondaryState.MACD:
      drawMACD(curPoint, canvas, curX, lastPoint, lastX);
      break;
    case SecondaryState.KDJ:
      drawLine(lastPoint.k, curPoint.k, canvas, lastX, curX,
          this.chartColors.kColor);
      drawLine(lastPoint.d, curPoint.d, canvas, lastX, curX,
          this.chartColors.dColor);
      drawLine(lastPoint.j, curPoint.j, canvas, lastX, curX,
          this.chartColors.jColor);
      break;
    case SecondaryState.RSI:
      drawLine(lastPoint.rsi, curPoint.rsi, canvas, lastX, curX,
          this.chartColors.rsiColor);
      break;
    case SecondaryState.WR:
      drawLine(lastPoint.r, curPoint.r, canvas, lastX, curX,
          this.chartColors.rsiColor);
      break;
    case SecondaryState.CCI:
      drawLine(lastPoint.cci, curPoint.cci, canvas, lastX, curX,
          this.chartColors.rsiColor);
      break;
    default:
      break;
  }
}

drawMACD MACD 指标绘制

以 MACD 为例,柱子和线的画法:

void drawMACD(MACDEntity curPoint, Canvas canvas, double curX,
    MACDEntity lastPoint, double lastX) {
  final macd = curPoint.macd ?? 0;
  double macdY = getY(macd);
  double r = mMACDWidth / 2;
  double zeroy = getY(0);
  if (macd > 0) {
    canvas.drawRect(Rect.fromLTRB(curX - r, macdY, curX + r, zeroy),
        chartPaint..color = this.chartColors.upColor);
  } else {
    canvas.drawRect(Rect.fromLTRB(curX - r, zeroy, curX + r, macdY),
        chartPaint..color = this.chartColors.dnColor);
  }
  if (lastPoint.dif != 0) {
    drawLine(lastPoint.dif, curPoint.dif, canvas, lastX, curX,
        this.chartColors.difColor);
  }
  if (lastPoint.dea != 0) {
    drawLine(lastPoint.dea, curPoint.dea, canvas, lastX, curX,
        this.chartColors.deaColor);
  }
}

手势缩放

k_chart 中大量采用自定义绘制,能实现更加强大的绘制效果,比如手势缩放。

手势缩放功能,指的是 K 线能够像手机浏览图片一样双指放大与缩小,平且效果十分平滑。

值得一提的是,依托于 Flutter 的跨端特性,该缩放不仅在手机上生效,在电脑上也同样生效,前提的你的电脑要支持屏幕触摸。

具体实现来到 KChartWidget,其布局中最外层是一个 GestureDetector 手势监听器,监听了一系列手势,其中包含放大、缩小:

onScaleUpdate: (details) {
  if (isDrag || isLongPress) return;
  mScaleX = (_lastScale * details.scale).clamp(0.5, 2.2);
  notifyChanged();
},

在该方法中,计算出一个新的横向缩放因子 mScaleX,通过 notifyChanged 刷新组件。notifyChanged 内部是一个 setState((){}),这是flutter组件的刷新方法,该方法调用后,KChartWidget 内部组件都会进行刷新,这就包括了前面的各个 Renderer。

这样,便实现了 K 线图的放大缩小手势功能。

小结

至此,这个库的核心原理已经研究清楚了。我的想法总结如下。

扩展性

基于代码来扩展,还是比较容易地,只要沿着作者地逻辑,不断对枚举进行扩充即可。包括主图、副图扩展。

Entity 也是具备扩展性的,对于新指标,也通过 mixin 进行扩充即可。

这个库如果以开源库粒度提供,扩展性会差一些。但如果以源码粒度提供,还是比较好扩展的。

理想架构

我也在脑中思考了一下,按照我的审美的理想架构:

比如会考虑如下的布局:

  • HorizontalScrollView
    • Column
      • Stack(主图)
        • KChart
        • MaLIne
        • ……
      • Stack(成交量图)
      • Stack(副图1)
      • Stack(副图2)

其中,区别在于:

  • 尽可能少用 CustomPainter,尽可能多用 Flutter 组件库
  • 将不同图片用不同 CustomPainter 分别实现,放在 Flutter 布局中组装,这样更加灵活
  • 支持多副图结构

减少 CustomPainter 的使用,或者说是将 CustomPainter 的绘制单元拆分为更小粒度,封装成一系列专门组件,这样的好处是可以更加声明式使用,这样可以实现开源库粒度的灵活性。