简介:面向安卓开发者与金融行情学习者的K线图实现范例,以完整工程包形式梳理了K线绘制、分时图与KDJ指标等核心模块。资源共有一百五十三个文件,包括五十八张图片素材、三十四个编译后的类文件、二十四个源码文件、二十一个界面布局与配置文件,并附有可直接安装的安装包和调试文件,总大小约四点九七兆字节。压缩包内还包含第三方依赖、运行属性配置和工程结构描述文件,目录层次清晰,能够从页面、片段到自定义图表控件逐层阅读源码。已有二百一十三人学习/下载,适合希望快速上手安卓图表自定义绘制与股票类应用开发的初中级开发者。借助该工程可以掌握自绘图表控件的事件处理、多线程数据刷新以及图表缓存策略,熟悉后也能用于课程设计或毕业设计的参考起点。 做了几年Android开发,最怕听到的需求就是“做个K线图”。股票、基金、数字货币,只要涉及行情展示,K线图基本是跑不掉的标配。这个需求说难不难,说简单也不简单,网上能搜到一堆开源库,但真正要接到项目里,对齐需求、调样式、解决性能问题,每一步都有不少坑。这篇东西就从一个实际开发者的角度,把Android端K线图的实现思路、核心绘制细节、性能优化方案和常见坑位一次讲透,给正在做或者准备做行情类App的朋友做个参考。
1. 方案选型:自己画还是用开源库
K线图实现路径大致分两类:一是直接用现成的开源库,二是自己通过自定义View绘制。这两条路我都走过,先说结论:如果只是做个静态展示、不涉及复杂交互,开源库完全够用;但只要你后续要做分时图联动、各种指标切换、大数据量滑动,就老老实实自己写,绕不开的。
1.1 主流开源库评测
Android生态里做K线图,最常见的几个库分别是MPAndroidChart、KLineChart和SmartChart。
MPAndroidChart是老牌图表库,社区活跃度高,文档也全。它的K线图叫CandleStickChart,支持蜡烛图、柱状图、折线图混合展示。但有两个明显的短板:一是它的K线交互做得不够细,十字光标、长按高亮、滑动跟手度都差点意思;二是大量数据时性能会肉眼可见地卡,毕竟内部用了大量的Canvas绘制和重绘逻辑。
KLineChart是专为K线设计的轻量库,支持主图指标(MA、BOLL、MACD等)和副图指标切换,缩放滑动的手感调得不错。它的缺点是拓展性受限,想改个样式或者加点自定义元素,得深入源码改,维护成本不低。网上不少项目直接拿它改,改到后面和上游脱节,升级都升不了。
SmartChart是国人开源的方案,支持K线、分时、指标联动,性能优化做得不错,在金融类App里有不少应用。它的缺点是文档相对简单,遇到问题主要靠读源码,对新手不太友好。
1.2 为什么最终还是选择自绘
我手头的项目最终选择自绘,核心原因有三个。
第一,K线图的UI定制需求太多了。不同券商App的颜色、字体、指标参数、十字光标样式五花八门。用开源库,碰到一个边角需求就得翻源码,时间成本高。第二,性能和内存需要精确控制。行情数据动辄几千几万个点,还要支持分页加载、预加载、缓存,开源库的封装隐藏了太多细节,出了问题没法快速定位。第三,后续要加自己的技术指标。市面上开源的指标就那么几个,真到做量化策略或者特色指标时,只有自己掌握绘制的每个像素,才能玩得转。
自绘听起来工作量很大,但拆解下来,核心其实就是数据管理、指标计算、Canvas绘制、触摸交互这几块,每块都有清晰的实现路径,下面逐个说。
2. 数据结构和核心算法准备
在动手画之前,数据结构得先设计好。这部分出问题,后面全白搭。
2.1 K线实体数据模型
K线图每根K线包含开盘价、收盘价、最高价、最低价四个核心价格,以及对应的成交量、时间戳。对应Java类如下:
public class KLineBean { public long timestamp; // 时间戳,毫秒 public float open; // 开盘价 public float close; // 收盘价 public float high; // 最高价 public float low; // 最低价 public float volume; // 成交量 public float amount; // 成交额,可选 }这里有个经验:价格类型用float就行,double虽然精度高,但在大量数据计算和绘制时,float的性能表现更好,K线图展示场景下float的精度完全够用。千万别用String存价格,内存占用大,计算也不方便,这是新手很容易踩的坑。
2.2 指标计算:MA均线和成交量
均线(MA)是K线图最基础的指标。MA5就是最近5根K线收盘价的平均值,MA10、MA20以此类推。实现上可以用滑动窗口,避免每次全量计算:
public static float[] calcMA(float[] closes, int period) { int n = closes.length; float[] mas = new float[n]; float sum = 0f; for (int i = 0; i < n; i++) { sum += closes[i]; if (i >= period) sum -= closes[i - period]; mas[i] = sum / Math.min(i + 1, period); } return mas; }成交量柱状图相对简单,直接用volume值映射高度绘制矩形即可。但要注意成交量图的Y轴刻度通常和价格图分开,主图用价格区间,副图用成交量区间,两者要独立计算Scale,不能共用一套坐标映射。
2.3 可见区间的管理
K线图绘制不是把全部数据一次画出来,而是只绘制当前屏幕上可见的部分。比如总共有5000根K线,屏幕宽度360dp,每根K线占8dp,那么一屏大约显示45根,实际需要绘制的数据只有屏幕上那45根,其他数据在滑动时再动态取。
这就需要维护一个startIndex和endIndex,表示当前屏幕显示的数据区间:
public int startIndex; // 屏幕最左侧K线在全部数据中的下标 public int endIndex; // 屏幕最右侧K线在全部数据中的下标每次滑动或缩放,只需要改变这两个下标,然后触发重绘即可。这个思路是整个K线图性能优化的基石,一定要想明白。
3. 核心绘制细节:蜡烛图、十字光标和指标
数据准备好,接下来是重头戏:绘制。这一节讲的都是实际项目中验证过的绘制方案,代码思路可以直接拿去做参考。
3.1 蜡烛图的绘制
蜡烛图是由实体和上下影线组成的。实体表示开盘到收盘的区间,影线表示最高到最低的区间。涨跌颜色规则一般遵循“红涨绿跌”(国内习惯)或者“绿涨红跌”(欧美习惯),具体看产品要求。
核心绘制代码如下:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 计算缩放比例,K线宽度和间隔 float candleWidth = mCandleWidth; for (int i = mStartIndex; i <= mEndIndex; i++) { KLineBean bean = mData.get(i); float x = getX(i); // 根据下标计算X坐标 // 计算Y坐标 float highY = mapPriceToY(bean.high); float lowY = mapPriceToY(bean.low); float openY = mapPriceToY(bean.open); float closeY = mapPriceToY(bean.close); Paint paint = bean.close >= bean.open ? mUpPaint : mDownPaint; // 画影线 canvas.drawLine(x, highY, x, lowY, paint); // 画实体,开盘收盘价格差距太小就画一条1px的线,避免看不到实体 float top = Math.min(openY, closeY); float bottom = Math.max(openY, closeY); if (Math.abs(bottom - top) < 1f) { bottom = top + 1f; } canvas.drawRect(x - candleWidth / 2f, top, x + candleWidth / 2f, bottom, paint); } }这段逻辑里有两个容易忽略的点。一是开盘价等于收盘价时,实体高度为0,需要特殊处理,不然十字星K线在屏幕上会看不到,实际做法是画一条水平粗线。二是画笔要设置成描边模式同时填充实体,如果只设置填充模式,影线会变成两条细线,视觉上很不自然。
3.2 价格映射:Y轴坐标的换算
mapPriceToY是K线图最关键的坐标换算方法。绘制区的高度固定,价格区间是动态的,需要根据当前可见区间的最高价和最低价计算缩放比例:
private float mapPriceToY(float price) { if (mMaxPrice == mMinPrice) return mChartTop; float ratio = (price - mMinPrice) / (mMaxPrice - mMinPrice); return mChartTop + (1f - ratio) * mChartHeight; }这里的mMaxPrice和mMinPrice不是全量数据的最大最小值,而是当前可见区间内的最大最小值。因为如果以全部数据的最大最小值来算,当用户放大到最近几十根K线时,K线上下的波动会被压缩得很小,看起来就像一条直线,没法看。所以每次滑动或缩放结束,都要重新计算可见区间的价格边界。
3.3 十字光标和长按高亮
十字光标是K线交互里非常影响体验的部分,长按屏幕,显示当前位置的十字线,并且显示对应K线的开盘价、收盘价、最高价、最低价、涨跌幅等信息。
实现思路是在onTouchEvent里监听ACTION_DOWN和ACTION_MOVE,记录手指位置,用X坐标反算出当前是第几根K线:
int index = (int) ((touchX - mChartLeft) / (mCandleWidth + mCandleSpacing)); index = Math.max(mStartIndex, Math.min(mEndIndex, index)); // 防止越界然后调用invalidate()触发重绘。在onDraw里,如果当前有十字光标状态,就画十字线、绘制指示框、显示数据详情。十字光标的颜色、线宽、指示框的背景色按App主题风格来定。
手感和性能上有个小技巧:十字光标在移动时,如果每次都导致整个View重绘,K线多的时候还是会有点开销。可以把十字光标层用单独的View或者单独的Canvas图层来实现,这样移动光标时只需要重绘画十字线和数据面板,K线主体不用重绘,体验会顺滑很多。
3.4 均线、布林带和成交量副图
主图的均线用Path来画,把对应均线值依次连起来,注意不要逐点drawLine,而是moveTo和lineTo到一个Path里,最后统一drawPath,性能和效果都好得多。均线Path只需要在startIndex到endIndex范围内构建,不需要全量构建。
布林带的绘制类似,只是多了一步计算上中下轨的数值,然后用两条Path配合一个Paint的Style.FILL,画一个半透明区间。
成交量副图,一般放在主图下方,单独划分一个高度比例的区域。副图也需要独立的Y轴映射,成交量最大值作为区间上限。注意主图和副图之间要留间隙,方便画时间轴和标签。
4. 交互体验与性能优化
K线图能不能用,一半看绘制,一半看交互。这一节聚焦三个点:触摸事件的处理、缩放滑动的实现、性能异常的排查。
4.1 触摸事件:点击、长按和滑动的判别
K线图的触摸交互比较复杂,正常滑动和长按光标是两个不同的操作,要做手势判别。我实践中用的方案是在ACTION_DOWN时记录起点坐标和时间,ACTION_MOVE时判断移动距离是否超过阈值(一般是TouchSlop,大概8dp),超过就判定为滑动模式,否则继续当作长按候选。用Handler发一个延迟任务,500ms后如果还没有滑动,就触发长按模式。一旦进入滑动模式,要取消长按。
滑动本身是通过改变mStartIndex和mEndIndex来实现的,每根K线的宽度固定时,手指滑动一个像素相当于移动若干分之一根K线。为了手感自然,一般是让K线跟随手指实时移动,而不是松手后再跳,这对绘制性能要求比较高,所以才会引出后面的绘制优化。
4.2 双指缩放的最小和最大限制
双指缩放是K线图刚需。实现上要用ScaleGestureDetector,在onScale回调里修改candleWidth,同时调整startIndex和endIndex,让缩放中心点位于双指中心位置对应的K线上。
缩放范围要有边界限制。我项目里设置最小K线宽度为3dp,最大为30dp,这个值可以根据屏宽和单屏显示数量来调整。比如一屏最少显示20根(对应最大宽度),最多显示120根(对应最小宽度),防止用户缩到只能看一根K线或者缩成一堆密集线条导致看不清。
private void updateScale(float scaleFactor) { mCandleWidth *= scaleFactor; if (mCandleWidth < MIN_CANDLE_WIDTH) mCandleWidth = MIN_CANDLE_WIDTH; if (mCandleWidth > MAX_CANDLE_WIDTH) mCandleWidth = MAX_CANDLE_WIDTH; // 重新计算可见区间,保持缩放中心不变 mEndIndex = mStartIndex + (int) (getWidth() / (mCandleWidth + mCandleSpacing)); if (mEndIndex >= mData.size()) mEndIndex = mData.size() - 1; mStartIndex = mEndIndex - (int) (getWidth() / (mCandleWidth + mCandleSpacing)); mStartIndex = Math.max(0, Math.min(mStartIndex, mData.size() - 1)); calculateVisibleMetrics(); // 重新计算可见区间价格边界 invalidate(); }这段代码的关键在于,缩放前后的中心K线要尽量保持不变,不然用户会觉得K线在漂移,体验很差。具体做法是记录双指中心对应的K线下标,缩放后用这个下标的X坐标位置,反推新的startIndex。
4.3 绘制优化:避免卡顿的几条实战经验
K线图的数据量上去了,很容易掉帧,尤其是老的Android机型。这一块踩过的坑比较多,整理几条核心经验:
一是避免在onDraw里做复杂计算。所有能提前算的(均线、指标、坐标映射)都在数据更新时算好,onDraw只做Canvas绘制动作。注意不要在onDraw里new对象,像Paint、Path这些要作为成员变量复用。
二是合理使用invalidate和postInvalidate。UI线程更新用invalidate,子线程更新用postInvalidate。不要动不动就全图重绘,局部区域用invalidate(Rect)效果不佳时,就改为上面说的分层绘制,把十字光标单独一层处理。
三是数据分页加载。几千甚至上万条K线,一次性全部加载进内存,滑动回退时还要从List里取数据,会造成启动慢和内存抖动。项目里我做了分页加载机制:先加载最近200根,滑到左侧边界时,再向历史方向追加200根。这样初始加载速度大幅提升。
四是关闭硬件加速的注意事项。K线图涉及的绘制场景,硬件加速一般建议开启,但如果用到了某些不支持加速的绘制API(比如一些混合模式的PorterDuff),会导致绘制结果异常。遇到绘制异常时,先试着关闭硬件加速排查一下,确认是API兼容性问题还是数据问题。实际项目中,我遇到过一个机型在绘制大量Path时出现花屏,关闭硬件加速后正常,但整体性能下降约15%,最终通过拆分Path解决,没有牺牲加速。
4.4 图表以外的协作:联动滚动和双指缩放
不少行情App里,K线图和下面的成交量图是同一个维度的时间轴。在分层的方案里,需要让主图和副图联动:主图滚动或缩放时,副图的startIndex和endIndex跟着变。这个联动不能通过分别计算实现,容易产生不一致,要抽出一个统一的可见区间管理类,主图和副图共享同一个区间状态,一方的变化通知另一方重绘。这也是为什么我之前强调,可见区间是整个K线图实现的基石。
5. 常见问题与排查技巧实录
最后这块,把实际项目中碰到过的典型问题列一下,方便大家遇到时快速对号入座。
5.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| K线不显示或者显示空白 | 数据为空、坐标映射计算出错(maxPrice和minPrice相等) | 检查数据源,minPrice等于maxPrice时做特殊处理,显示一条直线 |
| 滑动时K线闪烁 | onDraw里创建了新的Paint或Path对象 | 将Paint、Path提取为成员变量复用,避免每次绘制创建对象 |
| 十字光标显示位置不对 | 点击X坐标转index的计算公式不考虑图表左边距 | 计算公式要减去chartLeft的偏移量,同时加上mCandleWidth/2修正 |
| 双指缩放后K线漂移 | 缩放时没有保持缩放中心不变 | 记录缩放中心对应的K线下标,缩放后反推startIndex |
| 大量数据加载卡顿 | 一次性加载全量数据 | 改为分页加载,默认加载最近200根,滑到边界再追加 |
| 行情更新时K线闪动 | 整个View直接invalidate重绘 | 用局部更新,或者只更新最后一根K线的区域 |
5.2 独家排查技巧
不管是用日志还是断点,调试K线图最容易忽略的点是:数据源本身有没有脏数据。有的行情数据接口会返回乱序的数据,或者某根K线的high低于low。这种脏数据画出来光看几秒钟不一定发现问题,但一缩放或者一算指标就各种异常。建议接到数据后,先做一轮数据清洗,把high低于low、open高于high这类异常值直接修正,或者过滤掉。
另外,自定义View层级多了以后,事件分发容易出问题。比如你把十字光标做成了覆盖在最上层的View,这个View必须设置View.OnTouchListener并且返回false,否则它会把触摸事件消费掉,下层K线根本收不到手势。或者更稳妥的做法是,光标层不设点击监听,用setWillNotDraw(false)但把点击区域设成空,让事件穿透到下层。
还有一点,关于真机调试。K线图的交互适配问题,在模拟器上很容易被忽略。模拟器的触摸精度、双指缩放手感和真机有差异,尤其是十字光标的移动平滑度,必须在真机上验证。我在开发阶段都是用一台中低端Android手机作为基准机测试性能,保证基准机流畅了,高端机自然没问题。
做K线图这个项目,前后折腾了将近一个月,回头看的几个转折点都在于对“可见区间”这个概念的理解和运用:数据分页、指标计算、坐标映射、滑动缩放,全部围绕它展开。另外就是要耐得住性子调细节,蜡烛图的1像素实体、十字光标的跟手度、缩放中心的稳定性,每一个细节打磨到位,整个图表的体验才会有质的提升。如果有人后面要做类似的功能,我的建议是:先花两天时间彻底想清楚数据结构,再用一天搭出能滑动缩放的骨架,最后再慢慢打磨样式和交互,不要一上来就陷入细节,会走不少弯路。
本文还有配套的精品资源,点击获取