1. 先想清楚要不要用:Unity 调用系统自带的虚拟键盘的整体设计
做移动端 Unity 项目,只要涉及玩家输名字、填账号、发聊天、改公会公告,输入框这件事就绕不过去。我自己第一次在这上面翻车,是给一个模拟经营项目做存档命名功能,当时觉得"就一个输入框嘛",随手用图片拼了个九宫格自绘键盘,字母、数字、退格、确认,做得还挺精致。上线第一天收到的反馈是:日文玩家打不出假名,韩文玩家打不出谚文,中文玩家想打"店铺"两个字得先切出去用别的 App 复制粘贴。那一刻我才真正意识到,Unity 调用系统自带的虚拟键盘不是一个"可选优化项",而是多语言产品的底线需求。
这篇文章聊的就是怎么把这件事做扎实。核心工具是 Unity 内置的TouchScreenKeyboard类,它本质上是一层跨平台封装,Android 上它去调系统的输入法服务,iOS 上它去拉起原生的UITextField键盘,Windows 独立平台会弹出一个系统输入窗口。用它,你能在几乎不改业务代码的前提下拿到全语种输入能力。适合谁看?刚接手移动端项目的 Unity 开发、被输入法搞得焦头烂额的客户端同学,以及正在纠结"到底自绘还是调系统"的技术负责人。读完你应该能自己判断方案、写出稳定组件、并且知道那些官方文档里没写的坑长什么样。
这里先摆一个结论,省得你来回摇摆:只要你的输入场景涉及自然语言文本,就老老实实调系统键盘;只有在输入内容是强约束的短数字串(PIN 码、房号、金额)或需要贴合美术风格的场景下,自绘才有价值。原因后面慢慢拆。
1.1 自绘键盘看起来很美,坑在后面
自绘键盘最吸引人的点在于"完全可控"。美术能把它做成游戏世界观里的一部分,按钮圆角、按下动效、音效反馈全都能定制,甚至可以在键盘上方直接挂一排常用短语快捷输入。对一款注重沉浸感的产品来说,这确实有吸引力。
但代价清单拉出来会劝退大部分人。第一是输入法。中文拼音输入涉及候选词、词组联想、多音字,日文涉及罗马字转假名、假名转汉字的多段转换,韩文涉及字母组合成音节,阿拉伯语涉及从右往左排版和字母连写变形。这套逻辑是输入法引擎几十年的积累,自己在 Unity 里重写一遍基本不现实。第二是系统能力。剪贴板粘贴、长按选中、双击选词、撤销、语音转文字、密码管理器的自动填充,这些用户在别的地方用惯了,你的输入框没有,体验就是割裂的。第三是维护成本。光是各家机型的安全区、横竖屏切换、折叠屏展开收起,就足够吃满一个版本的排期。
所以我的一般做法是:文本类输入一律走系统键盘,把自绘精力省下来去做真正影响体验的东西。这不是偷懒,是把有限的工程资源投到收益更高的地方。
1.2 三条接入路径的取舍
在 Unity 里调起系统键盘,实际有三条路可走,很多人只知道其中一条,选错了后面会反复返工。
| 路径 | 实现方式 | 适用场景 | 主要代价 |
|---|---|---|---|
| UI 组件托管 | InputField/TMP_InputField | 常规表单、登录、聊天 | 样式受组件限制,深度定制要改源码 |
| 直接调 API | TouchScreenKeyboard.Open | 自绘输入框外观、需要精细控制时机 | 状态、文本同步、遮挡全要自己处理 |
| 原生插件 | Android/iOS 原生控件桥接 | 极端定制、需要原生特性的场景 | 双端各写一套,联调成本高 |
绝大多数项目落在第一和第二条之间。如果你的输入框长得跟标准 UI 一样,直接用TMP_InputField就行,它内部已经帮你把TouchScreenKeyboard的生命周期管好了。但如果你的输入界面是美术出的图,输入光标要跟着动效走,或者你需要在一个非 UI 的 3D 场景里让玩家输入文字(比如在一块木牌上刻字),那TMP_InputField就不好使了,得自己上手调TouchScreenKeyboard。
原生插件这条路我建议能不走就不走。除非你需要某些系统键盘特有而 Unity 封装没暴露的能力,否则双端维护两套原生代码,收益完全撑不起成本。真要做,也得先确认 Unity 当前版本的封装到底缺了什么。
2. 吃透 TouchScreenKeyboard:参数、状态机与平台差异
TouchScreenKeyboard这个类看起来很简单,十几个成员,但真正用起来,一半以上的 bug 都出在对它行为的误判上。它不是一个"打开就完事"的开关,而是一个有生命周期的异步对象,你得按它的规则来。
2.1 Open 方法的八个参数逐个拆
静态方法TouchScreenKeyboard.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是初始文本,调起键盘时会预填进去,同时光标默认落在末尾。注意它只在这个时刻生效一次,之后你改这个字符串对键盘没有任何影响,要改得走keyboard.text。
keyboardType决定弹出哪种布局,这个参数最容易被忽略,但影响巨大。给手机号输入配Default,用户看到的是全键盘,还得自己切数字页;配PhonePad就直接是拨号盘。可选的枚举值包括Default、ASCIICapable、NumbersAndPunctuation、URL、NumberPad、PhonePad、NamePhonePad、EmailAddress、Social,部分版本还有Search和DecimalPad。我的经验是:能明确约束输入类型的,一定别用 Default。邮箱用EmailAddress会带上@和.的快捷位置,网址用URL会带/,这些小细节用户感知很强。
autocorrection在 iOS 上控制是否启用自动更正。这里有个反直觉的点:开关打开后,用户输入的内容可能在你不注意的时候被系统替换掉。比如用户打了 "teh",系统在失焦时自动改成 "the",而你的keyboard.text在轮询里拿到的可能是替换前也可能是替换后的值,取决于时机。对玩家昵称、角色名这类不能随便改的内容,我一般直接关掉。
multiline决定回车键的行为。设为true时回车是换行,设为false时回车是"完成",会触发Status.Done。这里要注意 Android 上某些输入法在multiline = false时,回车键的标签会随keyboardType变化,比如邮箱类型下显示的是个箭头图标而不是"完成"。
secure控制密码模式,开启后字符显示为圆点,同时系统会禁用这个输入框的剪贴板复制、输入法联想和截屏(部分系统)。做密码框必须开,但要知道副作用:开了 secure 之后,很多输入法的候选词条会失效,中文用户会打不出字。所以千万别在需要输入中文的地方开 secure。
alert这个参数在绝大多数版本里已经没什么实际效果了,历史遗留,随便传false就行。
textPlaceholder是占位提示文字,键盘上没内容时显示。iOS 上原生支持,Android 上支持度参差,别指望它一定出现。
characterLimit是最容易出问题的一个。传 0 表示不限,传正整数表示上限。坑在于:它约束的是最终提交的字符数,但输入法组合过程中的中间态不受约束。中文用户打拼音的时候,键盘上会先出现一串字母,这时候keyboard.text的长度可能远远超过你的限制,等你按了空格选定汉字才会真正收敛。所以在OnTextChanged里做"超过长度就截断"是危险的,会把正在组合的拼音截坏。
2.2 Status 状态机与轮询节奏
TouchScreenKeyboard没有事件回调,你只能自己在Update里轮询。核心状态有三个:Visible(键盘还开着)、Done(用户点了完成)、Canceled(用户点了取消或返回键)。另外还有一个LostFocus,表示键盘还在但焦点被别的窗口抢走了,比如用户下拉通知栏、切到后台、或者弹了个系统权限框。
这里最容易犯的错是把LostFocus当成"输入结束"来处理。我见过有项目在LostFocus时直接提交表单,结果用户只是瞥了一眼通知栏,表单就被提交了,非常尴尬。正确做法是:LostFocus时保留当前文本、不提交、不做任何不可逆操作,等用户回来继续;只有Done才提交。
轮询的写法有个细节值得说。很多人这么写:
void Update() { if (keyboard == null) return; if (keyboard.status == TouchScreenKeyboard.Status.Done) { Submit(keyboard.text); keyboard = null; } }问题在于keyboard.text在某些平台上,Done之后再读可能拿到空串或者上一次的值,取决于时序。稳妥的做法是在每次文本变化时就缓存下来,Done时用缓存值提交:
private string cached = string.Empty; void Update() { if (keyboard == null) return; if (keyboard.text != cached) { cached = keyboard.text; // 更新界面显示 } var s = keyboard.status; if (s == TouchScreenKeyboard.Status.Done || s == TouchScreenKeyboard.Status.Canceled) { var final = cached; var ok = (s == TouchScreenKeyboard.Status.Done); keyboard = null; if (ok) Submit(final); else Cancel(); } }还有一点:不要每帧调用Open。有些项目为了"保证键盘一直开着",在Update里判断!TouchScreenKeyboard.visible就重开,结果是在 Android 上疯狂触发输入法启停,卡顿到没法用。Open只在用户明确点击输入框时调一次,之后交给状态机。
2.3 各平台能力对照
同一个 API,在不同平台上能力差异很大,不做平台判断会出各种怪问题。这张表是我自己在多个项目上验证过的结论,可以当参考基线。
| 能力 | Android | iOS | Windows 独立 | WebGL | 主机平台 |
|---|---|---|---|---|---|
| 基本调起 | 支持 | 支持 | 支持(系统输入窗) | 较新版本支持 | 不支持 |
isSupported | true | true | true | 视版本 | false |
area(键盘区域) | 部分版本支持 | 支持较好 | 不支持 | 不支持 | 不支持 |
| 中文组合输入 | 需要额外处理 | 系统自动 | 系统自动 | 视浏览器 | 不适用 |
secure密码模式 | 支持 | 支持 | 支持 | 有限 | 不适用 |
| 键盘遮挡通知 | 靠 Screen 变化 | 靠area | 无 | 无 | 不适用 |
| 多行换行行为 | 受输入法影响 | 稳定 | 稳定 | 视版本 | 不适用 |
关键点在于TouchScreenKeyboard.isSupported。在编辑器里它是false,所以你的代码在编辑器里跑的时候,Open调用不会有任何可见效果——这是正常的,不代表你写错了。我在编辑器里调试输入逻辑时,一般会写一个模拟输入分支,直接读Input.inputString或者用TMP_InputField顶上,把状态机跑通,再到真机上验证。
3. 完整实操:从零搭一个可用的原生输入组件
理论说完了,下面走一遍完整实现。目标是一个能挂在任意输入区域上的组件:点击调起系统键盘、实时同步文本、处理中文组合输入、自动应对键盘遮挡、在编辑器里能降级模拟。
3.1 组件框架与核心代码
先把骨架搭出来。核心思路是:组件本身不继承InputField,只负责管理TouchScreenKeyboard的生命周期,文本变化通过事件抛给业务层,显示层自己做。
using System; using UnityEngine; public class SystemKeyboardField : MonoBehaviour { [SerializeField] private TouchScreenKeyboardType keyboardType = TouchScreenKeyboardType.Default; [SerializeField] private bool multiline = false; [SerializeField] private bool secure = false; [SerializeField] private bool autocorrection = false; [SerializeField] private int characterLimit = 24; [SerializeField] private string placeholder = "请输入内容"; public event Action<string> TextChanged; public event Action<string> Submitted; public event Action Canceled; public event Action<bool> KeyboardVisibilityChanged; private TouchScreenKeyboard keyboard; private string cached = string.Empty; private bool lastVisible; public bool IsOpen => keyboard != null; public void Open(string initial) { if (keyboard != null) return; cached = initial ?? string.Empty; if (!TouchScreenKeyboard.isSupported) { // 编辑器或主机平台,走模拟输入 SimulateInput(cached); return; } keyboard = TouchScreenKeyboard.Open( cached, keyboardType, autocorrection, multiline, secure, false, placeholder, characterLimit); if (keyboard == null) { // 某些设备调起失败,降级 SimulateInput(cached); } } public void Close() { if (keyboard == null) return; keyboard.active = false; keyboard = null; } private void Update() { if (keyboard == null) return; bool visible = TouchScreenKeyboard.visible; if (visible != lastVisible) { lastVisible = visible; KeyboardVisibilityChanged?.Invoke(visible); } if (!string.Equals(keyboard.text, cached, StringComparison.Ordinal)) { cached = keyboard.text; TextChanged?.Invoke(cached); } var status = keyboard.status; if (status == TouchScreenKeyboard.Status.Done) { keyboard = null; Submitted?.Invoke(cached); } else if (status == TouchScreenKeyboard.Status.Canceled) { keyboard = null; Canceled?.Invoke(); } // LostFocus 不做任何处理,保留状态等用户回来 } private void SimulateInput(string initial) { // 编辑器里用 Debug 面板 + Input.inputString 做简易模拟,仅用于逻辑验证 cached = initial; TextChanged?.Invoke(cached); } }这段代码有几个刻意的设计。IsOpen暴露成属性而不是让外部访问keyboard,是为了防止业务代码乱改键盘状态。KeyboardVisibilityChanged单独抛一个事件,是因为键盘显隐往往要驱动别的 UI 变化(比如隐藏底部导航栏、收起悬浮按钮),跟文本变化解耦更清晰。SimulateInput在编辑器里只做最小模拟,重点是把状态流转跑通。
业务层的接法就很干净了:
[SerializeField] private SystemKeyboardField field; [SerializeField] private TMP_Text display; void OnEnable() { field.TextChanged += OnText; field.Submitted += OnSubmit; field.Canceled += OnCancel; field.KeyboardVisibilityChanged += OnKeyboardToggle; } void OnText(string t) => display.text = t; void OnSubmit(string t) { /* 存档、提交服务器 */ } void OnCancel() { /* 恢复原值 */ } void OnKeyboardToggle(bool visible) { /* 调整界面 */ }3.2 中文组合输入的处理
这是整个话题里最有技术含量的部分,也是最多人卡住的地方。中文拼音输入的过程是这样的:用户按下n、i、h、a、o,屏幕上先出现 "nihao" 这串字母,同时输入法上方弹出候选词列表。这时候keyboard.text里到底有没有 "nihao"?
答案是:看平台和版本,不能依赖。在 Android 上,组合中的字符串很多时候不会立刻写进keyboard.text,或者写进去的时机跟你预期不一致。如果你在TextChanged回调里对文本做截断、做正则校验、做敏感词过滤,很可能把用户正在拼的拼音判成非法输入直接清空,用户体验极差。
处理办法是拿keyboard.input属性。这个属性专门用来暴露当前输入法正在组合的字符串,在 Android 和 Windows 平台有效,iOS 上返回空串(因为 iOS 的组合过程不通过这个接口暴露)。拿到它之后,你的逻辑可以分成两路:
// 组合中:只更新预览显示,不做校验、不写入最终值 if (!string.IsNullOrEmpty(keyboard.input)) { previewLabel.text = cached + keyboard.input; return; } // 组合结束:cached 才是可信值,这时候再做长度和内容校验这样区分之后,拼音阶段只是"预览",用户看到的是自己打的字母,不会被打断;等选定汉字,input变空,cached收敛到最终值,这时候再跑你的业务校验。这个思路在日文假名转换上同样适用。
如果你的项目已经迁移到新输入系统,中文组合的判断方式会不一样,可以监听Keyboard.current.onIMECompositionChange拿到组合字符串。旧输入系统的Input.compositionString也有类似作用,但它和TouchScreenKeyboard是两套独立机制,混用容易出错,我不建议在同一个输入框里同时依赖两者。
还有一个相关配置要提醒:不要在 Android 上同时开启secure和输入中文。前面说过,secure = true会让系统关闭这个输入框的联想和组合能力,表现就是用户切到中文输入法后打字毫无反应。这个 bug 的隐蔽性在于它不是崩溃,只是"没反应",测试时如果只看崩溃日志根本发现不了。
3.3 键盘遮挡与界面适配
键盘弹出来会占掉屏幕下半部分,如果你的输入框正好在那个位置,用户就看不见自己打的字了。这不是 Unity 特有的问题,但 Unity 的处理方式跟原生不太一样。
Android 上最直接的方案是在AndroidManifest.xml里给 Activity 设置windowSoftInputMode。把配置放在Assets/Plugins/Android/AndroidManifest.xml:
<activity android:name="com.unity3d.player.UnityPlayerActivity" android:windowSoftInputMode="adjustResize" ...>adjustResize会让窗口在键盘弹出时缩小,Unity 里的Screen.height随之变小,你能感知到变化。adjustPan则是把整个窗口往上推,Screen.height不变但内容位置偏移,处理起来更麻烦。我的建议是用adjustResize,然后监听Screen.height的变化来重排界面。
private int lastHeight; void Start() { lastHeight = Screen.height; } void Update() { if (Screen.height == lastHeight) return; int delta = lastHeight - Screen.height; lastHeight = Screen.height; // delta > 0 表示键盘弹起,把输入区域上移 delta 像素 ApplyOffset(delta > 0 ? delta : 0); }要注意Screen.height在横屏模式下也会变化(横屏时高度是屏幕的短边),所以这个写法在横竖屏都成立。但有个小陷阱:在某些机型上,键盘收起时窗口尺寸变化会有延迟,delta 会出现抖动,建议加个阈值过滤,比如Mathf.Abs(delta) > 50才响应。
iOS 上处理更优雅,TouchScreenKeyboard.area能直接返回键盘在屏幕上占据的矩形区域:
var area = TouchScreenKeyboard.area; // 单位是 Unity 的屏幕像素 if (area.height > 0) { // area.y 是键盘顶部的坐标,把输入框保持在 area.y 之上 }不过area的坐标系是左下角原点,和 UI 的坐标系方向相反,换算的时候要用Screen.height - area.yMax。我第一次用这个属性时在这上面绕了半小时,实测下来挺坑的。
还有一个通用做法,不管哪个平台都建议配上:把输入区域放在一个ScrollRect里,键盘弹起时自动滚动到输入框可见的位置。逻辑不复杂,计算输入框在视口里的位置,超出可视区域就ScrollRect.verticalNormalizedPosition调一下。这套方案的好处是它对所有平台都成立,不依赖平台特有的键盘高度信息。
3.4 工程配置清单
代码之外,还有几处工程配置容易漏,列一下:
| 配置项 | 位置 | 说明 |
|---|---|---|
| 软键盘模式 | AndroidManifest.xml | 建议adjustResize,便于感知键盘高度 |
| 输入法兼容声明 | AndroidManifest.xml | 部分定制系统的输入法需要显式声明窗口属性 |
| 安全区适配 | Canvas / SafeArea 组件 | 折叠屏、挖孔屏必须处理 |
| 输入系统选择 | Project Settings → Player | 新旧输入系统会影响组合字符串的获取方式 |
| 平台宏开关 | 代码层 | 用UNITY_EDITOR、UNITY_ANDROID、UNITY_IOS分支处理差异 |
| 自动化测试覆盖 | CI 流程 | 编辑器里跑不到真键盘,必须有真机冒烟测试环节 |
最后一条是血泪教训。我们当时在 CI 上把输入框相关的逻辑全跑绿了,自以为万事大吉,结果真机上发现安卓折叠屏展开后输入框被键盘完全遮挡。编辑器测试只能验证状态机,验证不了键盘行为,真机冒烟这一步不能省。
4. 踩坑实录:典型故障与排查速查表
下面这些故障现场,来自我参与过的几个项目的真实反馈单(细节做过处理),基本覆盖了TouchScreenKeyboard八成以上的问题。
4.1 六个高频故障现场
现场一:点了输入框,键盘闪一下就消失。第一次遇到这个是在一个聊天功能里,现象是键盘弹起不到半秒就自动收起。排查了半天,最后发现是场景里另一个系统也在监听点击事件,同时触发了一个关闭面板的逻辑,面板关闭时把输入法也带走了。根因是多个组件同时争夺输入焦点。解决办法是把键盘的生命周期收敛到单一组件,其他系统只发事件不直接操作。
现场二:中文输入打完拼音,选完字,输入框里还是空的。这是最有代表性的问题。原因是业务代码在Done状态时读keyboard.text,而某些 Android 输入法在确认完成时,文本写入有延迟,读到的是前一帧的值。改成缓存制之后就再没出现过。
现场三:切到后台再回来,键盘还在但输入框失焦,继续打字没反应。这是LostFocus没处理好。我们的做法是在OnApplicationPause(false)时检查keyboard.status,如果是LostFocus,就把键盘关掉并保留文本,让用户点一下重新唤起。比尝试恢复焦点要稳。
现场四:iOS 上密码框打不开键盘。排查发现是secure = true和某个自定义键盘类型组合导致的。iOS 对secure模式下的键盘类型有限制,某些组合不被支持,Open返回的对象状态异常。解决办法是密码场景统一用Default类型,不要叠加其他类型参数。
现场五:Android 12 以上机型,键盘遮挡严重。新版系统对adjustResize的行为做了调整,Screen.height的变化时机和老版本不同,我们原来写的 50 像素阈值过滤失效了。改成用TouchScreenKeyboard.visible配合一次延时采样来判定。
现场六:编辑器里一切正常,真机上输入框不出现。最常见的原因是Open调用前没有判断isSupported,或者判断了但逻辑分支写反了。编辑器永远返回false,如果代码是if (!isSupported) return;,编辑器里就永远走不到打开逻辑——这本身没错,但如果编辑器测试依赖真实键盘就会出现认知偏差。
4.2 排查速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 键盘完全不出 | isSupported判断反了,或平台不支持 | 检查平台宏,主机平台需自备方案 |
| 键盘闪退 | 多个组件争夺焦点,或面板被关闭 | 收敛到单一管理组件 |
| 中文输入丢失 | 组合字符串未区分处理 | 用keyboard.input分离组合态 |
| 密码框无反应 | secure与键盘类型冲突 | 密码场景统一Default |
| 键盘遮挡 | 软键盘模式配置不当 | 改adjustResize并监听高度 |
| 提交内容为空 | Done时实时读取有时序问题 | 改用缓存值提交 |
| 切后台回来失效 | LostFocus未处理 | 检测后关闭键盘,保留文本 |
| 输入超长被截断 | 在组合态做了长度校验 | 只在收敛后校验 |
iOSarea位置错 | 坐标系翻转未处理 | 用Screen.height - area.yMax |
| 折叠屏布局错乱 | 未做安全区适配 | 接入 SafeArea 并监听尺寸变化 |
4.3 几条压箱底的实操经验
最后分享几条我在多个项目里验证过、但文档里基本不会提的做法。
第一,把输入框的"打开"和"关闭"做成幂等操作。不管业务层怎么乱调,组件内部保证重复调Open不会重复弹键盘、重复调Close不会崩溃。这层保护能挡掉大量因为事件重复触发导致的诡异问题。实现很简单,就是开头那个if (keyboard != null) return;。
第二,给输入框加一个"软键盘兜底"的降级路径。主机平台、某些定制 Android 设备、部分云游戏环境,系统键盘可能压根调不起来。这时候要么退回到自绘的简易键盘,要么直接改交互设计,让用户从预设列表里选名字。这个降级分支平时不生效,但一旦触发能避免整个功能彻底不可用。
第三,测试清单里一定要有"输入法切换"这一项。我见过太多项目只测系统默认输入法,一换成第三方输入法就崩。不同输入法对multiline、secure、characterLimit的处理都有差异,至少要覆盖系统输入法加两款主流第三方输入法的组合测试。
第四,别在键盘打开期间做 Canvas 重建这种重活。Android 上键盘弹起会触发窗口尺寸变化,如果这个时机正好叠加了 UI 重建,很容易掉帧到个位数。把重排逻辑做轻,能复用就复用,别每次都重新实例化列表项。
第五,characterLimit只在需要硬约束的地方用,比如玩家 ID 只能 12 位。对于留言、简介这类内容,用代码校验加提示比硬截断友好得多,因为硬截断会让用户打到一半突然打不进去,不知道发生了什么。真要限制,也应该在提交时给明确提示,而不是在输入过程中静默拦下。
第六,组件的TextChanged事件别做得太重。键盘输入是逐字符触发的,中文用户可能一秒触发好几次。如果每次回调都去做服务器校验、去查敏感词库,主线程会被拖住。我通常做一个 200 到 300 毫秒的防抖,等用户停顿了再跑重逻辑,输入框本体的显示更新仍然走即时路径。
这套东西做下来,输入功能基本就能稳定交付了。剩下的就是针对自己项目的输入场景做取舍——哪些地方必须系统键盘,哪些地方可以自绘兜底,哪些地方干脆改成点选。判断标准和上面拆的是一样的:自然语言走系统,强约束短输入可以自绘,极端环境留降级。