FLUTTER自己动手用绘图实现一个K线。(CustomPainter,GestureDetector,Listener)

先看下最终实现的效果

在这里插入图片描述
简单介绍一下,K线图功能方面主要是几个部分:

  1. 绘图 根据数据源绘制展示在屏幕上的图标,实际上主要就是连线,柱状图,绘制一些文字,有时候会有些圆点或者不规则图形 比较少见。
  2. 图表操作,一般只有平移,缩放,点选三种。
  3. 指标操作,包含指标的参数调整,指标切换。

所以这个DEMO从功能上来说,已经是一个“全功能”的K线图了(因为点选实际上非常简单而且随着不同应用业务逻辑变化也比较大,有的需要自由移动有的需要吸附某个指标等等因此,这个K线只是个参考DEMO就不做这个功能了)
因此K线模块主要需要用到的框架功能就是:绘图方法,手势,以及基本的计算,非常简单。
K线相对比较复杂的地方在于:数据安全方面的处理,包含数据访问边界,异常数字的处理和指标计算。在指标计算以及图表操作的过程中非常容易出现错误计算,一旦发生错误,相对来说是比较难以排查的,因此在实际工作的项目中为了大家好合作,对数据层的做好的抽象实际上会花费更长时间。当然这里作为一个DEMO就不考虑那么多了。

接下来一步一步学习一下如何搭建K线

先看一下K线的主要构成 。
蜡烛图:K线本身至少是有一条均线和一个蜡烛图构成的。均线就是当日的成交均价,通常是加权平均值,而蜡烛图就是一天的开盘收盘(实体,粗线柱),最高最低(影线,细线柱)四个数字表示而成。

主要指标:主要指标一般与均价相关因此通常与蜡烛图绘制在一起,这里的主视图辅助指标我在DEMO中提供了一个最基本的MA线,也就是N日均线。指标的参数可以手动调整。
辅助指标:辅助指标一般是综合性指标,参数较多范围比较复杂,有些还会包含柱状图因此一般不与主视图放在一起,最常见的有MACD,KDJ,BOLL等,通常会牵扯到一些平方差,标注差,移动平均值,N日极值等,最终表现方式仍然是柱状,连线等等。难度不高,只是比较费时,DEMO中就不去具体实现了,在底部放了一个表示成交额的柱状图。

因此我们最少最少需要5个数字。开盘,收盘,最高,最低,成交量,通常由服务器给出日均价,DEMO中是假数据,直接用(最高+最低)/2 计算的,实际不会这么做。通常K线的数据中还包含品种,日期等信息。

有了以上几个基本数据后,其他的指标数据几乎都是用以上几个基本数据计算而来的,而且指标存在可调节的参数。因此一个完整的K线模块是需要客户端本地进行计算的。而如果在服务端进行计算则实现可调参数的代价比较高。复杂度甚至高于本地运算。

因此绘制K线的简单流程图如下

默认参数
指定参数
原始数据
计算
含参数据
控件
图表展示
参数控制

处理数据

  1. 先建立数据模型

class KLineModel {
//原始数据
    String info;
    double top;
    double bottom;
    double open;
    double close;
    double avg;
    double vol;
//计算生成
    double ma1;
 

    @override
    KLineModel({Key key,this.info
                       ,this.top
                       ,this.bottom
                       ,this.open
                       ,this.close
                       ,this.avg
                       ,this.vol
                       ,this.ma1
});
}

DEMO中没用到诸如品种,日期之类的信息,就建了一个精简的模型。除了包含原始数据以外,添加了一个MA参数用来存储MA的数据,由于通常MA线有三条。这里命名为MA1 。
2. 参数计算方法。
MA代表的是所选数据前N日的均价的简单平均数。

//parmDays:均线周期
  void calcuateMa1(int parmDays, List<KLineModel> datalist){
  //输入数据安全
    if(datalist == null || parmDays == null || parmDays < 1){
      return;
    }
    //循环取应算模型
      for(int i = 0  ; i < datalist.length;i++){
        KLineModel model =  datalist[i];

        if(i < parmDays){
      //排除应空模型
          model.ma1 = null;
        }else{
       //应算模型
        double total = 0;
        int unUseableCount = 0;
        for(int j = i- (parmDays - 1);j<=i;j++){
        //获取前置模型
          KLineModel model =  datalist[j];
          if(model.avg == null){
          //排除空值
            total+=0;
            unUseableCount++;
          }else{
            total+=model.avg;
          }
        }
         if(parmDays - unUseableCount == 0){
         //防止0做分母
         model.ma1 = null;
       }else{
        model.ma1 = total / (parmDays - unUseableCount);}
          }
        }
     }
  }

