【iOS開發(fā)】從零開始、帶你用Swift實現(xiàn)類似東方財富的 K 線圖

世上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,
看看幀率能不能突破"肉眼極限"。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容