news 2026/8/30 12:20:29

在Android设备上将PWA打包成APK:从Manifest到签名

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在Android设备上将PWA打包成APK:从Manifest到签名

在 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 文件后,自己完成以下几件事:

  1. 读取 manifest.json,取应用名、图标、启动地址、主题色。
  2. 生成 Android 工程所需的最小资源集合,包括字符串、主题、多密度图标。
  3. 调用设备端可执行的 AAPT2 等原生二进制,把资源编译并链接成 APK。
  4. 把预先编译好的 WebView 壳 dex 合并进 APK。
  5. 使用 zipalign 对齐,使用 apksigner 签名。
  6. 通过 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 )

这里要注意optStringgetString的区别。optString在字段缺失时返回默认值,不会抛异常。manifest 来自第三方 PWA 时,字段缺失是常态,所以一律用opt*系列方法。

3.3 生成多密度启动图标

Android 启动图标按屏幕密度放在不同 mipmap 目录。常见映射关系如下。

密度目录图标尺寸(dp)
mipmap-mdpi48
mipmap-hdpi72
mipmap-xhdpi96
mipmap-xxhdpi144
mipmap-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 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 打包流程能成立的基础。

编译链接分两步:

阶段命令作用
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()) 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 端到端验证步骤

构建流程跑通后,不要只看到“生成成功”就结束,要按下面的顺序验证。

  1. 在宿主 App 中导入一个真实 PWA 目录,检查 manifest.json 能被解析。
  2. 观察 logs 中 AAPT2 compile 和 link 的输出,确认没有资源引用错误。
  3. 生成 APK 后,先用宿主 App 自身校验 APK 的包信息。
  4. 安装生成的 APK,打开确认 WebView 加载的是目标 PWA 首页。
  5. 在 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 errorcannot 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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/30 12:18:46

STM32WB55 USBDongle不广播?从硬件到协议栈的完整排查指南

遇到过“板子插上去&#xff0c;灯也亮了&#xff0c;手机就是扫不到广播包”这种情况的朋友&#xff0c;应该能秒懂标题里的这个痛点。NUCLEO-WB55.USBDongle&#xff0c;这块ST官方的USB小适配器&#xff0c;本质就是一块以STM32WB55为核心的BLE接收/调试工具&#xff0c;但很…

作者头像 李华
网站建设 2026/8/30 12:18:11

西安交大SDN实验课:Mininet+Ryu+Jupyter闭环实践指南

简介&#xff1a;本资源为西安交通大学计算机专业《软件定义网络》课程配套实验作业包&#xff0c;面向高校网络方向本科生及SDN初学者&#xff0c;旨在通过真实教学实验体系帮助学习者掌握SDN核心原理与工程实践能力。压缩包共73个文件&#xff0c;含20个Python控制器脚本&…

作者头像 李华
网站建设 2026/8/30 12:17:29

STM32C5双ADC交错模式配置实战:从CubeMX到HAL代码避坑指南

先说结论&#xff1a;STM32C5上配ADC交错模式&#xff0c;跟你在F1/F4/H7上那套经验有很大不同&#xff0c;至少我第一次在CubeMX里找配置入口时卡了半小时。这个系列虽然是Cortex-M33内核&#xff0c;按说外设逻辑应该老熟人&#xff0c;但它的ADC单元和时钟树改动不小&#x…

作者头像 李华
网站建设 2026/8/30 12:13:22

热浪冲击电力系统:从热力学原理到Python量化评估

最近两年&#xff0c;欧洲夏天几乎被“热浪停电风险”这两个词绑定。新闻里频繁出现核电站减载、燃煤电厂停机、电网进入紧急状态的报道。表面上看这是能源政策问题&#xff0c;但从工程角度来看&#xff0c;热浪对电力系统的冲击非常经典&#xff1a;一方面空调用电猛增&#…

作者头像 李华
网站建设 2026/8/30 12:13:14

DeepSeek接入Codex:Harness配置与reasoning_content报错排查

当你想把 DeepSeek 接入 Codex 这样的 AI 编程工具时&#xff0c;第一步就会碰到协议兼容问题&#xff1a;OpenAI 格式的请求到了 DeepSeek API&#xff0c;经常因为reasoning_content、思考模式参数返回 400。网上资料大多停留在“调通一次接口”&#xff0c;遇到真实工程化场…

作者头像 李华
网站建设 2026/8/30 12:12:37

AI生成代码正在重构代码审查,团队如何守住质量底线

过去一年里&#xff0c;你在 code review 时应该已经明显感觉到一种撕裂感&#xff1a;AI 生成的代码越来越流畅&#xff0c;函数命名整齐、注释齐全、结构分层合理&#xff0c;但评审者却越来越不敢点 Approve。团队里开始有人抱怨“既然 AI 都写出来了&#xff0c;为什么还要…

作者头像 李华