arrow_back返回文章列表

Objective-C 实现股票 K 线图:数据建模、坐标映射与高性能绘制

Objective-C 实现股票 K 线图:数据建模、坐标映射与高性能绘制

K 线图看起来只是红绿矩形和几条折线,真正动手后却会发现,它同时考验数据建模、坐标变换、手势状态和绘制性能。价格可能跨越几个数量级,交易日并不连续,缩放后还要保证蜡烛宽度可读;十字线移动时,如果每次都把整张图重画一遍,体验很快就会变得迟钝。

这篇文章以 Objective-C 和 UIKit/Core Graphics 为基础,拆解一个可落地的 K 线组件。代码是为说明结构而裁剪的片段,示例行情均为演示数据,不对应任何真实证券,也不构成投资建议。

一、先把行情数据定义清楚

一根蜡烛至少需要时间、开盘价、最高价、最低价、收盘价和成交量,也就是常说的 OHLCV。价格不要用 float:它的精度不足;如果数据源本身以最小货币单位传输,可以优先保存整数。本文为了让坐标计算直观,模型使用 double,展示文本再交给 NSNumberFormatter

@interface YKCandle : NSObject
@property (nonatomic, assign) NSTimeInterval timestamp;
@property (nonatomic, assign) double open;
@property (nonatomic, assign) double high;
@property (nonatomic, assign) double low;
@property (nonatomic, assign) double close;
@property (nonatomic, assign) double volume;
@end

模型进入图表前要做一次校验:六个数值必须有限;成交量不能为负;并且 high >= MAX(open, close)low <= MIN(open, close)high >= low。时间戳应按升序排列,同一周期出现重复时间时,也要在数据层明确覆盖还是合并,不能留给绘制层猜测。

均线最好作为派生序列单独计算,不要在 drawRect: 里反复求和。以收盘价五日均线为例,可以用滑动窗口将复杂度从 O(n × period) 降到 O(n);不足五根时保存 NAN,绘图时跳过。复权、时区、停牌和交易日历属于行情语义,也应在图表之前解决。

二、可见区间是组件的核心状态

图表不需要每次都处理全部历史数据。真正参与坐标计算和绘制的是可见区间:

@property (nonatomic, assign) NSRange visibleRange;
@property (nonatomic, assign) CGFloat candleWidth;
@property (nonatomic, assign) CGFloat candleSpacing;

假设绘图区宽度为 plotWidth,单元宽度是 candleWidth + candleSpacing,可见数量可由 floor(plotWidth / unitWidth) 得到。向左拖动时改变 visibleRange.location,双指缩放时改变 candleWidth,随后重新计算可见数量,并把区间裁剪到 [0, candles.count)

这里最容易漏掉两个细节。第一,缩放锚点不能总是视图中心,否则手指下的蜡烛会“滑走”。缩放开始时记录锚点对应的数据索引,宽度变化后反推新的起点,让该索引尽量留在原来的屏幕位置。第二,数据刷新后要区分用户是否正查看最新一根:如果是,就继续吸附右侧;如果用户已经拖到历史区间,不要强行跳回末尾。

三、价格、时间与屏幕坐标

UIKit 坐标原点在左上角,y 向下增长,而价格越高应该画得越靠上。先扫描可见蜡烛的最高、最低价,再留少量上下边距:

- (CGFloat)yForPrice:(double)price
             minPrice:(double)minPrice
             maxPrice:(double)maxPrice
              inFrame:(CGRect)frame {
    double span = maxPrice - minPrice;
    if (!isfinite(span) || span <= DBL_EPSILON) {
        return CGRectGetMidY(frame);
    }
    double ratio = (maxPrice - price) / span;
    return CGRectGetMinY(frame) + (CGFloat)ratio * CGRectGetHeight(frame);
}

- (CGFloat)xForVisibleOffset:(NSUInteger)offset inFrame:(CGRect)frame {
    CGFloat unit = self.candleWidth + self.candleSpacing;
    return CGRectGetMinX(frame) + offset * unit + unit * 0.5;
}

当最高价等于最低价时,直接除法会产生无效坐标。实际实现可以在中线绘制,并人为扩展一个很小的价格区间,让刻度仍然有意义。扫描价格范围时,还要决定是否把均线包含进范围:包含会避免均线被裁掉,但极端均线值也可能压扁蜡烛。这个策略应作为明确配置,而不是隐藏规则。

横轴更适合按“序号”而不是自然时间等距排列。周末和节假日没有蜡烛,如果直接使用时间差映射,图中会出现没有交易含义的空洞。时间戳用于格式化刻度和定位数据,屏幕上的间距则由可见序号决定。

