
在 Android 设备上把 PWA 打包成 APK相当于在手机里塞进一条精简的 Android 打包流水线。App 需要自己解析 Web App Manifest、生成图标资源、调用编译工具链接资源、合并模板 dex、对齐并签名最后输出一个可以安装的 APK。这个能力在离线分发、企业内部应用交付、现场演示场景里非常实用尤其是当用户手边没有 PC、没有 Android Studio只有一台 Android 平板或手机时。这类工具适合三类人阅读一是做过 PWA 但不想每次都用 PC 打包交付的开发者二是需要做“网站打包成 App”工具产品的 Android 工程师三是想理解 APK 构建链路如何在设备端复用的学习者。这篇文章会围绕一个具体目标展开做一个宿主 App用户导入一个 PWA 目录宿主 App 在本机生成可安装 APK安装后由 WebView 加载 PWA 页面。文章会给出资源生成、AAPT2 编译、zipalign 对齐、apksigner 签名的完整思路和关键代码并区分学习环境与生产环境的做法。1. 理解 On-device PWA APK Generator它到底要做什么1.1 从 PWA 到 APK 的常规路径PWAProgressive Web App本质上是一组 Web 静态资源加一个描述文件。描述文件叫 Web App Manifest是一个 JSON里面记录了应用名称、启动地址、图标、主题色、显示模式等信息。浏览器读取这些信息后可以把网页“安装”到桌面以独立窗口运行。常见做法是把 manifest.json 里的内容交给构建工具处理。PC 上通常使用 Bubblewrap 或 TWATrusted Web Activity方案生成一个包含 WebView 或 Custom Tabs 的 Android 工程再由 Gradle 打包成 APK。这个过程依赖 JDK、Android SDK、Gradle 和网络下载的依赖只能在 PC 或 CI 服务器上完成。On-device PWA APK generator 的思路完全不同构建工具不在 PC 上跑而是在 Android 应用内部执行。宿主 App 拿到 PWA 文件后自己完成以下几件事读取 manifest.json取应用名、图标、启动地址、主题色。生成 Android 工程所需的最小资源集合包括字符串、主题、多密度图标。调用设备端可执行的 AAPT2 等原生二进制把资源编译并链接成 APK。把预先编译好的 WebView 壳 dex 合并进 APK。使用 zipalign 对齐使用 apksigner 签名。通过 MediaStore 把 APK 写入 Downloads 目录并触发安装。每一步都能映射到 PC 上 Gradle 构建的某个阶段只是执行环境换成了 Android 自己的进程。1.2 在设备上构建的难点在哪里设备端构建最大的限制是环境不完整。Android 系统本身没有暴露 JDK也没有公开的 Android SDK 工具链。要让 AAPT2、zipalign、apksigner 在设备上运行需要把这些二进制作为宿主 App 的资产带到设备上并按 CPU 架构选择正确版本。另一个难点是 dex 的来源。APK 里必须有 classes.dex而生成 dex 通常需要 D8/R8 这类基于 Java 的工具。设备上没有 JVM不能直接跑 D8。实际项目里常见的做法是在 PC 上用 Android Studio 预先编译一个很小的 WebView 壳工程把它生成的 classes.dex 作为模板资产放进宿主 App生成 APK 时直接合并进包内。这个壳工程里的 Activity 类名固定不依赖每次生成的应用包名因此可以复用到任意生成的 APK 中。第三个难点是签名。Android 安装 APK 时要求包必须有签名系统校验失败会直接报“解析包出现问题”。设备端必须准备好一个 keystore并在每次打包时用它签名。学习阶段可以用调试密钥生产环境必须使用独立管理的 release 密钥。1.3 三条实现路线对比实现路线是否需要 PC构建速度可定制程度实现成本适用场景预置模板 APK只替换 assets 后重签名仅首次准备模板时需要快低图标和应用名固定低快速验证、内部演示设备端 AAPT2 模板 dex apksigner 完整构建不需要依设备性能而定高可自定义应用名和图标高完全离线的正式工具客户端生成工程文件上传服务端构建不需要但依赖网络快高中允许联网、需要多平台产物第一种路线适合快速跑通但只能改 assets 里的网页内容应用名和图标由模板固定产品体验打折。第二种路线是这篇文章的重点它更接近真实打包链路的复刻。第三种路线本质是“服务端打包”不算严格意义上的 on-device 生成这里不展开。2. 环境准备与项目骨架2.1 设备与构建工具二进制的要求在开始写代码前先明确宿主 App 的运行环境。项目要求Android 版本建议 Android 8.0API 26及以上CPU 架构arm64-v8a 优先构建工具二进制需要按 ABI 分别打包存储空间建议至少 200MB 空闲空间取决于 PWA 资源大小网络可选。如果 PWA 需要在线加载需要网络权限系统权限不需要 ROOT安装生成 APK 时需要允许“安装未知应用”AAPT2、zipalign、apksigner 都是官方构建工具的组成部分。它们在 PC 上以 x86_64 Linux 可执行文件形式提供也可以找到 Android/arm64 可执行版本。设备端集成时把对应 ABI 的二进制放到宿主 App 的 assets 目录首次运行时复制到私有目录并赋予执行权限。2.2 宿主 App 的目录结构这里给出一个可参考的结构。宿主 App 自身是一个普通 Android 工程名称可以叫 PwaAgent生成 APK 时会在应用私有目录下创建临时工程。PwaAgent/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/pwagent/ │ │ │ ├── MainActivity.kt │ │ │ ├── generator/ │ │ │ │ ├── ManifestParser.kt │ │ │ │ ├── IconGenerator.kt │ │ │ │ ├── ProjectAssembler.kt │ │ │ │ ├── BuildExecutor.kt │ │ │ │ └── Signer.kt │ │ │ └── util/ShellExecutor.kt │ │ ├── res/ │ │ └── AndroidManifest.xml │ └── build.gradle.kts ├── tools/ │ ├── arm64-v8a/ │ │ ├── aapt2 │ │ ├── zipalign │ │ └── apksigner │ └── android.jar └── template/ ├── classes.dex └── web/ # 示例 PWA 目录tools 目录下的二进制默认不打包进 debug 包而是在脚本或 CI 里放入 assets。这样做是为了控制 APK 体积也方便不同 ABI 分开处理。2.3 Gradle 配置要点宿主 App 需要开启文件读取、网络和安装未知应用的权限。uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.REQUEST_INSTALL_PACKAGES /Gradle 侧重点关注 minSdk 和 targetSdk。minSdk 26 以上可以更方便使用 java.nio、MediaStore 和更完整的 Kotlin 标准库。android { namespace com.example.pwagent compileSdk 34 defaultConfig { applicationId com.example.pwagent minSdk 26 targetSdk 34 versionCode 1 versionName 1.0 } }这里的 namespace 是宿主 App 的包名和生成目标 APK 的包名没有任何关系。生成 APK 时我们会在 AAPT2 link 阶段通过--rename-manifest-package或直接改写 AndroidManifest.xml 来设置目标包名。3. 第一步实现解析 PWA manifest 并生成图标资源3.1 Web App Manifest 的数据模型一个典型的 PWA manifest.json 长这样。以某内部工作平台为例它可能只是一个单页应用根目录里有 index.html 和配套静态资源。{ name: 手术室工作平台, short_name: 手术平台, start_url: ./index.html, display: standalone, background_color: #ffffff, theme_color: #1976d2, icons: [ { src: icons/icon-192.png, sizes: 192x192, type: image/png }, { src: icons/icon-512.png, sizes: 512x512, type: image/png } ] }name用于生成 APK 的 labelicons用于生成各密度 mipmap 图标theme_color可以写入主题资源display决定 WebView 是否默认全屏。3.2 用 Kotlin 解析 manifestAndroid 自带 org.json不需要额外引入 JSON 库。解析时要注意字段缺省的兜底很多 PWA 的 manifest 并不完整。fun parseManifest(webRoot: File): PwaConfig { val manifestFile File(webRoot, manifest.json) if (!manifestFile.exists()) { throw IllegalArgumentException(manifest.json 不存在) } val text manifestFile.readText() val json JSONObject(text) val name json.optString(name) .ifBlank { json.optString(short_name, PWA App) } val shortName json.optString(short_name, name) val startUrl json.optString(start_url, index.html) val displayMode json.optString(display, standalone) val themeColor json.optString(theme_color, #3F51B5) val backgroundColor json.optString(background_color, #FFFFFF) val icons mutableListOfPwaIcon() val iconArray json.optJSONArray(icons) if (iconArray ! null) { for (i in 0 until iconArray.length()) { val item iconArray.getJSONObject(i) icons.add( PwaIcon( src item.optString(src), sizes item.optString(sizes), type item.optString(type, image/png) ) ) } } return PwaConfig(name, shortName, startUrl, displayMode, themeColor, backgroundColor, icons) } data class PwaConfig( val name: String, val shortName: String, val startUrl: String, val displayMode: String, val themeColor: String, val backgroundColor: String, val icons: ListPwaIcon ) data class PwaIcon( val src: String, val sizes: String, val type: String )这里要注意optString和getString的区别。optString在字段缺失时返回默认值不会抛异常。manifest 来自第三方 PWA 时字段缺失是常态所以一律用opt*系列方法。3.3 生成多密度启动图标Android 启动图标按屏幕密度放在不同 mipmap 目录。常见映射关系如下。密度目录图标尺寸dpmipmap-mdpi48mipmap-hdpi72mipmap-xhdpi96mipmap-xxhdpi144mipmap-xxxhdpi192生成图标时优先从 manifest 的 icons 里取 192 或 512 的大图再缩放到各密度。如果原图不存在需要从 PWA 资源目录里按相对路径找到文件或者生成一个带应用名首字母的占位图标。fun generateLauncherIcons( source: Bitmap, outputDir: File ) { val densityMap listOf( mipmap-mdpi to 48, mipmap-hdpi to 72, mipmap-xhdpi to 96, mipmap-xxhdpi to 144, mipmap-xxxhdpi to 192 ) densityMap.forEach { (folder, dp) - val targetDir File(outputDir, folder) targetDir.mkdirs() val scale dp.toFloat() / 48f val px (48 * scale * resources.displayMetrics.density).toInt() val scaled Bitmap.createScaledBitmap(source, px, px, true) val outFile File(targetDir, ic_launcher.png) FileOutputStream(outFile).use { out - scaled.compress(Bitmap.CompressFormat.PNG, 100, out) } scaled.recycle() } }真实产品里还应该生成前景和背景两层配合adaptive-icon使用。这里先给出最直接的位图缩放保证生成流程能跑通。生产环境建议用 adaptive icon XML 引用前景和背景图避免系统遮罩裁切变形。3.4 组装最小 Android 工程目录资源和 manifest 都准备好后在宿主 App 私有目录下创建临时工程。结构如下。${filesDir}/pwa-build/ ├── AndroidManifest.xml ├── res/ │ ├── values/strings.xml │ ├── values/themes.xml │ └── mipmap-*/ic_launcher.png ├── assets/web/ # 从 PWA 目录整体复制 ├── build/ ├── gen/ └── bin/AndroidManifest.xml 是整个构建的入口。目标包名可以先用占位符由 AAPT2 链接时替换。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.pwagenerated uses-permission android:nameandroid.permission.INTERNET / application android:labelstring/app_name android:iconmipmap/ic_launcher android:themestyle/Theme.Pwa android:usesCleartextTraffictrue activity android:namecom.pwagent.template.WebActivity android:exportedtrue android:configChangesorientation|screenSize|keyboardHidden intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest这里android:name写的是模板 dex 里固定的类名com.pwagent.template.WebActivity它和 package 字段不同。这样每次生成不同包名的 APK 时dex 不需要重新编译。values/strings.xml 和 themes.xml 都很简单。resources string nameapp_name手术室工作平台/string /resourcesresources style nameTheme.Pwa parentandroid:Theme.Material.Light.NoActionBar item nameandroid:statusBarColor#1976d2/item item nameandroid:windowBackground#ffffff/item /style /resources组装时要把用户选择的 PWA 目录整体复制到 assets/web 下。复制前先清空旧目录避免上一次构建的残留文件混进来。4. 第二步实现设备端执行 AAPT2、zipalign 与 apksigner4.1 为什么要用 AAPT2而不是直接解压改包有些“网站转 APK”工具走的是捷径拿到一个现成的 APK直接改 assets然后重新签名。这种方式最快但有两个致命问题。一是 APK 里的 resources.arsc 是二进制索引表直接替换 res 目录里的文件会破坏索引安装时容易解析失败。二是应用名和图标都写死在资源表里只改 assets 无法更新它们。AAPT2 是 Android 官方的资源编译链接工具它能在不依赖完整 SDK 的情况下把 res 目录编译成中间文件再链接出 APK 的完整资源表。AAPT2 是原生可执行文件可以运行在 Android 设备上这是整个 on-device 打包流程能成立的基础。编译链接分两步阶段命令作用compileaapt2 compile --dir res -o build/res.zip把 XML 和 PNG 编译为二进制格式linkaapt2 link -o base.apk -I android.jar --manifest AndroidManifest.xml build/res.zip生成 resources.arsc 和最终 APK 基础结构-I android.jar参数指向 Android framework 资源这是 AAPT2 解析系统属性如android:theme时必需的。设备端需要随宿主 App 打包一个对应 compileSdk 版本的 android.jar体积通常几 MB可以放在 assets/tools 下。4.2 把构建工具二进制带入设备assets 目录里的文件不能直接执行需要先复制到应用私有目录并赋予执行权限。fun prepareTools(abi: String) { val toolDir File(filesDir, tools) toolDir.mkdirs() listOf(aapt2, zipalign, apksigner, android.jar).forEach { name - val source ${abi}/$name val target File(toolDir, name) if (target.exists()) returnforEach resources.assets.open(source).use { input - FileOutputStream(target).use { output - input.copyTo(output) } } if (name ! android.jar) { target.setExecutable(true, false) } } }这里 ABI 可以通过Build.SUPPORTED_ABIS[0]获取例如 arm64-v8a 或 x86_64。如果宿主 App 同时支持多个 ABIassets 里就要按目录分别放置对应二进制。4.3 用 ProcessBuilder 执行编译、链接与对齐设备上调用外部进程推荐使用 ProcessBuilder 而不是 Runtime.exec。ProcessBuilder 支持命令列表参数能避免路径里有空格导致解析错误。class ShellExecutor { fun run(command: ListString, workingDir: File? null): String { val process ProcessBuilder(command) .directory(workingDir) .redirectErrorStream(true) .start() val output process.inputStream.bufferedReader().readText() val exitCode process.waitFor() if (exitCode ! 0) { throw IllegalStateException( 命令执行失败: ${command.joinToString( )}\n$output ) } return output } }然后按顺序执行构建。这里的路径都以应用私有目录为基础。val tools File(filesDir, tools) val projectDir File(filesDir, pwa-build) val shell ShellExecutor() // 1. 编译资源 shell.run( listOf( ${tools}/aapt2, compile, --dir, File(projectDir, res).absolutePath, -o, File(projectDir, build/res.zip).absolutePath ) ) // 2. 链接资源并生成基础 APK shell.run( listOf( ${tools}/aapt2, link, -o, File(projectDir, build/unsigned.apk).absolutePath, -I, File(tools, android.jar).absolutePath, --manifest, File(projectDir, AndroidManifest.xml).absolutePath, --java, File(projectDir, gen).absolutePath, File(projectDir, build/res.zip).absolutePath ) ) // 3. 把模板 classes.dex 合并进 APK val dexFromAssets File(filesDir, template/classes.dex) val apkFile File(projectDir, build/unsigned.apk) addFileToZip(apkFile, dexFromAssets, classes.dex) // 4. zipalign 对齐 val alignedApk File(projectDir, build/aligned.apk) shell.run( listOf( ${tools}/zipalign, -f, 4, apkFile.absolutePath, alignedApk.absolutePath ) )把 classes.dex 合并进 APK本质上就是往 zip 里添加文件。可以用 ZipOutputStream 实现也可以用系统自带的 zip 命令。这里给出一个简洁实现fun addFileToZip(apk: File, fileToAdd: File, entryName: String) { val temp File(apk.parentFile, temp.apk) ZipOutputStream(FileOutputStream(temp)).use { zip - ZipInputStream(FileInputStream(apk)).use { input - var entry input.nextEntry while (entry ! null) { val name entry.name if (name entryName) { entry input.nextEntry continue } zip.putNextEntry(ZipEntry(name)) input.copyTo(zip, bufferSize 8192) zip.closeEntry() entry input.nextEntry } } zip.putNextEntry(ZipEntry(entryName)) FileInputStream(fileToAdd).use { it.copyTo(zip) } zip.closeEntry() } temp.renameTo(apk) }这里的逻辑是把原 APK 的所有条目原样复制到临时 zip跳过已有的 classes.dex然后再写入新的 classes.dex。这样保证 dex 在 APK 内只有一份。4.4 签名并输出到 Downloads签名必须发生在 zipalign 之后。apksigner 在签名时会校验 APK 对齐状态v2 签名会把对齐信息一并写入签名块因此签名后再执行 zipalign 反而会破坏签名。val finalApk File(projectDir, build/final.apk) shell.run( listOf( ${tools}/apksigner, sign, --ks, File(tools, release.jks).absolutePath, --ks-key-alias, pwa, --ks-pass, pass:changeit, --key-pass, pass:changeit, --out, finalApk.absolutePath, alignedApk.absolutePath ) )签名完成后把 final.apk 通过 MediaStore 写入系统 Downloads 目录。这样可以避免存储权限问题也是 Android 10 之后的推荐做法。fun publishToDownloads(apk: File): Uri? { val values ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, generated-pwa.apk) put(MediaStore.Downloads.MIME_TYPE, application/vnd.android.package-archive) put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val collection MediaStore.Downloads.EXTERNAL_CONTENT_URI val uri contentResolver.insert(collection, values) ?: return null contentResolver.openOutputStream(uri)?.use { out - FileInputStream(apk).use { it.copyTo(out) } } return uri }让用户点击安装时需要构造 ACTION_VIEW 意图并授予 URI 读取权限。val intent Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, application/vnd.android.package-archive) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } startActivity(intent)系统会弹出安装确认界面这依赖用户在设置里允许“安装未知应用”。5. 运行验证从生成到安装的完整检查5.1 端到端验证步骤构建流程跑通后不要只看到“生成成功”就结束要按下面的顺序验证。在宿主 App 中导入一个真实 PWA 目录检查 manifest.json 能被解析。观察 logs 中 AAPT2 compile 和 link 的输出确认没有资源引用错误。生成 APK 后先用宿主 App 自身校验 APK 的包信息。安装生成的 APK打开确认 WebView 加载的是目标 PWA 首页。在 WebView 里检查离线资源能否加载返回键是否能正常退出。宿主 App 里可以用 PackageManager 直接读取生成 APK 的元信息这个验证不需要额外工具。fun verifyApk(apkFile: File) { val pkgInfo packageManager.getPackageArchiveInfo( apkFile.absolutePath, PackageManager.GET_ACTIVITIES ) ?: throw IllegalStateException(APK 不是有效安装包) val label pkgInfo.applicationInfo.loadLabel(packageManager).toString() val packageName pkgInfo.packageName val version pkgInfo.versionName Log.i(PwaAgent, 验证通过 package$packageName, label$label, version$version) }如果getPackageArchiveInfo返回的非空说明资源表和 manifest 基本合法至少能通过第一层解析校验。5.2 用 aapt 校验 APK 元信息在 PC 或调试脚本中可以再跑一次 aapt2 相关的检查确认 APK 的 manifest 和签名正确。# 查看 APK 基本信息 aapt2 dump badging final.apk # 查看 manifest 树 aapt2 dump xmltree final.apk --file AndroidManifest.xml # 验证签名 apksigner verify --verbose final.apk如果 aapt2 dump badging 能输出 package name、launchable-activity、label 和 icon说明资源链接阶段没有大问题。签名的 v1、v2 方案都要为 true低版本设备才能正常安装。5.3 学习环境与生产环境的差异项目学习环境生产环境签名密钥使用调试 keystore口令固定独立管理的 release keystore口令放服务端或密钥库构建二进制放在 assets调试方便按 ABI 分包用 App Bundle 分发控制体积PWA 资源从本地目录导入支持 URI 导入、压缩包导入、版本校验错误处理日志输出即可记录失败原因、上传构建日志、支持反馈并发单线程构建构建任务放入前台 Service加任务队列安全不限校验 PWA 来源校验 manifest 字段合法性防止路径穿越回滚不处理保留上一版 keystore 和构建脚本版本学习环境里可以接受“每次用同一个固定包名”。但生产环境如果生成多个 APK必须让每个 APK 的包名不同否则第二个安装会覆盖第一个。6. 常见问题与排查链路6.1 构建工具执行报 Exec format error现象调用 aapt2 时ProcessBuilder 抛出异常日志里出现Exec format error或cannot execute binary file。可能原因assets 里的构建工具二进制是 x86_64 版本而设备是 arm64或者宿主 App 没有把二进制复制到可执行目录只是直接读取 assets 里的文件尝试执行。检查方式在设备上执行uname -m查看架构用Build.SUPPORTED_ABIS确认宿主 App 的 ABI同时确认目标文件确实已经复制到 filesDir 并设置了执行权限。处理建议if (!target.canExecute()) { target.setExecutable(true, false) }同时在 assets 里按 ABI 分目录放置二进制启动时根据 ABIS[0] 选择目录。6.2 APK 安装报“解析包出现问题”或“未找到签名”现象安装生成的 APK 时系统提示解析失败或者签名为空。可能原因link 出来的 APK 没有 classes.dex。zipalign 之前就签名导致签名信息被后续步骤破坏。APK 里混入了重复的 classes.dex。签名时用了错误的 keystore 别名或口令。检查方式unzip -l final.apk | grep classes.dex apksigner verify --verbose final.apk zipalign -c -v 4 final.apk处理建议严格按照 compile - link - 合并 dex - zipalign - sign 的顺序执行。每次构建前清空 build 目录避免上一次产物混入。使用addFileToZip时注意跳过已有 classes.dex防止重复。6.3 WebView 白屏或加载路径不对现象生成的 APK 能安装能打开但 WebView 显示白屏或只显示默认错误页。可能原因壳 Activity 加载的 URL 与 manifest 里的 start_url 不一致。PWA 资源里的相对路径在 file:// 协议下解析失败。页面引用了 HTTPS 外部资源而网络不可用。manifest 里 start_url 是绝对 URL但设备没联网。检查方式在壳工程里开启 WebView 调试。WebView.setWebContentsDebuggingEnabled(true)然后用 Chrome 的设备调试页面查看 console 报错。处理建议把 PWA 完全复制到assets/web/后壳 Activity 使用webView.loadUrl(file:///android_asset/web/index.html)。如果 PWA 必须走网络则使用 HTTPS 绝对地址并在宿主 App 的 networkSecurityConfig 里允许对应域名。不要用usesCleartextTraffictrue去纵容所有明文流量生产环境应精确配置。6.4 常见问题速查表问题现象常见原因检查方式处理建议AAPT2 执行报错二进制 ABI 不匹配或缺少执行权限检查 Build.SUPPORTED_ABIS 和文件权限按 ABI 分发工具启动时复制并 chmodmanifest.json 解析失败字段缺失或 JSON 格式错误用 JSON 校验器格式化原文件使用 optString 兜底并给出错误提示APK 安装解析失败dex 缺失、签名错误、zip 结构损坏apksigner verify 和 unzip -l按构建顺序重跑清空 build 目录WebView 白屏start_url 路径错误或资源未复制开启 WebView 调试查看 console使用 file:///android_asset/web/index.html两个生成 APK 互相覆盖包名相同PackageManager 查询包名生成时拼接随机字符串作为包名后缀安装按钮无反应未授予安装未知应用权限检查设置里的应用权限跳转 ACTION_MANAGE_UNKNOWN_APP