☰
Unity移动端系统虚拟键盘调用与封装实践
2026/11/3 11:47:43 网站建设 项目流程

1. 先把话说清楚:什么场景下才该用系统键盘

做 Unity 移动端项目,但凡涉及"让用户敲字"这件事,绕不开一个选择题:自己画一套键盘,还是直接喊系统自带的虚拟键盘出来。我见过不少团队一开始雄心勃勃,说自绘键盘才能保证 UI 风格统一,结果做到一半发现输入法联想、中文候选词、九宫格、手写、语音输入这些功能全得自己补,最后工期炸掉,又灰溜溜回来接系统键盘。这篇文章就聊 Unity 调用系统自带的虚拟键盘这件事——它是什么、能干什么、哪些坑必须先知道,以及怎么把它封装成一个真正能在项目里长期用的东西。适合已经上手 Unity、做过一两个移动端 Demo、准备把输入功能做扎实的开发者看,也适合刚接手别人项目、发现输入框在真机上行为诡异想找根因的同学。

先把概念对齐。Unity 里说的"系统自带虚拟键盘",指的是操作系统提供的软键盘(Soft Keyboard / On-Screen Keyboard),Android 上是各家输入法应用,iOS 上是系统键盘加第三方输入法,桌面端则是系统的触摸键盘。Unity 通过TouchScreenKeyboard这个类去唤起它,而 UI 组件InputField和TMP_InputField在底下也是走这条路。它解决的问题很纯粹:让用户用自己熟悉的输入法、自己习惯的词库、自己配置的布局来打字,你不用管拼音怎么组合、候选词怎么排序、九宫格怎么切。代价是,你失去了对键盘外观和部分行为的控制权,而且不同平台的表现差异相当大。

1.1 自绘键盘在什么情况下会翻车

我把自绘键盘的失败案例归成三类。第一类是输入效率问题:自绘键盘通常只做全键盘 QWERTY,中文用户要么被迫用拼音一个字母一个字母敲,要么就得自己实现一套简易的拼音转汉字,而词库、联想、纠错这些工作量是天文数字。第二类是适配问题:不同地区的用户习惯完全不同,九宫格、手写、语音、笔画,你不可能全做。第三类是维护成本:输入法生态每年都在变,自绘键盘等于把这个变化挡在门外,同时也把用户的习惯挡在门外。

自绘键盘唯一真正站得住的场景,是输入内容极度受限的情况。比如只需要输入 4 位数字验证码、只需要在虚拟摇杆旁边敲几个字母当昵称、或者是游戏手柄操作下需要一个方向键选字的极简输入。这种场景下自绘反而更快,因为用户不需要在手机上调出输入法再切回游戏。判断标准很简单:如果用户输入的内容有明确的字符集边界,且长度很短,自绘可以;否则老老实实调系统键盘。

1.2 系统键盘的成本清单

调系统键盘不是零成本,它的代价体现在三个地方。一是遮挡:键盘弹出来会盖住半屏,你的 UI 必须做避让,否则输入框被挡在键盘后面,用户看不见自己打的是什么。二是时序:键盘的弹出、收起、文字变化都是异步的,你在Update里读到的状态可能落后于用户的实际操作,处理不当就会出现"输完了但没提交""提交了两次"这类问题。三是平台差异:Android 的输入法有组合输入阶段,iOS 的键盘高度会变,WebGL 压根没有系统键盘这回事。这三条我会在后面逐个拆开讲。

提示:如果你的项目只在 PC 上跑、且用户都是用物理键盘,那这套东西基本用不上。真正需要认真处理的是移动端和触摸屏一体机场景。

2. Unity 里调用系统键盘的三条路径

很多人以为"调系统键盘"就是调一个 API,实际上 Unity 给了三层不同抽象级别的东西,选错了层,后面会一直别扭。搞清楚这三层各自负责什么,比背 API 参数重要得多。

