同一套应用,为什么浏览器、PWA 和 Android App 不能共用一个入口判断

0 阅读11分钟

当一个 Vue 应用同时运行在桌面浏览器、手机浏览器、PWA 和 Android WebView 中时,“首页跳转”会从一个简单路由问题,变成一套运行环境协议。

项目中同时存在两个入口:

/      公开官网首页
/app   应用入口

最初的需求看起来并不复杂:

  • 桌面浏览器访问 /,展示官网;
  • 手机浏览器第一次访问 /,也展示官网;
  • 手机浏览器再次访问时,可以直接进入应用;
  • 移动 PWA 和 Android App 冷启动时,不应该先看到官网;
  • Android App 无论屏幕多宽,都应该使用移动应用入口;
  • 桌面 PWA 仍然可以保留桌面布局和账号首页偏好。

如果把这些判断全部写进 Vue Router Guard,功能通常能跑,但用户会看到一个明显问题:官网先闪一下,然后才跳进应用。

更麻烦的是,通用 Android WebView 可能错误报告 standalone,viewport meta 生效前 innerWidth 可能接近 980px,桌面屏幕短边又可能被误判成移动宽度。

本文复盘一套多运行环境入口设计,重点讨论:

  • 为什么路由守卫处理首屏分流太晚;
  • 怎样区分“运行时身份”和“响应式布局”;
  • 哪些信号可以信,哪些信号只能用于体验优化;
  • 如何让 Android 原生壳保留 WebView 状态,而不是每次回桌面都重建;
  • 为什么入口判断失败时应该 fail-open,而不是把公开页面锁死。

juejin_runtime_fig1_environment_matrix.png

一、先把三个问题拆开

很多入口 Bug 的根源,是把下面三件事混成了一个布尔值 isMobile

1. 当前是什么运行环境?
2. 当前应该使用什么布局?
3. 当前应该进入哪个业务页面?

它们并不是同一件事。

运行环境

type Runtime = 'browser' | 'pwa-standalone' | 'android-app';

它决定是否拥有原生桥、是否是安装态、退出时回官网还是留在应用内。

渲染布局

mobile / desktop / compact

它主要由有效视口、指针类型和显式调试覆盖决定。

业务落点

今日
书签首页
账号配置的默认首页
公开首页

它还会受注册、登录、退出、视口宽度和账号偏好影响。

如果只写:

if (window.innerWidth < 768) {
  router.replace('/workbenches');
}

Android 平板、桌面 PWA、新账号注册和普通移动浏览器首次访问都会被混在一起。

二、Router Guard 为什么太晚

典型 SPA 启动顺序如下:

服务器返回 index.html
        ↓
浏览器解析 HTML / CSS
        ↓
公开首页可能已经首次绘制
        ↓
下载并执行 JS 模块
        ↓
创建 Vue AppRouter Guard 判断
        ↓
replace('/app')

用户看到的就是:

官网 → 白一下 / 布局跳动 → 应用

juejin_runtime_fig2_first_paint_timeline.png

解决办法不是让 Router 更快,而是把“是否需要离开根路径”的判断前移到 <head> 顶部。

在 Vite 中,可以通过 transformIndexHtml 注入经典内联脚本:

export default function earlyEntry(): Plugin {
  return {
    name: 'early-app-entry',
    transformIndexHtml: {
      order: 'pre',
      handler() {
        return [{
          tag: 'script',
          attrs: { 'data-early-entry': 'true' },
          children: createEarlyEntryScript(),
          injectTo: 'head-prepend',
        }];
      },
    },
  };
}

这里使用经典脚本而不是 ES Module,因为模块脚本会延后执行,无法稳定阻止第一次绘制。

如果决定跳转,先临时隐藏根节点,再使用:

window.location.replace('/app');

replace 不会把官网入口留在历史栈里,用户在应用首屏返回时不会又回到一闪而过的根页面。

三、运行时身份必须使用信号优先级

1. Android App 只认专属信号

可靠信号包括:

window.AndroidBridge 存在
专属自定义 UA 标记

通用的 ; wv) 只能说明当前是 Android WebView,不能证明它是自己的 App。

第三方应用内浏览器同样可能使用 WebView。如果把所有 wv 都当成自己的 APK,公开首页会在完全不相关的容器中自动跳转。

2. PWA 使用 display-mode,但要排除通用 WebView

matchMedia('(display-mode: standalone)').matches

是判断 PWA 安装态的重要信号,但一些第三方 Android WebView 可能错误报告 standalone。

因此顺序应该是:

专属 Android App
    ↓ 否
是否为通用 Android WebView
    ↓ 否
PWA standalone
    ↓ 否
