原创

uni-app H5 嵌入 WKWebView:安全区、页面返回与权限桥接

外包APP项目,前端是 uni-app 编译出的 H5,放在 iOS 的 WKWebView 中运行。

开发时主要在 Chrome 中调试,接入真机后,又陆续处理了顶部留白、聊天输入栏跟随消息滚动、返回闪黑,以及多级返回后旧页面盖住当前内容的问题。

这篇把相关实现放在一起,主要记录页面布局、返回过渡和权限桥接。下面的 DOM、CSS 和桥接示例用于 H5,代码按工具方法整理,消息名由网页与 iOS 宿主共同约定。

一、先确定安全区由哪一层处理

WKWebView 的适配先看原生容器怎么放。有两种情况:

宿主布局 H5 的处理方式
WebView 已经约束在原生安全区以内 页面不再补同一侧的系统留白
WebView 铺到屏幕边缘,宿主不额外缩进内容 页面给文字和按钮留出安全区,背景可以铺满

本文按第二种方式处理。宿主将 WebView 铺到容器边缘,并统一处理 contentInsetAdjustmentBehavior,避免原生滚动视图和 H5 同时增加缩进。如果宿主本身已经避开状态栏,网页再加一次状态栏高度,就会出现多余空白。

1. viewport 放在入口 HTML 中

在 H5 入口中静态声明,不等页面运行后再拼接:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, viewport-fit=cover"
/>

viewport-fit=cover 用于让页面使用完整的布局区域,重要内容再通过安全区留白避开遮挡。它不会自动改变原生 WebView 的约束,也不是所有设备都会得到非零的安全区值。WebKit 安全区说明

这里保留用户缩放能力,没有为了页面适配关闭缩放。

2. 页面统一读取一组变量

WebKit 提供 env(safe-area-inset-*),宿主也可以注入当前 WebView 还需要避让的距离。两边的值都按当前网页的 CSS 像素表示,在根节点合并一次:

:root {
  --page-background: #ffffff;
  --app-safe-top: max(
    var(--native-safe-area-top, 0px),
    env(safe-area-inset-top, 0px)
  );
  --app-safe-bottom: max(
    var(--native-safe-area-bottom, 0px),
    env(safe-area-inset-bottom, 0px)
  );
  --app-safe-left: max(
    var(--native-safe-area-left, 0px),
    env(safe-area-inset-left, 0px)
  );
  --app-safe-right: max(
    var(--native-safe-area-right, 0px),
    env(safe-area-inset-right, 0px)
  );
}

/* 宿主已经处理了四周留白时,页面不重复补齐。 */
html[data-safe-area-mode="native"] {
  --app-safe-top: 0px;
  --app-safe-bottom: 0px;
  --app-safe-left: 0px;
  --app-safe-right: 0px;
}

默认由 H5 留白。若宿主已经处理全部安全区,在页面首屏渲染前将 data-safe-area-mode 设置为 native。如果只处理某一侧,按同样方式单独覆盖对应变量,不把其他侧一起清零。

max() 比较的是同一侧、同一坐标含义的距离,不把它们相加。uni-app 的 --status-bar-height 不直接混进这组计算,避免把“状态栏高度”和“WebView 当前还需要的留白”当成一个值。

原来使用 var(--status-bar-height, env(...)) 时,H5 中定义好的 0px 会被直接采用。var() 的第二个参数不会因为第一个值是零就启用。这里把安全区单独管理,页面组件只读取 --app-safe-*。

宿主注入值集中到一个入口:

export function applyNativeSafeArea(insets) {
  const root = document.documentElement
  for (const side of ['top', 'bottom', 'left', 'right']) {
    const value = insets?.[side]
    if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) continue
    root.style.setProperty(`--native-safe-area-${side}`, `${value}px`)
  }
}

宿主在页面加载完成、安全区变化和横竖屏切换后重新传入四边的完整值,包括变为零的值。数值以当前 WebView 的实际布局为准,不写死刘海和 Home Indicator 的高度,也不直接复制整个屏幕的 inset。

3. 调试时看最终尺寸

自定义属性读出来可能还是一段 max(...) 表达式。要看最终用了多少像素,需要创建一个临时节点,读取它的计算样式:

