打开任意一款股票App,在K线图主图的下方几乎都会跟随一个成交量副图。除了表示每日成交量的红绿柱之外,图上通常还会叠加三条颜色不同的曲线,这就是VOL5、VOL10、VOL20成交量均线。它们分别代表最近5日、10日、20日的平均成交量,是判断量能放大与萎缩、识别放量突破与缩量回调的重要指标。本文将基于Core Graphics框架,在iOS端完整实现这三条均线的计算与绘制。

一、成交量均线的计算原理与数据预热
VOL均线的计算方式与价格均线(MA)完全一致,只是把计算对象从收盘价换成了成交量。以VOL5为例,第N日的VOL5值等于从第N-4日到第N日这5天成交量的算术平均值,公式为:VOL5(N) = (Vol(N-4) + Vol(N-3) + Vol(N-2) + Vol(N-1) + Vol(N)) / 5。VOL10和VOL20同理,只是窗口长度不同。
计算时有一个容易踩坑的点:前几根K线由于数据不足,是没有均线值的。比如第一根K线不存在VOL5值,第19根之前的K线不存在VOL20值。这些位置要么不画线,要么把起点从第一根有效数据开始,否则会出现均线起始端异常下坠到零值的情况,视觉上非常难看。下面是一段完整的计算代码:
/// 计算VOL均线,返回nil表示该位置数据不足,无法计算
+ (NSArray<NSNumber *> *)calcVolumeMA:(NSArray<NSNumber *>)volumes period:(NSInteger)period {
NSMutableArray *result = [NSMutableArray arrayWithCapacity:volumes.count];
for (NSInteger i = 0; i < volumes.count; i++) {
if (i < period - 1) {
// 数据不足窗口长度,该位置无数均线值
[result addObject:[NSNull null]];
} else {
CGFloat sum = 0;
for (NSInteger j = i - period + 1; j <= i; j++) {
sum += volumes[j].doubleValue;
}
[result addObject:@(sum / period)];
}
}
return result;
}
// 使用示例
self.vol5 = [self.class calcVolumeMA:self.volumes period:5];
self.vol10 = [self.class calcVolumeMA:self.volumes period:10];
self.vol20 = [self.class calcVolumeMA:self.volumes period:20];
上面的双重循环在数据量大时效率一般。如果K线数量达到几千根,可以改用滑动窗口累加法:每前进一根K线,减去移出窗口的值,加上新进入的值,把复杂度从O(n*m)降到O(n)。对于实时刷新的分时图和滚动加载的历史K线,这个优化很有意义。
二、绘制前的坐标映射与上下文配置
拿到均线数据后,不能直接开始画,需要先确定Y轴的映射关系。成交量区域的最大值应取可视范围内所有成交量以及所有均线值中的最大值,再乘以一个安全系数(比如1.1),给顶部留出呼吸空间。注意这里的最大值必须在可视数据范围内动态计算,而不是用全部数据的最大值,否则当用户翻看历史行情时,柱子和均线会被压得很矮。
坐标换算公式很简单:y = 区域底部 - (value / maxValue) * 区域高度。X轴方向,如果副图区域宽度为W,可视K线数量为n,则每根柱子的宽度约为W/n,柱子中心点x = i * 每根宽度 + 每根宽度/2。均线上的点就取各根K线的中心点,把相邻点用直线连起来即可。
绘制时建议在drawRect:中先做整体配置:设置setShouldAntialias:YES开启抗锯齿,否则斜线会出现明显锯齿;使用CGContextSaveGState和CGContextRestoreGState成对包裹每条曲线的绘制,避免路径和变换互相污染;柱状图先画,均线后画,这样均线能覆盖在柱子上方,层次更清晰。
三、分层绘制成交量柱与三条均线
具体的绘制可以分为三步。第一步画成交量柱,颜色跟随当日涨跌:收盘价大于等于开盘价用红色(A股习惯),反之用绿色,柱子之间留一点间隙会更好看。第二步依次绘制VOL5、VOL10、VOL20三条曲线,通常使用黄、紫、绿或白、黄、紫等配色加以区分。第三步在左上角绘制图例文字,标注各颜色对应的周期以及最新一日的数值。
绘制曲线时推荐使用CGMutablePathRef构建路径,遇到值为NSNull的位置直接断开当前路径并重新开始新的一段,这样VOL20开头那十几根空白区不会拖出一条错误的长直线。下面是核心绘制代码:
- (void)drawVolumeMASeries:(NSArray<NSNumber *> *)ma
color:(UIColor *)color
context:(CGContextRef)ctx
plotFrame:(CGRect)frame
maxValue:(CGFloat)maxValue
barWidth:(CGFloat)barWidth {
if (ma.count == 0) return;
CGMutablePathRef path = CGPathCreateMutable();
BOOL hasPoint = NO;
CGFloat top = CGRectGetMinY(frame);
CGFloat height = CGRectGetHeight(frame);
for (NSInteger i = 0; i < ma.count; i++) {
id value = ma[i];
if (value == [NSNull null]) {
// 数据断档,结束当前子路径
hasPoint = NO;
continue;
}
CGFloat x = i * barWidth + barWidth / 2.0;
CGFloat y = top + frame.size.height - ([value doubleValue] / maxValue) * height;
if (hasPoint) {
CGPathAddLineToPoint(path, NULL, x, y);
} else {
CGPathMoveToPoint(path, NULL, x, y);
hasPoint = YES;
}
}
CGContextSetStrokeColorWithColor(ctx, color.CGColor);
CGContextSetLineWidth(ctx, 1.0);
CGContextSetLineJoin(ctx, kCGLineJoinRound);
CGContextAddPath(ctx, path);
CGContextStrokePath(ctx);
CGPathRelease(path);
}
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextSetShouldAntialias(ctx, YES);
// 1. 计算最大值
CGFloat maxValue = 0;
for (NSInteger i = self.visibleStart; i < self.visibleEnd; i++) {
maxValue = MAX(maxValue, self.volumes[i].doubleValue);
maxValue = MAX(maxValue, [self.vol20[i] isKindOfClass:[NSNull class]] ? 0 : self.vol20[i].doubleValue);
}
maxValue *= 1.1;
CGFloat barWidth = rect.size.width / (self.visibleEnd - self.visibleStart);
// 2. 先画成交量柱
for (NSInteger i = self.visibleStart; i < self.visibleEnd; i++) {
CGFloat value = self.volumes[i].doubleValue;
CGFloat h = (value / maxValue) * rect.size.height;
CGFloat barRect = CGRectMake((i - self.visibleStart) * barWidth + 1,
rect.origin.y + rect.size.height - h,
barWidth - 2, h);
UIColor *c = [self klines[i].close doubleValue] >= [self.klines[i].open doubleValue]
? [UIColor colorWithRed:0.92 green:0.27 blue:0.27 alpha:1]
: [UIColor colorWithRed:0.13 green:0.71 blue:0.36 alpha:1];
CGContextSetFillColorWithColor(ctx, c.CGColor);
CGContextFillRect(ctx, barRect);
}
// 3. 再画三条均线,后画的覆盖在上方
[self drawVolumeMASeries:self.vol5 color:[UIColor colorWithRed:1 green:0.8 blue:0.2 alpha:1]
context:ctx plotFrame:rect maxValue:maxValue barWidth:barWidth];
[self drawVolumeMASeries:self.vol10 color:[UIColor colorWithRed:0.6 green:0.4 blue:0.95 alpha:1]
context:ctx plotFrame:rect maxValue:maxValue barWidth:barWidth];
[self drawVolumeMASeries:self.vol20 color:[UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1]
context:ctx plotFrame:rect maxValue:maxValue barWidth:barWidth];
}
四、图例绘制与性能优化建议
图例一般放在副图左上角,用NSAttributedString或者直接调用drawAtPoint:绘制,例如显示为“VOL5:123456 VOL10:135789 VOL20:142030”,文字颜色与对应曲线颜色一致,方便用户对照。数值建议用万手或亿手做格式化缩写,避免长数字挤占空间。
性能方面有几点经验值得注意。首先,如果K线图支持捏合缩放和拖动,drawRect:会被高频调用,建议把三条均线的计算结果缓存起来,只在数据源变化时重新计算,拖动缩放时只做坐标映射和绘制。其次,CADisplayLink驱动的连续重绘场景下,可以考虑改用CAShapeLayer承载均线,配合path动画获得更流畅的效果。另外,当可视K线数量超过几百根时,柱子宽度会小于1个像素,此时可以把绘制粒度降低为每几根画一个聚合值,既省性能又不影响观感。
最后一点,实时行情推送时只需增量更新最后一根K线的成交量并重算最后几个均线值,不要整表重算。配合setNeedsDisplayInRect:只刷新脏区域,可以在低端机型上也保持流畅的刷新体验。按照以上思路实现后,一个带VOL5、VOL10、VOL20均线的专业成交量副图就完整落地了。
Core GraphicsK线图成交量均线修改时间:2026-09-01 18:04:38