摘要
在升级后台里,上传 APK 后最好能自动填充应用名、包名、版本名、版本号和渠道信息。这样可以减少手工填写错误,尤其是 versionCode 写错会直接影响升级判断。
这篇单独记录服务端如何在 Node.js 里解析 APK:
text
APK(zip) -> AndroidManifest.xml(binary xml) -> packageName / versionName / versionCode
适用场景
- 后台上传 APK 后自动识别版本信息。
- 不想在服务器安装 Android SDK 或 aapt。
- 希望 Node 服务独立完成 APK 元数据读取。
- 需要识别
meta-data中的渠道、更新说明。
本文效果
完成后上传 APK 可以得到:
json
{
"appName": "GCS Demo",
"appIds": ["com.example.gcs"],
"flavors": ["release"],
"versionCode": 10308,
"versionName": "1.03.08",
"description": "修复已知问题",
"descriptionEn": ""
}
背景
最开始很容易以为 APK 里的 AndroidManifest.xml 可以这样读:
js
fs.readFileSync("AndroidManifest.xml", "utf8")
实际不行。
APK 本质是 zip 包没错,但里面的 AndroidManifest.xml 是 Android 二进制 XML,不是普通文本 XML。直接按 UTF-8 读会是一堆乱码。
所以服务端做了三层解析:
- 从 APK zip 中读取
AndroidManifest.xml。 - 解析二进制 XML 字符串池和节点属性。
- 解析
resources.arsc,把@0x7f...这种 label 资源 ID 还原成应用名。
解析流程图
#mermaid-svg-CaJXCVsLsqA8kDDk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CaJXCVsLsqA8kDDk .error-icon{fill:#552222;}#mermaid-svg-CaJXCVsLsqA8kDDk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CaJXCVsLsqA8kDDk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CaJXCVsLsqA8kDDk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CaJXCVsLsqA8kDDk .marker.cross{stroke:#333333;}#mermaid-svg-CaJXCVsLsqA8kDDk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CaJXCVsLsqA8kDDk p{margin:0;}#mermaid-svg-CaJXCVsLsqA8kDDk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster-label text{fill:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster-label span{color:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster-label span p{background-color:transparent;}#mermaid-svg-CaJXCVsLsqA8kDDk .label text,#mermaid-svg-CaJXCVsLsqA8kDDk span{fill:#333;color:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk .node rect,#mermaid-svg-CaJXCVsLsqA8kDDk .node circle,#mermaid-svg-CaJXCVsLsqA8kDDk .node ellipse,#mermaid-svg-CaJXCVsLsqA8kDDk .node polygon,#mermaid-svg-CaJXCVsLsqA8kDDk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CaJXCVsLsqA8kDDk .rough-node .label text,#mermaid-svg-CaJXCVsLsqA8kDDk .node .label text,#mermaid-svg-CaJXCVsLsqA8kDDk .image-shape .label,#mermaid-svg-CaJXCVsLsqA8kDDk .icon-shape .label{text-anchor:middle;}#mermaid-svg-CaJXCVsLsqA8kDDk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CaJXCVsLsqA8kDDk .rough-node .label,#mermaid-svg-CaJXCVsLsqA8kDDk .node .label,#mermaid-svg-CaJXCVsLsqA8kDDk .image-shape .label,#mermaid-svg-CaJXCVsLsqA8kDDk .icon-shape .label{text-align:center;}#mermaid-svg-CaJXCVsLsqA8kDDk .node.clickable{cursor:pointer;}#mermaid-svg-CaJXCVsLsqA8kDDk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CaJXCVsLsqA8kDDk .arrowheadPath{fill:#333333;}#mermaid-svg-CaJXCVsLsqA8kDDk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CaJXCVsLsqA8kDDk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CaJXCVsLsqA8kDDk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CaJXCVsLsqA8kDDk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CaJXCVsLsqA8kDDk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CaJXCVsLsqA8kDDk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster text{fill:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk .cluster span{color:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CaJXCVsLsqA8kDDk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CaJXCVsLsqA8kDDk rect.text{fill:none;stroke-width:0;}#mermaid-svg-CaJXCVsLsqA8kDDk .icon-shape,#mermaid-svg-CaJXCVsLsqA8kDDk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CaJXCVsLsqA8kDDk .icon-shape p,#mermaid-svg-CaJXCVsLsqA8kDDk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CaJXCVsLsqA8kDDk .icon-shape .label rect,#mermaid-svg-CaJXCVsLsqA8kDDk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CaJXCVsLsqA8kDDk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CaJXCVsLsqA8kDDk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CaJXCVsLsqA8kDDk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
上传 APK
multer 保存临时文件
parseZipEntries 读取 zip 目录
读取 AndroidManifest.xml
parseStringPool 解析字符串池
parseBinaryManifest 解析 manifest/application/meta-data
app label 是资源 ID?
读取 resources.arsc
parseResourceTableStrings 解析应用名
直接使用 label
inspectApk 返回元数据
1. 从 APK 中读取文件
APK 是 zip,所以第一步是读取 zip 中央目录,找到文件条目。
核心代码裁剪如下:
js
function parseZipEntries(filePath) {
const buffer = fs.readFileSync(filePath);
const minEocdSize = 22;
const maxCommentSize = 0xffff;
const start = Math.max(0, buffer.length - minEocdSize - maxCommentSize);
let eocdOffset = -1;
for (let offset = buffer.length - minEocdSize; offset >= start; offset -= 1) {
if (buffer.readUInt32LE(offset) === 0x06054b50) {
eocdOffset = offset;
break;
}
}
if (eocdOffset < 0) {
throw new Error("invalid APK zip: EOCD not found");
}
const entryCount = buffer.readUInt16LE(eocdOffset + 10);
const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16);
const entries = new Map();
let offset = centralDirOffset;
for (let i = 0; i < entryCount; i += 1) {
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error("invalid APK zip: central directory is broken");
}
const compression = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const fileNameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.toString("utf8", offset + 46, offset + 46 + fileNameLength);
entries.set(name, { compression, compressedSize, localHeaderOffset });
offset += 46 + fileNameLength + extraLength + commentLength;
}
return {
read(name) {
const entry = entries.get(name);
if (!entry) {
return null;
}
const localOffset = entry.localHeaderOffset;
const fileNameLength = buffer.readUInt16LE(localOffset + 26);
const extraLength = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + fileNameLength + extraLength;
const data = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.compression === 0) {
return data;
}
if (entry.compression === 8) {
return zlib.inflateRawSync(data);
}
throw new Error(`APK entry ${name} uses unsupported compression ${entry.compression}`);
}
};
}
这里没有依赖第三方 zip 库,而是直接读 zip 结构,主要为了部署简单。
2. 解析 Android 二进制 XML
Android 二进制 XML 里,字符串不是直接写在节点里,而是先放在字符串池,然后属性值通过索引引用。
服务端先把类型值转成 JS 可读值:
js
function androidTypedValue(type, data, strings) {
if (type === 0x03) {
return strings[data] || "";
}
if (type === 0x10 || type === 0x11) {
return data;
}
if (type === 0x12) {
return data !== 0;
}
if (type === 0x01) {
return `@0x${data.toString(16).padStart(8, "0")}`;
}
return data;
}
然后解析 manifest、application 和 meta-data 节点:
js
function parseBinaryManifest(buffer) {
const stringPool = parseStringPool(buffer, 8);
if (!stringPool) {
throw new Error("AndroidManifest.xml string pool not found");
}
const strings = stringPool.strings;
const manifest = {
packageName: "",
versionName: "",
versionCode: 0,
appLabel: "",
metaData: {}
};
let currentElement = "";
let offset = 8 + stringPool.chunkSize;
while (offset + 8 <= buffer.length) {
const type = buffer.readUInt16LE(offset);
const headerSize = buffer.readUInt16LE(offset + 2);
const chunkSize = buffer.readUInt32LE(offset + 4);
if (chunkSize <= 0 || offset + chunkSize > buffer.length) {
break;
}
if (type === 0x0102 && headerSize >= 16) {
const elementNameIndex = buffer.readUInt32LE(offset + 20);
currentElement = strings[elementNameIndex] || "";
const attrStart = buffer.readUInt16LE(offset + 24);
const attrSize = buffer.readUInt16LE(offset + 26);
const attrCount = buffer.readUInt16LE(offset + 28);
const attrs = {};
for (let i = 0; i < attrCount; i += 1) {
const attrOffset = offset + headerSize + attrStart + i * attrSize;
const nameIndex = buffer.readUInt32LE(attrOffset + 4);
const rawValueIndex = buffer.readInt32LE(attrOffset + 8);
const valueType = buffer[attrOffset + 15];
const valueData = buffer.readUInt32LE(attrOffset + 16);
const name = strings[nameIndex] || "";
const value = rawValueIndex >= 0
? strings[rawValueIndex] || ""
: androidTypedValue(valueType, valueData, strings);
attrs[name] = value;
}
if (currentElement === "manifest") {
manifest.packageName = String(attrs.package || "");
manifest.versionName = String(attrs.versionName || "");
manifest.versionCode = Number(attrs.versionCode || 0);
} else if (currentElement === "application") {
manifest.appLabel = String(attrs.label || "");
} else if (currentElement === "meta-data" && attrs.name) {
manifest.metaData[String(attrs.name)] =
attrs.value !== undefined ? attrs.value : attrs.resource;
}
}
offset += chunkSize;
}
return manifest;
}
这部分最关键的是 type === 0x0102,它表示 XML start element,也就是一个开始节点。
3. 还原应用名资源
很多 APK 的应用名不是直接字符串,而是类似:
text
@0x7f120001
这种值需要再去 resources.arsc 里找。
识别资源 ID:
js
function maybeResourceId(value) {
const match = String(value || "").match(/^@0x([0-9a-f]+)$/i);
return match ? Number.parseInt(match[1], 16) >>> 0 : 0;
}
还原资源字符串的完整代码比较长,思路是:
js
function parseResourceTableStrings(buffer) {
const strings = new Map();
if (!buffer || buffer.length < 12 || buffer.readUInt16LE(0) !== 0x0002) {
return strings;
}
const globalStringPool = parseStringPool(buffer, buffer.readUInt16LE(2));
if (!globalStringPool) {
return strings;
}
// 继续解析 package / type / entry,
// 找到 string 类型资源,把 resourceId -> value 存入 Map。
return strings;
}
实际项目中会遍历 resource table 的 package、type 和 entry,把字符串资源保存成:
js
strings.set(resourceId, {
key: "app_name",
value: "GCS Demo"
});
然后 inspectApk() 就能把 label 还原出来。
4. 提取渠道信息
有些项目会把渠道写到 meta-data,也可能体现在 APK 文件名里。
js
function extractFlavor(manifest, fileName) {
const candidates = [];
for (const [name, value] of Object.entries(manifest.metaData || {})) {
if (/channel|flavor/i.test(name)) {
candidates.push(String(value));
}
}
const fileMatch = path.basename(fileName || "")
.match(/(?:channel|flavor)[-_]?([a-zA-Z0-9._-]+)/i);
if (fileMatch) {
candidates.push(fileMatch[1]);
}
return candidates.filter(Boolean).join(",");
}
这样兼容两种情况:
- Manifest 里有
channel或flavor。 - 文件名里有
channel-release、flavor_aCom之类的信息。
5. inspectApk 总入口
最终对外只暴露一个方法:
js
function inspectApk(filePath, originalName = "") {
const zip = parseZipEntries(filePath);
const manifestBuffer = zip.read("AndroidManifest.xml");
if (!manifestBuffer) {
throw new Error("AndroidManifest.xml not found in APK");
}
const manifest = parseBinaryManifest(manifestBuffer);
const labelResourceId = maybeResourceId(manifest.appLabel);
const resourceStrings = labelResourceId
? parseResourceTableStrings(zip.read("resources.arsc"))
: new Map();
const resolvedLabel = labelResourceId && resourceStrings.has(labelResourceId)
? resourceStrings.get(labelResourceId).value
: "";
const appName = resolvedLabel
|| (manifest.appLabel && !manifest.appLabel.startsWith("@") ? manifest.appLabel : "");
return {
appName,
appIds: manifest.packageName ? [manifest.packageName] : [],
flavors: normalizeArray(extractFlavor(manifest, originalName)),
versionCode: manifest.versionCode || 0,
versionName: manifest.versionName || "",
description: String(
manifest.metaData["update.description.zh"]
|| manifest.metaData.updateDescriptionZh
|| ""
),
descriptionEn: String(
manifest.metaData["update.description.en"]
|| manifest.metaData.updateDescriptionEn
|| ""
),
metaData: manifest.metaData
};
}
6. 后台识别接口
后台选择 APK 后,会先调用识别接口,把表单自动填充。
服务端接口:
js
app.post("/admin/api/apk/inspect", requireAdmin, apkInspectUpload, (req, res) => {
if (!req.file) {
res.status(400).json({ ok: false, msg: "APK file is required" });
return;
}
try {
const metadata = inspectApk(req.file.path, req.file.originalname);
res.json({ ok: true, metadata });
} catch (err) {
res.status(400).json({ ok: false, msg: err.message });
} finally {
fs.rmSync(req.file.path, { force: true });
}
});
前端使用:
js
els.apkInput.addEventListener("change", async () => {
const file = els.apkInput.files[0];
if (!file) {
return;
}
els.formMsg.textContent = "正在上传并识别 APK,请稍候...";
try {
const result = await inspectApkWithProgress(file);
applyApkMetadata(result.metadata || {});
els.formMsg.textContent = "APK 信息已自动填充。";
} catch (err) {
els.formMsg.textContent = err.message;
}
});
自动填充:
js
function applyApkMetadata(metadata) {
setFieldValue("#appName", metadata.appName);
setFieldValue("#appIds", (metadata.appIds || []).join(","));
setFieldValue("#flavors", (metadata.flavors || []).join(","));
setFieldValue("#versionName", metadata.versionName);
setFieldValue("#versionCode", metadata.versionCode);
setFieldValue("#description", metadata.description);
setFieldValue("#descriptionEn", metadata.descriptionEn);
}
本地验证
启动服务:
powershell
cd D:\your_workspace\server
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://localhost:8080"
npm start
打开后台:
text
http://localhost:8080/admin
新增应用,选择 APK,观察表单是否自动填充:
- 应用名。
- 应用 ID。
- 版本名。
- 版本号。
- 渠道。
- 更新说明。
保存后检查 update-config.json 是否出现对应版本。
常见问题
1. APK 不是普通 XML
这是最核心的坑。AndroidManifest.xml 在 APK 里是二进制格式,不能直接用文本 XML 解析库处理。
2. versionName 和 versionCode 不一致
升级判断只看 versionCode。比如:
xml
android:versionName="1.03.08"
android:versionCode="10308"
下一版必须让 versionCode 增大:
xml
android:versionName="1.03.09"
android:versionCode="10309"
3. 应用名为空
如果 android:label 是资源 ID,但 resources.arsc 没解析到,就可能为空。
这种情况下不影响升级判断,因为真正匹配用的是包名 packageName,后台里也可以手动补应用名。
4. Android 7+ 安装 APK 需要 FileProvider
服务端只负责下载地址和 APK 文件。App 下载完成后,如果要调起安装,需要 Android 侧配置:
REQUEST_INSTALL_PACKAGES权限。FileProvider。provider_paths.xml。
这部分在 App 接入篇里展开。
小结
APK 自动识别的价值很直接:减少手工填写错误。尤其是远程升级里,versionCode 一旦填错,轻则不弹更新,重则老版本覆盖新版本。
这套 Node 实现没有依赖 Android SDK,部署起来比较轻。核心流程就是:
text
读取 APK zip -> 解析二进制 Manifest -> 提取 package/version/meta-data -> 自动填充后台表单
下一篇写 App 端接入,从 STGC_HTTP_ADDRESS 到升级弹窗、下载和安装,把整个闭环串起来。