◉ Pulse
sebastianevan200-stack avatar
#78 today ▼ 4

sebastianevan200-stack/pwa-to-apk

App

把已有的 PWA 打包成安卓 App,改前端不用重新打包安装。

Created Jul 28, 2026
Stars
12
Today
+2
Forks
0
Quality
85/100
Momentum
17

README

Fetched from GitHub on Oct 30, 2026, 10:17 PM UTC · README.md

View original on GitHub

Third-party content — Open Source Pulse is not the owner of this repository.

This README belongs to sebastianevan200-stack/pwa-to-apk and its contributors. We fetch it directly from the project’s public GitHub repository and display it for reference only. All text, code, images and trademarks remain the property of their respective authors. See the repository for its license terms. Copyright / takedown policy.

PWA → 安卓 App

把一个已经在跑的 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-jdk

2. 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 android

cap sync 干两件事:把 dist 复制进原生工程、把已安装的 Capacitor 插件注册进去。每次装了新插件或改了 capacitor.config.ts 都要重跑。

常用的官方插件(按用得上的顺序):

  • @capacitor/app —— 拿返回键、前后台切换事件。基本必装,不然按返回键会直接退出 App。
  • @capacitor/status-bar —— 控制状态栏颜色,不然深色主题下状态栏会很难看。
  • @capacitor/push-notifications —— 原生推送(需要 Firebase 项目,免费)。

四、签名配置(含"怎么不把密码传上 GitHub")

这一步很多教程会让你把密码直接写进 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 就够了,可以跳过整节。

8.1 Firebase 侧:建项目、拿两把钥匙

去 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,再把文件放进去)。

8.2 安卓侧:三处配置

根 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 android

8.3 前端:拿到 token,报给自己的服务器

FCM 的 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 列表。同一个人可能有多台设备,所以是列表不是单值。

8.4 服务器:发 data-only 消息

Python 侧用官方的 firebase-admin:

pip install firebase-admin
import 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" 也不能省,否则在省电模式下消息会被延迟投递,来电就没意义了。

8.5 安卓侧:自己接管消息,弹全屏来电

在 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 还是你的前端在画。这样一来接听、挂断、通话界面全都留在前端,改的时候不用重新打包。

8.6 这一节特有的坑

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,最后才怀疑配置。


License

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.

Rendered by GitHub from the repository’s README. Scroll to read the full file.
First trending

Jul 28, 2026

first spotted on the list

Best rank

#22

highest position reached

Appearances

2

times on the trending list

Quality score

85

out of 100

Momentum

17

trend strength indicator

Star velocity

0.13

stars per day since creation

Activity — last 60 days

daily counts, collected every 6 hours
  • Stars
  • Forks
  • Merged PRs
  • Issues
  • Closed issues

Collecting activity data since launch — check back in a few days.

Star history

total stars per snapshot
910111213sebastianevan200-stack/pwa-to-apk · Jul 28, 7 PM UTC: 10 starssebastianevan200-stack/pwa-to-apk · Jul 29, 2 AM UTC: 12 stars7 PM11 PM2 AM

Rank history

position on trending · lower is better
#1#20#30#40#50#60#70#80Jul 28, 7 PM UTC: rank #22Jul 29, 2 AM UTC: rank #787 PM11 PM2 AM

3D card for your website

Live HTML card with a glass surface and pointer tilt. Paste the iframe on any website.

<iframe src="https://www.notionpulse.cc/embed/sebastianevan200-stack/pwa-to-apk" width="420" height="240" style="border:0;max-width:100%;background:transparent" loading="lazy" title="sebastianevan200-stack/pwa-to-apk — trending on Open Source Pulse"></iframe>

Stars badge

Show current stars in your README or profile. Auto-updates every 6 hours.

sebastianevan200-stack/pwa-to-apk stars badge
Markdown
[![sebastianevan200-stack/pwa-to-apk](https://www.notionpulse.cc/api/badge/sebastianevan200-stack/pwa-to-apk.svg)](https://www.notionpulse.cc/repo/sebastianevan200-stack/pwa-to-apk)
HTML
<a href="https://www.notionpulse.cc/repo/sebastianevan200-stack/pwa-to-apk"><img src="https://www.notionpulse.cc/api/badge/sebastianevan200-stack/pwa-to-apk.svg" alt="sebastianevan200-stack/pwa-to-apk stars badge" /></a>

Repository of the Day

#22 Repository of the Day

Repository of the Week

#22 Repository of the Week

Repository of the Month

#22 Repository of the Month