世上K线大师有两个,一个是特朗普,另一个是你

44 阅读10分钟

世上K线大师有两个,一个是特朗普,另一个是你

特朗普发条推特,纳指跌 5%;再发一条,道指涨 3%。 这哥们不炒股——他就是 K 线

而作为一个 iOS 工程师,你连个像样的 K 线图都没写过, 凭什么说自己懂金融科技?

本文手把手带你读懂并实现 EFStockChart—— 一个从零开始、仿照东方财富的 Swift K 线图库。 GitHub 地址在文末,先收藏,后细看。


一、痛点:你不是第一个在 K 线图上翻车的人

产品经理端着咖啡,笑眯眯地走过来:

"这个 K 线图能不能做得和东方财富一样? 要能滑动,要有 MACD、KDJ、RSI, 最好还能实时推送,对了,还要有盘口……"

你面不改色,内心已经开始崩溃。

翻了一圈 GitHub——

  • 有的是五年前的 ObjC 老古董,连 Swift 都没有;
  • 有的用 UIScrollView 硬撑,一快速滑动就掉帧,K 线变幻灯片;
  • 有的文档一行没有,Issues 全是"求救"和"已放弃";
  • 有的画出来的蜡烛,颜色逻辑写反了,涨的是绿色跌的是红色……(这不是 A 股,谢谢)

与其找一个凑合用的,不如自己写一个让别人来抄的。

这就是 EFStockChart 的诞生背景。


二、效果:先看图,再谈原理

功能全家桶,一次列清楚:

📈 主图能力

模式说明
分时图个股(右侧五档盘口)/ 指数(涨跌家数内嵌柱)
五日分时连续 5 个交易日,共 1200 个分时点
K 线图日 / 周 / 月 / 季 / 年 / 1分 / 5分 / 15分 / 30分 / 60分 / 120分

📊 副图能力(最多 4 个,可插拔)

  • MACD(12,26,9):DIF 线 + DEA 线 + 柱状图(Bar = (DIF-DEA)×2)
  • KDJ(9,3,3):K / D / J 三条线,20/50/80 参考线
  • RSI(三线):RSI6 白 / RSI12 黄 / RSI24 紫,30/70 超买超卖线
  • 成交量:多空柱 + MA5 / MA10 均量线

🎯 交互能力

  • 惯性动量滚动:松手后继续滑行,物理减速,不是"嘎"的一声停死
  • 捏合缩放:蜡烛宽度 2-48pt 无级调节,缩放时自动右对齐
  • 十字线 + Tooltip:长按呼出,显示 OHLCV 全量数据,3 秒无操作自动隐藏
  • 周期切换栏:分时 / 五日 / 日K / 周K / 月K + 更多(展开 8 个周期)
  • 分页加载:滑到左边缘触发 delegate,通知业务层拉更早的历史数据

⚡ 实时推送

EFRealtimeSimulator 封装了实时分时的状态机,开箱即用:

let sim = EFRealtimeSimulator(prevClose: 1465.02)
chartView.loadTimeline(sim.initialData(count: 30))  // 前 30 分钟历史快照

Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    guard let pt = sim.nextPoint() else { return }  // 240 分钟后自动返回 nil
    chartView.appendTimelinePoints([pt])
    chartView.updateOrderBook(sim.makeOrderBook())  // 同步更新盘口
}

三、架构:一张图说清楚

EFStockChartView(主容器 UIView)
├── EFPeriodBar          ← 周期切换栏(分时/日K/周K…)
├── EFInfoBar            ← MA 数值展示行(K线模式专属)
├── mainImageView        ← 主图(UIImageView,贴 CGImage)
│   └── EFCrosshairLayer ← 十字线覆盖层(高频刷新,独立)
├── EFSubPanel × 4       ← 副图面板(各自含 ImageView)
└── EFOrderBookView      ← 五档盘口(个股分时专属)

后台渲染队列(background serial queue)
  └── EFKLineRenderer / EFTimelineRenderer
        ├── 离屏 CGContext 绘制 CGImage
        └── 回主线程:imageView.image = UIImage(cgImage: ...)

整个架构的核心原则只有一句话:

主线程只负责贴图,脏活全扔后台。