普通浏览器

3. 本地历史只能改善体验,不能成为身份凭据

移动浏览器是否回访,可以参考:

  • 是否已经展示过移动官网;
  • 是否保存过记住邮箱;
  • 是否存在近期登录兼容记录。

这些信号只决定“这次是否自动进入应用”,不能决定用户是否已经登录,更不能带入服务端权限。

浏览器本地记录可能过期、被清除或被伪造。真正的登录状态仍然必须由服务端会话确认。

四、viewport meta 生效前,innerWidth 可能骗你

早期脚本位于 <head> 顶部,甚至可能在 viewport meta 之前执行。

部分移动浏览器此时会返回接近 980px 的布局宽度。如果只看:

window.innerWidth

手机会被误判成桌面,等 meta 生效和 Vue 挂载后又切回移动布局,首屏产生二次跳动。

可以同时读取:

const innerWidth = Number(window.innerWidth);
const shortSide = Math.min(
  Number(screen.width),
  Number(screen.height),
);

但这里也有一个反向陷阱:桌面屏幕的短边通常是高度,例如 900px。若无条件使用 Math.min(innerWidth, shortSide),1600×900 的桌面会被误判成 900px 紧凑布局。

因此,屏幕短边只在它明确小于手机断点时用于修正:

let effectiveWidth = innerWidth;

if (shortSide < MOBILE_BREAKPOINT) {
  effectiveWidth = Math.min(innerWidth, shortSide);
}

这类逻辑不能简单地从响应式工具函数复制,因为它处理的是“viewport 还没准备好时”的特殊阶段。

五、入口判断树应该是可解释的

根路径分流可以整理成一棵清楚的决策树:

专属 Android App?
  是 → /app
  否
当前路径是 / ?
  否 → 不处理
  是
是否为手机布局?
  否 → 展示公开首页
  是
PWA standalone?
  是 → /app
  否
是否回访 / 有兼容身份记录?
  是 → /app
  否 → 展示公开首页

juejin_runtime_fig3_entry_decision_tree.png 入口脚本只处理根路径 /。用户直接打开某个详情页时,不应该被一个全局脚本粗暴改写到应用首页。

同时,所有 storage 操作都要放进 try/catch

用户可能禁用了本地存储,隐私模式也可能让调用抛错。此时最安全的行为不是无限白屏,而是:

fail-open,继续展示公开首页

“无法判断是否回访”不等于“有权自动进入应用”。

六、渲染档位和路由落点要分开计算

早期脚本除了决定是否跳转,还可以提前给 <html> 写入渲染档位:

<html
  class="mobile-rendering android-webview"
  data-render-profile="mobile"
  data-render-engine="android-webview"
>

这样 CSS 在 Vue 挂载前就能使用正确基线,减少首帧从桌面布局切到移动布局的闪动。

但渲染档位不能直接决定业务首页。

例如:

  • 平板可能使用紧凑布局,却仍进入书签首页;
  • Android App 即使运行在宽屏设备,也应该进入“今日”;
  • 桌面 PWA 使用桌面布局,并按账号偏好进入;
  • 新账号注册后不应该继承设备上一个账号的本地最近页面。

因此,可以把落点函数写成纯函数并分别测试:

getRuntimeApplicationHomePath(preferences, isMobileLayout, runtime)
getRuntimePostRegistrationPath(isMobileLayout, runtime)
getRuntimeApplicationEntryPath(preferences, viewportWidth, runtime)
getRuntimeGuestEntryPath(preferences, runtime)

典型规则:

移动布局 / Android App → 今日
平板 → 书签首页
桌面 → 账号偏好首页
桌面注册完成 → 固定书签首页
移动注册完成 → 今日
Android / 移动 PWA 退出 → 留在应用今日页
桌面浏览器退出 → 公开首页

“登录后去哪”“注册后去哪”“退出后去哪”是三个不同问题,不应共享一个万能函数。

七、Android 壳的首屏要做三段接力

Android WebView 冷启动通常经历:

系统 Splash
    ↓
Activity 创建
    ↓
WebView 加载
    ↓
网页首个路由完成绘制

如果系统 Splash、Activity 背景和 WebView 等待层不是同一套视觉,用户会看到:

系统启动图 → 纯色 / 黑屏 → 网页骨架 → 正文

更稳定的方案是三段接力:

  1. 系统 windowBackground 或 Android 12 Splash;
  2. Activity 中的原生 Launch Overlay;
  3. 网页首路由绘制完成后,通过受信消息发送 app.ready

juejin_runtime_fig4_android_shell.png 原生层收到 app.ready 后淡出覆盖层,同时保留两个兜底:

  • 页面 onPageFinished 后延迟关闭,兼容旧网页缓存;
  • 总超时强制关闭,避免异常时永久遮住 WebView。

