世上K線大師有兩個,一個是川普,另一個是你
川普發(fā)條推特,納指跌 5%;再發(fā)一條,道指漲 3%。
這哥們不炒股——他就是 K 線。而作為一個 iOS 工程師,你連個像樣的 K 線圖都沒寫過,
憑什么說自己懂金融科技?本文手把手帶你讀懂并實現(xiàn) EFStockChart——
一個從零開始、仿照東方財富的 Swift K 線圖庫。
GitHub 地址在文末,先收藏,后細看。
一、痛點:你不是第一個在 K 線圖上翻車的人
產(chǎn)品經(jīng)理端著咖啡,笑瞇瞇地走過來:
"這個 K 線圖能不能做得和東方財富一樣?
要能滑動,要有 MACD、KDJ、RSI,
最好還能實時推送,對了,還要有盤口……"
你面不改色,內(nèi)心已經(jīng)開始崩潰。
翻了一圈 GitHub——
- 有的是五年前的 ObjC 老古董,連 Swift 都沒有;
- 有的用 UIScrollView 硬撐,一快速滑動就掉幀,K 線變幻燈片;
- 有的文檔一行沒有,Issues 全是"求救"和"已放棄";
- 有的畫出來的蠟燭,顏色邏輯寫反了,漲的是綠色跌的是紅色……(這不是 A 股,謝謝)
與其找一個湊合用的,不如自己寫一個讓別人來抄的。
這就是 EFStockChart 的誕生背景。
二、效果:先看圖,再談原理
功能全家桶,一次列清楚:
?? 主圖能力
| 模式 | 說明 |
|---|---|
| 分時圖 | 個股(右側(cè)五檔盤口)/ 指數(shù)(漲跌家數(shù)內(nèi)嵌柱) |
| 五日分時 | 連續(xù) 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 均量線
?? 交互能力
- 慣性動量滾動:松手后繼續(xù)滑行,物理減速,不是"嘎"的一聲停死
- 捏合縮放:蠟燭寬度 2-48pt 無級調(diào)節(jié),縮放時自動右對齊
- 十字線 + Tooltip:長按呼出,顯示 OHLCV 全量數(shù)據(jù),3 秒無操作自動隱藏
- 周期切換欄:分時 / 五日 / 日K / 周K / 月K + 更多(展開 8 個周期)
- 分頁加載:滑到左邊緣觸發(fā) delegate,通知業(yè)務(wù)層拉更早的歷史數(shù)據(jù)
? 實時推送
EFRealtimeSimulator 封裝了實時分時的狀態(tài)機,開箱即用:
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()) // 同步更新盤口
}
三、架構(gòu):一張圖說清楚
EFStockChartView(主容器 UIView)
├── EFPeriodBar ← 周期切換欄(分時/日K/周K…)
├── EFInfoBar ← MA 數(shù)值展示行(K線模式專屬)
├── mainImageView ← 主圖(UIImageView,貼 CGImage)
│ └── EFCrosshairLayer ← 十字線覆蓋層(高頻刷新,獨立)
├── EFSubPanel × 4 ← 副圖面板(各自含 ImageView)
└── EFOrderBookView ← 五檔盤口(個股分時專屬)
后臺渲染隊列(background serial queue)
└── EFKLineRenderer / EFTimelineRenderer
├── 離屏 CGContext 繪制 CGImage
└── 回主線程:imageView.image = UIImage(cgImage: ...)
整個架構(gòu)的核心原則只有一句話:
主線程只負責貼圖,臟活全扔后臺。
這不是玄學(xué),這是讓 K 線滑動保持 60 FPS 的唯一正確姿勢。
代碼分層
| 層級 | 文件 | 職責 |
|---|---|---|
| Models | EFChartModels.swift | 純數(shù)據(jù)結(jié)構(gòu),全是 struct,值類型,線程安全 |
| Engine | EFIndicatorEngine.swift | 無狀態(tài)計算,MA/EMA/MACD/KDJ/RSI,全部 O(n) |
| Theme | EFChartTheme.swift + EFChartConfig.swift | 顏色體系 + 可配置項(K線樣式、MA周期等) |
| Context | EFRenderContext.swift | CGContext 工具集(坐標映射、文字、折線、虛線) |
| Renderers | EFKLineRenderer + EFTimelineRenderer | 純繪制函數(shù),無 UI 副作用 |
| Views | EFStockChartView + SubViews | 容器與手勢處理 |
| Demo | DemoViewController + EFMockData | 接入示例與數(shù)據(jù)模擬 |
四、技術(shù)挑戰(zhàn):三個坑,我已經(jīng)替你踩完了
坑 1:主副圖滾動不同步 ??
癥狀:快速劃一下,松手,主圖已經(jīng)跑到新位置,副圖還停在舊的地方。
像兩個人跳雙人舞,一個快進了八拍,另一個還在原地發(fā)呆。
根因
手勢 .ended
└─ triggerRender() 發(fā)出全量渲染(token = UUID-A)
momentum 第一幀(幾毫秒后)
└─ triggerRender(skipSub: true) 立刻覆蓋 token = UUID-B
后臺線程:拿到 UUID-A 核對 → 發(fā)現(xiàn)不是 B → 直接丟棄 ?
副圖永遠沒有被渲染,一直顯示舊幀
更糟糕的是:UIDynamicAnimator 自然減速到 0 時沒有任何回調(diào)——
它只是悄悄停止調(diào)用 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() } // ← 關(guān)鍵一行
}
private func scheduleSubSync() {
subSyncTimer?.invalidate()
// 只要還在滾動,timer 就一直被重置
// 最后一幀之后 150ms 無新活動,觸發(fā)全量渲染(副圖終于回來了)
subSyncTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
self?.triggerRender() // skipSub = false
}
}
150ms 是調(diào)試出來的經(jīng)驗值:太短副圖在慣性未停時就亂跳;太長用戶會明顯感知副圖"閃"了一下。實測 150ms 在主流設(shè)備上對用戶完全無感知。
坑 2:RSI 只有一根線 ??
癥狀:副圖 RSI 面板孤零零一條白線,對比東方財富的 RSI6 / RSI12 / RSI24 三色三線,簡直像窮人版。
根因:數(shù)據(jù)模型先天殘缺:
// 舊設(shè)計:一個 panel 只能放一條 RSI
public enum EFSubData {
case rsi(EFRSIResult) // ← 單條,注定殘廢
}
解法:改成數(shù)組,一個 panel 放多條:
// 新設(shè)計:數(shù)組,RSI6 / RSI12 / RSI24 一起進
public enum EFSubData {
case rsi([EFRSIResult])
}
數(shù)據(jù)側(cè)三行搞定:
let subData: [EFSubData] = [
.macd(macdData),
.kdj(kdjData),
.rsi([rsi6, rsi12, rsi24]), // ← 三劍客
.volume(volData),
]
渲染側(cè)按顏色遍歷繪制(白 / 黃 / 紫,對應(yīng)東方財富的配色):
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)
}
三行數(shù)據(jù)模型改動,RSI 面板從光桿司令變成三劍客。
坑 3:松手即停,體驗像拖拉機 ??
用過某些"自研 K 線圖"的同學(xué)都懂那種感覺:手指一抬,圖表"嘎"的一聲停死。
沒有絲滑,沒有余韻,像坐在不帶減震的拖拉機上,顛得牙齒打架。
解法:UIDynamicAnimator,向 UIScrollView 學(xué)習(xí)
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
}
// 手勢結(jié)束時,如果速度 > 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 // 阻力系數(shù),越大減速越快
behavior.action = { [weak self] in
// 每幀根據(jù) center 變化量推算需要移動多少根 K 線
let dist = self.dynamicItem.center.x - self.decelerationStartX
guard abs(dist) >= candleWidth else { return }
// 更新 visibleRange,觸發(fā)主圖渲染
}
animator.addBehavior(behavior)
EFDynamicItem 沒有任何 UI,純粹是拿來騙物理引擎算速度-位移曲線的。
優(yōu)雅到令人發(fā)指。
五、渲染原理:為什么不卡?
這是整個庫最值錢的部分,三句話說清楚:
① 主線程不畫圖
// 主線程:收集參數(shù),扔給后臺隊列
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。
這樣哪怕手指滑得比光還快,渲染隊列里永遠只有"最新的那一幀"在有效執(zhí)行,
舊圖永遠不會覆蓋新圖。
③ 滾動時跳過副圖——減少 60% 工作量
四個副圖面板,每個都需要獨立的 CGContext 繪制 MACD / KDJ / RSI / 成交量。
拖動時這些數(shù)據(jù)根本沒必要實時更新——用戶眼睛盯著主圖在看,副圖就讓它停那兒。
// 拖動 / 慣性中:只渲主圖
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 根蠟燭場景下感知明顯。
六、性能表現(xiàn):數(shù)據(jù)說話
| 指標 | 表現(xiàn) |
|---|---|
| FPS | 穩(wěn)定 60,拖動時偶有單幀 58,肉眼無感知 |
| CPU | EMA 平滑后 idle 約 5-15%,滾動時約 30-60% |
| K 線數(shù)據(jù)量 | 300 根 vs 3000 根無差別(只渲可見窗口 50-100 根) |
appendTimelinePoints |
O(1) amortized(Swift COW,不再逐次復(fù)制全數(shù)組) |
| 慣性滾動 | 物理減速,松手體驗與 UIScrollView 一致 |
關(guān)于 CPU 抖動:測 CPU 用的是 mach 線程 API,是瞬時快照。
渲染線程突發(fā)時確實會沖 100%,idle 時降到 0%,這是真實數(shù)據(jù),不是 bug。
用 EMA(α=0.35)平滑展示,消除視覺焦慮:
smoothedCPU = smoothedCPU * 0.65 + rawCPU * 0.35
本質(zhì)上和 K 線的 MA 均線是同一個數(shù)學(xué)原理——用移動平均消除噪聲。
七、接入:三步上車
Step 1:把 EFStockChart/ 文件夾拖進 Xcode
(Add Files to… → 勾選 Copy if needed)
Step 2:設(shè)置 rootViewController
window?.rootViewController = EFDemoViewController()
Step 3:加載數(shù)據(jù)
分時圖(最簡單)
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 {
// 用戶滑到了左邊緣,加載更多歷史數(shù)據(jù)
fetchMoreHistory(before: yourCandles[r.lowerBound]) { newCandles in
let merged = newCandles + yourCandles
// 重新計算指標,重新 loadKLine
}
}
}
八、展望:還能更好
現(xiàn)在這個版本已經(jīng)覆蓋了日常 80% 的金融行情 UI 需求,但還有一些值得繼續(xù)打磨的方向:
?? 近期可做
-
復(fù)權(quán)處理:前復(fù)權(quán) / 后復(fù)權(quán) / 不復(fù)權(quán),數(shù)據(jù)側(cè)支持
adjustType配置已有占位 -
更多 K 線樣式:空心蠟燭、美國線(OHLC Bar)、線形圖、山形圖(EFChartConfig 已有
candleStyle字段) -
對數(shù)坐標軸:長周期行情必備(
scaleType: .log配置已預(yù)留) - 集合競價時段:開盤前 15 分鐘灰色區(qū)域
?? 中期可探索
- Metal 渲染:用 GPU 替換 CGContext,理論上可以把渲染時間從 ~8ms 降到 ~1ms,幀率從 60 跑到"不需要 FPS 計數(shù)器"
- 增量渲染:只重繪發(fā)生變化的 dirty 區(qū)域,不全量重繪
- 自定義指標協(xié)議:讓調(diào)用方注入自己的指標計算邏輯和渲染邏輯
?? 長期可考慮
- macOS 適配(Catalyst)
- SwiftUI Wrapper
- 行情數(shù)據(jù)適配層(對接 WebSocket,內(nèi)置數(shù)據(jù)標準化)
最后
特朗普靠一條推特就能讓 K 線變臉,那是因為他手里有市場預(yù)期這把刀。
而你手里有的,是這套架構(gòu):
- 離屏渲染 + token 取消,主線程永遠不堵
- UIDynamicAnimator,慣性滾動不再是奢望
- 防抖 timer,主副圖永遠同步
- COW append,實時推送不卡頓
- EMA 平滑,性能數(shù)據(jù)不再心電圖
寫 K 線圖最難的不是畫蠟燭,是在用戶拼命滑動的時候還能保持 60 幀,
還能在副圖上準確顯示 RSI6 / RSI12 / RSI24 的實時數(shù)值。
這才是金融 App 和"會畫圖"之間的距離。
你現(xiàn)在跨過去了。
?? 項目地址:https://github.com/FreakLee/EFStockChart
歡迎 Star ?、Fork ??、提 Issue ??——
或者直接把你踩過的坑扔進評論區(qū),一起填。覺得有用的話,點個在看 ??,讓更多在 K 線圖里迷路的工程師找到方向。
?? 關(guān)注我,下一篇聊聊用 Metal 把渲染從 CPU 搬到 GPU,
看看幀率能不能突破"肉眼極限"。