安全区域适配实战指南覆盖iOS安卓和刘海屏场景

UX-世杰 优化 阅读 1,830
赞 19 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年接了个微信公众号内嵌的 H5 活动页,目标用户 90% 是 iOS 用户,尤其是 iPhone X 及以后机型。需求里有一屏「全屏滑动切换」+「底部固定操作栏」,设计师给的稿子是贴着屏幕底边的按钮组,离底部安全区只有 8px。我一开始还沾沾自喜:这不就是加个 env(safe-area-inset-bottom) 就完事了?

安全区域适配实战指南覆盖iOS安卓和刘海屏场景

结果上线前测到真机,发现按钮一半被刘海遮住,iOS 16 下更夸张——Safari 自动把整个 viewport 往上推,页面底部直接空白一大块,按钮飘在半空。这时候才意识到:安全区域不是“加个 CSS 变量就自动适配”的童话,它是会咬人的。

最大的坑:滚动和 safe-area-inset-bottom 的诡异联动

我们用的是 Vue 3 + Pinia + 原生滚动(没上 any-touch),核心逻辑是监听 touchmove + requestAnimationFrame 做手势驱动滑动。问题来了:当页面设置了 viewport-fit=cover 后,safe-area-inset-bottom 在某些场景下根本不会触发重绘。

比如用户从首页跳转进这个活动页,第一次加载时 CSS 生效,但后续页面内路由跳转(Vue Router hash 模式),inset 值卡在旧值不动。更绝的是,在微信内置浏览器里,你手动横屏再切回来,env(safe-area-inset-bottom) 居然还是 0 —— 而此时 window.innerHeight 已经变了。

折腾了半天发现,不是变量没生效,是它只在初始渲染时计算一次,之后不响应式更新。官方文档里那句 “It’s a constant” 我当时没细读,以为它会随 orientation change 自动刷新……踩过才知道,它真的只是常量,不是响应式变量。

最终的解决方案

最后搞了个混合方案:CSS 做兜底,JS 做动态修正。

首先 HTML 头部强制加上:

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

然后全局样式里写死安全区适配:

body {
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 但这里有个致命细节:不能直接用在 fixed 元素上 */
.action-bar {
  position: fixed;
  bottom: 0;
  /* 错误写法 ❌ */
  /* padding-bottom: env(safe-area-inset-bottom); */
  /* 正确写法 ✅ */
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
  /* 加 4px 是为了视觉补偿,因为按钮本身有 4px 内边距 */
}

但光靠 CSS 不行,上面说的路由跳转后 inset 不更新的问题还得靠 JS 补救。我在 mountedonActivated(keep-alive 场景)里加了这段:

function updateSafeArea() {
  const safeBottom = parseInt(
    getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)') || '0'
  );
  document.documentElement.style.setProperty('--safe-bottom', ${safeBottom}px);
}

// 手动触发一次
updateSafeArea();

// 监听 orientationchange(注意:不是 resize!)
window.addEventListener('orientationchange', () => {
  setTimeout(updateSafeArea, 100); // iOS Safari orientationchange 后需要一点延迟
});

// 微信里加个兜底:定时轮询(仅 iOS + 微信 UA)
if (/MicroMessenger/i.test(navigator.userAgent) && /iPhone|iPad/i.test(navigator.userAgent)) {
  const timer = setInterval(() => {
    const currentHeight = window.innerHeight;
    if (Math.abs(currentHeight - window.__lastHeight__) > 50) {
      window.__lastHeight__ = currentHeight;
      updateSafeArea();
    }
  }, 500);
  onBeforeUnmount(() => clearInterval(timer));
}

然后 CSS 里改用自定义变量:

.action-bar {
  bottom: calc(var(--safe-bottom, 0px) + 4px);
}

这里多说一句:千万别信网上那些 “监听 resize + clientHeight 对比” 的方案,iOS Safari 的 resize 事件在微信里压根不触发,白写。

踩坑提醒:这三点一定注意

  • 不要在伪元素里用 env() —— iOS 15.4 之前,::before::after 里 env 变量不生效,我们试过,真不认
  • fixed 元素慎用 padding-bottom + env() —— 它会改变元素高度,导致布局错位,建议统一用 bottom: calc(...) 控制位置
  • 微信里 env(safe-area-inset-bottom) 返回单位是 px,但值可能是小数(如 34.5px) —— parseFloat 会截断,要用 parseFloat(val) || 0 + Math.round() 再设回 CSS 变量,不然动画会有抖动

回顾与反思

最终上线后,iPhone XR、12、14、15 全系列通过测试,微信、Safari、QQ 浏览器也都 OK。但还有两个小尾巴没彻底解决:

  • iOS 17.4 上,横屏切回竖屏时偶尔出现 1px 的错位(怀疑是 WebKit 渲染管线 bug,没继续深挖,影响极小)
  • 安卓全面屏手机虽然也支持 env(),但我们项目没覆盖安卓用户,就没做兼容(坦白讲,懒了)

性能上,那个 500ms 的轮询确实有点糙,但实测内存占用不到 0.5MB,CPU 占用峰值 2%,权衡下来比引入 ResizeObserver polyfill 更轻量。毕竟这是个生命周期就 3 天的活动页,没必要过度工程化。

回头想想,安全区域最反直觉的点在于:它看起来是个“CSS 特性”,实际却严重依赖运行时环境状态。你得把它当成一个需要主动探测、手动同步的状态管理对象,而不是一个开箱即用的样式工具。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的动态监听方案(比如不用轮询也能稳定捕获 inset 变化),欢迎评论区交流 —— 我也想学学。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论