ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebView下H5密码框自定义随机键盘的完整实现方案

WebView下H5密码框自定义随机键盘的完整实现方案 接手这个需求时我心里其实是拒绝的在 WebView 里给第三方 H5 页面的密码框套一个自定义随机键盘听起来简单真做起来牵扯的东西不少。但干到后面发现这活儿的核心挑战其实不在“键盘”本身而在于怎么在不动 H5 代码的前提下把原生安全能力“无缝”嵌进一个完全不可控的页面里。本文就围绕 WebView、H5 密码框、自定义随机键盘这三个关键词完整复盘一下我的实现思路、踩坑记录和最终方案希望能给同样在做安全输入方向的同行一点参考。先说结果最终实现的效果是App 内嵌的 WebView 加载任意第三方 H5 页面时只要检测到密码输入框typepassword点击后不会弹出系统键盘而是弹出一个原生绘制的自定义随机数字/字母键盘。键盘键位每次弹出时随机排列输入值通过注入的 JS 回填到 H5 密码框同时触发 input/change 事件保证页面框架Vue、React 等能正常拿到值。1. 项目背景与需求拆解1.1 这个需求是怎么来的场景很典型某金融类 App 需要在内置浏览器里承载开户、绑卡、登录等 H5 业务但这些 H5 页面大多是第三方外包团队开发的甚至部分页面直接加载的是合作方的远程地址App 端根本没法改他们的代码。合规和风控要求密码框必须使用自定义安全键盘不允许系统键盘截屏、不允许第三方输入法窃取键入内容。这里就有一个天然矛盾H5 页面有自己的密码框用户点击后默认唤起系统软键盘。系统的软键盘每个输入法厂商都不一样安全性不可控虽然可以靠 window 的 resize 事件大致判断键盘弹出状态但对输入内容本身毫无保护能力。更麻烦的是第三方页面可能在多个渠道重复使用你不能要求在原生端做适配后就让他们配合改代码——合作方一句“我们页面是通用的不能为你们单独改”项目就只能自己想办法。1.2 为什么不能直接改 H5 页面很多人第一反应是让 H5 那边把密码框藏起来用一个普通文本展示框接自定义输入再传给原生。思路没错但在第三方页面上会遇到现实阻力页面归属权不在你手里改动需要走对方的发版流程周期不可控对方可能同时对接了多个渠道为某一个 App 单独开发分支后续合并维护成本极高安全键盘的触发、值回传等逻辑分散在两端一旦出问题很难界定责任。说白了最好的方案就是做一个对 H5 完全无感、纯原生侧统一的兜底方案不管页面里出现多少个密码框、不管 H5 用的是什么框架只要在 WebView 里就强制走安全输入流程。1.3 需求落地要达成的目标清单在动手前我把需求拆成了下面几条硬性指标作为后面技术选型和验证的标准自动识别 H5 页面中所有可见的密码输入框无需 H5 侧任何配合密码框获得焦点时系统软键盘不得弹出自定义键盘弹出时键位布局必须随机变化每点一次键盘随机算法都要重新计算输入的值可以实时回填到 H5 输入框并且页面框架至少是 Vue/React/jQuery 常见写法能监听到变化键盘不能因为 H5 页面滚动、跳转、弹窗等操作而残留或错位尽量不影响非密码输入框的正常系统键盘输入流程。2. 整体方案设计与选型2.1 三条路线的对比我把能想到的实现方案列了一下最终对比见下表方案侵入性兼容性开发量隐藏坑H5 侧改代码把密码框替换为自定义控件配合原生高好小但需要对方配合对方排期、分支维护、责任界定WebView 拦截密码框渲染使用原生 View 覆盖中中大坐标计算易错、滚动同步难、H5 页面结构复杂时容易错位JS 注入监听焦点隐藏系统键盘弹出自定义随机键盘低好中焦点管理、值同步需要精细化处理方案一依赖外部配合直接放弃。方案二听起来很“原生”但实操中有个致命问题你在原生层创建一个输入框覆盖在 WebView 上的某个位置只能通过getLocationOnScreen之类的 API 获取 H5 元素坐标一旦页面发生滚动、缩放、字体变化坐标就对不上了维护成本特别高后来也放弃了。最终确定的是第三套方案核心逻辑都放在注入的 JS 里原生只负责弹键盘和收键盘。JS 负责监听焦点、上报回调、接收原生回传的值原生负责绘制安全键盘、处理输入逻辑、更新 H5 输入框的值。两层之间通过WebView.evaluateJavascript和addJavascriptInterface通信。2.2 为什么选 JS 注入这个路线JS 注入方案最核心的优势是不受页面 DOM 结构变化影响。无论输入框位置怎么变、外层嵌套多少层、页面是 Vue 还是 React本质上我们监听的是focus/blur事件只要 DOM 节点还在回调就能执行。坐标、滚动这些问题全部绕开因为键盘是原生 View永远是悬浮在当前 Activity 窗口上的独立控件不依赖 H5 页面内的定位。再有就是它天然支持开箱即用的降级机制万一注入的 JS 因为某些异常没有执行用户依然可以正常点击密码框使用系统键盘输入只是体验上少了一层安全加固但不会造成页面功能直接瘫痪。生产环境里这种兜底设计非常重要。安全性方面自定义键盘触发的核心逻辑放在原生代码里键位渲染不使用系统 WebView 输入法框架并且可以在键盘 Window 上加FLAG_SECURE防截屏标记基本能满足常见的风控输入要求。2.3 技术栈与前置知识宿主Android 原生WebView 使用的系统内核注入层JavaScrip推荐在onPageStarted时就注入避免 H5 页面初始化后才挂载导致监听遗漏通信层JavascriptInterface提供原生方法给 JS 调用evaluateJavascript让原生执行 JS 并接收返回值自定义键盘原生 View推荐使用PopupWindow或Dialog按WindowManager.LayoutParams.TYPE_APPLICATION_PANEL类型弹出避免影响 WebView 的窗口状态。如果你是第一次做类似功能基础的知识点主要就是上面这几块不需要太复杂的架构但每一块的细节都会决定成败下面具体说实现。3. 核心实现细节3.1 JS 注入层的设计与实现JS 注入是整个方案的地基如果这层没做好后面的原生键盘再华丽也没有用。注入的核心任务是扫描并监听页面上所有密码输入框。一般在WebViewClient.onPageStarted或onPageFinished时注入。我建议在onPageStarted注入虽然此时 DOM 还未完整但没关系因为我们的 JS 是注入一个监控脚本而不是直接操作具体 DOM 节点脚本只需要注册全局事件监听即可。下面是核心注入代码为便于说明省略了部分字符串转义细节实际使用时需做转义处理(function() { // 记录当前活跃的密码输入框 var activePasswordField null; // 检查一个 input 元素是否为密码框 function isPasswordField(el) { var type (el.getAttribute(type) || ).toLowerCase(); return type password; } // 当密码框获得焦点时的处理函数 function onPasswordFocus(e) { var el e.target; if (!isPasswordField(el)) return; activePasswordField el; // 1. 防止 H5 页面自身的默认行为 e.preventDefault(); // 2. 通知原生弹出自定义键盘 if (window.AndroidBridge window.AndroidBridge.showSecureKeyboard) { window.AndroidBridge.showSecureKeyboard(); } } // 当密码框失焦时的处理 function onPasswordBlur(e) { var el e.target; if (el activePasswordField) { activePasswordField null; if (window.AndroidBridge window.AndroidBridge.hideSecureKeyboard) { window.AndroidBridge.hideSecureKeyboard(); } } } // 监听focus/blur document.addEventListener(focusin, function(e) { onPasswordFocus(e); }, true); document.addEventListener(focusout, function(e) { onPasswordBlur(e); }, true); })();这里有个关键点必须使用focusin/focusout而不是focus/blur。focusin和focusout支持事件冒泡即使焦点目标是在动态插入的 DOM 节点上也能被 document 层捕获。第三方页面你没法预判它们的 DOM 结构用捕获阶段或者冒泡事件可以最大程度地覆盖所有场景。同时为了兼容动态渲染的输入框比如用户点击“获取验证码”后才出现的密码框还建议加一个 MutationObserver 监听 DOM 变化var observer new MutationObserver(function(mutations) { // 扫描新出现的密码框做必要的属性标记等 }); observer.observe(document.documentElement, { childList: true, subtree: true });加上这个之后页面无论怎么动态生成密码框都能被我们的焦点监听捕获。3.2 如何隐藏系统软键盘自定义键盘要弹出的前提是系统软键盘不出现。这一步如果不处理会出现两个键盘同时弹出的诡异场景。我试过两种方式最终取的是组合拳第一步在 Android 侧设置windowSoftInputMode。把 WebView 所在 Activity 的软键盘模式设置为adjustNothing或adjustUnspecified目的是让系统不要把布局顶起来。但这种方式对 WebView 内部打开软键盘的请求阻止效果有限因为 WebView 自己会请求显示输入法。第二步在 JS 中主动让密码框只读 / 禁用系统输入法。这一点很关键也是最有效的手段。在密码框聚焦前先将readonly属性临时加上等原生键盘弹出后再移除。因为只读的输入框不会唤起软键盘。实现方式是调整上面的onPasswordFocus函数function onPasswordFocus(e) { var el e.target; if (!isPasswordField(el)) return; activePasswordField el; e.preventDefault(); // 临时加 readonly阻止系统键盘 el.setAttribute(readonly, readonly); // 延迟设置焦点确保 readonly 已生效 setTimeout(function() { el.focus(); if (window.AndroidBridge window.AndroidBridge.showSecureKeyboard) { window.AndroidBridge.showSecureKeyboard(); } }, 50); }加readonly这个操作是整套方案里最核心的小技巧实测对系统 WebView 和大多数国产浏览器内核都有效。不过注意readonly对密码框的值读取没有影响只是阻止系统软键盘弹出所以可以放心使用。第三步在onPasswordBlur里移除readonly。避免密码框残留只读属性影响后续用户通过系统键盘编辑虽然我们不希望用户这样操作但兜底还是要有。另外注意某些 H5 框架尤其 Vue在focus时会重新绑定事件或者操作 DOM 属性如果发现注入的只读属性被框架悄悄删掉可以加一个定时器周期性检查比如在 200ms 内每 50ms 检查一次当前活跃密码框是否还是只读状态如果被移除了就重新加上。这个操作虽然有点暴力但实测对兼容性问题非常有效。3.3 自定义随机键盘的原生实现键盘 UI 我用的是自定义 View PopupWindow 的组合。之所以不用 Dialog是因为 Dialog 在某些国产 ROM 上会有动画延迟且 Dialog 会抢焦点对 WebView 的焦点恢复有潜在影响。PopupWindow 更轻量而且可以指定showAtLocation显示在屏幕底部。键盘布局上我使用了一个GridLayout行列数视键盘类型动态生成纯数字密码3 x 4 布局0-9 加“删除”和“清空”字母数字混合密码3 x 5 或 4 x 5 布局26 个字母加数字再加符号容量不够时可支持翻页。随机键盘的核心是键位的随机排列算法。这一点上我踩过一个坑一开始是全量随机每次弹出时所有按键位置都完全无规律导致用户根本找不到字母位置体验很差。后来改成随机法 局部纠偏数字 0-9 的位置每次随机排列字母则按 QWERTY 键盘原有分组组内顺序随机打乱组的位置也随机。这样用户熟悉感还在但每次按键坐标都不同安全性也有基本保障。随机算法可以简单使用Collections.shuffle但要注意这是伪随机对于强安全场景建议把系统熵加入随机种子比如用SecureRandom替代Random避免键位排列可预测。对于每个按键的点击事件需要记录当前键盘的键位映射表。例如class SecureKeyboardView(context: Context) : GridLayout(context) { private var keyMap mutableMapOfInt, String() // viewId - char fun shuffleKeys() { val chars generateCharList() val shuffled chars.shuffled() // 为每个按键重新赋值 for (index in 0 until childCount) { val btn getChildAt(index) as Button val char shuffled[index] btn.text if (char DELETE) 退格 else char keyMap[btn.id] char } } }这里的 keyMap 非常重要因为随机键盘上按下的位置和实际输入的字符不再是固定的必须通过 keyMap 转换成真实字符再回传 H5。另外键盘的点击反馈震动/音效可以根据需求选择开启。安全键盘一般都会提供震动反馈但注意不要做得太夸张否则用户觉得廉价。我这边是用的默认震动强度触发时机为 10ms 短震动和系统键盘体验接近。3.4 值的回填与同步的完整链路用户点击自定义键盘上的某个键后值要走到 H5 的密码框里才算是闭环。链路是键盘 View 捕获点击事件通过 keyMap 找到真实字符原生调用evaluateJavascript执行一段 JS把值塞进密码框JS 再把输入框的值返回给原生作为下一次追加/删除操作的基准。第四步是这个方案里非常关键的细节不能只在原生侧维护一个全局变量记录当前密码框的值而要实时从 H5 获取实际值。原因很简单H5 页面自己的逻辑可能会修改输入框的值比如某些安全控件会自动清空密码框如果你只在原生侧盲目追加字符最终值和 H5 页面的实际值会不一致。所以我的做法是点击键盘时原生先异步取当前输入框的值// 原生调用此方法获取当前密码框的值 window.AndroidBridge.getCurrentPasswordValue();原生在JavascriptInterface中实现JavascriptInterface public void getCurrentPasswordValue() { webView.post(new Runnable() { Override public void run() { webView.evaluateJavascript( (function(){ return window.activePasswordField ? window.activePasswordField.value : ; })();, new ValueCallbackString() { Override public void onReceiveValue(String value) { // value 是 return 的 JSON 字符串需要解析 currentPasswordValue parseJsResult(value); } } ); } }); }拿到当前值之后如果是追加字符就拼上新的字符再一次性回填window.activePasswordField.value newValue;这里有一个隐藏的坑光设置.value是不够的H5 页面如果用 Vue 的v-model或 React 的受控组件它们监听的是input事件不会因为你直接设置 value 就同步。所以回填完之后一定要手动触发input事件var event new Event(input, { bubbles: true }); window.activePasswordField.dispatchEvent(event);这个input事件对 Vue 有效React 因为使用的是合成事件还需要特殊处理。React 的受控组件会覆盖 DOM 上的 value所以回填后还要在原生层通过输入法模拟一次按键或者用Object.getOwnPropertyDescriptor绕过 React 的 value 劫持。我这里引入了一套setNativeValue函数用于兼容 Reactfunction setNativeValue(element, value) { var proto Object.getPrototypeOf(element); var descriptor Object.getOwnPropertyDescriptor(proto, value); descriptor.set.call(element, value); }之后再触发 input 事件React 的 onChange 就能正常触发。这一层兼容逻辑花了不少时间后面在踩坑部分我还会详细讲。3.5 键盘适配 WebView 的焦点守卫焦点问题是做这套方案遇到的最头疼问题。WebView 内部的焦点管理和普通 Activity 的焦点管理不太一样它有自己的焦点上下文。自定义键盘弹出时如果不处理好焦点会出现这些状况键盘弹出后WebView 失去焦点后续点击密码框时焦点无法回到输入框H5 页面里面有多个密码框时点击第二个密码框键盘不会重新定位到新输入框的值键盘弹出期间如果用户点击了 H5 页面上的按钮比如“下一步”焦点变化导致键盘状态异常。解决思路是使用一个“焦点守卫变量”在 JS 注入层维护一个secureKeyboardActive布尔值只有在密码框触发焦点时设为 true原生层收到 hide 调用时才设为 false。同时原生键盘在任意非键盘区域的触摸事件中都尝试隐藏键盘、移除密码框的只读属性、将焦点交还给 WebView。secureKeyboardPopupWindow.setOnOutsideTouchListener(new PopupWindow.OnOutsideTouchListener() { Override public void onOutsideTouch() { hideSecureKeyboard(); // 通知 JS 移除 readonly 并把焦点还给密码框 webView.evaluateJavascript( window.AndroidBridge.restoreInputState();, null); } });JS 侧对应处理是如果当前活跃密码框存在调用blur()后再focus()确保焦点状态正常。这个过程可能稍微有点抖动但稳定性会好很多。4. 常见问题与排查技巧实录4.1 系统键盘还是会弹出来的几种情况这是接入过程中反馈最多的一个 bug。排查下来主要集中在这三种场景场景一注入 JS 时机太晚。页面加载完成之后用户在密码框聚焦时JS 监听器还没挂载上系统键盘自然就弹出来了。解决方法是把 JS 注入时机从onPageFinished提前到onPageStarted同时在onPageFinished里再补一次注入双保险。场景二H5 页面有自己绑定的事件导致preventDefault失效。有的页面在捕获阶段就把事件stopPropagation掉了你的 document 级监听器根本收不到事件。这种情况只能用替换原生事件监听的方式把事件直接绑定到具体元素上。因为第三方页面无法预判我的做法是再注入一个扫描器定时扫描新出现的密码框在他们本身上绑定独立的focus监听器function bindToElement(el) { el.addEventListener(focus, function(e) { handleFocus(e); }, true); }场景三某些国产浏览器的 X5 或 MTT 内核有缓存 WebView 配置的行为。如果用户之前访问过页面浏览器内核会缓存部分页面配置导致readonly延迟设置失效。这种情况下可以稍微延后readonly的设置时间或者直接在点击事件发生的mousedown阶段就设置readonly这样能更早地阻止键盘弹出。4.2 点击键盘按键后密码框的值没有同步值同步问题主要集中在 Vue 和 React 的两个框架上Vue 的 v-model直接用原生 input 事件触发即可Vue 会监听input事件。React 的受控组件React 内部有个 value tracker监听 DOM 的 input 事件但它会对比当前 DOM value 与内部状态 value不一致时会强制覆盖。所以必须用setNativeValue的方式修改值然后触发 input 事件。具体的setNativeValue代码我在上面已经给出了这里补充一个场景有时候 React 是在mousedown阶段就更新了焦点状态如果你在 click 事件阶段回填值React 的状态还没准备好就会丢失。解决办法是把回填值的操作放到setTimeout50ms 之后。4.3 键盘弹出时 H5 页面被顶起来或者背景错乱原因几乎都是 Activity 的windowSoftInputMode设置不当。如果系统键盘被抑制但是adjustResize仍然生效WebView 可能会在收到窗口尺寸变化后自动调整页面布局导致页面跳动或背景错乱。解决办法有两个方向把软键盘模式设置为adjustNothing完全禁止 WebView 调整页面尺寸如果因为某些原因不能设置adjustNothing比如页面里有其他文本输入框也需要系统键盘则可以在自定义键盘弹出时通过 JS 主动把document.body.style.height锁定再用position: fixed固定住页面键盘隐藏后再恢复。后者的实现比较繁琐而且会干扰 H5 自身的布局我建议能调原生配置就先调原生配置搞不定再上这种补丁方案。4.4 涉及安全性的额外加固项既然做的是安全键盘额外的防截屏 / 防录屏机制是少不了的。我在键盘 View 所在的 Window 上加了WindowManager.LayoutParams.FLAG_SECURE这样系统原生录屏和截图都会被拦截。但是注意WebView 所在的窗口如果没加这个 FLAG用户截屏仍然能截到 H5 页面内容虽然密码框是明文还是掩码取决于 H5 侧实现。如果对安全要求更高还可以考虑在密码框聚焦期间给整个 WebView 所在窗口也加上FLAG_SECURE退出焦点时再移除。很多金融 App 用的是这种方案代价是用户在输入密码期间不能随意截屏体验上稍有牺牲。另外随机键盘的键位映射表只保存在内存中键盘隐藏后立即清空避免被 dump 内存后分析出键位规律。原生侧日志一律不能打印键盘输入的内容Debug 模式下也只能打掩码后的值。4.5 键盘显示区域适配刘海屏、全面屏手势区自定义键盘弹出时如果只简单showAtLocation(parent, Gravity.BOTTOM, 0, 0)在全面屏、刘海屏手机上键盘底部可能会被系统导航条手势区遮住一部分导致最下面一排按键点击不灵敏。解决方式是获取系统导航栏高度在弹出键盘时向上偏移val navigationBarHeight getNavigationBarHeight() secureKeyboardPopupWindow.showAtLocation( webView, Gravity.BOTTOM, 0, navigationBarHeight )getNavigationBarHeight的实现网上很多这里不展开。核心要点是不同 ROM 返回的导航栏高度口径不一样有的已经把 navigation bar 的高度算在了屏幕里有的没算必须以运行时的WindowInsets为准不能硬编码。4.6 多密码框页面的处理一个 H5 页面里出现多个密码框时容易出问题用户先填第一个密码框再点第二个原生键盘不会重新读取新输入框的值而是把值继续回填到原来的activePasswordField上。在 JS 注入层我的处理是每次focusin都做一次activePasswordField的重新赋值同时调用原生的onPasswordFieldChanged方法通知原生当前聚焦的密码框已经切换。原生侧收到这个回调后清空本地维护的临时值缓存确保下一次点击键盘时重新从 H5 获取新输入框的值。5. 写在最后做这个功能将近两周最大的体会是做 WebView 和第三方 H5 的交互本质上是在一个你无法完全控制的系统里做防御性编程。你不仅要考虑正常的 DOM 操作还要应对各种框架的“自作主张”、各种浏览器的怪异行为、各种用户点击姿势。我最想分享的一个经验是任何注入到 H5 里的 JS都要做到“可降级”。也就是在某一步异常时系统要有能力退回最原始的输入方式。我们这个方案里即使注入的 JS 因为极端情况挂了用户还是可以用系统键盘正常输入只不过少了安全层。但在生产环境里这个降级能力救了无数次急因为线上 H5 页面你永远无法预知下一次改版会不会引入新的 DOM 结构变化。另外团队里如果有负责 H5 的同事强烈建议把注入的 JS 逻辑抽出来单独维护一个版本并写好注释因为这段代码要同时兼容多个页面、多个框架、多个 WebView 内核迭代频率非常高丢给一个人“记住”是不现实的必须有一个清晰的文档或注释来支撑长期维护。自己做过的项目里这块代码如果半年后再看没有注释的话真的会一脸懵。这个方案后续还可以扩展的方向很多比如自定义键盘支持指纹快捷填充、支持随机键盘类型切换数字/字母/符号、支持验证码自动填充等。只要 JS 注入层和原生键盘层的接口设计得足够干净新功能往上加并不是难事。希望这篇文章能帮到正在折腾同样需求的你。
返回列表