一个 H5 装饰按钮在 Android WebView 和 iOS Safari 上的两次渲染排查

一个移动 H5 页面里有个固定在右下角的小按钮。按钮本身不复杂:圆角胶囊、渐变底色、生日图标、右侧气球纹理、左上角高光纹理,收起时会变成一个窄按钮。

麻烦点在装饰层。为了贴近设计稿,按钮里用了负坐标纹理、mix-blend-mode、半像素描边、overflow: hidden、圆角和动画。Chrome 桌面看起来正常,不代表移动 WebView 也稳定。这个按钮先后暴露了两个问题:

  • Android WebView 上,按钮左上角多出一块异常区域;隐藏 .birthday-privilege-entry__texture 后,异常消失。
  • iOS Safari 上,Chrome 正常,但按钮左侧边缘像多了一条边框;最后通过显式 WebKit mask 稳住圆角裁剪。

最后稳定下来的写法大概是这样:

.birthday-privilege-entry {
  overflow: hidden;
  border-start-start-radius: 100px;
  border-start-end-radius: 100px;
  border-end-start-radius: 100px;
  border-end-end-radius: 100px;
  isolation: isolate;

  // Safari 会把 blend-mode 纹理作为独立图层合成,显式 mask 可以避免它突破圆角裁剪。
  -webkit-mask-image: -webkit-radial-gradient(#fff, #000);
}

这不是一个「加一条 CSS 就完事」的故事。更重要的是把问题归类清楚:它不是普通布局错位,也不是单纯 z-index 写小了,而是移动端浏览器在混合模式、裁剪和图层合成叠在一起时出现的视觉边界问题。

真实结构

按钮模板可以简化成这样:

<div class="birthday-privilege-entry">
  <img class="birthday-privilege-entry__texture" src="./assets/entry-texture.png" />
  <img class="birthday-privilege-entry__balloon" src="./assets/entry-balloon.png" />
  <img class="birthday-privilege-entry__icon" src="./assets/entry-icon.png" />
  <span class="birthday-privilege-entry__text">Claim your birthday gift</span>
</div>

样式里最关键的是这几块:

.birthday-privilege-entry {
  position: fixed;
  bottom: 60px;
  inset-inline-end: 16px;
  width: 151px;
  height: 48px;
  overflow: hidden;
  border-start-start-radius: 100px;
  border-start-end-radius: 100px;
  border-end-start-radius: 100px;
  border-end-end-radius: 100px;
  background: linear-gradient(107.798deg, #3f2504 2.1369%, #8c550f 48.206%, #5d3a0b 100.21%);
  isolation: isolate;
}

.birthday-privilege-entry::after {
  position: absolute;
  inset: 0;
  z-index: 4;
  border: 0.5px solid #fff;
  border-start-start-radius: inherit;
  border-start-end-radius: inherit;
  border-end-start-radius: inherit;
  border-end-end-radius: inherit;
  box-sizing: border-box;
  content: '';
  mix-blend-mode: soft-light;
  pointer-events: none;
}

.birthday-privilege-entry__texture {
  position: absolute;
  z-index: 1;
  top: -80.34px;
  inset-inline-start: -56.17px;
  width: 167.6px;
  height: 152.25px;
  opacity: 0.4;
  mix-blend-mode: color-dodge;
}

这段代码有几个风险点:

  • .birthday-privilege-entry__texture 的位置是负坐标,它大部分区域其实在按钮外面。
  • 父级靠 border-radius + overflow: hidden 把外面的纹理裁掉。
  • 纹理用了 mix-blend-mode: color-dodge
  • 伪元素描边用了 border: 0.5pxmix-blend-mode: soft-light
  • 父级用了 isolation: isolate,希望把混合范围限制在按钮内部。

MDN 的层叠上下文文档列出的高风险信号里,正好包括 mix-blend-modetransformfiltermask / mask-imageisolation: isolate。这些属性会改变元素参与层叠和绘制的方式。单独使用时问题不大,叠在移动端 WebView 的圆角裁剪里,就会把问题推到浏览器合成阶段。

MDN 的 compositing and blending 文档把混合模式描述为元素背景、边框、内容和父级背景之间的混合关系。这里不是简单给元素涂一个颜色,而是在让浏览器把两个视觉层按某种模式混在一起。

Android:隐藏 texture 后恢复正常

Android 那次的现象是按钮左上角多出异常色块。设备 UA 里能看到 Android 12、Chrome 95、wv,也就是 App 内 WebView 环境。

最有效的隔离动作是:

.birthday-privilege-entry__texture {
  visibility: hidden;
}

隐藏 texture 后异常消失。这个证据说明三件事:

  1. 问题不是文本、图标或主背景渐变引起的。
  2. 问题和 texture 这张装饰图,或者 texture 上的 CSS 声明有关。
  3. overflow: hidden 在这个组合里没有像预期那样稳定地把外部视觉层裁干净。

这一步还不能直接证明「Android WebView 有 bug」。它只能证明异常和 texture 这层强相关。后面要继续拆:

/* 只保留图片,不混合 */
.birthday-privilege-entry__texture {
  mix-blend-mode: normal;
}

/* 保留混合,但让它不跑到圆角外面 */
.birthday-privilege-entry {
  -webkit-mask-image: -webkit-radial-gradient(#fff, #000);
}

/* 视觉允许时,直接把纹理预合成到素材里 */
.birthday-privilege-entry__texture {
  display: none;
}

如果 mix-blend-mode: normal 后恢复,说明实时混合是关键触发点。如果补 mask 后恢复,说明父级裁剪边界在合成阶段不够稳定。如果预合成素材后恢复,说明运行时 blend 本身就不是这个 UI 必须保留的能力。

这次最后没有简单删掉 texture。删图会改变设计效果,也会让问题被「绕开」而不是被解释。更稳的做法是保留视觉层,但让父级的圆角裁剪边界更明确。

iOS:Chrome 正常,Safari 左边多出边框

iOS 那次更像典型 WebKit 视觉边界问题。桌面 Chrome 或 Android Chrome 看着没问题,Safari 上按钮左侧却像多了一条边框。

这个现象有两个候选触发点:

  • ::after 伪元素用了 border: 0.5px solid #fffmix-blend-mode: soft-light
  • texture 作为负坐标装饰层参与混合,WebKit 可能把它作为单独图层处理。

0.5px 本身不是错。问题在于它不是普通静态描边,而是叠在圆角裁剪、混合模式、设备像素比和 WebKit 合成层里。Safari 最后看到的可能不是「一个被柔化的 soft-light 描边」,而是一条被合成后更明显的左侧亮边。

稳定版本做了两件事:

.birthday-privilege-entry {
  overflow: hidden;
  border-start-start-radius: 100px;
  border-start-end-radius: 100px;
  border-end-start-radius: 100px;
  border-end-end-radius: 100px;

  // Safari 会把 blend-mode 纹理作为独立图层合成,显式 mask 可以避免它突破圆角裁剪。
  -webkit-mask-image: -webkit-radial-gradient(#fff, #000);
}

.birthday-privilege-entry::after {
  border: 0.5px solid #fff;
  border-start-start-radius: inherit;
  border-start-end-radius: inherit;
  border-end-start-radius: inherit;
  border-end-end-radius: inherit;
  mix-blend-mode: soft-light;
}

第一件事是给父级显式 mask。overflow: hidden 是布局裁剪,mask-image 是图像遮罩。MDN 的 mask-image 文档说明,mask 会根据 alpha 或 luminance 决定元素哪些区域可见。这里用一个实心 radial gradient,不是为了做渐隐效果,而是给 WebKit 一个更明确的圆角裁剪边界。

第二件事是伪元素描边的圆角逐个继承父级 logical corner。这样收起态改变右侧圆角时,描边层也跟着变,不会留下自己的旧圆角或边界。

这类修法最好保留注释。-webkit-mask-image: -webkit-radial-gradient(#fff, #000) 看起来像老式 hack;没有 Why 注释,后续 review 很容易把它删掉。

最小 playground

这里放了一个自包含 playground,移动端可以直接扫码打开:

打开 playground

playground 二维码

playground 里有四个模式:

  • 风险组合:负坐标 texture、color-dodge、半像素 soft-light 描边和圆角裁剪同时存在。
  • 隐藏 texture:模拟 Android 那次的最小隔离动作。
  • 加 WebKit mask:模拟最终稳定修法。
  • 预合成思路:去掉运行时 blend,用普通背景和 inset shadow 表达接近效果。

这个 playground 不承诺在所有设备上都复现异常。它的用途是让读者快速切换变量,理解这类问题为什么要从 texture、blend、border 和 mask 几个方向拆,而不是看到 Chrome 正常就结束排查。

为什么不是单纯的 border 问题

如果只看 iOS 截图,很容易把它归成「左侧 border 没裁好」。但 Android 那次隐藏 texture 后恢复,说明问题不只在 ::after 描边层。

更合理的模型是:

负坐标装饰图
  + opacity
  + mix-blend-mode
  + 圆角裁剪
  + 半像素描边
  + 移动端 WebView 合成
  = 边缘处可能出现异常视觉层

WebKit 的 Layers 文章解释过,DOM 元素不是一个个直接画到屏幕上的。元素会先被绘制到一组 layers / surfaces,再按顺序合成。某些元素类型、某些 CSS 属性和与其他图层的交互,都可能生成新的 layer。这样看,Android 和 iOS 的两个现象就能放到同一条线上:它们都是复杂装饰层在合成和裁剪边界上的问题,只是不同引擎暴露出来的位置不一样。

下次怎么排查

遇到「Chrome 正常、移动端 WebView 边缘异常」时,可以按这个顺序拆:

  1. 先找最小隐藏层。把纹理图、伪元素、阴影层、描边层逐个 visibility: hidden,确认隐藏哪一层后恢复。
  2. 再关高风险声明。优先关 mix-blend-modefilteropacitytransformborder: 0.5pxoverflow: hidden
  3. 检查图片本身。看素材是否有透明底、半透明边缘、暗色底、非整数尺寸或被 CSS 拉伸。
  4. 检查裁剪边界。border-radius + overflow: hidden 不稳定时,尝试 -webkit-mask-imageclip-path: inset(0 round ...)
  5. 评估预合成。装饰层只是为了接近设计稿时,运行时 mix-blend-mode 未必值得保留。把高光、纹理、描边合进 PNG / WebP,通常比让移动端实时混合更稳。

如果能连接 Safari Web Inspector,可以打开 Layers 视图看相关元素是否被拆成独立合成层。WebKit 的 Layers 工具本来就是为了观察 layer 创建原因、绘制次数、内存成本和合成顺序。

修法选择

这类问题不要默认只有一个解。按侵入程度从低到高,可以分成几档:

方案 适用场景 风险
给父级补 -webkit-mask-image 视觉效果必须保留,问题集中在圆角裁剪边缘 增加一条兼容性写法,需要注释解释
border: 0.5px 换成 inset shadow 描边只是轻微质感,不强依赖 blend 视觉和设计稿可能有细微差异
去掉运行时 mix-blend-mode 装饰层只是高光、纹理、氛围 需要重新确认设计效果
预合成素材 移动端稳定性优先,装饰层不需要动态变化 后续改视觉要重新出图

这次选择的是第一档:保留视觉效果,用 mask 明确裁剪边界,并在代码里写清楚原因。如果后续某个设备仍然异常,就应该往「预合成素材」方向走,而不是继续在运行时叠更多 blend / filter。

结论

这个按钮的问题不在尺寸,也不在文案,而在装饰层太复杂。mix-blend-mode、负坐标纹理、半像素描边、圆角裁剪和移动端 WebView 合成叠在一起后,Android 和 iOS 都可能在边缘处暴露不同问题。

排查时最有用的动作不是立刻加 hack,而是先隐藏一层,确认异常跟谁强相关。Android 那次隐藏 texture 后恢复,说明 texture 或它触发的合成路径是关键线索;iOS 那次给父级补 WebKit mask 后恢复,说明显式裁剪边界能让 Safari 的合成结果稳定下来。

后续再做这类 H5 装饰按钮时,运行时视觉效果要克制一点。能用普通背景和 inset shadow 表达的,不要急着上 blend;必须上 blend 时,把裁剪边界、注释和实机验证一起补上。