股票??數字貨幣??都是浮雲,沒那智商還是好好擼代碼吧,啊哈哈哈!今天作爲一個嫩綠嫩綠的韭菜,就來用技術征服一下割過自己的股票行情圖。
股票行情圖中比較複雜的應該當屬於蠟燭線(陰陽線),這塊手勢處理複雜、圖表指標複雜、交互複雜、數據處理複雜......總之:複雜!
所以就從今天開始我從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、實現各種圖表動態添加、切換等。
歡迎大家提出寶貴意見!!!
再次附上源碼地址: