怀念经典,一步步开发一个ttplayer千千静听,skinwindow源码解析
源码深度解剖:SkinWindow —— 支撑千千静听所有窗口的“骨骼系统”
从BMP解码到九宫格拉伸,从边缘拖拽到控件自适应——这一个基类,撑起了8个窗口的整片天空。
在上一期,我们把官方 skin.xml 拆得干干净净,搞懂了每一个坐标、每一个图片的含义。但XML只是一份“蓝图”,真正让这些坐标变成可交互窗口的,是今天我们要剖析的这段Java源码——SkinWindow。
这是整个播放器UI体系的“上帝类”。主播放器、歌词秀、均衡器、播放列表……所有皮肤窗口都继承自它。看懂了这个类,你就看懂了整个播放器“换肤 + 自适应”的核心黑魔法。
一、上帝视角:这个类到底在解决什么问题?
在开始逐行分析前,先想清楚三个核心痛点:
- BMP图片带透明色:千千静听的皮肤用
#FF00FF(亮粉色)做透明色,Swing默认不支持,必须手动解码并镂空。 - 窗口可拉伸(九宫格):皮肤背景是一张固定的BMP,但窗口允许拖拽变大。如果简单拉伸,按钮和边框会变形。必须用九宫格(9-patch)算法保住四个角。
- 控件随窗口缩放而移动:窗口变大后,进度条要拉长,关闭按钮要钉在右上角,歌名要居中——每个控件的
align(对齐方式)决定了它的新坐标。
SkinWindow 的使命就是:把XML里的死坐标,变成活窗口。
二、核心属性:一台精密仪器的“零件库”
public final TtSkin skin; // 皮肤数据源(持有所有BMP字节数组)
public final TtSkin.WindowDef def; // 当前窗口的XML定义(坐标、图片、resize_rect)
protected final Color transparentColor; // 透明色(通常为 #FF00FF)
private BufferedImage bgImage; // 解码后的背景图
private int[] ninePatch; // 九宫格四边距 [左, 上, 右, 下]
protected int bgW, bgH; // 背景图原始宽高(设计尺寸)
private List<ControlInfo> controls = new ArrayList<>(); // 记录所有子控件及其配置
亮点:controls 列表非常重要。它把每个 JComponent 和它的 TtSkin.Ctl(XML配置)绑定在一起。当窗口缩放时,遍历这个列表,根据配置的 align 重新计算每个控件的位置和大小。
三、构造方法:窗口出生的“第一声啼哭”
public SkinWindow(TtSkin skin, TtSkin.WindowDef def, boolean isMain) {
super(def != null ? def.name : "Window");
// 1. 保存引用
this.skin = skin;
this.def = def;
this.transparentColor = skin.getTransparentColor(); // 通常为 #FF00FF
this.isMain = isMain;
// 2. 去掉系统标题栏(无边框)
setUndecorated(true);
// 3. 加载背景图(BMP解码 + 透明色镂空)
loadBg();
// 4. 解析九宫格边距(从 resize_rect 属性来)
setupResizeRect();
// 5. 设置窗口初始大小(优先用背景图尺寸,其次用XML里的width/height)
setSize(bgW > 0 ? bgW : def.width, bgH > 0 ? bgH : def.height);
// 6. 设置内容面板为自定义BgPanel(负责九宫格绘制背景)
setContentPane(new BgPanel());
// 7. 设置窗口透明(Swing必须的魔法代码)
setBackground(new Color(0, 0, 0, 0));
// 8. 构建子控件(交给子类实现,如 MainPlayerWindow 重写此方法)
buildControls();
// 9. 如果窗口支持拉伸,装上边缘拖拽器 + 窗口尺寸变化监听器
if (hasResizeRect()) {
new EdgeResizer(this);
addComponentListener(new ComponentAdapter() {
public void componentResized(ComponentEvent e) {
repositionControls(); // 缩放时重新布局控件
updateWindowShape(); // 更新窗口外形(圆角等)
}
});
}
}
值得细品的细节:
- 为什么
setBackground(new Color(0,0,0,0))?因为Swing默认背景不透明,要显示镂空区域,必须让JFrame本身支持透明。 buildControls()是模板方法模式。父类啥也不干,留给MainPlayerWindow、LyricWindow等子类去往面板上添加按钮、滑块。
四、灵魂布局引擎:repositionControls —— 让控件随窗口“呼吸”
这是整个类里最聪明的方法。当窗口从 420x280 拖拽到 800x600 时,按钮不能傻站在原地,必须跟着动。
protected void repositionControls() {
int deltaW = getWidth() - bgW; // 窗口变宽了多少
int deltaH = getHeight() - bgH; // 窗口变高了多少
for (ControlInfo ci : controls) {
repositionControl(ci, deltaW, deltaH);
}
}
核心逻辑藏在 repositionControl 里。它解析控件的 align 字符串(如 "right", "bottom", "center", "fill"),决定新坐标:
| align 值 | 水平行为 | 垂直行为 |
|---|---|---|
left |
X = ctl.left(靠左不动) | — |
right |
X = 窗口宽度 - 右边距 - 控件宽度(钉在右边) | — |
center |
X = (窗口宽度 - 控件宽度) / 2 | — |
fill / stretch |
宽度拉伸填满左右边距 | 高度拉伸填满上下边距 |
bottom |
— | Y = 窗口高度 - 下边距 - 控件高度(钉在底部) |
middle |
— | Y = (窗口高度 - 控件高度) / 2 |
举个实战例子:
- 关闭按钮 (
close):align="right",钉在右上角。窗口变宽,它跟着右边缘走。 - 进度条 (
progress):align="fill",宽度自动拉伸,左右边距始终保持16和4像素。 - 歌名标签 (
info):align="center",永远保持在标题栏正中间。
这套机制完美复刻了千千静听原始皮肤的自适应行为,且完全不用Swing那套复杂的 LayoutManager。
五、九宫格绘制:drawNinePatch —— 背景不变形的秘密
如果你粗暴地把一张 420x280 的BMP拉伸到 800x600,圆角会变成椭圆,边框会变得粗细不一。九宫格算法解决了这个问题。
public static void drawNinePatch(Graphics2D g, BufferedImage src,
int dstW, int dstH,
int nl, int nt, int nr, int nb) {
// nl: 左边框宽度, nt: 上边框高度, nr: 右边框宽度, nb: 下边框高度
int sw = src.getWidth(), sh = src.getHeight();
// 如果四个边距都是0,直接整张拉伸
if (nl == 0 && nt == 0 && nr == 0 && nb == 0) {
g.drawImage(src, 0, 0, dstW, dstH, null);
return;
}
// 否则,把图片切成9块,分别绘制到目标区域的9个位置
// 1. 左上角(原样大小)
g.drawImage(src, 0, 0, nl, nt, 0, 0, nl, nt, null);
// 2. 上边缘(水平拉伸)
g.drawImage(src, nl, 0, dstW - nr, nt, nl, 0, sw - nr, nt, null);
// 3. 右上角(原样大小)
g.drawImage(src, dstW - nr, 0, dstW, nt, sw - nr, 0, sw, nt, null);
// ... 中间、左边缘、右边缘、下边缘、右下角同理
}
原理解释:
- 四个角(左上、右上、左下、右下):不拉伸,保持原像素,保住圆角。
- 四条边(上、下、左、右):单向拉伸(水平或垂直),填充中间区域。
- 中心区域:双向拉伸,填充整个内容区。
这样,无论你把窗口拖多大,四个角永远圆润,边框粗细永远一致。
六、EdgeResizer:给无边框窗口装上“隐形把手”
因为去掉了系统标题栏,用户没法像普通窗口那样拖拽边缘调整大小。EdgeResizer 是一个挂在 玻璃面板(GlassPane) 上的鼠标监听器,解决了这个问题。
private class EdgeResizer extends MouseAdapter {
@Override
public void mouseMoved(MouseEvent e) {
int edge = hitEdge(e); // 检测鼠标是否在窗口边缘6px范围内
frame.setCursor(cursorFor(edge)); // 改变鼠标样式(←→、↕、↖↘等)
}
@Override
public void mouseDragged(MouseEvent e) {
if (edge == 0) return;
// 根据鼠标拖拽的位移,调整窗口的 x, y, width, height
// 按位标记:1=左, 2=右, 4=上, 8=下
if ((edge & 1) != 0) { // 拖拽左边缘
x = Math.max(screen.x, Math.min(frameX + dx, rightEdge - minW));
w = rightEdge - x;
}
// ... 其他边缘同理
frame.setBounds(x, y, w, h);
}
}
巧妙之处:玻璃面板透明且覆盖整个窗口,它的 contains 方法被重写,只有当鼠标落在边缘6像素内时才响应。这样既不影响点击按钮,又能实现边缘拖拽。
七、BMP解码器:decodeBmp —— 向老格式致敬的“翻译官”
千千静听的皮肤包里的图片大多是BMP格式,且使用 #FF00FF 做透明色。Swing的 ImageIO 能读BMP,但不会自动抠掉亮粉色。所以作者手写了一个从字节流解析BMP的轮子。
public static BufferedImage decodeBmp(byte[] data, Color transp) {
// 1. 先尝试用 ImageIO 读取(快速通道)
BufferedImage img = decodeWithImageIO(data, transp);
if (img != null) return img;
// 2. 如果ImageIO失败,手写解析BMP头
int offset = readInt(data, 10); // 像素数据偏移量
int w = readInt(data, 18);
int h = readInt(data, 22);
int bpp = readShort(data, 28); // 位深度:8, 24, 32
// 3. 根据位深度逐行解析
if (bpp == 24) {
for (int y = 0; y < absH; y++) {
for (int x = 0; x < w; x++) {
int b = data[rowStart + x*3] & 0xFF;
int g = data[rowStart + x*3 + 1] & 0xFF;
int r = data[rowStart + x*3 + 2] & 0xFF;
// 如果匹配透明色,设置alpha为0
if (r == tr && g == tg && b == tb) {
img.setRGB(x, y, 0x00FF00FF); // 完全透明
} else {
img.setRGB(x, y, 0xFF000000 | (r<<16) | (g<<8) | b);
}
}
}
}
// ... 8位(调色板)和32位(带Alpha)同理
}
为什么作者要写这么复杂的BMP解析器?因为千千静听的皮肤BMP有些是非标准的,ImageIO 可能读不出或读错。这个手写解析器保证了100%兼容所有千千皮肤格式。
八、控件工厂:createButton / createLed / createInfo
最后,基类提供了几个工具方法,让子类可以快速创建控件并自动绑定到 controls 列表:
createButton(TtSkin.Ctl ctl):从BMP字节数组加载四态图片(Normal/Hover/Pressed/Disabled),切成4帧,生成TtButton。createLed(TtSkin.Ctl ctl):用数字图片拼合时间显示(如"01:23"),支持右对齐。createInfo(TtSkin.Ctl ctl):创建一个JLabel,自动应用XML里的color、font、font_size、align。
子类只需这样调用:
// 在 MainPlayerWindow 的 buildControls() 中
TtButton playBtn = createButton(def.getCtl("play"));
playBtn.addActionListener(e -> player.play());
TtLed timeLed = createLed(def.getCtl("led"));
timeLed.setText("03:45");
总结:这个类教会了我们什么?
SkinWindow 的设计堪称复古UI框架的教科书:
- 模板方法模式:
buildControls()留给子类,父类控制整体生命周期。 - 策略模式:
align字符串解析就是一套布局策略。 - 九宫格算法:完美解决位图拉伸变形问题。
- 玻璃面板(GlassPane):Swing高级技巧,用于无边框窗口的拖拽和缩放。
- 手动BMP解析:保证了对老格式的绝对兼容。
有了这个坚实的基类,主播放器、歌词秀、均衡器等子类只需要专注于“放什么控件”,而不用关心“怎么放、怎么拉伸、怎么透明”。这也是整个千千静听复刻项目能够快速落地的根本原因。
下一期,我们将进入 TtButton 和 TtSlider 的内部,看它们如何实现三态切换、鼠标悬停发光,以及进度条的平滑拖拽——敬请期待!
License
本项目仅供学习和个人使用。皮肤资源版权归原作者所有。
「获取方式」:后台回复 ttplayer


浙公网安备 33010602011771号