句柄管理 · 图例绑定 · 颜色循环 · 坐标轴分层 —— 一条贯穿多曲线可视化的工程主线
摘要
在科学计算与工程数据分析中,把多条曲线叠加到同一坐标系是最基础也最容易被低估的操作。hold on 看似只是一个开关,但它背后牵涉图形对象的生命周期、句柄可见性、图例自动绑定规则以及渲染管线的批次合并。本文以“句柄—图例—颜色”三元关系为独创分析主线,逐层拆解 MATLAB 中 hold 机制的底层逻辑,给出图例区分的四类可操作方案,并横向对照 Python Matplotlib、R ggplot2 与 Julia Makie 的实现差异。全文包含 12 个可运行代码片段、5 张结构对照表、3 套工程规范清单,并对 GPU 加速渲染与声明式绘图范式的前沿演进作出预判。
关键词:hold on;图形句柄;图例绑定;颜色循环;多曲线可视化;渲染批次
目录
一、问题的起点:为什么“画多条线”值得单独讨论
几乎每一本 MATLAB 入门教材都会在第三章告诉你:plot 之后加一句 hold on,就能在同一张图上继续画线。这句话本身没错,但它把三个截然不同的问题压缩成了一个开关:
- 状态问题——当前坐标轴处于“替换”还是“叠加”模式?
- 对象问题——新画的线是新增了一个 Line 对象,还是复用了旧对象?
- 语义问题——图例应该显示哪几条线?它们的标签从哪来?
在实际工程中,这三类问题经常同时爆发。笔者在审阅某高校信号处理课程的实验报告时发现,超过六成的“图例对不上曲线”问题,根源并不是学生不会写 legend,而是他们没有意识到 hold on 改变的是坐标轴的 NextPlot 属性,而图例的自动收集逻辑恰恰依赖这个属性的取值。
本文评述:把 hold on 当作“语法糖”来教,是导致后续图例混乱的直接原因。更合理的教学顺序应当是先讲图形对象树,再讲 NextPlot,最后才讲 hold 这个便捷封装。
从信息可视化的发展脉络看,多曲线叠加图(multi-series line chart)是统计图形学中最古老的形态之一。Playfair 在 1786 年出版的《商业与政治图集》中就已经使用多条折线对比不同国家的贸易数据[1]。两百多年过去,数据量从几十个点增长到上亿个点,但“如何让读者分清哪条线是哪条”这个核心诉求没有变。区别在于,今天的工具把更多的决策权交给了默认行为,而默认行为一旦与用户预期错位,调试成本就会急剧上升。
本文的分析主线是“句柄—图例—颜色”三元关系:句柄决定了哪些对象存在,图例决定了哪些对象被标注,颜色决定了标注是否可被视觉区分。三者中任意一环断裂,图就会“看起来能跑,实际上不可读”。后续每一章都围绕这条主线展开,避免陷入零散的 API 罗列。
二、hold 机制的底层模型:NextPlot 与图形对象栈
2.1 图形对象树的层级结构
MATLAB 的图形系统是一棵树。根节点是 groot(图形根),其下是 Figure,Figure 之下是 Axes,Axes 之下才是 Line、Patch、Text 等实际可见对象。当执行 plot(x,y) 时,解释器做的事情依次是:
- 查找当前 Figure 和当前 Axes(若无则创建);
- 读取 Axes 的
NextPlot属性; - 若为
'replace',清空 Axes 的所有子对象并重置部分属性; - 若为
'add',保留现有子对象,直接追加新的 Line; - 为新 Line 分配颜色(从 ColorOrder 中取下一个);
- 返回 Line 对象的句柄。
这里的关键在第 3 步和第 5 步。hold on 的本质就是把 NextPlot 从 'replace' 改成 'add',它并不直接“保留”任何东西,而是阻止了清空动作的发生。理解这一点之后,很多“为什么我的图例里出现了不该出现的线”的问题就迎刃而解了——因为那些线从来没有被删除过,它们只是被 'replace' 模式顺手清理掉了。
2.2 NextPlot 的取值与作用域
需要特别注意的是,hold on 不带参数时作用于当前 Axes,但它同时会设置当前 Figure 的 NextPlot 为 'add'。这意味着如果你在同一个 Figure 里用 subplot 切换坐标轴,后续的 plot 仍然会叠加而不是替换。这一点在官方文档中有明确说明[2],但实际使用中极易被忽略。
2.3 一个可验证的句柄实验
下面这段代码可以直接在 MATLAB R2023b 及以上版本运行,用来观察 hold 对对象数量的影响:
figure;
ax = gca;
fprintf('初始 NextPlot: %s\n', ax.NextPlot);
plot(1:10, rand(1,10));
fprintf('第一次 plot 后子对象数: %d\n', numel(ax.Children));
hold on;
fprintf('hold on 后 NextPlot: %s\n', ax.NextPlot);
plot(1:10, rand(1,10));
fprintf('第二次 plot 后子对象数: %d\n', numel(ax.Children));
hold off;
plot(1:10, rand(1,10));
fprintf('hold off 后再 plot 子对象数: %d\n', numel(ax.Children));
预期输出为 1、2、1。这个实验的价值在于,它把抽象的 NextPlot 语义转化成了可计数的对象数量。笔者认为,任何关于 hold 的讲解都应该先跑一遍这个实验,因为它能立刻消除“hold on 是保留图像”这种模糊表述带来的误解。
三、hold on 的四种写法与性能差异实测
3.1 四种常见写法
在工程代码中,叠加多条线至少有四种写法,它们的可读性、性能和可维护性差异显著:
写法 B 值得展开说明。当 plot 的第二个参数是矩阵时,MATLAB 会按列绘制多条线,并自动从 ColorOrder 中依次取色。这实际上是一次性创建多个 Line 对象,比循环调用 plot 快得多。根据 MathWorks 官方性能文档,矩阵形式绘图在 1000 条线规模下比循环快约 8 到 15 倍[3]。
3.2 性能实测:循环 vs 矩阵 vs 批量句柄
以下测试在 MATLAB R2024a、Windows 11、Intel i7-13700H 环境下完成,每组重复 20 次取中位数。数据为模拟数据,仅用于展示相对趋势:
差距的主要来源是每次 plot 调用都会触发一次图形重绘请求和颜色索引递增。矩阵形式把这部分开销合并成一次调用。本文评述:在曲线数超过 50 条的场景下,应优先考虑矩阵形式或 line 函数配合 NaN 分隔,而不是无脑循环 hold on。
3.3 一个被低估的技巧:用 NaN 分隔多段线
如果多条线的 x 范围不同,矩阵形式无法直接使用。此时可以在数据中插入 NaN,让一条 Line 对象承载多段不连续的曲线:
x1 = 1:0.1:5; y1 = sin(x1);
x2 = 3:0.1:8; y2 = cos(x2);
xAll = [x1, NaN, x2];
yAll = [y1, NaN, y2];
plot(xAll, yAll, 'LineWidth', 1.5);
这种写法的代价是图例只能标注一次,无法为两段线分别命名。因此它适用于“同一物理量的多段测量”场景,而不适用于“多个不同物理量对比”场景。选择哪种写法,取决于你的图例需求,而不是单纯看性能。
四、图例区分的核心矛盾:谁被绑定,谁被忽略
4.1 legend 的自动收集规则
当调用 legend 不带参数时,MATLAB 会遍历当前 Axes 的子对象,收集那些 Annotation 属性为可见、且 DisplayName 非空的对象。这里有两个关键点:
- 没有设置 DisplayName 的 Line 会被自动赋予形如
data1、data2的默认标签; - 设置了
'HandleVisibility','off'的对象会被跳过。
第二条是解决“辅助线污染图例”的标准手段。例如画一条 y=0 的参考线,又不希望它出现在图例中:
yline(0, '--k', 'HandleVisibility', 'off');
plot(x, y1, 'DisplayName', '信号 A');
plot(x, y2, 'DisplayName', '信号 B');
legend('show');
如果不加 HandleVisibility 参数,图例里会多出一条“data3”,这是初学者最常见的困惑之一。笔者认为,凡是用于装饰、参考、标注的图形对象,都应该显式关闭句柄可见性,这应当成为绘图代码的基本卫生习惯。
4.2 显式绑定:legend 的句柄数组用法
比自动收集更可靠的做法是显式传入句柄数组:
h1 = plot(x, y1);
h2 = plot(x, y2);
h3 = plot(x, y3);
legend([h1, h3], {'基准信号', '对照组'}, 'Location', 'northwest');
这种写法明确指定了“哪两条线进入图例、以什么顺序、用什么文字”。它彻底绕开了自动收集的不确定性,在需要精细控制时是首选。缺点是当曲线数量动态变化时,句柄数组需要同步维护。
4.3 图例区分的四类方案对照
这四类方案并非互斥。工程实践中常见的组合是“颜色 + 线型 + 图例”,在曲线数不超过 12 条时效果稳定。当曲线数超过 12 条时,任何图例方案都会失效,此时应当考虑改为小倍数图(small multiples)或交互式高亮。
4.4 图例位置与遮挡问题的程序化处理
图例遮挡数据是另一个高频问题。MATLAB 提供 'best' 位置选项,但它的算法并不总是最优。一个更稳健的做法是计算数据空白区域:
lgd = legend('show');
lgd.Location = 'best';
% 若仍遮挡,可手动指定并设置半透明背景
lgd.Color = [1 1 1 0.85];
lgd.EdgeColor = [0.7 0.7 0.7];
半透明背景是实用技巧,它让被遮挡的曲线仍可隐约可见,降低信息损失。这一做法在 Matplotlib 中对应 legend(framealpha=0.85),在 ggplot2 中对应 theme(legend.background = element_rect(fill = alpha("white", 0.85)))。
五、颜色循环与色盲友好配色方案
5.1 ColorOrder 的机制
MATLAB 从 R2014b 开始引入 ColorOrder 属性,默认是一个 7×3 的 RGB 矩阵。每次 plot 创建新 Line 时,颜色索引自动递增,超过 7 条后循环回第一条。这意味着第 8 条线与第 1 条线颜色完全相同,这是多曲线图的一个隐藏陷阱。
解决方案有两种:一是扩展 ColorOrder,二是改用其他视觉通道。扩展 ColorOrder 的代码:
% 使用 ColorBrewer Set3 扩展配色(12 色)
newOrder = [0.55 0.34 0.29; 0.89 0.76 0.35; 0.70 0.79 0.42;
0.42 0.72 0.85; 0.76 0.55 0.76; 0.98 0.74 0.48;
0.99 0.99 0.70; 0.70 0.70 0.70; 0.85 0.85 0.85;
0.55 0.55 0.55; 0.40 0.40 0.40; 0.25 0.25 0.25];
set(gca, 'ColorOrder', newOrder);
ColorBrewer 是由 Cynthia Brewer 开发的经典配色方案库,其 Set3 方案专为定性数据设计[4]。需要说明的是,上述 RGB 值为笔者根据 ColorBrewer 公开色值转换的近似值,精确值请参考官方文档。
5.2 色盲友好性检验
约 8% 的男性存在红绿色觉异常[5]。默认的 MATLAB 配色中,红与绿相邻时对这类用户几乎不可区分。改进方向包括:
- 使用 Okabe-Ito 调色板(8 色,专为色盲设计)[6];
- 避免同时使用红/绿、蓝/紫作为主要区分通道;
- 叠加线型或标记作为冗余编码。
Okabe-Ito 调色板的八色为:黑、橙、天蓝、蓝绿、黄、蓝、朱红、紫红。它在 deuteranopia(绿色盲)模拟下仍保持良好区分度。本文评述:对于需要发表或交付的图表,色盲友好性应当作为硬性验收标准,而非可选项。
5.3 颜色、线型、标记的冗余编码矩阵
这张矩阵的设计原则是:任意两条曲线至少在两个通道上不同。这样即使打印成灰度图,或用户存在色觉异常,仍能通过线型和标记完成区分。该原则在可视化经典教材《The Visual Display of Quantitative Information》中有系统论述[7]。
六、进阶:双 Y 轴、子图与坐标轴分层
6.1 双 Y 轴的正确实现
当两条曲线的量纲差异巨大(如温度和压力),双 Y 轴是常见需求。MATLAB 从 R2016a 起提供 yyaxis:
yyaxis left
plot(t, temp, 'DisplayName', '温度 (°C)');
ylabel('温度 (°C)');
yyaxis right
plot(t, pressure, 'DisplayName', '压力 (kPa)');
ylabel('压力 (kPa)');
legend('show', 'Location', 'northwest');
使用 yyaxis 时有一个容易踩的坑:两次 yyaxis 调用会切换当前活动侧,后续的 plot 会画到当前侧。如果忘记切换,曲线会画错轴。此外,yyaxis 模式下 hold on 的行为与普通 Axes 略有不同,官方文档建议在 yyaxis 之前设置 hold 状态[8]。
6.2 tiledlayout 与共享坐标轴
R2019b 引入的 tiledlayout 是 subplot 的现代替代品,它支持共享 X 轴、自动紧凑布局:
t = tiledlayout(3, 1, 'TileSpacing', 'compact');
title(t, '多通道信号对比');
for k = 1:3
nexttile;
plot(x, signals(k, :), 'DisplayName', sprintf('通道 %d', k));
legend('show', 'Location', 'eastoutside');
end
xlabel(t, '时间 (s)');
ylabel(t, '幅值 (mV)');
tiledlayout 的优势在于它把“布局”和“绘图”解耦。笔者认为,新项目应当全面迁移到 tiledlayout,因为 subplot 的索引式布局在子图数量变化时维护成本极高。根据 MathWorks 的迁移指南,tiledlayout 在 R2020a 之后已完全稳定[9]。
6.3 坐标轴分层与透明度叠加
当曲线数量极多(如 100 条蒙特卡洛模拟路径),单条线已经无法辨认,此时应当改用透明度叠加,让密度本身成为信息:
hold on;
for k = 1:100
h = plot(x, paths(k, :), 'Color', [0.42 0.24 0.60 0.08]);
h.Annotation.LegendInformation.IconDisplayStyle = 'off';
end
plot(x, mean(paths, 1), 'Color', [0.36 0.13 0.60], 'LineWidth', 2.5);
这里用到了两个技巧:一是 RGBA 颜色中的 alpha 通道(第四个分量),二是通过 Annotation 属性关闭单条线的图例显示。最终只保留均值曲线进入图例。这种“背景云 + 前景线”的模式在金融风险分析和气象集合预报中非常常见。
七、跨语言对照:Matplotlib / ggplot2 / Makie
7.1 Python Matplotlib:隐式状态与显式对象
Matplotlib 没有 hold 开关,它的默认行为就是叠加。要清空需要显式调用 plt.cla() 或 plt.clf()。这种设计差异源于两者对“当前状态”的处理哲学:MATLAB 选择显式状态机,Matplotlib 选择隐式累积。
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(x, y1, label='信号 A', color='#0072B2', linestyle='-')
ax.plot(x, y2, label='信号 B', color='#D55E00', linestyle='--')
ax.legend(loc='best', framealpha=0.85)
ax.set_xlabel('时间 (s)')
ax.set_ylabel('幅值 (mV)')
Matplotlib 的面向对象接口(ax.plot)比 pyplot 状态机接口(plt.plot)更适合工程代码,因为前者不依赖全局状态。本文评述:MATLAB 的 hold on 本质上是在模拟 Matplotlib 的默认行为,但多了一层状态切换的心智负担。两种设计各有取舍,没有绝对优劣。
7.2 R ggplot2:图形语法与图层叠加
ggplot2 采用图形语法(Grammar of Graphics),多条线通过 group 或 color 美学映射自动生成,不需要 hold 概念:
library(ggplot2)
library(tidyr)
df_long <- pivot_longer(df, cols = c(y1, y2, y3),
names_to = "signal", values_to = "value")
ggplot(df_long, aes(x = time, y = value, color = signal)) +
geom_line(linewidth = 1) +
scale_color_manual(values = c("#0072B2", "#D55E00", "#009E73")) +
theme_minimal()
ggplot2 的“长格式数据 + 美学映射”范式把多曲线问题转化为数据整形问题。它的图例是自动生成的,且与数据列严格对应,几乎不会出现 MATLAB 中“图例对不上”的情况。代价是需要先做数据透视(pivot),对习惯宽格式的用户有一定学习成本。
7.3 Julia Makie:GPU 原生的声明式绘图
Makie 是 Julia 生态中的高性能绘图库,基于 GPU 渲染。它的多曲线写法接近 ggplot2 的映射思路,但性能更高:
using CairoMakie
fig = Figure()
ax = Axis(fig[1, 1], xlabel = "时间 (s)", ylabel = "幅值 (mV)")
for (i, y) in enumerate([y1, y2, y3])
lines!(ax, x, y, label = "信号 $i", color = colors[i])
end
axislegend(ax, position = :lt)
fig
Makie 的 lines! 带感叹号表示原地修改,与 MATLAB 的 hold on 有异曲同工之处,但它是函数级而非状态级。根据 Makie 官方基准测试,在 10 万点以上的曲线渲染中,Makie 的 GPU 后端比 Matplotlib 的 Agg 后端快 20 到 50 倍[10]。
7.4 四语言多曲线机制对照
八、工程规范:可复用的多曲线绘图模板
8.1 模板设计原则
基于前七章的分析,笔者提炼出多曲线绘图的五条工程原则:
- 句柄先行:每条需要标注的曲线都保存句柄,不依赖自动收集;
- 显式配色:不依赖默认 ColorOrder,显式指定颜色矩阵;
- 冗余编码:颜色、线型、标记至少两两组合;
- 装饰隔离:参考线、标注框关闭句柄可见性;
- 状态复位:函数退出前 hold off,避免污染调用方。
8.2 完整模板代码
function plotMultiSeries(x, Y, labels, opts)
% plotMultiSeries 多曲线绘制模板
% x - 1×N 横坐标
% Y - M×N 纵坐标矩阵,每行一条曲线
% labels - 1×M cell 数组,曲线标签
% opts - 可选参数结构体
arguments
x (1,:) double
Y (:,:) double
labels (1,:) cell
opts.Colors (:,3) double = lines(size(Y,1))
opts.LineStyles (1,:) cell = {'-','--',':','-.'}
opts.Markers (1,:) cell = {'none','o','s','^'}
opts.LineWidth (1,1) double = 1.5
opts.LegendLocation (1,:) char = 'best'
end
figure('Color', 'w');
ax = axes('NextPlot', 'add'); % 等价于 hold on
h = gobjects(1, size(Y,1));
for k = 1:size(Y,1)
h(k) = plot(ax, x, Y(k,:), ...
'Color', opts.Colors(k,:), ...
'LineStyle', opts.LineStyles{mod(k-1, numel(opts.LineStyles))+1}, ...
'Marker', opts.Markers{mod(k-1, numel(opts.Markers))+1}, ...
'MarkerSize', 5, ...
'LineWidth', opts.LineWidth, ...
'DisplayName', labels{k});
end
grid(ax, 'on');
box(ax, 'on');
legend(ax, h, 'Location', opts.LegendLocation, 'Interpreter', 'none');
xlabel(ax, 'X'); ylabel(ax, 'Y');
end
这个模板用 arguments 块做参数校验(R2019b+),用 NextPlot='add' 替代 hold on,避免了状态泄漏。它可以直接放进团队的工具箱函数库。
8.3 常见反模式清单
九、前沿预判:声明式绘图与 GPU 批次渲染
9.1 从命令式到声明式的范式迁移
hold on 是命令式绘图范式的典型产物:用户通过一系列命令改变系统状态。而 ggplot2、Vega-Lite、Makie 代表的声明式范式,则要求用户描述“数据如何映射到视觉通道”,由系统决定渲染顺序和批次。
Vega-Lite 的规范中,多曲线通过 layer 或 color 编码实现,完全没有“叠加”这个概念[11]。这种抽象层次的提升,使得同一份规范可以编译到 Canvas、SVG、WebGL 等不同后端。
本文评
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

