做Java GUI开发的人,早晚都会碰上一个让人抓狂的问题:用drawString画出来的文字,位置死活对不上预期。明明调了半天坐标,字还是往上飘、往下坠,或者左右差几像素。等你折腾完才发现,真正应该先弄清楚的是一个叫FontMetrics的类——它是 Java 字体渲染体系里最基础、也最容易被人忽略的“测量工具”。
这篇文章我就围绕FontMetrics字体属性类展开,把它的度量模型、常用方法、真实绘图场景中的计算公式,以及我在多个项目里踩过的坑一次性讲清楚。不管你是刚开始写 Swing 自定义控件,还是已经做了很久 Java 桌面端,只要你需要手动绘制文本、做文本居中、做多行排版,这篇文章都值得花十分钟看完。
1. 先弄明白:FontMetrics 到底在量什么
1.1 一个基线引发的“垂直居中失败”
先从我第一次写自定义按钮的遭遇说起。当时我想在一个 200 高的面板上画一行字,于是很朴素地写了g.drawString("你好", x, 100),以为 y 传 100 就是把字的顶部放在 100 的位置,结果画出来文字整体偏向面板上方,下面空了一大截。
后来我才知道,drawString的第二个 y 参数根本不是文字的“顶部坐标”,而是文字的“基线坐标”(baseline)。就像英语练习本上那条横线,大部分字母都是“坐在”这条线上写的,而字母顶部和底部离基线都有距离。如果直接用面板高度的一半当 y,那么实际看到的文字主体就被抬高了,因为字母的上半部分(ascent)比下半部分(descent)长得多。
这个问题的本质,就是FontMetrics这类字体度量类存在的意义:它告诉你在当前字体下,文字从基线向上能伸多高、向下能探多低、一行占多高、一段字符串占多宽。没有这些数据,任何“居中”“对齐”“换行”都是瞎蒙。
1.2 ascent、descent、leading、height 四个量别搞混了
FontMetrics里有几个核心度量值,理解它们比背 API 重要得多:
ascent:从基线到文字顶部的距离,也就是字母上伸部分的高度。descent:从基线到文字底部的距离,也就是字母下探部分的高度。leading:字体设计者预留的行间距,通常值很小甚至为 0。height:leading + ascent + descent,代表这一行文字占用的总高度。
这里最容易被误解的是字号的 size。new Font("SansSerif", Font.PLAIN, 24)里的 24 是字体的点尺寸(point size),它并不等于文字的视觉高度,更不等于FontMetrics.getHeight()。中文黑体、宋体在同字号下,视觉块大小和行高都不一样;西文字体里g、y的下探部分更是五花八门。FontMetrics拿到的height,才是绘制时真正应该参考的“一行文字占多高”的值。
我做东西的时候习惯用“四线三格”来类比。英文练习本有四条线,中间那条横线就是基线,字母a、c、e坐在基线上,b、d从基线往上顶,p、q从基线往下穿。ascent是第 1 条线到第 3 条线的距离,descent是第 3 条线到第 4 条线的距离。想好这层关系,后面所有坐标计算都不会乱。
1.3 为什么 FontMetrics 必须从 Graphics 上拿
这是新手最常踩的第二个坑:想用一个“全局通用”的字体度量对象。实际代码里,FontMetrics并不是一个随手new的工具类,它依赖渲染上下文。同一个Font在普通屏幕、高分屏、非抗锯齿环境下,度量结果可能有差异;更准确地说,FontMetrics内部会结合当前Graphics的FontRenderContext,把字体点尺寸换算成当前环境下的像素尺寸。
所以最稳妥的写法是:在paintComponent里面,从正在绘制的Graphics2D对象上获取度量:
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; FontMetrics fm = g2.getFontMetrics(); // 使用 g2 当前字体 // 使用 fm 测量、绘制 }这样拿到的度量和你即将drawString的环境完全一致,不会出现“测量时用一个字体,绘制时又换了字体”的错位问题。后面讲踩坑的时候,你会看到这个细节有多关键。
2. 常用 API 清单和取对象的三条路
2.1 十个高频方法一览
FontMetrics的方法不算多,但每个都有自己对应的场景。我按高频程度整理了一张表,方便随手查阅:
| 方法 | 返回值含义 | 典型使用场景 |
|---|---|---|
getAscent() | 当前字体基线到顶部的距离 | 计算文字顶部位置、画背景矩形 |
getDescent() | 当前字体基线到底部的距离 | 计算文字底部位置、画背景矩形 |
getLeading() | 字体内置的行间距建议值 | 一般配合height使用 |
getHeight() | 一行文字的总高度(含 leading) | 多行文本的行距递增 |
stringWidth(String) | 传入字符串的逻辑宽度 | 水平居中、自动换行、截断判断 |
charWidth(char) | 单个字符的宽度 | 逐字排版 |
getMaxAscent() | 字体内所有字形的最深上伸值 | 保守的高度估算,对齐大段文本 |
getMaxDescent() | 字体内所有字形的最深下探值 | 保守的底部估算 |
getMaxAdvance() | 最宽字符的最大前进宽度 | 估算文本区域最大宽度 |
getFont() | 返回关联的Font对象 | 对照确认度量与字体的对应关系 |
绝大多数业务代码里,getAscent()、getDescent()、getHeight()和stringWidth()这四个就够用了。其他方法属于锦上添花,遇到特定排版需求再翻出来不迟。
2.2 获取 FontMetrics 的三种姿势
第一,也是我最常用的:从Graphics拿。
FontMetrics fm = g.getFontMetrics(); FontMetrics fm2 = g.getFontMetrics(font); // 指定字体无参版本获取的是g当前字体的度量;带参版本会在内部临时切换字体计算,不影响g本身的字体状态。这个方式最可靠,因为度量环境和你实际绘制环境完全一致。
第二,脱离Graphics静态获取:
FontMetrics fm = Toolkit.getDefaultToolkit().getFontMetrics(font);这种方式不需要图形对象就能拿到,代码写起来方便,但它基于默认屏幕渲染上下文。如果代码跑在无头模式(headless)环境下,Toolkit相关操作可能会出问题。
第三,在 headless / 后台线程中兜底:
BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); Graphics2D ig = img.createGraphics(); FontMetrics fm = ig.getFontMetrics(font); // 测量 / 排版 ig.dispose();BufferedImage的 createGraphics 不依赖真实显示器,后台任务也能安全使用。我用它做过服务端的文字宽度预计算,稳定可靠。
2.3 缓存与线程安全:这个类没那么“无脑”
FontMetrics的创建有开销,特别是频繁在不同字体间切换的时候。长时间运行的 GUI 程序里,如果一个自定义控件每次都现取现用,性能影响虽然不至于卡顿,但反复创建对象会产生大量垃圾。我一般会把静态文本的FontMetrics缓存起来,比如在JPanel里维护一个Font到FontMetrics的小型 Map。
但同时要记住:FontMetrics不是设计成线程安全的。它内部可能缓存本次测量的字符宽度,多个线程同时用同一个实例做测量,理论上会有数据错乱的风险。跨线程使用时,要么每个线程自己获取,要么确保访问同一个Graphics对象时同步。实际操作中,Swing 的绘制线程本来就是单线程模型,同一个组件的paintComponent不会并发执行,所以“只在绘制线程中测量”这个习惯,能规避掉九成问题。
另外一点:我习惯把一个渲染次数很高的文本宽度预先算好缓存起来,而不是每次paint都调stringWidth。比如在表格列宽自适应、图标角标数字动态变化的场景下,预计算能明显减少绘制时间的抖动。
3. 实战一把:文本居中、自动换行与省略号截断
3.1 自定义面板里画一行居中的字
回到开篇的问题:到底怎么让一行文字在面板里真正居中?关键在于把 y 坐标当成基线来处理。
文字要垂直居中,需要让文字块的上边缘和面板上边缘的距离,等于文字块的下边缘和面板下边缘的距离。文字块高度用ascent + descent更接近视觉感受,因为getHeight()里那一点点leading是行间距,画单独一行时算进去反而显得靠上。
import javax.swing.*; import java.awt.*; public class CenterTextPanel extends JPanel { private final String text = "Hello, FontMetrics"; public CenterTextPanel() { setPreferredSize(new Dimension(400, 200)); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g.create(); g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2.setFont(new Font("SansSerif", Font.PLAIN, 24)); FontMetrics fm = g2.getFontMetrics(); int textWidth = fm.stringWidth(text); int textHeight = fm.getAscent() + fm.getDescent(); int x = (getWidth() - textWidth) / 2; int y = (getHeight() - textHeight) / 2 + fm.getAscent(); g2.drawString(text, x, y); g2.dispose(); } public static void main(String[] args) { JFrame frame = new JFrame(); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.add(new CenterTextPanel()); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); } }这段代码的 y 计算是整个问题的核心。拆开看:
(getHeight() - textHeight) / 2是面板高度减去文字块高度后,文字块顶部应该离面板顶部多远;但drawString不认识“文字块顶部”,它只认基线,所以要再加上一个ascent,把顶部坐标换算成基线坐标。
这个公式我几乎在每一个自定义控件里都会用。画通知角标、画自定义开关里的文字、画图表坐标轴标签,本质都是同一个套路。
3.2 多行文本自动换行:别再用 '\n' 硬撑
多行排版比单行更容易翻车,尤其是“每行宽度不同、需要自动换行”的场景。手动在字符串里塞换行符,换行点全靠猜,一改字体大小全部作废。正确做法是逐字符测量,宽度超了就换行。
我常用下面这个换行工具类方法:
import java.awt.FontMetrics; import java.util.ArrayList; import java.util.List; public class TextWrapUtil { public static List<String> wrap(String text, FontMetrics fm, int maxWidth) { List<String> lines = new ArrayList<>(); if (text == null || text.isEmpty()) { return lines; } // 先按显式换行符分段 String[] segments = text.split("\n", -1); for (String segment : segments) { if (segment.isEmpty()) { lines.add(""); continue; } StringBuilder currentLine = new StringBuilder(); int currentWidth = 0; for (int i = 0; i < segment.length(); i++) { int codePoint = segment.codePointAt(i); String ch = new String(Character.toChars(codePoint)); if (Character.isSupplementaryCodePoint(codePoint)) { i++; // 跳过代理对中的低位代理 } int charWidth = fm.stringWidth(ch); // 如果加上当前字符会超出最大宽度,并且当前行已经有点内容,就换行 if (currentWidth + charWidth > maxWidth && currentLine.length() > 0) { lines.add(currentLine.toString()); currentLine.setLength(0); currentWidth = 0; } currentLine.append(ch); currentWidth += charWidth; } lines.add(currentLine.toString()); } return lines; } }为什么不用fm.charWidth(segment.charAt(i))直接量?因为含 Emoji 或者其他增补平面字符时,charAt(i)只能取到半个字符,得到的宽度是乱的。这里用codePointAt+Character.isSupplementaryCodePoint处理,能把真正的码点位取出来,再量宽度。
绘制多行时,行与行之间的间距直接用fm.getHeight()递增:
int lineHeight = fm.getHeight(); int baselineY = topY + fm.getAscent(); for (String line : lines) { g2.drawString(line, startX, baselineY); baselineY += lineHeight; }这样设置行距,会把leading也纳入计算,行与行之间不会挤在一起。对于代码注释、描述性文本、通知消息这类场景,效果跟成熟编辑器里的普通文本区域已经很接近了。
3.3 单行截断:“…”不是简单 substring 一下
列表、菜单、表格里,文本长了都要截断显示省略号。最直观的做法是不断去掉最后一个字符再量宽度,但遇到很长的字符串,逐字符截断会明显卡顿。我习惯用二分法找最大可显示字符数,效率稳定,代码也清晰:
import java.awt.FontMetrics; public class EllipsizeUtil { public static String ellipsize(String text, FontMetrics fm, int maxWidth) { if (text == null || text.isEmpty()) { return ""; } String dots = "..."; int dotsWidth = fm.stringWidth(dots); // 全部放得下,直接返回 if (fm.stringWidth(text) <= maxWidth) { return text; } // 极端情况:省略号本身就超宽 if (dotsWidth >= maxWidth) { String first = text.substring(0, 1); return fm.stringWidth(first) <= maxWidth ? first : ""; } int low = 0; int high = text.length(); while (low < high) { int mid = (low + high + 1) >>> 1; String sub = text.substring(0, mid); if (fm.stringWidth(sub) + dotsWidth <= maxWidth) { low = mid; } else { high = mid - 1; } } return text.substring(0, low) + dots; } }二分法的前提是:字符宽度随字符数增加单调不减。对绝大多数字体成立。极端的中文标点、全角字符会有宽度跳动,但总体趋势仍然是递增的,二分结果与实际最优解相差不大,可以接受。
使用的时候注意,maxWidth也要根据具体情况留一点余量,比如表格单元格内边距,否则省略号和相邻控件会贴在一起,看着很挤。
4. 踩坑实录:这些问题我基本都遇到过
4.1 字体换粗体后,文字位置“横跳”
这是个特别隐蔽的问题。我做过一个自绘菜单,鼠标悬停时要加粗。当时paint方法里先量了高度,再画文字,用的却是两套字体:测量用普通体,绘制用粗体。结果每次悬停,整行文字宽度变了,右边的图标位置也跟着跳。
原因就是FontMetrics和实际绘制字体不一致。粗体字符平均宽度比普通体大,用普通体量出来的字符串宽度放到粗体上完全不对。排查方式也简单:把这个组件里所有“先取FontMetrics,再setFont”的代码理顺,保证测量字体和绘制字体是同一个对象。
我后来养成了一个习惯:在任何paint方法里,先把字体setFont完成,再从同一个Graphics获取FontMetrics,绝不让测量和绘制跨字体操作。这一点对自定义控件尤其重要。
4.2 中文/Emoji 宽度对不上,排版塌了
英文排版稳得很,一换中文或者加个 Emoji,宽度突然不对,这是很多人的噩梦。先说中文:中文字符通常接近一个固定方块宽度,但并不是所有字体都如此。更麻烦的是 Emoji,一个 Emoji 在部分字体里宽度接近两个普通字符,而且本身可能是代理对,charAt截半就乱了。
遇到过的情况是:用fm.stringWidth量的字符串和最终drawString渲染出来的宽度差异很大。经排查,大多发生在字体本身不含对应字形,系统自动 fallback 到另一套字体渲染时,FontMetrics内部执行了字体替换计算,但替换字体在不同平台上的度量值不同,导致视觉和测量有偏差。
针对这个问题的建议是:尽量在测量和绘制时使用同一套逻辑,如果对宽度敏感(例如芯片级像素对齐),可以考虑自行锁定字体,或者对 Emoji 单独做宽度处理,比如统一当两个普通字符宽来处理。无脑相信stringWidth而忽略字体替换,是排版塌掉的高危因素。
4.3 HiDPI 高分屏下偏大偏小
高分屏在 Windows、Linux 上越来越普遍,如果你的自绘控件里“手算”了 DPI,很容易踩进缩放泥潭。核心认知是:Swing 的Graphics2D在 HiDPI 环境下已经带了缩放变换,你拿到的Graphics坐标系是逻辑坐标系;FontMetrics在缩放后的Graphics上获取,测出来的宽度、高度也是逻辑单位。
所以,只要你的绘制逻辑全程在paint内使用同一个Graphics对象,让系统帮你做缩放,就没有问题。真正翻车的是自己用Toolkit.getDefaultToolkit().getScreenResolution()读取物理 DPI,再手动去乘字号,结果 100% 缩放下正常,150% 缩放下文字突然大了一圈。记住一句话:Swing 里画图不要自己折算 DPI,它的图形上下文已经处理好了;你折算一次,就是重复造轮子,还容易造歪。
4.4 文字背景色总是差一两像素
自绘标签、按钮通常要给文字画一个高亮背景矩形。很多人画矩形时,高度用fm.getAscent() + fm.getDescent(),矩形起点定在y - fm.getAscent()。这个做法对大部分拉丁文字是正确的,但对中文、斜体、带重音的字体,矩形边缘总会多出或缺少一两像素。
原因在于FontMetrics的 ascent/descent 是基于字体整体设计的度量,不是某个具体字符串的实际字形边界。想要像素级贴合,就要用TextLayout拿视觉边界,我在下一章会展开说。如果你只是想要一个“差不多”的背景,那用ascent + descent就够了,最后微调一两像素也不丢人。
4.5 headless 环境没有窗口,怎么量
服务端生成图片、后台批处理排版时,没有显示器也没有窗口,直接拿Toolkit获取FontMetrics可能抛异常。我的兜底方案是前面提过的BufferedImage:
BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); FontMetrics fm = img.createGraphics().getFontMetrics(font);另外,Font.getLineMetrics也能在不创建图像的情况下拿到 ascent、descent、leading 等行度量信息;但只有这些还不够,因为算字符串宽度需要TextLayout。如果 headless 环境对性能有要求,建议用BufferedImage创建一次Graphics2D并复用,别在循环里反复createGraphics。
4.6 常用症状与排查速查表
| 症状 | 可能原因 | 优先排查方向 |
|---|---|---|
| 文字偏上/偏下 | drawStringy 被当成顶部 | 确认 y 是基线,用 ascent 换算 |
| 垂直居中不居中 | 用了getHeight()而非ascent+descent | 单行视觉居中改用ascent+descent |
| 鼠标悬停文本跳动 | 测量字体和绘制字体不一致 | 统一在一个Graphics上先设字体再测量 |
| 中文/Emoji 宽度异常 | 字体 fallback 或代理对截断 | 检查字体字形覆盖,用codePointAt逐码点 |
| HiDPI 下文字过大 | 手动乘了物理 DPI | 去掉手工缩放,信任 Graphics 变换 |
| 背景矩形不贴合 | FontMetrics是逻辑度量并非字形边界 | 换TextLayout.getBounds() |
这些坑加起来,其实都指向同一个教训:FontMetrics是好用的,但它约定的是“逻辑排版度量”,不是“实际像素边界”。搞清楚它的边界在哪里,什么时候该换更精细的工具,才是进阶的关键。
5. 进阶:FontMetrics 撑不住的场景怎么办
5.1 TextLayout:从“逻辑宽度”到“视觉边界”
FontMetrics.getStringBounds也能拿到一个Rectangle,但它更偏向字符的 advance(前进宽度),而非真正渲染后的像素边界。斜体字、字距调整、行尾空白、某些组合字符,都会让视觉边界和逻辑边界产生差异。
这个场景下,就该用TextLayout。它能基于FontRenderContext精确计算文本的视觉边界,并且把drawString内部的那套复杂实现封装成可直接调用的draw方法。
Font font = g2.getFont(); FontRenderContext frc = g2.getFontRenderContext(); TextLayout layout = new TextLayout(text, font, frc); float visualX = layout.getBounds().getBounds().x; float visualY = layout.getBounds().getBounds().y; float visualW = layout.getBounds().getBounds().width; float visualH = layout.getBounds().getBounds().height; // 绘制文字本身 layout.draw(g2, baseX, baseY); // 用视觉边界画背景框,非常贴合 g2.fill(new Rectangle2D.Float( baseX + visualX, baseY + visualY, visualW, visualH));我做一个富文本高亮控件时,用FontMetrics画背景总是差几个像素,换成TextLayout后,高亮块和文字边缘严丝合缝。代价是比FontMetrics重一些,所以只在高精度绘制场景用,普通居中依然首选FontMetrics。
5.2 复杂排版需要认识 GlyphVector 和 FontRenderContext
如果再往下走,你会遇到GlyphVector。它把一段文本拆成一个一个的具体字形对象,可以拿到每个字形的精确轮廓、位置和度量。做文本阴影、描边、逐字动画、艺术字变换时,GlyphVector是底层武器。
FontRenderContext则负责描述渲染环境的细节:有没有抗锯齿、有没有使用分数度量、坐标变换是什么样。TextLayout和GlyphVector的很多构造函数都需要它。从Graphics2D.getFontRenderContext()拿到的上下文最贴合当前绘制环境,自己new FontRenderContext也能用,但不建议随便填参数。
我的个人建议是:日常 95% 的文本绘制需求,FontMetrics足够。只有当你要做像素级对齐、文字背景高亮、动态缩放变形时,再上TextLayout和GlyphVector。工具不是越高级越好,而是越匹配越好。
最后分享一个我自己的习惯。写自绘控件时,我会先在纸上把“希望文字出现在哪个盒子”画出来,标出盒子的四个边,然后想清楚drawString的基线落在盒子里的哪个位置。所有坐标计算都以基线为准,以FontMetrics的数据为输入。这样哪怕换字体、换字号、换平台,逻辑都不会乱。这份扎实的基本功,比背一百个 API 都管用。