第 1 周做完 TextView 后,页面终于能把文字稳定地展示出来了。到了第 2 周,事情开始往前走一步:用户不只是看,还要开始输入。
很多人第一次接触 EditText,会觉得它就是"能打字的 TextView"。这个理解不能说完全错,但太浅了。真正写业务以后你会发现,输入框一旦出问题,用户的感受会特别明显:键盘弹不出来、密码切换后光标乱跳、验证码输入不了、搜索每打一个字就卡一下、退出页面后监听器还没清理,这些都不是"好不好看"的问题,而是页面有没有专业度的问题。
所以第 2 周的目标很明确:把 EditText 从"能输入"做到"输入得舒服、稳定、可控"。

这周到底要解决什么
第 2 周主题是:EditText 全功能 + 输入体验优化。
这一周要把下面这些点真正跑通:
-
输入类型限制:
inputType -
自定义过滤规则:
InputFilter -
密码显隐:
TYPE_TEXT_VARIATION_PASSWORD/TYPE_TEXT_VARIATION_VISIBLE_PASSWORD -
字数校验:
LengthFilter+TextWatcher -
软键盘控制:
InputMethodManager -
光标操作:
setSelection -
文本监听:
TextWatcher -
输入防抖:
DebouncedTextWatcher -
生命周期清理:
removeTextChangedListener和Handler.removeCallbacks
看起来很多,但它们其实都围绕同一件事:输入过程必须可治理。
先从输入边界开始:用户能输入什么,不应该最后才知道
真实项目里,很多输入框不是"什么都能输入"。用户名可能只能允许字母、数字和下划线;验证码只能是数字;昵称有最大长度;简介不能无限输入。
这时候第一层能力就是 inputType 和 InputFilter。
bash
binding.etUsername.apply {
inputType=InputType.TYPE_CLASS_TEXTorInputType.TYPE_TEXT_VARIATION_NORMAL
filters=arrayOf(
InputFilter.LengthFilter(12),
EditTextExperienceOptimizer.usernameFilter()
)
}
这段代码做了两件事:
-
inputType:告诉系统这是一个普通文本输入场景,系统会据此决定键盘类型和输入行为 -
InputFilter.LengthFilter(12):限制最多输入 12 个字符 -
usernameFilter():自定义过滤规则,只允许特定字符进入输入框
如果没有这些限制,很多错误会被拖到"点击提交"那一刻才暴露。对用户来说,这种体验很糟:他已经输入了一长串,最后你才告诉他"不合法"。更好的做法是,在输入入口就把边界立住。
自定义过滤规则可以这样写:
bash
funusernameFilter(): InputFilter {
returnInputFilter { source, start, end, _, _, _->
valfiltered=buildString {
for (indexinstartuntilend) {
valchar=source[index]
if (char.isLetterOrDigit() ||char=='_') {
append(char)
}
}
}
if (filtered.length==end-start) nullelsefiltered
}
}
这里有一个初学者很容易漏掉的点:InputFilter 返回 null 表示"原样接受输入",返回一个字符串表示"用这个过滤后的内容替换原输入"。所以这段代码不是提交时校验,而是字符进入 EditText 前就先筛一遍。
这就是输入治理的第一步:不合法的东西,最好不要让它进来。
密码显隐:别只会切换,还要保住光标
密码显隐看起来很简单:点一下显示密码,再点一下隐藏密码。很多人第一次写的时候也确实能跑起来,但会遇到一个很烦的小问题:切换后光标位置乱跳。
这不是小事。用户正在输入密码,你一切换显示状态,光标跳到开头或者末尾,他马上会觉得这个输入框"不顺手"。

