Android K线图源码拆解:自定义View、坐标映射与手势性能优化
简介这份面向Android开发者的K线图学习示例围绕移动端金融行情图表的绘制与页面组织展开适合具备一定Java和Android基础的开发者参考也可供初学者理解K线、分时图及技术指标在客户端中的实现思路。压缩包共153个文件以Java源码、XML布局与配置、class编译文件为主并配有PNG图片素材、JAR依赖、APK演示程序及工程配置文件RAR包体约4.97MB便于对照源码与运行效果进行拆解。已有217人学习说明其在Android图表入门场景中具有一定参考价值。内容涉及K线视图、网格图表、分时视图、Fragment页面组织以及KDJ指标实体等模块读者可借此梳理行情图表的视图分层、数据计算、界面搭建与模块协作方式并观察演示程序中的实际呈现效果对于希望理解行情数据如何转换为图表元素的学习者也具备较好的借鉴意义可在此基础上开展二次开发或专项练习。1. 一段掉帧的行情页把我推向了这份 android K 线图源码前阵子一个做行情客户端的开发者丢给我一份android_k线图源码包说他们 App 里的 K 线是 WebView 套 H5 图表列表滑到第六十屏就开始掉帧双指缩放还会把整个页面连着一块儿放大。我拆完之后最大的感受是这份代码把 K 线图最容易被写乱的三件事——坐标映射、可视窗口、手势边界——各自关进了独立的类里View只负责往 Canvas 上落笔。它适合两类人一类是手里已经有行情数据接口、只差一个能捏合缩放、能长按看盘的图表控件另一类是接手了别人写的几千行巨型自定义 View想看看正常的分层长什么样。看完至少能搞清楚一根蜡烛从Float数组变成屏幕上那根红柱子中间经过了哪几刀。2. 工程结构与分层K 线图为什么不写成一个巨型自定义 View2.1 目录结构与各包的职责边界拆包第一件事是看目录。示例工程和图表库本体是分开的两个 moduleapp只放一个MainActivity和一份布局所有图表逻辑都在klineview里这样它既能被project依赖也能单独打成 aar 丢给别人用。不同分支的类名会有出入但职责划分基本是这个形状kline/ ├── app/ # 示例工程只负责喂数据 摆 View │ └── src/main/ │ ├── java/.../MainActivity.kt │ └── res/layout/activity_main.xml └── klineview/ # 图表库本体可独立打包 └── src/main/ ├── java/.../kline/ │ ├── KLineView.kt # 对外唯一入口onDraw / onTouchEvent 都在这 │ ├── chart/ │ │ ├── CandleChart.kt # 主图蜡烛 均线 网格 │ │ ├── VolumeChart.kt # 副图成交量柱 │ │ └── IndicatorChart.kt # 副图MACD / KDJ可切换 │ ├── model/ │ │ ├── Candle.kt # 单根 K 线的数据结构 │ │ └── VisibleWindow.kt # 当前看得见哪几根 │ ├── calc/ │ │ ├── PriceScale.kt # 价格区间 → 像素 Y 的映射 │ │ └── IndicatorCalc.kt # MA / EMA / MACD纯函数 │ ├── gesture/ │ │ └── ChartGestureHelper.kt # 缩放、平移、长按的状态机 │ └── attrs/ └── res/values/attrs.xml # 涨跌色、网格行数、副图类型等自定义属性这个切法的好处在改需求的时候特别明显。产品说成交量柱子要加一条 5 日均量线你只需要动VolumeChart和IndicatorCalcKLineView的触摸逻辑一行不用碰反过来说如果所有东西都塞在一个onDraw里加一根均线就得在几百行绘制代码里找位置改完还得把整块重测一遍这种血泪经验估计不少人都有。我更看重的是calc包里全是纯函数这一点。IndicatorCalc不持有任何Context、不碰 Canvas、不读全局变量输入一个FloatArray输出一个FloatArray。这类代码可以直接拷到 JVM 单元测试里跑不用起模拟器。K 线的指标算错一位小数肉眼在图上根本看不出来等用户拿着截图来问你们的 MA5 为什么和别家不一样那就是翻车现场了。2.2 三层数据流数据 → 计算 → 绘制只往一个方向流整个库的数据流是单向的KLineView自己只持有两样东西一份排好序的ListCandle和一个VisibleWindow。屏幕上的每一个像素都是从这两样东西即时推导出来的不缓存任何绘制结果。用户缩放、平移改的只是VisibleWindow里的两个字段然后invalidate()。class KLineView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { // 数据源外部 setData 塞进来内部只读不改 private var candles: ListCandle emptyList() // 可视窗口缩放和平移只改这个对象 private val window VisibleWindow() // 价格映射每次 onDraw 根据当前窗口重算不做跨帧复用 private lateinit var priceScale: PriceScale fun setData(data: ListCandle) { // 行情推送经常乱序或重复入口处统一兜一次底 candles data.sortedBy { it.time }.distinctBy { it.time } window.dataSize candles.size window.endIndex candles.lastIndex.coerceAtLeast(0) invalidate() } fun append(candle: Candle) { // 最后一根时间戳相同就替换这是分时数据最常见的形态 val last candles.lastOrNull() candles if (last ! null last.time candle.time) { candles.dropLast(1) candle } else { candles candle } window.dataSize candles.size window.endIndex candles.lastIndex invalidate() } }setData里那两行sortedBy和distinctBy看着像多余实际是保命的。行情接口在网络抖动时补发历史数据、或者 WebSocket 断线重连后推一坨快照时间戳重复几乎是必然的一旦列表里出现两个相同的time后面所有index的换算全都会偏一根画出来的 K 线就是整体错位一格而且极难从图上反推原因。在入口处花 O(n log n) 排一次序比在onDraw里怀疑人生要划算得多。append这个最后一根时间戳相同就替换的分支也很关键。很多行情源的做法是当前这根还没收盘的 K 线每隔几秒重推一次完整的 OHLC 快照而不是推增量。如果每次都无脑add画十分钟你就能在屏幕上看到几百根一模一样的小蜡烛挤在一起。参数上没什么可调的唯一要注意的是替换时用dropLast(1) candle生成新列表别在ArrayList上直接set不然后面跟 diff 或者做撤销就麻烦了。2.3 把示例跑起来从零到屏幕上出现蜡烛的四步这个过程我一般会按顺序走一遍每一步都能单独验证出问题好定位。第一步把klineview目录整体拷进工程根目录然后在settings.gradle.kts里注册 module// settings.gradle.kts include(:app) include(:klineview) // 目录名和这里保持一致否则 sync 直接报错第二步在app的构建脚本里加依赖。因为是自己工程内的 module不用去配远程仓库// app/build.gradle.kts dependencies { implementation(project(:klineview)) implementation(androidx.core:core-ktx:1.12.0) // 版本按自己工程已有的对齐 }提示如果原工程的 AGP 版本比较旧klineview的build.gradle里compileSdk、minSdk要跟app拉平否则 sync 阶段会提示 variant 不匹配。我一般让两边都读rootProject.ext里的同一份值。第三步布局里摆上控件。可以纯代码 new也可以用 XML后者能直接把配色写进属性com.example.kline.KLineView android:idid/klineView android:layout_widthmatch_parent android:layout_height320dp app:kl_upColor#E5484D app:kl_downColor#12A150 app:kl_gridRows4 app:kl_subChartVOL app:kl_showCrosshairtrue /kl_upColor/kl_downColor就是国内用户的习惯配色红涨绿跌kl_gridRows控制主图横向网格线的条数行数越多价格刻度越密但 4 到 5 条之后再密就糊了kl_subChart决定副图默认显示成交量还是 MACD。第四步在Activity里喂数据。示例工程里是造了一组随机游走的假数据接真实接口时把生成逻辑替换掉就行class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val kLineView findViewByIdKLineView(R.id.klineView) kLineView.setData(loadCandles()) // 必须先有数据再触发绘制 kLineView.setOnCrosshairListener { candle - // 手指按住时回调用来刷新顶部那排开高低收文字 updateQuoteBar(candle) } } private fun loadCandles(): ListCandle generateMockCandles(300) }跑起来用一条命令就够./gradlew :app:installDebug装到机器上之后屏幕上应该是 300 根蜡烛里最靠右的 80 根能双指捏合缩放、单指左右拖、长按出十字线。如果只看到一块纯色背景八成是setData传了空列表或者传进去的high小于low——这类脏数据会让价格区间的最大值和最小值倒挂PriceScale算出负高度drawRect直接画到屏幕外面去了。3. 绘制链路拆解从一根蜡烛到整屏像素的坐标映射3.1 Candle 与 VisibleWindow先把画哪几根确定下来Candle这个数据类看着平平无奇但里面有两个方法值得单独拎出来说因为它们决定了后面绘制代码的判断分支data class Candle( val time: Long, // 秒级时间戳排序和横轴刻度的唯一依据 val open: Float, val high: Float, val low: Float, val close: Float, val volume: Float ) { // 涨跌只认当天开收盘不要拿 close 和上一根的 close 比 val isUp: Boolean get() close open val bodyTop: Float get() maxOf(open, close) val bodyBottom: Float get() minOf(open, close) val bodyHeight: Float get() bodyTop - bodyBottom }isUp用close open而不是和上一根的收盘价比这是 A 股和港股的通用约定也是绝大多数行情软件的做法。用和昨收比看着更符合直觉但一到除权除息日昨收被调整过整屏颜色会集体反一次用户第一反应就是你们数据错了。这个坑我在别的项目里踩过改回来的时候被测试追着问了两天。bodyTop/bodyBottom的作用是让绘制代码不用再到处写if (open close)。K 线实体永远是从min(open, close)画到max(open, close)只有颜色区分涨跌方向在几何上没有区别。把这两个值提前算好onDraw里就只剩纯粹的坐标变换。真正决定画哪几根的是VisibleWindow它是整个库最核心的一个对象class VisibleWindow { var dataSize 0 // 总共有多少根 var visibleCount 80 // 屏幕能放下多少根 var endIndex 0 // 窗口最右一根含的下标 var candleWidthPx 6f // 蜡烛实体宽度px缩放改的就是它 var gapRatio 0.35f // 间距占实体宽度的比例 // 最左一根的下标永远不小于 0防止越界 val startIndex: Int get() (endIndex - visibleCount 1).coerceAtLeast(0) // 相邻两根蜡烛中心的水平距离绘制和手势换算共用这一份 val stepWidth: Float get() candleWidthPx * (1 gapRatio) }stepWidth这个派生属性是全篇最值得抄的一行。绘制时用它算每根蜡烛的中心 X手势里用它把手指位移换算成移动了几根刻度绘制时用它决定横轴标签的疏密。如果这几个地方各算各的改一次间距比例就得改三处漏掉一处就是那种缩放之后十字线跟蜡烛差半个身位的玄学 bug。3.2 价格到 Y 轴的映射以及蜡烛的落笔过程Y 轴映射单独抽成了一个PriceScale因为它是纯计算和 View 生命周期完全无关class PriceScale(private val maxPrice: Float, private val minPrice: Float) { // 价格 → 屏幕 Y。Android 的 Y 轴朝下所以价格越高 Y 越小 fun y(price: Float, top: Float, bottom: Float): Float { val span (maxPrice - minPrice).takeIf { it 1e-6f } ?: 1e-6f val ratio (price - minPrice) / span return bottom - ratio * (bottom - top) } // 屏幕 Y → 价格给网格刻度文字和十字线读数用 fun priceAt(y: Float, top: Float, bottom: Float): Float { val span (maxPrice - minPrice).takeIf { it 1e-6f } ?: 1e-6f return minPrice (bottom - y) / (bottom - top) * span } }span那个takeIf是防止一整段数据全是同一个价格停牌、或者只有一根 K 线时除零。除零在 Kotlin 的Float上不会抛异常而是得到Infinity或NaNdrawRect拿到NaN会静默什么都不画表现出来就是数据明明有屏幕上空的排查起来极其难受。区间怎么取也有讲究我用的是当前窗口内所有蜡烛的 high 和 low上下再各留 8% 的余量private fun buildScale(): PriceScale { var max -Float.MAX_VALUE var min Float.MAX_VALUE for (i in window.startIndex..window.endIndex) { val c candles[i] if (c.high max) max c.high if (c.low min) min c.low } // 上下各留 8%否则最高价那根会正好贴着顶边视觉上像被切了一刀 val pad (max - min) * 0.08f return PriceScale(max pad, min - pad) }这里只遍历可见区间不是整份数据。300 根还好说有些股票日线有上万根每帧遍历全量就是白送的性能开销。缩放和平移改变startIndex/endIndex之后重算一次这个区间成本可以忽略。真正落笔的绘制函数长这样private fun drawCandles(canvas: Canvas) { val top paddingTop val bottom mainChartBottom val half window.candleWidthPx / 2f for (i in window.startIndex..window.endIndex) { val c candles[i] // 取每根的中心点0.5f 让第一根和左边缘留出半个身位 val cx contentLeft (i - window.startIndex 0.5f) * window.stepWidth val linePaint if (c.isUp) upLinePaint else downLinePaint val bodyPaint if (c.isUp) upBodyPaint else downBodyPaint // 影线一根竖线贯穿 high 到 low canvas.drawLine( cx, priceScale.y(c.high, top, bottom), cx, priceScale.y(c.low, top, bottom), linePaint ) val yOpen priceScale.y(c.open, top, bottom) val yClose priceScale.y(c.close, top, bottom) val bodyTop minOf(yOpen, yClose) val bodyH abs(yClose - yOpen) if (bodyH 1f) { // 一字板开收相等实体退化成一条横线不画就会少一根 canvas.drawLine(cx - half, yOpen, cx half, yOpen, linePaint) } else { canvas.drawRect(cx - half, bodyTop, cx half, bodyTop bodyH, bodyPaint) } } }linePaint和bodyPaint是两套不同的Paint影线用STROKE且strokeWidth给 1dp实体用FILL。有人图省事用同一个Paint靠每次绘制前改style来切换结果是每根蜡烛切换两次模式80 根就是 160 次状态变更在低端机上这笔账会体现在掉帧上。bodyH 1f这个分支是一定要有的。涨停一字板、停牌后复牌第一根这些情况下open close矩形的上下边重合drawRect画出来是零高度肉眼什么都看不到。而 K 线的语义是这一天存在过少了这根后面的横轴刻度、指标对齐全都会错位一根。我见过有的实现干脆把实体高度强行coerceAtLeast(1f)也能显示但会把十字线的价格读数带偏——还是画一条横线更老实。3.3 网格、坐标轴与文字对齐的细节网格线本身没什么技术含量麻烦的是刻度文字的位置private fun drawGridAndLabels(canvas: Canvas) { val rows gridRows // 来自 attrs默认 4 val rowH (mainChartBottom - paddingTop) / rows for (i in 0..rows) { val y paddingTop rowH * i // 网格线压在蜡烛下面所以先画网格再画蜡烛 canvas.drawLine(contentLeft, y, contentRight, y, gridPaint) // 刻度值取这根线对应的价格而不是均分值避免出现 12.3333 val price priceScale.priceAt(y, paddingTop, mainChartBottom) val text priceFormatter.format(price) // drawText 的 y 是文字基线不是文字底部所以要减去一个字高左右 val baseline (y - labelPaint.textSize * 0.35f) .coerceIn(paddingTop labelPaint.textSize, mainChartBottom) canvas.drawText(text, contentLeft 4f, baseline, labelPaint) } }两个细节第一drawText的 y 参数是基线位置不是文字框顶部直接传y会让文字整体偏下看起来像贴在了网格线上。减掉textSize * 0.35是个经验值不同字体略有差异。第二最上面和最下面那两行刻度要coerceIn夹一下否则文字会画到 padding 区域外面被父容器裁掉半截。绘制顺序也要留意网格 → 蜡烛 → 均线 → 十字光标 → 浮层信息。网格必须在蜡烛下面不然价格线会横穿蜡烛实体看起来像画坏了十字光标必须在最上面不然被蜡烛盖住就看不见了。这个顺序在onDraw里从上到下一目了然拆成多个drawXxx(canvas)方法之后反而容易搞混我一般会在onDraw顶部写一行注释把顺序固定下来。4. 交互与刷新双指缩放、惯性平移、十字光标与指标副图的联动4.1 双指缩放锚点要绑在手指上不是屏幕中心缩放的实现有两种常见做法一种是改visibleCount显示多少根一种是改candleWidthPx每根多宽。这份源码选的是后者前者由contentWidth / stepWidth反推出来。好处是蜡烛宽度是用户能直接感知的量最小宽度和最大宽度有明确的物理意义。真正容易做错的是缩放锚点。以屏幕中心为锚点缩放用户捏住右下角想放大那里结果画面围绕中心展开手指下的那根 K 线跑没影了体验非常别扭。正确做法是记住焦点落在第几根蜡烛上缩放前后让这根蜡烛的屏幕 X 保持不变private val scaleDetector ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { val focusX detector.focusX if (focusX contentLeft || focusX contentRight) return false // 缩放前焦点对着第几根 val anchorIndex indexAt(focusX) // 焦点在这根蜡烛内部的偏移比例0 表示正中心越界会被 clamp 到 [-0.5, 0.5] val fraction (focusX - xOf(anchorIndex)) / window.stepWidth val next (window.candleWidthPx * detector.scaleFactor) .coerceIn(minCandleWidthPx, maxCandleWidthPx) if (next window.candleWidthPx) return true window.candleWidthPx next // 缩放后反推 endIndex让 anchorIndex 仍然停在 focusX 上 val offsetPx (window.endIndex - anchorIndex fraction) * window.stepWidth window.visibleCount (contentWidth / window.stepWidth).toInt().coerceAtLeast(10) window.endIndex anchorIndex (offsetPx / window.stepWidth - fraction).toInt() clampWindow() invalidate() return true } })fraction那一项是蜡烛内部偏移没有它的话两根蜡烛之间按下再缩放画面会跳一下手感像卡了一帧。minCandleWidthPx一般给 3dp 左右——再窄蜡烛就糊成一片了maxCandleWidthPx给到 40dp 左右宽到能看清实体中间的空隙就够了。这两个值我建议直接暴露成自定义属性因为不同产品对最密能密到什么程度的要求差别挺大做成硬编码迟早要改。clampWindow()是必须调用的它负责把endIndex限制在[visibleCount - 1, dataSize - 1]区间内。少了这一步用户捏合几次就能把窗口推到数据范围外面屏幕上出现大片留白看起来像加载失败。4.2 惯性平移与越界处理别让用户把图表拖出银河系单指平移用GestureDetector的onScroll就够位移除以stepWidth得到移动了几根累加到endIndex上。真正需要额外处理的是抬手之后的惯性private val scroller OverScroller(context) override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean { // 把每秒多少像素换算成每秒多少根蜡烛再乘回像素域交给 Scroller val unit window.stepWidth val maxOffset ((window.dataSize - window.visibleCount) * unit).toInt().coerceAtLeast(0) scroller.fling( /* startX */ 0, /* startY */ 0, /* velocityX */ (-vx).toInt(), /* velocityY */ 0, /* minX */ 0, /* maxX */ maxOffset, /* minY */ 0, /* maxY */ 0 ) postInvalidateOnAnimation() return true } override fun computeScroll() { if (scroller.computeScrollOffset()) { val movedPx scroller.currX - lastScrollPx lastScrollPx scroller.currX // 像素位移换回根数注意这里是连续值先累积到浮点再取整 window.endIndex - (movedPx / window.stepWidth).toInt() clampWindow() invalidate() postInvalidateOnAnimation() } }OverScroller的边界我直接交给它自己的minX/maxX处理这样滚到最左或者最右时它有内建的减速不会硬生生截断。省掉这层的话用户快速一甩endIndex直接被clampWindow摁住画面会啪一下停死手感非常廉价。注意computeScroll()里一定要用postInvalidateOnAnimation()不要用invalidate()。前者会挂到下一个 vsync后者在某些机型上会连发多次重绘滚动过程中反而更耗电。还有一个坑是父容器的滑动冲突。如果 K 线控件放在NestedScrollView或者ViewPager2里横向拖动会被父容器截走表现是图表能拖一点点然后整个页面开始翻页。标准解法是在onTouchEvent的ACTION_DOWN里调用parent.requestDisallowInterceptTouchEvent(true)在ACTION_UP/ACTION_CANCEL里再放开override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - parent?.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL - parent?.requestDisallowInterceptTouchEvent(false) } scaleDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event) return true }4.3 长按十字光标吸附到蜡烛中心读数才不会跳十字光标看着简单两根线加一个浮层但要做到能看盘的程度得处理三件事吸附、边界、联动。吸附是指 X 坐标不要跟着手指像素走而是吸到最近一根蜡烛的中心private fun drawCrosshair(canvas: Canvas) { val index indexAt(crosshairX).coerceIn(window.startIndex, window.endIndex) // 吸附到蜡烛中心手指在两根之间时读数不会来回跳 val snappedX xOf(index) val y crosshairY.coerceIn(paddingTop, subChartBottom) canvas.drawLine(snappedX, paddingTop, snappedX, subChartBottom, crossPaint) canvas.drawLine(contentLeft, y, contentRight, y, crossPaint) // 右侧价格标签跟着 Y 走顶部时间标签跟着吸附后的 X 走 drawPriceTag(canvas, y) drawTimeTag(canvas, snappedX, candles[index].time) // 所有副图共用同一个 index同步刷新 volumeChart.onCrosshairMoved(index) indicatorChart.onCrosshairMoved(index) onCrosshairListener?.invoke(candles[index]) }indexAt和xOf必须互为反函数两者都用window.stepWidth和startIndex推导private fun indexAt(x: Float): Int ((x - contentLeft) / window.stepWidth).toInt() window.startIndex private fun xOf(index: Int): Float contentLeft (index - window.startIndex 0.5f) * window.stepWidth联动这件事我见过不少实现是每个副图自己维护一份当前选中下标结果主图的十字线指着一根大阳线副图的成交量柱却对在隔壁。统一由一个index派发下去是最省事的做法也方便把顶部那排开高低收、涨跌幅、成交量一起更新。4.4 指标计算与副图刷新纯函数 版本号缓存指标这块的核心原则是算一次就该缓存住每帧重算是性能黑洞。MA 用滑动窗口一个循环搞定object IndicatorCalc { fun ma(closes: FloatArray, period: Int): FloatArray { val out FloatArray(closes.size) { Float.NaN } var sum 0f for (i in closes.indices) { sum closes[i] if (i period) sum - closes[i - period] // 滑出窗口 if (i period - 1) out[i] sum / period // 凑够 period 个才开始输出 } return out } fun ema(values: FloatArray, period: Int): FloatArray { val k 2f / (period 1) val out FloatArray(values.size) var prev values.firstOrNull() ?: 0f for (i in values.indices) { prev if (i 0) values[0] else values[i] * k prev * (1 - k) out[i] prev } return out } // DIF EMA12 - EMA26DEA EMA(DIF, 9)柱 (DIF - DEA) * 2 fun macd(closes: FloatArray): TripleFloatArray, FloatArray, FloatArray { val e12 ema(closes, 12) val e26 ema(closes, 26) val dif FloatArray(closes.size) { e12[it] - e26[it] } val dea ema(dif, 9) val bar FloatArray(closes.size) { (dif[it] - dea[it]) * 2f } return Triple(dif, dea, bar) } }out初始化成Float.NaN而不是 0 很重要。前period - 1根没有均线值画成 0 会在图上拉出一条从 0 开始的斜线特别显眼用NaN的话绘制时判断isNaN()直接跳过线段自然从第一个有效点开始。Path那条线也要记得moveTo到第一个有效点而不是从 (0, 0) 开始lineTo。缓存用一个带版本号的Mapclass IndicatorCache { private val cache HashMapString, FloatArray() private var version 0 fun getOrCompute(key: String, producer: () - FloatArray): FloatArray { val realKey $version:$key return cache.getOrPut(realKey) { producer() } } // 追加新 K 线时保留缓存整体换数据切标的、切周期才清空 fun onDataChanged(appendOnly: Boolean) { if (!appendOnly) cache.clear() version } }这个写法是简化版正式用的时候要留个清理策略——版本号一直涨cache里的旧 key 不会自己消失跑一天下来内存里会攒一堆废数组。我一般的做法是只保留当前版本和上一个版本的条目onDataChanged里顺手把version - 1开头的 key 全删掉。至于追加新 K 线这种情况严格说 MA 只需要重算最后几根但从工程成本看几百根的数组整个重算也就微秒级不值得为它写增量逻辑。真有上万根的需求再上增量也不迟。5. 常见问题排查数据错位、首帧空白和滑动掉帧的五个坑5.1 数据与绘制这一类问题多半出在入口坑一打开界面一片空白日志里啥也没有。现象是setData明明传了 300 条数据屏幕上只有背景色网格、坐标轴都没画出来。原因通常是时机问题KLineView在onCreate里就setData此时View还没经过onMeasurewidth和height都是 0contentWidth算出来是负数visibleCount被coerceAtLeast(10)兜住但stepWidth乘出来是负的所有 X 坐标全画到屏幕左边外面去了。解决方式是所有依赖宽高的计算都推迟到onSizeChanged之后做setData里只存数据、标记脏位真正算窗口放到onSizeChanged和onDraw的开头override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) window.visibleCount (contentWidth / window.stepWidth).toInt().coerceAtLeast(10) clampWindow() }坑二K 线整体偏了半根十字线读数永远差一格。现象是十字线指着的价格标签和蜡烛的位置对不上或者 K 线图上最左边永远露出半根被裁掉的蜡烛。原因基本是把蜡烛中心和蜡烛左边缘混用了drawCandles里用的是(i - startIndex 0.5f) * stepWidthindexAt里却写成(x - contentLeft) / stepWidth startIndex少减了那半个身位。这种偏移在小stepWidth下只有几个像素肉眼很难发现等用户把图放大到 40dp 一根偏差就变成 20 个像素一眼就能看出来。解决办法是只保留xOf(index)和indexAt(x)这一对函数绘制代码里所有 X 坐标都调xOf绝不在别处重新推导。坑三涨停一字板消失横轴刻度和实际数据差了几天。现象是图上某几天的 K 线看不到仔细数会发现横轴的时间戳跳了。原因就是前面提到的open close时实体高度为 0drawRect什么也不画。更隐蔽的是如果只画了影线没画那条退化横线影线本身是从high到low的一条竖线当high low open close一字板就是四个价相等时这条竖线高度也是 0整根 K 线彻底隐形。解决就是在bodyH 1f时补一条横线长度取candleWidthPx。5.2 交互与性能这一类问题多半出在循环里坑四滑动的时候一顿一顿Choreographer报掉帧。现象是手指拖动时画面能跟手但一松手惯性滚动就明显卡低端机上更严重。原因通常是onDraw里干了不该干的事每次重绘都new Paint()、每次都重新跑一遍 MA 计算、每次都生成新的Path。80 根蜡烛 × 每根一个Path一帧就是 80 次对象分配GC 一介入立刻掉帧。解决办法是把Paint全部提成成员变量在init里初始化Path复用一个对象、用reset()而不是new指标结果走缓存。经验上这三处改完中端机的onDraw耗时会从 8ms 降到 1ms 以内。坑五长按十字线以后列表滑不动了。现象是把图表放在RecyclerView或NestedScrollView里长按出十字线之后竖向滚动图表外面的列表没反应必须先点一下别处。原因是在ACTION_DOWN里无条件调了requestDisallowInterceptTouchEvent(true)却没有在合适的时候放开。正确的做法是只在确认是横向拖动或缩放时才拦截长按和点击不拦override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN - // 双指按下一定要接管 parent?.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL - parent?.requestDisallowInterceptTouchEvent(false) } return super.onTouchEvent(event) }单指的情况我一般放在onScroll里判断横向位移大于纵向位移的 1.5 倍才requestDisallowInterceptTouchEvent(true)否则把手势还给父容器。这样竖着划列表、横着划图表互不打架。6. 二次开发把 K 线图接到自己的行情源上我一般从这三处下手拿到这份源码之后绝大多数人的下一步是接自己公司的行情接口而不是继续用假数据。改造的落点其实很集中我按优先级列一下改造点落点验证方式数据源对接KLineView.setData/append的调用方断网重连后推一坨乱序快照看是否有重复蜡烛涨跌色与配色attrs.xml里的kl_upColor/kl_downColor切到港股看是否要改成绿涨红跌新增指标IndicatorCalc加纯函数 IndicatorChart画拿一段已知结果的收盘价序列跑单测数据对接这块最常见的麻烦是周期切换。日线、周线、60 分钟线很多接口是分三个 endpoint 提供的切周期时要先把旧数据清掉再setData否则指标缓存里还留着上一个周期的 MA画出来的均线前几十根是空的。我在setData里加了个判断如果新数据的第一个时间戳和旧数据的重合度不到一半就当作换标的或换周期处理连带清空缓存。指标扩展是第二个高频需求。加 KDJ 或者 RSI 的时候不要去改IndicatorChart的绘制逻辑先在IndicatorCalc里写一个纯函数输入FloatArray输出FloatArray然后在 JVM 单元测试里拿手工算好的几组值验一遍。这一步千万别省——KDJ 里RSV的分母是high - low的 N 日极值停牌期间这个值是 0不处理就是一个NaN顺着 EMA 传染整条线图上一段空白你还得回头怀疑是绘制的问题。fun kdj( highs: FloatArray, lows: FloatArray, closes: FloatArray, n: Int 9, m1: Int 3, m2: Int 3 ): TripleFloatArray, FloatArray, FloatArray { val rsv FloatArray(closes.size) { Float.NaN } for (i in closes.indices) { if (i n - 1) continue var hh -Float.MAX_VALUE var p a hrefhttps://download.csdn.net/download/u010688919/9177737 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p