怀念经典,一步步开发一个ttplayer千千静听,skinwindow源码解析

源码深度解剖:SkinWindow —— 支撑千千静听所有窗口的“骨骼系统”

从BMP解码到九宫格拉伸,从边缘拖拽到控件自适应——这一个基类,撑起了8个窗口的整片天空。

在上一期,我们把官方 skin.xml 拆得干干净净,搞懂了每一个坐标、每一个图片的含义。但XML只是一份“蓝图”,真正让这些坐标变成可交互窗口的,是今天我们要剖析的这段Java源码——SkinWindow

这是整个播放器UI体系的“上帝类”。主播放器、歌词秀、均衡器、播放列表……所有皮肤窗口都继承自它。看懂了这个类,你就看懂了整个播放器“换肤 + 自适应”的核心黑魔法。


一、上帝视角:这个类到底在解决什么问题?

在开始逐行分析前,先想清楚三个核心痛点:

  1. BMP图片带透明色:千千静听的皮肤用 #FF00FF(亮粉色)做透明色,Swing默认不支持,必须手动解码并镂空。
  2. 窗口可拉伸(九宫格):皮肤背景是一张固定的BMP,但窗口允许拖拽变大。如果简单拉伸,按钮和边框会变形。必须用九宫格(9-patch)算法保住四个角。
  3. 控件随窗口缩放而移动:窗口变大后,进度条要拉长,关闭按钮要钉在右上角,歌名要居中——每个控件的 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()模板方法模式。父类啥也不干,留给 MainPlayerWindowLyricWindow 等子类去往面板上添加按钮、滑块。

四、灵魂布局引擎: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",宽度自动拉伸,左右边距始终保持 164 像素。
  • 歌名标签 (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里的 colorfontfont_sizealign

子类只需这样调用:

// 在 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框架的教科书

  1. 模板方法模式buildControls() 留给子类,父类控制整体生命周期。
  2. 策略模式align 字符串解析就是一套布局策略。
  3. 九宫格算法:完美解决位图拉伸变形问题。
  4. 玻璃面板(GlassPane):Swing高级技巧,用于无边框窗口的拖拽和缩放。
  5. 手动BMP解析:保证了对老格式的绝对兼容。

有了这个坚实的基类,主播放器、歌词秀、均衡器等子类只需要专注于“放什么控件”,而不用关心“怎么放、怎么拉伸、怎么透明”。这也是整个千千静听复刻项目能够快速落地的根本原因。


下一期,我们将进入 TtButtonTtSlider 的内部,看它们如何实现三态切换、鼠标悬停发光,以及进度条的平滑拖拽——敬请期待!

License

本项目仅供学习和个人使用。皮肤资源版权归原作者所有。

「获取方式」:后台回复 ttplayer

image

posted @ 2026-08-28 10:23  tomj2ee  阅读(5)  评论(0)    收藏  举报