实际上关于数据源的问题到此就结束了,实际工作中只需要扩展模型的属性和相应的计算方法即可接下来我们需要一个容器来存放K线模型。

不过这里我要补充一点内容
从上面MA的计算应该可以看出来,指标计算牵扯到非常多的数组操作,乘除操作等,一个不小心非常容易出错,用例的数据也比较多,因此做安全处理是非常重要的。因此我们可以先大概写一下逻辑,然后根据逻辑画一下流程图然后从图上寻找优化方案和做安全处理的位置。

计算过程如下
循环数组取当前模型->根据参数取前置组->计算前置组->对当前模型赋值

完整循环
AVG
输出结果
输入数组
parmDays
当前模型
前置模型
计算
条件循环

从流程图可以看出
需要保证 输入数组,parmDays,AVG三个变量在安全范围内
两次循环前应保证不会越界
计算中应保证不会出现分母为0的情况
在输入前应保证数组的AVG是有值的。通常这是数据供给方的任务。
为了以防万一可以在初始化的时候检查一遍,无值通常可选择获取前值,或者直接抛出异常。

设计容器

根据K线的功能容器需要:

  1. 展示K线数据
  2. 接收滑动,缩放事件
  3. 计算方法,因为是DEMO ,计算方法比较简单我就不做抽象了,计算器的职责就也由KLineWidget管理。
  4. 绘图器

这里我的设计是让绘图器只需要接收数组并按照数组绘制即可,因此KLineWidget的职责还包含根据当前状态计算出需要在界面上绘图的数组。

因此设计一个复合widget 接收输入KLineModle数组即可

默认值
绘图参数
原始数组
KLineWidget
手势
触摸监听
指标参数计算
绘图参数
含指标模型数组
绘图数组
指标参数
指标控制组件
绘图器

根据设计 先设置一下widget的接口

import 'KLineModel.dart';

import 'PrintCore.dart';
class KLineWidget extends StatefulWidget{
    //绘图范围
    Rect drawRect ;
    //数据
    List<KLineModel> datalist;
    @override
    //初始化时输入
    KLineWidget({Key key,this.drawRect,this.datalist});

  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
   return _kLineWidgetState();
  }
}

而如果考虑增量的问题则建议开放初始化和增量两个接口,增量后重新走一遍计算流程即可。
虽然DEMO中直接从外部修改已存储的数据手动调用一遍重绘也可以但这不是个好方法!

接下来按照功能布置一下widget

class _kLineWidgetState extends State<KLineWidget>{
  
//绘制参数
  //右侧起始位置
  int rightIndex;
  //K线绘制根数
  int count;
  //MA1默认参数
   int ma1Parm = 5;
   //缩放基数
  int scaleStartCountTemp;

  void calcuateMa1(int parmDays, List<KLineModel> datalist){
  ......
  }
  //设置默认绘图参数
  void setDefaultShowParm(){
  if(widget.datalist == null){
  return;
  }
   rightIndex = widget.datalist.length;
   if(widget.datalist.length > 10){
     count = 10;
   }else if(widget.datalist.length > 0){
     count = widget.datalist.length;
     
   }
   
}

@override
  void initState() {
  
    calcuateMa1(ma1Parm,widget.datalist);

    setDefaultShowParm();

    super.initState();
  }

  @override
  Widget build(BuildContext context) {

    // TODO: implement build
    return new Column(
      children: <Widget>[
        new Listener(
          //触摸事件监听
         child:new GestureDetector(
           //手势监听
           child:  new Container(
             //绘图组件
               height: widget.drawRect.height == null?200:widget.drawRect.height + 20,
                 width: MediaQuery.of(context).size.width,
                //绘图器
                child: new CustomPaint(painter:  new KLinePainter(drawRect:widget.drawRect,dataList:this.showList(count, rightIndex, widget.datalist))),
    ),
    //缩放手势
    onScaleUpdate: onScaleUpdate,
    //缩放开始
    onScaleStart: onScaleStart,

    ),
    //触摸事件
    onPointerMove: onTouchsMove,
    ),
      //参数控制组件
      new Row(
        children: <Widget>[
          new FlatButton(onPressed: onDividepress, child: Text('maDivide')),
          new FlatButton(onPressed: onAddpress, child: Text('maAdd')),
          new Text(this.ma1Parm.toString())
        ],
      )
      ],
    );
  }
  //指标参数+ - 事件
 void onAddpress(){
   int temp = ma1Parm+1;
    if(temp < widget.datalist.length - 1){
      ma1Parm = temp;
    }
    this.calcuateMa1(ma1Parm, widget.datalist);
    setState(() {

    });
  }
  void onDividepress(){
    int temp = ma1Parm-1;
    this.calcuateMa1(ma1Parm, widget.datalist);
    if(temp >0 ){
      ma1Parm = temp;
    }

    setState(() {

    });
  }
//缩放开始时记录当前绘图根数
  void onScaleStart(ScaleStartDetails details){
    scaleStartCountTemp = count;
  }
//缩放事件 调整绘图根数count (!数组越界)
  void onScaleUpdate(ScaleUpdateDetails details){
 scaleTo(details.horizontalScale.toDouble(), scaleStartCountTemp);

   setState(() {

   });
  }

