一篇小文章让你了解花花绿绿股票线的来源!

近日,蔚来披露合肥战略投资协议重大进展,已按计划实质性完成注资。今年4月29日,蔚来官方宣布,蔚来中国总部落户合肥项目协议正式签署,战略投资者将向蔚来中国投资70亿元人民币。蔚来今年已累计完成融资超百亿人民币融资。

/ 前言 /

股票??数字货币??都是浮云,没那智商还是好好撸代码吧!今天作为一个嫩绿嫩绿的韭菜,就来用技术征服一下割过自己的股票行情图。

股票行情图中比较复杂的应该当属于蜡烛线(阴阳线),这块手势处理复杂、图表指标复杂、交互复杂、数据处理复杂......总之:复杂!

所以就从今天开始我从0到1打造出这个复杂的行情图!费话不多说,上图!上链接:



/ 绘制流程 /

整个绘制过程完全自定义View不依赖任何第三方绘制工具,大概分为三个部分:具体的绘制过程、手势的处理、数据的处理。下面就从这三个方面逐个进行讲解。

具体绘制过程

这里使用的是Android的canvas进行绘制的,android的canvas真的是特别的强大,为了调高绘制效率,我在这里的绘制进行了修改:提前创建一个Canvas和Bitmap,然后在子线程当中进行绘制:

private void initCanvas() {
    repeatNum = 0;
    if (mRealCanvas == null) {
      mRealCanvas = new Canvas();

      Bitmap curBitmap =
          createBitmap(mViewPortHandler.getChartWidth(), mViewPortHandler.getChartHeight(),
              Bitmap.Config.ARGB_8888);
      Bitmap alterBitmap = curBitmap.copy(Bitmap.Config.ARGB_8888, true);
      if (curBitmap != null && alterBitmap != null) {
        mRealCanvas.setBitmap(curBitmap);
        mCurBitmap = curBitmap;
        mAlterBitmap = alterBitmap;
      }
    }
  }

接下来采用双缓冲的绘图机制,先在子线程当中将所有的图像都绘制到一个Bitmap对象上,然后一次性将内存中的Bitmap绘制到屏幕,提高绘制的效率。Android中View的onDraw()方法已经实现了这一层缓冲。onDraw()方法中不是绘制一点显示一点,而是全部绘制完之后一次性显示到屏幕。

/**
   * 进行具体的绘制
   */
  class DoubleBuffering implements Runnable {

    private final WeakReference<BaseChartView> mChartView;

    public DoubleBuffering(BaseChartView view) {
      mChartView = new WeakReference<>(view);
    }

    @Override
    public synchronized void run() {
      if (mChartView != null) {
        BaseChartView baseChartView = mChartView.get();
        if (baseChartView != null && baseChartView.mRealCanvas != null) {
          baseChartView.drawFrame(baseChartView.mRealCanvas);

          Bitmap bitmap = baseChartView.mCurBitmap;
          if (bitmap != null && baseChartView.mHandler != null) {
            baseChartView.mHandler.sendEmptyMessage(baseChartView.REFRESH);
          }
        }
      }
    }
  }

然后将我们绘制完成的bitmap对象交给View的onDraw()方法的canvas去绘制

@Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (mRealBitmap != null) {
      canvas.drawBitmap(mRealBitmap, 0, 0, mPaint);
    }
    if (hasDrawed) {
      hasDrawed = false;
      if (!mHandler.hasMessages(START_PAINT)) {
        Message message = new Message();
        message.what = START_PAINT;
        message.obj = mDoubleBuffering;
        mHandler.sendMessageDelayed(message, 25);
      }
    }
  }

是整个绘制流程的关键代码,和平时的自定义绘制没有什么特殊的区别,只不过这里采用了双缓冲的绘图机制。提前绘制到一个Bitmap上去。

我做过一个简单的测试,当绘制的视图比较复杂的时候,如果提前进行绘制,打开开发者的呈现模式,可以发现越复杂的视图,对GPU的消耗减少的越明显,这里大家可以写一个demo简单测试一下,这里不再赘述。

蜡烛线、长按十字线和长按弹框的具体绘制

长按手势的识别方法可以继续参考下面的手势的处理部分。

