长期以来,在App里嵌套WebView加载第三方H5页面,都是移动端开发里绕不开的环节。页面是别人家的,需求却是自家提的,一旦涉及密码输入这类敏感场景,问题就来了:H5那边用的是type=password的明文输入框,不管系统键盘怎么换,输入法供应商、截屏、录屏、甚至是输入法自带的云词库,都可能把用户密码悄悄送走。标题里这个“自定义随机键盘”的需求,我第一反应是“能不能改H5源码”,答案当然是改不了,三方页面谁给你改。也就是在这种前提下,才逼出了用原生WebView注入自定义键盘的整套方案。
这篇文章我会从需求拆解说起,把密码框定位、键盘随机化设计、原生与JS互调的坑、以及防截屏防输入法弹出这些细节全部过一遍。适合正在做金融、政企、内部业务系统内嵌H5的Android开发,或者想给WebView加一层安全输入屏障的同行参考。整套思路跨Android和JS两端,核心逻辑也能移植到iOS,但我实测和踩坑主要集中在Android侧,所以下文默认以Android WebView为主。
1. 项目拆解:三方H5密码框的安全输入困局
1.1 WebView加载三方H5,到底“困”在哪里
先还原一下典型场景:应用里有一个WebView容器,URL指向的是外部服务商提供的H5页面,可能是登录页、绑卡页、实名认证页,上面有一个再普通不过的HTML密码框:
<input type="password" name="password" placeholder="请输入密码" />问题在于,这个input是H5页面自己写的,App作为宿主只能看到WebView渲染出来的内容,无法直接修改它的业务逻辑。你没法要求对方为了你一个渠道客户改一套自定义键盘,也不适合在WebView外面套一层原生输入框去模拟,逻辑太重,页面联动容易出问题。
安全风险反而非常具体:系统输入法在输入密码时,虽然系统层面会拦截一部分泄露路径,但第三方输入法在部分机型上仍然可以截获按键;应用切换到后台时,系统默认的截图行为也可能把包含密码的页面快照记录到最近任务列表里;更别说用户自己随手截屏,密码直接曝光。金融类业务的合规审核遇到这种裸奔式密码框,十有八九会被打回。
所以这个需求的核心关键词是“对第三方页面”和“自定义随机键盘”:页面我们改不了,但可以在加载过程中注入代码,把密码框的输入行为接管过来,把每次弹出的键位顺序打乱,让输入法供应商和截屏录屏都无从下手。
1.2 方案选型:为什么是JS注入 + 原生浮层键盘
要实现自定义随机键盘,业内通常有三条路,我按实际落地难度排个序:
- 改H5源码,直接用H5版自定义键盘。最干净,但三方页面没有任何配合空间,直接排除。
- 在WebView上叠加一个原生自定义键盘View,输入时隐藏系统输入法,键盘完全由原生绘制。这是最终采用的主方案,稳定性最高,键盘渲染不受H5页面样式干扰,随机打乱键位在Java/Kotlin层做,逻辑简单可控。
- 在H5里用纯JS模拟键盘层,完全脱离原生View。跨端复用性有优势,但键盘容易被页面样式覆盖,渲染性能不如原生View,而且iOS和Android对WebView内层浮层支持不一致,兼容成本高。
三条路里,只有第二条能同时满足“不改第三方页面”和“键位可随机”两个硬性条件。整体架构拆成两半:
- 安卓端负责:注入JS、提供原生随机键盘View、JSBridge通信、防截屏标记、拦截系统输入法。
- H5侧负责:检测密码框、监听焦点事件、暴露密码框输入接口、把原生键盘按键写回输入框。
至于苹果端思路类似,只是键盘浮层和注入API有差异,本文就不展开讲。
2. 第一步:定位H5密码框并接管焦点事件
2.1 注入时机:onPageFinished只是起点
干这行的都知道,WebView加载完页面后有个onPageFinished回调,很多人习惯在这里做注入。但三方H5页面通常带异步数据初始化,密码框可能是页面加载后才通过Ajax渲染出来的,甚至某些单页应用要等用户切了几个Tab才出现密码框,你再怎么在onPageFinished里同步注入都没用。
我的做法是注入一段“探针脚本”,在页面加载完成后立刻挂一个MutationObserver监听DOM变化,一旦发现页面新增了input[type=password]节点,就自动绑定事件。同时保留一次全量扫描,双保险。
这段探针脚本通过WebView.evaluateJavascript注入即可,核心逻辑如下:
(function () { function installPasswordListener() { var inputs = document.querySelectorAll('input[type="password"]'); for (var i = 0; i < inputs.length; i++) { var input = inputs[i]; if (input.getAttribute('data-custom-kb-bound')) continue; input.setAttribute('data-custom-kb-bound', 'true'); bindPasswordEvents(input); } } function bindPasswordEvents(input) { // 原生键盘打开时,需要禁止系统输入法弹出 input.addEventListener('focus', function () { // 通知原生层:密码框聚焦了 if (window.AndroidBridge) { window.AndroidBridge.onPasswordFocus(input.dataset ? input.dataset.index : ''); } }); input.addEventListener('blur', function () { if (window.AndroidBridge) { window.AndroidBridge.onPasswordBlur(); } }); } installPasswordListener(); var observer = new MutationObserver(function (mutations) { installPasswordListener(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); })();2.2 关键技巧:给密码框标记索引,备好回写通道
上面的脚本里,我给每个密码框打了一个data-index标记。为什么要打这个标记?因为页面里可能不止一个密码框,比如旧密码、新密码、确认密码,三个框同时存在,原生键盘弹起后必须知道当前到底是哪个框要接收输入,否则回调写回时就会张冠李戴。
还有一种特殊情况:页面登录态过期后,HTML结构被局部刷新,同一个密码框可能被替换成新节点。MutationObserver虽然能发现新节点,但data-index如果只用自增数字,可能出现新节点覆盖旧索引的情况。稳妥一点的做法是每次注入脚本前先用window.crypto.getRandomValues或时间戳生成一个随机页面会话ID,再拼接密码框索引,例如page_88231_input_1。
同时在Android端,这个索引最终要传到原生键盘的Handler里,键盘每次按键都携带这个索引回传,JS收到后再定位对应输入框,进行插入或删除操作:
public class JsBridge { @JavascriptInterface public void onPasswordFocus(String inputKey) { // 切到主线程,显示自定义随机键盘 runOnUiThread(() -> showRandomKeyboard(inputKey)); } }2.3 阻止焦点触发系统输入法
这是整套方案里最容易翻车的第一步。密码框是H5自己的input,用户一点它,系统输入法默认就会弹出来。自定义随机键盘还没显示,系统键盘先占坑了,用户体验直接崩盘。
阻断系统输入法的手段有几个层次:
- 最底层的做法是在WebView的Activity里设置输入法模式为
SOFT_INPUT_STATE_ALWAYS_HIDDEN,但这只能影响初始化状态,密码框主动聚焦时系统键盘仍然会弹。 - 比较实用的做法是让JS在焦点事件中把密码框暂时设为
readonly,等原生键盘显示后,再移除readonly属性。因为readonly的input不会触发系统键盘,但我们的JS已经捕获了focus事件,完全不受影响。 - 还有一种曲线方案:在WebView外层套一个全透明的原生EditText,焦点事件来到时先把焦点转移到隐藏EditText上,H5密码框的focus节点后续手动通过JS处理。这个方案适合原页面里有大量交互控件的场景,能统一拦截,但实现复杂度更高。
实测最简单可维护的还是“readonly瞬间切换法”。这里有一个非常关键的时序问题:先“让input变成readonly”,再“把input.focus()设置到它本身”,顺序不能反,否则系统输入法还是会弹。具体的JS逻辑我会在下一节的代码里有体现,那个部分也卡过我两天。
3. 第二步:随机键盘的设计与原生实现细节
3.1 键位随机化的算法选择
自定义键盘的核心卖点是“随机”。但随机不是点一下键盘,让26个字母重新洗牌那么简单,要考虑用户肌肉记忆和容错率。我的建议是两种随机模式同时上线:
- 模式A:每次弹出键盘时,对数字0-9和字母A-Z(或常用字符集)整体做一次乱序排列。
- 模式B:在模式A的基础上,从第二次按键开始,允许“短时锁定”当前排列不变,避免每次按一个数字整个键盘都跳来跳去,用户输错概率暴增。
具体洗牌算法,我直接用Java的Collections.shuffle,简单可靠。如果追求“不可预测性”更高,可以用SecureRandom替代默认随机源:
List<Character> keys = new ArrayList<>(); // 添加0-9和指定字符集 for (char c = '0'; c <= '9'; c++) keys.add(c); for (char c = 'A'; c <= 'Z'; c++) keys.add(c); Collections.shuffle(keys, new SecureRandom()); renderKeyboard(keys);3.2 原生浮层键盘的布局实现
前面说了,键盘用原生View实现。最方便维护的形态是用一个Dialog,设置成全屏或按屏幕高度比例定位,背景透明,只在底部弹出键盘区域,这样键盘不会受H5页面样式和margin影响。
键盘本身用GridLayout或RecyclerView都行。我推荐第一版用GridLayout,按键少于30个,手工控制行列间距最直观,不需要引入太多依赖。按键数据绑定到上面list里的字符,动态创建TextView,设置统一高度,点击事件回调给外层管理类。
需要特别注意,WebView页面可能有横竖屏切换,或者软键盘弹出后adjustResize行为导致WebView高度变化。键盘Dialog外加一个全屏透明Window,把Window的setSoftInputMode设置成SOFT_INPUT_STATE_ALWAYS_HIDDEN,可以彻底阻止页面resize造成的布局闪烁。
3.3 防截屏与防录屏
自定义键盘弹出期间,页面里出现的所有内容,包括密码框的值(即使显示为圆点),都不能被屏幕截图或录屏捕捉到。Android端最直接的手段是在当前Activity的Window上设置FLAG_SECURE:
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);设置了FLAG_SECURE后,系统会禁止用户截图,第三方录屏App也无法捕获内容。缺点也很明显:这个flag是整个Activity级别的,不光是密码框区域,用户可能本来想在App里正常截个图,结果发现整个页面都截不了。如果业务只要求密码输入那一小段时间防截屏,可以在密码框focus时动态加上flag,blur后再移除。
实际项目里,如果页面同时有视频播放或图片展示,全局加FLAG_SECURE容易引入线上客诉,能做成局部动态加flag就做成动态的。
3.4 输入内容的过程管理
自定义键盘不持有真正的密码值,这一点很重要。密码框的值始终在H5页面的DOM里,我们的键盘只是“远程控制输入”,不能在原生层维护一个String再写回,否则内存和日志里的明文泄露风险更大。
键盘每按一个键,原生层通过JSBridge调用JS侧方法:
public void onKeyClick(String inputKey, char keyChar, int action) { if (action == ACTION_INSERT) { webView.evaluateJavascript( "window.insertPasswordChar('" + inputKey + "', '" + escapeJs(keyChar) + "');", null ); } else { // 删除或清空 webView.evaluateJavascript( "window.deletePasswordChar('" + inputKey + "');", null ); } }JS侧对应维护当前光标位置和密码框对象,执行插入或删除后,再把光标移回正确位置。很多基础实现只会把输入的内容拼在末尾,但用户可能会中途点击密码框中间来修改密码,光标位置不能丢。
4. 第三步:JSBridge与H5回写的细节
4.1 插入字符:必须触发原生input事件
H5页面里的前端框架(比如Vue、React)通常通过监听input事件来同步表单数据。如果只用element.value = element.value + 'x',框架不会感知到值变化,最后提交时密码字段就是空字符串,或者还是旧值。
所以每次我们JS侧修改密码框的Value后,必须手动触发一个InputEvent,而且要注意兼容性。现在移动端WebView内核基本都是Chromium系,直接构造InputEvent没太多问题:
function dispatchInputEvent(el) { var event = new InputEvent('input', { bubbles: true, inputType: 'insertText', data: el.value, }); el.dispatchEvent(event); }但有些老旧的内核版本对InputEvent构造器支持不全,一旦抛异常,后面的值同步逻辑就断了。稳妥做法是try-catch后再补一个Event作为fallback:
try { el.dispatchEvent(new InputEvent('input', { bubbles: true })); } catch (e) { el.dispatchEvent(new Event('input', { bubbles: true })); }4.2 光标位置管理
第三方H5页面里的密码框,用户用原生系统键盘输入时可以自由移动光标。自定义键盘浮层没有原生光标拖动能力,所以必须在JS侧模拟一整套光标管理逻辑。
我的做法比较简单粗暴但靠谱:每次密码框获得焦点,立刻记录selectionStart和selectionEnd,原生键盘按键到来时:
- 插入:从
selectionStart处分割原值,插入新字符,再计算新的光标位置。 - 删除:优先删除选中区域;没有选中区域时,删除光标前一个字符。
做完操作后,重新设置el.focus(),再用setSelectionRange把光标放回正确位置。这里一定注意,设置selectionStart前必须保证元素是focus状态,否则在部分WebView内核里会静默失败。
4.3 清空和退格:要区分“删一个”和“全清”
键盘上一般至少要有“退格”和“清空”两个功能键。退格对应删除当前光标前一个字符,清空理论上直接把el.value = ''即可。但不少三方H5在清空时仍然希望触发一次input事件,否则清除按钮的红色状态不会消失。所以清空动作也要走一遍dispatchInputEvent。
另外提醒一个细节,WebView的JS调用是异步的,连续快速点击退格时,JS侧还没处理完上一次删除,下一次删除指令又到了。建议在JSBridge调用侧加一个轻量级队列,将键盘操作先顺序缓存,JS的promise回调完毕后再取下一个操作,避免丢字符或者乱序。
4.4 键盘显示与隐藏的联动
密码框blur时,自定义键盘必须隐藏。但H5里面的blur触发有自己的节奏,有时候用户点击页面上非输入区域,blur先触发,键盘随即消失,这个没问题;麻烦的是用户点击密码框内部想重新定位光标,在input上只算再次focus,不算blur,键盘状态还能保持。
比较理想的联动方式是:JS把focus和blur都通知原生层,原生层在收到focus时show键盘,收到blur时hide键盘。如果WebView页面里有多个密码框,从一个框跳到另一个框,可能先触发旧框blur、再触发新框focus,中间键盘闪一下。这种情况可以在原生层加一个300毫秒的延时隐藏,如果在延时期间收到新的focus,直接取消隐藏。
5. 常见问题与踩坑实录
5.1 键盘弹起后WebView高度被压缩
这是WebView嵌套键盘类需求里最经典的坑。WebView所在Activity如果设置了adjustResize,系统输入法弹出时WebView高度会自动压缩,但我们的自定义键盘采用的是Dialog浮层,不需要压缩WebView高度。为了避免页面被无意义resize,建议Activity的windowSoftInputMode设置成adjustNothing,反正系统输入法全程不会真的弹出,WebView保持原高度最合适。
5.2 evaluateJavascript调用时机太早
有时候onPageFinished触发,但页面本身还处于SPA路由切换过程中,DOM并不完全稳定。此时注入的探针脚本可能因为全局变量冲突或者document状态不对而执行失败。
我建议在注入脚本前先判断一下页面是否已经准备好:
webView.evaluateJavascript("(function(){ if (document.readyState === 'complete') { ... } else { window.addEventListener('load', ...) } })()", null);同时给注入代码外面包一层try-catch,把错误通过onConsoleMessage回调暴露出来。这样即使页面有问题,也能在Android端日志里看到“Injection failed: xxx”,快速定位是注入语法问题还是页面兼容问题。
5.3 iframe内嵌的密码框
第三方H5页面为了追踪风控或嵌入子应用,经常用iframe引入登录组件。主文档的DOM查询查不到iframe内部的input,必须显式进入iframe的contentDocument中遍历。但跨域iframe的contentDocument会被浏览器拦截,这种情况没有完美的纯前端方案,只能要求对方页面允许同域访问,或双方约定好postMessage通信。
如果平台方愿意配合,推荐在iframe内再注入一段相同探针脚本,通过postMessage把focus事件上报给外层。如果不配合,那就只能退回普通系统键盘方案,这也是现实世界经常要做的取舍。
5.4 密码框字体和圆点显示问题
自定义键盘输入回显到密码框后,密码框默认会用圆点或星号掩盖。不同WebView内核处理密码字体样式不一样,部分安卓机型上密码框会显示成超大号圆点,布局被顶乱。
解决方式:注入一段自定义CSS,强制密码框的字体大小和行高:
input[type="password"] { font-size: 16px !important; line-height: normal !important; -webkit-text-security: disc; }注意,-webkit-text-security这个属性在部分内核上是可覆盖的,真机测试时多看几台机器,以实际渲染为准。
5.5 随机键盘点击后,H5密码框自动填充账号
很多三方H5页面接入了浏览器的自动填充服务,或者前端框架自身有记住密码的插件。当我们的自定义键盘向页面设置value时,自动填充逻辑可能把整个账号密码一起填进去,把用户原本输入的内容冲掉。
这个问题的根源在于浏览器判定焦点input和自动填充条件匹配。目前比较有效的规避办法是:修改输入内容时不直接使用el.value赋值,而是通过document.execCommand('insertText', false, char),execCommand方式在某些内核里可以绕过自动填充的判断。如果无效,只能通过给input加个autocomplete="off"属性,虽然第三方页面不让我们改,但我们可以注入啊,注入代码里顺手加上:
input.setAttribute('autocomplete', 'off');效果不能说100%,但实测下来能减少至少一半的误触发。
5.6 快速连续输入丢字
之前提到的JSBridge异步队列,其实很多人第一版不会做,按下键盘按键后直接evaluateJavascript,结果用户手速一快,字符就丢。原理很简单,evaluateJavascript是异步的,JS侧拿到第一个字符插入到密码框,第二个字符拿到的可能是密码框更新前的旧value,于是两个字符互相覆盖。
解决办法有两个方向:
- 原生侧串行化:在Java层维护一个队列,上一个Javascript回调执行完再发下一个。
- JS侧串行化:所有插入操作放进一个Promise链,每个操作完成后resolve,再执行下一个。
我实际项目中用的是后者,因为它不用改原生层逻辑,原生只管往管道里丢操作,JS自己排队处理。核心伪代码如下:
let pendingOps = Promise.resolve(); function enqueueInsert(inputKey, ch) { pendingOps = pendingOps.then(() => { return new Promise(resolve => { insertCharInternal(inputKey, ch); // 延迟到下一帧执行,确保DOM已更新 requestAnimationFrame(resolve); }); }); }5.7 键盘随机排列每次都不一样,但用户觉得“晕”
这里有一个体验与安全的平衡问题。纯安全角度,每次弹出全随机排列最好;但用户输密码时习惯性找某个字母,结果这次排序完全变了,操作效率骤降,误触概率升高。
我建议做一个三级策略:
- 数字键:每次都是随机排列,因为密码长度短,数字盲打准确率高。
- 字母键:第一次弹出时全随机,用户按下第一个字母后,后续键位保持不变,直到本次输入结束。
- 功能键(退格、清空、确认):永远固定在两侧,不参与随机。
这样既不牺牲太多安全性,又保证了基本操作效率。核心代码改造也就几句话:
if (this.currentKeySequence != null && !this.needReshuffle) { // 沿用当前序列 } else { this.currentKeySequence = randomizeChars(); this.needReshuffle = false; }5.8 Android 7以下低版本的兼容性
现在虽然Android版本普遍偏高,但还是有部分政企定制设备停留在Android 5-6之间。这些老版本WebView不支持evaluateJavascript的部分新特性,InputEvent构造器也可能报错。在做兼容方案时,我发现最简单的方法是接入Android系统WebView的onRenderProcessGone回调,提前捕获页面渲染进程崩溃。不过跟本文主题关系不大,不多展开。
只提醒一点:低版本WebView不支持ES6语法,注入的JS代码如果用了箭头函数、模板字符串,页面会直接抛SyntaxError,导致后续所有逻辑失效。最佳实践是注入代码用ES5写,或者用Babel编译之后再注入。
6. 整体落地效果与扩展思路
到这里,整套方案的主体已经可以落地:WebView加载三方H5页面,注入探针脚本捕获密码框focus,拉起原生自定义随机键盘,键盘通过JSBridge回写字符,同时防截屏、防系统输入法、防自动填充,全程不需要对方H5改动一行代码。
我在内部测试中验证过几个关键指标:键盘随机序列启动耗时约30毫秒,按键到字符回显的链路耗时稳定在10毫秒以内,连续快速输入100个字符没有丢字;密码框在页面刷新、路由切换、多密码框切换等场景下均能保持正确关联。
唯一需要业务方拍板的是安全策略的“严格程度”,比如是否允许密码可见(部分场景需要用户确认输入内容),是否允许截图记录,键盘随机频率是多高。这些策略配置我建议都做成可远程下发的开关,不同渠道包用不同配置,也算是给后续风控留接口。
如果你打算在一个已经上线的App里临时加上这个能力,我特别建议先做内部灰度,重点观察两个数据:一是用户从聚焦到键盘弹出的耗时,二是用户误触导致的密码输入错误率。这两项直接决定了方案能不能从“技术演示”变成“真的要上线的功能”。另外,如果团队里同时维护iOS端,Android这边的JS注入脚本和JSBridge协议可以完全复用,iOS端只要对应实现一套WKScriptMessageHandler即可,整体工作量不会翻倍。
最后留一个问题给看到这里的同行:如果密码框不是原生HTML input,而是canvas绘制的模拟输入界面,这套注入方案就彻底失效了,那种场景只能靠OCR识别或无障碍服务去读取,风险和成本都会高一个数量级。所以做安全输入方案前,先看清页面密码框的真实实现,比急着写代码更重要。