  //记录移动距离
   double dxCount = 0;
   
   //触摸移动事件左移 右移  调整rightIndex  (!数组越界)
    void onTouchsMove(PointerMoveEvent event){

    dxCount = dxCount + event.delta.dx;
    if(dxCount.abs() > widget.drawRect.width / count){
    //移动超过1根调用

      if(event.delta.dx > 0){
      //左移
        moveLeft();
        }
       if(event.delta.dx < 0 ){
       //右移
        moveRight();

        }
       setState(() {

         });
    dxCount = 0;
    }
  }
//根据参数筛选出绘图数组
  List<KLineModel> showList(int count,int rightIndex,List source){
//数据安全
    if(count == null || rightIndex == null || source == null) return[];


    int start = rightIndex - count;
//防止越界
    if(start < 0)start = 0;
    if(rightIndex > source.length)  rightIndex = source.length;
    
    return source.sublist(start,rightIndex);
  }



//左移 右移
   void moveLeft(){
    if(rightIndex >  count + 1){
    rightIndex--;
    }

  }
  void moveRight(){
    if(rightIndex < widget.datalist.length -1){
      rightIndex++;
    }
  }
//缩放
  void scaleTo(double scale,int currentCount) {

    if(widget.datalist.length < 5){
      return;
    }
    if (scale < 1 && scale > 0.1) count = currentCount + (4 * (1 / scale)).toInt();
    if (scale > 1 && scale < 10) count = currentCount - (scale * 5).toInt();

    if (count > widget.datalist.length) {
      count = widget.datalist.length;
      rightIndex = count;
    }

    if (rightIndex - count < 0 && count < widget.datalist.length) {
      rightIndex = count;
    }
    if(count < 5 ){
      count = 5;
    }
  }
}

KLineWidget的任务就是将输入转转化为绘图用数组。
因此本身保存完整数组。起始位置和绘图根数取得完整数组的子数组给与绘图器即可。
一般默认情况下K线是从最新的开始往后画因此保存右侧起始位置比较不容易取错。
而绘图一般是从左往右绘图 因此最终给到绘图器的是通过这两个参数计算出的左侧起始位置和根数
取的过程中我们只要保证 根数小于数组数量。起始位置大于0
起始位置+根数小于数组数量 即可。

绘图器

完成了widget的输入输出,接下来就该设计绘图器了。
绘图器本身并不复杂。流程很简单

输入数组
控制参数
子图比例
子图参数类型
计算绘图区域
绘图

DEOM没有把所有控制参数暴露在外,实际工作中应根据使用场景设计合适的控制参数并对外暴露

绘图器使用CustomPainter,如果关于CustomPainter有什么疑问的话可以百度一下有很多写的很好的文章。
根据K线图表现出来的样子,我们只需要根据控件传入的数据选择对应的模型属性绘制柱状图和折线图就可以了。

那么关于绘图可以注意以下几点

  1. 需要一个良好的封装,尽量使用方法调用值传递,尽量避免在方法中直接获取数据源数据,合理的命名,以便出现需求变更时回来看得懂代码(血的教训)

  2. K线一般有上下两部分,上下两部分虽然展示的是不同的指标,但是我们认为,蜡烛图也是一种指标那么,业务逻辑就是相同的,因此可以封装成相同的方法,只是绘制不同的指标。

  3. 对于一般的项目只需要对外暴露上下两个图形框的指标类型调整接口,并在长按事件对外传递选中的模型。

  4. 绘图时牵扯到较多数组操作,如果在绘图器中需要做异常值处理,非常繁杂且大量的if语句会拖累性能。因此应在外部传入数组前保证数组中数据有效。那么在绘图器中只需要注意不要越界即可。

  5. 注意完全有效指标和不完全有效指标。有些通过制定周期计算的指标在周期前的数据中本身是无效数据,例如前方介绍图中MA增大时 整个数组的前N个日期是没有该指标的,因此异常值在在K线图中是作用的。(所以千万要在数据输入到绘图器前检查确保无意外异常值)

  6. 对于意外异常值的处理方法通常是取前值或者刨除该数据

