從0到1繪製蠟燭線(實現細節)

股票??數字貨幣??都是浮雲,沒那智商還是好好擼代碼吧,啊哈哈哈!今天作爲一個嫩綠嫩綠的韭菜,就來用技術征服一下割過自己的股票行情圖。

股票行情圖中比較複雜的應該當屬於蠟燭線(陰陽線),這塊手勢處理複雜、圖表指標複雜、交互複雜、數據處理複雜......總之:複雜!

所以就從今天開始我從0到1打造出這個複雜的行情圖!費話不多說,上圖!上鍊接:

https://github.com/SlamDunk007/StockChart

一、效果圖 

       

二、繪製流程

整個繪製過程完全自定義View不依賴任何第三方繪製工具,大概分爲三個部分:具體的繪製過程、手勢的處理、數據的處理。下面就從這三個方面逐個進行講解。

1、具體繪製過程

(1)這裏使用的是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簡單測試一下,這裏不再贅述。

(2)蠟燭線、長按十字線和長按彈框的具體繪製

長按手勢的識別方法可以繼續參考下面的手勢的處理部分。

蠟燭線:股票的蠟燭線有高、開、低、收四個參數,分別代表:最高價、開盤價、最低價、收盤價。這裏首先計算出最高價當中的最大值和最低價當中的最小值,然後根據(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);

2、手勢的處理

代碼當中的ChartTouchHelper是處理手勢的關鍵類,目前行情圖的手勢有幾種:左右滑動DRAG、慣性滑動FLING、放大縮小Scale、長按LONG_PRESS。

這裏使用了android當中的GestureDetectorCompat結合onTouch(View v, MotionEvent event)來處理這幾種手勢。

(1)左右滑動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;
    }
  }

(2)慣性滑動FLING

當手指快速滑動離開的那一瞬間,有一個初始速度。通過SensorManager計算出加速度,根據公式a=V^2/2S(加速度等於最大速度的平方除以2倍的路程),可以反推出S=V^2/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);
    }
  }

(3)放大縮小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);
                }
              }
            }
          }
        }

(4)長按LONG_PRESS

長按的處理是簡單的,直接實現接口中的onLongPress方法即可知道當前長按的位置。然後根據長按動作去處理十字線以及長按的彈框等

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

3、數據的處理

使用ChartDataSourceHelper和TechParamsHelper(相關技術指標的計算),根據上面手勢移動的偏移量、縮放比進行數據的重組,這塊可以直接參考源碼閱讀即可,沒有什麼特別複雜的地方。

(1)根據初始位置計算初始化數據

 /**
   * 初始化行情圖初始數據
   */
  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);
  }

(2)當橫向滑動、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基本上能夠滿足大部分的圖表使用,但是對行情圖來說還是遠遠不夠。所以出於興趣,就模仿火幣和炒股軟件進行了一個自定義蠟燭線,由於不是專業人士,可能有的金融指標有一些偏差,這裏明白繪製技術即可,不必關心這些金融細節。

規劃(項目會繼續完善更新):

1、後面會繼續豐富圖標的各項指標

2、數據層要進行整理,目前有些地方處理不是特別高效

3、實現各種圖表動態添加、切換等。

歡迎大家提出寶貴意見!!!

再次附上源碼地址:

https://github.com/SlamDunk007/StockChart

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章