重要的是:兜底负责可用性,app.ready 负责正确时机。

八、返回键不能只看 webView.canGoBack()

移动 Web 页面通常有三类状态:

overlay:弹框、抽屉、全屏选择器
page:详情页或二级页面
root:底部一级导航根页面

如果原生层只执行:

if (webView.canGoBack()) webView.goBack();
else finish();

会出现:

  • 弹框还没关,整个页面先返回;
  • 一级导航的历史占位被逐个回退;
  • 回到根页后直接销毁 Activity;
  • 再次点击桌面图标,WebView 状态和滚动位置全部丢失。

一种可行方式是让原生先询问网页当前状态:

window.history.state?.overlayId
  ? 'overlay'
  : document.documentElement.dataset.primaryRoot === 'true'
    ? 'root'
    : 'page'

然后:

overlay / page → WebView.goBack()
root → 两次返回确认后 moveTaskToBack(true)

这里选择 moveTaskToBack 而不是 finish,是为了让 singleTask 桌面入口恢复同一个 Activity 和 WebView 实例。

Android 13+ 的预测返回也应注册到同一 handleBackNavigation(),避免新旧返回路径行为不一致。

九、原生桥应该是“受信能力适配器”

WebView 壳通常还要处理:

  • 文件选择和相机;
  • 下载进度;
  • 保存 Base64 图片;
  • 安装下载后的 APK;
  • 写入系统日历;
  • 同步系统深浅色。

这些能力不应该通过任意 javascriptInterface 对所有页面开放。

更安全的方式是使用带允许来源的 WebMessage Listener:

只接受受信 origin
只接受主 frame
未知 message type 不执行任何操作
原生结果按 token 回传网页

例如日历写入:

Web 发 calendar.insert + token
        ↓
原生尝试打开系统日历
        ↓
回传 { token, ok, reason }
        ↓
Web 超时未收到结果 → 当作旧版不支持,回落到 .ics

这种“能力协商”不一定需要显式版本号。旧版壳不认识消息时不回复,网页超时后自然降级。

juejin_runtime_fig5_mobile_real.png

十、应该怎样测试多运行环境入口

这类逻辑非常适合纯函数和脚本快照测试。

运行时识别

专属 Bridge 存在 → android-app
仅有通用 wv → browser
通用 wv + standalone → 仍是 browser
无 WebView + standalone → pwa-standalone

根路径分流

桌面浏览器首次访问 → 不跳转
手机浏览器首次访问 → 不跳转
手机浏览器回访 → /app
移动 PWA → /app
Android App → /app,不受宽度影响
非根路径 → 不处理
localStorage 抛错 → 继续公开首页

视口修正

手机 innerWidth=980,screen shortSide=390 → 按 390 处理
桌面 1600×900 → 不把 900 当成手机宽度
粗指针紧凑设备 → 使用移动渲染档位
显式 desktop override → 保持桌面档位

Android 返回

overlay → 先关闭浮层
page + canGoBack → 正常回退
root 第一次返回 → 显示提示
root 两秒内第二次返回 → moveTaskToBack
从桌面恢复 → onNewIntent,不重建 WebView

测试重点不是某个 URL 字符串,而是优先级是否稳定、失败方向是否安全。

十一、可复用的四条经验

1. 首屏分流必须早于框架启动

只要目标是避免首次绘制错误页面,就不能把全部逻辑留在 Router Guard 中。

2. 运行环境、布局和业务首页必须解耦

一个 isMobile 无法表达 PWA、WebView、平板、回访浏览器和 Android App 的差异。

3. 体验信号不能升级成安全凭据

本地访问历史可以帮助决定是否跳过官网,但登录和权限永远由服务端确认。

4. 原生壳应保持薄,但必须接住系统语义

“薄壳”不等于只放一个 WebView。启动页、返回键、文件选择、下载、系统主题和受信消息都属于系统语义,应该由原生层负责;业务模型和页面仍由 Web 端统一维护。

结语

一套多端 Vue 应用真正难的不是“能不能在 WebView 中打开”,而是不同运行环境能否拥有符合预期的:

第一帧
入口落点
返回行为
状态连续性
系统能力边界

当这些问题被压成一个路由判断时,Bug 会以闪屏、错误跳转和状态丢失的形式不断出现。把它们拆成早期入口脚本、运行时识别、纯函数路由策略和薄原生壳后,系统才开始变得可解释、可测试。

本文实现来自一个公开的 Vue + Android WebView 项目,完整代码可参考:

github.com/VeteranBoLu…