蜡烛线:股票的蜡烛线有高、开、低、收四个参数,分别代表:最高价、开盘价、最低价、收盘价。这里首先计算出最高价当中的最大值和最低价当中的最小值,然后根据(maxPrice<最高价> - openPrice<开盘价>)/diffPrice<最高价-最低价>,计算出蜡烛线的上影线,下影线,开盘价,收盘价的占比。从而就能计算出在绘制区域的具体位置。

// 计算蜡烛线
 float scaleY_open = (maxPrice - open) / diffPrice;
 float scaleY_low = (maxPrice - close) / diffPrice;
 RectF candleRect = getRect(contentRect, k, scaleY_open, scaleY_low);
 drawItem.rect = candleRect;
 // 计算上影线,下影线
 float scale_HL_T = (maxPrice - high) / diffPrice;
 float scale_HL_B = (maxPrice - low) / diffPrice;
 RectF shadowRect = getLine(contentRect, k, scale_HL_T, scale_HL_B);
 drawItem.shadowRect = shadowRect;

长按十字线和弹框:这个是根据长按的动作然后在右上角的位置,获取最后一天的高开低收等数据,最后重新绘制当前屏幕。

// 绘制长按十字线
    if (mFocusPoint != null && onLongPress) {
      if (contentRect.contains(mFocusPoint.x, mFocusPoint.y)) {
        canvas.drawLine(contentRect.left, mFocusPoint.y, contentRect.right, mFocusPoint.y,
            PaintUtils.FOCUS_LINE_PAINT);
      }
      canvas.drawLine(mFocusPoint.x, contentRect.top, mFocusPoint.x, contentRect.bottom,
          PaintUtils.FOCUS_LINE_PAINT);
      KLineToDrawItem item = mToDrawList.get(mFocusIndex);
      drawBollDes(canvas, contentRect, item);
    }

    // 长按显示的弹框
    showLongPressDialog(canvas, contentRect);

手势的处理

代码当中的ChartTouchHelper是处理手势的关键类,目前行情图的手势有几种:左右滑动DRAG、惯性滑动FLING、放大缩小Scale、长按LONG_PRESS。

这里使用了android当中的GestureDetectorCompat结合onTouch(View v, MotionEvent event)来处理这几种手势。

左右滑动DRAG

实现OnGestureListener接口,有一个onScroll的方法,在这里将X轴移动的距离当做偏移量,一屏默认显示的蜡烛线是60个,根据偏移量可以计算出移动了多少个蜡烛线,然后就能根据这个去计算下一次绘制的起始点的位置,重新计算滑动后的屏幕的数据。最后Invalidate一下,重新进行绘制即可。

/**
   * @param e1 down的时候event
   * @param e2 move的时候event
   * @param distanceX x轴移动距离:两个move之间差值
   * @param distanceY y轴移动距离
   */
  @Override
  public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

    if (mChartGestureListener != null) {
      scrollX -= distanceX;
      // 当X轴移动距离大于18px认为是移动
      if (Math.abs(scrollX) > mXMoveDist) {
        mChartGestureListener.onChartTranslate(e2, scrollX);
        scrollX = 0;
      }
    }
    if (Math.abs(distanceX) > Math.abs(distanceY)) {
      return true;
    } else {
      return false;
    }
  }

惯性滑动FLING

当手指快速滑动离开的那一瞬间,有一个初始速度。通过SensorManager计算出加速度,根据公式a=V2/2S(加速度等于最大速度的平方除以2倍的路程),可以反推出S=V2/2a,计算出加速度减为0的时候,总共Fling的距离。这里默认是匀减速运动,然后使用手指离开时的速度/加速度=总共耗时duration,最后就可以根据上面这些数据计算出每时间内移动的距离,把这个距离当做偏移量去计算我们的数据起始位置,重新绘制即可。

