怀念经典,一步步开发一个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。 - 从根节点读取
name和transparent_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=100→width="100")。 - 处理属性值中意外出现的
"导致解析错误。 - 将未转义的
&(非实体)转义为&,避免 XML 解析异常。
6. 控件信息提取 (getWindows())
该方法遍历 xmlDoc 的根节点,查找所有以 _window 结尾的标签(如 main_window、eq_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 频繁刷新。
- 可扩展性:
Ctl和WindowDef可作为数据模型传递给渲染层。
潜在问题或改进点
- 内存占用:将整个皮肤文件(可能包含大图)全部缓存到
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]+;)", "&");
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


浙公网安备 33010602011771号