资讯动态

R语言多图组合全攻略:par到patchwork五大工具对比

发布时间:2026/9/20 15:08:45 来源:尧图企业网站定制
用R画图这些年最让人头疼的往往不是单张图画不出来而是怎么把一堆图拼成一张体面的组合图。cowplot、patchwork、layout()、par()、gridExtra这五套方案我前前后后都折腾过踩过不少坑也总结出了各自的适用边界。这篇文章就把它们从底层逻辑到实战用法拉通讲清楚每个工具的代码都能直接抄最后再聊聊我自己的选型标准和排错记录。1. 为什么plot()画完图后R的画布和你想象的完全不一样1.1 图形设备的坐标系与画布分配逻辑很多人在R里第一次拼图失败问题不是代码写错而是根本没搞懂R的图形设备graphics device是怎么运作的。简单说你用plot()画在屏幕上的每一个图形其实都落在一个看不见摸不着的画布上这个画布在R内部被划分成几层区域最外层是outer margin用oma参数控制往里是figure region用mar控制再往里才是真正的plot region绘图区。par(mfrow c(2, 2))这句代码的底层逻辑就是把这个画布物理切割成2行2列的四块figure region然后R内部维护一个当前活动区域的计数器每画一张图就跳到下一个格子。这和你想象中哪个格子是空的、我能不能自由指定完全是两码事——mfrow是严格按行优先顺序推进的。理解这层逻辑极其重要因为后面所有工具的本质区别都落在它们分别在哪一层做布局这个问题上。par()和layout()是直接在设备层面切分区域属于base系统gridExtra、cowplot、patchwork则是在grid图形系统上构建一棵视图树把每个绘图对象当作树上的节点来排列。这两个体系之间有着一道不容易察觉的鸿沟混用时经常翻车。1.2 组合多图的三个底层流派R生态里拼图工具虽多但底子上只有三套思路。第一套是设备切块派代表是par(mfrow)、par(mfcol)、par(fig)和layout()。它们不做任何数据层面的组合纯粹控制当前画布上哪个位置是活动绘图区。好处是零依赖、运行快坏处是布局能力弱想跨格拼图、加共享图例、对齐坐标轴都得手动精算坐标。第二套是视图树派代表是gridExtra、cowplot和patchwork。它们先把你传给它们的每一个ggplot对象转成一个gtablegrid里的表格布局对象再按照你给出的行列关系把这些gtable拼接成一棵更大的视图树。对使用者来说最大的感受是像排版软件一样拼图——可以指定宽度比例、对齐方式、嵌套结构。第三套是底层协议派直接操作grid包的原生对象viewport、grob、gtable。这不是普通用户该碰的东西但理解它的存在很重要因为ggplot2底层的坐标轴对齐、图例提取最终都落在这套协议上。cowplot::plot_grid()里的align参数本质就是在做gtable层面的宽度调整而不是什么魔法。1.3 从调参到布局为什么需要专门的工具你可能会问既然par(mfrow)几行代码就能搞定2x2拼图为什么还要引入一堆包答案在于你真正想要的是布局而不是切块。切块意味着所有格子大小一致、位置固定、图与图之间互不感知布局意味着你可以让第一张图占一整行、第二张图占右侧两格、所有图共享一个图例、坐标轴严格对齐。现实中的数据分析报告很少是规规矩矩的2x2网格更多是上面一张大图下面三张小图这种不对称结构。从效率角度说patchwork的|和/操作符还能让你用一行代码表达复杂嵌套布局这在探索阶段特别爽。而从维护角度说用cowplot拼出来的图代码语义清晰别人接手时一看就懂你要干什么。这就是专门工具存在的意义——它们把你从坐标计算中解放出来让你专注在布局关系上。2. 经典双雄par()和layout()是怎么把一张画布切成多张图的2.1 par(mfrow)快、糙、直接par(mfrow c(2, 2))是接触R图形最早学会的拼图方式适合快速预览四张图尤其是在循环里一口气画出多个变量的分布时非常方便。它的工作方式前面已经说了——把画布按行列切块依序填充。# 基本用法2行2列按行填充 par(mfrow c(2, 2)) plot(mtcars$mpg, main MPG) plot(mtcars$wt, main Weight) hist(mtcars$hp, main HP) boxplot(mtcars$disp, main Disp) par(mfrow c(1, 1)) # 切记重置这个函数有几个限制你早晚会撞上。第一所有格子大小完全一致没法让某张图占两列第二图与图之间的坐标轴不会自动对齐第三par()参数是一次性全局设置如果在拼图过程中某个绘图函数内部也调用了par()很可能把后续布局搞乱。所以用mfrow时建议把mfrow设置放在最前面拼完立刻用par(mfrow c(1, 1))恢复避免影响后面的图。还有一个容易被忽略的点mfrow和mfcol的区别在于填充顺序。mfrow按行填充mfcol按列填充。在需要先画完某一列再做其他事的场景下mfcol更顺手。2.2 par(fig)自由摆放不规则的入门方案mfrow只能产生均匀网格但par(fig c(x1, x2, y1, y2))能让你把下一张图放在画布上的任意矩形区域内。这四个数字是归一化坐标范围是0到1。# 大图占左侧2/3小图占右上角 par(fig c(0, 0.67, 0, 1), mar c(4, 4, 2, 1)) plot(mtcars$mpg, mtcars$wt, main Main plot) par(fig c(0.67, 1, 0.55, 1), new TRUE, mar c(2, 2, 1, 1)) hist(mtcars$mpg, main MPG hist) par(fig c(0.67, 1, 0, 0.45), new TRUE, mar c(2, 2, 1, 1)) boxplot(mtcars$wt, main WT box)关键在new TRUE它的意思是在已有画布上叠加新的绘图区域而不是清空画布。如果不加这句会直接把上一个图区域覆盖掉。这招在base绘图年代是嵌入小图的唯一手段但现在看有两个明显问题坐标全得手算稍微复杂点的布局就要在草稿纸上算半天而且fig是归一化坐标和你的实际图尺寸、边距参数mar之间容易产生叠加误差经常出现图上图露馅。2.3 layout()的核心优势layout matrix的理解layout()之所以比mfrow强是因为它引入了一个核心概念布局矩阵。你把一个矩阵传给layout()矩阵里每个元素代表一张图的编号相同编号的元素表示这张图跨越多个格子。# 第一张图占整行下面三张图各占一列 mat - matrix(c(1, 2, 3, 4), nrow 2, byrow TRUE) mat # [,1] [,2] [,3] # [1,] 1 1 1 - 第一行三个格子都是图1 # [2,] 2 3 4 - 第二行分别是图2、3、4 layout(mat) plot(1:10, main Top full-width) plot(1:10, main Left) plot(1:10, main Middle) plot(1:10, main Right)这里matrix(c(1, 2, 3, 4), nrow 2, byrow TRUE)会生成一个2行3列的矩阵因为12个元素分两行第一行全是1第二行是2、3、4。layout()读这个矩阵的时候遇到相同的数字就把这些格子合并成一个大的绘图区域。这比mfrow灵活太多。更强大的功能来自widths和heights参数它们控制每列每行的相对宽度或高度# 第二列两倍宽于第一列 layout( matrix(c(1, 2), nrow 1), widths c(1, 2) ) plot(1:10, main Narrow) plot(1:10, main Wide)宽度可以传数值相对比例也可以传lcm(2)这样的绝对单位。这在做需要精确控制尺寸的报告图时非常有用。另外layout()还有配套的layout.show(n)函数可以在正式画图前预览每个subplot将落在哪个位置强烈建议每次画之前跑一下能少走很多弯路。2.4 layout的坑common legend、复杂嵌套时不方便layout()虽强但它终究还是在base体系里几个痛点绕不开。最头疼的是共享图例。base画的图图例要么画在图内legend(topright, ...)要么单独留一块区域专门画图例。用layout()要留图例区就得在布局矩阵里单独给图例留一个位置然后调用plot.new()和legend()往里填。问题是图例的文字长度、条目数量一变预留区域的大小就不好控制经常出现图例被截断或者空一大块。# 底部预留图例区域 mat - matrix(c(1, 1, 2, 3, 4, 5), nrow 2, byrow TRUE) # 常见问题图例区大小靠猜文字长一点就溢出另一个痛点是复杂嵌套布局。layout()只能表达网格合并没法表达左侧是一列两行的图、右侧是一行两列的图这种跨层级的嵌套关系。虽然可以通过在矩阵里反复用同一个数字来实现部分效果但矩阵会变得非常难读。我自己维护过一段用了layout()的老代码三个月后回来看那个矩阵完全记不清当初是怎么设计的。所以在base体系里我的建议是快速固定网格用mfrow不对称但规则的结构用layout()再复杂的嵌套布局就干脆跳到grid大家族。3. grid体系三剑客gridExtra、cowplot、patchwork怎么选3.1 gridExtra老牌稳妥但API设计略显生硬gridExtra是较早进入大众视野的拼图包核心函数是grid.arrange()和arrangeGrob()。前者直接把图渲染到当前图形设备上后者返回一个gtable对象方便你再嵌入别的地方。基本用法library(gridExtra) library(ggplot2) p1 - ggplot(mtcars, aes(mpg, wt)) geom_point() p2 - ggplot(mtcars, aes(factor(cyl), mpg)) geom_boxplot() grid.arrange(p1, p2, ncol 2)也支持用matrix参数模拟layout()的跨格效果grid.arrange( p1, p2, p3, p4, layout_matrix rbind(c(1, 1, 2), c(3, 4, 4)) )gridExtra的最大价值在于老而稳。它在CRAN上存在多年文档齐全和ggplot2的兼容性好适合写正式的分析报告代码。但它的问题也很明显API不够直观。ncol、nrow、widths、heights、layout_matrix这些参数要记一大堆写复杂布局时容易在括号里迷失。而且grid.arrange是直接画在当前设备上如果你想把它嵌进更复杂的布局还得先转成arrangeGrob再手动处理这个步骤对新手不太友好。3.2 cowplot精准对齐和标签功能的代表cowplot出现的时候解决了一个当时非常痛的问题多图之间的坐标轴对齐。cowplot::plot_grid()的align参数可以按h水平、v垂直或hv两个方向都对齐来调整子图让它们的绘图区域拥有完全一致的边界。这在比较多个时间序列的走势时尤其重要——如果两张图坐标轴长度不一致视觉上会非常误导。library(cowplot) library(ggplot2) p1 - ggplot(mtcars, aes(mpg, wt)) geom_point() p2 - ggplot(mtcars, aes(hp, qsec)) geom_point() plot_grid(p1, p2, labels c(A, B), align hv)plot_grid()的rel_widths和rel_heights参数是它的另一个杀手锏。你可以让左边图占两倍宽度、右边图占一倍宽度而不用像layout()那样靠数值矩阵去猜。配合labels参数可以在子图上自动标注A、B、C省去了手动加annotate()的麻烦。cowplot还有一个独门绝技plot_grid()里嵌套plot_grid()实现多层嵌套布局。比如外层的左边是两张图组合右边是一张大图inner - plot_grid(p1, p2, ncol 1, align hv) plot_grid(inner, p3, ncol 2)这个能力让cowplot在需要精确控制嵌套结构的场景下无可替代。但代价是语法写起来稍微啰嗦。如果你只是快速试布局每次都要写inner变量再套一层确实不如patchwork来得爽利。3.3 patchwork用和/直接定义布局直观到不像Rpatchwork是我现在最常用的拼图工具核心优势就是语法极其简洁。你不需要记任何矩阵或ncol参数直接用表示左右并列用/表示上下堆叠library(patchwork) library(ggplot2) p1 - ggplot(mtcars, aes(mpg, wt)) geom_point() p2 - ggplot(mtcars, aes(hp, qsec)) geom_point() p3 - ggplot(mtcars, aes(cyl, mpg)) geom_boxplot() # 左边p1右边(p2在p3上面) p1 (p2 / p3)更爽的是你可以用plot_layout()函数给整体指定行列宽度、嵌套对齐、是否共享图例p1 (p2 / p3) plot_layout(widths c(2, 1))还能用双括号和括号的嵌套组合表达非常复杂的布局比如左上角大图右边两行底下横跨全宽(p1 | (p2 / p3)) / p4这种语法表达力强读起来又符合直觉探索阶段改布局就是改一行代码的事。patchwork还内置了操作符可以对所有子图统一设置主题或坐标轴范围比如(p1 | p2) theme_minimal()省去逐张图修改的麻烦。不过patchwork有一个小坑它是通过重载操作符实现的如果你在同一个表达式里混用了ggplot2内部的用来往图层上添加组件容易把ggplot的图层操作和patchwork的布局操作搞混。一般建议先把每一张子图赋值给独立变量再在最后一步用patchwork的来拼接避免歧义。3.4 三种工具的能力边界对比维度gridExtracowplotpatchwork布局语法ncol/nrow/layout_matrixplot_grid(嵌套)、/、表达嵌套较弱需layout_matrix强可无限嵌套最强接近直觉坐标轴对齐需手动自带align参数自带align参数自动标签无labels参数plot_annotation(tag_levels)共享图例需手动get_legend 手动内置common.legend学习曲线中等中等偏深平缓典型场景老代码维护正式报告快速探索和交互表格里的共享图例一列值得展开说。gridExtra本身没有专门的图例合并函数得自己用gridExtra::grid.arrange配合ggplotGrob手工处理cowplot提供了get_legend()函数提取图例再用plot_grid放到底部patchwork则更简单plot_layout(guides collect)或者 guide_area()就能实现。但简单也意味着自定义余地小如果你需要把多个图例排列在不同位置patchwork反而不如手动处理灵活。所以我的建议是探索阶段用patchwork交付厚报告用cowplot维护老代码才用gridExtra。这不是说哪个工具不好而是每个工具的设计哲学决定了它最擅长的场景。4. 实战一个企业级报告图的五种组合方案4.1 任务描述典型报告页面包含什么空谈工具没有意义我拿一个真实任务来对比。假设你在做一个「销售数据分析报告」需要在一页图里呈现四个板块左上方销售额趋势折线图时间跨度长要占较大面积右上方各品类销售占比饼图左下方各地区销售额柱状图右下方客单价分布直方图底部一段共享图例因为折线图有分组这几乎是商业报告里最常见的不对称布局。下面是五种方案的实现我们逐一看每种方案的代码量和可维护性。4.2 方案一仅用basepar layout实现library(ggplot2) # 准备数据略 plot_trend - ggplot(df_sales, aes(date, revenue, color region)) geom_line() labs(color Region) plot_pie - ggplot(df_sales, aes(x , y share, fill category)) geom_bar(stat identity, width 1) coord_polar(theta y) plot_bar - ggplot(df_sales, aes(region, revenue)) geom_col() plot_hist - ggplot(df_sales, aes(unit_price)) geom_histogram(bins 30) # 用layout矩阵第一行左侧占两格右侧占一格第二行各占一格 layout( matrix(c(1, 1, 2, 3, 4, 5), nrow 2, byrow TRUE), widths c(2, 1, 1) ) # 这里还要手动调整每个ggplot的plot.margin让它们看起来整齐 print(plot_trend theme(plot.margin margin(5, 5, 5, 5))) print(plot_pie theme(plot.margin margin(5, 5, 5, 5))) print(plot_bar theme(plot.margin margin(5, 5, 5, 5))) print(plot_hist theme(plot.margin margin(5, 5, 5, 5)))这就是混用base和ggplot最容易踩的坑layout()切分的是base图形设备的区域但如果你直接print(ggplot对象)ggplot会接管当前区域并重新计算内部边距theme(plot.margin)调起来非常繁琐。而且图例区域你还得额外用一个空位置加legend()图例文字一多很容易溢出。这套方案技术上可行但维护成本很高我自己现在基本不会这么干。4.3 方案二gridExtra实现library(gridExtra) grid.arrange( plot_trend, plot_pie, plot_bar, plot_hist, layout_matrix rbind(c(1, 1, 2), c(3, 4, 5)), widths c(2, 1, 1) )gridExtra代码比base简洁但有两个问题一是widths只作用于列无法单独指定第一行第二列比第三列宽这种细粒度控制二是那张共享图例gridExtra没有内置方案得手动提取ggplot的gtable再拼接# 提取图例 legend_grob - ggplotGrob(plot_trend)$grobs[[which( sapply(ggplotGrob(plot_trend)$grobs, function(x) x$name) guide-box )]] grid.arrange( plot_trend, plot_pie, plot_bar, plot_hist, legend_grob, layout_matrix rbind(c(1, 1, 2), c(3, 4, 5), c(6, 6, 6)), widths c(2, 1, 1), heights c(2, 2, 0.3) )提图例的代码看着就劝退而且heights c(2, 2, 0.3)这种凭感觉给高度的做法很容易在字体缩放后导致图例区域过高或过矮。4.4 方案三cowplot实现library(cowplot) plot_grid( plot_grid(plot_trend, plot_pie, nrow 1, rel_widths c(2, 1)), plot_grid(plot_bar, plot_hist, nrow 1), ncol 1, rel_heights c(2, 1) )cowplot的嵌套思路非常清晰先把上方一左一右拼成inner_top再把下方一左一右拼成inner_bottom最后上下堆叠。rel_widths和rel_heights让比例控制变得直观。共享图例在cowplot里也有专门的方案先用get_legend()提取再把它和主图拼在一起legend_bottom - get_legend(plot_trend theme(legend.position bottom)) plot_grid( plot_grid( plot_trend theme(legend.position none), plot_pie, nrow 1, rel_widths c(2, 1) ), plot_grid(plot_bar, plot_hist, nrow 1), legend_bottom, ncol 1, rel_heights c(2, 1, 0.1) )这里要注意legend.position bottom的图例高度会随字体缩放变化所以rel_heights c(2, 1, 0.1)里的0.1只是初始值渲染后如果发现图例被截断需要调到0.15或0.2。这是cowplot实际使用中最常被忽略的地方。4.5 方案四patchwork实现与最终对比library(patchwork) design - AB CD (plot_trend plot_pie plot_bar plot_hist) plot_layout(design design)patchwork还支持用字符串定义布局区域A、B代表位置同一字母出现多次表示跨格design - AAB CCD (plot_trend plot_pie plot_bar plot_hist) plot_layout(design design, widths c(2, 1, 1))更棒的是patchwork内置了共享图例(plot_trend plot_pie plot_bar plot_hist) plot_layout(design design, guides collect) plot_annotation(tag_levels A)guides collect会自动把所有的图例汇总成一个共享图例位置默认在右侧或底部省去手工提取的步骤。plot_annotation(tag_levels A)则自动给每个子图加上A、B、C、D编号。从代码量上看patchwork完胜。但要说清楚一点代码短不等于所有场景都合适。如果你需要精细控制每个子图之间的间距、background、边框这样的细节cowplot的align和rel_widths体系反而更顺手。4.6 方案对比总结方案代码量布局灵活度共享图例支持嵌套表达可读性parlayout中中低需手写弱低gridExtra中中需手写中中cowplot中高高有专用函数强中高patchwork少高内置最强高多写几张图你就会有感觉探索布局时用patchwork因为改起来快正式交付时我会转成cowplot因为它的结构更显性别人接手时通过读plot_grid的嵌套关系就能理解版面设计。gridExtra则像是旧时代的可靠伙伴在没有其他选择的场景下也能完成任务但没必要主动选它。5. 五个工具混战之后我总结的选型心得与踩坑记录5.1 多工具混用的正确姿势什么时候该混怎么混工具之间不是互斥的实际情况中经常需要组合使用。比如我用patchwork搭好主版面但内部某个局部结构想用cowplot的plot_grid因为那里需要align hv严格对齐inner_align - cowplot::plot_grid( p2, p3, ncol 1, align hv ) p1 inner_align plot_layout(widths c(2, 1))这里要注意plot_grid返回的是gtable/ggplot对象patchwork能识别它。但如果你把gridExtra::grid.arrange的结果直接传给patchwork就不行因为grid.arrange是没有返回值直接画到设备上的你必须改用gridExtra::arrangeGrob拿到对象才能参与组合。还有一个重要的混搭场景用patchwork拼好图之后如果还想在它外面再套一层par(mfrow)就不行了。patchwork是基于grid的一旦用了grid绘图base设备的工作方式就被切断了。如果你非要在同一个页面上同时放ggplot拼图和base图最稳妥的办法是把base图转换成ggplot对象用ggplotify::as.ggplot()包或者用cowplot::ggdraw()当画布在上面draw_image()和draw_plot()。5.2 常见报错与混乱状态排查记录报错「figure margins too large」这可能是不少人用par系工具时第一次崩溃。原因很简单——你设置的边距参数太大或图形设备窗口太小导致绘图区域变成负数。解决方法有两个把mar或oma调小或者把设备窗口拉大。但如果你是在RStudio里用小尺寸的Plots面板建议先拉大面板看看因为代码本身可能没写错。patchwork布局突然乱套多半是括号写错。p1 p2 / p3表示(p1 p2) / p3而p1 (p2 / p3)才是p1在左边右边p2在上p3在下。差异巨大务必用括号把层级写清楚。我刚用patchwork时踩过好几次这坑后来养成了习惯凡是嵌套布局一律用括号显式分组绝不依赖R的运算符优先级。cowplot对齐之后坐标轴还是歪的可能的原因是子图里有coord_flip()、coord_polar()这类改变坐标系的图层。align只在常规笛卡尔坐标系下才有意义饼图的极坐标对齐不了是正常的。另一个原因是子图的theme()里设了不同的axis.title边距导致绘图区域尺寸天然不同align能对齐绘图区但内部元素仍然视觉上不整齐。共享图例字体忽大忽小在cowplot或patchwork里提取图例时图例是作为独立gtable嵌入的它的大小不会跟随主图全局缩放。如果你最后用ggsave(scale ...)调整图片尺寸图例文字和主图文字之间可能出现比例不协调。解决办法是手动设置图例的字体大小比如theme(legend.text element_text(size 12))让它和主图的theme(text ...)协调。设备没恢复导致后续图全乱par(mfrow)设置之后忘记恢复后面画的所有图都会继续被分块。这个问题在R脚本里尤其阴险因为脚本越跑越长你根本想不到是几百行之前一个par()在捣乱。保险做法是在脚本开头保存old_par - par(no.readonly TRUE)在脚本结束用on.exit(par(old_par))恢复或者各自图块内尽量不用会修改全局状态的函数。5.3 我的选型标准什么场景用哪个在踩了足够多的坑之后我给自己定了一套选型标准分享出来供你参考快速探索、调整布局阶段默认patchwork。代码量最小改布局成本最低和/的语法让你在草稿阶段就能快速尝试十几种版面方案找到最合适的一种。正式报告、论文插图输出转用cowplot。不是因为它比patchwork功能强而是因为plot_grid的嵌套结构在交付后更容易被其他同事理解和修改labels参数也更规范。纯base环境或嵌入式设备用layout()。有些老BI系统或自定义的shiny app里引入新包成本高layout()零依赖就能解决大部分需求。我建议只要布局不是特别复杂的网格layout()完全可以胜任。动态生成报告、批量产出坚持patchworkggsave的组合。plot_layout(widths c(...))这种表达在循环里很好生成不像cowplot那样要构建嵌套变量。老代码维护原样保留现有工具。不要在维护老项目时顺手改成新工具除非你可以确认改动不会引入回归。通常我的原则是新项目用新工具老项目保持不动。5.4 最后再分享一个小技巧很多人不知道patchwork有一个wrap_plots()函数它接受一个列表自动帮你把列表里的图排列成网格。这个函数在批量生成图表时极其实用——你可以把10张图放在一个list里然后wrap_plots(plot_list, ncol 2)一把梭。甚至可以直接传入nrow或ncol之外的byrow参数控制填充顺序。library(purrr) plot_list - map(names(mtcars)[1:6], function(var) { ggplot(mtcars, aes_string(var)) geom_histogram(bins 20) }) wrap_plots(plot_list, ncol 3)这套写法配合purrr::map几乎能无脑应付任何一个多字段批量可视化的需求。回看这几个工具par()和layout()代表的是base时代的严谨和克制gridExtra奠定了grid拼图的基本范式cowplot在坐标轴对齐上做到了极致patchwork则用最优雅的语法把布局体验带到新高度。没有谁完全代替谁关键看你的场景。我希望这篇文章能帮你省下当初我踩坑花掉的那些时间。如果你在自己的拼图过程中碰到什么特殊情况也欢迎随时分享——毕竟每次遇到的怪问题才是真正让人进步最快的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价