在iOS上开发股票、期货或加密货币行情页面时,K线图和下方的技术指标区域几乎是绕不开的模块。UIKit本身没有提供现成的金融图表控件,第三方库功能虽全但定制空间有限,很多团队最终选择用Core Graphics自绘。这篇文章就来完整讲解如何用Core Graphics计算并绘制MACD、KDJ、RSI、BOLL四大经典技术指标,涵盖算法推导、坐标映射、性能优化等实战要点。

一、技术指标的计算原理与Swift实现
绘制之前必须先把指标算对,否则图画得再漂亮也是错的。四个指标里最基础的概念是EMA(指数移动平均),MACD和BOLL都依赖它。EMA的特点是对近期价格赋予更高权重,公式为:当日EMA = 前一日EMA × (1 - 平滑系数) + 当日收盘价 × 平滑系数,其中平滑系数 = 2 / (N + 1)。理解了这个递推关系,后面的计算就水到渠成。
MACD由三部分组成:DIF线、DEA线和MACD柱。DIF是12日EMA减去26日EMA,DEA是DIF的9日EMA,MACD柱为2倍(DIF - DEA)。需要注意的是第一个EMA值通常用收盘价直接初始化,之后逐日递推。KDJ则基于最高价、最低价与收盘价的关系,先算未成熟随机值RSV = (C - L9) / (H9 - L9) × 100,再对RSV做三次指数平滑得到K、D、J值。RSI衡量一段时间内上涨幅度占总波动的比例,常用周期为6、12、24。BOLL最简单,中轨是N日简单均线,上下轨分别是中轨加减2倍标准差。
struct Candle {
let open: Double, high: Double, low: Double, close: Double
}
// 计算EMA数组,首次值用首日收盘价初始化
func ema(_ values: [Double], period: Int) -> [Double] {
guard !values.isEmpty else { return [] }
let alpha = 2.0 / Double(period + 1)
var result = [Double](repeating: 0, count: values.count)
result[0] = values[0]
for i in 1..<values.count {
result[i] = alpha * values[i] + (1 - alpha) * result[i - 1]
}
return result
}
// MACD:返回DIF、DEA、柱状值
func macd(_ candles: [Candle]) -> ([Double], [Double], [Double]) {
let closes = candles.map { $0.close }
let ema12 = ema(closes, period: 12)
let ema26 = ema(closes, period: 26)
let dif = zip(ema12, ema26).map { $0 - $1 }
let dea = ema(dif, period: 9)
let bar = zip(dif, dea).map { ($0 - $1) * 2 }
return (dif, dea, bar)
}
// KDJ,初始K=D=50
func kdj(_ candles: [Candle], period: Int = 9) -> ([Double], [Double], [Double]) {
var ks = [Double](), ds = [Double](), js = [Double]()
var k = 50.0, d = 50.0
for i in 0..<candles.count {
let start = max(0, i - period + 1)
let window = candles[start...i]
let hn = window.map { $0.high }.max() ?? 0
let ln = window.map { $0.low }.min() ?? 0
let rsv = hn == ln ? 50 : (candles[i].close - ln) / (hn - ln) * 100
k = 2.0 / 3 * k + 1.0 / 3 * rsv
d = 2.0 / 3 * d + 1.0 / 3 * k
let j = 3 * k - 2 * d
ks.append(k); ds.append(d); js.append(j)
}
return (ks, ds, js)
}
// RSI
func rsi(_ candles: [Candle], period: Int) -> [Double] {
var result = [Double]()
var gain = 0.0, loss = 0.0
for i in 0..<candles.count {
if i == 0 { result.append(50); continue }
let diff = candles[i].close - candles[i - 1].close
let up = max(diff, 0), down = max(-diff, 0)
// 采用Wilder平滑,等价于递推的EMA
gain = (gain * Double(period - 1) + up) / Double(period)
loss = (loss * Double(period - 1) + down) / Double(period)
result.append(loss == 0 ? 100 : 100 - 100 / (1 + gain / loss))
}
return result
}
// BOLL:中轨、上轨、下轨
func boll(_ candles: [Candle], period: Int = 20, multiplier: Double = 2) -> ([Double]?, [Double]?, [Double]?) {
let closes = candles.map { $0.close }
guard closes.count >= period else { return (nil, nil, nil) }
var mid = [Double](), up = [Double](), low = [Double]()
for i in (period - 1)..<closes.count {
let window = closes[(i - period + 1)...i]
let avg = window.reduce(0, +) / Double(period)
let variance = window.reduce(0) { $0 + ($1 - avg) * ($1 - avg) } / Double(period)
let sd = variance.squareRoot()
mid.append(avg)
up.append(avg + multiplier * sd)
low.append(avg - multiplier * sd)
}
return (mid, up, low)
}这里有几个容易踩的坑要提醒一下。第一,不同数据源的指标值可能有细微差异,原因在于EMA初始值的处理方式不同,有的用首日收盘价,有的用前N日均值,对接后端数据时要统一口径。第二,RSI常见实现有Cutler简单平均和Wilder平滑两种,主流行情软件用后者,上面代码采用的是Wilder算法。第三,数据量不足period时BOLL返回nil,绘制时要跳过这些空点,避免画出错位的线段。
二、Core Graphics绘制架构与坐标映射
计算完成后,接下来的核心问题是把数值空间映射到屏幕像素。K线图通常分为主图区和副图区:主图绘制K线蜡烛和BOLL轨道,副图绘制MACD、KDJ或RSI。推荐用单个UIView子类承载整个图表,在draw(_:)方法里通过UIGraphicsGetCurrentContext()拿到上下文进行绘制,也可以拆成主图View加多个副图View,方便手势联动时分别重绘。
坐标映射的关键是求出可见区域内的最大值和最小值,再按比例换算成view坐标。注意Core Graphics的坐标系原点在左下角,而UIView的绘制上下文默认已经被UIKit翻转过,原点在左上角,所以y值越大越靠下。如果直接在draw(_:)中绘制,可以直接按"y = height - (value - min) / (max - min) * height"计算,或者对context做一次ctm.scaleBy(x: 1, y: -1)翻转后按数学坐标画。两种方式都可以,但一定要统一,否则坐标全反。
struct PlotRange {
var minValue: Double
var maxValue: Double
var height: CGFloat
func y(for value: Double) -> CGFloat {
let span = max(maxValue - minValue, 0.0001)
let ratio = (value - minValue) / span
return height - CGFloat(ratio) * height
}
}
// 绘制MACD柱状图示例
func drawMACDBars(_ bars: [Double], context: CGContext,
plotRange: PlotRange, startX: CGFloat, barWidth: CGFloat) {
let zeroY = plotRange.y(for: 0)
for (i, value) in bars.enumerated() {
let x = startX + CGFloat(i) * barWidth
let y = plotRange.y(for: value)
let rect = CGRect(x: x, y: min(y, zeroY),
width: barWidth * 0.7,
height: abs(zeroY - y))
// 红涨绿跌或绿涨红跌按产品要求配置
context.setFillColor(value >= 0 ? UIColor.red.cgColor : UIColor.green.cgColor)
context.fill(rect)
}
}
// 绘制折线(DEA/DIF/K/D/RSI等)
func drawLine(_ values: [Double], color: UIColor, context: CGContext,
plotRange: PlotRange, startX: CGFloat, barWidth: CGFloat) {
guard values.count > 1 else { return }
let path = UIBezierPath()
for (i, v) in values.enumerated() {
let point = CGPoint(x: startX + (CGFloat(i) + 0.5) * barWidth,
y: plotRange.y(for: v))
i == 0 ? path.move(to: point) : path.addLine(to: point)
}
context.setStrokeColor(color.cgColor)
context.setLineWidth(1)
context.addPath(path.cgPath)
context.strokePath()
}像素对齐是新手最容易忽略的细节。当线宽为1时,如果线画在整数坐标上,实际会落在两个像素的交界处,系统会把它绘制成两个半透明的像素,看起来发虚。解决办法是在计算x坐标时加上0.5做半像素偏移,或者将坐标用floor后加0.5。另外建议开启抗锯齿并根据Retina屏幕倍数设置线宽,必要时可以调用context.setAllowsAntialiasing(true)和context.setShouldAntialias(true)确保线条平滑。
三、长分时数据下的性能优化实践
当K线数量达到几万根时,如果每次滑动都全量重绘所有指标,帧率会明显下降。优化思路主要有三个层面。首先是数据层面的降采样:屏幕上可视区域通常只有一两百根蜡烛,只需计算并绘制可视范围内的数据,指标计算结果可以缓存到数组中,新数据到来时增量计算EMA这类递推指标,避免从头重算。
其次是绘制层面的裁剪。Core Graphics本身不会智能跳过屏幕外的绘制,需要在循环里判断点的x坐标是否超出bounds,或者用context.clip(to:)先裁剪绘制区域。对于MACD柱这类矩形填充,直接跳过屏幕外的索引即可。线条类指标则要额外多画可视区左右两侧各一个点,保证边缘线段完整。
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
// 只绘制可视范围内的数据,左右各多取一根保证边缘完整
let visibleRange = calculateVisibleIndexRange(padding: 1)
let slice = Array(candles[visibleRange])
let (dif, dea, bar) = cachedMACD(in: visibleRange)
ctx.saveGState()
ctx.clip(to: plotAreaRect) // 限制绘制区域
drawMACDBars(bar, context: ctx, plotRange: range,
startX: 0, barWidth: barWidth)
drawLine(dif, color: .white, context: ctx,
plotRange: range, startX: 0, barWidth: barWidth)
drawLine(dea, color: .yellow, context: ctx,
plotRange: range, startX: 0, barWidth: barWidth)
ctx.restoreGState()
}最后是渲染层面的选择。当用户频繁捏合缩放时,可以改用CADisplayLink配合setNeedsDisplay节流,把多次重绘请求合并到一帧内处理。对性能要求极高的场景(如全屏拖动几百个指标点),可以考虑用CAShapeLayer替代直接绘制,把线条生成和光栅化交给独立图层,还能天然获得离屏渲染的优势。不过CAShapeLayer在数据量极大时路径重建成本也不低,两者可以结合使用:蜡烛和柱状图用CGContext直接画,折线用CAShapeLayer缓存。
总结一下,做一套自绘的K线技术指标,难点不在Core Graphics的API本身,而在于三件事:指标算法与服务端口径一致、坐标映射和像素对齐处理正确、大数据量下的增量计算与局部重绘。把这三块做好,再复杂的指标都只是公式翻译问题,后续扩展CCI、EXPMA等指标时复用同一套绘制框架即可。
Core GraphicsK线图MACD指标修改时间:2026-09-06 17:20:51