接下来上代码
先设置一下基本的参数
数据都在一定的范围内波动,因此在绘图前,需要确定所有指标的极值,这样我们就可以算出这个指标中每一个数字对应图形的位置了

import 'package:flutter/material.dart';
import 'KLineModel.dart';
import 'dart:ui'as ui;

const String lineTypeCandle = 'candle';
const String lineTypeAVG = 'avg';
const String lineTypeMA1 = 'ma1';
const String lineTypeMA2 = 'ma2';
const String lineTypeMA3 = 'ma3';
const String lineTypeVOL = 'vol';


class KLinePainter extends CustomPainter{

//数据源
    List<KLineModel> dataList;

    //极值
    double _priceTop;
    double _priceBottom;
    double _volTop;

    //默认指标类型
    String _topType = lineTypeMA1;
    String _bottomType = lineTypeVOL;
    void setTopType(String type){
      _topType = type;
    }
    void setBottomType(String type){
      _bottomType = type;
    }

    //绘图单元数量(20 ~ 300)

    //如需右侧留白则使用该参数 默认=数组长度
    int drawUnitCount =30;


    Paint _paint = new Paint();
    Rect drawRect;

    @override
    KLinePainter({Key key,this.drawRect,this.dataList});


 @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint


    return true;
  }

 @override
 void paint(Canvas canvas, Size size) {
if(dataList == null || this.drawRect == null){
  return;
}
 this.drawKline(canvas);
 }


void drawKline(Canvas canvas){
//主视图的区域
    Rect rectTop = Rect.fromLTWH(drawRect.left, drawRect.top, drawRect.width, drawRect.height * 0.65);
    //辅视图的区域
    Rect rectBottom = Rect.fromLTWH(drawRect.left, drawRect.top + drawRect.height * 0.70, drawRect.width, drawRect.height * 0.3);
    
     this.caculateDrawArea();
     
    _drawKLineUnit(rectTop, [lineTypeCandle,lineTypeAVG,_topType], canvas);

    _drawKLineUnit(rectBottom, [_bottomType], canvas);

 }
//设置极值
 void caculateDrawArea(){

   double max = 9999999999999;
   _priceTop = 0;
   _priceBottom = max;
   _volTop = 0;

   for(int i = 0 ;i < dataList.length;i++){
     KLineModel model = dataList[i];

     //找到最大最小
     if(model.top > _priceTop){_priceTop = model.top;}
     if(model.bottom < _priceBottom){_priceBottom = model.bottom;}


     if(model.vol > _volTop){
       _volTop = model.vol;
     }

   }
   //如需留白 则将绘图区域设置为大于dataList.length 的数字即可
  
   this.drawUnitCount = dataList.length;

 }
 .........
}

添加不完全有效指标的无效区域获取方法

  int getLeftUnuseCountWith(String lineType,List<KLineModel> dataList){
 int unUseCount = 0;
   for(int i =0;i < dataList.length;i++){
     KLineModel model = dataList[i];
    switch (lineType){
      case lineTypeMA1:{
        if(model.ma1 == null){
            unUseCount++;
          }else{
          return unUseCount;
          }
      }
      break;

    }
   }
    return unUseCount;
 }