bash
privatefuntogglePasswordVisible() {
valselection=binding.etPassword.selectionEnd.coerceAtLeast(0)
passwordVisible=!passwordVisible
binding.etPassword.inputType=InputType.TYPE_CLASS_TEXTorif (passwordVisible) {
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
} else {
InputType.TYPE_TEXT_VARIATION_PASSWORD
}
binding.etPassword.setSelection(selection.coerceAtMost(binding.etPassword.text.length))
binding.btnTogglePassword.text=if (passwordVisible) "隐藏密码"else"显示密码"
}
这段代码的关键不是 passwordVisible 这个布尔值,而是这三步:
-
切换前先保存
selectionEnd -
修改
inputType -
用
setSelection()把光标恢复回去
如果少了第三步,密码显隐看起来也许能用,但体验会很粗糙。写输入框的时候,这种"看起来没大问题"的细节,往往就是专业和不专业的分界线。
字数校验:LengthFilter 管上限,TextWatcher 管反馈
很多输入框都要做字数限制,比如昵称 12 个字、简介 80 个字、评论 500 个字。这里至少要分清两件事:
-
InputFilter.LengthFilter:负责硬性限制输入上限 -
TextWatcher:负责在输入过程中给用户实时反馈
bash
binding.etBio.apply {
inputType=InputType.TYPE_CLASS_TEXTorInputType.TYPE_TEXT_FLAG_MULTI_LINE
filters=arrayOf(InputFilter.LengthFilter(80))
}
attachSimpleWatcher(binding.etBio) {
binding.tvCounterState.text="简介字数:${binding.etBio.text.length}/80"
}
这里 LengthFilter(80) 保证用户最多只能输入 80 个字符,TextWatcher 则让用户随时知道自己已经输入了多少。
这比"提交时才告诉你超字数"要友好得多。输入体验的很多优化,不是靠炫技,而是靠这种及时反馈一点点堆出来的。
TextWatcher 的基础写法是这样的:
bash
privatefunattachSimpleWatcher(editText: EditText, afterChanged: () ->Unit) {
valwatcher=object : TextWatcher {
overridefunbeforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) =Unit
overridefunonTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) =Unit
overridefunafterTextChanged(s: Editable?) =afterChanged()
}
editText.addTextChangedListener(watcher)
normalWatchers+=editTexttowatcher
}
TextWatcher 有三个回调:
-
beforeTextChanged:文本变化前 -
onTextChanged:文本变化过程中 -
afterTextChanged:文本变化后
真实业务里最常用的是 afterTextChanged,因为这时候拿到的内容已经是最终结果,适合做计数、按钮状态更新、简单校验。
软键盘控制:先有焦点,再谈弹键盘
EditText 里一个很常见的误区是:我调用了显示键盘的方法,为什么键盘没弹?
原因经常是:输入框还没有焦点。
bash
privatefunshowKeyboard(editText: EditText) {
editText.requestFocus()
valimm=getSystemService(Context.INPUT_METHOD_SERVICE) asInputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
privatefunhideKeyboard() {
valtarget=currentFocus?: binding.root
valimm=getSystemService(Context.INPUT_METHOD_SERVICE) asInputMethodManager
imm.hideSoftInputFromWindow(target.windowToken, 0)
}
这段代码里有两个专业名词一定要记住:
-
requestFocus():让某个输入框成为当前焦点目标 -
InputMethodManager:Android 里负责和输入法交互的系统服务
显示键盘时,先 requestFocus(),再 showSoftInput()。隐藏键盘时,需要通过当前 View 的 windowToken 告诉系统"我要隐藏这个窗口上的输入法"。
这类代码看起来不像业务逻辑,但它直接决定输入体验。搜索页、登录页、评论弹窗、聊天输入框,全都会遇到它。
光标操作:setSelection 是小 API,但很常用
输入框里还有一个细节经常被忽略:光标位置。
比如用户点"编辑简介",你希望光标默认在末尾;点"补充前缀",你希望光标移动到开头;密码显隐切换后,你希望光标还停在原来的地方。这些都离不开 setSelection()。
bash
privatefunmoveBioCursor(toEnd: Boolean) {
binding.etBio.requestFocus()
valtarget=if (toEnd) binding.etBio.text.lengthelse0
binding.etBio.setSelection(target)
showKeyboard(binding.etBio)
}
这里一定要注意:setSelection() 的目标位置不能越界。比如文本长度是 10,你不能把光标设置到 20,否则就可能直接崩。
这也是为什么密码显隐那段代码里要写:
bash
binding
.
etPassword
.
setSelection
(
selection
.
coerceAtMost
(
binding
.
etPassword
.
text
.
length
))
coerceAtMost() 的作用就是把光标位置控制在合法范围内。
输入防抖:不要每输入一个字就做重任务
第 2 周最重要的优化意识,就是输入防抖。
想象一个搜索框:用户输入"android",如果你每打一个字就请求一次网络,那么 a、an、and、andr、andro、android 都会触发请求。这不仅浪费资源,还可能造成结果乱序、页面卡顿和状态混乱。
更合理的方式是:用户停下来一小段时间后,再执行真正的逻辑。
bash
classDebouncedTextWatcher(
privatevaldelayMillis: Long=300L,
privatevalonTextDebounced: (String) ->Unit
) : TextWatcher {
privatevalhandler=Handler(Looper.getMainLooper())
privatevarpendingTask: Runnable?=null
overridefunbeforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) =Unit
overridefunonTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
pendingTask?.let(handler:: )
valsnapshot=s?.toString().orEmpty()
pendingTask=Runnable { onTextDebounced(snapshot) }
handler.postDelayed(pendingTask!!, delayMillis)
}
overridefunafterTextChanged(s: Editable?) =Unit
funclear() {
pendingTask?.let(handler::removeCallbacks)
pendingTask=null
}
}
这段代码做了一个很经典的防抖动作:
-
每次输入变化,先取消上一次还没执行的
Runnable -
保存这次输入的文本快照
-
延迟一段时间再执行真正逻辑
-
如果这段时间内用户继续输入,就继续取消并重新计时
防抖不是为了"显得高级",它解决的是高频输入带来的性能和状态问题。搜索联想、用户名可用性校验、远程表单校验,都很需要它。
生命周期清理:TextWatcher 用完要拆
这一点非常重要,但很多初学者会跳过去。
TextWatcher 是挂在 EditText 上的监听器;防抖里还有 Handler 和延迟任务。如果页面退出后这些东西还没清理,轻则逻辑继续执行,重则可能让 Activity 被间接持有,增加内存泄漏风险。
bash
overridefunonDestroy() {
normalWatchers.forEach { (editText, watcher) ->
editText.removeTextChangedListener(watcher)
}
debounceWatchers.forEach { (editText, watcher) ->
editText.removeTextChangedListener(watcher)
watcher.clear()
}
normalWatchers.clear()
debounceWatchers.clear()
super.onDestroy()
}
这段代码做的是"收尾工作":
-
removeTextChangedListener():把监听器从EditText上拆下来 -
watcher.clear():取消还没执行的延迟任务 -
clear():清空集合引用
说实话,这类代码写起来不刺激,也不酷,但它很专业。因为真正稳定的页面,不只是进入时能跑,还要退出时干净。
为什么第 2 周也要做成可交互 Demo
这周的 Demo 不是只摆几个输入框,而是做成了一个完整的输入实验区:
-
用户名输入:看
inputType和InputFilter -
密码输入:看显隐切换和强度提示
-
简介输入:看字数统计和防抖预览
-
验证码输入:看数字输入和实时监听
-
控制按钮:看软键盘、焦点和光标操作
-
功能卡片:把每个输入能力拆开讲清楚
-
验收页:把本周交付结果做成可复盘材料
这样做有一个很大的好处:你不是只学"某个 API 怎么写",而是在训练自己把输入框当成一个完整系统来处理。
这一周最容易踩的坑
1. 只用 inputType,不做过滤
inputType 可以影响键盘和输入场景,但它不等于业务规则。比如你设置了文本键盘,不代表用户就不能粘贴非法字符。所以真正重要的输入边界,还是要配合 InputFilter 或提交前校验。
2. 密码显隐后忘记恢复光标
这个问题很细,但用户非常容易感觉到。只要光标一乱跳,输入就会变得别扭。
3. 在 TextWatcher 里直接做重任务
每输入一个字符都会触发监听。如果你在里面直接查数据库、发网络请求、做复杂计算,很容易把输入体验拖慢。防抖就是为了解决这类问题。
4. 页面退出时不清理监听器
Demo 里可能看不出问题,但真实项目里页面越来越多、输入框越来越复杂,不清理监听器会让问题越来越难查。
这一周真正学到的是什么
第 2 周表面上学的是 EditText,但真正学的是输入体验的完整链路:
-
输入前:用
inputType和InputFilter控制边界 -
输入中:用
TextWatcher和计数提示给用户反馈 -
输入后:用校验、防抖和状态快照控制业务逻辑
-
页面交互:用焦点、软键盘、光标让输入过程更顺
-
页面销毁:移除监听和延迟任务,保证收尾干净
到这里你会发现,EditText 真的不是"能打字"那么简单。它是用户和 App 对话的入口。入口体验做得粗糙,后面页面再漂亮也很难救回来。
下一步怎么走
第 3 周会进入基础按钮组件:Button、CheckBox、RadioButton、Switch 等。
如果说第 2 周解决的是"用户怎么输入",那第 3 周要解决的就是"用户怎么选择、确认和触发动作"。到时候会开始接触按钮状态、选中逻辑、水波纹、重复点击拦截和状态切换优化。
在进入第 3 周之前,建议先确认这几个问题你能自己说清楚:
-
inputType和InputFilter分别解决什么问题? -
密码显隐为什么要保存并恢复光标?
-
TextWatcher三个回调分别什么时候触发? -
为什么输入搜索、远程校验这类场景要做防抖?
-
为什么 Activity 销毁时要移除
TextWatcher和延迟任务?
如果这些问题你能讲出来,第 2 周就不是"写了几个输入框",而是真的开始理解 Android 输入体验了。