简介这是一套面向 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别用嵌套树很多人第一版会写成嵌套结构省对象里带一个ListCity城市里带一个ListDistrict。看起来直观实际用起来处处别扭——搜索要递归遍历回显从区反查省要写三层循环增量更新时合并两棵树更是灾难。扁平表就没有这些问题。所有节点放一个 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: ListArea) { // parentId - 子节点列表构建一次之后三级联动全是 O(1) private val childrenIndex: MapString, ListArea raw.groupBy { it.parentId } // id - 节点回显时从区上溯到省用 private val idIndex: MapString, Area raw.associateBy { it.id } fun provinces(): ListArea childrenIndex[0].orEmpty() fun childrenOf(parentId: String): ListArea childrenIndex[parentId].orEmpty() // 从任意层级的 id 反查出完整路径最多循环三次 fun pathOf(areaId: String, depth: Int 3): ListArea { val path ArrayDequeArea() 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: ListArea): ListArea list.map { area - if (area.level ! 2) returnmap 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: ListArea): PairListChar, IntArray { val letters ArrayListChar() val starts ArrayListInt() 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: ListChar emptyList() private var starts: IntArray IntArray(0) private var currentIndex -1 fun submit(letters: ListChar, 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: ListArea, keyword: String): ListArea 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) returnrequestDeviceLocation 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省下的返工时间远超前期这半小时。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?