export function inspectSafeArea() {
  const probe = document.createElement('div')
  probe.style.cssText = `
    position: fixed;
    visibility: hidden;
    pointer-events: none;
    padding-top: var(--app-safe-top);
    padding-bottom: var(--app-safe-bottom);
  `
  document.body.appendChild(probe)
  try {
    const style = getComputedStyle(probe)
    console.table({
      viewport: document.querySelector('meta[name="viewport"]')?.content ?? '',
      mode: document.documentElement.dataset.safeAreaMode ?? 'web',
      top: style.paddingTop,
      bottom: style.paddingBottom
    })
  } finally {
    probe.remove()
  }
}

这种写法能区分组件没有挂载、变量没有传入,以及布局本来就不需要留白这几种情况。

二、聊天页固定头尾,中间滚动

聊天页需要固定标题和输入栏,只有消息列表滚动。安全区占位放在列表外面,列表内部不再增加相同方向的 padding。

下面是 H5 页面结构。这里直接让 .message-scroll 成为滚动容器,省去额外的内外两层滚动:

<template>
  <view class="page">
    <view class="safe-area-top" aria-hidden="true" />
    <view class="page-header">
      <slot name="header" />
    </view>
    <view class="message-scroll">
      <slot />
    </view>
    <view class="input-bar">
      <slot name="input" />
    </view>
    <view class="safe-area-bottom" aria-hidden="true" />
  </view>
