ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity系统虚拟键盘真机适配与TouchScreenKeyboard排查

Unity系统虚拟键盘真机适配与TouchScreenKeyboard排查

玩家在角色命名弹窗里点了输入框,屏幕下方弹出一块白底键盘,结果输入框被整个顶到屏幕外面;换个手机测试,拼音打了一半还没上屏,搜索列表已经开始按拼音字母过滤了;再换一台,干脆什么都不弹,日志里干干净净,连报错都没有。这类问题我见过太多次,几乎全部集中在同一个接口上:在 Unity 里调用系统自带的虚拟键盘。它看着只是几行代码的事,实际牵扯到平台差异、输入法状态、原生视图生命周期和 UI 布局适配,任何一环没考虑清楚,就会以“某个机型不行”的形式反馈到你手上。

这篇内容面向已经写过 UI 输入框、但准备在真机上交付的开发者。我会把TouchScreenKeyboard这套接口拆开讲:哪些场景必须用它、它在 Android 和 iOS 上的行为差在哪、一个能进版本的键盘控制器该怎么写、真机上最容易翻车的几个点分别怎么定位。涉及真机适配的部分都会给出可直接对照的代码和判断依据,不涉及具体机型品牌的对比。文中出现的项目一律为虚构代称,代码是按常见实践整理的通用写法,你拿到自己项目里需要按版本微调。

1. 系统键盘在游戏里到底值不值得调

先说结论:只要你的产品需要用户自由输入文字,尤其是可能输入中文、日文、韩文或者其他非拉丁字符,那系统自带键盘基本是唯一正确选择。自己拿 Button 拼一套键盘,输拉丁字母还行,遇到拼音候选、长按选词、联想词、语音输入这些,全部做不了。这不是工作量的问题,是根本拿不到输入法的候选词库和组词逻辑。

1.1 三类场景,自绘键盘永远做不好

第一类是多语言自由输入。角色名、公会公告、聊天、留言板都属于这类,用户可能在任何时候切换到任何一种输入法。系统键盘的价值不只在按键,更在于它背后挂着完整的输入法引擎。你自己画的键盘只能拿到一个个字符,拿不到“用户正在打 zhangsan 但还没决定选哪个字”这个中间状态。

第二类是账号、密码、验证码输入。系统键盘在部分平台上会走安全输入通道,还会配合系统的密码管理和自动填充。这类输入就算你自绘得再漂亮,用户也未必敢用,何况很多平台的合规检查会盯着这块。

第三类是系统级输入习惯的延续。剪贴板粘贴、语音转文字、长按移动光标、双击选词、撤销手势,这些东西用户已经形成了肌肉记忆。自绘键盘每少一个,就多一批“这游戏输入框好难用”的评价。

1.2 自绘键盘依然成立的两种场合

但也不是所有输入都该调系统键盘。如果你的输入只涉及固定词条、数字、预设短语,比如游戏内快捷喊话、数量输入、卡组命名选模板,那自绘一套网格按钮反而更快更稳,还不会被键盘遮挡 UI。另一种情况是纯手柄操作的场景:主机、掌机或者接了手柄的电视端,用户手里根本没有触摸屏和软键盘,这时候只能靠虚拟光标或者九宫格选字。

我这里给一个粗略的判断标准,实际项目可以按这个走:

输入内容推荐方案理由
昵称、聊天、留言系统键盘需要完整输入法与候选词
密码、手机号、验证码系统键盘安全通道与自动填充
数量、金额、纯数字自绘数字键盘无输入法依赖,布局可控
固定词条、预设短语自绘按钮组无需自由输入,省一次弹窗
手柄/主机端文本输入自绘网格 + 光标无系统软键盘可用
VR 场景内输入自绘或系统面板视平台能力而定

这张表我在几个项目里都用过,边界基本清楚。真正麻烦的是中间地带,比如“允许用户输入自定义标签,但九成用户只会打两三个字”。这种我一般还是走系统键盘,因为一旦有一个人想打完整句子,自绘键盘就暴露了短板。

2. TouchScreenKeyboard 的真实能力与平台边界

TouchScreenKeyboard是 Unity 里访问系统软键盘的入口,命名空间就在UnityEngine下,不用额外引包。它的用法极其朴素:调用静态方法Open拿到一个键盘对象,然后在Update里轮询它的status和text,等它关闭后读最终文本。就这三步,但每一步在不同平台上都有各自的脾气。

2.1 一个键盘对象的完整生命周期