2.1 TouchScreenKeyboard:官方唯一正经入口

TouchScreenKeyboard是 UnityEngine 命名空间下的静态类,是所有系统键盘调用的最终落点。它的核心方法就一个Open,返回一个TouchScreenKeyboard实例,你通过轮询这个实例的status和text来获知用户输入了什么。这个类最大的特点是它不参与 UI 渲染——它不画输入框,不画光标,只负责唤起键盘并把文字回传给你。也就是说,屏幕上那个"输入框长什么样"完全由你自己决定。

这个设计的好处是灵活,你可以把它接在任意 UI 元素上,甚至接在一个 3D 物体上做世界空间输入。坏处是所有细节都得自己管:光标闪烁、文字截断、占位符、密码掩码显示,统统要自己实现。所以除非你有特殊需求,否则不建议直接在业务代码里裸用TouchScreenKeyboard,那是重复造轮子。

2.2 InputField 家族:封装层到底替你做了什么

Unity 提供了两套输入框组件:老的UnityEngine.UI.InputField和 TextMeshPro 的TMP_InputField。它们在移动端的行为逻辑是:检测到聚焦事件后,在底层调TouchScreenKeyboard.Open,然后把键盘回传的文本同步到自己的text属性上,同时触发onValueChanged和onEndEdit事件。除此之外,它还替你处理了光标位置、选区、字符限制、内容类型校验这些琐事。

这里有个关键开关值得单独拎出来说:shouldHideMobileInput(老 InputField)和shouldHideSoftKeyboard(部分版本叫法不同,TMP 里是shouldHideMobileInput)。当它为true时,Unity 会隐藏系统原生输入框,只用自己渲染的文本;为false时,系统会在屏幕某个位置(通常是顶部或底部)弹出一个原生的输入条,里面有系统级的编辑能力。这个开关直接决定了中文输入法的体验好坏,后面第 5 节会详细展开。

2.3 原生桥接:什么时候必须自己写

有些需求 Unity 的封装层覆盖不到,只能自己写平台代码。典型的三类:第一,需要在键盘弹出/收起的瞬间拿到精确的高度和动画曲线,用来做 UI 联动;第二,需要控制键盘的 appearance,比如深色模式、返回键文字;第三,需要拿到输入法的组合输入中间状态(也就是用户还在敲拼音、没选字的那一刻)。前两类可以用AndroidJavaObject和 iOS 的DllImport做桥接,第三类在 Unity 层面基本拿不到,只能靠原生层拦截。

注意:写原生桥接的时候一定要做空实现兜底,也就是#if UNITY_ANDROID && !UNITY_EDITOR这类宏要写全。我踩过最蠢的坑是在编辑器里调 Android 的 Java 类,报了一堆找不到类的错,排查了半小时才发现是宏没写对。

3. 把 TouchScreenKeyboard 的每个参数都掰开

既然最终都要落到这个类上,那就把它的参数一个一个说清楚。很多人抄代码的时候只改了前两个参数,剩下的全用默认值,结果在真机上出现自动纠错把用户昵称改掉、单行输入框里能敲回车这类问题。

3.1 Open 方法的签名与参数取舍

签名大致是这样:

public static TouchScreenKeyboard Open( string text, TouchScreenKeyboardType keyboardType = TouchScreenKeyboardType.Default, bool autocorrection = true, bool multiline = false, bool secure = false, bool alert = false, string textPlaceholder = "", int characterLimit = 0);

逐个说。第一个text是预填内容,用户打开键盘时输入框里已经有的字。第二个keyboardType决定键盘布局,枚举里有Default、ASCIICapable、NumbersAndPunctuation、NumberPad、PhonePad、EmailAddress、URL、NamePhonePad等,选对了能省掉用户切键盘的动作。这里有个坑:NumberPad在 iOS 上确实只有数字,但在部分 Android 输入法上会退化成全键盘,所以如果你的逻辑强依赖"只能输数字",别指望键盘类型,必须在代码里过滤。

