简介: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.xmlCityPickerActivity是唯一对外暴露的类,其他都是内部实现,这个边界要守住。我见过有人把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 判断 |
| 回传结构 | CityPickerActivity | putStringArrayListExtra(EXTRA_CITY_CODES, codes) |
| 完成按钮 | 布局 + Activity | 单选时点条目直接返回,多选时点条目只切状态,底部按钮才返回 |
验证多选有个技巧:选中 3 个城市,返回上一页,再重新打开选择器,看是否保留上次选中。如果不保留,说明你把状态存在了 Activity 局部变量里,应该放到 ViewModel 或者跟调用方约定好传入。这个场景很像某些“后悔药”需求,用户选完发现选错了退回来,状态丢了就得从头点一遍。
最后一条经验:这类“列表 + 索引 + 吸顶”的控件,我每次接都会先把两件事写进设计稿再动 UI——数据怎么拍平成 position,以及 position 和业务标识之间怎么映射。UI 画错了改起来是分钟级,映射建错了排查起来是小时级。从那以后我每次做列表类控件,都强制自己先在纸上把这两张映射表画出来,再写第一行Adapter。希望帮到你。
本文还有配套的精品资源,点击获取