☰
Android城市选择器:省市区三级联动、拼音索引与定位降级实践
2026/10/23 4:28:11 网站建设 项目流程

简介:这是一套面向 Android 开发者的城市选择器组件源码包,模仿美团城市选择界面,适合电商、旅游、社交与生活服务类应用快速集成城市定位与选择功能。压缩包共 285 个文件,约 908KB,包含 31 个 Java 源文件、164 个 class 文件、40 个 XML 布局与配置、22 个 PNG 图片资源及 3 个 jar 依赖,覆盖界面样式、城市数据、定位逻辑与可复用模块,目录分层较清晰。已有 930 人学习下载,说明该组件在常见业务场景中具有一定参考价值。读者可从中获得可直接引入工程的模块化实现,理解城市选择弹窗的调用方式、回调结果处理、简单 API 的调用边界与基础自定义配置,并借助较完整的工程结构熟悉城市数据维护、界面资源调整和高德定位接入思路,用于二次开发或学习 Android 组件封装。整体适合具备基础 Android 经验的开发者。

1. 城市选择器不是「扔个 RecyclerView」:四件事没想清就得返工

城市选择器这三个字,在需求文档里往往只有一行,落到 Android 代码里却是一个能占两三天、上线后还可能返工两轮的模块。多数人第一次做都会走同一条路:找一份省市区数据塞进 assets,用三个列表做级联,Demo 跑通就交付,然后在真实数据量、低端机和弱网定位面前集体翻车。它真正难的地方不在列表怎么写,而在四件事——几千条数据怎么存、拼音索引怎么建、定位拿不到时怎么降级、搜索怎么同时命中中文和拼音。这篇按我在几个 Android 项目里拆过的组件结构讲,代码可以直接借鉴,参数怎么改、失败时看哪里都会写清楚,踩过的坑也一并标出来。

2. 数据层先立住:省市区三级联动的存储、建模与拼音索引

2.1 三种数据源形态,按数据量级和更新频率选

省市区数据规模不大,全国三级区划一般也就三千到四千条,但这个量级刚好卡在一个尴尬位置:一次性解析进内存完全放得下,但放主线程解析就会掉帧;放数据库查询很快,但冷启动打开库有固定成本。选型时先问自己两个问题——区划多久变一次、要不要支持模糊查询。

常见做法是三种形态:

形态冷启动开销包体增量更新成本适合什么场景
assets 内 JSON,一次性解析进内存随条目线性增长,低端机上几十到上百毫秒中等,文本可压缩需发版区划一年几乎不动的产品
预置 SQLite,按需查询打开库有固定成本,查询本身很快略大于 JSON需发版,可打增量补丁需要模糊查询、分页、统计的场景
服务端下发 + 本地缓存首次无数据要等网络几乎为零随时可改区划变动频繁或要按业务裁剪

我一般会走混合方案:assets 里带一份兜底数据保证首次安装可用,服务端下发一个带版本号的增量包覆盖。判断是否要更新只看版本号,不看时间戳,避免用户改系统时间导致永远不更新。这个决定要放在数据层做,不要放到 UI 层——后面所有关于「为什么列表里有这个城市、那个没有」的疑问,答案都在这里。

还有一点容易被忽略:数据里到底存几级。有些产品只需要省市两级(选城市做门店筛选),有些要三级(收货地址),还有些要四级(街道)。我的建议是数据层永远按最细粒度存,UI 层用level参数决定展示到哪一级。反过来做,等到要加区级时就得改数据格式、改 Adapter、改回显逻辑,一次改动牵三个模块。

2.2 实体建模:扁平表 + parentId,别用嵌套树

很多人第一版会写成嵌套结构:省对象里带一个List<City>,城市里带一个List<District>。看起来直观,实际用起来处处别扭——搜索要递归遍历,回显(从区反查省)要写三层循环,增量更新时合并两棵树更是灾难。

扁平表就没有这些问题。所有节点放一个 List,用parentId串关系,加载完成后建一次索引,之后所有查询都是 O(1) 查表:

// 扁平实体:省/市/区同构,靠 parentId 和 level 区分层级 data class Area( val id: String, // 行政区划编码,全局唯一;绝对不要拿 name 当主键 val parentId: String, // 顶级节点的 parentId 固定为 "0" val name: String, // 纯展示名,"省/市/区"后缀单独存,搜索时要用到 val suffix: String, // 如 "省" "市" "区" "自治州",展示时拼接 val level: Int, // 1=省级 2=市级 3=区级 val pinyin: String, // 全拼小写、无空格,构建索引时填充 val initial: Char // 首字母大写;非汉字归入 '#' )

几个字段的取舍值得说清楚。id必须用行政区划编码而不是名称,因为同名区县在国内非常常见,用名称做主键会在搜索和回显时出现「选了 A 结果跳到 B」的玄幻问题。suffix单独存是为了搜索——用户搜「示例市」时不该被后缀干扰,但展示时需要完整的「示例市」。level用整型而不是枚举,是为了序列化方便,服务端下发时直接透传。

拿到扁平列表后,建一个父节点索引就够了:

class AreaRepository(raw: List<Area>) { // parentId -> 子节点列表,构建一次,之后三级联动全是 O(1) private val childrenIndex: Map<String, List<Area>> = raw.groupBy { it.parentId } // id -> 节点,回显时从区上溯到省用 private val idIndex: Map<String, Area> = raw.associateBy { it.id } fun provinces(): List<Area> = childrenIndex["0"].orEmpty() fun childrenOf(parentId: String): List<Area> = childrenIndex[parentId].orEmpty() // 从任意层级的 id 反查出完整路径,最多循环三次 fun pathOf(areaId: String, depth: Int = 3): List<Area> { val path = ArrayDeque<Area>() var cursor = idIndex[areaId] var guard = 0 while (cursor != null && guard++ <= depth) { path.addFirst(cursor) cursor = idIndex[cursor.parentId] } return path.toList() } }

groupBy的复杂度是一次线性遍历加哈希分组,三千条数据在测试机上通常几毫秒量级,可以放心放在数据加载完成后执行。pathOf里的guard是防御性的——万一某条脏数据把parentId指成了自己的子节点,没有这个计数器就会死循环,而这种脏数据在人工维护的区划表里真的会出现。

childrenOf返回的列表顺序取决于原始数据顺序,如果产品要求「直辖市排前面、其余按拼音排」,排序要在这里做一次并缓存,不要每次onBindViewHolder里排。

2.3 拼音索引:全拼、首字母与 # 分组

右侧那根字母索引条能不能用,全看这一节。索引的本质是给每个城市算两个值:全拼(给搜索用)和首字母(给分组用)。

// 只在数据加载完成后执行一次,结果可以随数据版本号一起缓存到磁盘 private fun buildPinyinIndex(list: List<Area>): List<Area> = list.map { area -> if (area.level != 2) return@map area // 只给市级建索引,省和区用不上 val full = StringBuilder() var initial: Char? = null area.name.forEach { ch -> val py = PinyinRules.toPinyin(ch) // 单字转全拼小写;非汉字返回 null if (py.isNullOrEmpty()) { full.append(ch) // 数字和英文字母原样保留,如 "开发区 3 号" } else { if (initial == null) initial = py.first().uppercaseChar() full.append(py) } } area.copy(pinyin = full.toString(), initial = initial ?: '#') }

toPinyin这一步是整段里唯一有不确定性的地方:多音字。像名字里带「长」「重」「厦」「朝」这类字的城市,通用拼音库给出的默认读音大概率是错的,用户按正确读音的首字母去索引条里找,就找不到。血泪经验是维护一张几十条的手工覆盖表,键是城市名,值是正确全拼,加载时优先查表、查不到再走通用库。这张表不大,但能省掉一批「索引条点了不对」的反馈。

首字母算不出来的一律落#,比如「其他」「全部」这种非地名条目。排序时把#固定放最后,用自定义比较器处理,别指望 ASCII 顺序恰好把它排到末尾:

// 字母升序,'#' 恒定排在最后 val letters = cities.map { it.initial } .distinct() .sortedWith(compareBy({ it == '#' }, { it }))

索引构建的耗时在三千条量级上通常是几十毫秒,放在Application启动后的子线程里预热一次,进选择器页面时直接读缓存。缓存要带数据版本号,版本不一致就整套重算——千万不要只缓存一半,半新半旧的数据比重新算更麻烦。

3. 交互骨架:字母索引条、吸附分组头和搜索的落地写法

3.1 吸附分组头:用 ItemDecoration 而不是多类型 Item

分组头有两种做法:把 header 当成一种 item type 塞进 Adapter,或者用ItemDecoration直接绘制。前者改动数据源,任何一次submitList都要重新拼装带 header 的列表,索引条点击时算出来的 position 还得再映射一遍;后者不动数据源,position 就是数据下标,索引定位天然对齐。

我一般选ItemDecoration,只在onDrawOver里画:

class StickyHeaderDecoration( private val sectionOf: (position: Int) -> Char // Adapter 告诉装饰器某 position 属于哪个字母 ) : RecyclerView.ItemDecoration() { private val bgPaint = Paint().apply { color = 0xFFF4F5F7.toInt() } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 36f color = 0xFF333333.toInt() } override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val topChild = parent.getChildAt(0) ?: return val topPos = parent.getChildAdapterPosition(topChild) if (topPos == RecyclerView.NO_POSITION) return val header = sectionOf(topPos) var offsetY = 0 // 下一组的第一项顶到当前 header 下沿时,header 跟着往上推,做出"顶走"效果 val nextStart = nextSectionStart(topPos + 1) if (nextStart != -1) { val nextView = parent.findViewHolderForAdapterPosition(nextStart)?.itemView if (nextView != null && nextView.top < topChild.height) { offsetY = nextView.top - topChild.height } } c.save() c.translate(0f, offsetY.toFloat()) c.drawRect(0f, 0f, parent.width.toFloat(), topChild.height.toFloat(), bgPaint) c.drawText(header.toString(), 32f, topChild.height * 0.7f, textPaint) c.restore() } private fun nextSectionStart(from: Int): Int = /* 查预计算的分组起点数组,见 3.2 */ -1 }

这里有两个参数要盯住。topChild.height必须取自实际子 View 高度,不能写死成 dp 值,否则用户改了系统字体大小后 header 会盖住第一行内容。textPaint的textSize是 px 不是 sp,写死 px 在深色模式和大字体下都会出问题,正式代码里应该从主题属性里取,并且监听配置变化重建画笔。

分组起点数组在数据变化时重建一次:

// 返回 (字母列表, 每个字母对应分组在列表中的起始 position) fun buildSections(list: List<Area>): Pair<List<Char>, IntArray> { val letters = ArrayList<Char>() val starts = ArrayList<Int>() list.forEachIndexed { index, area -> if (index == 0 || area.initial != list[index - 1].initial) { letters += area.initial starts += index } } return letters to starts.toIntArray() }

这个函数看起来平淡,但它是整个索引联动的唯一数据源:索引条点击、列表滚动高亮、吸附头取字母,全部读它。一旦它和数据不同步,表现就是「点了 C 滚到了 D」,所以凡是submitList之后都必须重建,不能增量维护。

3.2 字母索引条:触摸分发与反向联动

索引条本身是个自绘 View,难点在事件处理,不在绘制。外层常常套着NestedScrollView或ViewPager2,手指一划就被父容器抢走事件,表现是「按住能滑,一移动就跳回顶部」。

class LetterIndexBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { var onLetterSelected: ((letter: Char, position: Int) -> Unit)? = null private var letters: List<Char> = emptyList() private var starts: IntArray = IntArray(0) private var currentIndex = -1 fun submit(letters: List<Char>, starts: IntArray) { this.letters = letters this.starts = starts invalidate() } override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { // 关键:告诉父容器别拦事件,否则滑动会被外层滚动容器吃掉 parent?.requestDisallowInterceptTouchEvent(true) val idx = ((event.y / height) * letters.size) .toInt().coerceIn(0, letters.size - 1) if (idx != currentIndex) { currentIndex = idx onLetterSelected?.invoke(letters[idx], starts[idx]) invalidate() } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { parent?.requestDisallowInterceptTouchEvent(false) currentIndex = -1 invalidate() } } return true } }

三个参数决定手感。第一个是可用高度:字母多的时候(加上#通常 27 个左右),如果控件高度不够,字母之间会挤成一团,需要限制最小行高,比如每格不小于 14dp,超了就按比例缩放字号或者裁掉部分字母。第二个是触摸热区:索引条视觉宽度通常只有 24dp,手指按不准,实际布局宽度给 40dp,左右各留 8dp 透明区,绘制时居中。第三个是coerceIn的边界,手指滑出控件外时仍要能响应,所以ACTION_MOVE阶段不要判断x是否在范围内。

反向联动在列表侧做,滚动时反查当前第一个可见项属于哪个字母:

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { val first = (rv.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() if (first == RecyclerView.NO_POSITION) return // 用二分查 starts,找到第一个 <= first 的分组,O(log n) val groupIndex = starts.indexOfLast { it <= first }.coerceAtLeast(0) indexBar.highlight(letters[groupIndex]) } })

点击跳转要用scrollToPositionWithOffset(position, 0),第二个参数写 0,不要留默认值。默认行为会把目标项滚到「刚好可见」,视觉上就是差半行,用户会觉得没点准。另外这个调用要post到下一帧执行,直接在触摸回调里调,列表可能还在布局中,跳转会失效。

3.3 搜索:中文、全拼、首字母三路匹配

搜索的坑不在算法,在优先级和输入清洗。用户输「hz」期望是杭州,输「hang」也期望是杭州,输「杭州」还期望是杭州,三种输入走的是三条不同路径,得分必须能比较。

object AreaMatcher { // 命中优先级:中文前缀 > 全拼前缀 > 单字母首字母 > 全拼包含 > 中文包含 fun score(area: Area, rawKeyword: String): Int { val keyword = rawKeyword.trim().lowercase() if (keyword.isEmpty()) return 0 return when { area.name.startsWith(keyword, ignoreCase = true) -> 100 area.pinyin.startsWith(keyword) -> 80 keyword.length == 1 && area.initial.lowercaseChar() == keyword[0] -> 60 area.pinyin.contains(keyword) -> 40 area.name.contains(keyword, ignoreCase = true) -> 20 else -> 0 } } fun search(all: List<Area>, keyword: String): List<Area> = all.asSequence() .map { it to score(it, keyword) } .filter { it.second > 0 } .sortedByDescending { it.second } .map { it.first } .toList() }

100 / 80 / 60 / 40 / 20这套分值不是拍脑袋,它对应的实际预期是:用户打中文时最想要前缀命中,打全拼时想要拼音前缀命中,打单个字母时多半是在试索引。同分的情况靠sortedByDescending的稳定性保持原始数据顺序,所以原始列表本身要先按拼音排好序,否则同分结果每次都可能不一样。

性能上,三千条数据做一次线性扫描加排序,通常在十几毫秒内,直接在TextWatcher里同步跑没问题。上到一万条以上再考虑放子线程加 200ms 防抖,或者建一张「拼音前缀 → 城市」的倒排表。别一上来就上协程和 Flow,输入法每次按键都触发一次调度,反而更抖。

还有个细节:输入法会带进来全角字符和空格,trim()之外最好再做一次全角转半角和去除内部空格,否则用户从通讯录粘贴过来的城市名永远搜不到。

4. 定位城市与热门城市:三段超时和行政区划对齐

4.1 定位链路:三段超时,每段都要有兜底

定位是这个模块里最容易让用户干等的地方。系统定位 API 在室内可能十几秒都不返回,逆地理编码再走一次网络,串起来就是三十秒的 loading。合理的做法是把整条链路切成三段,每段都有超时:

class CityLocator(private val cache: LocationCache) { fun locate(onResult: (LocatedCity?) -> Unit) { // 第一优先:5 分钟内成功过的结果直接复用,避免每次进页面都拉定位 cache.lastLocated(maxAgeMs = 5 * 60_000L)?.let { return onResult(it) } var finished = false val main = Handler(Looper.getMainLooper()) fun finish(city: LocatedCity?) { if (finished) return // 保证回调只走一次,定位和超时谁先到算谁 finished = true main.removeCallbacksAndMessages(null) onResult(city) } // 整体兜底 3 秒,到点无论定位有没有回来都先给 UI 一个结果 main.postDelayed({ finish(null) }, 3_000L) requestDeviceLocation(timeoutMs = 2_000L) { latLng -> if (latLng == null) return@requestDeviceLocation finish(null) reverseGeocode(latLng, timeoutMs = 1_500L) { adCode, name -> // 优先用行政区划编码对齐本地库,编码对不上再退回名称匹配 val matched = adCode?.let { cache.byAdCode(it) } ?: cache.fuzzyByName(name) finish(matched) } } } }

三个时间参数可以按产品调,但顺序不能乱:设备定位超时 2 秒,逆地理编码 1.5 秒,整体兜底 3 秒。整体兜底要小于两段之和,这样才能在定位成功后还有余量做 UI 更新。finished标志位是必须的,没有它就会出现「超时回调先到、定位结果后到」,UI 先显示默认城市再被覆盖一次,用户看到的是闪烁。

定位失败不要弹 toast 说「定位失败」,直接把默认城市(比如上一次选中的城市,或者服务端配的兜底城市)填进取景框,让用户自己决定要不要改。这块的体验差异比技术方案差异大得多。

4.2 行政区划对齐:名称能对上,id 对不上

这是最容易在上线后才暴露的问题。定位接口返回的是「某某市某某区」,本地区划表里存的是编码加名称,两边的命名口径往往不一致——有的带「市」后缀,有的不带;有的用「自治州」全称,有的简写成两个字。

对齐策略按优先级排:

优先级匹配方式命中率说明
1行政区划编码精确匹配高定位接口能返回 adCode 时首选,直接查本地索引
2去掉「省/市/区/县/自治州」后缀后名称相等中高处理口径差异最有效的一招
3名称包含匹配中兜底,可能有歧义,命中多个时取层级最高的
4匹配失败,原样展示不可选中—展示定位结果供参考,但筛选条件仍用默认城市

第 4 条很重要:宁可让用户手动选一次,也不要拿一个错的 id 去请求业务接口,那种错误在服务端日志里极难定位。

4.3 热门城市:本地兜底 + 服务端覆盖

热门城市不适合写死在代码里——运营要换一批就得发版,成本太高。常见做法是本地内置一份默认列表保证首次可用,同时用带版本号的接口下发覆盖,版本号没变就不请求。

点击热门城市还有一个容易漏的点:热门列表里通常只有城市名和 id,没有完整的三级路径。用户点了「某某市」,回显时要展示「某省 某某市」,就得用pathOf反查一次。反查不到说明热门数据和本地区划版本不一致,此时应该降级为「只填城市,省市置空并提示用户补全」,而不是直接崩或者填一个空字符串。

5. 避坑与排查:五条上线后才暴露的问题记录

5.1 低端机首次打开白屏半秒

现象:中高端机进页面很顺,低端机点开选择器先白屏 300 到 500 毫秒,然后列表一次性出现。

原因:解析 assets 里的 JSON、构建拼音索引、submitList三步全在主线程串着跑,加起来正好卡在首帧。

解决:把解析和索引构建提前到Application启动后的子线程预热,页面打开时只做一次内存读取;如果数据还没就绪,先展示骨架屏和热门城市,数据到位后再补齐列表。另外submitList要给DiffUtil一个稳定的 id,别每帧都全量刷新。

5.2 拼音搜索漏字,用户搜不到自己的城市

现象:搜某个多音字城市的正确读音首字母,搜不出来;搜另一个错误读音反而能出来。

原因:通用拼音库对多音字只给一个默认读音,而这个默认读音往往不是地名读音。

解决:维护一张多音字覆盖表,加载时优先查表。同时保留中文包含匹配作为兜底路径,用户打中文一定能搜到,只是排序靠后一点。覆盖表要跟着数据版本一起走,接新区划数据时顺手检查一遍有没有新的多音字城市。

5.3 索引条点了滚不到目标字母

现象:点字母 B 滚到 B 组中间甚至 C 组开头;或者搜索过滤之后,索引条还停留在全量数据的字母上。

原因:starts数组只在初始化时算了一次,submitList之后没有重建;或者点击时用的是字母序号而不是分组起点 position。

解决:把所有数据变更收敛到一个入口,submitList之后立刻重建letters和starts并同步给索引条;搜索状态下直接隐藏索引条,不要让用户对着过滤后的列表用全量索引。

5.4 深色模式下吸附头颜色不跟随

现象:列表背景是深色的,唯独顶部那个吸附的分组头还是浅灰色,很扎眼。

原因:ItemDecoration里的Paint颜色在构造时写死了,主题切换后没有重建。

解决:颜色从主题属性里读,并且在onDrawOver里每次绘制时读当前值,或者监听onConfigurationChanged重建画笔。同理,textSize也别写死 px,用sp换算,否则系统字体放大后分组头和列表内容会对不上。

5.5 回显错乱,历史地址指向了别的城市

现象:用户之前存过的收货地址,区划数据更新一次之后,回显成了同名的另一个区。

原因:本地历史记录存的是名称而不是 id,或者存了 id 但数据升级后 id 变了没有做迁移。

解决:历史记录同时存 id 和名称两份,读取时用 id 查库、查不到再用名称做一次模糊匹配,仍然匹配不上就提示用户重新选择。数据升级脚本里要有一份「旧 id → 新 id」的映射表,没有映射的按名称兜底,这一步别省。

6. 封装与验证:把选择器收敛成一行调用的检查清单

做到这一步,模块已经能跑,但还没到「可以在三个项目里复用」的程度。真正决定复用成本的是对外契约有多窄。我的做法是只暴露一个入口、一个结果对象,中间过程全部私有:

// 唯一对外入口:调用方只关心"要几级"和"结果怎么拿" CityPickerLauncher.show( activity = this, config = CityPickerConfig( maxLevel = 3, // 需要几级,1/2/3 showLocatedCity = true, // 是否展示定位城市卡片 showHotCities = true, // 是否展示热门城市 themeColorRes = R.color.brand_primary ) ) { result -> // result 里一定是完整的三级路径,level 不足的字段为空串而不是 null result.province?.id result.city?.name result.district?.id }

对外只给CityPickerConfig和回调结果,意味着调用方拿不到 RecyclerView、拿不到 Adapter,也就不会有人在业务代码里偷偷改列表数据。这条约束省掉过很多次「这个页面能按拼音搜、那个页面不能」的排查。

上线前我会走一遍下面这张表,五项全过才敢发:

检查项判定标准
低端机首帧冷启动进页面无白屏,骨架屏先出现
索引双向联动点击滚动到位,滚动高亮跟随,搜索态隐藏索引条
拼音三路匹配中文、全拼、单字母各测三个城市,多音字城市重点测
定位降级关定位权限、开飞行模式、弱网三种情况下都能拿到默认城市
回显一致性存一条历史地址,切数据版本后重新打开,指向同一行政区

最下面这行是血泪经验。前面四项都是当场能测出来的,唯独回显问题要等数据更新之后才暴露,而那时候已经是一周后了。所以现在只要动了区划数据,我都会手动造一条旧版本的历史记录,切到新版本重开一次页面对比 id。从那以后我每次接这种「加个城市选择器」的需求,都强制先把数据源、索引、降级、回显四条各走一遍再动手写 UI,省下的返工时间远超前期这半小时。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询