为了让一像素的线更清晰,可以根据屏幕缩放因子对坐标做像素对齐:

static inline CGFloat YKAlignToPixel(CGFloat value, CGFloat scale) {
    return round(value * scale) / scale;
}

四、用 Core Graphics 绘制蜡烛与成交量

UIViewdrawRect: 已经提供当前图形上下文。不要自行创建位图上下文,也不要长期持有从这里取得的 CGContextRef。绘制顺序通常是:背景和网格、成交量、蜡烛、均线、坐标文字,最后才是十字线覆盖层。

- (void)drawCandle:(YKCandle *)item
                 x:(CGFloat)x
             frame:(CGRect)frame
          minPrice:(double)minPrice
          maxPrice:(double)maxPrice
           context:(CGContextRef)context {
    CGFloat yHigh = [self yForPrice:item.high minPrice:minPrice
                           maxPrice:maxPrice inFrame:frame];
    CGFloat yLow = [self yForPrice:item.low minPrice:minPrice
                          maxPrice:maxPrice inFrame:frame];
    CGFloat yOpen = [self yForPrice:item.open minPrice:minPrice
                           maxPrice:maxPrice inFrame:frame];
    CGFloat yClose = [self yForPrice:item.close minPrice:minPrice
                            maxPrice:maxPrice inFrame:frame];

    BOOL rising = item.close >= item.open;
    UIColor *color = rising ? self.risingColor : self.fallingColor;
    CGContextSetStrokeColorWithColor(context, color.CGColor);
    CGContextSetFillColorWithColor(context, color.CGColor);
    CGContextSetLineWidth(context, 1.0 / UIScreen.mainScreen.scale);
    CGContextMoveToPoint(context, x, yHigh);
    CGContextAddLineToPoint(context, x, yLow);
    CGContextStrokePath(context);

    CGFloat bodyTop = MIN(yOpen, yClose);
    CGFloat bodyHeight = MAX(fabs(yClose - yOpen),
                             1.0 / UIScreen.mainScreen.scale);
    CGRect body = CGRectMake(x - self.candleWidth * 0.5,
                             bodyTop, self.candleWidth, bodyHeight);
    CGContextFillRect(context, body);
}

示例把上涨画成实心色块,真实产品也可能采用“涨空跌实”,并且不同市场对红绿含义的习惯并不一致,因此颜色和填充方式都应开放配置。open == close 的十字星至少保留一个物理像素,否则会在高分屏上消失。

成交量使用独立区域和独立比例尺。可见区最大成交量映射到成交量区顶部,零映射到底部;若全部为零则不画柱体。柱体可复用蜡烛涨跌颜色,但不能把成交量和价格共用纵轴。

double ratio = maxVolume > 0.0 ? item.volume / maxVolume : 0.0;
CGFloat height = CGRectGetHeight(volumeFrame) * (CGFloat)ratio;
CGRect bar = CGRectMake(x - self.candleWidth * 0.5,
                        CGRectGetMaxY(volumeFrame) - height,
                        self.candleWidth, height);
CGContextFillRect(context, bar);

五、均线要处理断点,而不只是连线

均线绘制可以使用 CGMutablePathRef。第一个有效点调用 CGPathMoveToPoint,后续调用 CGPathAddLineToPoint;遇到 NAN 或超出可用数据区间时,把“已有起点”标志重置。否则路径会错误地跨过缺失数据。

CGMutablePathRef path = CGPathCreateMutable();
BOOL hasStart = NO;
for (NSUInteger offset = 0; offset < values.count; offset++) {
    double value = values[offset].doubleValue;
    if (!isfinite(value)) {
        hasStart = NO;
        continue;
    }
    CGFloat x = [self xForVisibleOffset:offset inFrame:priceFrame];
    CGFloat y = [self yForPrice:value minPrice:minPrice
                      maxPrice:maxPrice inFrame:priceFrame];
    if (hasStart) {
        CGPathAddLineToPoint(path, NULL, x, y);
    } else {
        CGPathMoveToPoint(path, NULL, x, y);
        hasStart = YES;
    }
}
CGContextAddPath(context, path);
CGContextStrokePath(context);
CGPathRelease(path);

生产代码通常同时展示 MA5、MA10、MA20。均线结果可以随数据版本缓存,绘制层只读取可见切片;周期变化或行情追加时再增量更新,避免每一次拖动都重复计算。

六、缩放、平移和手势冲突

平移用 UIPanGestureRecognizer,累计位移超过一个单元宽度后换算为整数根数,并保留不足一个单元的余量。这样既不会因每帧取整而丢失手势,也能让蜡烛最终稳定落在数据格上。

