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 到升级弹窗、下载和安装,把整个闭环串起来。

相关推荐
Winlifes2 天前
我给 Codex 做了一个 Git 面板:分支树、提交历史和工作区操作
git
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
虎头金猫2 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
lpfasd1232 天前
2026年第38周GitHub趋势周报
python·科技·github
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
u1301302 天前
GitHub 热榜项目:日榜(2026-09-21)
人工智能·github
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android