把一个已经在跑的 PWA(或任何前端网页)打包成能装在安卓手机上的 App。
核心:改前端不用重新打包安装。
你已经有一个网页版应用,跑在自己的服务器上,手机浏览器能打开。现在你想要它变成桌面上一个真正的图标——点开就是全屏的 App,没有浏览器地址栏。
这篇讲怎么做到,以及怎么做到之后日常改动不用重新打包。
最后一节是可选的:怎么做锁屏全屏来电那种原生推送——这是壳子唯一真正比网页多出来的能力。
不讲:怎么从零写一个 PWA、iOS 打包、上架应用商店。
前置要求:会用命令行。不需要懂安卓开发,不需要开发者账号,不需要实名认证。
先说这个,因为它决定了后面所有事的体验。
Capacitor 默认会把你的前端构建产物(dist 目录)打包死在 apk 里。这意味着:以后改一个按钮的颜色,你都要重新构建、重新签名、重新传到手机、重新安装一遍。
大部分人做私人 App 会死在这一步。不是技术难,是烦到最后不想改了。
破解方法是一行配置——让原生壳子去线上读页面:
// capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.yourname.app',
appName: '你的应用名',
webDir: 'dist',
server: {
url: 'https://your-domain.com/', // ← 关键:壳子去线上读
cleartext: false,
errorPath: 'offline.html' // ← 服务器连不上时的兜底页,这个是打包在本地的
}
};
export default config;配完之后的分工变成这样:
| 改什么 | 要不要重新打包装机 |
|---|---|
| 前端 UI、功能、bug 修复 | 不用。构建 + 重启服务,手机上下拉刷新就有 |
| 后端接口 | 不用 |
| App 图标、应用名、原生权限、原生插件 | 要 |
代价要说清楚,别被这个方案骗了:
- 离线打不开。 所以
errorPath那个兜底页必须配,否则断网就是一片白屏。 - 首屏依赖网络。 服务器慢,开屏就慢。
server.url必须是 HTTPS 且证书有效,否则壳子直接白屏,而且不会告诉你为什么。自签证书也不行——浏览器会弹警告让你点"继续",壳子里没有那个按钮。
对私人应用来说这笔买卖非常划算:一次打包,之后前端随便改。
三样,全部免费,不需要注册任何账号(想做原生推送的话需要一个 Google 账号,见第八节,可选):
1. JDK 21
注意版本。Capacitor 8 的 capacitor-android 模块要求 Java 21,用 JDK 17 会在构建时报 invalid source release: 21。Capacitor 6/7 用 17 即可——这是版本升级带来的变化,大部分现存教程还停在 17。
sudo apt install openjdk-21-jdk2. Android SDK
不需要装完整的 Android Studio(那玩意几个 G,还要图形界面)。命令行工具就够:
下载 commandline-tools,解压后:
export ANDROID_HOME=$HOME/android-sdk
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"把 ANDROID_HOME 写进 ~/.bashrc,省得每次导。
3. 签名钥匙(keystore)
自己生成,不经任何审核:
keytool -genkey -v -keystore my-app.keystore \
-alias myapp -keyalg RSA -keysize 2048 -validity 10000会问你密码和一堆信息(姓名、组织、城市),随便填,但密码要记住并且备份。
这把钥匙丢了的后果:以后打的包签名对不上,用户(也就是你自己)必须卸载重装才能升级,App 里的本地数据全部丢失。备份它,跟备份密码一样认真。
npm install @capacitor/core @capacitor/cli @capacitor/android
npx cap init "你的应用名" "com.yourname.app" --web-dir=dist
npx cap add android然后写 capacitor.config.ts(见第一节),再:
npm run build # 构建前端,产出 dist/
npx cap sync androidcap sync 干两件事:把 dist 复制进原生工程、把已安装的 Capacitor 插件注册进去。每次装了新插件或改了 capacitor.config.ts 都要重跑。
常用的官方插件(按用得上的顺序):
@capacitor/app—— 拿返回键、前后台切换事件。基本必装,不然按返回键会直接退出 App。@capacitor/status-bar—— 控制状态栏颜色,不然深色主题下状态栏会很难看。@capacitor/push-notifications—— 原生推送(需要 Firebase 项目,免费)。
这一步很多教程会让你把密码直接写进 build.gradle。别这么干,那个文件是要进仓库的。
第一步:在 android/ 目录下建 keystore.properties:
storeFile=/绝对路径/my-app.keystore
keyAlias=myapp
storePassword=你的密码
keyPassword=你的密码第二步:立刻把它加进 android/.gitignore:
keystore.properties
先加 gitignore 再写密码,顺序反了就有可能手滑提交上去。已经提交过的话,改密码比删 commit 靠谱——git 历史删不干净。
第三步:改 android/app/build.gradle。
在文件最顶上,android { ... } 块之前加:
def keystorePropsFile = rootProject.file("keystore.properties")
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) {
keystorePropsFile.withInputStream { keystoreProps.load(it) }
}然后在 android { ... } 块里面加 signingConfigs,并让 release 用上它:
android {
// ... 其他配置
signingConfigs {
release {
if (keystoreProps['storeFile']) {
storeFile file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release // ← 这一行是关键,少了它就还是 debug 签名
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}那个 if 判断是为了让别人克隆你的仓库时(没有 keystore.properties)也能正常构建 debug 版,不会直接报错。
cd android
export ANDROID_HOME=$HOME/android-sdk
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
./gradlew assembleRelease产物在 android/app/build/outputs/apk/release/app-release.apk。
打完一定要验签,这一步别省:
$ANDROID_HOME/build-tools/34.0.0/apksigner verify --print-certs app-release.apk看输出里的 Signer #1 certificate DN:
- 如果显示
CN=Android Debug—— 签名配置没生效,你打的还是 debug 包。回去检查signingConfig signingConfigs.release那一行加了没有。 - 如果显示的是你生成 keystore 时填的信息 —— 成功了。
这个检查很有必要:assembleRelease 在签名没配好的时候不会报错,它会默默用 debug 钥匙签,然后正常输出一个 apk。你不去验,根本不知道用的是哪把钥匙。
顺带一提,release 包会比 debug 包小很多(去掉了调试符号),差不多能小一半以上。如果你发现两者体积差不多,那也是个信号:你可能一直在打 debug 包。
装机:apk 传到手机,在系统设置里开"允许安装未知来源",点击安装。
升级:同一把 keystore 签名的包可以直接覆盖安装,数据保留。换了钥匙就必须先卸载。
按踩到的概率排序。
1. 构建脚本里的类型检查会挡路
很多脚手架的 npm run build 是 tsc -b && vite build。项目里有历史遗留的类型错误时,构建会直接中断,而这些错误对运行没有任何影响。
打包时可以直接跑构建工具本身跳过类型检查:npx vite build。
2. 图标改了不生效
改了图标,重新打包安装,桌面上还是旧的。原因通常是缓存——图标资源的 URL 需要带版本参数(icon.png?v=2)才能让系统重新拉取。
3. cap sync 忘了跑
改了 capacitor.config.ts 直接去 assembleRelease,配置不会生效。任何配置改动之后,先 npx cap sync android。
4. 开屏页
PWA 从浏览器"添加到主屏幕"时那帧系统强制的启动画面,进壳之后就归你管了。Capacitor 会生成一套 splash 资源和 Theme.SplashScreen 主题,替换 android/app/src/main/res/ 下各个 density 目录里的图即可。注意要全套 density 都换,只换一个的话不同分辨率的机器上会看到不一样的东西。
5. 通知相关的坑单独放在第八节
全屏来电、铃声、通知渠道这一类,只有做原生推送才会撞上,集中写在最后一节里了。
配好活壳子之后,这个清单会短得让人愉快:
- 改 App 图标、应用名
- 加/删原生插件
- 改
capacitor.config.ts - 改原生权限、原生代码
- 升级 Capacitor 版本
除此之外的一切——UI、交互、业务逻辑、后端接口、样式——git push 加重启服务就完事,手机上的 App 一个字节都不用动。
这一节是可选的,但它是整篇里唯一一个"必须用原生壳子才能做到"的能力。
前面七节做的事,本质上是给网页换了个更好的容器。而推送不一样:网页版的 Web Push 在安卓上能弹一条普通通知,但做不到锁屏状态下把界面整个拉起来——就是微信语音来电那种、屏幕黑着突然亮起来一整屏的效果。要那个,只能走 FCM + 原生通知。
代价有两个,先说清楚再决定做不做:
- 需要一个 Google 账号建 Firebase 项目(免费额度对个人应用绰绰有余,几乎不可能用超)
- 这一节改的是原生代码,以后动它必须重新打包——它是第七节那张清单上的常客
如果你只是想要"有新消息时手机响一下",浏览器的 Web Push 就够了,可以跳过整节。
去 console.firebase.google.com 建一个项目。Google Analytics 那一步可以直接关掉,用不上。
进去之后要做两件事,拿到两个文件。这两个文件用途完全不同,别搞混:
(1)添加 Android 应用 → 下载 google-services.json
填包名的时候注意:必须跟 capacitor.config.ts 里的 appId 一字不差。
这是这一节最容易踩的坑,因为填错了不会报任何错——构建照样成功,App 照样能装,只是推送永远收不到。排查起来非常费时间,因为整条链上没有一个地方会告诉你哪里不对。
下载下来的文件放到 android/app/google-services.json。
(2)项目设置 → 服务账号 → 生成新的私钥
会下载一个 json。这个是服务器端发消息用的凭证,跟上面那个完全不是一回事。
它等于你项目的通行证,泄露了别人就能冒充你给你的用户发推送。第一件事就是把它加进 .gitignore(顺序参考第四节:先写 gitignore,再把文件放进去)。
根 build.gradle 的 buildscript.dependencies 里加插件:
classpath 'com.google.gms:google-services:4.4.4'android/app/build.gradle 里加依赖,并在文件末尾条件应用插件:
dependencies {
implementation "com.google.firebase:firebase-messaging:25.0.1"
}
// 放在文件最后。写成条件判断是为了:没有 google-services.json 时构建不会崩,
// 只是推送不工作——方便别人 clone 你的仓库时能直接编译。
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, push notifications won't work")
}AndroidManifest.xml 里加权限:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />前端装插件:
npm i @capacitor/push-notifications
npx cap sync androidFCM 的 token 是"这台设备"的地址。服务器要给它发消息,就得先知道它。
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
useEffect(() => {
if (!Capacitor.isNativePlatform()) return; // 浏览器里跑不要执行
(async () => {
let perm = await PushNotifications.checkPermissions();
if (perm.receive !== 'granted') perm = await PushNotifications.requestPermissions();
if (perm.receive !== 'granted') return;
await PushNotifications.addListener('registration', token => {
// 发给你自己的后端存起来
fetch('/push/fcm_register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value }),
});
});
await PushNotifications.register();
})();
}, []);服务端就存一个 token 列表。同一个人可能有多台设备,所以是列表不是单值。
Python 侧用官方的 firebase-admin:
pip install firebase-adminimport firebase_admin
from firebase_admin import credentials, messaging
_app = firebase_admin.initialize_app(
credentials.Certificate("firebase_service_account.json")
)
def send_push(tokens, data: dict):
dead = []
for token in tokens:
try:
messaging.send(messaging.Message(
data={k: str(v) for k, v in data.items()}, # data 的值必须全是字符串
android=messaging.AndroidConfig(priority="high"),
token=token,
), app=_app)
except Exception as e:
msg = str(e).lower()
# token 会失效:用户卸载重装、清数据、长期不用都会让它作废。
# 不清理的话这个列表会越积越多,每次推送都在往死地址发。
if "unregistered" in msg or "not found" in msg:
dead.append(token)
return dead # 调用方负责把这些从库里删掉这里有一个决定成败的细节:只发 data,不要带 notification 字段。
区别是这样的:
带 notification 字段 |
只有 data(data-only) |
|
|---|---|---|
| App 在前台 | 交给你的代码 | 交给你的代码 |
| App 在后台/被杀 | 系统直接接管,按默认样式弹一条普通通知,你的代码收不到 | 照样交给你的代码 |
也就是说,带 notification 字段时,你精心写的全屏来电界面在最需要它的场景下(手机锁着、App 早被杀了)根本不会执行——系统会替你弹一条安静的通知栏消息,然后就没了。
priority="high" 也不能省,否则在省电模式下消息会被延迟投递,来电就没意义了。
在 AndroidManifest.xml 的 <application> 里注册一个自己的 service:
<service
android:name=".CallMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>android/app/src/main/java/com/yourname/app/CallMessagingService.java:
public class CallMessagingService extends FirebaseMessagingService {
private static final String CALL_CHANNEL_ID = "incoming_call";
@Override
public void onMessageReceived(RemoteMessage message) {
super.onMessageReceived(message);
Map<String, String> data = message.getData();
// 服务器发过来的 data 全在这里,按你自己定的字段分流
if ("call".equals(data.get("type"))) {
showFullScreenCall(data.get("id"));
}
}
private void showFullScreenCall(String callId) {
Context ctx = getApplicationContext();
NotificationManager nm =
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel ch = nm.getNotificationChannel(CALL_CHANNEL_ID);
if (ch == null) {
ch = new NotificationChannel(CALL_CHANNEL_ID, "来电",
NotificationManager.IMPORTANCE_HIGH);
ch.enableVibration(true);
ch.setVibrationPattern(new long[]{0, 800, 400, 800});
nm.createNotificationChannel(ch);
}
}
Intent intent = new Intent(ctx, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra("call_id", callId);
PendingIntent pending = PendingIntent.getActivity(ctx, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
nm.notify(9001, new NotificationCompat.Builder(ctx, CALL_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("来电")
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setAutoCancel(true)
.setFullScreenIntent(pending, true) // ← 锁屏直接拉起界面,就靠这一句
.setContentIntent(pending)
.build());
}
}界面被拉起来之后,webview 里的页面自己去后端查"当前有没有待接的来电"就行——原生这层只负责把 App 叫醒,具体 UI 还是你的前端在画。这样一来接听、挂断、通话界面全都留在前端,改的时候不用重新打包。
1. 铃声只响一下就停
不少机型对 CATEGORY_CALL 渠道的循环铃声支持不完整,指望渠道自带的 setSound 会得到"叮"一声然后安静。
可靠做法是渠道设成静音(channel.setSound(null, null)),自己用 MediaPlayer 循环播放,并且加一个超时(比如 60 秒)自动停——否则没人接的时候它会一直响下去。两条路只能选一条,同时开会两个声音打架。
2. 渠道建好之后改不动了
NotificationChannel 的声音、重要级别一旦创建就写死在系统里,代码里再怎么改都没用。调试期间改声音的唯一办法是换一个渠道 id(incoming_call → incoming_call_v2),或者让用户去设置里手动清数据。
上线前把渠道参数定下来,别等用户装了才发现要改。
3. Android 12+ 的全屏 intent 要用户手动开
USE_FULL_SCREEN_INTENT 在 Android 12 以上是需要用户在系统设置里手动允许的,代码没法代劳。没开的时候不会崩、不会报错,只是全屏效果降级成普通通知。
做的时候在应用里加个引导页,否则你自己都会以为是代码写错了。
4. 通知小图标必须是纯白剪影
安卓的 setSmallIcon 只取图标的 alpha 通道。直接用彩色的应用图标,通知栏上会出现一个白色的方块或者一团糊。要单独做一张纯白透明底的图。
5. 整条链断了不会报错
这是这一节最讨厌的地方。包名填错、google-services.json 忘了放、插件没 apply、token 没上报——以上任何一环出问题,表现都是同一个:构建成功、App 正常、推送收不到、日志干净。
排查顺序建议从后往前:先在 Firebase 控制台用"发送测试消息"直接对着 token 发(能收到说明安卓侧是好的,问题在你服务器),再看服务器有没有存到 token,最后才怀疑配置。
CC BY-NC-SA 4.0 © 2026 Evelyn & River.
- ✅ Use, modify and redistribute freely
- ✅ Keep the attribution, credit the source, state your changes
⚠️ Derivative versions must stay open source under the same license — no closed-sourcing- ❌ No commercial use — not in a paid product, paid service, or paid feature
For commercial licensing, get in touch. See NOTICE.md for the plain-language summary and LICENSE for the full text.
Previously released under MIT; copies obtained before 2026-09-02 keep those terms.