缩放用 UIPinchGestureRecognizer。不要直接连续乘上每一帧的 scale,否则会指数式放大;可以在 .began 保存初始宽度,在 .changed 使用 initialWidth * recognizer.scale,再限制到例如 3~24 点的可读范围。缩得太小时,优先切换到聚合或简化绘制,而不是把无数条亚像素线硬塞进屏幕。

当图表嵌在纵向 UIScrollView 中时,手势代理应根据移动方向决定是否同时识别:横向移动交给图表,明显的纵向移动留给页面。长按十字线期间可暂时禁止平移,结束后恢复,避免两个状态同时修改可见区间。

七、十字线应该是轻量覆盖层

十字线不适合和历史蜡烛绑定在同一个重绘周期。更实用的方式是单独放一个透明 UIView 或使用两个 CAShapeLayer:主图只在数据、尺寸、缩放和平移变化时重绘;手指移动时仅更新横线、竖线和标签。

长按位置到数据索引的换算同样基于单元宽度:

CGFloat localX = location.x - CGRectGetMinX(priceFrame);
NSInteger offset = (NSInteger)llround(localX /
                      (self.candleWidth + self.candleSpacing) - 0.5);
offset = MAX(0, MIN(offset, (NSInteger)self.visibleRange.length - 1));
NSUInteger index = self.visibleRange.location + (NSUInteger)offset;

得到索引后,竖线吸附到蜡烛中心;横线可以跟随手指显示对应价格,也可以吸附收盘价,两种交互含义不同,需要产品层明确。标签必须限制在视图边界内,并通过 NSDateFormatter 展示交易时区中的日期。VoiceOver 用户无法使用长按精确探索时,应有可访问的摘要或逐项浏览入口。

八、大数据量下的优化顺序

几万根历史数据并不可怕,可怕的是每次刷新都遍历、格式化和分配它们。优化应先从工作量边界入手:

  1. 只扫描和绘制可见数据。 缩放到一屏 80 根时,计算就限定在这 80 根及少量缓冲区。
  2. 把计算移出绘制函数。 均线、日期字符串、数据校验和可见极值缓存到独立结果中;后台队列计算完成后,回主线程替换不可变快照并触发重绘。
  3. 合并同色路径。 大量线段可按颜色加入路径后一次描边,减少频繁修改上下文状态。
  4. 静态层与交互层分开。 网格、K 线和均线低频更新,十字线高频更新;后者使用图层位移,不重画前者。
  5. 控制刷新频率。 行情回调可能快于屏幕刷新,不必每收到一条数据就调用 setNeedsDisplay。合并主线程更新,并以屏幕能展示的节奏提交。
  6. 先测量再优化。 使用 Instruments 的 Time Profiler 和 Core Animation 观察 CPU 热点、离屏渲染与掉帧,而不是凭感觉引入复杂缓存。

异步计算还要处理版本竞争:用户切换周期后,旧任务晚到不能覆盖新数据。可以为每份行情快照附带递增版本号,提交结果时再次核对。UIKit 对象及绘制触发留在主线程,后台只处理不变的数据值。

九、不能靠“正常行情”掩盖的边界

一个可靠组件至少要验证这些情况:空数组和只有一根数据;所有价格相同;零成交量;极大或极小价格;NaN、无穷值和非法 OHLC;数据量少于均线周期;旋转、分屏或安全区变化;缩放到上限和下限;新数据到达时位于最新区与历史区两种状态。

还要警惕“最新价一定是最后一个元素”这种隐含假设。网络乱序或周期切换时,应先在数据层排序、去重,再发布一份不可变数组。绘制过程中不要直接读取正在被其他线程修改的可变数组,否则轻则一帧数据不一致,重则越界崩溃。

十、一份可执行的检查清单

  • 数据入图前完成排序、去重、合法性校验和时区统一。
  • 可见区间始终不越界,数据追加不会打断用户查看历史。
  • 价格轴处理零跨度,时间轴按交易序号等距。
  • 蜡烛、成交量、均线使用各自正确的范围和断点策略。
  • 平移保留余量,缩放保留手指锚点并限制最小、最大宽度。
  • 十字线独立更新,索引和标签都能在边界处正确夹取。
  • 只处理可见数据,缓存派生结果,并用 Instruments 验证瓶颈。
  • 颜色、涨跌规则、精度和无障碍描述均可配置或本地化。

K 线组件的难点不在某一段 Core Graphics API,而在于把行情语义、可见窗口、坐标系统与交互状态分开。当这些边界清楚后,绘图代码只是把已经确定的数据投影到屏幕;当边界混在一起时,任何新需求都会牵动整张图。先建立稳定的数据流,再追求漂亮的线条,是更可靠的实现顺序。

参考资料

flare

保持好奇,继续思考。