ARTICLE DETAIL

资讯详情

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

第三方H5页面自定义随机安全键盘:WebView注入与原生键盘叠层实战

第三方H5页面自定义随机安全键盘:WebView注入与原生键盘叠层实战 1. 第三方H5页面里的密码框到底藏着什么风险先还原一个真实场景。你手上有一个银行系或证券系的App里面嵌了不少合作方的H5页面——比如贷款申请页、开户资料页、积分兑换页。这些页面是第三方团队维护的你们拿不到源码改不了里面的input标签但监管和用户都把“密码安全”这笔账算在App头上。问题就出在密码框本身。系统输入法上屏时会经过输入法进程第三方输入法完全有能力记录键盘事件就算用户装了系统输入法Android的输入法框架本身也存在被hook的风险。更重要的是系统键盘没有随机性按键位置固定被偷窥、录屏、屏幕共享时等于把密码直接送出去。这两年金融类App上安全键盘几乎是标配但第三方H5页面往往来不及改造或者根本不愿意配合改造只能由宿主App兜底。我当时接手这个需求时业务方给的约束很明确H5页面不能动只能从WebView这一层想办法密码框是标准的input typepassword可能有多个可能是动态渲染出来的键盘必须每次弹出时随机排列数字。这就是标题里“第三方H5页面”和“自定义随机键盘”两个关键词的由来。这篇文章把我实际做这套方案时的选型逻辑、注入脚本、原生键盘层、值回填机制和兼容性坑逐一讲清楚。不论你用的是原生Android WebView还是uni-app、Flutter里封装的WebView容器核心思路都能迁移。有些坑是文档里查不到的我尽量把排查过程和背后原因说透。2. 两条技术路线对比纯JS键盘 vs 原生键盘叠层需求确定后第一个问题是键盘做在哪一层。当时我评估了两条路线。路线A是纯JS方案在H5页面里注入一套HTMLCSS的随机键盘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本身就是一个单页应用SPAonPageFinished可能在DOM完全就绪前就回调了也可能在一次完整的页面生命周期里被触发多次比如页面内部发生了hash路由变化。我采用的策略是在onPageFinished里先执行幂等注入。所谓幂等就是脚本通过一个全局标记保证只生效一次比如在window对象上挂一个window.__secureKeyboardInjected__属性注入脚本开头先检查这个标记已经存在就直接跳过避免同一个页面上重复绑定focus监听导致原生键盘弹出两次。这是注入脚本的骨架(function() { if (window.__secureKeyboardInjected__) { return; } window.__secureKeyboardInjected__ true; // ...绑定逻辑 })();3.2 密码框的识别与focus/blur事件绑定识别密码框最直接的方式是input[typepassword]。但现实项目中会遇到几种变体有的H5为了自定义样式会把type设成text再通过CSS样式把字符遮住有的会用input事件自己拼掩码。如果只认typepassword这些变体密码框就漏了。考虑到这次需求明确限定是“文本密码框”即标准typepassword我先把标准场景做扎实。但代码里可以预留扩展点把选择器统一收口到一个数组里var PASSWORD_SELECTORS input[typepassword];以后遇到变体只需要往这个数组里加规则。绑定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里。原生层后续回填值时只需要传这个IDJS侧通过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_SECUREgetActivity().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布局里addViewWebView的高度可能会自适应变化。当时我采用的方案是无论系统键盘是否弹出都固定WebView的布局高度不动键盘View叠加在WebView之上两者互不干预。H5页面感知不到键盘的存在视觉上不会出现页面跳动。具体做法是用addContentView把键盘View添加到Window的根布局挂载时记录WebView原有高度键盘弹出时不让根布局重算高度。6.2 首次点击二次隐藏的尴尬系统键盘的延迟显示隐藏系统输入法时有一个常见问题hideSoftInputFromWindow在onFocus回调里调用有时隐藏不彻底系统键盘闪一下又弹回来了。原因是WebView内核自己的焦点请求是异步的隐藏动作被后来的焦点请求覆盖。我的处理办法是在onInputFocus里延迟两次执行隐藏间隔分别是50ms和150mspublic 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的扩展库供多个业务方复用。安全键盘这件事永远没有终点攻防双方都在进步但只要思路对后续迭代的路子就很顺。
返回列表