一个黑底白字、沉浸式全屏的"北京时间数字时钟" Android APP。 用 Capacitor 8 把一个纯 HTML 页面打包成原生 APK,并实现了类似"游戏模式"的沉浸式体验 —— 系统栏平时完全隐藏,从屏幕边缘下滑时临时浮现几秒后自动收回。
一个极简的 Android 时钟应用:
- 打开就是一个纯黑背景的页面,中央是大号白色的北京时间(HH:mm:ss,每秒跳动)
- 下方显示中文日期 + 星期,再往下是一行"中国标准时间 UTC+8"
- 状态栏和导航栏默认完全隐藏,时钟占满整个屏幕(包括刘海/挖孔区域)
- 从屏幕顶部或底部边缘向下滑,系统栏会临时浮现,几秒不用它又会自动缩回去
- 全程离线运行,不联网,时间是从设备时间换算成 UTC+8 得到的
本质上是一个只有一个 HTML 页面的 Web 应用,通过 Capacitor 装进了一个原生 Android 壳里。
做这个项目时考虑过最朴素的方案:在手机上用 Python http.server 起个本地服务,然后用浏览器打开 localhost:8000。但这个方案有几个硬伤:
| 方案 | 问题 |
|---|---|
| Python + 本地 HTTP 服务 | ① 得先开 Termux、起服务、再开浏览器输地址,步骤繁琐;② 浏览器顶部永远有地址栏和工具栏,做不到真正全屏;③ 不能切后台,服务一停页面就没了;④ 没有一个可以从桌面点开、有自己图标的"APP" |
| Capacitor 打包成 APK | ① 编译出一个独立 APK,装上就是正经 APP,桌面点图标直接进;② 可以调用原生 API 真正控制系统栏,实现沉浸式;③ 离线运行,不依赖任何后台进程;④ 体验和原生 APP 完全一致 |
所以选择了 Capacitor —— 它的定位就是"用 Web 技术写页面,套一层原生壳变成 APP"。页面部分你写的还是 HTML/CSS/JS,但跑在一个由原生 Activity 托管的 WebView 里,并且可以通过插件桥接调用原生能力(比如本项目用到的系统栏控制)。
你记不清的"那个工具"就是 Capacitor(对标 Cordova / PhoneGap,Ionic 团队出品,目前是这个领域的主流方案)。
| 层 | 技术 | 版本 |
|---|---|---|
| 页面层 | 纯 HTML + CSS + 原生 JavaScript(无任何框架) | — |
| Web 容器 | Capacitor(core / cli / android 三件套) | 8.4.2 |
| 原生壳 | Java MainActivity 继承 Capacitor 的 BridgeActivity |
— |
| 沉浸式控制 | AndroidX WindowCompat + WindowInsetsControllerCompat |
core 1.17.0 |
| 构建系统 | Gradle + Capacitor Gradle 插件 | — |
| 最低支持 | Android 7.0 | minSdk 24 |
| 目标版本 | Android 16 | targetSdk 36 |
BJClock/
├── src/
│ └── index.html # ★ 全部页面逻辑:时钟渲染 + 时间换算 + 全屏按钮
├── capacitor.config.json # Capacitor 配置:APP 名、webDir、SystemBars 插件配置
├── package.json # 依赖:@capacitor/core / cli / android
│
└── android/ # Capacitor 生成的原生 Android 工程
├── app/src/main/
│ ├── java/com/bjclock/app/
│ │ └── MainActivity.java # ★ 沉浸式核心代码(详见下文)
│ ├── res/values/
│ │ └── styles.xml # ★ 启动主题:透明系统栏 + 挖孔屏适配
│ └── AndroidManifest.xml # 入口声明 + 屏幕配置变化处理
├── variables.gradle # SDK 与 AndroidX 依赖版本
└── ...
真正"自己写的代码"只有三个文件:src/index.html、MainActivity.java、styles.xml。其余都是 Capacitor 模板生成的工程骨架。
整体流程是四步,理解了这套流程任何"网页变 APP"的需求都能套:
src/index.html 就是普通网页,没什么特殊。关键逻辑是把设备时间换算成北京时间:
const now = new Date();
// 先拿到 UTC 时间(本地时间 + 本地时区偏移)
const utc = now.getTime() + now.getTimezoneOffset() * 60000;
// UTC 再加 8 小时 = 北京时间,无论设备在哪个时区都正确
const bj = new Date(utc + 8 * 3600000);这一步的意义:就算手机设的是纽约时区,显示的依然是北京时间,而不是简单读
new Date()。
npm init -y
npm install @capacitor/core @capacitor/cli @capacitor/android然后在 capacitor.config.json 里告诉 Capacitor 三件事:APP 叫什么、网页在哪、插件怎么配:
{
"appId": "com.bjclock.app",
"appName": "北京时间",
"webDir": "src",
"plugins": {
"SystemBars": {
"insetsHandling": "disable",
"style": "DARK",
"hidden": true
}
}
}这里的 SystemBars 是 Capacitor 8 内置的核心插件(不用单独装),负责初步隐藏系统栏。style: "DARK" 表示系统栏图标用浅色(因为我们背景是黑的)。
npx cap add android # 生成完整的 android/ 原生工程(只跑一次)
npx cap sync # 把 webDir 里的网页拷进原生工程,以后每次改网页都跑这个cap add android 会生成一个标准的 Gradle 工程,里面带一个继承 BridgeActivity 的 MainActivity —— 这个 Activity 里嵌着一个 WebView,Capacitor 会把你 src/ 下的网页加载进去。
需要一个 Android SDK + JDK 环境,然后:
cd android
./gradlew assembleDebug # 产出 debug 包,可直接装
# 或
./gradlew assembleRelease # 产出 release 包(需签名才能装)产物路径:android/app/build/outputs/apk/debug/app-debug.apk
本项目最终在 Termux 里用 commandlinetools 装的 Android SDK 完成了编译(详见下文"Termux 里怎么搞 Android 编译环境"),把生成的 APK 重命名成了
北京时间.apk。
这是本项目最关键的原生知识点,涉及三层配合,缺一不可。目标是:系统栏默认隐藏、内容占满全屏(含挖孔区)、下滑边缘系统栏临时浮现。
MainActivity.java 的 onCreate 里:
// 关键!告诉系统"我的内容要自己负责系统栏区域,别帮我留边"
WindowCompat.setDecorFitsSystemWindows(window, false);
// 系统栏背景设成透明,否则会是一条灰/白条
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);setDecorFitsSystemWindows(false):Android 15 (API 35) 默认开启 edge-to-edge,但之前的版本必须手动调这个,否则 WebView 会被限制在状态栏和导航栏之间的"安全区"里。- 这一步只是让内容能"延伸"到系统栏的底下,系统栏本身还在,只是变成透明的浮在内容上。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // API 28+
WindowManager.LayoutParams attrs = window.getAttributes();
attrs.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
window.setAttributes(attrs);
}现在很多手机有挖孔/刘海屏。默认情况下,系统不允许你的内容画到挖孔那条区域(怕挡住摄像头)。LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES 的意思是"短边(顶部/底部)的挖孔区域我也照画",这样时钟才能真正占满物理屏幕的每一个角落。
同一个设置也在 styles.xml 的启动主题里配了一份,保证从启动那一刻起就是全屏,不会有"先白边后全屏"的闪烁:
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/black</item>
</style>这才是"顶部框平时缩进去、下拉才出来"的实现,MainActivity.java 的 hideSystemBars():
private void hideSystemBars() {
Window window = getWindow();
View decor = window.getDecorView();
// 拿到系统栏控制器(AndroidX 兼容封装,底层根据 API 版本自动选实现)
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(window, decor);
// 黑底 → 系统栏图标用白色,不然黑底上看不见图标
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
// ★ 关键行为:从边缘滑出时系统栏"临时"显示,几秒后自动收回
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
// 隐藏状态栏 + 导航栏
controller.hide(WindowInsetsCompat.Type.systemBars());
}三个要点逐个拆:
WindowInsetsControllerCompat:AndroidX 提供的系统栏控制器,兼容到 API 21+。不用它就得自己写View.SYSTEM_UI_FLAG_*那套老 API(已废弃且难维护)。BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE:这就是"游戏模式"的灵魂。设置之后,隐藏的系统栏不会彻底消失,而是从屏幕边缘做一次"滑入"手势时短暂浮现,过几秒自动收回去。很多视频/游戏 APP 的全屏就是这个行为。controller.hide(Type.systemBars()):一次性隐藏状态栏 + 导航栏。
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
hideSystemBars(); // 重新拿到焦点时再次隐藏
}
}坑:APP 切到后台再切回来、或者关了一个弹窗之后,系统会自作主张把系统栏恢复显示。如果不重写 onWindowFocusChanged 在重新获得焦点时再次调用 hideSystemBars(),沉浸式就"破功"了。这是做沉浸式最容易漏的一个细节。
index.html 里也有两处和沉浸式呼应:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">viewport-fit=cover:告诉 WebView"内容可以铺满整个视口,包括刘海/挖孔遮挡区",和原生侧的第 2 层对应。
body {
height: 100vh;
height: 100dvh; /* 动态视口高度,规避移动端浏览器地址栏伸缩导致的高度抖动 */
}<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
... />默认情况下,手机旋转屏幕、切换深浅色模式、改字体大小、键盘弹出这些事件都会让 Activity 销毁重建——对一个跑着 JS 定时器的 WebView 来说,每次重建页面都会白屏闪一下、时钟还会重置。
把这一系列事件写进 configChanges 等于告诉系统:"这些变化我自己处理,别重启我的 Activity"。Capacitor 模板默认就配好了这一串,一般不用动。
没有电脑,整个编译是在手机 Termux 里完成的,步骤:
# 1. 装 JDK 17(Gradle 编译需要)
pkg install openjdk-17
# 2. 下载 Android 命令行工具并解压到 ~/.android-sdk
# (commandlinetools-linux-*_latest.zip,谷歌官网可下)
mkdir -p ~/.android-sdk/cmdline-tools
# 解压后目录结构应为 ~/.android-sdk/cmdline-tools/latest/bin/...
# 3. 配环境变量
export ANDROID_HOME=~/.android-sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
# 4. 用 sdkmanager 装编译必需组件
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
# 5. 接受协议
yes | sdkmanager --licenses
# 6. 告诉工程 SDK 在哪(android/local.properties,这个文件不该进 git)
echo "sdk.dir=$HOME/.android-sdk" > android/local.properties
# 7. 编译
cd android && ./gradlew assembleDebug
⚠️ Termux 里跑 Gradle 偶尔会遇到内存不足或文件锁问题,给gradle.properties加org.gradle.jvmargs=-Xmx2048m和org.gradle.daemon=false会稳定不少。
# 改了网页之后同步到原生工程
npx cap sync
# 编译 debug APK
cd android && ./gradlew assembleDebug
# 编译 release APK(需要先在 android/app/ 配签名 keystore)
cd android && ./gradlew assembleRelease
# 清理构建产物
cd android && ./gradlew clean- Capacitor 三段式:写网页 →
cap add android生成原生壳 →cap sync同步 → Gradle 编译 - edge-to-edge:
WindowCompat.setDecorFitsSystemWindows(window, false)+ 系统栏设透明 - 挖孔屏:
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES(Java 和启动主题各配一份) - 沉浸式/游戏模式:
WindowInsetsControllerCompat+BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - 防破功:重写
onWindowFocusChanged重新隐藏 - 网页配合:
viewport-fit=cover+100dvh - Termux 编译:openjdk-17 + commandlinetools + sdkmanager +
local.properties
在 native-c/ 目录下有一个用纯 C + NDK Native Activity 重写的版本,APK 体积从 4 MB 压缩到 12 KB(缩小 330 倍),LED 数码管外观(尖角段、鬼影底纹、冒号闪烁)。
| 指标 | Capacitor 版(本目录) | Native C 版(native-c/) |
|---|---|---|
| APK 大小 | 4 MB | 12 KB |
| 代码 | HTML + Java + Capacitor 框架 | 纯 C:main.c + clock_draw.h |
| DEX | 8.5 MB | 无(零 Java 代码) |
| 渲染 | WebView | 直接操作像素缓冲 |
| 沉浸式 | 有(手势下滑呼出系统栏) | 无(简单全屏) |
MIT