完整链路是这样的:TouchScreenKeyboard.Open(...)发起请求并返回一个对象(也可能是null),系统弹出原生键盘;之后每一帧检查keyboard.status,处于Visible时通过keyboard.text读取当前内容;当状态变成Done、Canceled、LostFocus或RequiresEditing时,进入对应的处理分支;处理完把引用置空,避免继续轮询。

这里有个很多人第一次踩的点:Open是异步的,返回的对象不代表键盘已经弹出来了。你拿到的只是一个句柄,实际的原生视图要等系统调度。所以千万别在Open之后立刻去读keyboard.area或者keyboard.text做 UI 布局,那时候拿到的值大概率是空的或者上一帧的残留。

另一个点是必须在Update里轮询。TouchScreenKeyboard的状态更新依赖主线程的帧循环,你不用协程里长yield等它,也不要在OnGUI或者后台线程去读。我见过有人在异步回调里读keyboard.status,结果永远读到Visible,因为主线程根本没在推进状态。

2.2 参数表里没写清楚的那些差异

Open的完整签名有八个参数,按顺序是初始文本、键盘类型、是否自动纠错、是否多行、是否密码模式、是否 alert 模式、占位文本、字符上限。参数名文档里都有,但真正影响交付的是“哪些参数在哪些平台上会被忽略”。

TouchScreenKeyboard.Open( string text, // 初始文本 TouchScreenKeyboardType type = TouchScreenKeyboardType.Default, bool autocorrection = true, bool multiline = false, bool secure = false, bool alert = false, string textPlaceholder = "", int characterLimit = 0);

我把实际项目里验证过的差异整理成一张表,注意这是“常见表现”,具体版本可能微调,交付前务必真机过一遍:

能力AndroidiOS桌面(Windows/macOS)WebGL
弹出系统键盘支持支持仅在有触摸/软键盘环境时不支持Open
键盘类型type基本生效,数字类映射较准生效较完整视系统——
multiline多行生效生效————
secure密码部分输入法仍会显示联想生效,单行全屏————
characterLimit生效但计数口径不一生效————
area键盘区域常返回零值一般可用————
textPlaceholder生效情况不稳定生效————
编辑器内表现不弹,返回null同左同左同左

看这张表你会发现一个很现实的问题:area在 Android 上不可靠。这意味着你不能指望用键盘区域高度去做精确布局,得准备一套兜底方案,后面第 4 节会细说。

还有两个静态属性值得单独拎出来。一个是TouchScreenKeyboard.isSupported,它表示当前平台是否具备弹出软键盘的能力,注意它返回false不代表功能不可用,只是当前环境没有软键盘(比如没有触摸屏的 PC)。另一个是TouchScreenKeyboard.hideInput,这是个静态开关,在 Android 上设为true会隐藏原生输入条那一截,只保留键盘本身。这个开关对本来自绘了输入框的 UI 特别重要,不设置的话用户会看到两层输入框叠在一起。

2.3 status 的四种返回与业务映射

TouchScreenKeyboard.Status一共有五个枚举值,其中Visible是过程中的状态,剩下四个是结束状态,每个的业务含义都不一样:

  • Done:用户点了键盘上的确认键,属于明确提交。这时候keyboard.text就是最终值,直接读。
  • Canceled:用户主动取消了输入(部分平台有取消按钮)。
  • LostFocus:键盘因为失去焦点而关闭,比如用户点了输入框外面的区域,或者页面被切走。这个状态最需要斟酌。
  • RequiresEditing:这是 Android 特有的,表示系统希望重新进入编辑状态,通常出现在应用从后台切回来、或者原生视图被打断之后。
  • Visible:键盘正处于显示状态,继续轮询。

LostFocus的处理方式直接体现产品判断。如果是搜索框、备注框这种“随时可以继续编辑”的输入,我一般把LostFocus当作“保留当前输入但不提交”,用户点回输入框还能接着改。如果是支付密码、抽奖码这种一锤子买卖,LostFocus一律当取消处理,把已输入内容清掉,避免误提交。这个判断没有标准答案,但一定要在代码里显式写出来,不能靠默认行为蒙混过去。

3. 手写一个能进版本的键盘控制器

现成的InputField也能触发系统键盘,但它把状态管理、文本同步、光标处理全打包在一起了,一旦出问题很难单独排查。我后来的做法都是自己写一层薄控制器,只负责“打开—轮询—回调”这条链路,UI 部分完全自己控。这样出问题时,日志能精确到状态切换的那一帧。

3.1 先把状态机立起来,再谈 UI

控制器的核心是一个三态机:空闲、输入中、收尾中。之所以要显式建状态,是为了解决一个特别常见的 bug:用户在键盘还开着的时候又点了一次输入框,代码又调了一次Open,结果两个原生视图叠在一起,关闭其中任意一个都会让另一个变成孤儿。表现出来就是“键盘关不掉了”。

public enum KeyboardState { Idle, Editing, Closing }

加上状态判断之后,重复请求会被直接忽略,或者先把旧的一次收尾再开新的。我更倾向后者,因为用户连续点两次输入框的概率不低,直接忽略会让人以为没反应。

3.2 打开阶段:参数拼装与前置保护

打开阶段的代码不长,但几个细节必须写对:先判isSupported,再设hideInput,再调Open,最后判返回值是不是null。

public bool RequestKeyboard( string initialText, TouchScreenKeyboardType type = TouchScreenKeyboardType.Default, bool multiline = false, bool secure = false, int characterLimit = 0, string placeholder = "", Action<string> onComplete = null, Action onCancel = null) { if (state == KeyboardState.Editing) { // 已有输入在进行,先收尾再开新的 ForceClose(false); } if (!TouchScreenKeyboard.isSupported) { // 交给自绘键盘或 InputField 兜底 return false; } // Android 上隐藏原生输入条,避免和自绘输入框重叠 TouchScreenKeyboard.hideInput = true; keyboard = TouchScreenKeyboard.Open( initialText ?? string.Empty, type, autocorrection: !secure && !multiline, multiline: multiline, secure: secure, alert: false, textPlaceholder: placeholder ?? string.Empty, characterLimit: characterLimit); if (keyboard == null) { state = KeyboardState.Idle; return false; } lastConfirmedText = initialText ?? string.Empty; this.onComplete = onComplete; this.onCancel = onCancel; state = KeyboardState.Editing; return true; }

autocorrection我习惯在密码模式和多行输入下关掉。密码模式关掉是因为纠错会参与文本改写,明文对不上;多行输入关掉是因为纠错容易在换行附近误判,用户打个回车它给你加个句号。

把isSupported判断放在前面,是为了让 PC 端一次调用就能拿到false,然后平滑切到自绘键盘,而不是先弹一个空窗再发现失败。

3.3 轮询阶段:文本同步与组合态处理

轮询里最关键的一行是同步text。别小看它,不同平台刷新频率不同,你每帧读,Android 上读到的是包含未上屏组合字符的内容,iOS 上则可能只在部分时机刷新。所以同步的时候要做两件事:一是缓存上一次读到的值,值变了才触发 UI 刷新;二是把“实时预览”和“最终提交”彻底分开。

private void Update() { if (state != KeyboardState.Editing || keyboard == null) return; switch (keyboard.status) { case TouchScreenKeyboard.Status.Visible: string current = keyboard.text ?? string.Empty; if (current != cachedText) { cachedText = current; onTextChanged?.Invoke(current); // 只做预览,不提交 } break; case TouchScreenKeyboard.Status.Done: Finish(keyboard.text ?? cachedText, true); break; case TouchScreenKeyboard.Status.Canceled: Finish(cachedText, false); break; case TouchScreenKeyboard.Status.LostFocus: Finish(cachedText, keepOnLostFocus); break; case TouchScreenKeyboard.Status.RequiresEditing: ReopenForEditing(); break; } }

keepOnLostFocus就是这个控制器对外暴露的一个 bool,搜索类输入传true,一次性输入传false。这样同一个控制器能适配两类完全不同的业务,不用写两份。

RequiresEditing的处理是重新Open一次,把当前缓存的文本传进去,让用户回到原来的编辑位置。这个分支在 Android 上不写,表现就是“切了个后台回来,键盘再也弹不出来了”。

3.4 关闭阶段:确认、取消与异常回收

收尾要保证三件事:回调只触发一次、引用彻底置空、异常路径也能走到收尾。我见过的最隐蔽的 bug 是回调里的代码抛了异常,导致state没被重置,后面所有请求全部被吞掉,用户以为输入框坏了。

private void Finish(string value, bool accepted) { if (state != KeyboardState.Editing) return; state = KeyboardState.Closing; var complete = onComplete; var cancel = onCancel; onComplete = null; onCancel = null; keyboard = null; cachedText = value ?? string.Empty; state = KeyboardState.Idle; try { if (accepted) complete?.Invoke(cachedText); else cancel?.Invoke(); } catch (Exception e) { Debug.LogError($"[Keyboard] callback failed: {e}"); } finally { state = KeyboardState.Idle; } }

再加上两个保底处理:OnApplicationPause(true)的时候主动ForceClose(false),防止用户切后台时键盘残留;OnDisable和OnDestroy里也调一次。Android 上如果键盘还开着就切场景,原生输入条有可能留在屏幕上,重启应用才能消失,这个现象在真机上未必每次都能复现,但一旦出现就是严重问题。

4. 真机上最常翻车的四件事

代码写对了不代表能用。下面这四类问题基本每次上线前都会遇到其中的一两个,我把现象、成因和处理方式都列出来,方便你对照排查。

4.1 键盘把输入框顶出屏幕之外

这个问题的成因在 Android 侧叫窗口软输入模式。系统在键盘弹出时会按 Activity 的配置处理窗口:一种是把整个画面往上推,一种是把窗口高度压缩。前者会让顶部内容整体消失,后者会让底部 UI 被压扁。而 Unity 打包出来的配置在不同版本里可能不一样,所以同一套 UI 在两个版本上表现可以完全不同。

处理方式我一般用两层保险。第一层是布局层面:把输入区域放在屏幕中上部,或者干脆做成一个居中的模态弹窗,不贴底。这样无论窗口怎么变,输入框都不会被推出可视区。第二层是运行时偏移:在键盘打开后拿到TouchScreenKeyboard.area的高度,用它去偏移输入面板。

// 仅在平台支持时使用,务必做零值保护 Rect area = TouchScreenKeyboard.area; float keyboardHeight = area.height > 1f ? area.height : FallbackKeyboardHeight();

FallbackKeyboardHeight()里我按屏幕高度比例给一个经验值,横屏大概取 0.45 倍屏高,竖屏取 0.4 倍屏高,再乘上安全区的可用高度。这个数字不精确,但用于把面板整体上移足够,用户不会觉得 UI 被键盘挡住。真要精确适配,只能靠“居中模态弹窗”这一条路,这是我的实际结论。

4.2 中文输入法的组合态让搜索框疯狂抖动

拼音打了一半,还没选字,keyboard.text里已经是拼音字母了。你如果每帧拿这个值去触发搜索、校验、列表刷新,用户就会看到列表按拼音疯狂跳动,体验极差。

处理思路分两档。轻方案是防抖:文本变化后不立刻处理,等 250 到 350 毫秒没有新变化再走搜索逻辑。用户在连续输入时不会被中间态打扰,停下来选字后触发一次。重方案是只认最终值:Visible期间不做任何业务处理,只更新输入框显示,等Done或者LostFocus之后再走搜索。

我通常给搜索框用防抖,给昵称、备注这类“只在结束时生效”的输入用最终值。还有一个细节:不同输入法在组合态下的表现不一样,有的会把候选字母也塞进text,有的不会。所以别赌某个输入法的行为,统一按最保守的方式处理。

4.3 characterLimit 与 emoji 的计数陷阱

characterLimit传 20,用户输入二十个英文字母没问题,但输入十个 emoji 可能就被截断了,因为某些 emoji 在底层占两个甚至更多码元。更麻烦的是不同平台的计数方式不完全一致,同一个字符串在两端得到的截断位置可能不同。

我的处理是把characterLimit当作第一道防护,同时在自己的回调里做二次校验和截断。尤其涉及用户昵称这种要入库的字段,服务端还得再校验一次长度和字符集,不能指望客户端。另外密码模式下的长度限制建议放宽一点,防止用户设了一个刚好卡在边界上的密码,换个平台就登不进去。

4.4 编辑器里一切正常,打包后失灵

这句几乎可以当成通用结论:TouchScreenKeyboard在编辑器里不弹键盘,Open直接返回null。所以你在编辑器里点输入框没反应,是完全正常的,不代表代码有问题。反过来,编辑器里“正常”也证明不了任何事,所有验证必须真机跑。

我一般会在打开失败的分支里加一条日志,标明是isSupported为假还是Open返回了空:

if (!TouchScreenKeyboard.isSupported) { Debug.LogWarning($"[Keyboard] unsupported on {Application.platform}"); return false; }

有了这行日志,排查时能少走很多弯路。

5. 一次“点了没反应”的完整排查链路

去年帮一个朋友看一个问题:他们的输入框在部分设备上点了完全没反应,日志干净,没有异常,也没有警告。整条排查过程我记下来了,因为它的思路可以套用到大部分“键盘不弹”的场景。

5.1 从点击事件开始往回倒推

第一步先确认点击有没有真的到达按钮。在按钮的onClick最前面加一条Debug.Log,跑一遍。结果日志没有输出。这说明问题根本不在键盘,而在 UI 事件层。检查发现他们在全屏上放了一个透明的 Image 当背景遮罩,挂在按钮的上层,射线检测把它当成了目标。把遮罩的raycastTarget关掉之后,点击通了。

这个案例说明一件事:排查输入问题,先确认输入事件这条链路是通的,再怀疑键盘。顺序反过来会浪费大量时间。我一般的排查顺序是这样:

  1. 按钮点击有没有触发(打点确认)
  2. 控制器有没有收到请求(打点确认)
  3. isSupported是否为真(打点确认)
  4. Open返回是否为null(打点确认)
  5. status是否在变化(每帧或每隔几帧打一次)
  6. 原生视图是否被别的全屏 UI 盖住(关掉其他 UI 验证)

按这个顺序走,绝大多数问题能在十分钟内定位。

5.2 三个被忽略的工程设置

继续那个案例。事件通了之后,键盘偶尔能弹,偶尔不能。这时候就要看工程设置了。三个地方值得逐一确认:

输入系统设置。如果工程启用了新输入系统且只启用它,那么旧版的InputField组件在读取输入时会报错,表现就是输入框能显示但打不进字。这个在日志里会有明确提示,但很多人会把它当成警告忽略掉。如果你的工程是混合模式,最好在项目初期就统一,别拖到上线前才发现。

Android 的软输入模式配置。前面提过,这个配置决定了键盘弹出时窗口怎么变。如果你的 UI 依赖固定分辨率布局,模式不对就会出现“键盘弹了但看不见输入框”的情况,用户的主观体验就是没反应。

屏幕方向与安全区。横屏游戏在带刘海或者挖孔的屏幕上,如果输入面板贴着边缘,键盘弹出后可能整个落到安全区外。这个用Screen.safeArea做一次面板定位就能解决,但必须在真机上测,编辑器里看不出来。

5.3 最后的结论与复现条件

那个问题最后定位到两条:遮罩挡了射线,以及他们在OnApplicationPause里没有关闭键盘,导致切后台再回来之后status卡在RequiresEditing,而代码里没有处理这个分支,所以后续所有请求都被状态机吞掉了。修完之后我加了一条规范:所有TouchScreenKeyboard的switch必须写default分支,并且打日志。省得下次又靠猜。

这类问题的共同特点是“偶发、无日志、换个设备就好”。对抗它的唯一办法是把每个分支都显式处理掉,不留默认路径。

6. 桌面端与网页端的兜底策略

移动端之外,另外两个平台的策略完全不同,不能一套代码硬套。

6.1 有触摸屏的 PC 与掌机

桌面平台没有常驻软键盘,isSupported在无触摸设备上通常是false。这时候有两条路:一是检测到isSupported为假就直接走自绘键盘,用鼠标和手柄都能操作;二是调用系统自带的屏幕键盘程序。后者在部分设备上体验不错,但会带来进程管理、窗口焦点、关闭时机这些新问题,而且不同系统的行为差异很大。我的建议是,除非你的用户明确是在触屏一体机或者掌机设备上使用,否则老老实实自绘一套网格键盘更可控。

6.2 网页端的隐藏输入元素方案

WebGL 平台不支持TouchScreenKeyboard.Open,但浏览器的原生输入是可以拿到的,原理是在页面上挂一个隐藏的输入元素,把焦点给它,然后监听输入事件。这套机制在主流的前端引擎里已经封装好了,你只要用InputField组件,打包成网页后它会自动接管。需要注意的是移动浏览器上的软键盘会改变可视区高度,页面布局要跟着适配,否则输入框同样会被顶出视口之外。

6.3 抽象成一层可替换的输入服务

跨三个平台之后,最省心的做法是把键盘抽成一个服务接口,按平台注入不同实现:

public interface ITextInputService { bool IsAvailable { get; } bool Request(string initial, TextInputOptions options, Action<string> onComplete, Action onCancel); void ForceClose(bool accepted); }

移动端实现走TouchScreenKeyboard,桌面端走自绘键盘,网页端交给InputField。上层业务只认接口,不关心背后是谁。这样做的好处是,某个平台出问题时你可以单独替换实现,不用改业务代码,也不用担心把别的平台一起改坏。

最后分享一个小技巧:我会在所有键盘实现的入口打一条带平台名的日志,格式统一成[TextInput] platform=xxx available=xxx result=xxx。上线之后如果收到用户反馈说打不了字,让他报一下机型和系统版本,再对照这条日志,基本能判断是能力不支持还是调用失败。这个习惯帮我省掉了好几次远程排查的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进