简介:这是一份面向Android初中级开发者的K线图实现学习工程,以股票行情展示为场景,完整演示如何通过自定义View搭建K线、分时等核心图表。压缩包共153个文件,约4.97MB;其中24个java文件提供源码逻辑,21个xml布局与58个png资源覆盖界面与图形素材,另有34个class、3个jar及apk等编译与运行文件,便于对照源码理解构建过程。资源已吸引213人学习下载。学习者可从中拆解KChartsView、GridChart、TimesView等关键类的绘制思路,并结合KDJEntity等实体设计掌握图表的组织方式;同时项目中的Fragment、Activity结构也展示了Android图表类App的常见工程分层,适合作为数据可视化与自定义控件入门练手的范本。
1. 为什么我最终选择自研K线图,而不是直接引第三方库
先交代一下背景。我是在Android Studio里用Java开发一个行情类App,需求就是最常见的日K、周K、分时图加成交量。当时接手项目的第一反应和大多数人一样,先搜一下有没有现成的开源库,毕竟K线图这种高频需求,网上方案一堆,没必要重复造轮子。
这段时间搜索“android k线图”相关的内容,你会发现大部分教程其实都在讲怎么引MPAndroidChart,或者用一些比较老的库。MPAndroidChart确实有CandleStickChart,简单场景下够用。但真到接券商数据源、做自定义指标、处理几十万根历史K线的时候,库的短板就会暴露得很明显。比如数据更新时的整体重绘开销、十字游标的交互体验、指标计算的扩展性,这些在自定义View里反而更好控制。
我的建议是分两种情况:
- 业务简单、样式要求低、上线时间紧:直接用MPAndroidChart,按官方demo跑通,样式微调即可。
- K线是产品核心、交互和指标需要深度定制:强烈建议自研,不要在大后期再去换轮子,那比一开始就自研要痛苦得多。
这篇文章我会把实现“Android K线图”过程中最核心的算法、手势处理和性能优化拆开讲,包含可以直接参考的Java代码逻辑。文章基于我自己的实际项目经验,方案不一定是最优解,但踩坑经验肯定是真实的。
2. K线数据模型与坐标系换算,这是整个图表的基石
2.1 先定义一套能落地的数据结构
很多新手画K线,喜欢直接在onDraw里临时拼数据,结果项目一复杂就全乱套。正确的做法是先定义一套严格的数据模型,把所有指标、成交量、日期统一管理起来。
我用的核心数据结构是这样的(简化版):
public class KLineBean { public float open; // 开盘价 public float close; // 收盘价 public float high; // 最高价 public float low; // 最低价 public float volume; // 成交量 public long time; // 时间戳(用于切换周期时对齐数据) public float ma5; // 5日均线值 public float ma10; // 10日均线值 public float ma20; // 20日均线值 }为什么要提前把ma5、ma10、ma20算好存进bean里?因为均线计算是在数据源的地方一次性完成的,如果在绘制的时候每次都去算,那性能会非常差。几十万根K线你不可能每次都去循环求均值。
另外我建议对数据做一层缓存数组处理,在真正绘制时尽量用基本类型数组而不是List对象,减少GC压力。比如:
private float[] openArr; private float[] closeArr; private float[] highArr; private float[] lowArr;这在数据量大时性能差异非常明显,Java在for循环里反复通过List.get()访问包装类型,开销比想象中大得多。
2.2 价格到屏幕坐标的换算逻辑
K线图的核心就是一组映射关系:把价格范围映射到View的高度范围,把索引范围映射到View的宽度范围。
先看价格范围maxPrice和minPrice的计算。这里有个细节,就是最高价和最低价不能只看当前显示的那根K线,还要考虑当前设置的指标(比如布林带上轨可能比最高价还高),否则指标线会画出屏幕外。我一般会预留10%的上下边距,防止实体柱顶到边界:
float maxPrice = max(visibleHigh, indicatorMax); float minPrice = min(visibleLow, indicatorMin); float padding = (maxPrice - minPrice) * 0.1f; maxPrice += padding; minPrice -= padding;接着是坐标映射:
float priceToY(float price, float maxPrice, float minPrice, float chartHeight) { return (maxPrice - price) / (maxPrice - minPrice) * chartHeight; }这里要注意,Android的Y轴是向下增长的,所以要用最大值减去当前价格再做归一化,否则K线就是上下颠倒的。
索引到X坐标的换算类似,搞懂这两套映射关系,K线图等于完成了一半。
2.3 蜡烛实体和影线的绘制细节
开始画K线之前,先确定每个K线蜡烛的宽度。这个宽度不是固定的,要跟随缩放比例动态变化。我的做法是维护一个candleWidth变量,初始值根据可见区间K线数量和View宽度计算:
mCandleWidth = mChartWidth / 50f; // 默认显示50根然后通过手指缩放,每次缩放乘以一个比例系数,并限制最大最小值,避免蜡烛宽到看不清或窄到全是噪点。
绘制单根蜡烛的代码核心思路如下:
// 先算好整根蜡烛占的横向空间,实体左右各留10%空隙 float totalSpace = mCandleWidth * 1.2f; float left = indexToX(i) - totalSpace / 2f + totalSpace * 0.15f; float right = indexToX(i) - totalSpace / 2f + totalSpace * 0.85f; float openY = priceToY(kLine.getOpen()); float closeY = priceToY(kLine.getClose()); float highY = priceToY(kLine.getHigh()); float lowY = priceToY(kLine.getLow());然后是画影线的逻辑,影线顶部是highY,底部是lowY:
canvas.drawLine(centerX, highY, centerX, lowY, mLinePaint);如果close > open,就是阳线,用红色绘制空心灵体(或实心);如果close < open,就是阴线,用绿色绘制实心柱体。
这里尤其要注意颜色的切换不能频繁设置Paint的颜色,最合理的做法是准备两套画笔(红画笔、绿画笔),绘制时直接切换到目标画笔就够了:
canvas.drawRect(left, openY, right, closeY, isBull ? mRedPaint : mGreenPaint);3. 手势交互的实现:缩放、滑动、长按十字线
自研K线图最值得做的就是手势交互,因为你完全可以按照产品需求定义交互细节。这里我把最核心的三种手势讲清楚。
3.1 用ScaleGestureDetector处理双指缩放
Android的ScaleGestureDetector封装了大部分缩放检测逻辑,但K线图的缩放需要自己控制逻辑,不是直接改scaleX/scaleY,而是改变可见K线的数量和蜡烛宽度。
核心逻辑如下:
float scaleFactor = detector.getScaleFactor(); mVisibleCount /= scaleFactor; // 双指放大,可见数量减少 mVisibleCount = Math.max(15, Math.min(120, mVisibleCount)); mCandleWidth = mChartWidth / mVisibleCount;这里有几个细节要特别提醒:
- 缩放中心问题:缩放时的焦点应该在双指中心对应的那根K线附近,否则双手缩放时,K线会飘走,体验很差。处理方法是记录下焦点位置的索引,缩放后让这个索引依然处于相同的屏幕位置。
- 边界限制:
mVisibleCount必须有上下限,最小值是为了避免单根K线被放得巨大,最大值是为了避免缩到所有K线挤成一条线。 - 缩放结束后重算可见区域:缩放结束后,要重新计算当前可见区域是原始数据里的哪个区间,然后触发重绘。
3.2 处理onTouchEvent的事件分发和滑动
K线图肯定要响应左右滑动来切换查看不同时段的数据。这一块容易出错的地方在于事件冲突,尤其是在外层套了ViewPager2或者RecyclerView的情况下。
我处理滑动的思路是:
case MotionEvent.ACTION_MOVE: float dx = event.getX() - mLastX; mScrollOffset += dx; // 根据偏移量换算成移动的K线索引个数 int moveCount = (int) (mScrollOffset / mCandleWidth); if (moveCount != 0) { mStartIndex -= moveCount; // 限制边界 mStartIndex = Math.max(0, Math.min(mStartIndex, mData.size() - mVisibleCount)); mScrollOffset -= moveCount * mCandleWidth; } invalidate(); break;这里有两点经验想分享:
一是不能每次手指划一点就把startIndex变一次,因为每次变更都涉及重算可见数据、重新测量文本宽度等操作,频繁执行会卡顿。所以要等累计偏移量超过一根K线宽度时,才真正触发索引变化。
二是最左和最右的边界弹性处理。K线图滑到边界时不应该出现大片空白,也不能滑出去之后就卡死。我的做法是,数据量少于可见数量时禁止滑动;数据量足够且滑到最左或最右时,强制修正偏移量。
3.3 长按十字游标显示详细信息
长按十字游标是这个图表交互里用户感知最强的部分。实现的思路不复杂,就是监听长按事件,然后根据触摸点的X坐标反推出对应的K线索引:
int targetIndex = (int) ((event.getX() + mScrollOffset) / mCandleWidth);然后在这个索引对应的中心X、价格Y位置画十字虚线,并在顶部或侧面用文字标出OHLC数据。需要注意十字线绘制时的抗锯齿效果和虚线样式,线条颜色不能太重,否则会遮挡K线主体。
另外在手指长按移动的过程中,游标要实时跟随。我在这个阶段测下来发现,频繁的invalidate()其实压力不大,真正的瓶颈往往是文字重绘和底部时间标签的重新计算。建议把时间标签的格式化缓存起来,不要每帧都做SimpleDateFormat的操作,这个对象创建开销在低端机上真不能忽视。
4. 指标线绘制原理(MA/EMA/成交量副图扩展思路)
4.1 均线不能一根根画,要按折线路径连起来
MA5、MA10、MA20这类均线,用Android Canvas画的时候,最忌讳的是每根K线都单独drawLine。正确做法是用Path把可见区域内所有均线点连成一条折线,然后一次性drawPath。
大致逻辑是:
Path maPath = new Path(); for (int i = visibleStart; i <= visibleEnd; i++) { float x = indexToX(i); float y = priceToY(kLineBean.ma5); if (i == visibleStart) { maPath.moveTo(x, y); } else { maPath.lineTo(x, y); } } canvas.drawPath(maPath, mMa5Paint);这样一次绘制调用就能渲染整条均线,性能上完全没问题。MA10、MA20同理,只是换了不同的Paint。
4.2 EMA和MACD一类指标的注意点
如果你要实现MACD,它的计算是递推式的,初始化需要从第一根K线开始,不能只算可见区域那段。很多人这里踩过坑,就是只算屏幕上能看到的K线,导致MACD数值从中间突然跳变。
正确的做法是把整套EMA12、EMA26、DIF、DEA全部在数据源加载完成时一次性计算并缓存,绘制时直接取数组里的值映射到坐标。这跟前面说的MA统一提前算好的思路是一致的。
4.3 副图(成交量、MACD)与主图的联动布局
副图和主图通常在一个View里划分上下两个区域,而不是用两个独立View去同步滚动,因为同步滚动的时序很难处理得绝对无偏差。我用的是一个KLineView内部划分上下两个区域:
- 主图区:K线、均线、指标线。
- 副图区:成交量柱状图或者MACD柱状图。
副图区的坐标系换算是独立计算的,maxVolume只统计可见区间内的最大成交量,Y轴从0开始向上,柱子底部对齐副图区域底部,代码如下:
float volumeTotal = visibleMaxVolume * 1.1f; float volumeLeft = left + 4; float volumeRight = right - 4; float volumeBottom = subChartBottom; float volumeTop = subChartBottom - (volume / volumeTotal) * subChartHeight; canvas.drawRect(volumeLeft, volumeTop, volumeRight, volumeBottom, volumePaint);副图的柱子宽度要比K线蜡烛窄一些,否则视觉上会很拥挤。这个微调我们在真机上试了好几个比例,最终觉得K线蜡烛宽度的80%左右比较舒服。
5. 双线程更新数据导致的卡顿,以及优化重构过程
5.1 最开始的卡顿:每次数据刷新全量重绘
项目刚做完的时候,K线图在低端机上明显卡顿,尤其是行情推送频繁时,第一版实现直接在数据回调里调invalidate(),然后全量重算可见区域、重绘所有K线。表面看起来没啥问题,但实际渲染一帧要30~50ms,UI线程被拖得很严重。
后来定位到几个关键瓶颈:
- 初次加载10000根K线时,全部走for循环计算价格范围,这一块随着数据量增长线性变慢。
- 每次重绘都会重新格式化时间标签,
SimpleDateFormat在低端机上是很慢的。 - 绘制过程中不断创建
RectF、Path对象,导致GC频繁触发,掉帧雪上加霜。
5.2 优化一:可见区域裁剪
第一个优化就是只遍历并绘制可见区域内的K线,visibleStart和visibleEnd是根据mStartIndex和mVisibleCount提前算好的。对于maxPrice和minPrice的计算,也只需要遍历可见范围内那几十根K线就够了,和全量10000根的性能开销完全不是一个量级。
但注意,指标计算依然要全量计算并缓存,这里的优化只是针对绘制阶段的遍历范围。
5.3 优化二:静态复用绘制对象
在onDraw里我建立了对象池或者直接复用成员变量。比如Path只在初始化时创建一次,每次drawPath前用path.reset()再重新连接点。对于柱状图使用的RectF也做了复用,虽然Canvas的drawRect有直接传四个浮点参数的重载方法,但能不创建新对象就尽量不创建。
5.4 优化三:处理高频数据推送
行情接口往往是每秒推送好几帧的数据,如果每次都立即重绘,UI线程必然扛不住。我的做法是加了一个数据缓冲刷新策略:
private boolean mDataDirty = false; public void updateData(List<KLineBean> newData) { synchronized (mDataLock) { mData = newData; mDataDirty = true; } postInvalidateOnAnimation(); // 在下一帧统一渲染 }然后在draw方法开始的时候检查mDataDirty,如果为true才重新计算指标和可见范围,否则直接沿用上一次的绘制参数。这样就算推送频率再高,实际重绘频率也被限制在了系统帧率范围内。
6. 真实踩坑记录与避坑建议
6.1 坑一:真机上出现黑块或残影
一开始我把K线View的setLayerType设成了LAYER_TYPE_SOFTWARE,结果真机上出现严重的残影和黑块,特别是在滚动和缩放过程中。后来换回默认的硬件加速并给Paint开启抗锯齿,问题就消失了。建议不要轻易关闭硬件加速,绝大多数绘制问题可以通过其他方式解决。
6.2 坑二:横竖屏切换时数据丢失
如果K线Activity没有做状态保存,切屏就会回到初始状态。很多人觉得K线图切屏后重新拉一次数据就行,但真实的用户场景是,他可能看了很久某一段时间的行情,切个屏你让他重新找,体验是非常糟的。我是在onSaveInstanceState里存了当前起始索引和可见数量,恢复时直接还原位置。
6.3 坑三:与ViewPager2的滑动冲突
K线View放在ViewPager2里时,横向滑动会被ViewPager2拦截,导致K线图没法顺畅滑动。解决办法是重写K线View的onInterceptTouchEvent,当手指在K线图区域横向滑动时,告诉父容器不要拦截。具体就是通过requestDisallowInterceptTouchEvent(true)在ACTION_DOWN之后马上调用。
@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { getParent().requestDisallowInterceptTouchEvent(true); } // ... 其他手势处理 }这个方法在绝大多数场景下都能解决冲突问题。
6.4 坑四:时间标签被截断
底部时间轴的文案如果是跨年数据,比如2024-12-31切换到2025-01-02,很容易出现左右两边标签重叠或截断。我的做法是先计算每个标签的理论位置,如果和前一个标签的距离小于文字宽度,就跳过一个标签不绘制。另外时间格式化只做两层判断:跨年显示年月日,同一年只显示月日,这样既简洁又不会重叠。
6.5 坑五:空数据与null保护
行情数据有时候因为网络原因返回空列表,这个时候K线View如果不加保护会直接崩溃。我的做法是在setData方法里判断数据是否为空,为空就显示一个"暂无数据"的占位文案,并禁止所有手势操作。别小看这个逻辑,上线后你会发现它帮你拦截掉很多线上崩溃。
7. 从能用走向好用:后续可扩展的方向
如果你照着上面的思路把基本的K线图跑通了,恭喜你,已经完成了最硬核的部分。接下来想让这个图表真正进入生产环境,还有几个可以逐步扩展的方向。
第一,分时图复用同一套坐标系逻辑。分时图本质上就是K线图的均价线和成交量的组合,缩放和十字线逻辑可以完全复用,只需要把蜡烛绘制换成折线绘制。
第二,支持多周期切换。日K、周K、月K、60分钟、30分钟这些周期的本质区别只是数据聚合方式不同。把数据源按周期聚合的逻辑抽象出来,View层可以做到完全复用。我在项目里就是用同一个View,靠切换数据源来实现多周期切换的,没有为每个周期单独写一套View。
第三,把指标算法模块化。目前我的MA、EMA、BOLL计算都是独立的工具类,输入原始K线数据,输出指标数组。这样做的好处是以后加新指标,比如KDJ、RSI,只需要新增一个计算类,主图和副图都能灵活配置。
说到底,Android K线图这个需求,真正考验的不是某个API用得多熟练,而是你对数据流、坐标映射、绘制性能和事件分发这几个基础能力的综合运用。我在实际开发中最大的体会是:K线图写一次,之后几乎不用大改,但前期设计的数据结构和绘制架构一定要想清楚,否则后期每加一个功能都会让你从头改一遍。希望这些经验能帮你在自研K线图这条路上少走几个弯。
本文还有配套的精品资源,点击获取