十轮排查,凶手竟在门口

十轮排查,凶手竟在门口

  1. Dev 🐛
  2. in 7 hours
  3. 7 min read

这是一篇关于这次漫长排查过程的技术博文:


十轮排查,凶手竟在门口 —— 记一次支付宝小程序 input 无法删除的 Debug 之旅

—— 当所有不可能都被排除,剩下的那个即使再离谱,也是真相。


问题

公司项目是支付宝小程序,某个搜索列表组件中的 <input> 输入框在所有手机上都工作正常——唯独在荣耀(Honor)手机上,用户输入文字后按删除键毫无反应

听起来像是一个普通的兼容性问题,对吧?我也这么想。然后我花了整整十轮才找到真凶。


第一轮:经典套路 controlled

支付宝小程序文档明确提到 <input>controlled 属性用于解决 Android 输入框删除问题。加上去。

<input controlled value="{{searchKeyword}}" onInput="onSearchInput" />

结果:无效。


第二轮:setData 渲染竞态

会不会是每次 onInputthis.setData() 触发组件重渲染,和键盘的删除操作产生了竞态?把 onInput 改成只存本地变量,确认搜索时才同步:

onSearchInput(e) {
  this._inputValue = e.detail.value;  // 不调 setData
},
onSearchConfirm() {
  this.setData({ searchKeyword: this._inputValue });
  this.fetchData(true);
}

结果:无效。


第三轮:scroll-view 的罪过?

input 嵌套在 <scroll-view> 内部,原生滚动容器可能拦截了键盘事件。把筛选区从 scroll-view 中提出来,只让结果列表滚动。

结果:无效。


第四轮:overflow: hidden

外层容器有 height: 100vh; overflow: hidden; position: relative——是不是固定视口高度在键盘弹起时导致了布局异常?移除所有定位,改成 min-height: 100vh 流式布局。

结果:无效。


第五轮:textarea + enableNative

<input> 不行,换成原生层级的 <textarea>,再加上 enableNative 属性强制启用原生控件。

结果:无效。


第六轮:slot 提升法

Component() 中渲染的 input 无论如何都不行。那通过 slot 让 input 在 Page() 上下文中声明呢?组件暴露一个 <slot name="search-input" />,页面在 slot 中放 input。

<search-list>
  <view slot="search-input">
    <input value="{{keyword}}" onInput="onSearchChange" />
  </view>
</search-list>

结果:无效。


第七轮:外部 fixed 层

slot 也不行,干脆把 input 提到组件外部,用 position: fixed 悬浮在组件上方。

结果:无效。


第八轮:完全放弃组件

把整个 search-list 组件拆掉,所有逻辑、模板、样式全部内联到 Page 中。input 是 Page 模板的直接子元素,不经过任何 Component 节点。

结果:无效。


第九轮:极简对照实验

这是最关键的一步。我注意到项目里有一个测试页面 /pages/test-input,里面就一个 input,在荣耀手机上删除完全正常

我把 lawyer 页面删到和测试页一样极简:

<!-- lawyer/index.axml -->
<view class="law-page">
  <view class="law-test-body">
    <input value="{{keyword}}" onInput="onInput" />
  </view>
</view>
// lawyer/index.ts
Page({
  data: { keyword: '' },
  onInput(e) { this.setData({ keyword: e.detail.value }); },
});
// lawyer/index.json
{ "defaultTitle": "请律师" }

两个页面现在 100% 同构——同样的 template 结构、同样的 1 个 data key、同样的 CSS、同样的 Page() 上下文。

结果:终于能删除了!


找到凶手

极简版能正常工作,说明问题不在 template、不在 CSS、不在 JS 逻辑、不在 Component。

我开始往回加东西。加回 transparentTitle: "always"

{
  "defaultTitle": "请律师",
  "transparentTitle": "always"
}

结果:立即无法删除。

移除 transparentTitle,一切恢复正常。又试了 "auto" 模式——同样不行。

真凶找到了。


真相

支付宝小程序中,transparentTitle: "always" 会让导航栏变为透明,页面内容延伸到状态栏区域。这个效果在 iOS 和其他 Android 机型上一切正常,但在荣耀(Honor)手机的 Android WebView 中,框架为了渲染透明导航栏会在页面顶部创建一个 native OS 层级的覆盖层

这个覆盖层的范围是整个页面——不是因为 input 在导航栏下面被遮挡,而是这个 native 层从物理上拦截了键盘的 Backspace 事件。不管你的 input 离导航栏多远(即使用 padding-top: 200px 把 input 推到屏幕中央),keydown 事件依然被吞。

这也是为什么 <input><textarea>controlledenableNative 全部无效——这些属性影响的是 WebView 层面的 input 行为,但背身键事件在到达 WebView 之前就已经被 OS 层的透明导航栏覆盖层劫持了。

test-input 页面之所以正常,是因为它的 index.json 里从来没有 transparentTitle


修复

移除所有包含 input 的搜索页面的 transparentTitle 配置:

{
  "defaultTitle": "请律师",
- "transparentTitle": "always",
- "titleBarColor": "#ffffff",
  "usingComponents": { "search-list": "/components/search-list/index" }
}

仅此一行修改。六行代码的删除,解决了十轮排查的难题。


反思

这次 debug 让我重新理解了”逐层排除法”:

  1. 从最可能的开始controlled 属性、setData 竞态)
  2. 逐步走向不可能的scroll-view 原生层、overflow CSS、position 定位)
  3. 建立对照组(极简测试页 vs 完整页面的对照实验)
  4. 二分逼近(加回一个配置 → 测试 → 定位)

第 9 轮的极简对照实验是整个过程的转折点。在此之前,我一直在 Component/CSS/属性体系中打转;当我把页面删到和测试页一模一样时,差异终于暴露出来——配置层,而不是代码层。

Bug 不一定藏在你的代码里。有时候,它静静躺在配置文件的最深处,等待你用对照实验把它揪出来。

debug dev