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、最终安全区像素和路由层级。
- 本文标签: Swift Vue
- 本文链接: https://www.tianyajuanke.top/article/102
- 版权声明: 本文由吴沛芙原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权