接下来添加画图的方法

  1. 画外框的方法

    void _drawRoundRect(Color color,double width,Canvas canvas,Rect rect){
      _paint.color = color;
      _paint.strokeCap = StrokeCap.butt;
      _paint.isAntiAlias = true;
      _paint.strokeWidth = width;
      _paint.style = PaintingStyle.stroke;
      canvas.drawRect(rect, _paint);
    }

  1. 画折线图的方法
 void _drawLineContect(Color color,double width,List<double> data,int startCount,Canvas canvas,Rect rect,double areaTop,double areaBottom){

   double lineSpace = drawRect.width/drawUnitCount;

   _paint.color = color;
   _paint.strokeCap = StrokeCap.round;
   _paint.isAntiAlias = true;
   _paint.strokeWidth = width;
   _paint.style = PaintingStyle.stroke;
   
   //高度单位
   double unitHigh =  rect.height / (areaTop - areaBottom);
   
   for(int i = 0 ; i < data.length - 1;i++){
     double current = data[i] - areaBottom;
     double next = data[i + 1] - areaBottom;
     canvas.drawLine(Offset(rect.left + lineSpace * (i + startCount + 0.5), rect.bottom - current * unitHigh), Offset(rect.left + lineSpace * (i + 1 + startCount + 0.5), rect.bottom - next * unitHigh), _paint);
   }
 }

  1. 画蜡烛图单元的方法

 void _drawCandleLine(Color color,int count,Canvas canvas,Rect rect,double top,double bottom,double open,double close,double areaTop,double areaBottom){


   double lineSpace = drawRect.width/drawUnitCount;

   _paint.color = color;
   _paint.strokeCap = StrokeCap.butt;
   _paint.isAntiAlias = true;
   _paint.strokeWidth = lineSpace  * 0.8;
   _paint.style = PaintingStyle.stroke;
   double unitHigh =  rect.height / (areaTop  - areaBottom);
   canvas.drawLine(Offset(rect.left + lineSpace * (count + 0.5) , rect.bottom - (open - areaBottom) * unitHigh), Offset(rect.left + lineSpace *  (count + 0.5), rect.bottom - (close - areaBottom) * unitHigh), _paint);
   _paint.strokeWidth = 1;
   canvas.drawLine(Offset(rect.left + lineSpace * (count + 0.5), rect.bottom - (top - areaBottom) * unitHigh), Offset(rect.left + lineSpace *  (count + 0.5), rect.bottom - (bottom - areaBottom) * unitHigh), _paint);
 }

举个例子 完整的画蜡烛图方法如下

void _drawLinkCandleLine(List<KLineModel> dataList,Canvas canvas,Rect rect){
  for(int i = 0 ; i < dataList.length; i++){

    KLineModel model = dataList[i];
    if(model.open > model.close){
      _drawCandleLine(Colors.green, i, canvas, rect, model.top, model.bottom, model.open, model.close,_priceTop,_priceBottom);
    }else if(model.open < model.close){
      _drawCandleLine(Colors.red, i, canvas, rect, model.top, model.bottom, model.open, model.close,_priceTop,_priceBottom);
    }else{
      _drawCandleLine(Colors.grey, i, canvas, rect, model.top, model.bottom, model.open, model.close,_priceTop,_priceBottom);
    }

  }
}

就不浪费篇幅了 完整的代码可以在这里找到

封装好后调用起来会比较方便

void drawKline(Canvas canvas){

    Rect rectTop = Rect.fromLTWH(drawRect.left, drawRect.top, drawRect.width, drawRect.height * 0.65);
    Rect rectBottom = Rect.fromLTWH(drawRect.left, drawRect.top + drawRect.height * 0.70, drawRect.width, drawRect.height * 0.3);
    this.caculateDrawArea();
    _drawKLineUnit(rectTop, [lineTypeCandle,lineTypeAVG,_topType], canvas);

    _drawKLineUnit(rectBottom, [_bottomType], canvas);

 }


void _drawKLineUnit(Rect rect,List lineTypes,Canvas canvas){


  //画外框
   _drawRoundRect(Colors.grey, 0.5, canvas,rect);


      if(this.dataList == null){
        return;
      }
      if(lineTypes.contains("candle")){

        _drawLinkCandleLine(this.dataList, canvas, rect);
//        _drawText(Offset(rect.left, rect.top), 100, '最高:' + priceTop.toString() , Colors.red, 10, canvas);
//        _drawText(Offset(rect.left, rect.top + 11), 100, '最低:' + priceBottom.toString() , Colors.green, 10, canvas);

      }

   if(lineTypes.contains("vol")){

    _drawVol(this.dataList, canvas, rect);

   }
   if(lineTypes.contains("avg")){

     _drawAVG(this.dataList, canvas, rect);

   }
   if(lineTypes.contains("ma1")){

     _drawMA1(this.dataList, canvas, rect);

   }

}

最后 FLUTTER中绘制文字比较麻烦
附上绘制文字的方法

void _drawText(Offset offset,double width,String text ,Color color ,double fontSize,Canvas canvas){

 ui.ParagraphBuilder pb = ui.ParagraphBuilder(ui.ParagraphStyle(
   textAlign: TextAlign.left,
   fontWeight: FontWeight.w300,
   fontStyle: FontStyle.normal,
   fontSize: fontSize,
 ));
 pb.pushStyle(ui.TextStyle(color: color));
 pb.addText(text);
 ui.ParagraphConstraints pc = ui.ParagraphConstraints(width: width);
 ui.Paragraph paragraph = pb.build()..layout(pc);
 canvas.drawParagraph(paragraph, offset);

}

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值