/**
   * @param e1 手指按下的位置
   * @param e2 手指抬起的位置
   * @param velocityX 手指抬起时的x轴的加速度  px/s
   */
  @Override
  public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    mLastGesture = ChartGesture.FLING;
    fling(velocityX, e2.getX() - e1.getX());
    return true;
  }

  private void fling(float velocity, float offset) {
    stopFling();
    if (Math.abs(mDeceleration) > DataUtils.EPSILON) {
      // 根据加速度计算速度减少到0时的时间
      int duration = (int) (1000 * velocity / mDeceleration);
      // 手指抬起时,缓冲的距离
      int totalDistance = (int) ((velocity * velocity) / (mDeceleration + mDeceleration));
      int startX = (int) offset, flingX;
      if (velocity < 0) {
        flingX = startX - totalDistance;
      } else {
        flingX = startX + totalDistance;
      }
      mFlingRunnable = new FlingRunnable(startX, flingX, duration, mHandler, mChartGestureListener);
      mHandler.post(mFlingRunnable);
    }
  }

放大缩小SCALE

放大缩小的处理稍微就简单了一些,这里监听MotionEvent.ACTION_POINTER_DOWN这个手势,这个手势处理的就是多指按下的情况,根据多指的按下位置和缩放之后的位置计算出一个缩放比出来。然后动态的去更改一屏默认显示的蜡烛线个数,并且更改绘制的起始位置,刷新即可。

case MotionEvent.ACTION_POINTER_DOWN:
        if (event.getPointerCount() >= 2) {
          saveTouchStart(event);
          // 两个手指之间在X轴的距离
          mSavedXDist = getXDist(event);
          // 两个手指之间的距离
          mSavedDist = spacing(event);
          // 两个手指之间距离大于10才认为是缩放
          if (mSavedDist > 10f) {
            mTouchMode = X_ZOOM;
          }
          // 计算两个手指之间的中点位置
          midPoint(mTouchPointCenter, event);
        }
        break;

根据移动后的位置计算缩放比

case MotionEvent.ACTION_MOVE:
        if (mTouchMode == DRAG) {
          mLastGesture = ChartGesture.DRAG;
        } else if (mTouchMode == X_ZOOM) {
          if (event.getPointerCount() >= 2) {

            // 手指移动的距离
            float totalDist = spacing(event);

            if (totalDist > mMinScalePointerDistance) {
              if (mTouchMode == X_ZOOM) {
                mLastGesture = ChartGesture.X_ZOOM;
                float xDist = getXDist(event);
                float scaleX = xDist / mSavedXDist;
                if (mChartGestureListener != null) {
                  mChartGestureListener.onChartScale(event, scaleX, 1);
                }
              }
            }
          }
        }

长按LONG_PRESS

长按的处理是简单的,直接实现接口中的onLongPress方法即可知道当前长按的位置。然后根据长按动作去处理十字线以及长按的弹框等

@Override
  public void onLongPress(MotionEvent e) {
    mTouchMode = LONG_PRESS;
    if (mChartGestureListener != null) {
      mChartGestureListener.onChartLongPressed(e);
    }
  }

数据的处理

使用ChartDataSourceHelper和TechParamsHelper(相关技术指标的计算),根据上面手势移动的偏移量、缩放比进行数据的重组,这块可以直接参考源码阅读即可,没有什么特别复杂的地方。

根据初始位置计算初始化数据

/**
   * 初始化行情图初始数据
   */
  public void initKDrawData(List<KLineItem> klineList,
      KMasterChartView kLineChartView,
      KSubChartView volumeView, KSubChartView macdView) {

    this.mKList = klineList;
    this.mKLineChartView = kLineChartView;
    this.mVolumeView = volumeView;
    this.mMacdView = macdView;

    mSubChartData = new SubChartData();

    // K线首次当前屏初始位置
    startIndex = Math.max(0, klineList.size() - K_D_COLUMNS);
    // k线首次当前屏结束位置
    endIndex = klineList.size() - 1;
    // 计算技术指标
    mTechParamsHelper.caculateTechParams(klineList, TechParamType.BOLL);
    mTechParamsHelper.caculateTechParams(klineList, TechParamType.MACD);
    initKMoveDrawData(0, SourceType.INIT);
  }

当横向滑动、Fling惯性滑动和缩放之后,重新计算初始位置和当前屏幕的蜡烛线等