第三个autocorrection我的建议是永远设成 false。系统自动纠错在聊天场景是加分项,但在游戏昵称、账号、道具名这些场景就是灾难,用户打了一串自定义 ID,系统给他改成了字典里的词。第四个multiline控制是否允许多行,设成 true 后回车键会变成换行而不是提交。第五个secure是密码模式,开启后系统会把输入内容显示成圆点,并且通常会关闭联想和纠错——但记住,它只是显示层掩码,text属性返回的仍然是明文,别以为开了 secure 就安全了。

第六个alert比较冷门,开启后键盘会进入一种"确认模式",主要影响 iOS 上的回车键文案。第七个textPlaceholder是占位符文字,只在部分平台生效。第八个characterLimit限制最大字符数,0 表示不限制,设了之后系统会直接阻止用户继续输入,比自己截断体验好。

3.2 status 状态机的正确读法

TouchScreenKeyboard.status是个枚举,四个值:Visible、Done、Canceled、LostFocus。看起来简单,实际用起来有个反直觉的地方:键盘实例在用户点击"完成"之后并不会立刻失效,你还能读到最终的text。所以正确的处理顺序是,在Update里先判断status,如果是Visible就持续同步text,如果是Done就把最终文本提交并置空引用,如果是Canceled或LostFocus就回滚到打开前的值。

Canceled和LostFocus的区别值得说一句。Canceled通常对应 Android 的物理返回键或者 iOS 的取消操作,语义是"用户不想输入了"。LostFocus则是键盘被其他东西抢走了焦点,比如来电、切后台、或者你的代码把焦点移到了另一个输入框。这两种情况下用户的意图不同,是否需要保留已输入内容也不同——取消应该回滚,失焦通常应该保留。我见过有项目把这两个当成一回事处理,结果用户切了个后台回来发现刚打的字全没了。

3.3 area 与键盘高度测量

TouchScreenKeyboard.area返回一个Rect,表示键盘在屏幕上占据的区域,单位是像素,原点在左下角。这个属性理论上是做 UI 避让的最佳数据源,但实际可靠性因平台而异:iOS 上比较准,Android 上部分输入法会返回全屏或者零,尤其是在悬浮键盘、分屏模式下。所以我的做法是不把它当唯一依据,而是用它做初值,再结合配置里的兜底高度(比如屏高的 40%)取较大值。

还有一个属性TouchScreenKeyboard.visible,返回布尔值表示键盘当前是否可见。它比读area更可靠一些,适合用来判断"要不要做避让"这个开关。而isSupported则告诉你当前平台支不支持这套机制,桌面端独立运行时它通常返回 false,这时候就得走第 4.3 节的方案。

提示:键盘高度在 iOS 上是有动画的,如果你在键盘弹出的第一帧就去读area,拿到的可能是 0 或者旧值。稳妥做法是延迟一帧再读,或者干脆用visible状态变化作为触发信号。

4. 分平台落地:Android、iOS、桌面、WebGL

同一个 API,四个平台四种脾气。这一节按平台拆,每个平台给出真正会影响结果的那几个配置点和代码点。

4.1 Android:清单配置比代码更关键

Android 上最常见的问题不是代码写错,而是AndroidManifest 里的windowSoftInputMode没配对。这个属性的作用是告诉系统:键盘弹出来的时候,窗口该怎么调整。可选取值里最常用的是adjustResize和adjustPan。adjustResize会把窗口压缩,让出键盘的空间,Unity 的屏幕高度会跟着变,你的 UI 可以感知到并重新布局;adjustPan则是把整个窗口往上推,Unity 拿不到高度变化,但内容不会被压缩变形。

选哪个取决于你的 UI 结构。如果是全屏 Canvas 加锚点布局,adjustResize更合适,因为你可以监听屏幕尺寸变化做自适应。如果是固定相机视野、UI 跟着相机走的游戏界面,adjustPan更省事。这里要注意,不同 Unity 版本导出的清单默认值有出入,稳妥做法是显式在 Player Settings 的 Custom AndroidManifest 里写死,别依赖默认。

