ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Android城市选择器:RecyclerView字母索引与吸顶实现

Android城市选择器:RecyclerView字母索引与吸顶实现

简介:Android城市选择器是一份面向Android应用开发者的可复用组件资源,界面仿照美团城市选择页设计,并接入高德地图SDK完成地理位置定位,能快速为电商、旅游、社交及生活服务类应用补上城市选择这一常见功能。压缩包共285个文件、约908KB,以class编译产物和java源码为主体,辅以xml布局与样式资源、png图片、json配置、jar依赖,另含少量db城市数据与gradle构建脚本,模块划分完整,便于直接引入工程调用。资源已有931人学习下载。其价值在于把界面、定位与数据封装为独立模块,内部接口简洁,开发者通过回调即可获取用户选中的城市,并能按需替换城市数据、调整UI样式,省去从零编写与反复调试的成本,对初中级开发者尤为省时,也适合作为模块化封装的参考范例。

1. 城市选择器看着简单,上线后最先被投诉的往往是它

城市选择器在需求文档里常常只占一行:用户可选择所在城市。真正做起来才知道,拼音排序、多音字、字母索引、吸顶分组、搜索联想、定位回填、热门城市配置,一样都不能少;其中任何一个环节出问题,用户就会卡在注册或下单的第一步。这个 Android 城市选择器把这些功能拆成了几个独立模块:assets 里一份城市 JSON、做拼音预处理的 Repository、多类型 RecyclerView Adapter、侧边字母索引条、吸顶 ItemDecoration。它适合两类人:正缺一个能直接嵌进 App 的城市选择页的开发者,以及想搞清楚“字母索引 + 吸顶”这套组合在 RecyclerView 上怎么落地的人。

2. 架构与数据流:一次选择背后的三个角色

整个选择器可以拆成三个角色,分清这三个角色,后面改代码就不会乱。

数据角色负责把一份原始城市列表读进内存,做三件事:补拼音、算首字母、排序,最后产出一个“已经排好、带索引”的集合,放在内存里复用。列表角色负责把这堆数据拍平成一维的 Row 列表,交给 RecyclerView 渲染,同时维护“字母 → position”的映射。交互角色只有两个入口:侧边索引条的触摸事件和搜索框的文本变化,它们最终都收敛成一句“滚到某个 position”或“重新提交一份数据”。

这个分法的好处是:换数据源只动数据角色,换 UI 只动列表角色,加个“最近选择”只加一种 Row 类型。下面按目录、数据、出参三块拆开看。

2.1 目录结构:四层分包与 assets 里的城市数据

拿到工程先看目录,比看 Activity 里的几百行代码高效得多。典型的分包是这样:

citypicker/ ├── src/main/ │ ├── assets/ │ │ └── city.json # 原始城市数据,全量约几百条 │ ├── java/.../citypicker/ │ │ ├── CityPickerActivity.java # 唯一入口,负责收参数、回传结果 │ │ ├── model/City.java # 城市实体 │ │ ├── model/Row.java # 扁平化后的列表行(多类型包装) │ │ ├── data/CityRepository.java # 读 assets、拼音预处理、内存缓存 │ │ ├── ui/CityAdapter.java # 多类型 Adapter │ │ ├── ui/SideBar.java # 侧边字母索引条(自定义 View) │ │ ├── ui/StickyHeaderDecoration.java # 吸顶分组头 │ │ └── util/PinyinHelper.java # 拼音转换 + 多音字修正表 │ └── res/layout/activity_city_picker.xml

CityPickerActivity是唯一对外暴露的类,其他都是内部实现,这个边界要守住。我见过有人把CityAdapter直接暴露给业务层改数据,结果列表数据和缓存数据出现两份状态,选完城市再进来就乱了。保持“业务层只认识 Activity 的入参和出参”这一条,后面不管怎么重构都不影响调用方。

assets/city.json放原始数据而不是写成 Java 常量数组,原因很实际:城市数据会变,换文件不用重新编译,也能按渠道下发不同版本。数据格式长这样,字段刻意保持少:

[ { "name": "城市A", "code": "100001" }, { "name": "城市B", "code": "100002" }, { "name": "城市C", "code": "100003" } ]

name是展示用,code是回传给业务的唯一标识。拼音不进 JSON,因为拼音可以由name算出来,写死在数据里一旦改名就得同步改两处,迟早对不上。

2.2 数据模型与拼音索引:一次预处理解决排序问题

City实体只多两个字段,都标transient语义(不参与序列化):

public class City { private String name; // 展示名 private String code; // 回传用的唯一标识 private String pinyin; // 全拼小写,如「chengshia」,运行时生成 private String firstLetter; // 首字母大写 A-Z,非字母统一归到 "#" // getter/setter 省略 }

接下来是整篇最核心的一段预处理,它决定了列表顺序和索引条能不能对上:

public static void sortAndIndex(List<City> raw) { for (City c : raw) { // 1. 转全拼,第二个参数传空串表示不要分隔符 String py = PinyinHelper.toPinyin(c.getName()); py = fixPolyphone(c.getName(), py); // 2. 多音字修正,见下文 c.setPinyin(py); // 3. 取首字母,非 A-Z 一律归到 "#" char f = py.isEmpty() ? '#' : Character.toUpperCase(py.charAt(0)); c.setFirstLetter(f >= 'A' && f <= 'Z' ? String.valueOf(f) : "#"); } // 4. 先按首字母,再按全拼,保证同组内也是有序的 Collections.sort(raw, (a, b) -> { if ("#".equals(a.getFirstLetter())) return 1; // "#" 永远沉底 if ("#".equals(b.getFirstLetter())) return -1; int r = a.getFirstLetter().compareTo(b.getFirstLetter()); return r != 0 ? r : a.getPinyin().compareTo(b.getPinyin()); }); }

这里有几处参数和逻辑值得说明。toPinyin的第二个参数是分隔符,传空串得到连写的chengshia,这样startsWith做前缀搜索时不用再去空格。Collections.sort用的比较器是稳定排序,同一首字母内再按全拼排,索引条才不会出现“点 C 进去看到的是乱序”的尴尬。

多音字单独留一个修正表,这是血泪经验:自动转换对多音字城市名的判断基本靠猜,某些读cháng的城市会被转成zhǎng,直接排到 Z 组去,用户点 C 永远找不到。修正表就是一个Map<String, String>,命中就覆盖:

private static final Map<String, String> POLYPHONE = new HashMap<>(); static { // key 是城市名,value 是正确全拼;数据量很小,几十条足够 POLYPHONE.put("多音字城市甲", "changs"); POLYPHONE.put("多音字城市乙", "chongq"); } private static String fixPolyphone(String name, String auto) { String fixed = POLYPHONE.get(name); return fixed != null ? fixed : auto; }

整个预处理只在 Repository 第一次初始化时跑一次,结果缓存在内存里,后面每次打开选择页都是直接读缓存。几百条数据做一次排序的耗时在个位数毫秒,但如果你每次onCreate都重跑一遍,加上 JSON 解析,冷启动就能明显感觉到卡顿。

2.3 出入参设计:Intent 里到底该传什么

调用方只关心两件事:怎么打开、选完怎么拿结果。

// 打开 public static void start(Activity activity, int requestCode) { Intent i = new Intent(activity, CityPickerActivity.class); i.putExtra(EXTRA_SHOW_LOCATE, true); // 是否展示定位行 i.putExtra(EXTRA_SHOW_HOT, true); // 是否展示热门城市宫格 i.putExtra(EXTRA_TITLE, "选择城市"); // 标题文案 activity.startActivityForResult(i, requestCode); } // 回传 private void finishWith(City city) { setResult(RESULT_OK, new Intent() .putExtra(EXTRA_CITY_NAME, city.getName()) .putExtra(EXTRA_CITY_CODE, city.getCode())); finish(); }

只回传name和code,不要把整个City对象塞进 Intent。原因有两点:一是City实现了Serializable之后,字段一改就容易在跨进程时踩到BadParcelableException;二是业务层真正需要的就是标识和展示名,多传反而诱导别人去依赖内部字段。

EXTRA_TITLE这类纯展示参数走 Intent 而不是主题配置,是为了让同一个选择器能在“注册页”和“收货地址页”复用出不同文案,不用为了改四个字去新建一个 Activity。

3. 核心模块拆解:Adapter、索引条、吸顶头三件套

这一章是整篇最需要照抄的部分。三个模块互相咬合:Adapter 提供 position 映射,SideBar 消费这个映射去滚动,ItemDecoration 在上层画吸顶头。任何一处对不上,用户看到的就是“点了没反应”或者“点 C 跳到 D”。

3.1 多类型 Adapter:把定位、热门、分组头、城市拍平成一维

列表里其实有四类内容,但 RecyclerView 只认 position。最稳的做法是先拍平成一个Row列表,position 就是唯一真相,索引映射也从这里长出来:

public class CityAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { public static final int TYPE_LOCATE = 0; // 当前定位 public static final int TYPE_HOT = 1; // 热门城市宫格 public static final int TYPE_HEADER = 2; // 字母分组头 A/B/C public static final int TYPE_CITY = 3; // 单个城市 private final List<Row> rows = new ArrayList<>(); private final Map<String, Integer> letterIndex = new HashMap<>(); /** 全量提交:定位 + 热门 + 已排序城市 */ public void submit(List<City> sorted, List<City> hot, City located) { rows.clear(); letterIndex.clear(); if (located != null) rows.add(Row.locate(located)); if (hot != null && !hot.isEmpty()) rows.add(Row.hot(hot)); String last = null; for (City c : sorted) { if (!c.getFirstLetter().equals(last)) { last = c.getFirstLetter(); letterIndex.put(last, rows.size()); // 关键:记住分组头的 position rows.add(Row.header(last)); } rows.add(Row.city(c)); } notifyDataSetChanged(); } @Override public int getItemViewType(int position) { return rows.get(position).type; } @Override public int getItemCount() { return rows.size(); } /** 索引条查询入口:字母 -> 分组头 position */ public Integer getPositionForLetter(String letter) { return letterIndex.get(letter); } /** 吸顶用:向上找最近的、且 index <= position 的分组头 */ public int headerPosAbove(int position) { int best = -1; for (Integer p : letterIndex.values()) { if (p <= position && p > best) best = p; } return best; } }

letterIndex这张表是整章的关键。它记录的是“分组头在 rows 里的下标”,不是“字母在字母表里的下标”,这两者的区别就是后面索引错位的根源。submit里先 clear 再重建,保证搜索态和正常态共用一套数据结构,不会出现“搜索完清空关键字,索引条还是旧映射”的情况。

headerPosAbove用遍历找最大值,看着笨,但letterIndex最多 27 项,一次遍历的开销可以忽略,换来的好处是不用额外维护一个有序 position 列表。

3.2 侧边字母索引条:从触摸坐标到 scrollToPositionWithOffset

SideBar是一个纯自定义 View,只干两件事:把触摸的 Y 坐标换算成字母下标,把字母回调出去。

public class SideBar extends View { private final List<String> letters = new ArrayList<>(); private float itemHeight; private int lastIndex = -1; private OnLetterTouchListener listener; @Override protected void onDraw(Canvas canvas) { itemHeight = getHeight() * 1f / letters.size(); // 均分高度 for (int i = 0; i < letters.size(); i++) { String letter = letters.get(i); float cy = itemHeight * i + itemHeight / 2; // 省略:paint 设置字号、居中计算、高亮当前字母 canvas.drawText(letter, getWidth() / 2f, cy, paint); } } @Override public boolean onTouchEvent(MotionEvent e) { switch (e.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 按下先抢事件,避免父容器(RecyclerView 的滑动)把它吃掉 getParent().requestDisallowInterceptTouchEvent(true); // fall through case MotionEvent.ACTION_MOVE: int idx = (int) (e.getY() / itemHeight); idx = Math.max(0, Math.min(idx, letters.size() - 1)); if (idx != lastIndex) { lastIndex = idx; listener.onLetter(letters.get(idx)); } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: lastIndex = -1; getParent().requestDisallowInterceptTouchEvent(false); listener.onLetterUp(); return true; } return true; } public interface OnLetterTouchListener { void onLetter(String letter); void onLetterUp(); } }

requestDisallowInterceptTouchEvent(true)这一句不能省。手指按在索引条上往下滑,父容器默认会尝试拦截成滚动,表现就是“索引条滑到一半卡住,列表纹丝不动”。加上这句之后,事件在按下期间完全归SideBar管,松手再还给父容器。

idx记得做clamp。手指滑出控件边界时e.getY()会变成负数或者大于高度,不夹一下就会IndexOutOfBounds,线上偶现崩溃大多是这个。

外部接上:

sideBar.setOnLetterTouchListener(new SideBar.OnLetterTouchListener() { @Override public void onLetter(String letter) { Integer pos = adapter.getPositionForLetter(letter); if (pos == null) return; // 该字母没有城市,直接忽略 LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager(); lm.scrollToPositionWithOffset(pos, 0); // offset 传 0,分组头顶到顶部 } @Override public void onLetterUp() { letterOverlay.setVisibility(View.GONE); // 中间那个大字母提示浮层 } });

用scrollToPositionWithOffset(pos, 0)而不是scrollToPosition(pos)。后者只保证目标项可见,可能是贴底出现,分组头就跑到屏幕下半部分去了,用户会觉得“点了但没跳到位”。offset传 0 表示让这一项的顶部和列表顶部对齐,配合吸顶头看起来才自然。

3.3 ItemDecoration 画吸顶分组头

吸顶头不占 Adapter 的 position,画在onDrawOver里最干净,不会打乱letterIndex:

@Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { final int top = parent.getPaddingTop(); final int childCount = parent.getChildCount(); if (childCount == 0) return; int firstPos = parent.getChildAdapterPosition(parent.getChildAt(0)); int headerPos = adapter.headerPosAbove(firstPos); if (headerPos < 0) return; // 下一个分组头顶上来时,把当前吸顶头往上推 int offset = 0; for (int i = 0; i < childCount; i++) { View child = parent.getChildAt(i); int pos = parent.getChildAdapterPosition(child); if (pos == headerPos + 1 && child.getTop() < top + headerHeight) { offset = child.getTop() - (top + headerHeight); // 负值 break; } } c.save(); c.clipRect(parent.getLeft(), top, parent.getRight(), parent.getBottom()); c.translate(0, offset); drawHeader(c, parent, headerPos, top); // 内部按 headerPos 取字母并绘制背景+文字 c.restore(); }

三处细节:clipRect必须加,否则吸顶头会在列表滑动到边缘时画到工具栏外面去;translate的offset是负值,靠“推开”实现两个分组头交替的视觉效果;headerHeight要和分组头 item 的layout_height保持一致,改了一边忘了另一边,就会出现一条细缝或者压住第一个城市。

3.4 搜索过滤:汉字、全拼、首字母三路命中

搜索不做单独页面,直接复用submit:

public void filter(String keyword) { if (TextUtils.isEmpty(keyword)) { adapter.submit(allSorted, hotCities, locatedCity); // 恢复全量 return; } String k = keyword.trim().toLowerCase(); List<City> hits = new ArrayList<>(); for (City c : allSorted) { boolean hit = c.getName().contains(keyword) // 汉字直接匹配 || c.getPinyin().startsWith(k) // 全拼前缀,如 "cheng" || c.getPinyin().contains(k) // 全拼包含,如 "shia" || c.getFirstLetter().equalsIgnoreCase(k); // 单字母 if (hit) hits.add(c); } adapter.submit(hits, null, null); // 搜索态不展示定位和热门 }

搜索态传null给热门和定位,是为了让结果列表只有一种语义——“匹配到的城市”。混着定位行会让用户困惑:我搜的是这几个字,为什么第一行是定位城市。

4. 跑起来:从工程导入到真机验证的完整步骤

代码看懂只是第一步,真把它跑起来还会遇到几个配置问题。这一章按顺序走一遍,每一步都给出验证方法,出错能立刻定位。

4.1 工程导入与依赖配置

如果是作为 module 引入,settings.gradle里加一行,然后在主 module 里依赖:

// settings.gradle include ':app', ':citypicker' // app/build.gradle dependencies { implementation project(':citypicker') implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.2' // 拼音库按项目已有选型替换:体积敏感用轻量版,多音字要求高用词库版 // implementation '你的拼音库:版本号' }

minSdk至少 21,因为ItemDecoration的onDrawOver和scrollToPositionWithOffset在低版本上行为差异较大。compileSdk跟着主工程走,别单独拔高,否则会出现“主工程编译过了,选择器 module 报资源找不到”的怪现象。

如果只想要 aar,把 module 的build.gradle里com.android.library保持不动,执行./gradlew :citypicker:assembleRelease,产物在citypicker/build/outputs/aar/下。aar 方式要注意assets会一起打进去,不用额外拷贝。

4.2 城市数据落位与首次运行

city.json必须放在citypicker/src/main/assets/下,不是res/raw。放res/raw的话资源 ID 会被合并,多个 module 里有同名文件时可能读到你没预期的那个。

读取代码里加个兜底,第一次跑就能看出数据有没有到位:

private List<City> loadFromAssets() { try (InputStream is = context.getAssets().open("city.json")) { String json = readAll(is); List<City> list = new Gson().fromJson(json, new TypeToken<List<City>>(){}.getType()); CityRepository.sortAndIndex(list); Log.d("CityPicker", "loaded " + list.size() + " cities, first=" + list.get(0).getName()); return list; } catch (IOException e) { Log.e("CityPicker", "city.json missing", e); return Collections.emptyList(); // 兜底:宁可空列表,也别崩 } }

第一次运行看 logcat,如果打出loaded 0 cities,八成是 assets 目录层级错了(多套了一层assets/assets/),或者 module 的sourceSets被改过。

4.3 端到端验证:用日志把一次选择走完

功能跑通不等于逻辑对。加三处日志,能把整条链路串起来:

// 1. 数据加载 Log.d("CityPicker", "load size=" + all.size()); // 2. 索引跳转 Log.d("CityPicker", "letter=" + letter + " -> pos=" + pos); // 3. 结果回传 Log.d("CityPicker", "select name=" + city.getName() + " code=" + city.getCode());

用命令行过滤:

adb logcat -s CityPicker:D

验证顺序建议是:先滑动列表确认分组头和城市对应(滑到 C 组,第一个城市首字母得是 C);再点索引条逐字母跳,看跳过去的第一个可见项是不是对应分组头;最后搜索两个字的城市名,清空关键字,确认列表能完整恢复。这三步过了,基本功能就站住了。

4.4 常用配置项怎么调

需求改哪里注意
隐藏定位行EXTRA_SHOW_LOCATE传 false同时别再请求定位权限
换热门城市CityPickerActivity里的热门数组数组元素必须能在全量数据里找到,否则点击无效
改索引条字母集CityRepository生成后过滤只保留数据里真实存在的字母,避免点了没反应
换标题栏样式activity_city_picker.xml别改 RecyclerView 的 id,代码里按 id 取

5. 避坑与排查:索引错位、跳组异常、数据翻车

这一章记录的都是我实际踩过的,按“现象 → 原因 → 解决”写,遇到对得上的直接照方抓药。

5.1 索引条与滚动

现象:点索引条上的 S,列表跳到了 R 组开头,往后每个字母都偏一组。

原因:把“字母在字母表里的下标”当成了“分组头在列表里的 position”。字母表里 A 是 0,但列表前面还有定位行和热门宫格,两者根本不是一个坐标系。

解决:一律走adapter.getPositionForLetter(letter)取映射,不要在 Activity 里自己算下标。同时确保letterIndex是在submit里、拍平之后记录的。


现象:手指在索引条上快速滑动,列表只跳了第一个字母,后面全没反应。

原因:ACTION_MOVE被父容器拦截成了滚动。ACTION_DOWN里没调requestDisallowInterceptTouchEvent(true),或者只在SideBar内部处理了但父容器是NestedScrollView包着 RecyclerView,拦截发生在更外层。

解决:在ACTION_DOWN里对getParent()调用一次,ACTION_UP/ACTION_CANCEL里还原。如果是嵌套滚动,把SideBar的父容器换成最外层的那个可滚动 View。


现象:跳到某个字母后,吸顶头和上面的分组头重叠,出现两层字母叠在一起。

原因:吸顶头的headerHeight和分组头 item 的实际高度不一致,offset算出来的推挤距离偏小,两个头短暂共存。

解决:把headerHeight抽成一个常量,分组头 item 的layout_height引用同一个值(或者用@dimen),改一处两边同步。别在布局里写死48dp而在代码里写50。

5.2 数据与搜索

现象:某些多音字城市点 C 找不到,点 Z 反而能找到。

原因:拼音库按默认读音转换,把读cháng的城市转成了zhǎng,首字母算成了 Z。

解决:在PinyinHelper里维护修正表,转换完成后覆盖。加新数据时,把所有首字母和实际读音对不上的城市补进表里,这是唯一稳妥的办法。


现象:搜索框输入关键字,列表筛出结果后清空输入,列表只剩下几个城市,回不到全量。

原因:filter里对allSorted做了原地Collections.sort或者直接retainAll,把源数据改了。源数据一旦被污染,后面的“恢复全量”恢复的其实是被改过的数据。

解决:filter只读allSorted,把命中结果放进新的ArrayList再提交。所有对源数据的修改都只在CityRepository初始化时发生一次,之后全部只读。

6. 二次开发:换成网络数据源与加多选

真正落地时,最常见的两个改造是“城市数据要走服务端下发”和“要支持多选”。

换数据源的关键在 Repository 分层,别去动 Adapter。做法是先读 assets 兜底渲染,再异步请求网络,拿到新数据后比对版本号,不同才刷新:

public void load(LoadCallback cb) { List<City> local = cache != null ? cache : readFromAssets(); cb.onReady(local, false); // 先给一份能用的 api.fetchCities(localVersion, resp -> { if (resp.version <= localVersion) return; // 版本没变,不折腾 CityRepository.sortAndIndex(resp.cities); // 统一走同一套预处理 cache = resp.cities; localVersion = resp.version; cb.onReady(cache, true); // 再刷一次,标记为远端数据 }); }

这里有个容易忽略的点:远端数据拿回来必须先过sortAndIndex。我见过直接submit(remoteList, ...)的写法,后果是索引条映射建在未排序的数据上,点 A 跳到一半乱序的位置。

多选改造涉及三个位置,列个表对照着改:

改造点文件要点
选中状态CityAdapter维护Set<String> selectedCodes,onBindViewHolder里按 code 判断
回传结构CityPickerActivityputStringArrayListExtra(EXTRA_CITY_CODES, codes)
完成按钮布局 + Activity单选时点条目直接返回,多选时点条目只切状态,底部按钮才返回

验证多选有个技巧:选中 3 个城市,返回上一页,再重新打开选择器,看是否保留上次选中。如果不保留,说明你把状态存在了 Activity 局部变量里,应该放到 ViewModel 或者跟调用方约定好传入。这个场景很像某些“后悔药”需求,用户选完发现选错了退回来,状态丢了就得从头点一遍。

最后一条经验:这类“列表 + 索引 + 吸顶”的控件,我每次接都会先把两件事写进设计稿再动 UI——数据怎么拍平成 position,以及 position 和业务标识之间怎么映射。UI 画错了改起来是分钟级,映射建错了排查起来是小时级。从那以后我每次做列表类控件,都强制自己先在纸上把这两张映射表画出来,再写第一行Adapter。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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