症状

在线壳 APK(WebView 加载线上站点)顶端留一截空位:内容被整体下压,顶部出现一段与状态栏高度相关的空白带。同一套前端在桌面浏览器完全正常——只有 APK 里出现。gap 高度随设备密度变化。

触发条件

  • Android 在线壳:addJavascriptInterface 注入能力桥,桥把 native 测得的系统栏 inset 返回给前端;
  • 前端把桥返回值落成 CSS 变量当长度用(如 --safe-area-top: <n>px);
  • 设备密度 ≠ 1(真机 density 2.6~3.0,mdpi 模拟器 density=1 时不触发,这就是「第一次打 APK 没事、换真机后出事」的来源)。

根因

单位的隐性错位,跨过一次 JS 桥边界就丢掉了换算:

环节单位
WindowInsets.getInsets(statusBars()).top / getSystemWindowInsetTop()物理像素 px(Android 所有 insets 都是 px)
WebView 渲染1 CSS px = 1 dp(默认 devicePixelRatio = density)
native 布局 marginpx(native 里直接当 px 用是对的)

所以:桥把 native 的 px 原样返回 → 前端写成 <px>px → WebView 里就是 px 个 dp → 顶部让位被放大 density 倍。例:420dpi(density 2.625)真机状态栏 24dp=63px,前端拿到 63px = 63dp ≈ 165 物理像素,顶部让出 2.6 倍状态栏。

同一条链路里,底部导航栏/IME 走的是 native bottomMargin(px),不经过 CSS,所以底部没这个问题——两条链路单位语义不同,最容易踩的就是「以为顶部底部一样」。

解决

桥返回前做 px → dp 换算,前端照旧当 CSS px 用:

@JavascriptInterface
public String getStatusBarHeight() {
    int px = MainActivity.cachedTopInset();
    int dp = Math.round(px / webView.getResources().getDisplayMetrics().density);
    return String.valueOf(dp);
}

只改 native、重打 APK 即可,前端不用发版(前端契约本来就是「这个数 = CSS px」)。反过来让前端除以 devicePixelRatio 也行,但会和 native 换算互斥——只能一端换算,别两端都除。

下次怎么识别

  • 症状「web 正常 / APK 异常」且与状态栏相关 → 先怀疑桥返回值的单位;
  • gap 大小随设备密度变化(低密度模拟器不出现)→ 单位换算问题实锤;
  • native 里的值给了 CSS,就要想一遍「物理 px / dp / CSS px」的换算链。

相关概念

  • 密度无关像素(density-independent pixels, dp)
  • 设备像素比(devicePixelRatio)
  • WindowInsets
  • Edge-to-Edge 沉浸式布局(setDecorFitsSystemWindows)

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Programming