第二个 Android 特有的点是返回键。键盘弹出来时按物理返回键,系统会先收起键盘,这个事件 Unity 侧是收不到的,所以你别指望在Update里通过Input.GetKeyDown(KeyCode.Escape)捕获到这个动作。如果你的游戏在别处也监听了返回键做"退出游戏"的逻辑,一定要在输入框激活期间屏蔽掉,否则用户想收键盘,结果游戏退出了。

// 输入框激活期间屏蔽返回键逻辑 private bool _inputActive; private void Update() { if (_inputActive) return; if (Input.GetKeyDown(KeyCode.Escape)) { // 正常的返回逻辑 } }

另外还有个小技巧:如果某些机型上键盘弹出后 Unity 画面被压缩得很奇怪,可以尝试在TouchScreenKeyboard.Open之后把TouchScreenKeyboard.hideInput设为 true,能减少一层原生输入框的渲染,画面会稳定一些。

4.2 iOS:差异集中在类型映射与回收时机

iOS 的键盘整体比 Android 规矩,但也有两处要留意。第一处是keyboardType的映射,iOS 对键盘类型的支持更严格,NumberPad就是纯数字键盘,连小数点都没有,如果你要做金额输入,得用NumbersAndPunctuation。第二处是键盘回收,iOS 上如果一个TouchScreenKeyboard实例没有被正确关闭,用户切到别的应用再回来,键盘可能会残留或者反复弹出。所以务必在OnDisable、OnDestroy里主动把active置 false。

iOS 13 之后引入了"浮动键盘"和"快速输入条",这带来了一个新变量:TouchScreenKeyboard.isInPlaceEditingAllowed。它表示当前是否允许原地编辑,也就是键盘不遮挡内容、直接在输入框位置编辑。如果你要做精细的避让逻辑,这个属性可以帮你判断"这次不用挪 UI"。但要注意它只代表系统允许,不代表用户真的开成了浮动态,所以还是得结合area判断。

4.3 桌面端:调起系统触摸键盘的两种做法

桌面独立构建下TouchScreenKeyboard.isSupported通常返回 false,但这不代表触摸屏一体机不需要软键盘。Windows 系统自带的触摸键盘可以通过进程调起,最直接的方式是启动TabTip.exe:

