在 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 的常规路径
PWA(Progressive Web App)本质上是一组 Web 静态资源加一个描述文件。描述文件叫 Web App Manifest,是一个 JSON,里面记录了应用名称、启动地址、图标、主题色、显示模式等信息。浏览器读取这些信息后,可以把网页“安装”到桌面,以独立窗口运行。
常见做法是把 manifest.json 里的内容交给构建工具处理。PC 上通常使用 Bubblewrap 或 TWA(Trusted 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.0(API 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:name="android.permission.INTERNET" /> <uses-permission android:name="android.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 的 label,icons用于生成各密度 mipmap 图标,theme_color可以写入主题资源,display决定 WebView 是否默认全屏。
3.2 用 Kotlin 解析 manifest
Android 自带 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 = mutableListOf<PwaIcon>() 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: List<PwaIcon> ) data class PwaIcon( val src: String, val sizes: String, val type: String )这里要注意optString和getString的区别。optString在字段缺失时返回默认值,不会抛异常。manifest 来自第三方 PWA 时,字段缺失是常态,所以一律用opt*系列方法。
3.3 生成多密度启动图标
Android 启动图标按屏幕密度放在不同 mipmap 目录。常见映射关系如下。
| 密度目录 | 图标尺寸(dp) |
|---|---|
| mipmap-mdpi | 48 |
| mipmap-hdpi | 72 |
| mipmap-xhdpi | 96 |
| mipmap-xxhdpi | 144 |
| mipmap-xxxhdpi | 192 |
生成图标时,优先从 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 version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.pwagenerated"> <uses-permission android:name="android.permission.INTERNET" /> <application android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:theme="@style/Theme.Pwa" android:usesCleartextTraffic="true"> <activity android:name="com.pwagent.template.WebActivity" android:exported="true" android:configChanges="orientation|screenSize|keyboardHidden"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.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 name="app_name">手术室工作平台</string> </resources><resources> <style name="Theme.Pwa" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:statusBarColor">#1976d2</item> <item name="android:windowBackground">#ffffff</item> </style> </resources>组装时要把用户选择的 PWA 目录整体复制到 assets/web 下。复制前先清空旧目录,避免上一次构建的残留文件混进来。
4. 第二步实现:设备端执行 AAPT2、zipalign 与 apksigner
4.1 为什么要用 AAPT2,而不是直接解压改包
有些“网站转 APK”工具走的是捷径:拿到一个现成的 APK,直接改 assets,然后重新签名。这种方式最快,但有两个致命问题。一是 APK 里的 resources.arsc 是二进制索引表,直接替换 res 目录里的文件会破坏索引,安装时容易解析失败。二是应用名和图标都写死在资源表里,只改 assets 无法更新它们。
AAPT2 是 Android 官方的资源编译链接工具,它能在不依赖完整 SDK 的情况下,把 res 目录编译成中间文件,再链接出 APK 的完整资源表。AAPT2 是原生可执行文件,可以运行在 Android 设备上,这是整个 on-device 打包流程能成立的基础。
编译链接分两步:
| 阶段 | 命令 | 作用 |
|---|---|---|
| compile | aapt2 compile --dir res -o build/res.zip | 把 XML 和 PNG 编译为二进制格式 |
| link | aapt2 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()) return@forEach 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 同时支持多个 ABI,assets 里就要按目录分别放置对应二进制。
4.3 用 ProcessBuilder 执行编译、链接与对齐
设备上调用外部进程,推荐使用 ProcessBuilder 而不是 Runtime.exec。ProcessBuilder 支持命令列表参数,能避免路径里有空格导致解析错误。
class ShellExecutor { fun run(command: List<String>, 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 里允许对应域名。不要用usesCleartextTraffic="true"去纵容所有明文流量,生产环境应精确配置。
6.4 常见问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| AAPT2 执行报错 | 二进制 ABI 不匹配或缺少执行权限 | 检查 Build.SUPPORTED_ABIS 和文件权限 | 按 ABI 分发工具,启动时复制并 chmod |
| manifest.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 |