最近在写 LiveDock 时,我需要做一个无边框、带圆角、还能正常拖动的 Swing 窗口。

一开始我对这件事很乐观。Swing 虽然老,但 JFrame.setUndecorated(true)、透明背景和 setShape(new RoundRectangle2D(...)) 这些能力都在,看起来只要把它们拼起来,再配合 Graphics2D 的抗锯齿绘制,应该就能得到一个还不错的现代窗口外观。

结果实际效果并不理想。

一个粗糙圆角的无边框窗口

最开始我以为问题只是“没有开抗锯齿”,但真正做下去以后才发现,症结并不在 Graphics2D,而在 setShape() 本身。

目标其实有两个

如果只是想在 Swing 里“画一个圆角矩形”,事情并不复杂。真正麻烦的是这里其实有两个目标,而且它们并不完全一致:

  1. 视觉上看起来要是平滑的圆角。
  2. 窗口实际可点击、可命中的区域,也要是合理的圆角区域。

这两个目标分别对应两套不同层级的机制:

  • Graphics2D 负责“你画出来什么”
  • Window#setShape() 负责“窗口真正长成什么形状”

只要这两者完全重合,你就很容易碰到锯齿问题。

最初的实现

一开始我的代码很简单,大概就是这样:

frame.setUndecorated(true);
frame.setBackground(new Color(0, 0, 0, 0));
frame.setShape(new RoundRectangle2D.Double(0, 0, w, h, arc, arc));

然后内容面板里再自己画一个圆角背景,比如:

@Override
protected void paintComponent(Graphics g) {
    Graphics2D g2 = (Graphics2D) g.create();
    g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g2.setColor(Color.DARK_GRAY);
    g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc);
    g2.dispose();
}

单看这段代码,逻辑几乎是无懈可击的:

  • 窗口是无边框的
  • 背景是透明的
  • 窗口形状被裁成圆角矩形
  • 面板绘制也开了抗锯齿

但最终效果仍然是边缘发硬,圆角不够干净。

这也是最容易让人困惑的地方:明明绘制代码已经开了抗锯齿,为什么看起来还是锯齿?

真正的问题不在“画”,而在“裁”

后来我才逐渐意识到,setShape() 才是这里真正的关键。

setShape() 不是一个“视觉装饰 API”,而是一个更底层的窗口裁切机制。它决定的是:

  • 窗口的真实外轮廓
  • 哪些区域属于窗口
  • 哪些区域不再参与显示与事件命中

这意味着,哪怕你在 paintComponent() 里画出了一个边缘柔和、带抗锯齿过渡的圆角矩形,最终它还是要再经过一次窗口形状裁切。

而问题就出在这里:

抗锯齿本质上会在边缘留下一圈半透明过渡像素,但 setShape() 的裁切轮廓往往更“硬”。

于是会发生一件很尴尬的事:

  • 你先画出了一个看起来更平滑的圆角
  • 然后这圈本来应该负责“柔化边缘”的像素,又被 setShape() 的轮廓裁掉了一部分
  • 最终落在屏幕上的,还是一个偏硬的边缘

从结果上看,就像是 Graphics2D 的抗锯齿没有生效。实际上它生效了,只是被后面的窗口裁切吞掉了。

一个偶然但很关键的发现

这个问题不是我一开始靠理论分析出来的,而是在调参数时误打误撞发现的。

我当时随手把两边的圆角半径改成了不同的值:

  • setShape() 用一个更小的 arc
  • fillRoundRect() 继续用一个更大的 arc

结果窗口边缘突然变得顺眼很多,甚至已经接近我真正想要的效果。

也就是说,真正有用的不是:

让“窗口裁切形状”和“视觉绘制圆角”完全一致

而是:

让裁切圆角略小于视觉圆角

一旦这样做,绘制出来的圆角边缘就会有一部分“缓冲空间”,不会被裁切得那么死,视觉上就会更平滑。