using System.Diagnostics; using System.IO; public static class WindowsSoftKeyboard { private static Process _proc; public static void Show() { if (_proc != null && !_proc.HasExited) return; string commonFiles = Path.Combine( System.Environment.GetFolderPath( System.Environment.SpecialFolder.CommonProgramFiles), @"microsoft shared\ink\TabTip.exe"); if (File.Exists(commonFiles)) { _proc = Process.Start(commonFiles); } else { // 兜底:老式的屏幕键盘 _proc = Process.Start("osk.exe"); } } }

这里有个实操细节:TabTip.exe启动后不会自己消失,用户点击输入框外部它也不会自动收起,需要你调用Kill或者发送关闭消息。更麻烦的是,如果同一时间已经有实例在跑,再次Start可能会直接返回已有进程或者什么都不做,所以上面代码里加了进程存活判断。另外,这个方案只在 Windows 上有效,跨平台构建时要用宏隔离。

注意:调外部进程属于"能用但不优雅"的方案,如果你的项目要上架,得考虑某些环境可能限制进程启动。正式项目更稳的做法是自己做一个轻量的屏幕键盘 UI,只在检测到触摸设备且当前没有物理键盘输入时才显示。

4.4 WebGL:系统键盘根本不存在,只能借 DOM

WebGL 平台是很多人没意识到的一个大坑。Unity 的 WebGL 构建运行在浏览器沙箱里,没有权限也没有能力去调起操作系统的软键盘,TouchScreenKeyboard.isSupported返回 false。你在手机上用浏览器打开一个 WebGL 游戏,点击输入框,系统键盘不会出来。

但这里有个转机:浏览器的原生<input>元素是能唤起系统键盘的,而这个能力通过 JavaScript 桥接是可以借到的。Unity 的老InputField在 WebGL 上其实内部就是这么做的——它会在页面里塞一个隐藏的input元素,聚焦它来唤起键盘,再把值同步回 Unity。但TMP_InputField在部分版本上这个机制不生效,需要自己写.jslib插件:

// SoftKeyboard.jslib mergeInto(LibraryManager.library, { ShowKeyboard: function (placeholderPtr, defaultPtr) { var placeholder = Pointer_stringify(placeholderPtr); var def = Pointer_stringify(defaultPtr); var el = document.getElementById('unity-soft-input'); if (!el) { el = document.createElement('input'); el.id = 'unity-soft-input'; el.type = 'text'; el.style.position = 'fixed'; el.style.left = '-9999px'; // 移出可视区,避免被用户看到 el.style.top = '0'; document.body.appendChild(el); } el.value = def; el.placeholder = placeholder; el.focus(); } });

隐藏元素的做法是把left设成一个很大的负值,而不是用display: none,因为display: none的元素无法获得焦点,也就唤不起键盘。这个细节坑了不少人,我当年就是卡在这,一直以为是浏览器不支持。

5. 中文输入法才是真正的坑

前面那些都算工程配置,真正让人掉头发的是中文输入。英文输入是"按一个键、屏幕多一个字符",一一对应,简单直接。中文输入是"按一串键、屏幕上先出现拼音、再出现候选词、用户选了才上屏",中间有一个**组合输入(Composing)**阶段。Unity 的输入框在这个阶段的表现,直接决定了中文用户骂不骂你。

5.1 组合输入阶段的中间态

用户敲下nihao这五个字母时,屏幕上通常会显示带下划线的nihao,此时还没有确定要输入什么字。Unity 的InputField.text在这个阶段会读到什么?答案是不确定的,取决于shouldHideMobileInput的设置和输入法实现。如果隐藏了原生输入框(Unity 自己渲染),很多时候读到的是拼音原文,用户还没选字,onValueChanged就疯狂触发,你的业务逻辑如果在这个回调里做实时搜索、实时校验,就会收到一堆无意义的拼音串。

这个问题的解法有两条路。第一条是把shouldHideMobileInput设成false,让系统弹出原生输入条,用户在原生输入条里完成组合输入,上屏后才同步回 Unity。这条路体验最自然,但原生输入条的位置由系统决定,可能盖住你的 UI,视觉上割裂。第二条路是保留隐藏原生输入框,但在业务层做延迟处理:不要在onValueChanged里立即响应,而是用定时器做 200 毫秒的防抖,等用户停止输入再处理。防抖还能顺带解决另一个问题——每次按键都发网络请求或者跑一次完整校验,性能开销很大。

5.2 多行输入与回车提交的冲突

multiline和"回车提交"是互斥的。开了multiline,回车就是换行,键盘上的按键文案是"换行";关了multiline,回车就是完成,文案变成"完成"或"前往"。有些需求既要多行输入,又想让用户用某个键提交,这在中式输入法下尤其别扭,因为中文用户在组合输入时经常用回车来确认选字。这时候按回车到底是"选字"还是"提交"?

我的处理方式是:多行输入场景下不提供回车提交,只给界面上的一个显式"发送"按钮。这样用户选字时按回车不会误触发送,操作意图清晰。单行场景才依赖键盘的完成键。这个改动看起来小,但在实际测试里能减少一大类用户投诉。

5.3 密码框与限制字符的边界情况

secure模式和中文输入法组合起来会有奇怪的边缘情况。某些 Android 输入法在密码模式下会禁用中文输入,用户只能打英文字母;另一些则允许中文但候选词不显示,用户处于"瞎猜"状态。所以密码框的输入字符集最好明确限定为 ASCII,在onValidateInput里做过滤:

private char ValidatePassword(string text, int charIndex, char addedChar) { // 只允许可见 ASCII 字符 if (addedChar >= 0x20 && addedChar <= 0x7E) return addedChar; return '\0'; // 返回 \0 表示拒绝这次输入 }

字符限制还有一个隐性坑:characterLimit计的是字符数,而一个中文字在 UTF-16 里算一个 char,但在视觉上占两个英文宽度。如果你的 UI 布局是按宽度算的,用户输入中文时会出现"字符数没超,但文本溢出框外"的情况。解决方式是在显示层用 TextMeshPro 的自动缩放或者 overflow 模式,而不是只靠characterLimit卡。

提示:如果你的需求是"昵称最多 12 个字",用characterLimit = 12是符合用户直觉的;但如果需求是"最多 12 个字符宽度",那就得自己算权重,中文算 2、英文算 1。这类需求在表单校验里很常见,别想当然。

6. 键盘遮挡与焦点管理的实战方案

输入框被键盘挡住,是玩家反馈里出现频率最高的输入相关问题,没有之一。尤其是表单类界面,输入框排在页面下半部分,键盘一弹出来,用户完全看不见自己打的东西。

6.1 用 area 做动态避让

避让的核心逻辑是:算出输入框在屏幕坐标里的位置,和键盘占据的矩形做相交判断,如果相交就把整个界面往上平移。平移的距离等于相交部分的高度加一点边距。

private const float SafetyMargin = 24f; private void AdjustForKeyboard(Rect keyboardArea) { if (keyboardArea.height <= 0f) return; // 键盘区域换算成屏幕坐标系(原点左下,与 Unity 屏幕坐标一致) float keyboardTop = keyboardArea.y + keyboardArea.height; // 取当前聚焦输入框在世界空间的包围盒,转成屏幕坐标 Vector3[] corners = new Vector3[4]; _activeFieldRect.GetWorldCorners(corners); float fieldBottom = RectTransformUtility .WorldToScreenPoint(null, corners[0]).y; float overlap = keyboardTop - fieldBottom + SafetyMargin; if (overlap <= 0f) return; _canvasRoot.anchoredPosition = new Vector2( _canvasRoot.anchoredPosition.x, _canvasRoot.anchoredPosition.y + overlap); }

这段代码里有几个地方容易写错。第一,WorldToScreenPoint的第一个参数是相机,Canvas 是 Screen Space - Overlay 时传 null,是 Camera 模式时传对应相机,传错了坐标全歪。第二,corners[0]是左下角,这个顺序是GetWorldCorners的约定,别记混。第三,平移之后一定要在键盘收起时把位置还原,否则用户下次打开别的输入框会发现界面莫名其妙飘着。

更省事的做法是把所有输入框放在一个ScrollRect里,键盘弹出时直接滚动到让输入框可见的位置。这种方式对表单类界面特别友好,因为用户本来就需要上下滚动。实现上用ScrollRect.verticalNormalizedPosition配合RectTransformUtility.CalculateRelativeRectTransformBounds算相对位置就行。

6.2 焦点切换与防止键盘反复弹出

多输入框的界面有个经典 bug:用户从输入框 A 点到输入框 B,键盘会先收起来再弹出来,中间闪烁一下,视觉上很难看。原因是 A 失焦时触发了DeactivateInputField,键盘开始收起动画,B 聚焦时又立刻调Open,两次调用打架。

解决思路是延迟关闭。在 A 失焦时不立刻关键盘,而是等一小段时间(比如 0.1 秒),如果这段时间内 B 聚焦了,就跳过关闭动作,直接复用键盘实例。这个技巧在 TMP_InputField 里可以通过重写焦点逻辑实现,或者更简单地,在业务层维护一个"当前键盘实例",所有输入框共享它,只更新text和keyboardType,不重新Open。

public class SharedKeyboard : MonoBehaviour { private TouchScreenKeyboard _kb; private TMP_InputField _owner; private float _pendingCloseTime = -1f; public void Attach(TMP_InputField field) { // 取消待执行的关闭 _pendingCloseTime = -1f; _owner = field; if (_kb == null) { _kb = TouchScreenKeyboard.Open( field.text, MapType(field.contentType), autocorrection: false, multiline: field.lineType == TMP_InputField.LineType.MultiLineNewline, secure: field.contentType == TMP_InputField.ContentType.Password); } } public void Detach(TMP_InputField field) { if (_owner != field) return; _pendingCloseTime = Time.unscaledTime + 0.1f; } private void Update() { if (_kb == null) return; if (_kb.status == TouchScreenKeyboard.Status.Visible) { if (_owner != null) _owner.text = _kb.text; } else if (_kb.status == TouchScreenKeyboard.Status.Done) { _owner?.onEndEdit?.Invoke(_kb.text); Close(); } else if (_kb.status == TouchScreenKeyboard.Status.Canceled || _kb.status == TouchScreenKeyboard.Status.LostFocus) { Close(); } if (_pendingCloseTime > 0f && Time.unscaledTime >= _pendingCloseTime) Close(); } private void Close() { if (_kb != null) _kb.active = false; _kb = null; _owner = null; _pendingCloseTime = -1f; } private void OnDisable() => Close(); }

用Time.unscaledTime而不是Time.time是有意的,因为很多游戏的输入界面会把Time.timeScale设为 0 暂停游戏,用缩放时间会导致延迟关闭永远不触发。

注意:OnDisable里一定要关键盘。我在一个项目里遇到过场景切换后键盘残留的情况,用户在加载界面看到一个孤零零的键盘浮在那,点了也没反应,非常尴尬。根因就是旧场景的键盘实例没被回收。

7. 常见问题速查表与排查思路

实际排障的时候,最怕的是"现象描述不清、不知道该查哪一层"。我整理了一张表,把近几年遇到的典型现象和对应根因列出来,遇到问题先对号入座,再往下深挖。

7.1 问题速查表

现象高概率根因排查方向
点输入框没反应,键盘不弹isSupported为 false,或点击事件被上层 UI 拦截打印TouchScreenKeyboard.isSupported,检查 Canvas 上有没有透明遮罩层
键盘弹出但输入框不显示文字shouldHideMobileInput为 true 但自己没渲染文本检查输入框的 text 组件是否被禁用或层级错误
中文输入时文字重复上屏组合阶段text被同步了两次检查onValueChanged里是否有回写 text 的逻辑
键盘挡住输入框未做遮挡避让,或 Android 清单模式选错检查windowSoftInputMode,检查避让代码是否被执行
键盘收起后界面位置没还原避让逻辑只做了平移没做还原在关闭回调里恢复原始anchoredPosition
按返回键想收键盘,游戏却退出了返回键被业务逻辑捕获输入激活期间屏蔽返回键处理
场景切换后键盘残留实例没被主动关闭在OnDisable/ 场景卸载回调里调用active = false
密码框里能输入中文未做字符集过滤在onValidateInput里限定 ASCII 范围
WebGL 上点输入框没反应系统键盘在浏览器沙箱里不可用检查是否实现了.jslib的 DOM input 桥接
多输入框切换时键盘闪烁关闭和打开打架用共享实例加延迟关闭

7.2 排查顺序

我个人的习惯是按"平台 → 配置 → 代码"的顺序查。先确认问题在哪些平台复现,如果只有 Android 有问题,那大概率是清单配置或者输入法差异;如果只有某个品牌的机器有问题,那大概率是该机型输入法的特殊行为,得单独做兼容。配置层查完再查代码层,代码层优先看输入框组件的 Inspector 参数,很多人调了半天代码,结果发现是contentType设成了IntegerNumber却想输英文。

还有一个容易被忽略的点:真机日志。编辑器里的行为和真机差得很远,尤其是键盘相关的部分。建议在键盘打开、关闭、状态变化的地方都打一条日志,真机跑一遍,把日志拉出来对着时序看,比盲猜快得多。

提示:Android 上抓日志可以过滤输入法相关的关键字,能看到系统层收到的指令,有助于判断"是 Unity 没发指令"还是"输入法没响应"。

8. 封装一个能复用的键盘服务

聊了这么多细节,最后落到工程实践上:别在每个页面里重复写这套逻辑,抽一个服务层出来。这个服务层的职责很明确——管理键盘实例的生命周期、把平台差异挡在外面、对外只暴露"打开输入""读取结果""关闭"三个语义。

8.1 接口设计

设计上我用一个回调式的接口,而不是让业务代码去轮询状态:

public interface ISoftKeyboardService { bool IsSupported { get; } void Open(KeyboardRequest request, Action<string> onConfirm, Action onCancel); void Close(); }

KeyboardRequest里放标题、预填文本、键盘类型、是否多行、是否密码、最大长度这些参数,做成一个结构体,避免Open方法的参数列表长到没法看。这样做的好处是后续要加新参数(比如 iOS 的返回键文案)时不会破坏已有调用方。

8.2 代码实现与接入方式

实现层按平台分三个子类,用一个工厂在运行时选择:

public static class SoftKeyboardFactory { public static ISoftKeyboardService Create() { #if UNITY_ANDROID || UNITY_IOS return new MobileSoftKeyboardService(); #elif UNITY_WEBGL return new WebGLSoftKeyboardService(); #elif UNITY_STANDALONE_WIN return new WindowsSoftKeyboardService(); #else return new NullSoftKeyboardService(); #endif } }

NullSoftKeyboardService是个空实现,所有方法什么都不做,IsSupported返回 false。它的作用是让不支持键盘的平台上业务代码不至于崩,只需要在调用处判断一下IsSupported决定要不要显示"请用系统键盘输入"的提示。

接入方式上,我倾向于在游戏的启动入口把服务注册进去,页面层通过一个静态访问点拿实例。这里要小心一点:编辑器里跑的时候走哪个分支。#if UNITY_ANDROID在编辑器下是不成立的,所以如果不额外处理,编辑器里永远走 Null 分支,测试的时候输入框完全没反应,很容易误以为代码坏了。我的做法是在编辑器下也返回移动端实现,只是内部对TouchScreenKeyboard的调用做了空判断——这样在编辑器里至少能测通流程,真机再验细节。

#if UNITY_EDITOR return new MobileSoftKeyboardService(); // 编辑器下走移动端逻辑,方便调试 #endif

最后再补一个小技巧,是我踩坑之后总结的:给键盘加一个全局的"最后操作时间"记录。当游戏进入后台再回来,或者长时间没有输入操作时,主动检查一下键盘实例是否还存活,如果系统已经把它回收了而你的引用还在,及时清空,避免后续读到脏的text。这个检查成本极低,但能规避一类很难复现的偶发问题——尤其是那种"用户反馈偶尔输入框里的字变成上一次的内容"的诡异 bug。

我在几个项目里反复调这套东西,最深的一点体会是,Unity 的系统键盘调用看起来只是两个 API,真正的工作量全在边界情况上:输入法组合、平台差异、遮挡避让、焦点切换。这些细节单拎出来都不难,难的是它们会同时出现,而且只在真机上、只在特定的输入法里、只在用户操作足够快的时候才暴露。所以我的建议永远是,输入功能一旦写完,别在编辑器里点两下就交付,找几台不同品牌的真机,切换不同的输入法,用中文、英文、数字、表情各试一遍,把异常流程(中途返回、切后台、来电)也走一遍,这才是真正能过验收的测试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询