/**
   * 根据移动偏移量计算行情图当前屏数据
   *
   * @param distance 手指横向移动距离
   */
  public void initKMoveDrawData(float distance, SourceType sourceType) {

    // 重置默认值
    resetDefaultValue();

    // 计算当前屏幕开始和结束的位置
    countStartEndPos(distance, sourceType);

    // 计算蜡烛线价格最大最小值,成交量最大值
    ExtremeValue extremeValue = countMaxMinValue();

    // 最大值最小值差值
    float diffPrice = maxPrice - minPrice;

    // MACD最大最小值
    float diffMacd = maxMacd - minMacd;

    float diffBoll = maxBoll - minBoll;

    RectF contentRect = mKLineChartView.getViewPortHandler().mContentRect;

    // 计算当前屏幕每一个蜡烛线的位置和涨跌情况
    for (int i = startIndex, k = 0; i < endIndex; i++, k++) {
      KLineItem kLineItem = mKList.get(i);
      // 开盘价
      float open = kLineItem.open;
      // 最低价
      float close = kLineItem.close;
      // 最高价
      float high = kLineItem.high;
      // 最低价
      float low = kLineItem.low;

      KLineToDrawItem drawItem = new KLineToDrawItem();

      // 计算蜡烛线
      float scaleY_open = (maxPrice - open) / diffPrice;
      float scaleY_low = (maxPrice - close) / diffPrice;
      RectF candleRect = getRect(contentRect, k, scaleY_open, scaleY_low);
      drawItem.rect = candleRect;
      // 计算上影线,下影线
      float scale_HL_T = (maxPrice - high) / diffPrice;
      float scale_HL_B = (maxPrice - low) / diffPrice;
      RectF shadowRect = getLine(contentRect, k, scale_HL_T, scale_HL_B);
      drawItem.shadowRect = shadowRect;

      // 计算红涨绿跌,暂时这么计算(其实红涨绿跌是根据当前开盘价和前一天的收盘价做对比)
      if (i - 1 >= 0) {
        KLineItem preItem = mKList.get(i - 1);
        if (kLineItem.open > preItem.close) {
          drawItem.isFall = false;
        } else {
          drawItem.isFall = true;
        }
        if (preItem.close != 0) {
          kLineItem.preClose = preItem.close;
        } else {
          kLineItem.preClose = kLineItem.open;
        }
      }

      // 计算每一个月的第一个交易日
      if (i - 1 >= 0 && i + 1 < endIndex) {
        int currentMonth = DateUtils.getMonth(kLineItem.day);
        KLineItem preItem = mKList.get(i - 1);
        int preMonth = DateUtils.getMonth(preItem.day);
        if (currentMonth != preMonth) {
          drawItem.date = kLineItem.day.substring(0, 10);
        }
      }

      // 计算成交量
      if (Math.abs(maxVolume) > DataUtils.EPSILON) {
        RectF volumeRct = mVolumeView.getViewPortHandler().mContentRect;
        float scaleVolume = (maxVolume - kLineItem.volume) / maxVolume;
        drawItem.volumeRect = getRect(volumeRct, k, scaleVolume, 1);
      }

      // 计算BOLL
      caculateBollPath(diffBoll, contentRect, i, k, drawItem);

      // 计算附图MACD Path
      caculateMacdPath(diffMacd, i, k, drawItem.isFall);

      drawItem.klineItem = kLineItem;
      kLineItems.add(drawItem);
    }

    List<KLineToDrawItem> resultList = new ArrayList<>();
    // 数据准备完毕
    if (mReadyListener != null) {
      resultList.addAll(kLineItems);
      mReadyListener.onReady(resultList, extremeValue, mSubChartData);
    }
  }

/ 总结 /

目前市面上有很多的自定义图表,但是能将行情图以及各项指标完全复用的基本上没有,比较牛逼的就是MPChart基本上能够满足大部分的图表使用,但是对行情图来说还是远远不够。所以出于兴趣,就模仿火币和炒股软件进行了一个自定义蜡烛线,由于不是专业人士,可能有的金融指标有一些偏差,这里明白绘制技术即可,不必关心这些金融细节。

规划(项目会继续完善更新):

后面会继续丰富图标的各项指标
数据层要进行整理,目前有些地方处理不是特别高效
实现各种图表动态添加、切换等。

以下是我所整理出来的一份资料,想要的小伙伴可以点击我的GitHub来获取哦。


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