这不是玄学,这是让 K 线滑动保持 60 FPS 的唯一正确姿势。

代码分层

层级文件职责
ModelsEFChartModels.swift纯数据结构,全是 struct,值类型,线程安全
EngineEFIndicatorEngine.swift无状态计算,MA/EMA/MACD/KDJ/RSI,全部 O(n)
ThemeEFChartTheme.swift + EFChartConfig.swift颜色体系 + 可配置项(K线样式、MA周期等)
ContextEFRenderContext.swiftCGContext 工具集(坐标映射、文字、折线、虚线)
RenderersEFKLineRenderer + EFTimelineRenderer纯绘制函数,无 UI 副作用
ViewsEFStockChartView + SubViews容器与手势处理
DemoDemoViewController + EFMockData接入示例与数据模拟

四、技术挑战:三个坑,我已经替你踩完了

坑 1:主副图滚动不同步 🩺

症状:快速划一下,松手,主图已经跑到新位置,副图还停在旧的地方。 像两个人跳双人舞,一个快进了八拍,另一个还在原地发呆。

根因

手势 .ended
  └─ triggerRender()                    发出全量渲染(token = UUID-A)

momentum 第一帧(几毫秒后)
  └─ triggerRender(skipSub: true)        立刻覆盖 token = UUID-B

后台线程:拿到 UUID-A 核对 → 发现不是 B → 直接丢弃 ✗
副图永远没有被渲染,一直显示旧帧

更糟糕的是:UIDynamicAnimator 自然减速到 0 时没有任何回调—— 它只是悄悄停止调用 action block,不通知任何人。 没人告诉你"我停了",副图就这样永远被遗忘在旧帧里。

解法:防抖 Timer,150ms 补帧

private func triggerRender(skipSub: Bool = false) {
    let token = UUID(); renderToken = token
    scheduleRender(token: token, onlyPanel: nil, skipSub: skipSub)
    if skipSub { scheduleSubSync() }    // ← 关键一行
}

private func scheduleSubSync() {
    subSyncTimer?.invalidate()
    // 只要还在滚动,timer 就一直被重置
    // 最后一帧之后 150ms 无新活动,触发全量渲染(副图终于回来了)
    subSyncTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
        self?.triggerRender()   // skipSub = false
    }
}

150ms 是调试出来的经验值:太短副图在惯性未停时就乱跳;太长用户会明显感知副图"闪"了一下。实测 150ms 在主流设备上对用户完全无感知。


坑 2:RSI 只有一根线 📈

症状:副图 RSI 面板孤零零一条白线,对比东方财富的 RSI6 / RSI12 / RSI24 三色三线,简直像穷人版。

根因:数据模型先天残缺:

// 旧设计:一个 panel 只能放一条 RSI
public enum EFSubData {
    case rsi(EFRSIResult)   // ← 单条,注定残废
}

解法:改成数组,一个 panel 放多条:

// 新设计:数组,RSI6 / RSI12 / RSI24 一起进
public enum EFSubData {
    case rsi([EFRSIResult])
}

数据侧三行搞定:

let subData: [EFSubData] = [
    .macd(macdData),
    .kdj(kdjData),
    .rsi([rsi6, rsi12, rsi24]),   // ← 三剑客
    .volume(volData),
]

渲染侧按颜色遍历绘制(白 / 黄 / 紫,对应东方财富的配色):

for (i, rd) in rds.enumerated() {
    let color = EFColor.rsiColors[Swift.min(i, EFColor.rsiColors.count - 1)]
    let pts: [CGPoint?] = vis.enumerated().map { li, gi in
        guard gi < rd.values.count else { return nil }
        return CGPoint(
            x: cr.minX + (CGFloat(li) + 0.5) * slotW,
            y: tlR.yFor(price: rd.values[gi], range: pRange, rect: cr)
        )
    }
    ctx.strokePolyline(points: pts, color: color, lineWidth: 1.0)
}

三行数据模型改动,RSI 面板从光杆司令变成三剑客。


坑 3:松手即停,体验像拖拉机 🎿

用过某些"自研 K 线图"的同学都懂那种感觉:手指一抬,图表"嘎"的一声停死。 没有丝滑,没有余韵,像坐在不带减震的拖拉机上,颠得牙齿打架。

