1. 第三方H5页面里的密码框,到底藏着什么风险
先还原一个真实场景。你手上有一个银行系或证券系的App,里面嵌了不少合作方的H5页面——比如贷款申请页、开户资料页、积分兑换页。这些页面是第三方团队维护的,你们拿不到源码,改不了里面的input标签,但监管和用户都把“密码安全”这笔账算在App头上。
问题就出在密码框本身。系统输入法上屏时,会经过输入法进程,第三方输入法完全有能力记录键盘事件;就算用户装了系统输入法,Android的输入法框架本身也存在被hook的风险。更重要的是,系统键盘没有随机性,按键位置固定,被偷窥、录屏、屏幕共享时等于把密码直接送出去。这两年金融类App上安全键盘几乎是标配,但第三方H5页面往往来不及改造,或者根本不愿意配合改造,只能由宿主App兜底。
我当时接手这个需求时,业务方给的约束很明确:H5页面不能动,只能从WebView这一层想办法;密码框是标准的<input type="password">,可能有多个,可能是动态渲染出来的;键盘必须每次弹出时随机排列数字。这就是标题里“第三方H5页面”和“自定义随机键盘”两个关键词的由来。
这篇文章把我实际做这套方案时的选型逻辑、注入脚本、原生键盘层、值回填机制和兼容性坑逐一讲清楚。不论你用的是原生Android WebView还是uni-app、Flutter里封装的WebView容器,核心思路都能迁移。有些坑是文档里查不到的,我尽量把排查过程和背后原因说透。
2. 两条技术路线对比:纯JS键盘 vs 原生键盘叠层
需求确定后,第一个问题是键盘做在哪一层。当时我评估了两条路线。
路线A是纯JS方案,在H5页面里注入一套HTML+CSS的随机键盘DOM。这套键盘直接附着在页面内,点击时通过JS往input里填值。优点是实现简单、跨平台一致,iOS和Android共用一套JS;缺点也很致命:第三方页面的脚本随时可能操作DOM,键盘层会被页面样式干扰;H5页面发生路由跳转或框架重新渲染时,键盘DOM可能被清掉;而且键盘本身画在页面上,视频录制、页面快照都能直接拍到键盘内容,防截屏的作用大打折扣。另一个隐形问题是,如果H5页面本身有CSP(内容安全策略)限制,通过evaluateJavascript注入内联脚本和样式时有些WebView内核会执行时处理得比较别扭,排查成本不低。
路线B是原生键盘叠层方案。注入的JS只做一件事——找到密码框、监听focus/blur事件、把焦点状态告诉原生层;原生层在WebView之上盖一个自定义键盘View,按键点击后通过JS接口把值回填到H5的input里。这个方案的好处是键盘完全脱离页面DOM,不受页面脚本干扰;键盘本体是原生View,天然支持FLAG_SECURE防截屏;随机键盘逻辑在Java层维护,每次弹出重新洗牌,第三方H5拿不到任何键盘事件。
我最终选了路线B,但这里必须说清楚一个前置条件:WebView的JS注入能力是整套方案的地基。如果页面禁用了JavaScript,或者WebView的JavaScriptEnabled没有开启,后面所有步骤都无从谈起。所以拿到需求后第一件事不是写键盘,而是确认目标WebView允许注入JS,且第三方H5页面没有显式禁用。
两条路线的核心差异可以归结为一张表:
| 对比维度 | 纯JS键盘 | 原生键盘叠层 |
|---|---|---|
| 实现成本 | 低 | 中高 |
| 防页面脚本干扰 | 弱 | 强 |
| 防截屏/录屏 | 弱 | 强(配合FLAG_SECURE) |
| 跨平台一致性 | 高 | 各自实现 |
| 针对动态DOM的健壮性 | 中等 | 较高 |
| 后续维护成本 | 中(JS被页面更新破坏) | 低(原生层稳定) |
生产环境里我强烈建议走路线B。虽然原生侧多写几百行代码,但换来的是“键盘不再受制于人”。
3. 注入脚本与焦点劫持:核心链路与关键代码
3.1 注入时机与去重
注入JS的时机是个很容易踩坑的点。onPageFinished是最多人用的回调,但它有个问题:页面里如果有持续加载的异步资源,或者H5本身就是一个单页应用(SPA),onPageFinished可能在DOM完全就绪前就回调了,也可能在一次完整的页面生命周期里被触发多次(比如页面内部发生了hash路由变化)。
我采用的策略是:在onPageFinished里先执行幂等注入。所谓幂等,就是脚本通过一个全局标记保证只生效一次,比如在window对象上挂一个window.__secureKeyboardInjected__属性,注入脚本开头先检查这个标记,已经存在就直接跳过,避免同一个页面上重复绑定focus监听导致原生键盘弹出两次。
这是注入脚本的骨架:
(function() { if (window.__secureKeyboardInjected__) { return; } window.__secureKeyboardInjected__ = true; // ...绑定逻辑 })();3.2 密码框的识别与focus/blur事件绑定
识别密码框最直接的方式是input[type="password"]。但现实项目中会遇到几种变体:有的H5为了自定义样式,会把type设成text,再通过CSS样式把字符遮住;有的会用input事件自己拼掩码。如果只认type="password",这些变体密码框就漏了。
考虑到这次需求明确限定是“文本密码框”,即标准type="password",我先把标准场景做扎实。但代码里可以预留扩展点,把选择器统一收口到一个数组里:
var PASSWORD_SELECTORS = 'input[type="password"]';以后遇到变体,只需要往这个数组里加规则。
绑定focus/blur的代码:
function hookPasswordInput(input) { if (input.__secureKeyboardHooked__) { return; } input.__secureKeyboardHooked__ = true; input.addEventListener('focus', function() { // 通知原生层:当前密码框获得了焦点 if (window.SecureKeyboardBridge && window.SecureKeyboardBridge.onInputFocus) { window.SecureKeyboardBridge.onInputFocus(input.__secureKeyboardId__, getInputPosition(input)); } }); input.addEventListener('blur', function() { if (window.SecureKeyboardBridge && window.SecureKeyboardBridge.onInputBlur) { window.SecureKeyboardBridge.onInputBlur(); } }); }这里有个细节:每个input需要有一个唯一ID。第三方页面的input大概率没有id属性,或者id是重复的,所以注入脚本要在绑定时自己分配一个自增ID,同时把input引用缓存到一个Map里。原生层后续回填值时,只需要传这个ID,JS侧通过ID找到对应的DOM元素。这样比用document.activeElement更可靠,因为键盘弹出后焦点状态可能被干扰。
3.3 原生层收到focus事件后的三件事
JS通知原生会通过WebView.addJavascriptInterface注入的桥接对象。原生onInputFocus回调里必须同步做三件事:
第一,隐藏系统输入法。这是整个方案成败的关键步骤。Android的WebView内部有一个WebViewClassic或WebViewChromium持有的输入框(EditText子类),当我们不做任何处理时,点击密码框系统输入法会自动弹出来。要禁用它,最有效的手段是把这个内部输入框标记为showSoftInputOnFocus(false)。
通过反射拿WebView内部输入框的代码大致是这样:
private void disableSoftInput(WebView webView) { try { Class<?> clazz = Class.forName("android.webkit.WebViewClassic"); Field field = clazz.getDeclaredField("mInputConnection"); field.setAccessible(true); // 真实项目中这一层反射在不同ROM上差异较大,建议加try-catch降级 } catch (Throwable t) { Log.w("SecureKeyboard", "disableSoftInput reflect failed", t); } }这段反射代码在现代Android版本上已经不太可靠,因为WebView内核发生了变化。更稳妥的做法是双管齐下:
- 在
onFocus回调里直接调用InputMethodManager.hideSoftInputFromWindow()隐藏系统键盘; - 在注入JS时,对密码框设置
readonly属性。只读输入框不会唤起系统输入法,这是浏览器内核层面的默认行为。
设置readonly有一个副作用:用户看起来光标还是有的,但H5页面自身的脚本如果读取input.readOnly属性,可能会做一些额外判断。因此我在注入脚本里用了一个更隐蔽的方式——设置readonly的同时,在focus事件里把焦点重新拉回这个input,让H5感知不到异常:
input.setAttribute('readonly', 'true'); input.addEventListener('mousedown', function(e) { e.preventDefault(); });mousedown的preventDefault能挡住鼠标点击事件默认给input带来的焦点转移,确保焦点始终停留在密码框上,这样selectionStart和selectionEnd才能正常读取。
第二,记录当前密码框的身份和位置。JS桥接回调里会传过来input.__secureKeyboardId__和位置信息,原生层把它们存成成员变量,键盘弹出后按这个ID做回填。位置信息主要用于确定键盘在屏幕上的弹出位置,但由于密码框通常在页面中间高度,键盘可以直接用底部弹出的方式,位置计算其实用不太多。位置信息更多的价值在于调试时定位“哪个input触发了focus”。
第三,弹出原生随机键盘。这一步放在焦点记录之后,避免键盘比键盘层先出现导致闪烁。
3.4 动态DOM怎么办:MutationObserver兜底
现代H5页面几乎没有纯静态的。React、Vue这类框架会根据用户操作动态渲染表单;有些页面在用户切换tab后才生成密码框;甚至有的页面在setTimeout里延迟创建input。如果只在注入时遍历一次DOM,动态出现的密码框就漏了。
因此注入脚本里必须挂一个MutationObserver,监听页面DOM结构变化,对新出现的密码框补绑事件:
var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { var addedNodes = mutation.addedNodes; for (var i = 0; i < addedNodes.length; i++) { var node = addedNodes[i]; if (node.nodeType !== 1) continue; if (node.matches(PASSWORD_SELECTORS)) { hookPasswordInput(node); } var inputs = node.querySelectorAll ? node.querySelectorAll(PASSWORD_SELECTORS) : []; for (var j = 0; j < inputs.length; j++) { hookPasswordInput(inputs[j]); } } }); }); observer.observe(document.documentElement, { childList: true, subtree: true });注意:监听的是document.documentElement而不是document.body,因为有些页面在body还没生成时就开始了脚本执行。subtree: true必须加,否则子节点变化可能观察不到。
MutationObserver本身也有性能开销,我在实际项目中给observer加了一个简单的“积压-批量处理”策略:当一次mutations数组长度超过50时,主动合并去重再遍历,避免短时间内大量DOM变更导致绑卡顿。不过对密码框场景来说,大多数页面不会频繁增删密码框,基本没有性能压力。
4. 随机键盘本体:UI设计、洗牌算法与防泄露加固
4.1 键盘布局与自适应宽度
原生侧的键盘View我用一个自定义LinearLayout实现,最外层是垂直排列的两行:上面是数字区,下面是功能键区。数字区用GridLayout,列数设为5,每行放5个按键,这样10个数字刚好两行,功能键(删除、清空、收起)可以放在第三行。
屏幕宽度通过getResources().getDisplayMetrics().widthPixels获取,每个按键的宽度按屏幕宽度除以5计算,高度统一设为屏幕宽度的六分之一左右,大拇指按压时不会觉得局促。这里有个小细节:按键之间一定要留1~2dp的间距,否则快速连续点击时极容易误触到相邻键。
按键的字体大小推荐sp单位且不小于20sp,因为金融类App用户很多是中老年用户,字太小会被吐槽。
4.2 Fisher-Yates洗牌:每次弹出都是新布局
随机键盘的核心是“随机”。每次键盘弹出时,十个数字的排列顺序都不同。如果用一个固定的排列顺序,键盘就失去了抗偷窥的意义。
洗牌用经典的Fisher-Yates算法,从数组末尾往前遍历,每次随机选一个位置交换。这个算法O(n)时间复杂度,均匀性好,不会出现某些排列组合概率偏高的问题。实现如下:
private static final int[] DIGITS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; private int[] shuffledDigits() { int[] arr = Arrays.copyOf(DIGITS, DIGITS.length); Random random = new Random(); for (int i = arr.length - 1; i > 0; i--) { int j = random.nextInt(i + 1); int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } return arr; }Random类的实例可以复用,不要每次洗牌都new一个,否则在高频点击场景下可能产生相同的随机序列。每个按键构建时,根据洗牌结果重新设置TextView的文本。
4.3 FLAG_SECURE防截屏与防录屏
随机键盘防的是“眼睛”,FLAG_SECURE防的是“摄像头”。
在键盘View显示时,把整个Activity的Window加上FLAG_SECURE:
getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);加上这个flag后,系统截屏返回的内容是黑色或空白,主流的录屏软件也录不到有效内容。Android 10及以上部分系统录屏会弹出“当前应用禁止截屏”的提示,对金融App来说这个提示反而是一种合规信号,能震慑别有用心的人。
需要特别注意的是,FLAG_SECURE是一种全窗口级别的保护,一旦设置,整个Activity的截屏都会被禁止,而不只是键盘区域。如果你不想让整个页面都禁止截屏——比如页面其他地方有分享需求——那就要考虑用SurfaceView实现键盘,或者把键盘放到一个单独的Dialog窗口里。这里我踩过一个坑:Dialog设置FLAG_SECURE只能保护Dialog自己的窗口,主窗口不受影响,但Dialog的显示层级和WebView叠加时的焦点控制比较麻烦,所以最终我还是选择了全窗口FLAG_SECURE。
键盘像素层的防泄露还有两个小点:按键字体不要用太花哨的字体,统一用系统默认字体,毕竟防的是泄露不是美观;按键不要有涟漪之外的高光反馈,涟漪动画默认是半透明的,录屏时不会把数字信息带出来。
4.4 生命周期与键盘回收
键盘View的生命周期必须和Activity绑定。我在onPause()里强制隐藏键盘,onDestroy()里把键盘View从父容器中移除并置空。否则Activity切到后台再回来时,键盘可能悬挂在屏幕上,而且焦点状态已经乱了。
有一种极端场景:用户在密码框获得焦点后,点击了H5页面里的某个链接,触发了页面跳转。这时blur事件通常会被触发,但页面跳转过程中WebView正在销毁旧的document,注入脚本里的blur回调可能来不及执行。因此不能只依赖JS的blur通知,原生层还要监听WebView的onPageStarted回调,在页面开始跳转时立即隐藏键盘并清空焦点记录。
5. 值回填的完整链路:从按键到H5表单数据同步
5.1 原生按键到JS插值的调用链
用户点击随机键盘上的数字“7”,事件经过Java层的OnClickListener,最终要落到H5页面里当前焦点密码框的value上。这一条链路看起来就一行evaluateJavascript,但里面有三层细节必须处理干净。
首先是取当前焦点密码框的句柄。我在前面提到,JS侧维护了一个__secureKeyboardMap__,键是自增ID,值是DOM元素。原生侧存了当前焦点ID,所以调用链是:
Java: 拿到当前焦点ID(比如 "3") -> evaluateJavascript("window.__secureKeyboard__.insertChar('7', '3')") -> JS侧找到id为3的input -> 读取selectionStart/selectionEnd -> 在光标处插入字符 -> 触发input事件 -> 更新光标位置JS侧的核心实现:
window.__secureKeyboard__ = { currentId: null, insertChar: function(ch, id) { var input = this.getInputById(id); if (!input) return; var start = input.selectionStart; var end = input.selectionEnd; var val = input.value; var newValue = val.substring(0, start) + ch + val.substring(end); // 关键:使用原型上的原生setter,触发框架的value变更检测 var protoSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value').set; protoSetter.call(input, newValue); // 同步光标位置:插入后光标移动到插入字符的后面 input.selectionStart = input.selectionEnd = start + 1; // 触发事件,让H5框架感知到值变化 input.dispatchEvent(new Event('input', { bubbles: true })); }, removeChar: function(id) { var input = this.getInputById(id); if (!input) return; var start = input.selectionStart; var end = input.selectionEnd; if (start === 0 && end === 0) return; var newValue; if (start === end) { // 没有选中文本时,删除光标前一个字符 newValue = input.value.substring(0, start - 1) + input.value.substring(end); start = start - 1; } else { // 有选中文本时,删除整个选区 newValue = input.value.substring(0, start) + input.value.substring(end); } var protoSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value').set; protoSetter.call(input, newValue); input.selectionStart = input.selectionEnd = start; input.dispatchEvent(new Event('input', { bubbles: true })); }, clear: function(id) { var input = this.getInputById(id); if (!input) return; var protoSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value').set; protoSetter.call(input, ''); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); } };5.2 为什么必须用原型上的原生setter
直接写input.value = newValue行不行?对于纯HTML页面来说行,但对于React或Vue这类框架页面来说不行。框架在初始化时会劫持input元素value属性的setter,如果直接赋值,框架内部的虚拟DOM状态没有同步,用户点提交按钮时框架读取到的是自己维护的state,拿到的还是空字符串,导致“密码明明输入了,提交却提示密码为空”。
解决办法就是上面代码里的“偷鸡”方式:从HTMLInputElement.prototype上拿到框架劫持之前的原始setter,用protoSetter.call(input, newValue)赋值。这一步绕过了框架层的属性劫持,让原生setter直接写到DOM底层值上,同时框架内部的value tracker也能感知到值变化(因为最终还是走了一遍原生setter,触发了对应属性变更)。
这个技巧不是我发明的,React社区早就有人总结过,但在WebView注入键盘回填这个场景里,它直接决定方案能不能在SPA页面上跑通。我第一次接测试反馈“回头页面不正常”就是这个原因。
5.3 dispatchEvent触发input事件
只改value还不够。现在很多H5页面在密码框上绑定了input或change事件,用于实时校验密码强度、控制提交按钮置灰状态。如果不触发事件,页面UI会出现两种症状:提交按钮永远置灰、密码强度提示永远不更新。
我在insertChar和removeChar方法末尾都dispatchEvent(new Event('input', {bubbles: true})),bubbles: true必须写,因为有些框架是事件委托,事件冒泡到document才被处理。change事件不用手动触发,因为change是在失焦时才触发的,密码框失焦时blur事件会自然带出change。不过为了保险,我在removeChar里也会触发一下input,删除键如果没触发事件,页面的剩余字符数提示可能错乱。
5.4 焦点与光标维护的几个坑
readonly属性在阻止系统键盘的同时,也带来了一个副作用:某些Android WebView版本中,设置了readonly的input在点击时不会自动获得焦点,导致selectionStart获取不到。这个问题的表现是:键盘弹出来了,但输入的字符都跑到输入框最末尾,而不是光标位置。
解决办法是在绑定focus事件时,在setTimeout里把焦点强制拉回来:
input.focus(); setTimeout(function() { input.focus(); try { input.setSelectionRange(input.value.length, input.value.length); } catch (e) {} }, 0);另外,原生键盘上的删除键逻辑需要同时处理“有选中文本”和“无选中文本”两种情况。上面代码里已经区分了。这里的细节是很多半成品安全键盘会忽略的——用户长按数字想选中一段文本时,如果键盘不支持删除选中区,体验会非常割裂。
6. 动态页面与极端ROM下的兼容性实战
6.1 键盘弹出导致WebView重排的连锁反应
键盘的弹入弹出会让WebView高度发生变化,H5页面内部通常会对window.resize事件做响应,比如把底部按钮顶上来或者调整滚动条位置。在测试中我们发现,部分页面的密码输入框在键盘弹出后会向上滚动一段距离,但原生的键盘View是贴在Window底部的,两者叠加后,密码框反而被键盘挡住了。
这个问题的根源是:键盘View使用WindowManager直接添加时,不影响WebView的高度;但如果用FrameLayout在Activity布局里addView,WebView的高度可能会自适应变化。当时我采用的方案是:无论系统键盘是否弹出,都固定WebView的布局高度不动,键盘View叠加在WebView之上,两者互不干预。H5页面感知不到键盘的存在,视觉上不会出现页面跳动。
具体做法是:用addContentView把键盘View添加到Window的根布局,挂载时记录WebView原有高度,键盘弹出时不让根布局重算高度。
6.2 首次点击二次隐藏的尴尬:系统键盘的延迟显示
隐藏系统输入法时有一个常见问题:hideSoftInputFromWindow在onFocus回调里调用,有时隐藏不彻底,系统键盘闪一下又弹回来了。原因是WebView内核自己的焦点请求是异步的,隐藏动作被后来的焦点请求覆盖。
我的处理办法是在onInputFocus里延迟两次执行隐藏,间隔分别是50ms和150ms:
public void onInputFocus(String inputId, int x, int y) { currentInputId = inputId; showSecureKeyboard(); for (int delay : new int[]{50, 150}) { webView.postDelayed(() -> hideSoftInput(), delay); } }第一次隐藏能抢在系统键盘完全展开前按下去,第二次兜底防止WebView重新唤起。这个“postDelayed双次隐藏”方案在小米、华为、三星等主流机型都验证过,覆盖面够广。
如果遇上某些定制ROM(比如部分OPPO/vivo),两次隐藏仍然效果不佳,还有一个杀手锏:在注入脚本里,对密码框的touchend事件调用preventDefault。这样WebView不会收到点击事件,自然不会触发自身的焦点请求,系统键盘就没有机会弹出来。但这种方法会同时阻止页面的点击聚焦逻辑,需要额外在touchend结束后手动调用input.focus(),否则光标无法闪烁。
6.3 SPA路由切换导致的焦点残留
单页应用路由切换时,document可能整体不刷新,但原密码框所在DOM节点被移除了。此时原生层记录的currentInputId还指向一个不存在的节点,键盘弹出后,用户点击数字键,JS侧getInputById找不到元素,所有输入静默丢失。
这个问题的表象比真实情况要隐蔽——键盘正常弹出、按键有声音反馈、密码框看起来也有焦点,但输入就是不进表单。排查了一整天才确认是路由切换后旧input被卸载,新input虽然出现了但focus事件还没来得及触发。
解决方案是双管齐下:一是在JS侧监听popstate和pushState包装,路由发生变化时主动通知原生层清除焦点记录:
['pushState', 'replaceState'].forEach(function(method) { var original = history[method]; history[method] = function() { original.apply(this, arguments); if (window.SecureKeyboardBridge && window.SecureKeyboardBridge.onPageRouteChange) { window.SecureKeyboardBridge.onPageRouteChange(); } }; }); window.addEventListener('popstate', function() { if (window.SecureKeyboardBridge && window.SecureKeyboardBridge.onPageRouteChange) { window.SecureKeyboardBridge.onPageRouteChange(); } });二是在getInputById找不到节点时,原生层自动隐藏键盘并清空currentInputId,避免后续点击空转。
6.4 多个密码框切换时的脏状态
一个H5页面上可能出现两个密码框,“输入密码”和“确认密码”。简单实现下,如果用户在第二个密码框上点击时,键盘还停留在第一个密码框的回填ID上,会导致两个框的内容串掉。原因在于原生层只存了一个currentInputId。
第二个密码框的focus事件触发后,JS桥接会更新currentInputId为新的ID。这个逻辑本身没问题,但有一个时序陷阱:如果用户从一个密码框直接切换到另一个密码框,旧input的blur事件和新input的focus事件几乎是同时触发的,原生层可能先收到onInputBlur隐藏键盘,再收到onInputFocus重新显示键盘,造成键盘闪烁。
我在onInputBlur里加了一个判断:如果500ms内收到了新的onInputFocus,就不隐藏键盘,直接更新currentInputId。这个“防抖”策略让键盘在密码框之间切换时保持稳定,用户体验顺畅很多。
6.5 内存与性能:注入脚本别铺太大
最后提一下性能。注入到H5页面的JS代码要克制,不要塞进一个完整的jQuery,三五十行搞定核心逻辑就够了。整套方案跑下来,注入脚本执行耗时应该控制在10ms以内。如果页面本身有几千个节点,querySelectorAll遍历会稍微慢一点,但要主动加input.__secureKeyboardHooked__标记避免重复遍历。
原生键盘View的创建也要懒加载。不要每次弹出都重新inflater布局,而是在键盘第一次弹出时创建一次,后续通过setVisibility(VISIBLE/GONE)控制显隐。这样键盘弹出速度能控制在100ms左右,用户几乎感知不到延迟。
结尾:一点实际操作中的个人体会
这套方案落地到现在跑了快半年,线上反馈的键盘相关bug一只手数得过来。回头再看,整个项目的核心难点其实不在键盘UI,而在“JS和原生两个世界之间的状态同步”上。无论焦点、光标、DOM节点还是框架状态,任何一个环节脱节,用户体验都会崩掉。
我自己的习惯是:每次交互都在原生侧打一条点日志——焦点ID是几、按键是几、插入后光标在哪——配合WebView的evaluateJavascript回调,能快速定位问题出在JS层还是原生层。这类侵入式的监控代码,排查多密码框切换问题时帮了大忙。
如果你想在这个方案上继续扩展,有几个方向可以参考:把键盘的随机范围从数字扩展到字母和特殊符号;在键盘底层接入更多生物识别方案(比如指纹确认后自动填充);把整套注入逻辑封装成一个WebView的扩展库,供多个业务方复用。安全键盘这件事永远没有终点,攻防双方都在进步,但只要思路对,后续迭代的路子就很顺。