1. 安卓 WebView 聊天输入框点击发送后键盘收回的真实场景
如果你在做安卓端 H5 或 WebView 聊天页面,大概率遇到过这个现象:用户正在textarea里打字,软键盘弹得好好的,手指一点右侧的「发送」按钮,消息是发出去了,但软键盘「唰」地一下收回去,输入框也失去了焦点。用户想接着发第二条,还得再点一次输入框把键盘重新唤起来。连续聊天时这个动作要重复几十次,体验非常割裂。
这个问题的本质不是「键盘有 bug」,而是焦点转移。在安卓的输入法框架里,软键盘的显示与隐藏是跟着输入焦点走的:只要当前页面存在一个处于聚焦状态的可编辑元素(input、textarea、contenteditable),输入法就会保持弹出;一旦焦点被移走、或者聚焦元素被隐藏/移除,系统就会认为「用户不再需要输入」,主动收起键盘。而「发送」按钮如果是一个普通的button或div,点击时浏览器会把焦点从textarea转移到这个按钮上,textarea触发blur,键盘随之收起。
所以「键盘常驻」的核心思路就一句话:让可编辑元素在发送动作前后始终保持聚焦。围绕这个思路,业界常见的做法有三类。第一类是「焦点回填」,发送后立刻调用focus()把焦点抢回输入框;第二类是「双输入框接力」,用一个隐藏的input作为焦点锚点,在发送瞬间把焦点在两者之间切换,骗过系统的收起逻辑;第三类是「阻止默认失焦」,在按钮的touchstart/mousedown阶段就preventDefault,让焦点压根不离开输入框。
这三类方案各有适用场景。纯 H5 页面用第一类和第三类就够;uni-app、小程序这类跨端框架因为渲染层和逻辑层分离,focus()的时机不好控制,往往需要第二类「双输入框」兜底。下面我会把三类方案都拆开讲,并给出可以直接复制的配置和验证步骤。如果你在调这些交互的同时还要接大模型对话能力,输入框的焦点管理和接口调用其实是两条并行的线,接口那侧可以用 TaoToken 这类聚合网关来统一管理模型调用,把精力集中在交互细节上。
需要先明确一个前提:安卓机型碎片化严重,华为、小米、OPPO、vivo 对输入法的处理策略都不一样,同一套代码在不同机器上表现可能不同。所以本文给的方案不是「一次配置永久生效」,而是给你一套可验证、可微调的排查方法。你要做的是理解原理,然后在自己的目标机型上跑验证。
2. TaoToken 接入前置:Base URL、API Key 与 Model ID 三件套
在动手改焦点逻辑之前,先把模型调用这条链路理顺,否则你调好了键盘常驻,发送时接口报 401,还是白搭。TaoToken 的接入方式和主流 OpenAI 兼容接口一致,核心就是三件套:Base URL、API Key、Model ID。这三样配齐,任何支持自定义端点的客户端或 SDK 都能直接连。
Base URL 填https://taotoken.net/api,注意这里不要带任何多余路径,很多 401 和 404 都是因为把/v1重复拼了两次。API Key 需要你登录后在控制台生成,路径是 API Keys 页面,生成后复制那一串sk-开头的字符串,只显示一次,务必存好。Model ID 则取决于你要调哪个模型,比如对话场景常用的通用模型 ID,具体以文档里的模型列表为准。
如果你用的是 Claude Code 这类命令行编码工具,配置方式略有不同,它读的是环境变量或者settings.json。下面给一份可直接复制的settings.json片段,路径放在项目根目录的.claude/settings.json,或者用户级的~/.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的Key粘贴在这里", "ANTHROPIC_MODEL": "你的ModelID" } }如果你用的是 Codex 这类工具,它读的是auth.json,通常位于~/.codex/auth.json,结构如下:
{ "OPENAI_API_KEY": "sk-你的Key粘贴在这里", "OPENAI_BASE_URL": "https://taotoken.net/api" }Model ID 在 Codex 里一般通过启动参数或配置文件指定,具体字段名以你所用版本为准。这里要提醒一句:Base URL、Key、Model ID 三者必须来自同一个来源,不要 Base URL 填 TaoToken、Key 却用了别处的,那样必然 401。
对于前端 H5 聊天页面,你通常不会把 Key 写在前端代码里,而是让前端请求你自己的后端,后端再用 Key 去调 TaoToken。这样既安全,也方便你做限流和日志。前端要做的只是把用户输入发给你自己的接口,接口返回模型回复后渲染到消息列表。焦点管理属于纯前端交互,和这层调用是解耦的。
配置完成后,建议先用一个最简单的请求验证链路通不通,再回去调键盘。验证命令用 curl 即可:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "你的ModelID", "messages": [{"role": "user", "content": "你好"}] }'如果返回里能看到choices数组和正常的回复内容,说明三件套配置正确。如果报 401,先检查 Key 有没有复制完整、有没有多余空格;如果报模型不存在,检查 Model ID 拼写。这一步过了,再进入下面的焦点配置。
3. 可复制的焦点保持配置:双输入框接力与 focus 回填
现在进入正题。我把三种方案按「改动量从小到大」排列,你可以先试最简单的,不行再往上加。
方案一:发送后 focus 回填。这是最直观的做法。在发送按钮的点击回调里,消息发送逻辑执行完之后,立刻调用输入框的focus()。原生 H5 里大概是这样:
const textarea = document.querySelector('#chat-input'); const sendBtn = document.querySelector('#send-btn'); sendBtn.addEventListener('click', async () => { const text = textarea.value.trim(); if (!text) return; await sendMessage(text); textarea.value = ''; textarea.focus(); });关键点是focus()要放在await之后,确保发送逻辑不会阻塞焦点回填。但实测下来,这个方案在部分安卓机型上会「闪一下」——键盘先收起再弹出,视觉上有跳动。原因是点击按钮时blur已经先触发了,focus()只是补救。
方案二:阻止默认失焦。在按钮上监听touchstart或mousedown,调用preventDefault(),让焦点压根不离开输入框:
sendBtn.addEventListener('touchstart', (e) => { e.preventDefault(); handleSend(); }, { passive: false });注意passive: false必须加,否则preventDefault不生效。这个方案能消除键盘跳动,但会带来一个新问题:按钮的click事件可能不触发了,所以发送逻辑要挪到touchstart里。另外preventDefault会阻止按钮的按压态样式,需要自己用 CSS 补一个:active效果。
方案三:双输入框接力(uni-app 场景推荐)。这是 excerpt 里那套思路的通用化版本。核心是准备两个可聚焦元素:一个是真正给用户打字的textarea,另一个是「发送按钮」本身用一个input来实现(maxlength="0",视觉上伪装成按钮)。当用户点击这个「input 按钮」时,它先获得焦点,触发focus事件,在事件里立刻把焦点转回textarea,同时执行发送。这样焦点始终在两个输入框之间流转,系统认为「页面一直有输入需求」,键盘就不会收。
用原生 H5 复刻这个逻辑:
<textarea id="chat-input" placeholder="说点什么"></textarea> <input id="send-btn" type="text" maxlength="0" readonly placeholder="发送" />const input = document.querySelector('#chat-input'); const sendBtn = document.querySelector('#send-btn'); let keepFocus = false; sendBtn.addEventListener('focus', () => { keepFocus = true; input.focus(); handleSend(); }); input.addEventListener('blur', () => { if (keepFocus) { input.focus(); } });这里的keepFocus是个开关,防止blur里无限递归。发送完成后把keepFocus置回false,让用户主动点击别处时能正常失焦。
如果你在 uni-app 里,配置要写成:focus绑定变量的形式,和 excerpt 里的结构类似:
<textarea class="input-text" auto-height :focus="softfocus" maxlength="120" @blur="softblur" @focus="msgfocus" v-model="inputValue" /> <input class="send-btn" v-show="sendflags" maxlength="0" @blur="sendblur" @focus="sendfocus" @tap="sendMessage" type="text" placeholder="发送" />export default { data() { return { softfocus: false, sendflags: true, inputValue: '' }; }, methods: { sendfocus() { this.sendblur(); }, sendblur() { this.softfocus = true; }, sendMessage() { if (!this.inputValue.trim()) return; // 这里调用你的发送接口 this.inputValue = ''; this.softfocus = true; } } };CSS 上,那个伪装成按钮的input要把光标和文字颜色设成透明,避免用户看到闪烁的光标:
.send-btn { position: absolute; right: 3%; top: 30upx; outline: none; caret-color: rgba(0, 0, 0, 0); color: transparent; height: 68upx; line-height: 68upx; text-align: center; background-image: linear-gradient(#387aff, #1440e6); border-radius: 6px; font-size: 28upx; }这套配置的要点是:softfocus变量控制textarea的聚焦状态,sendfocus里调用sendblur把softfocus置true,形成「点按钮 → 按钮聚焦 → 输入框重新聚焦」的闭环。maxlength="0"保证按钮输入框不会真的接收文字。
4. 验证请求与成功结果:键盘常驻不闪退的实测动作
配置写完,怎么确认它真的生效了?不能只看「感觉键盘没收回」,要有可复现的验证动作。我一般按下面四步走。
第一步:确认焦点状态。在textarea的focus和blur事件里打日志,观察点击发送按钮时blur有没有被触发。如果方案正确,blur不应该在发送瞬间触发,或者触发了但立刻被focus拉回来。日志大概长这样:
input.addEventListener('focus', () => console.log('[focus] textarea 获得焦点')); input.addEventListener('blur', () => console.log('[blur] textarea 失去焦点'));在 Chrome 的chrome://inspect里连上安卓设备,看控制台输出。理想情况下,点击发送后你只会看到一次focus,看不到blur。
第二步:观察键盘动画。在真机上连续发送 10 条消息,注意键盘有没有「收起再弹出」的跳动。如果键盘全程稳定停留,说明焦点没有中断。如果偶尔跳动,多半是focus()调用时机太晚,把它挪到发送逻辑最前面试试。
第三步:验证输入框可继续输入。发送后不要点任何地方,直接在键盘上继续打字,看字符能不能正常进入textarea。这一步能排除「焦点回来了但输入法没绑定」的情况。
第四步:验证接口链路。发送的消息要能正常拿到模型回复。如果你用 TaoToken 做后端调用,可以在后端日志里确认请求到达、返回choices。前端这边,消息列表要能正确渲染回复内容。一个完整的成功结果应该是:用户打字 → 点发送 → 消息上屏 → 键盘不动 → 模型回复上屏 → 用户继续打字,全程无需二次点击输入框。
如果你在验证时想快速对比不同模型对同一句输入的回复效果,可以打开模型对话页面手动测几条,确认 Model ID 和回复质量符合预期,再回到代码里固定下来。这一步不是必须,但能帮你排除「接口通了但模型选错」的隐性坑。
验证通过后,建议把softfocus的初始值设为false,避免页面一加载就自动弹键盘,那会让用户觉得突兀。只有在用户主动点击输入框之后,才进入「键盘常驻」模式。
5. 本篇常见错排查:401、local proxy failed 与 reading choices 报错
调这套东西时,报错基本集中在两类:一类是焦点逻辑本身的,一类是接口链路的。我按真实遇到的频率排一下。
报错一:401 Unauthorized。这个几乎都是 Key 的问题。检查三处:Key 有没有复制完整(sk-后面那串别漏字符)、有没有多余空格或换行、Base URL 有没有写错。特别注意,如果你在settings.json里同时配了ANTHROPIC_AUTH_TOKEN和ANTHROPIC_API_KEY,有些工具会优先读后者,导致用了旧的 Key。统一只留一个。
报错二:local proxy failed / connection refused。这个通常出现在你本地起了代理服务、但服务没启动或端口不对的情况。检查你的客户端配置里 Base URL 是不是指向了http://localhost:xxxx这类本地地址,如果是,确认本地服务在跑。如果你直接用 TaoToken 的https://taotoken.net/api,就不该出现这个错,出现了说明配置被别的工具覆盖了。
报错三:reading 'choices' of undefined。这是前端解析响应时的经典错误,意思是返回体里没有choices字段。原因通常是:请求根本没成功(返回的是错误对象),或者你解析的层级不对。先console.log整个响应体,确认结构。正常返回是{ choices: [{ message: { content: "..." } }] },如果看到{ error: {...} },那就是接口侧的问题,回到 401 那步排查。
报错四:OAuth 相关错误。如果你用的是 Claude Code 这类带 OAuth 流程的工具,报 OAuth 错说明它没走 API Key 模式,而是尝试走账号授权。检查配置里是不是混入了 OAuth 相关字段,把它们删掉,只保留 Base URL 和 Token。
报错五:键盘还是收回。如果接口都通了,键盘依然收,按这个顺序查:preventDefault有没有加passive: false;focus()是不是在异步逻辑之后才调用;textarea有没有被v-show或display: none隐藏过(隐藏元素无法保持焦点);maxlength="0"的按钮 input 是不是被设成了readonly导致无法聚焦(readonly元素在部分安卓上不可聚焦,去掉它)。
报错六:焦点死循环。如果页面卡死或者疯狂打印 focus/blur 日志,说明blur里的focus()递归了。加一个布尔开关,只在「发送动作触发」时才回填焦点,用户主动点击别处时不要拦截。
排查时建议一次只改一个变量,改完立刻在真机上验证,不要一次改一堆然后猜是哪个生效了。安卓机型差异大,最好在你目标用户占比最高的两三款机器上都跑一遍。
6. 从焦点管理到对话链路:把交互和模型调用分开治理
把键盘常驻调好之后,你会发现聊天页面的体验瓶颈往往不在交互,而在模型响应速度和稳定性。焦点管理是纯前端的事,模型调用是后端的事,这两条线最好分开治理,别混在一起调。
前端这边,你只需要保证:输入框焦点稳定、发送动作幂等(防止用户连点发重复消息)、消息列表渲染不卡顿。发送时给按钮加一个短暂的禁用态,避免重复请求。消息上屏用乐观更新,先本地插入一条「发送中」的气泡,接口返回后替换成真实回复,这样即使模型响应慢,用户也不会觉得卡。
后端这边,用 TaoToken 这类聚合网关的好处是,你可以在一个 Base URL 下切换不同 Model ID,不用为每个模型单独维护一套鉴权和计费。对于长期跑的编码类或 Agent 类任务,可以考虑用 Coding Plan 这类按周期计费的方式,成本更可控;如果是临时验证某个模型的效果,直接用 API Key 按量调用就行。两种方式共用同一套 Base URL 和 Key,切换成本很低。
最后给一个实用技巧:在开发阶段,把textarea的focus状态用一个可视化的角标显示在页面上(比如右上角一个小圆点,聚焦时绿色、失焦时红色),这样你在真机调试时一眼就能看出焦点有没有丢,比翻控制台日志快得多。上线前把这个角标去掉即可。
整套方案的核心就一句话:让可编辑元素在发送前后始终有焦点。理解了这个,无论你用的是原生 H5、uni-app 还是别的框架,都能把键盘常驻调出来。剩下的就是针对你的目标机型做微调,多测几台,问题基本都能定位。