世上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 的唯一正确姿势。
代码分层
| 层级 | 文件 | 职责 |
|---|---|---|
| Models | EFChartModels.swift | 纯数据结构,全是 struct,值类型,线程安全 |
| Engine | EFIndicatorEngine.swift | 无状态计算,MA/EMA/MACD/KDJ/RSI,全部 O(n) |
| Theme | EFChartTheme.swift + EFChartConfig.swift | 颜色体系 + 可配置项(K线样式、MA周期等) |
| Context | EFRenderContext.swift | CGContext 工具集(坐标映射、文字、折线、虚线) |
| Renderers | EFKLineRenderer + EFTimelineRenderer | 纯绘制函数,无 UI 副作用 |
| Views | EFStockChartView + SubViews | 容器与手势处理 |
| Demo | DemoViewController + 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,肉眼无感知 |
| CPU | EMA 平滑后 idle 约 5-15%,滚动时约 30-60% |
| K 线数据量 | 300 根 vs 3000 根无差别(只渲可见窗口 50-100 根) |
appendTimelinePoints | O(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, 看看帧率能不能突破"肉眼极限"。