刚开始接触 Android 的时候,我对搜索框里那种边打字边出联想词的效果特别好奇。后来翻了官方文档才知道,这套交互早就被封装成现成的控件了,名字叫 AutoCompleteTextView(自动完成文本框),一个继承自 EditText 的成员,几行配置就能复现。网上很多基础教程习惯把这一节——通常就是 2.5.4 这种小节——当成“认识一下就行”的控件来教,随手跑通 demo 就翻篇了。但真正落地到项目里,你会发现它身上全是值得抠的细节:数据到底由谁匹配?列表什么时候弹出来?候选行怎么定制?数据量大了会不会卡?
这篇文章不打算讲花活,就按我实际做项目的顺序,把 AutoCompleteTextView 从布局配置、Adapter 绑定,再到自定义过滤器的完整链路走一遍,最后附上我踩过的坑和排错清单。适合刚入门、刚会用 Android Studio 搭项目的初学者,也适合想把这个控件用得更透、想做搜索联想和智能输入的开发者。全文用 Java 为主,Kotlin 的写法在关键位置单独给,代码都是真实项目里验证过的,可以直接抄。
1. 先搞清楚它内部是怎么工作的
1.1 三个角色:输入框、适配器、过滤器
AutoCompleteTextView 不是一个独立的“搜索组件”,它本质上是三层逻辑的叠加。第一层身份是 EditText,凡是 EditText 有的能力它全部保留——软键盘、光标移动、文本选择、TextWatcher 监听,这决定了它可以无缝替换项目里现有的输入框而不破坏原有逻辑。第二层身份是“弹窗宿主”,它内部维护了一个下拉列表,用来展示候选数据,这个列表跟输入框的关系,可以理解为“一个会随着输入出现的浮层”。第三层身份才是“自动完成”的关键,它对接了 Filterable 过滤体系:控件本身不负责匹配,真正干活的是 Adapter,控件只负责把输入内容作为过滤条件传进去,再把过滤结果展示出来。
很多人学到这里会懵:既然名字叫 AutoCompleteTextView,为什么匹配逻辑不在控件里?我个人的理解是,Android 的设计者把“数据从哪来、按什么规则匹配”这两件事完全交给了开发者,控件只做 UI 和交互这一件事。数据源可以是一个 String 数组、一个 List、甚至数据库的 Cursor;匹配规则可以是前缀、包含、拼音首字母,全看你传进去的 Adapter 怎么实现。先把角色分工理解透,后面所有自定义都会顺理成章。
1.2 从一次按键到列表弹出,完整链路是这样的
我是在源码里跟着走了一遍 TextWatcher 的调用链,才彻底弄明白的。整个流程可以拆成这么几步:
- 用户每输入或删除一个字符,控件内部的 TextWatcher 的 afterTextChanged 就会回调一次。
- 控件把当前输入文本包装成 constraint,调用 adapter.getFilter().filter(constraint)。
- Filter 框架在一个后台线程执行 performFiltering,把匹配结果封装成 FilterResults,再送回主线程。
- publishResults 在主线程被回调,Adapter 的数据被替换为筛选结果,并调用 notifyDataSetChanged。
- 控件收到过滤完成通知后,判断“输入长度是否达到阈值、列表是否有数据、输入框是否还持有焦点”,都满足就 showDropDown()。
这里藏着新手最容易踩的第一个坑:ArrayAdapter 的默认匹配规则不是“包含”,而是“以小写形式 startsWith”。数据源里有“广东”和“广西”,你把阈值设成 1 输入“广”,两个都会出来;但输入“西”的时候,“广西”永远不出现,因为“广西”不以“西”开头。很多人在这里测到怀疑人生,以为控件坏了,其实只是默认过滤器就是这么设计的。后面我会专门讲怎么把规则改成“包含”。
2. 基础使用:十分钟跑通一个联想输入框
2.1 布局文件里的三个关键配置
在 Android Studio 新建项目,往 activity_main.xml 里放一个 AutoCompleteTextView,先不改花哨属性,跑通最重要:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <AutoCompleteTextView android:id="@+id/actv_city" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入城市名称" android:completionThreshold="1" android:inputType="text" android:dropDownWidth="match_parent" android:popupBackground="@android:color/white" /> </LinearLayout>completionThreshold 是这条链路里第一个关键参数,表示“用户输入多少个字符后才开始提示”,默认值是 2。demo 里我特意设成 1,这样输第一个字就能看到效果。很多人刚接触时不知道这个属性,总觉得“怎么输了半天不弹列表”,十有八九就是没到阈值。inputType 保持 text 是稳妥的,如果你在做一个邮箱输入框,可以改成 textEmailAddress,它会顺带调出适合邮箱输入的键盘。
2.2 用 ArrayAdapter 把数据源喂进去
AutoCompleteTextView 的使用套路和给 ListView 设置适配器几乎一样,准备好数据源和 Adapter:
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); AutoCompleteTextView actvCity = findViewById(R.id.actv_city); String[] cities = new String[]{"北京", "上海", "广州", "深圳", "杭州", "成都", "重庆", "武汉", "西安"}; ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, cities); actvCity.setAdapter(adapter); } }注意这里用的是 android.R.layout.simple_dropdown_item_1line,看名字就知道,这是官方专门为下拉列表准备的单行 item 布局。很多人习惯写 simple_list_item_1,两个都能跑,但后者是为了 ListView 场景设计的,下拉列表里建议优先用 simple_dropdown_item_1line,它在 View 复用和 padding 处理上更贴合弹窗场景。
如果项目用的是 AppCompat 主题,建议直接用 AppCompatAutoCompleteTextView 替换控件声明,它继承了 AutoCompleteTextView 的所有行为,外观和 Material 主题更搭:
<androidx.appcompat.widget.AppCompatAutoCompleteTextView ... />代码里引用时不用改,因为它也是 AutoCompleteTextView 的子类。Kotlin 版本跟 Java 几乎一样,无非是把 findViewById 换成 viewBinding,或者直接使用 kotlin synthetic,逻辑完全一致。
2.3 点击候选词之后的回调怎么写才不出错
列表弹出、能选了,接下来要处理“用户点了哪一项”。这一步用 setOnItemClickListener,注意不是 setOnClickListener,很多新人在这两个名字上栽过跟头:
actvCity.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { String selected = (String) parent.getItemAtPosition(position); Toast.makeText(MainActivity.this, "选择了:" + selected, Toast.LENGTH_SHORT).show(); } });这里藏着第一个真正意义上的坑:回调里的 position 是“过滤后的临时列表”的索引,不是原始数据数组的索引。如果图省事直接用 cities[position],在数据被过滤之后十有八九会取错。正确做法是从 parent.getItemAtPosition(position) 反查当前项对象,拿到的才是用户真正点的那个数据。这个细节我在 code review 里见过太多次,属于那种报错信息一点没有、线上才能暴露的问题。
3. 进阶:自定义候选行样式和过滤规则
3.1 自定义 Adapter 实现带拼音的城市候选行
基础 demo 的候选行只有一行白字,真实产品远远不够。比如城市选择列表,我习惯在行右边显示拼音,或者给热门城市加一个小标签。做法是自定义 Adapter,继承 ArrayAdapter,重写视图创建逻辑。
先准备一个数据实体:
public class CityBean { public String name; public String pinyin; public boolean isHot; public CityBean(String name, String pinyin, boolean isHot) { this.name = name; this.pinyin = pinyin; this.isHot = isHot; } @Override public String toString() { return name; } }重写 toString 返回 name 是有讲究的:ArrayAdapter 的默认过滤器会把 item.toString() 当作匹配文本。我们把匹配规则换成自定义 Filter 之前,默认规则至少得是“按城市名匹配”,否则传进去一个对象,匹配文本变成对象的内存地址,输入什么都匹配不到。
候选行布局 item_city.xml 放两个 TextView,一个显示城市名、一个显示拼音,热门城市可以额外加个小角标:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:padding="12dp"> <TextView android:id="@+id/tv_name" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:textSize="16sp" /> <TextView android:id="@+id/tv_pinyin" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="#999999" /> </LinearLayout>Adapter 代码:
public class CityAdapter extends ArrayAdapter<CityBean> { private final List<CityBean> allCities; public CityAdapter(Context context, List<CityBean> allCities) { super(context, 0, new ArrayList<>(allCities)); this.allCities = allCities; } @Override public View getView(int position, View convertView, ViewGroup parent) { if (convertView == null) { convertView = LayoutInflater.from(getContext()) .inflate(R.layout.item_city, parent, false); } CityBean city = getItem(position); TextView tvName = convertView.findViewById(R.id.tv_name); TextView tvPinyin = convertView.findViewById(R.id.tv_pinyin); tvName.setText(city.name); tvPinyin.setText(city.pinyin); return convertView; } }构造方法里我先 new ArrayList<>(allCities) 复制了一份传给 super,是为了防止父类的数据操作污染保存的全量数据源。另外提醒一句:下拉列表实际走的是 getDropDownView 而不是 getView,ArrayAdapter 里两者默认逻辑一致,但如果你在自定义 Adapter 里只重写了其中一个,建议两个方法都保持一致,或者统一让它们引用同一个布局填充方法,避免列表行和下拉行长得不一样。这个坑在自定义样式时非常典型。
3.2 重写 Filter,把前缀匹配改成“包含 + 拼音”
这是整篇文章里我认为最值得动手的部分。默认 startsWith 规则太死板,搜索场景哪有“必须从头匹配”的道理。电商搜索、通讯录、城市选择,哪个不是“输入任意一段,只要名称里包含就能出来”。重写 getFilter 就能解决:
@Override public Filter getFilter() { return new Filter() { @Override protected FilterResults performFiltering(CharSequence constraint) { FilterResults results = new FilterResults(); if (constraint == null || constraint.toString().trim().isEmpty()) { results.values = new ArrayList<CityBean>(); results.count = 0; return results; } String keyword = constraint.toString().toLowerCase(); List<CityBean> filtered = new ArrayList<>(); for (CityBean city : allCities) { String name = city.name.toLowerCase(); String pinyin = city.pinyin.toLowerCase(); if (name.contains(keyword) || pinyin.contains(keyword) || containsInitial(pinyin, keyword)) { filtered.add(city); } } results.values = filtered; results.count = filtered.size(); return results; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { clear(); if (results.values instanceof List) { addAll((List<CityBean>) results.values); } notifyDataSetChanged(); } }; }containsInitial 是处理“拼音首字母”匹配的小工具,思路是把拼音按空格切分,取每个字的首字母拼成小写串,再看是否包含关键字。比如“广州”存成 pinyin = “guang zhou”,首字母串就是“gz”,用户输入 gz 也能匹配到,这在手机端搜索城市是很刚需的体验。
关键在于理解 Filter 的两段式设计:performFiltering 在后台线程执行,适合做遍历匹配;publishResults 回到主线程,只负责替换数据并刷新。这两段的分工是 Android 官方定的规矩,必须遵守。如果你把耗时逻辑塞进 publishResults,或者反过来在 performFiltering 里直接改 UI,轻则掉帧,重则崩溃。
3.3 MultiAutoCompleteTextView:一个输入框填多个值
和 AutoCompleteTextView 长得很像的还有一个 MultiAutoCompleteTextView,它解决的是“一个输入框里输入多个值,每个值分别联想”的场景,典型例子就是邮件发送页把收件人逐个输进去。用法几乎没有差别,唯独多指定一个 Tokenizer:
MultiAutoCompleteTextView actvEmails = findViewById(R.id.actv_multi); String[] emails = new String[]{"zhangsan@qq.com", "lisi@163.com", "wangwu@gmail.com"}; ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, emails); actvEmails.setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer()); actvEmails.setAdapter(adapter);注意顺序,Tokenizer 要在 setAdapter 之前设置,这样控件在第一次过滤时就已经知道“按逗号切分输入内容”。CommaTokenizer 的意义是告诉控件输入框里的哪一段内容需要参与联想:用户写完一个邮箱,打个逗号,再输入下一个邮箱时,控件会忽略前面已提交的部分,只对逗号之后的内容做过滤。如果忘了设置 Tokenizer,控件会把整个输入框里所有文本当成一个关键字,结果就是越输入越匹配不到。这个控件适合做标签输入、收件人输入之类的表单场景,候选列表样式也是可以自定义 Adapter 的,套路跟前面一样。
4. 常用属性和 API 的查漏补缺
4.1 XML 属性速查表,着重建这三个
我把平时项目里真正用过的属性整理成一张表,方便直接抄作业:
| 属性 | 作用 | 我的建议值/备注 |
|---|---|---|
| android:completionThreshold | 输入多少个字符后开始联想 | 默认 2,搜索场景设 1 |
| android:completionHint | 下拉列表底部的灰色提示文字 | 一般可省 |
| android:dropDownWidth | 下拉列表宽度 | 想跟输入框同宽就设 match_parent |
| android:dropDownHeight | 下拉列表最大高度 | 设 wrap_content 或固定值 |
| android:dropDownHorizontalOffset | 列表相对输入框的水平偏移 | 微调位置时有用 |
| android:dropDownVerticalOffset | 列表相对输入框的垂直偏移 | 默认 0 |
| android:popupBackground | 弹出列表的背景 | 深色主题必须显式设置 |
| android:dropDownAnchor | 列表锚定的控件 id | 默认锚定输入框自身 |
这张表不用全背,核心记住 completionThreshold、dropDownWidth 和 popupBackground 三个就够。尤其 popupBackground,Material 深色主题下如果忘记设置浅色背景,候选文字和背景颜色撞色,列表弹出来根本看不清,看起来像 bug,其实是主题问题。还有一个经验:如果 App 里好几个页面共用一套输入联想样式,可以把这些属性抽到一个 style 里,比如:
<style name="Widget.App.AutoComplete" parent="android:Widget.AutoCompleteTextView"> <item name="android:popupBackground">@drawable/bg_dropdown</item> <item name="android:completionThreshold">1</item> </style>4.2 几个容易忽略但有奇效的方法
除了 setAdapter 和 setThreshold,下面这几个方法值得记在笔记里:
- showDropDown() / dismissDropDown():手动控制列表的显示和关闭。想实现“输入框一聚焦就展示全部历史记录”的需求,可以配合 setThreshold(0) 使用,因为阈值 0 代表空文本也会触发过滤,Adapter 的过滤逻辑对空字符串返回全量数据即可。
- enoughToFilter():判断当前文本是否满足触发过滤的条件,是调试“为什么列表不出来”时最该先调用的方法。
- isPopupShowing():判断列表当前是否弹出,跟输入法交互时可以用来避免误操作。
- setOnItemSelectedListener:注意它和 setOnItemClickListener 的触发时机完全不同,前者是列表项被键盘或方向键高亮时回调,后者是用户点击确认时回调,写业务逻辑时千万不要混。
把 setThreshold(0) 再讲细一点:默认情况下空文本不会弹列表,这让“聚焦就显示全部候选项”的需求没法直接实现。阈值设成 0 之后,每次焦点变化或文本变化都会触发过滤,Adapter 在 performFiltering 里对空字符串返回全量数据,就能做到“点一下拉出所有搜索历史”。代价是文本变化时列表也会闪一下,体验上需要权衡。我做搜索历史这个功能时用过这个技巧,能接受轻微闪烁的话,是最省事的方案。
5. 真实项目里的场景和排坑
5.1 跟 TextWatcher 共存不打架
很多人给 AutoCompleteTextView 自己又加了一个 TextWatcher,想监听输入内容变化去做实时搜索,结果发现列表弹出来的时机和回调顺序乱成一团。要理解一个顺序:控件内部自己就有一个 TextWatcher,你通过 addTextChangedListener 添加的 TextWatcher 注册在它之后。每次文本变化,先是控件内部的 TextWatcher 触发过滤、弹列表,然后才是你的回调。如果两个 TextWatcher 里同时操作文本,很容易引起递归或重复过滤。
我的习惯是,监听输入关键词做网络搜索时,不要把 TextWatcher 和 AutoCompleteTextView 的下拉联想绑在同一个输入框上。真想这么做,就自己做防抖:在 afterTextChanged 里 removeCallbacks 再 postDelayed 300ms 再发起请求,同时把 completionThreshold 调大,避免每敲一个字都触发一次过滤。道理很简单,联想是本地内存数据的匹配,响应要快;搜索引擎请求是网络操作,必须节流。两种场景的触发频率要求完全不同,硬放在一起必然互相干扰。
5.2 数据量大时怎么保证不卡
AutoCompleteTextView 的原生过滤对几万条字符串数据会明显吃力,因为 performFiltering 里的遍历虽然是后台线程执行的,但 Filter 的调度线程有限,多个过滤任务会排队,快速连续输入时会出现明显的列表滞后。项目里真正遇到大列表时,我有两个常用方案。
第一个方案是数据量在几千条级别时,用内存数据源配合自定义 Filter,并在遍历里做细节优化:比如用 ArrayList 遍历加 contains,避免创建大量临时对象;比如把城市名和拼音提前拼接成“北京 beijing bj”这种复合串,一次 contains 就能同时命中名称、全拼、首字母,省掉多次判断。第二个方案是数据源来自数据库时,直接继承 CursorAdapter,让数据库的 LIKE 查询替你完成过滤,性能比把全量数据载入内存好得多,还能天然配合断词和索引。
还有个容易被忽略的性能点:自定义 item 布局里不要每次 bindView 都创建一堆新对象。下拉列表滚动时,convertView 复用机制很重要,findViewById 可以缓存到 ViewHolder 模式里,所有 TextView 的 setText 也尽量复用同一个字符串对象。候选列表一次滚 20 条,每个 item 多创建一次对象,卡顿就是这么积累出来的。
5.3 排坑速查表,一张解决 90% 的问题
最后把我真实遇到过、以及帮别人排查过的问题整理成速查表:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 输入半天没反应,列表不弹 | 没达到 completionThreshold;没绑 Adapter;数据为空 | 打印 adapter.getCount() 和 enoughToFilter() |
| 候选列表弹出来了,但内容不对 | 默认 Filter 是 startsWith 不是 contains | 按 3.2 节重写 Filter |
| 点了候选词没有任何反馈 | 用了 setOnClickListener 而不是 setOnItemClickListener | 换成 setOnItemClickListener |
| position 取到的数据是错的 | 用 position 直接索引原始数组 | 用 parent.getItemAtPosition(position) |
| 深色主题下列表白字看不清 | popupBackground 没设置 | 显式设置浅色背景 |
| 下拉列表在软键盘上方变形 | windowSoftInputMode 没调整 | 试试 adjustResize 或 adjustPan |
| 输入法弹出时列表被顶出屏幕 | dropDownVerticalOffset、anchor 设置不当 | 调整 offset,或考虑对话框式展示 |
| 数据源更新后列表不刷新 | 改了原数组但没通知 Adapter | 用 adapter.add()/clear() + notifyDataSetChanged() |
| 输入一个字弹两次列表 | 手动调用了 showDropDown 又被内部 TextWatcher 触发 | 去掉手动调用,或做防抖 |
| 自定义 item 的下拉行不显示新样式 | 只重写了 getView,没处理 getDropDownView | 两个方法统一指向同一个填充逻辑 |
表格里每一条我都在真实环境里踩过或帮同事解决过。尤其“position 取错”和“深色主题看不清”这两条,属于那种没有任何报错、纯靠经验才能快速定位的问题,遇到一次就长记性了。
6. 一些实操体会
最后分享两个我做这个控件的心得。第一个是“官方控件不等于不能用”。我见过不少团队为了做一个搜索联想,从零手写了一个自定义 View,最后打磨几个月还时不时出现点击穿透、列表位置不对的 bug。而系统控件的源码就在那里,读一读它的 TextWatcher 和 DropDownList 实现,很多稀奇古怪的现象都能直接找到答案。AutoCompleteTextView 的源码不算复杂,有空翻一翻,比盲目换方案值钱得多。
第二个心得是关于“过滤规则”的取舍。重写 Filter 支持包含匹配和拼音首字母之后,确实提升了体验,但也模糊了“用户输入什么才算有效候选”的边界。做个搜索联想框,如果候选词一两千条,包含匹配没问题;如果数据到几万条,还是想清楚哪些字段值得参与匹配,别把无意义字段也塞进索引串里。我在项目里见过一个通讯录搜索,把公司、部门、职位全拼进一个匹配串,结果输入一个常见字出来几百条,用户反而更难选了。好的输入联想不是“能匹配越多越好”,而是“匹配得越准越好”,这个度只能靠真实数据去调。
这个控件后面还可以继续扩展的方向也有不少:比如配合数据库做增量加载,用 CursorLoader 在输入停顿后再查询;比如把下拉列表换成对话框样式;比如把候选item改成两行富文本,带图标和描述。基础玩法吃透之后,这些扩展都是顺水推舟的事。先用好系统控件,再谈自定义,这条路我走了好几年,确实绕不开。