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

TtSkin 类代码分析

1. 概述

TtSkin 是用于加载和解析千千静听(TTPlayer)皮肤文件.skn 格式)的 Java 工具类。它支持从文件、目录或 Classpath 资源中读取皮肤包,解析其中的 XML 描述文件(Skin.xml),提取窗口、控件的位置、图片、尺寸、颜色等配置信息,并为后续的渲染(如 Swing 或 JavaFX UI)提供数据模型。

千千静听是一款经典的国产音乐播放器,其皮肤文件本质上是 ZIP 压缩包,内部包含 XML 布局描述和若干 BMP/PNG 图片资源。


2. 类结构与主要成员

2.1 内部类

  • Ctl:表示窗口中一个具体控件(按钮、滑块、LED 面板等)的定义。
    属性包括标签(tag)、位置(left/top/right/bottom)、图片名称、帧尺寸(frameWidth/Height)、列数(cols)、滑块/进度条相关图片(barImage, thumbImage 等)、颜色、字体、对齐方式等。

  • WindowDef:表示一个窗口定义(如主窗口、均衡器窗口、播放列表窗口等)。
    包含窗口名称、背景图片、可调整边框区域(resizeRect)、拉伸模式(resizeTile)、以及包含的 Ctl 列表。

2.2 成员变量

  • Map<String, byte[]> files:缓存皮肤包内所有文件的文件名及其二进制数据。
  • Document xmlDoc:解析后的 XML DOM 对象。
  • String skinName:皮肤名称(从 XML 根标签 name 属性读取)。
  • Color transparentColor:透明色(默认洋红 #FF00FF),用于标记图片中需透明处理的颜色。

3. 加载方法(资源输入)

3.1 load(File sknFile)

从本地 .skn 文件加载。内部调用 readZip() 读取 ZIP 条目,然后 parseXml() 解析。

3.2 loadDir(File dir)

解压后的目录加载,遍历目录下所有文件,存入 files 映射,然后解析 XML。

3.3 loadFromClasspath(String resource)

从类路径加载,支持两种场景:

  • 资源指向解压后的目录(URL 以 / 结尾),则根据协议(file:jar:)分别使用 loadDir 或遍历 JAR 条目。
  • 资源指向单个 .skn 文件(如打包在 Fat JAR 中),则直接以流方式解压读取。

此方法增强了部署灵活性,便于将皮肤嵌入到应用程序 JAR 中。


4. ZIP 文件解析

4.1 readZip(File file)

尝试用 UTF-8 字符集打开 ZIP,若失败则回退到 GBK(中文环境常见),确保中文文件名正常读取。

4.2 readZipEntries(ZipFile zf)

遍历 ZIP 条目,将每个非目录条目存入 files,同时检查是否存在 Skin.xml(忽略大小写),返回值指示是否找到。

4.3 readZipStream(InputStream is)

用于从流中解压(如 Classpath 中的 .skn 文件),逻辑与 readZipEntries 类似。

设计上,files 作为内存缓存,避免反复读取磁盘,提高解析效率。


5. XML 解析流程

5.1 parseXml()

  • files 中查找 Skin.xml(不区分大小写)。
  • 调用 decodeXml() 将字节转为字符串,自动检测编码(UTF-8 BOM、XML 声明中指定的编码,或通过中文字符推断)。
  • 调用 fixSkinXml() 对 XML 进行“修复”(见下文)。
  • 使用 JAXP DOM 解析器(禁用 DTD 验证)加载为 Document
  • 从根节点读取 nametransparent_color 属性。

5.2 decodeXml(byte[] data)

智能解码:

  • 检查 UTF-8 BOM 并跳过。
  • 在 XML 头部查找 encoding= 关键字,若指明 GBK/GB2312 或 UTF-8 则使用对应编码。
  • 若未显式声明,尝试 UTF-8 并检测是否包含中文字符,若包含则采用 UTF-8,否则回退 GBK。

5.3 fixSkinXml(String xml)

对原始 XML 进行规范化处理(因早期 TTPlayer 皮肤 XML 可能不严谨):

  • 给无引号的属性值添加双引号(例如 width=100width="100")。
  • 处理属性值中意外出现的 " 导致解析错误。
  • 将未转义的 &(非实体)转义为 &amp;,避免 XML 解析异常。

6. 控件信息提取 (getWindows())

该方法遍历 xmlDoc 的根节点,查找所有以 _window 结尾的标签(如 main_windoweq_window),为每个窗口构建 WindowDef 对象。

对于窗口内的每个子元素(控件),解析其属性,并依据图片的 BMP 头部(readInt 读取宽高)计算:

  • 如果是 led(LED 显示),固定 12 列。
  • 否则,如果控件的 frameWidth(由 position 计算得出)与图片宽度成整数倍,则计算 cols(精灵图切分列数)。

这样,一个图片可以包含多帧(如按钮的多种状态),供渲染时按帧索引绘制。


7. 图片资源获取 (getBmp(String name))

  • 首先从 files 缓存中按文件名查找。
  • 若未找到,尝试从类路径的 ico/ 目录加载同名文件或同名的 PNG 变体(_16x16_32bpp.png_32x32_32bpp.png),用于兼容旧版皮肤中可能缺失的图标。

此设计允许将常用图标放在外部,减少皮肤包的体积。


8. 辅助工具方法

  • readInt(byte[], int):以小端序读取 BMP 文件头中的整数(宽度/高度)。
  • readAllBytes(InputStream):将输入流全部读入字节数组。
  • containsChinese(String):检测字符串是否包含中文字符,用于编码决策。

9. 设计亮点与潜在问题

优点

  • 多源加载:支持文件、目录、类路径,适配各种部署场景。
  • 健壮的编码处理:自动识别 BOM、XML 声明、中文字符,兼容 GBK/UTF-8。
  • XML 修复:容忍原始皮肤文件中的格式错误,提高兼容性。
  • 内存缓存:所有资源读入内存,减少 I/O,适合 UI 频繁刷新。
  • 可扩展性CtlWindowDef 可作为数据模型传递给渲染层。

潜在问题或改进点

  • 内存占用:将整个皮肤文件(可能包含大图)全部缓存到 byte[],若皮肤较多可能占用大量内存,可考虑改用文件引用或延迟加载。
  • 线程安全:类非线程安全(files/xmlDoc 可变),多线程使用时需外部同步。
  • BMP 解析局限readInt 仅读取 BMP 头,未处理压缩格式(如 RLE),假设所有图片为未压缩 BMP,可能不适应某些变体。
  • 颜色解析transparentColor 仅从 XML 读取,未处理图片中的实际透明像素,渲染时需自行替换。
  • 错误处理:部分异常被吞没(如 log.warn),生产环境应加强日志或抛出明确异常。
  • 依赖:使用了 SLF4J 日志,但未在分析中体现,若项目无此依赖需补充。

10. 使用场景示例

TtSkin skin = new TtSkin();
skin.load(new File("skin.skn"));         // 从文件加载
// 或 skin.loadFromClasspath("skins/default/"); // 从类路径目录加载
List<WindowDef> windows = skin.getWindows();
for (WindowDef wd : windows) {
    System.out.println("Window: " + wd.name);
    for (Ctl ctl : wd.elements) {
        System.out.println("  Control: " + ctl.tag + " at (" + ctl.left + "," + ctl.top + ")");
    }
}
byte[] bgData = skin.getBmp(windows.get(0).image); // 获取窗口背景图

完整的源码

点击查看代码
package org.ttplayer.skin;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.*;
import javax.xml.parsers.*;
import java.awt.*;
import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;
import java.util.List;
import java.util.zip.*;

public class TtSkin {
    private static final Logger log = LoggerFactory.getLogger(TtSkin.class);

    /** 窗口中一个控件的定义 */
    public static class Ctl {
        public String tag;
        public int left, top, right, bottom;
        public String image;
        public int frameWidth, frameHeight;
        public int cols;
        public String barImage, thumbImage, fillImage, buttonsImage, hotImage;
        public boolean vertical;
        public String align;
        public String color, bkgnd, font;
        public int fontSize;
        public int thumbResizeCenter;
        public int thumbResizeTile;

        void parsePosition(String pos) {
            if (pos == null || pos.isEmpty()) return;
            String[] p = pos.split(",");
            left = Integer.parseInt(p[0].trim());
            top = Integer.parseInt(p[1].trim());
            right = Integer.parseInt(p[2].trim());
            bottom = Integer.parseInt(p[3].trim());
        }
    }

    public static class WindowDef {
        public String name;
        public String image;
        public int left, top, right, bottom;
        public int width, height;
        public String resizeRect;
        public int resizeTile;
        public final List<Ctl> elements = new ArrayList<>();
        public String eqInterval;

        void parsePosition(String pos) {
            if (pos == null || pos.isEmpty()) return;
            String[] p = pos.split(",");
            left = Integer.parseInt(p[0].trim());
            top = Integer.parseInt(p[1].trim());
            right = Integer.parseInt(p[2].trim());
            bottom = Integer.parseInt(p[3].trim());
        }
    }

    private final Map<String, byte[]> files = new HashMap<>();
    private Document xmlDoc;
    private String skinName;
    private Color transparentColor = new Color(255, 0, 255);

    public String getSkinName() { return skinName; }
    public Color getTransparentColor() { return transparentColor; }

    public void load(File sknFile) throws IOException {
        files.clear();
        readZip(sknFile);
        parseXml();
    }

    public void loadDir(File dir) throws IOException {
        files.clear();
        File[] list = dir.listFiles();
        if (list != null) {
            for (File f : list) {
                if (f.isFile()) {
                    files.put(f.getName(), Files.readAllBytes(f.toPath()));
                }
            }
        }
        parseXml();
    }

    public void loadFromClasspath(String resource) throws IOException {
        files.clear();
        ClassLoader cl = getClass().getClassLoader();
        java.net.URL url = cl.getResource(resource);
        if (url == null) throw new IOException("Cannot find classpath resource: " + resource);

        // 目录资源(解压后的皮肤目录)以 "/" 结尾,单个 .skn 文件不是
        if (url.toExternalForm().endsWith("/")) {
            if ("file".equals(url.getProtocol())) {
                try {
                    loadDir(new File(url.toURI()));
                } catch (java.net.URISyntaxException e) {
                    throw new IOException("Invalid resource URI: " + url, e);
                }
            } else if ("jar".equals(url.getProtocol())) {
                String path = url.getPath();
                int idx = path.indexOf('!');
                String jarFile = path.substring(5, idx);
                String prefix = resource.endsWith("/") ? resource : resource + "/";

                try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(
                         java.net.URLDecoder.decode(jarFile, "UTF-8"))) {
                    java.util.Enumeration<? extends java.util.zip.ZipEntry> entries = zf.entries();
                    while (entries.hasMoreElements()) {
                        java.util.zip.ZipEntry entry = entries.nextElement();
                        String name = entry.getName();
                        if (name.startsWith(prefix) && !name.equals(prefix)) {
                            String rel = name.substring(prefix.length());
                            if (!rel.contains("/")) {
                                files.put(rel, readAllBytes(zf.getInputStream(entry)));
                            }
                        }
                    }
                }
                parseXml();
            }
        } else {
            // 单个 .skn 文件资源(fat jar 里的皮肤就是这种形式)
            try (java.io.InputStream is = cl.getResourceAsStream(resource)) {
                if (is == null) throw new IOException("Cannot find classpath resource: " + resource);
                readZipStream(is);
            }
            parseXml();
        }
    }

    private void readZipStream(InputStream is) throws IOException {
        try (java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(is)) {
            java.util.zip.ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                String name = entry.getName();
                if (name.isEmpty() || entry.isDirectory()) continue;
                files.put(name, readAllBytes(zis));
            }
        }
    }

    public byte[] getBmp(String name) {
        byte[] data = files.get(name);

        // 如果皮肤目录没有,尝试从resources/ico目录读取
        if (data == null) {
            String lowerName = name.toLowerCase();

            // 先尝试直接从ico目录读取
            data = loadFromResources("ico/" + name);

            // 如果是ICO文件,尝试用PNG替代
            if (data == null && lowerName.endsWith(".ico")) {
                String baseName = name.substring(0, name.length() - 4);
                data = loadFromResources("ico/" + baseName + "_16x16_32bpp.png");
                if (data == null) {
                    data = loadFromResources("ico/" + baseName + "_32x32_32bpp.png");
                }
            }
        }

        return data;
    }

    private byte[] loadFromResources(String path) {
        try {
            ClassLoader cl = getClass().getClassLoader();
            java.io.InputStream is = cl.getResourceAsStream(path);
            if (is != null) {
                try (java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream()) {
                    byte[] buffer = new byte[8192];
                    int read;
                    while ((read = is.read(buffer)) != -1) {
                        baos.write(buffer, 0, read);
                    }
                    return baos.toByteArray();
                }
            }
        } catch (Exception e) {
            log.warn("Could not load resource: {}", path);
        }
        return null;
    }

    public List<WindowDef> getWindows() {
        List<WindowDef> result = new ArrayList<>();
        if (xmlDoc == null) return result;

        org.w3c.dom.Element root = xmlDoc.getDocumentElement();
        NodeList children = root.getChildNodes();

        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() != Node.ELEMENT_NODE) continue;
            org.w3c.dom.Element el = (org.w3c.dom.Element) node;
            String tag = el.getTagName();

            if (!tag.endsWith("_window")) continue;

            WindowDef wd = new WindowDef();
            wd.name = tag;
            wd.image = el.getAttribute("image");
            wd.resizeRect = el.getAttribute("resize_rect");
            String tile = el.getAttribute("resize_tile");
            wd.resizeTile = tile.isEmpty() ? 0 : Integer.parseInt(tile);
            wd.eqInterval = el.getAttribute("eq_interval");
            wd.parsePosition(el.getAttribute("position"));
            wd.width = wd.right - wd.left;
            wd.height = wd.bottom - wd.top;

            NodeList subs = el.getChildNodes();
            for (int j = 0; j < subs.getLength(); j++) {
                Node sub = subs.item(j);
                if (sub.getNodeType() != Node.ELEMENT_NODE) continue;
                org.w3c.dom.Element subEl = (org.w3c.dom.Element) sub;

                Ctl btn = new Ctl();
                btn.tag = subEl.getTagName();
                btn.parsePosition(subEl.getAttribute("position"));
                btn.image = subEl.getAttribute("image");
                btn.barImage = subEl.getAttribute("bar_image");
                btn.thumbImage = subEl.getAttribute("thumb_image");
                btn.fillImage = subEl.getAttribute("fill_image");
                btn.buttonsImage = subEl.getAttribute("buttons_image");
                btn.hotImage = subEl.getAttribute("hot_image");
                btn.vertical = "true".equals(subEl.getAttribute("vertical"));
                btn.align = subEl.getAttribute("align");
                btn.color = subEl.getAttribute("color");
                btn.bkgnd = subEl.getAttribute("bkgnd");
                btn.font = subEl.getAttribute("font");
                String fs = subEl.getAttribute("font_size");
                btn.fontSize = fs.isEmpty() ? 12 : Integer.parseInt(fs);
                String trc = subEl.getAttribute("thumb_resize_center");
                btn.thumbResizeCenter = trc.isEmpty() ? 0 : Integer.parseInt(trc);
                String trt = subEl.getAttribute("thumb_resize_tile");
                btn.thumbResizeTile = trt.isEmpty() ? 0 : Integer.parseInt(trt);

                if (btn.right > btn.left && btn.bottom > btn.top) {
                    btn.frameWidth = btn.right - btn.left;
                    btn.frameHeight = btn.bottom - btn.top;
                }

                if (btn.image != null && !btn.image.isEmpty()) {
                    byte[] bmp = files.get(btn.image);
                    if (bmp != null) {
                        int iw = TtSkin.readInt(bmp, 18);
                        int ih = Math.abs(TtSkin.readInt(bmp, 22));

                        if ("led".equals(btn.tag)) {
                            btn.cols = 12;
                            btn.frameWidth = iw / 12;
                            btn.frameHeight = ih;
                        } else if (btn.frameWidth > 0 && iw % btn.frameWidth == 0) {
                            btn.cols = iw / btn.frameWidth;
                        } else {
                            btn.cols = 1;
                        }
                    }
                }

                wd.elements.add(btn);
            }
            result.add(wd);
        }
        return result;
    }

    private void readZip(File file) throws IOException {
        boolean found = false;
        try {
            try (ZipFile zf = new ZipFile(file, StandardCharsets.UTF_8)) {
                found = readZipEntries(zf);
            }
        } catch (IOException | IllegalArgumentException e) {
            found = false;
        }
        if (!found) {
            try (ZipFile zf = new ZipFile(file, Charset.forName("GBK"))) {
                readZipEntries(zf);
            }
        }
    }

    private boolean readZipEntries(ZipFile zf) {
        Enumeration<? extends ZipEntry> entries = zf.entries();
        boolean hasSkinXml = false;
        while (entries.hasMoreElements()) {
            ZipEntry entry = entries.nextElement();
            String name = entry.getName();
            if (name.isEmpty() || entry.isDirectory()) continue;
            if (name.equalsIgnoreCase("Skin.xml") || name.equalsIgnoreCase("skin.xml")) {
                hasSkinXml = true;
            }
            try (InputStream in = zf.getInputStream(entry)) {
                files.put(name, readAllBytes(in));
            } catch (IOException ignored) {}
        }
        return hasSkinXml;
    }

    private void parseXml() {
        byte[] data = null;
        for (String key : files.keySet()) {
            if (key.equalsIgnoreCase("Skin.xml")) {
                data = files.get(key);
                break;
            }
        }
        if (data == null) return;

        String xmlStr = decodeXml(data);
        xmlStr = fixSkinXml(xmlStr);

        try {
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setValidating(false);
            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);

            DocumentBuilder builder = factory.newDocumentBuilder();
            xmlDoc = builder.parse(new ByteArrayInputStream(xmlStr.getBytes(StandardCharsets.UTF_8)));
            org.w3c.dom.Element root = xmlDoc.getDocumentElement();
            if (root != null) {
                skinName = root.getAttribute("name");
                String tc = root.getAttribute("transparent_color");
                if (tc != null && tc.startsWith("#")) {
                    try {
                        transparentColor = Color.decode(tc);
                    } catch (Exception ignored) {}
                }
            }
        } catch (Exception e) {
            log.error("Failed to parse skin XML", e);
        }
    }

    private String decodeXml(byte[] data) {
        if (data.length >= 3 && data[0] == (byte)0xEF && data[1] == (byte)0xBB && data[2] == (byte)0xBF) {
            return new String(data, 3, data.length - 3, StandardCharsets.UTF_8);
        }

        String header = new String(data, 0, Math.min(200, data.length), StandardCharsets.US_ASCII);
        if (header.contains("encoding=")) {
            if (header.contains("GBK") || header.contains("gb2312") || header.contains("GB2312")) {
                return new String(data, Charset.forName("GBK"));
            }
            if (header.contains("UTF-8") || header.contains("utf-8")) {
                return new String(data, StandardCharsets.UTF_8);
            }
        }

        String utf8 = new String(data, StandardCharsets.UTF_8);
        if (containsChinese(utf8)) return utf8;
        return new String(data, Charset.forName("GBK"));
    }

    private boolean containsChinese(String s) {
        for (char c : s.toCharArray()) {
            if (c >= 0x4E00 && c <= 0x9FFF) return true;
        }
        return false;
    }

    private static String fixSkinXml(String xml) {
        if (xml == null || xml.isEmpty()) return xml;
        xml = xml.replaceAll("(?<=\\s)(\\w+)=(\\d+)(?=\\s|/?>)", "$1=\"$2\"");
        xml = xml.replaceAll("\"([a-zA-Z_]\\w*=)", "\" $1");
        xml = xml.replaceAll("&(?!amp;|lt;|gt;|quot;|apos;|#\\d+;|#x[0-9a-fA-F]+;)", "&amp;");
        return xml;
    }

    private static byte[] readAllBytes(InputStream in) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream(65536);
        byte[] buf = new byte[8192];
        int n;
        while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
        return out.toByteArray();
    }

    static int readInt(byte[] data, int off) {
        return (data[off] & 0xFF) | ((data[off + 1] & 0xFF) << 8)
                | ((data[off + 2] & 0xFF) << 16) | ((data[off + 3] & 0xFF) << 24);
    }

}

总结

TtSkin 是一个专为千千静听皮肤设计的解析器,通过 ZIP 解压、XML 解析、BMP 头读取等手段,将皮肤定义转化为 Java 对象。它具备良好的兼容性和易用性,可嵌入到自定义音乐播放器或皮肤编辑器项目中。代码结构清晰,但需注意内存和线程安全问题,并可根据实际需求扩展支持更多图片格式(如 PNG 透明通道)。

License

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

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

image

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