最终方案

最后我用的是这种思路:

  • visualArc 负责视觉上的圆角大小
  • shapeArc 负责窗口真实裁切形状
  • shapeArc 略小于 visualArc

代码大概如下:

int visualArc = 40;
int shapeArc = 35;

frame.setUndecorated(true);
frame.setBackground(new Color(0, 0, 0, 0));
frame.setShape(new RoundRectangle2D.Double(0, 0, w, h, shapeArc, shapeArc));

JPanel content = new JPanel() {
    @Override
    protected void paintComponent(Graphics g) {
        Graphics2D g2 = (Graphics2D) g.create();
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2.setColor(new Color(30, 30, 30));
        g2.fillRoundRect(0, 0, getWidth(), getHeight(), visualArc, visualArc);
        g2.dispose();
    }
};
content.setOpaque(false);

效果如下:

一个光滑圆角的无边框窗口

这套方案的好处在于,它同时兼顾了两件事:

  1. 视觉上保留了更自然的圆角过渡。
  2. 窗口外轮廓依然是真实生效的,不会把本应透明的角落当成可点击区域。

为什么这个方案有效

如果把这个现象说得更直白一点,可以这样理解:

  • fillRoundRect() 画的是“你希望用户看到的圆角”
  • setShape() 决定的是“系统最终承认的窗口边界”

而这两者不需要 100% 使用同一个参数。

在这个问题里,完全一致反而更容易出问题,因为:

  • 视觉抗锯齿需要边缘过渡
  • 窗口裁切需要明确边界

这两个需求天然就有一点冲突。

所以更实用的做法不是追求“数学上完全重合”,而是给视觉绘制留出一点余量,让它在真实裁切轮廓内完成过渡。

还能继续优化什么

这个方案已经足够解决大多数 Swing 无边框圆角窗口的观感问题,但如果要进一步工程化,还是有几件事值得继续做。

1. 把圆角参数关系抽出来

不同尺寸的窗口,适合的 visualArcshapeArc 差值不一定完全一样。

比较实用的方式是把这两个值的关系封装起来,例如:

  • 固定差值
  • 按窗口尺寸比例计算
  • 针对不同 DPI 做修正

这样后面就不用每个窗口手动调参。

2. 封装成统一的窗口辅助类

例如统一做成一个类似这样的入口:

WindowFrameHelper.createFramelessWindow(...)

里面负责:

  • 设置 undecorated
  • 设置透明背景
  • 设置 shape
  • 创建支持抗锯齿背景绘制的内容面板
  • 补上拖动、阴影、尺寸监听等通用逻辑

这样后续再做类似窗口时,基本就不会反复踩这类坑。

3. 留意平台差异

setShape()、透明窗口和 Swing 绘制在不同平台上的细节表现不一定完全一致。

如果你要把这个方案用于真正的桌面应用,而不是只在自己的机器上验证一下,最好至少测一下:

  • Windows
  • macOS
  • Linux

尤其是高 DPI、不同窗口管理器和不同 Java 运行时下的边缘表现。

一个值得记住的小结论

这次踩坑以后,我对 Swing 这类老技术栈有一个更具体的感受:

它的问题通常不是“做不到”,而是“你必须非常清楚每一层到底负责什么”。

在这个例子里:

  • Graphics2D 负责的是绘制
  • setShape() 负责的是裁切

如果把它们都当成“圆角 API”来理解,事情就会变得很混乱。
但一旦你意识到两者分属不同层级,这个问题反而会变得很简单。

所以这篇文章真正想记录的,不只是一个小技巧,而是一个更普遍的经验:

当一个视觉效果明明“已经画出来了”,但最终看起来还是不对时,问题不一定出在绘制本身,也可能出在更下层的裁切或合成阶段。

对我来说,这比“把 arc 从 40 改到 35”更值得记住。

如果你也在 Swing 里做无边框窗口,希望这篇记录能帮你少走一点弯路。