目錄
- 一、核心實現方案
- 1. 原生方案:使用 Core Graphics 繪制
- 2. 使用第三方庫:Charts
- 3. 跨平臺方案:使用 SwiftUI + Canvas
- 二、技術指標實現
- 1. 移動平均線 (MA)
- 2. 布林帶 (Bollinger Bands)
- 3. MACD (Moving Average Convergence Divergence)
- 三、性能優化策略
- 1. 數據分頁與懶加載
- 2. 離屏渲染優化
- 3. 手勢交互優化
- 四、高級功能實現
- 1. 十字線光標
- 2. 多時間周期切換
- 五、架構設計建議
- 1、分層架構設計:
- 2、性能優化總結表:
- 3、跨平臺適配方案:
- 六、推薦方案選擇
- 1、根據需求選擇方案
- 2、性能對比表
股票圖(尤其是K線圖)是金融應用的核心功能。下面我將詳細介紹多種實現方案,并提供完整的代碼示例和優化建議。
一、核心實現方案
1. 原生方案:使用 Core Graphics 繪制
優勢:
- 完全可控,無依賴
- 高性能,適合大數據量
- 定制化程度高
實現代碼:
import UIKitstruct Candle {let date: Datelet open: Doublelet high: Doublelet low: Doublelet close: Doublelet volume: Double
}class StockChartView: UIView {var candles: [Candle] = [] {didSet {calculateMetrics()setNeedsDisplay()}}// 計算指標private var minPrice: Double = 0private var maxPrice: Double = 0private var maxVolume: Double = 0private var candleWidth: CGFloat = 0private var candleSpacing: CGFloat = 2override func draw(_ rect: CGRect) {super.draw(rect)guard let context = UIGraphicsGetCurrentContext() else { return }guard !candles.isEmpty else { return }drawGrid(context: context)drawCandles(context: context)drawVolume(context: context)drawIndicators(context: context)}private func calculateMetrics() {minPrice = candles.map { $0.low }.min() ?? 0maxPrice = candles.map { $0.high }.max() ?? 0// 添加10%的上下空間let priceRange = maxPrice - minPriceminPrice = minPrice - priceRange * 0.1maxPrice = maxPrice + priceRange * 0.1maxVolume = candles.map { $0.volume }.max() ?? 0// 計算蠟燭寬度let availableWidth = bounds.width - 40 // 左右邊距candleWidth = (availableWidth / CGFloat(candles.count)) - candleSpacing}private func drawGrid(context: CGContext) {context.setStrokeColor(UIColor.systemGray4.cgColor)context.setLineWidth(0.5)// 水平網格線let horizontalLines = 5for i in 0...horizontalLines {let y = bounds.height * CGFloat(i) / CGFloat(horizontalLines)context.move(to: CGPoint(x: 0, y: y))context.addLine(to: CGPoint(x: bounds.width, y: y))}// 垂直網格線let verticalLines = 6for i in 0...verticalLines {let x = bounds.width * CGFloat(i) / CGFloat(verticalLines)context.move(to: CGPoint(x: x, y: 0))context.addLine(to: CGPoint(x: x, y: bounds.height))}context.strokePath()}private func drawCandles(context: CGContext) {let chartHeight = bounds.height * 0.7 // 70%高度用于K線let topMargin: CGFloat = 20for (index, candle) in candles.enumerated() {let x = 20 + CGFloat(index) * (candleWidth + candleSpacing)// 計算價格對應的Y坐標func priceToY(_ price: Double) -> CGFloat {let priceRatio = (price - minPrice) / (maxPrice - minPrice)return topMargin + chartHeight * (1 - CGFloat(priceRatio))}let highY = priceToY(candle.high)let lowY = priceToY(candle.low)let openY = priceToY(candle.open)let closeY = priceToY(candle.close)// 繪制影線context.setStrokeColor(UIColor.gray.cgColor)context.setLineWidth(1)context.move(to: CGPoint(x: x + candleWidth/2, y: highY))context.addLine(to: CGPoint(x: x + candleWidth/2, y: lowY))context.strokePath()// 繪制實體let bodyHeight = max(1, abs(openY - closeY))let bodyY = min(openY, closeY)if candle.close > candle.open {context.setFillColor(UIColor.systemRed.cgColor) // 陽線} else {context.setFillColor(UIColor.systemGreen.cgColor) // 陰線}context.fill(CGRect(x: x, y: bodyY, width: candleWidth, height: bodyHeight))}}private func drawVolume(context: CGContext) {let volumeHeight = bounds.height * 0.3 // 30%高度用于成交量let volumeTop = bounds.height * 0.7 + 10for (index, candle) in candles.enumerated() {let x = 20 + CGFloat(index) * (candleWidth + candleSpacing)// 計算成交量高度let volumeRatio = CGFloat(candle.volume / maxVolume)let barHeight = volumeHeight * volumeRatio// 設置顏色(與K線一致)if candle.close > candle.open {context.setFillColor(UIColor.systemRed.cgColor)} else {context.setFillColor(UIColor.systemGreen.cgColor)}context.fill(CGRect(x: x, y: volumeTop + volumeHeight - barHeight, width: candleWidth, height: barHeight))}}private func drawIndicators(context: CGContext) {// 繪制移動平均線示例let movingAverage = calculateMovingAverage(period: 5)context.setStrokeColor(UIColor.blue.cgColor)context.setLineWidth(1.5)for (index, value) in movingAverage.enumerated() {let x = 20 + CGFloat(index) * (candleWidth + candleSpacing) + candleWidth/2let y = bounds.height * 0.7 * (1 - CGFloat((value - minPrice) / (maxPrice - minPrice))) + 20if index == 0 {context.move(to: CGPoint(x: x, y: y))} else {context.addLine(to: CGPoint(x: x, y: y))}}context.strokePath()}private func calculateMovingAverage(period: Int) -> [Double] {guard period > 0, candles.count >= period else { return [] }var averages: [Double] = []for i in 0..<candles.count {let start = max(0, i - period + 1)let end = ilet range = candles[start...end]let sum = range.reduce(0) { $0 + $1.close }averages.append(sum / Double(range.count))}return averages}
}
2. 使用第三方庫:Charts
優勢
- 快速實現復雜圖表
- 內置多種技術指標
- 支持交互功能
實現步驟:
- 安裝 Charts 庫(通過 CocoaPods 或 SPM)
- 創建 K 線圖視圖
import UIKit
import Chartsclass StockChartViewController: UIViewController {@IBOutlet weak var candleStickChartView: CandleStickChartView!@IBOutlet weak var volumeChartView: BarChartView!override func viewDidLoad() {super.viewDidLoad()setupCharts()loadData()}private func setupCharts() {// 配置K線圖candleStickChartView.dragEnabled = truecandleStickChartView.setScaleEnabled(true)candleStickChartView.pinchZoomEnabled = truecandleStickChartView.xAxis.labelPosition = .bottomcandleStickChartView.legend.enabled = false// 配置成交量圖volumeChartView.dragEnabled = falsevolumeChartView.setScaleEnabled(false)volumeChartView.pinchZoomEnabled = falsevolumeChartView.xAxis.labelPosition = .bottomvolumeChartView.legend.enabled = falsevolumeChartView.leftAxis.enabled = false}private func loadData() {let candles = generateSampleData()// K線數據var candleEntries = [CandleChartDataEntry]()for (i, candle) in candles.enumerated() {let entry = CandleChartDataEntry(x: Double(i),shadowH: candle.high,shadowL: candle.low,open: candle.open,close: candle.close)candleEntries.append(entry)}let candleDataSet = CandleChartDataSet(entries: candleEntries, label: "K線")candleDataSet.increasingColor = .systemRedcandleDataSet.increasingFilled = truecandleDataSet.decreasingColor = .systemGreencandleDataSet.decreasingFilled = truecandleDataSet.shadowColor = .darkGraycandleDataSet.shadowWidth = 1candleStickChartView.data = CandleChartData(dataSet: candleDataSet)// 成交量數據var volumeEntries = [BarChartDataEntry]()for (i, candle) in candles.enumerated() {volumeEntries.append(BarChartDataEntry(x: Double(i), y: candle.volume))}let volumeDataSet = BarChartDataSet(entries: volumeEntries, label: "成交量")volumeDataSet.colors = candles.map { $0.close > $0.open ? .systemRed : .systemGreen }volumeChartView.data = BarChartData(dataSet: volumeDataSet)// 添加移動平均線let movingAverage = calculateMovingAverage(data: candles.map { $0.close }, period: 5)var lineEntries = [ChartDataEntry]()for (i, value) in movingAverage.enumerated() {lineEntries.append(ChartDataEntry(x: Double(i), y: value))}let lineDataSet = LineChartDataSet(entries: lineEntries, label: "5日均線")lineDataSet.colors = [.blue]lineDataSet.drawCirclesEnabled = falselineDataSet.lineWidth = 2candleStickChartView.data?.addDataSet(lineDataSet)}private func generateSampleData() -> [Candle] {// 生成示例數據...}private func calculateMovingAverage(data: [Double], period: Int) -> [Double] {// 計算移動平均...}
}
3. 跨平臺方案:使用 SwiftUI + Canvas
import SwiftUIstruct CandleChart: View {let candles: [Candle]@State private var visibleRange: ClosedRange<Int> = 0...50var body: some View {GeometryReader { geometry inZStack(alignment: .topLeading) {// 網格背景GridBackground()// K線圖CandlesView(candles: Array(candles[visibleRange]),width: geometry.size.width,height: geometry.size.height * 0.7).offset(y: 20)// 成交量圖VolumeView(candles: Array(candles[visibleRange]),width: geometry.size.width,height: geometry.size.height * 0.3).offset(y: geometry.size.height * 0.7 + 10)// 技術指標MovingAverageView(candles: Array(candles[visibleRange]),width: geometry.size.width,height: geometry.size.height * 0.7).offset(y: 20)}.gesture(DragGesture().onChanged { value in// 處理拖動邏輯})}}
}struct CandlesView: View {let candles: [Candle]let width: CGFloatlet height: CGFloatprivate var minPrice: Double { candles.map { $0.low }.min() ?? 0 }private var maxPrice: Double { candles.map { $0.high }.max() ?? 0 }private var candleWidth: CGFloat { (width - 40) / CGFloat(candles.count) - 2 }var body: some View {Canvas { context, size infor (index, candle) in candles.enumerated() {let x = 20 + CGFloat(index) * (candleWidth + 2) + candleWidth/2// 影線context.stroke(Path { path inpath.move(to: CGPoint(x: x, y: priceToY(candle.high)))path.addLine(to: CGPoint(x: x, y: priceToY(candle.low)))},with: .color(.gray),lineWidth: 1)// 實體let bodyRect = CGRect(x: 20 + CGFloat(index) * (candleWidth + 2),y: priceToY(max(candle.open, candle.close)),width: candleWidth,height: abs(priceToY(candle.open) - priceToY(candle.close)))context.fill(Path(bodyRect),with: .color(candle.close > candle.open ? .red : .green))}}.frame(height: height)}private func priceToY(_ price: Double) -> CGFloat {let priceRatio = (price - minPrice) / (maxPrice - minPrice)return height * (1 - CGFloat(priceRatio))}
}
二、技術指標實現
1. 移動平均線 (MA)
extension Array where Element == Candle {func movingAverage(period: Int) -> [Double] {guard period > 0, count >= period else { return [] }return self.indices.map { index inlet start = max(0, index - period + 1)let end = indexlet range = self[start...end]return range.reduce(0) { $0 + $1.close } / Double(range.count)}}
}
2. 布林帶 (Bollinger Bands)
struct BollingerBands {let upper: [Double]let middle: [Double] // 即MAlet lower: [Double]
}extension Array where Element == Candle {func bollingerBands(period: Int, multiplier: Double = 2) -> BollingerBands {let ma = movingAverage(period: period)guard ma.count == count else { return BollingerBands(upper: [], middle: [], lower: []) }let stdDev: [Double] = indices.map { index inlet start = max(0, index - period + 1)let end = indexlet range = self[start...end].map { $0.close }let average = ma[index]let variance = range.reduce(0) { $0 + pow($1 - average, 2) } / Double(range.count)return sqrt(variance)}let upper = zip(ma, stdDev).map { $0 + $1 * multiplier }let lower = zip(ma, stdDev).map { $0 - $1 * multiplier }return BollingerBands(upper: upper, middle: ma, lower: lower)}
}
3. MACD (Moving Average Convergence Divergence)
struct MACD {let macdLine: [Double]let signalLine: [Double]let histogram: [Double]
}extension Array where Element == Candle {func macd(fastPeriod: Int = 12, slowPeriod: Int = 26, signalPeriod: Int = 9) -> MACD {let fastEMA = exponentialMovingAverage(period: fastPeriod)let slowEMA = exponentialMovingAverage(period: slowPeriod)let macdLine = zip(fastEMA, slowEMA).map { $0 - $1 }let signalLine = macdLine.exponentialMovingAverage(period: signalPeriod)let histogram = zip(macdLine, signalLine).map { $0 - $1 }return MACD(macdLine: macdLine, signalLine: signalLine, histogram: histogram)}private func exponentialMovingAverage(period: Int) -> [Double] {guard count >= period else { return [] }var ema: [Double] = []let multiplier = 2.0 / Double(period + 1)// 第一個EMA是簡單移動平均let firstSMA = prefix(period).reduce(0) { $0 + $1.close } / Double(period)ema.append(firstSMA)// 計算后續EMAfor i in period..<count {let value = self[i].closelet prevEMA = ema[i - period]let currentEMA = (value - prevEMA) * multiplier + prevEMAema.append(currentEMA)}// 補齊前面的空值return Array(repeating: Double.nan, count: period - 1) + ema}
}
三、性能優化策略
1. 數據分頁與懶加載
class StockDataLoader {private let allCandles: [Candle]private var loadedCandles: [Candle] = []private let pageSize = 100init(candles: [Candle]) {self.allCandles = candles}func loadMore(completion: @escaping ([Candle]) -> Void) {let startIndex = loadedCandles.countguard startIndex < allCandles.count else { return }let endIndex = min(startIndex + pageSize, allCandles.count)let newCandles = Array(allCandles[startIndex..<endIndex])// 模擬網絡請求延遲DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {DispatchQueue.main.async {self.loadedCandles.append(contentsOf: newCandles)completion(self.loadedCandles)}}}func visibleCandles(for range: ClosedRange<Int>) -> [Candle] {guard !loadedCandles.isEmpty else { return [] }let start = max(range.lowerBound, 0)let end = min(range.upperBound, loadedCandles.count - 1)return Array(loadedCandles[start...end])}
}
2. 離屏渲染優化
class StockChartView: UIView {private var renderLayer: CALayer?override func draw(_ rect: CGRect) {if renderLayer == nil {renderOffscreen()}}private func renderOffscreen() {DispatchQueue.global(qos: .userInitiated).async {let format = UIGraphicsImageRendererFormat()format.scale = UIScreen.main.scalelet renderer = UIGraphicsImageRenderer(size: self.bounds.size, format: format)let image = renderer.image { context inself.drawContent(context: context.cgContext)}DispatchQueue.main.async {let layer = CALayer()layer.contents = image.cgImagelayer.frame = self.boundsself.layer.addSublayer(layer)self.renderLayer = layer}}}private func drawContent(context: CGContext) {// 繪制邏輯...}
}
3. 手勢交互優化
class InteractiveStockChartView: StockChartView {private var panStart: CGPoint = .zeroprivate var visibleCandleCount: Int = 50override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {super.touchesBegan(touches, with: event)panStart = touches.first?.location(in: self) ?? .zero}override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {guard let touchPoint = touches.first?.location(in: self) else { return }let deltaX = panStart.x - touchPoint.xlet candleDelta = Int(deltaX / (candleWidth + candleSpacing))if abs(candleDelta) > 0 {// 更新可見范圍visibleCandleCount = max(10, min(200, visibleCandleCount + candleDelta))setNeedsDisplay()panStart = touchPoint}}@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {let scale = gesture.scalelet newCount = Int(Double(visibleCandleCount) / scaleif gesture.state == .changed {visibleCandleCount = max(10, min(500, newCount))setNeedsDisplay()}}
}
四、高級功能實現
1. 十字線光標
class CrosshairView: UIView {var currentPosition: CGPoint? {didSet { setNeedsDisplay() }}override func draw(_ rect: CGRect) {guard let position = currentPosition else { return }guard let context = UIGraphicsGetCurrentContext() else { return }// 水平線context.setStrokeColor(UIColor.white.withAlphaComponent(0.7).cgColor)context.setLineWidth(0.5)context.setLineDash(phase: 0, lengths: [5, 5])context.move(to: CGPoint(x: 0, y: position.y))context.addLine(to: CGPoint(x: bounds.width, y: position.y))// 垂直線context.move(to: CGPoint(x: position.x, y: 0))context.addLine(to: CGPoint(x: position.x, y: bounds.height))context.strokePath()// 信息框drawInfoBox(at: position, in: context)}private func drawInfoBox(at position: CGPoint, in context: CGContext) {let boxWidth: CGFloat = 120let boxHeight: CGFloat = 80let boxX = position.x > bounds.width / 2 ? position.x - boxWidth - 10 : position.x + 10let boxY = position.y > bounds.height / 2 ? position.y - boxHeight : position.ylet boxRect = CGRect(x: boxX, y: boxY, width: boxWidth, height: boxHeight)// 背景context.setFillColor(UIColor.systemBackground.withAlphaComponent(0.9).cgColor)context.fill(boxRect)// 邊框context.setStrokeColor(UIColor.systemGray.cgColor)context.setLineWidth(1)context.stroke(boxRect)// 繪制文本信息let paragraphStyle = NSMutableParagraphStyle()paragraphStyle.alignment = .leftlet attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12),.paragraphStyle: paragraphStyle,.foregroundColor: UIColor.label]let dateText = "日期: 2023-06-15"let priceText = "價格: 150.25"let volumeText = "成交量: 1.2M"dateText.draw(at: CGPoint(x: boxX + 8, y: boxY + 8), withAttributes: attributes)priceText.draw(at: CGPoint(x: boxX + 8, y: boxY + 28), withAttributes: attributes)volumeText.draw(at: CGPoint(x: boxX + 8, y: boxY + 48), withAttributes: attributes)}
}
2. 多時間周期切換
enum ChartTimeframe: String, CaseIterable {case oneMinute = "1分"case fiveMinutes = "5分"case fifteenMinutes = "15分"case thirtyMinutes = "30分"case oneHour = "1小時"case fourHours = "4小時"case oneDay = "日線"case oneWeek = "周線"case oneMonth = "月線"var interval: TimeInterval {switch self {case .oneMinute: return 60case .fiveMinutes: return 300case .fifteenMinutes: return 900case .thirtyMinutes: return 1800case .oneHour: return 3600case .fourHours: return 14400case .oneDay: return 86400case .oneWeek: return 604800case .oneMonth: return 2592000 // 近似值}}
}class TimeframeSelector: UIView {var selectedTimeframe: ChartTimeframe = .oneDay {didSet { updateSelection() }}private var buttons: [UIButton] = []override init(frame: CGRect) {super.init(frame: frame)setupView()}private func setupView() {let stackView = UIStackView()stackView.axis = .horizontalstackView.distribution = .fillEquallystackView.spacing = 8for timeframe in ChartTimeframe.allCases {let button = UIButton(type: .system)button.setTitle(timeframe.rawValue, for: .normal)button.titleLabel?.font = UIFont.systemFont(ofSize: 14)button.addTarget(self, action: #selector(timeframeTapped(_:)), for: .touchUpInside)button.tag = ChartTimeframe.allCases.firstIndex(of: timeframe) ?? 0buttons.append(button)stackView.addArrangedSubview(button)}addSubview(stackView)stackView.translatesAutoresizingMaskIntoConstraints = falseNSLayoutConstraint.activate([stackView.topAnchor.constraint(equalTo: topAnchor),stackView.bottomAnchor.constraint(equalTo: bottomAnchor),stackView.leadingAnchor.constraint(equalTo: leadingAnchor),stackView.trailingAnchor.constraint(equalTo: trailingAnchor)])updateSelection()}private func updateSelection() {for (index, button) in buttons.enumerated() {let isSelected = ChartTimeframe.allCases[index] == selectedTimeframebutton.tintColor = isSelected ? .systemBlue : .systemGraybutton.backgroundColor = isSelected ? .systemBlue.withAlphaComponent(0.1) : .clearbutton.layer.cornerRadius = 4}}@objc private func timeframeTapped(_ sender: UIButton) {selectedTimeframe = ChartTimeframe.allCases[sender.tag]}
}
五、架構設計建議
1、分層架構設計:
┌───────────────────────────────┐
│ UI Layer │
│ - Chart Views (UIKit/SwiftUI)│
│ - Gesture Handlers │
│ - View Controllers │
└──────────────┬────────────────┘│
┌──────────────▼───────────────┐
│ Domain Layer │
│ - Technical Indicators (MA, │
│ MACD, Bollinger Bands) │
│ - Data Transformations │
└──────────────┬───────────────┘│
┌──────────────▼───────────────┐
│ Data Layer │
│ - Network Service (API) │
│ - Database (CoreData/Realm) │
│ - Caching Mechanism │
└──────────────────────────────┘
UI Layer:包含圖表視圖(UIKit/SwiftUI)、手勢處理程序和視圖控制器。
Domain Layer:包含技術指標(如MA、MACD、布林帶)和數據轉換。
Data Layer:包含網絡服務(API)、數據庫(CoreData/Realm)和緩存機制。
它們之間的關系是:UI層依賴于領域層,領域層依賴于數據層。
2、性能優化總結表:
優化點 | 技術方案 | 適用場景 |
---|---|---|
大數據量 | 分頁加載 + 增量渲染 | 歷史數據加載 |
實時更新 | 差異更新 + 增量繪制 | 實時行情 |
復雜指標 | 后臺計算 + 緩存結果 | MACD/布林帶等 |
流暢交互 | 離屏渲染 + GPU加速 | 手勢縮放平移 |
內存優化 | 對象復用 + 惰性加載 | 移動設備限制 |
3、跨平臺適配方案:
#if os(iOS)
import UIKit
typealias ViewRepresentable = UIView
#elseif os(macOS)
import AppKit
typealias ViewRepresentable = NSView
#endifclass CrossPlatformChartView: ViewRepresentable {#if os(iOS)override init(frame: CGRect) {super.init(frame: frame)commonInit()}#elseif os(macOS)override init(frame frameRect: NSRect) {super.init(frame: frameRect)commonInit()}#endifprivate func commonInit() {// 共享初始化代碼}#if os(iOS)override func draw(_ rect: CGRect) {super.draw(rect)drawContent()}#elseif os(macOS)override func draw(_ dirtyRect: NSRect) {super.draw(dirtyRect)drawContent()}#endifprivate func drawContent() {// 共享繪制邏輯}
}
六、推薦方案選擇
1、根據需求選擇方案
需求場景 | 推薦方案 | 理由 |
---|---|---|
簡單展示 | SwiftUI + Canvas | 開發快,代碼簡潔 |
復雜交互 | UIKit + Core Graphics | 性能好,控制精細 |
多技術指標 | Charts 第三方庫 | 內置多種指標實現 |
跨平臺 | SwiftUI + 條件編譯 | 支持iOS/macOS |
實時行情 | Core Animation | 高效增量更新 |
2、性能對比表
方案 | 10K數據點渲染 | 手勢流暢度 | 內存占用 | 開發效率 |
---|---|---|---|---|
Core Graphics | 300ms | ????? | 低 | ?? |
SwiftUI Canvas | 800ms | ???? | 中 | ???? |
Charts 庫 | 500ms | ???? | 中高 | ????? |
Core Animation | 100ms | ????? | 低 | ??? |
最終建議:
- 對于專業交易應用,使用 Core Graphics + 自定義手勢 實現最佳性能和體驗
- 對于普通金融應用,使用 Charts 庫 快速實現豐富功能
- 對于 SwiftUI 項目,使用 Canvas + 異步繪制 平衡性能與開發效率
股票圖的實現需要平衡性能、功能和用戶體驗。建議從簡單實現開始,逐步添加技術指標和交互功能,同時做好性能監控和優化。