</template>
.page {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  height: 100vh;
  min-height: 0;
  overflow: hidden;
  padding-left: var(--app-safe-left);
  padding-right: var(--app-safe-right);
  background: var(--page-background, #fff);
}

@supports (height: 100dvh) {
  .page { height: 100dvh; }
}

.safe-area-top {
  flex: none;
  height: var(--app-safe-top);
}
.safe-area-bottom {
  flex: none;
  height: var(--app-safe-bottom);
}
.page-header,
.input-bar {
  flex: none;
}
.message-scroll {
  box-sizing: border-box;
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior-y: auto;
  scrollbar-width: none;
}
.message-scroll::-webkit-scrollbar {
  display: none;
}

min-height: 0 让中间区域能够收缩,超出的消息由它自己滚动。输入栏是它的兄弟节点,不会跟着消息内容一起滚走。

页面使用 100dvh 时,旧环境仍有 100vh 作为回退。键盘弹起是否改变可用高度,还取决于 WKWebView 宿主和当前视口行为;如果项目使用原生键盘事件调整容器高度,就由同一套适配同步页面高度,不另外叠加一份键盘位移。

如果页面继续使用 uni-app 的 scroll-view,也保持同样的结构:输入栏在外,中间区域可收缩。滚动样式作用在它实际产生滚动的内部节点上,外层不再设置第二个 overflow-y: auto。

头图需要铺到顶部时

有些页面的背景图要延伸到状态栏区域,这时去掉顶部独立占位,由头图组件负责顶部留白:

<view class="page">
  <view class="hero-header">
    <view class="hero-header__content">页面标题</view>
  </view>
  <view class="message-scroll">页面内容</view>
  <view class="safe-area-bottom" aria-hidden="true" />
</view>
.hero-header {
  flex: none;
  box-sizing: border-box;
  padding-top: var(--app-safe-top);
  background: #315ec9;
}
.hero-header__content {
  padding: 16px;
  color: #fff;
}

顶部由头图处理,底部仍然保留原来的占位。Flex 布局会扣除头图和底部占位实际占用的高度,不再手写一份 calc(100vh - ...)。

三、滚动区域保留系统手势

页面根节点不滚动,业务内容放进明确的滚动区域。隐藏滚动条时只调整滚动条样式,不禁用纵向溢出,也不在全局通过 touchmove.prevent 拦截滚动。

overscroll-behavior 控制滚动到边缘时的行为:

值 常见用途
auto 使用浏览器默认的边缘行为
contain 阻止滚动继续传到外层,仍可保留边缘效果
none 阻止滚动传递,并抑制相应的边缘效果

普通内容区使用 auto。有独立滚动内容的弹层可以使用 contain,减少弹层滚动到底后继续带动背景的情况。具体回弹表现还受 WebKit 版本及宿主滚动设置影响。属性说明

短列表按实际内容高度显示,不通过额外增加几个像素强行制造溢出。如果产品要求短内容也能下拉,使用明确的刷新交互或宿主的滚动设置处理。

真机上主要看长列表的惯性、到底后的行为、弹层是否带动背景,以及输入栏是否保持在消息列表之外。桌面滚轮可以检查内容有没有溢出,但不能替代手机触摸操作。

四、页面过渡中的快照和前景层

这个项目的页面切换需要类似 iOS 页面栈的效果:新页面从右侧进入,旧页面向左移动一小段;返回时,当前页退出,上一页恢复到原位置。

实现中给来源页保留一份只用于显示的快照。快照和当前页面放在同一个过渡容器里,避免各自落入不同父节点的层叠上下文后,再单纯比较 z-index。

.route-stack-stage {
  position: relative;
  isolation: isolate;
  background: var(--page-background, #fff);
}
.route-stack-snapshot {
  position: absolute;
  inset: 0;
  z-index: 10;
  overflow: hidden;
  pointer-events: none;
}
.route-stack-page--foreground {
  position: relative;
  z-index: 11;
  background: var(--page-background, #fff);
}

当前页根节点有不透明背景。只有卡片或列表设置背景时,空隙仍可能露出来源页,返回时也容易出现一帧不同颜色。

快照只用于绘制,不继续响应业务。快照创建方法需要同步来源页的滚动位置和表单显示状态,图片、Canvas、视频等内容按各自方式处理,不能把 cloneNode(true) 当成完整截图。

创建完成后,再统一处理交互属性:

export function prepareSnapshot(snapshot) {
  if (!('inert' in HTMLElement.prototype)) {
    // 当前实现要求 inert;旧宿主走普通页面过渡,不保留交互 DOM 快照。
    snapshot.remove()
    return false
  }
  snapshot.classList.add('route-stack-snapshot')
  snapshot.inert = true
  snapshot.setAttribute('aria-hidden', 'true')
  snapshot.style.pointerEvents = 'none'
  return true
}

pointer-events: none 处理指针交互,inert 同时让这份展示层退出焦点操作。这里只对快照设置,当前真实页面仍保持可访问。inert 说明

1. 前景标记跟着引用释放

A 进入 B,再进入 C 时,B 仍可能被上一层页面过渡引用。清理 C 时,只解除 C 对 B 的引用;B 没有其他持有者后,才移除前景标记。

层级管理集中在一处:

export function createStackRegistry() {
  const entries = new Set()
  const owners = new WeakMap()

  function releaseForeground(entry) {
    const page = entry.foreground
    if (!page) return
    entry.foreground = null
    const holders = owners.get(page)
    if (!holders) return
    holders.delete(entry)
    if (holders.size === 0) {
      owners.delete(page)
      page.classList.remove('route-stack-page--foreground')
    }
  }

  function bindForeground(entry, page) {
    if (entry.disposed || entry.foreground === page) return
    releaseForeground(entry)
    if (!page) return
    let holders = owners.get(page)
    if (!holders) {
      holders = new Set()
      owners.set(page, holders)
    }
    holders.add(entry)
    entry.foreground = page
    page.classList.add('route-stack-page--foreground')
  }

  function create(snapshot) {
    const entry = {
      snapshot,
      foreground: null,
      disposed: false,
      disposers: new Set()
    }
    entries.add(entry)
    return entry
  }

  function dispose(entry) {
    if (entry.disposed) return
    entry.disposed = true
    for (const cleanup of entry.disposers) {
      try { cleanup() } catch (error) { console.error(error) }
    }
    entry.disposers.clear()
    releaseForeground(entry)
    entry.snapshot?.remove()
    entry.snapshot = null
    entries.delete(entry)
  }

  function clear() {
    for (const entry of [...entries]) dispose(entry)
  }

  return { create, bindForeground, dispose, clear }
}

过渡代码创建条目后,通过 bindForeground() 绑定页面;动画帧、定时器、局部事件监听的取消方法放进 entry.disposers。成功返回、导航失败或重定向时,都调用同一个清理方法。

异步回调进入时先看 entry.disposed。已经清理的条目直接结束,不再往 DOM 里添加快照或前景标记。这样清理后才执行的回调也不会把旧图层重新挂回来。

2. 动画与路由各自完成自己的步骤

这里使用相同的时长和缓动曲线移动两层页面,当前页背景保持不透明,不叠加淡出。

.route-stack-moving {
  transition: transform 280ms cubic-bezier(.22, .61, .36, 1);
}

@media (prefers-reduced-motion: reduce) {
  .route-stack-moving {
    transition: none;
  }
}

动画结束的处理还覆盖无动画、被中断和页面切换取消的情况,不把资源释放只挂在 transitionend 上。

uni-app 的路由完成和页面 DOM 准备好是两个时机。快照管理接入项目封装的页面就绪回调,再绑定目标页;不在 navigateTo 的成功回调中立即假定目标节点已经渲染。H5 的 CSS 过渡由页面层处理,不把 App 端的窗口动画参数当成 H5 的通用开关。uni-app 路由说明

五、登录跳转前先结束旧的过渡

登录守卫在创建快照前执行。这样未登录时,先转去认证页,不会先留下当前页快照,再等待导航失败回调来清理。

这里把公开路由和跳转入口放在一起。navigateWithTransition 是项目已有的过渡封装,内部负责创建第四节的条目,以及导航失败、页面就绪后的处理:

export function createNavigation({ stack, isLoggedIn, navigateWithTransition }) {
  const publicRoutes = new Set([
    '/pages/login/index',
    '/pages/agreement/index',
    '/pages/privacy/index'
  ])

  function routePath(url) {
    const path = url.split(/[?#]/, 1)[0]
    if (!path.startsWith('/pages/')) {
      throw new Error('只接受应用内页面路径')
    }
    return path
  }

  function reLaunch(url) {
    routePath(url)
    stack.clear()
    uni.reLaunch({ url })
  }

  function navigateTo(url) {
    const path = routePath(url)
    if (!publicRoutes.has(path) && !isLoggedIn()) {
      reLaunch('/pages/login/index')
      return
    }
    navigateWithTransition(url)
  }

  return { navigateTo, reLaunch }
}

登录、协议和隐私页进入公开列表。页面发起跳转时走同一个入口,redirectTo、switchTab 等会替换当前页面栈的操作,也在执行前清理对应快照。

如果项目保留 uni.addInterceptor 处理其他跳转入口,拒绝导航的分支也主动清理,不依赖它一定会调用 fail 或 complete。清理函数可以重复调用。拦截器说明

原生返回与 H5 历史变化

WKWebView 的返回手势操作的是 WebView 的历史记录,不等于调用了一次 uni.navigateBack()。H5 同文档历史变化可以观察 popstate,文档离开时则在 pagehide 清理。

export function installStackCleanup(stack) {
  const onHistoryChanged = () => stack.clear()
  const onPageHide = () => stack.clear()
  window.addEventListener('popstate', onHistoryChanged)
  window.addEventListener('pagehide', onPageHide)

  return () => {
    window.removeEventListener('popstate', onHistoryChanged)
    window.removeEventListener('pagehide', onPageHide)
    stack.clear()
  }
}

这个版本在历史变化后清理自定义过渡层,让路由显示真实页面,不再补播一次点击返回的动画。popstate 也可能来自浏览器按钮或脚本,不能单凭它判断是原生左滑。WKWebView 返回手势接口

手势刚开始时不修改页面栈。需要跟随原生交互进度的动画,则由宿主明确通知开始、提交和取消,网页按通知处理,避免手势取消后已经把上一层清掉。

六、权限桥接统一请求和回调

相机、麦克风、相册和定位分别对应不同的权限状态。桥接层把请求 ID、消息名、超时和回调放在一个模块,页面只消费结果。

这里约定 appPermission 为原生 handler,协议版本为 1。请求格式如下:

{
  "version": 1,
  "requestId": "permission-1",
  "resource": "camera",
  "action": "request"
}

宿主回传:

{
  "version": 1,
  "requestId": "permission-1",
  "resource": "camera",
  "status": "granted"
}

授权结果按能力解释:

状态 页面处理
granted 本次请求的能力已授权
limited 相册仅能访问用户选中的照片,不当作完整相册授权
denied 用户拒绝,停止当前功能并提供设置或重试提示
restricted 系统限制,停止当前功能
not_determined 尚未得到授权结果,不提前使用能力
unavailable 当前能力不可用,按功能决定是否走浏览器方式

不同系统 API 的数字枚举含义不同,由宿主根据相机、定位、相册各自的 API 转成上述字符串,再交给 H5。通用的 success 只表示某次调用完成,不作为授权通过的状态。

1. 请求与超时集中处理

下面是 ios-permission.js 的核心代码,模块在 H5 应用启动时加载一次:

const pending = new Map()
const resources = new Set(['camera', 'microphone', 'photoRead', 'locationWhenInUse'])
const statuses = new Set([
  'granted', 'limited', 'denied', 'restricted', 'not_determined', 'unavailable'
])
const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
let sequence = 0

function finish(requestId, result) {
  const task = pending.get(requestId)
  if (!task) return
  clearTimeout(task.timer)
  pending.delete(requestId)
  task.resolve(result)
}

export function requestIOSPermission(resource) {
  if (!resources.has(resource)) {
    return Promise.resolve({ status: 'unsupported', canProceed: false })
  }
  const handler = window.webkit?.messageHandlers?.appPermission
  if (!handler || typeof handler.postMessage !== 'function') {
    return Promise.resolve({ status: 'unavailable', canProceed: false })
  }

  const requestId = `${sessionId}-${++sequence}`
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      finish(requestId, { status: 'timeout', canProceed: false })
    }, 60000)
    pending.set(requestId, { resource, resolve, timer })

    try {
      handler.postMessage({ version: 1, requestId, resource, action: 'request' })
    } catch (error) {
      finish(requestId, { status: 'bridge_error', canProceed: false })
    }
  })
}

function receivePermissionResult(payload) {
  let result
  try {
    result = typeof payload === 'string' ? JSON.parse(payload) : payload
  } catch {
    return
  }
  if (!result || typeof result !== 'object') return
  const task = pending.get(result.requestId)
  if (!task) return

  const valid = result.version === 1
    && result.resource === task.resource
    && statuses.has(result.status)
    && (result.status !== 'limited' || task.resource === 'photoRead')
  if (!valid) {
    finish(result.requestId, { status: 'invalid_response', canProceed: false })
    return
  }

  finish(result.requestId, {
    status: result.status,
    canProceed: result.status === 'granted'
      || (task.resource === 'photoRead' && result.status === 'limited')
  })
}

window.__appPermissionResult = receivePermissionResult

function cancelPending() {
  for (const requestId of [...pending.keys()]) {
    finish(requestId, { status: 'cancelled', canProceed: false })
  }
}
window.addEventListener('pagehide', cancelPending)

export function disposePermissionBridge() {
  cancelPending()
  window.removeEventListener('pagehide', cancelPending)
  if (window.__appPermissionResult === receivePermissionResult) {
    delete window.__appPermissionResult
  }
}

宿主将 JSON 序列化后调用 window.__appPermissionResult(...),不要把动态字段直接拼进 JavaScript 字符串。原生 handler 只接受约定来源的主框架消息,并检查资源名称,实际调用设备能力时再次读取系统授权状态。

请求发出前没有写入授权成功。只有匹配请求 ID、能力名称和协议版本的回调才会结束对应请求;超时后的旧回调因为已经找不到请求而被忽略。

旧版宿主如果使用其他消息名或状态码,集中在宿主适配或桥接模块中转换,不让页面各自兼容。权限也不作为永久的本地 true 保存,用户从系统设置返回后,下一次使用时重新确认。

2. 页面区分拒绝和不可用

页面消费相册权限结果时,可以这样处理:

import { requestIOSPermission } from './ios-permission.js'

export async function choosePhoto({ openNativeAlbum, openWebPicker, showMessage }) {
  const result = await requestIOSPermission('photoRead')
  if (result.status === 'unavailable') {
    return openWebPicker()
  }
  if (result.status === 'limited') {
    return openNativeAlbum({ selectedOnly: true })
  }
  if (result.status === 'granted') {
    return openNativeAlbum({ selectedOnly: false })
  }
  if (result.status === 'denied' || result.status === 'restricted') {
    showMessage('当前无法访问相册,请检查系统权限设置')
    return
  }
  showMessage('暂未取得相册授权,请稍后重试')
}

这里的三个参数由页面接入已有的相册、文件选择和提示方法。浏览器方式仍需要用户主动操作,并遵守浏览器自身的权限要求;

原生相机权限和网页 getUserMedia() 的访问也分开处理。原生授权通过后走对应的原生能力调用,网页相机则按 WebKit 的权限流程申请,不能相互代替。

七、真机上按完整路径走一遍

页面适配后,除了单独打开页面,还要连续走几条路径:

  • 普通页面进入聊天页,滚动长消息,再返回。
  • A 进入 B、B 进入 C,然后连续返回,确认真实页面和前景标记对应。
  • 未登录点击受限页面,打开协议页,再回到登录页。
  • 开始侧滑后取消,再完成一次返回,确认页面没有提前切换。
  • 横竖屏切换,检查四边安全区和头图背景。
  • 分别允许、拒绝相机或相册权限,从系统设置修改授权后再次使用。
  • 在普通浏览器中打开同一份 H5,确认没有原生桥接时仍可使用相应的网页功能。

遇到布局变化时,同时记录 WebView 的约束、页面 viewport、最终安全区像素和路由层级。

正文到此结束