摘要

在升级后台里,上传 APK 后最好能自动填充应用名、包名、版本名、版本号和渠道信息。这样可以减少手工填写错误,尤其是 versionCode 写错会直接影响升级判断。

这篇单独记录服务端如何在 Node.js 里解析 APK:

APK(zip) -> AndroidManifest.xml(binary xml) -> packageName / versionName / versionCode

适用场景

  • 后台上传 APK 后自动识别版本信息。
  • 不想在服务器安装 Android SDK 或 aapt。
  • 希望 Node 服务独立完成 APK 元数据读取。
  • 需要识别 meta-data 中的渠道、更新说明。

本文效果

完成后上传 APK 可以得到:

{
  "appName": "GCS Demo",
  "appIds": ["com.example.gcs"],
  "flavors": ["release"],
  "versionCode": 10308,
  "versionName": "1.03.08",
  "description": "修复已知问题",
  "descriptionEn": ""
}

背景

最开始很容易以为 APK 里的 AndroidManifest.xml 可以这样读:

fs.readFileSync("AndroidManifest.xml", "utf8")

实际不行。

APK 本质是 zip 包没错,但里面的 AndroidManifest.xml 是 Android 二进制 XML,不是普通文本 XML。直接按 UTF-8 读会是一堆乱码。

所以服务端做了三层解析:

  1. 从 APK zip 中读取 AndroidManifest.xml
  2. 解析二进制 XML 字符串池和节点属性。
  3. 解析 resources.arsc,把 @0x7f... 这种 label 资源 ID 还原成应用名。

解析流程图

上传 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 中央目录,找到文件条目。

核心代码裁剪如下:

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 可读值:

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;
}

然后解析 manifestapplicationmeta-data 节点:

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 的应用名不是直接字符串,而是类似:

@0x7f120001

这种值需要再去 resources.arsc 里找。

识别资源 ID:

function maybeResourceId(value) {
  const match = String(value || "").match(/^@0x([0-9a-f]+)$/i);
  return match ? Number.parseInt(match[1], 16) >>> 0 : 0;
}

还原资源字符串的完整代码比较长,思路是:

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,把字符串资源保存成:

strings.set(resourceId, {
  key: "app_name",
  value: "GCS Demo"
});

然后 inspectApk() 就能把 label 还原出来。

4. 提取渠道信息

有些项目会把渠道写到 meta-data,也可能体现在 APK 文件名里。

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 里有 channelflavor
  • 文件名里有 channel-releaseflavor_aCom 之类的信息。

5. inspectApk 总入口

最终对外只暴露一个方法:

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 后,会先调用识别接口,把表单自动填充。

服务端接口:

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 });
  }
});

前端使用:

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;
  }
});

自动填充:

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);
}

本地验证

启动服务:

cd D:\your_workspace\server
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://localhost:8080"
npm start

打开后台:

http://localhost:8080/admin

新增应用,选择 APK,观察表单是否自动填充:

  • 应用名。
  • 应用 ID。
  • 版本名。
  • 版本号。
  • 渠道。
  • 更新说明。

保存后检查 update-config.json 是否出现对应版本。

常见问题

1. APK 不是普通 XML

这是最核心的坑。AndroidManifest.xml 在 APK 里是二进制格式,不能直接用文本 XML 解析库处理。

2. versionName 和 versionCode 不一致

升级判断只看 versionCode。比如:

android:versionName="1.03.08"
android:versionCode="10308"

下一版必须让 versionCode 增大:

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,部署起来比较轻。核心流程就是:

读取 APK zip -> 解析二进制 Manifest -> 提取 package/version/meta-data -> 自动填充后台表单

下一篇写 App 端接入,从 STGC_HTTP_ADDRESS 到升级弹窗、下载和安装,把整个闭环串起来。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