Codex实战:APP远程升级服务搭建(四)Node 服务端自动识别 APK 信息

摘要

在升级后台里,上传 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 读会是一堆乱码。

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

  1. 从 APK zip 中读取 AndroidManifest.xml
  2. 解析二进制 XML 字符串池和节点属性。
  3. 解析 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;
}

然后解析 manifestapplicationmeta-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 里有 channelflavor
  • 文件名里有 channel-releaseflavor_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 到升级弹窗、下载和安装,把整个闭环串起来。

相关推荐
IT WorryFree2 小时前
ESXi 全维度监控方式完整分类(按使用场景排序)
运维·服务器·网络
JohnnyDeng942 小时前
【Android】ViewModelScope 与协程生命周期管理:告别内存泄漏,掌控异步边界
android·kotlin·mvvm·协程
私人珍藏库2 小时前
【Android】瞬净ins版-无水印解析-无水印视频保存
android·app·工具·软件·多功能
Maxwellhang2 小时前
Termux 安装 Claude Code + 配置 DeepSeek API
android·智能手机
herinspace2 小时前
管家婆辉煌软件如何新增往来单位档案分类
服务器·数据库·电脑·管家婆软件
百度搜知知学社2 小时前
一键装裱照片,相框APP内置滤镜与贴纸编辑器
android·编辑器·滤镜·图片编辑·贴纸·相框
RoboWizard3 小时前
一块硬盘上架前要闯多少关?
java·服务器·数据库
吴阿福|一人公司3 小时前
深度解析 Python 类变量修改的命名空间隔离
java·服务器·数据结构
AFinalStone3 小时前
Android12 U盘插拔链路源码全解析(四):Framework层(上) —— UsbHostManager
android·frameworks