解法:UIDynamicAnimator,向 UIScrollView 学习

UIScrollView 的惯性滚动背后用的就是这套物理引擎,我们直接借来用:

// 一个轻量级的物理代理对象——专门骗引擎计算位移
private final class EFDynamicItem: NSObject, UIDynamicItem {
    var center:    CGPoint           = .zero
    var bounds:    CGRect            = CGRect(x: 0, y: 0, width: 1, height: 1)
    var transform: CGAffineTransform = .identity
}

// 手势结束时,如果速度 > 80 pt/s,加入物理减速行为
case .ended, .cancelled:
    let velocity = gr.velocity(in: self)
    guard abs(velocity.x) > 80 else { triggerRender(); return }

    dynamicItem.center = .zero
    let behavior = UIDynamicItemBehavior(items: [dynamicItem])
    behavior.addLinearVelocity(velocity, for: dynamicItem)
    behavior.resistance = 3.0    // 阻力系数,越大减速越快

    behavior.action = { [weak self] in
        // 每帧根据 center 变化量推算需要移动多少根 K 线
        let dist = self.dynamicItem.center.x - self.decelerationStartX
        guard abs(dist) >= candleWidth else { return }
        // 更新 visibleRange,触发主图渲染
    }
    animator.addBehavior(behavior)

EFDynamicItem 没有任何 UI,纯粹是拿来骗物理引擎算速度-位移曲线的。 优雅到令人发指。


五、渲染原理:为什么不卡?

这是整个库最值钱的部分,三句话说清楚:

① 主线程不画图

// 主线程:收集参数,扔给后台队列
renderQ.async { [weak self] in
    guard self?.renderToken == token else { return }  // ② 过期直接扔
    
    // 后台画 CGImage
    let mainImg = klR.renderMain(data: klData, rect: mainRect, ...)
    
    // ③ 主线程只做一件事:贴图
    DispatchQueue.main.async {
        self?.mainImageView.image = UIImage(cgImage: mainImg, ...)
    }
}

② Token 取消机制——防止"幽灵帧"

每次 triggerRender() 生成一个新 UUID 写入 renderToken。 后台线程开始渲染前先核对 token,如果已过期(有更新的请求来了),直接 return。

这样哪怕手指滑得比光还快,渲染队列里永远只有"最新的那一帧"在有效执行, 旧图永远不会覆盖新图。

③ 滚动时跳过副图——减少 60% 工作量

四个副图面板,每个都需要独立的 CGContext 绘制 MACD / KDJ / RSI / 成交量。 拖动时这些数据根本没必要实时更新——用户眼睛盯着主图在看,副图就让它停那儿。

// 拖动 / 惯性中:只渲主图
triggerRender(skipSub: true)

// 停止后 150ms:副图终于被想起来了
Timer.fire()  triggerRender()   // skipSub = false,全量

④ 蜡烛批量路径——别一根一根画

// 把所有上涨蜡烛的矩形一次性加入路径,一次 fillPath 搞定
let risingBody = CGMutablePath()
let fallingBody = CGMutablePath()

for (li, c) in candles.enumerated() {
    let body = CGRect(...)
    c.isBullish ? risingBody.addRect(body) : fallingBody.addRect(body)
}

ctx.addPath(risingBody)
ctx.setFillColor(EFColor.rising.cgColor)
ctx.fillPath()   // ← 一次提交,不是 N 次

批量路径比逐个 fillRect 快 10 倍以上,在 100 根蜡烛场景下感知明显。


六、性能表现:数据说话

指标表现
FPS稳定 60,拖动时偶有单帧 58,肉眼无感知
CPUEMA 平滑后 idle 约 5-15%,滚动时约 30-60%
K 线数据量300 根 vs 3000 根无差别(只渲可见窗口 50-100 根)
appendTimelinePointsO(1) amortized(Swift COW,不再逐次复制全数组)
惯性滚动物理减速,松手体验与 UIScrollView 一致

关于 CPU 抖动:测 CPU 用的是 mach 线程 API,是瞬时快照。 渲染线程突发时确实会冲 100%,idle 时降到 0%,这是真实数据,不是 bug。 用 EMA(α=0.35)平滑展示,消除视觉焦虑:

smoothedCPU = smoothedCPU * 0.65 + rawCPU * 0.35

本质上和 K 线的 MA 均线是同一个数学原理——用移动平均消除噪声。


七、接入:三步上车

Step 1:把 EFStockChart/ 文件夹拖进 Xcode

(Add Files to… → 勾选 Copy if needed)

Step 2:设置 rootViewController

window?.rootViewController = EFDemoViewController()

Step 3:加载数据

分时图(最简单)

let data = EFTimelineData(
    securityType: .stock,
    stockCode: "600519", stockName: "贵州茅台",
    prevClose: 1465.02,
    upperLimit: 1611.52, lowerLimit: 1318.52,
    points: yourTimelinePoints,
    period: .timeline,
    orderBook: yourOrderBook     // 可选,个股盘口
)
chartView.loadTimeline(data)

K 线图(先异步算指标)

EFIndicatorEngine.calculateAsync(candles: yourCandles) { result in
    let kData = EFKLineData(
        securityType: .stock,
        period: .daily,
        candles: yourCandles,
        maResults: result.maLines,          // MA5/10/20/60/120/250
        subData: [
            .macd(result.macd),
            .kdj(result.kdj),
            .rsi([result.rsi6, result.rsi12]),
            .volume(result.volumeData),
        ],
        prevClose: 1465.02
    )
    self.chartView.loadKLine(kData)
}

处理分页加载(重点)

func chartView(_ v: EFStockChartView, visibleRangeChanged r: Range<Int>) {
    if r.lowerBound <= 5 {
        // 用户滑到了左边缘,加载更多历史数据
        fetchMoreHistory(before: yourCandles[r.lowerBound]) { newCandles in
            let merged = newCandles + yourCandles
            // 重新计算指标,重新 loadKLine
        }
    }
}

八、展望:还能更好

现在这个版本已经覆盖了日常 80% 的金融行情 UI 需求,但还有一些值得继续打磨的方向:

📌 近期可做

  • 复权处理:前复权 / 后复权 / 不复权,数据侧支持 adjustType 配置已有占位
  • 更多 K 线样式:空心蜡烛、美国线(OHLC Bar)、线形图、山形图(EFChartConfig 已有 candleStyle 字段)
  • 对数坐标轴:长周期行情必备(scaleType: .log 配置已预留)
  • 集合竞价时段:开盘前 15 分钟灰色区域

📌 中期可探索

  • Metal 渲染:用 GPU 替换 CGContext,理论上可以把渲染时间从 ~8ms 降到 ~1ms,帧率从 60 跑到"不需要 FPS 计数器"
  • 增量渲染:只重绘发生变化的 dirty 区域,不全量重绘
  • 自定义指标协议:让调用方注入自己的指标计算逻辑和渲染逻辑

📌 长期可考虑

  • macOS 适配(Catalyst)
  • SwiftUI Wrapper
  • 行情数据适配层(对接 WebSocket,内置数据标准化)

最后

特朗普靠一条推特就能让 K 线变脸,那是因为他手里有市场预期这把刀。

而你手里有的,是这套架构:

  • 离屏渲染 + token 取消,主线程永远不堵
  • UIDynamicAnimator,惯性滚动不再是奢望
  • 防抖 timer,主副图永远同步
  • COW append,实时推送不卡顿
  • EMA 平滑,性能数据不再心电图

写 K 线图最难的不是画蜡烛,是在用户拼命滑动的时候还能保持 60 帧, 还能在副图上准确显示 RSI6 / RSI12 / RSI24 的实时数值。 这才是金融 App 和"会画图"之间的距离。

你现在跨过去了。


📎 项目地址github.com/FreakLee/EF…

欢迎 Star ⭐、Fork 🍴、提 Issue 🐛—— 或者直接把你踩过的坑扔进评论区,一起填。

觉得有用的话,点个在看 👀,让更多在 K 线图里迷路的工程师找到方向。

🔔 关注我,下一篇聊聊用 Metal 把渲染从 CPU 搬到 GPU, 看看帧率能不能突破"肉眼极限"。