资讯详情

Java FontMetrics 字体度量实战:文本居中、自动换行与踩坑指南

📅 2026/10/15 22:35:49 | 华诺云谱 👁 阅读
Java FontMetrics 字体度量实战:文本居中、自动换行与踩坑指南
做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。heightleading 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 ListString wrap(String text, FontMetrics fm, int maxWidth) { ListString 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)只能取到半个字符得到的宽度是乱的。这里用codePointAtCharacter.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可能抛异常。我的兜底方案是前面提过的BufferedImageBufferedImage 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()而非ascentdescent单行视觉居中改用ascentdescent鼠标悬停文本跳动测量字体和绘制字体不一致统一在一个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 都管用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