简介:这是一份面向Android开发者的K线图实现学习示例,适合初步接触自定义图表绘制、希望掌握股票类应用界面搭建的读者。压缩包共包含153个文件,整体约4.97MB,其中以Java源码、XML布局/配置、PNG图片资源为主,另有少量JAR依赖库、Class编译文件以及一个可直接安装的APK,便于边看代码边运行验证。作者将K线图相关模块单独拆分,内容预览中出现KChartsView、KChartsFragment、TimesView、KDJEntity等类,覆盖K线主图、分时图、KDJ指标实体定义等常见环节,可帮助理解从数据封装到自定义View绘制的完整链路。目前已有213人学习下载,适合作为入门Android图表开发的参考项目,也能为后续扩展量能、MACD等指标提供思路。
1. 项目概述与需求拆解
1.1 这个标题背后到底是什么需求
"android_k线图"这个项目名看起来很简单,但做行情类App的同行都知道,这五个字背后是一整套技术栈的浓缩。K线图,学名蜡烛图(Candlestick Chart),是金融行情展示的核心组件,无论你是做股票、基金、数字货币还是期货类应用,都绕不开它。
拆开这个需求,核心点其实有三个维度:
- 数据层面:OHLC(开盘价、最高价、最低价、收盘价)四元组如何组织、如何从服务端拉取、如何分页加载。
- 绘制层面:蜡烛实体、上下影线、均线、成交量、分时走势,这些元素在Android Canvas上怎么高效画出来。
- 交互层面:左右滑动查看历史、双指缩放切换周期、长按十字光标查看明细、拖动查看最新价格——行情类App的交互体验直接决定用户留存,这块在需求里虽然没写,但一定是刚需。
这里补充一下,我做过的几个行情项目里,K线图是需求变更最频繁的模块之一。今天要红涨绿跌,明天改红跌绿涨;这周要加MA5/MA10/MA20,下周要加BOLL、MACD、KDJ。如果一开始没有把架构设计成可扩展的,后面每次改需求都是灾难。
1.2 面对这个需求,先想清楚的三件事
第一件事:是直接用第三方库还是自己造轮子?市面上有MPAndroidChart、HelloCharts、KLineChart等开源方案,各有优劣。如果你只是做个Demo或者对K线交互要求不高,用MPAndroidChart的CandleStickChart确实快,一两天就能出来。但MPAndroidChart在K线图上的表现力其实很有限——缩放卡顿、十字光标交互弱、没有组合指标联动,真到上线商用场景,大概率要自己二次开发甚至推倒重来。我见过不止一个项目,一开始图省事用开源库,后来发现撑不住,又花双倍时间去自研。
第二件事:数据量级预估。一次加载多少根K线?1分钟级的日内K线,一天就有240根,一周就是1200根。如果用户从5分钟切到1分钟,数据量直接翻数倍。这块不提前设计好分页加载和聚合抽稀策略,列表必然卡成PPT。
第三件事:绘制线程模型。K线图的绘制和手势频繁交互决定了它必须在主线程响应触摸事件,同时又要能快速更新。默认的onDraw机制每次全量重绘,数据量一大就是性能瓶颈。这套东西的性能优化是一整个专题,后面我会展开讲。
总结一句话:这个项目标题的核心,不只是一张能看的K线图,而是能在Android上流畅运行、稳定支撑业务迭代的行情组件。下面我按实际开发流程,把从方案选型到核心绘制的完整链路拆开讲。
2. 方案选型:为什么我最终选了自研自定义View
2.1 主流三方库的真实表现评估
先不急着写代码,把选型的事说透。我这里给一个比较客观的横向对比,都是我实际用过的感受:
| 方案 | 上手难度 | 性能表现 | 可扩展性 | 维护活跃度 | 适用场景 |
|---|---|---|---|---|---|
| MPAndroidChart | 低 | 中等,大量数据卡顿明显 | 一般,定制需读懂内部实现 | 已停止维护几年 | 简单图表、Demo |
| HelloCharts | 低 | 中等 | 一般 | 基本停更 | 简单图表 |
| KLineChart | 中 | 较好,K线专用 | 较好,模块划分清晰 | 个人维护 | 商用K线,可二次开发 |
| 自研Canvas绘制 | 高 | 最优,可按场景极致优化 | 完全可控 | 自己掌控 | 核心行情组件,长期迭代 |
MPAndroidChart是很多人的第一选择,但它的CandleStickChart有个老毛病——每次数据更新或者进入动画时,会触发全量重绘,数据一旦超过几百根,掉帧就很明显。而且它为了兼容所有图表类型,内部抽象层次很多,你为了改一个十字光标,要翻五六层类,改完还要担心影响其他功能。
KLineChart是我之前接手的一个项目里用过的方案,它本身就是为K线而生,绘制逻辑上比MPAndroidChart合理,源码结构清晰,一些国内券商App里也能看到它的影子。但问题是它已经停更了两年多,Android高版本适配、暗黑模式适配、刘海屏安全区域这些都得自己改,改起来虽然比MPAndroidChart舒服,但长期依赖一个个人作者的项目,心里总不踏实。
2.2 自研方案的核心架构设计
权衡再三,我最后选择的是自研路线,核心基于自定义View+Cavas绘制,配合Matrix手势变换。整体架构分四层:
第一步,数据模型层。定义一个KLineEntity类,包含时间戳、开盘价、最高价、最低价、收盘价、成交量、成交额等字段。再定义一个指标计算器接口,MA、BOLL、MACD、KDJ各实现一个子类,数据进来之后统一走指标计算管线。
第二步,视口映射层。这是K线图的核心抽象。屏幕上K线图的绘图区域对应一个"视口"(Viewport),视口包含两个维度:横向是K线索引范围(比如当前可视区域显示第100到第250根K线),纵向是价格范围(当前可视区域的最低最低价到最高最高价)。所有触摸手势,本质上都是改变Viewport的参数。
第三步,绘制渲染层。自定义View的onDraw里,按顺序绘制网格线、蜡烛图、均线、成交量、指标区、十字光标、最新价格标签。每一层独立绘制、独立缓存,某些层在不变化时可以用Bitmap缓存避免重复计算。
第四步,手势交互层。GestureDetector处理单击、双击、长按,ScaleGestureDetector处理双指缩放,onTouchEvent里处理单指滑动。手势回调直接修改Viewport参数,然后调用invalidate()触发重绘。
这个架构的核心思想是:数据、状态、渲染三者分离。数据层只负责纯计算,不关心UI;Viewport是唯一的状态源,所有手势和业务逻辑都通过修改Viewport来影响画面;渲染层只做一件事——根据当前Viewport把数据画出来。这样每个环节都能独立测试,也能独立优化。
2.3 交易时间轴的坐标换算设计
这里有个细节特别重要:K线图的X轴坐标不是均匀的时间轴,而是按K线索引均匀分布的。为什么会这样?因为A股有休市时间,如果按真实时间轴画,每天开盘和收盘之间会有很长的空白段,视觉上很难看,而且会浪费大量屏幕空间。
所以坐标换算的公式其实很朴素:
x = paddingLeft + (candleWidth + candleSpacing) * (index - viewportFirstIndex)其中candleWidth是每根K线占的宽度,candleSpacing是K线之间的间距,viewportFirstIndex是可视区域第一根K线的索引。缩放的时候,改变的是candleWidth;滑动的时候,改变的是viewportFirstIndex。这两个变量由Viewport统一管理,绘制层只需要按公式计算即可。
Y轴方向正好相反,屏幕Y坐标向下为正,价格向上为正,所以:
priceY = chartTop + (maxPrice - price) / (maxPrice - minPrice) * chartHeight这里有一个新手很容易踩的坑:边界值处理。如果某根K线的最高价或最低价正好等于视口的最高或最低价,那影线会贴着图表边缘,画出来很难看,而且十字光标的标签会溢出屏幕。通常做法是让视口上下限各留出5%的padding空间,我一般用4%,上下各2%,视觉最舒服。
3. 核心绘制细节:从蜡烛到指标的一步步实现
3.1 蜡烛图和影线的绘制原理
蜡烛图的绘制原理不难,难点在细节和效率。先说绘制逻辑。
每根蜡烛由实体(Body)和影线(Wick)两部分组成。实体是开盘价和收盘价之间的矩形区域,影线是最高价和最低价之间的竖线。
颜色规则用了一个纯业务逻辑来区分:收盘价高于开盘价,画阳线(默认红色);反之画阴线(默认绿色)。这块要注意,国内和欧美市场、股票和数字货币的颜色习惯不一样,一定要把颜色做成可配置的,我给客户做海外版时,把红色和绿色做了个开关,一行代码就切换了。
实体矩形Rect的计算:
float openY = priceToY(openPrice); float closeY = priceToY(closePrice); float left = x - candleWidth / 2; float right = x + candleWidth / 2; float top = Math.min(openY, closeY); float bottom = Math.max(openY, closeY);然后canvas.drawRect和canvas.drawLine分别绘制即可。
一个容易被忽视的细节:实体高度为0的情况。当开盘价等于收盘价(比如一字板、停牌复牌),openY等于closeY,drawRect画出来的矩形高度为0,视觉上就消失了。正确做法是当实体高度小于1dp时,强制画一个1dp高度的横线,保证用户能看到这根K线存在。
3.2 均线计算与绘制
均线(MA)是最基础的指标,算法本身很简单——N日收盘价的算术平均值。但有个工程上的问题必须考虑:滚动窗口的增量计算。
移动窗口的和 = 前一个窗口的和 - 移出的收盘价 + 新加入的收盘价这块如果不用增量计算,每个点都重新加总和求平均,数据量上千时时间复杂度是O(n*m)。使用增量计算后,每个指标是O(n),整体性能提升非常明显。对于MA5、MA10、MA20、MA60多条均线同时绘制,性能差距从几秒缩短到毫秒级。
绘制的时候,相邻两个点之间用Path连接,注意:当某个点尚未形成(比如第k根K线还没有MA20的值),需要跳过,断线处理。这块的实现要在循环里加个判断,数据不足的索引不连点。
Path path = new Path(); for (int i = 0; i < count; i++) { if (maValues[i] == null) continue; float x = indexToX(i); float y = priceToY(maValues[i]); if (isFirst) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, maPaint);还有一个绘制效果上的细节:均线的抗锯齿。Canvas默认的Paint是没有抗锯齿的,画出来的线条有明显锯齿感。记得给所有绘制K线的Paint设置setAntiAlias(true),这一行代码带来的视觉提升胜过很多花哨的优化。
3.3 成交量与副图指标的关联设计
行情软件的下半部分通常是成交量或副图指标(MACD、KDJ等)。这块如果和主图混在一起画,代码会非常混乱。我用的是一个固定比例的分区策略:
主图区高度 = 总绘图区高度 * 0.7 副图区高度 = 总绘图区高度 * 0.2 间隙 = 总绘图区高度 * 0.1主图和副图共用一个X轴坐标系,也就是说X轴的缩放和滑动,主图和副图是联动的,这个由同一个Viewport控制。但Y轴各自独立计算价格(或指标值)范围。
成交量柱的绘制和蜡烛实体几乎一样,区别是颜色——涨的成交量用阳线色,跌的成交量用阴线色,跟我前面说的一样,这块做成可配置。MACD这种副图指标,绘制它的柱状线和折线时,Y轴范围要根据指标值的正负最大值动态计算,不能让柱子超出副图区。
一个实践中总结的小技巧:副图指标的X轴和主图同步,但绘制时要考虑label的上下间距。如果副图高度太矮,MACD的柱状图全都挤在一起,观感很差。我一般会限制副图最小高度,比如总绘图区高度低于某个值时,副图区保持固定高度,主图区留出剩余空间,这样窄屏手机上不至于挤成一团。
3.4 十字光标与浮动信息窗口的交互方案
长按出现十字光标,是所有行情App的标配交互。实现思路如下:
在onTouchEvent的ACTION_DOWN或ACTION_MOVE中,用event.getX()反算当前对应的K线索引:
int index = (int) ((x - paddingLeft) / (candleWidth + candleSpacing)) + viewportFirstIndex;注意越界判断:index必须在[0, dataSize-1]范围内,长按超出了数据边界,不显示光标或者把光标吸附到最近的有效K线上。
十字光标的绘制分三部分:
- 竖线:从主图顶部画到底部(和副图底部分开控制,我一般只画主图区域)。
- 横线:在当前触摸Y位置画一条水平线,并显示对应的价格数值。
- 信息窗:在屏幕右上角或十字光标附近弹出一个半透明的圆角矩形,显示当前K线的开盘、最高、最低、收盘、涨跌幅、成交量等数据。
信息窗的绘制要特别注意边缘检测。比如触摸点在屏幕最左侧,信息窗从左侧弹出就会超出屏幕。简单的策略:触摸点靠近屏幕左边,信息窗画在右边;靠近右边,画在左边;上下方向同理。这块虽然逻辑简单,但做的精细和粗糙,用户一眼能感受到。
另外,十字光标期间的性能优化有个细节:十字光标跟随手指移动时,重绘频率极高,这时候如果每次都全量绘制所有K线和所有指标,Android系统根本扛不住。解决方案是分层缓存——把不跟随手指变化的部分(K线、均线、成交量、网格)缓存到Bitmap里,手指移动时只重绘十字光标和信息窗,重绘量减少90%以上。这一步是"流畅版"和"卡顿版"的分水岭。
3.5 自定义View的onMeasure与自适应尺寸
这个坑我踩过一次。自定义View如果不重写onMeasure,在布局里设置match_parent或者weight权重时,尺寸计算会不正确。K线图通常塞在一个LinearLayout或ConstraintLayout里,和上方的价格数字区域共享屏幕空间。
正确做法:重写onMeasure并调用setMeasuredDimension。当MeasureSpec的mode为EXACTLY时直接用specSize,为AT_MOST时取specSize和最小尺寸中较小值,UNSPECIFIED时用默认尺寸。还有一个更省心的思路:整个K线图区域用一个自定义ViewGroup管理,主图、副图、底部时间轴的尺寸和比例都统一在ViewGroup里计算,子View各司其职,只做绘制不操心布局。
从我个人实践来看,后者更适合商用场景。因为一旦涉及到复杂的指标区切换(主图下面可能同时挂成交量、MACD、KDJ三个副图),纯自定义View的onDraw里写多分区的布局计算会很难维护。
4. 数据适配与性能优化:让万根K线不卡顿
4.1 大数据量下的绘制策略
假设你的服务端一次返回了5000根5分钟K线,按默认的candleWidth=7dp计算,5000根K线的总宽度是35000dp,而手机屏幕宽度大概400dp。你需要在400dp里画出5000根K线,每根蜡烛的宽度只有0.08dp,根本不可能。
所以数据抽稀是必须的。常见做法是:
- 当屏幕上的K线宽度小于某个阈值(比如3dp)时,不再逐根绘制,而是按固定间隔抽样绘制。
- 抽样时,如果直接均匀抽样会丢失最高点和最低点,所以要用分区聚合抽稀算法,把可视区域的数据分成N个桶,每个桶里取最小值作为最低价、最大值作为最高价,绘制成一根"聚合蜡烛"。
分区聚合抽稀的伪代码:
int bucketSize = max(1, visibleCount / (screenWidth / minCandleWidth)); List<KLineEntity> sampledList = new ArrayList<>(); for (int i = start; i <= end; i += bucketSize) { KLineEntity bucketMax = findBucketMax(data, i, i + bucketSize); KLineEntity bucketMin = findBucketMin(data, i, i + bucketSize); // 用桶内的最高/最低价模拟一根新K线 }这个方案我用在实际项目中表现不错,5000根K线滑动缩放基本能保持60fps。前提是抽稀计算不能在onDraw里做——onDraw每帧都调用,抽稀算法如果有整数除法和大循环,必然卡顿。正确做法是:把抽稀结果缓存起来,只有数据源变化或缩放跨过阈值时才重新计算。
4.2 异步加载与分页分段的思考
行情数据的加载有三类场景,处理方式不同:
第一类:首次进入页面加载历史数据。用分页接口,每页500根,加载完成后整体赋值给数据源。注意要loading态,且用户从进入页面到第一屏展示的时间不能超过500ms,否则用户感知明显。
第二类:用户向左滑动查看更早的历史。这里有个交互细节——当滑动到最左边边缘且没有更多历史数据时,触发下一页加载。加载过程中在左边显示一个loading长度或等待文案,加载完成插入到数据源头部,然后刷新。
第三类:实时订阅新K线。成交发生后,服务端推送数据,客户端每秒或每几秒更新最后一根K线(最新的未收盘K线的价格实时变动)。这块要用增量更新,不要整表刷新。我用的方案是维护一个undo列表,最后一根K线每次推送都替换,滑动到最右边时总能看到最新行情。
这里给一个实际项目的设计建议:所有网络请求和指标计算都放到子线程,只把计算好的绘制数据通过主线程回调更新UI。K线数据量通常不大(几千条),用RxJava或者协程都能轻松的切线程,但一定不能直接在主线程做指标计算和数组拷贝,否则卡顿问题又回来了。
4.3 Bitmap缓存与脏区重绘
Bitmap缓存是K线性能优化里提升最明显的一招。
原理是:K线图的底层图层(网格线、蜡烛图、均线、成交量)在"手势滑动过程中"其实是相对静止的画面——你说滑动的时候它在动,但那是Viewport平移导致的视觉错觉,本质上底层图像的像素内容没变,只是位置在变。因此,把这些静止内容画到一张离屏Bitmap上,然后onDraw里先drawBitmap,再做平移变换,最后在上层叠加绘制会动的十字光标、价格标签等。这样每帧的绘制量从上万条绘制指令降到几十条。
实现靠Canvas的分层:
// 离屏缓存 Bitmap cacheBitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); Canvas cacheCanvas = new Canvas(cacheBitmap); // 第1步:画底层内容到cacheCanvas drawGrid(cacheCanvas); drawCandles(cacheCanvas); drawVolume(cacheCanvas); // 第2步:onDraw时,把缓存贴上去 canvas.drawBitmap(cacheBitmap, 0, 0, null); // 第3步:绘制浮动层 drawCrosshair(canvas); drawPriceLabel(canvas);注意一个坑:缓存Bitmap要根据View的尺寸变化重建,比如屏幕旋转、横竖屏切换。在onSizeChanged回调里重新生成缓存,确保宽度高度正确。
内存方面,全屏K线图的离屏Bitmap大约等于屏幕面积的ARGB_8888位图,8MB左右,这块在内存吃紧的低端机上要注意回收策略。我一般会把最近的缓存强引用,若有内存压力再降级为直接绘制模式。
4.4 手势缩放与惯性滑动的实现细节
K线图的手势交互是用户体验的核心。双指缩放用系统自带的ScaleGestureDetector,这块逻辑比较固定。重点说几个容易出问题的细节:
缩放中心点锚定问题。用户双指缩放时,手指中间点对应的K线位置应该在缩放过程中保持不动,视觉上才自然。实现方式是:缩放前记录手指中心点对应的K线索引和X坐标,缩放后计算新的viewport参数,保证该索引对应的新X坐标仍然落在手指中心点。
缩放后Y轴的联动更新。水平缩放改变了可视K线数量,对应价格范围(最高价和最低价)也会变化,必须重新计算Y轴范围,否则会出现影线越界或者价格超出绘图区域。
惯性滑动。ACTION_UP之后用VelocityTracker拿到速度,然后开启一个ValueAnimator或使用Scroller做减速运动。这里如果用自定义Runnable配合postOnAnimation会更流畅,避免Scroller的动画时间固定导致的卡顿感。
触点数量判断:单指是滑动,双指是缩放。如果用户单指滑动过程中又放下第二个手指,要能平滑切换模式,不能出现跳动。这块需要在ACTION_POINTER_DOWN和ACTION_POINTER_UP时保存好状态,实际操作中这个状态机是K线图交互里最容易出bug的地方,没有之一。
5. 常见问题与排查技巧实录
5.1 Canvas绘制偶发卡顿与掉帧
现象:数据量大或者快速滑动时,帧率掉到30fps以下。
排查思路:
第一步,用Android Studio自带的Profile工具抓CPU和GPU渲染耗时,看onDraw的CPU耗时和GPU的绘制指令耗时。
第二步,LargeHeap有没有开?应用申请的Bitmap没有及时回收时,频繁GC会导致掉帧。
第三步,确认是不是每次都重新计算指标了。有些代码在onDraw里直接调用了指标计算函数,这是性能杀手。正确做法是:指标只计算一次缓存起来,手势时只查缓存。
第四步,确认离屏缓存是否生效。打日志或者用Systrace看drawCandles的调用次数,如果svg手势过程中依然每次全量绘制,说明Bitmap缓存没有生效或者缓存被意外重建了。
5.2 数据为空或数据不足时的边界case
这是个很容易忽略的场景。K线图在进入页面时,数据可能还没加载完成,onDraw会先被调一次,这时候数据源是空的。如果代码里没有判空,大概率出现数组越界崩溃。
我统一用了一个空状态设计:当数据为空时,直接在画布中间画一句"暂无数据",同时跳过所有绘制逻辑。等数据到达后再invalidate。这个处理要同时覆盖数据和指标两个层面:
- 数组为空,没画。
- 数据不足某条均线的最短周期(比如MA60要求至少60根数据,但实际只有30根),指标数组为空,画指标时跳过。
实践中尤其要注意指标的null判断,我见过太多"偶发崩溃"是因为取指标数组第i个值时空指针。
5.3 刷新率不一致导致的视觉闪烁
现象:快速滑动时K线图有明显的闪烁感,尤其是蜡烛图的红绿色交替。
原因:部分设备GPU缓存模式导致画面重绘时双缓冲切换不完整。或者是在onDraw里修改Paint的状态,导致绘制批次混乱。
解决方案:
第一,给自定义View设置硬件加速,一般默认开启,但如果有Layer.Type被设置为SOFTWARE就需要检查。
第二,避免在onDraw里创建新的对象(Path、Paint、Rect),这些对象应该在init阶段一次性创建好。每帧创建对象不仅带来GC压力,在某些GPU驱动下还会触发绘制缓存失效,直接闪屏。
第三,绘制顺序上尽量把静态和动态分开,静态层用缓存,动态层用轻量绘制,减少同一帧内绘制状态的频繁切换。
5.4 十字光标长按后无法正常取消
现象:长按后松手,十字光标偶尔不消失。
原因:ACTION_CANCEL事件没有被正确拦截。在触摸事件里,如果同时有滑动和长按发生,系统会先触发ACTION_CANCEL让长按消失,这时要重置十字光标状态。但因为代码里只在ACTION_UP时处理复位,ACTION_CANCEL分支遗漏了。
修复方式:
case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: isCrosshairVisible = false; invalidate(); break;这类边界事件处理,看似不起眼,实际用户感知很强。还是那句老话:K线图开发,细节决定成败。
6. 一点个人经验总结
做行情组件这些年,我最大的体会是:K线图不是一个"画图"问题,而是一个"状态管理+性能工程"问题。数据模型怎么组织、Viewport怎么维护、绘制怎么分层,比用什么Canvas API重要得多。
如果在架构阶段就坚持"数据、状态、渲染"分离,后面接任何指标、任何新图表都只是新增模块的事;反之如果一上来就堆代码,哪怕是几千行的Demo,等真到了商用规模和机型覆盖阶段,改起来会非常痛苦。
最后分享一个小技巧:在K线图上叠加一层半透明的"涨跌分布热力图"或者"成交密集区",视觉信息密度高很多,用户口碑也很好。这个功能做起来不难,在Canvas上按价格区间叠加一层渐变半透明矩形就行,但对产品差异化帮助不小。希望这篇内容对正在做或准备做K线图需求的你有用。
本文还有配套的精品资源,点击获取