导读:本期聚焦于夏天宇创作的《iOS如何用Core Graphics绘制MACD、KDJ、RSI、BOLL技术指标?K线图开发完整指南》,敬请观看详情。股票和加密货币行情类App里,K线图几乎是必备组件,但系统并没有现成控件可以直接画出MACD、KDJ、RSI、BOLL这些常用技术指标。本文基于Core Graphics框架,从指标算法原理入手,先推导MACD的EMA平滑、DIF与DEA的计算公式,再分析KDJ的随机波动逻辑、RSI的相对强弱算法以及BOLL布林带的均线与标准差计算,最后给出完整的绘制实现思路,包括坐标映射、像素对齐、双缓冲与内存性能优化。文中代码全部使用Swift编写,可直接集成到自定义K线图控件中,适合正在开发行情图表的iOS工程师参考。

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

iOS如何用Core Graphics绘制MACD、KDJ、RSI、BOLL技术指标?K线图开发完整指南

一、技术指标的计算原理与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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260906/51684.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。