简介:Android城市选择器是一份面向Android应用开发者的可复用UI组件源码包,仿照美团城市选择界面,解决电商、旅游、社交与生活服务类应用快速集成城市选择功能的问题。组件依托高德地图定位能力获取准确的位置数据,采用独立模块化结构,内部API简洁,便于按项目需要修改城市数据与界面样式,既适合初学者参照实现,也方便有经验的开发者直接嵌入现有工程。压缩包共285个文件、约908KB,以164个class编译产物、31个java源码、40个xml布局与资源文件、22个png图片及3个jar依赖为主,另含json配置、db城市数据与txt说明,可完整还原工程结构,已有931人学习下载。读者可从中获得可直接运行的城市选择器示例、城市数据与定位调用方式、弹窗式选择界面的布局与交互实现,以及回调处理与自定义配置的参考写法,为项目中常见的城市选择需求提供节省调试时间的落地方案。
1. 一个城市选择器,真正的难点不在选城市
城市选择器看着像二十行代码就能做出来的东西——一个 ListView、一份城市数组、一个点击回调。但真做进项目,第一个版本往往会在三个地方翻车:几百个城市滑起来首帧卡顿、索引条按下去跳错字母、搜索框里敲 “chongqing” 搜不出重庆。我在某跨平台系统的 Android 端做过一次完整重写,把城市选择器从「一个页面」拆成数据层、分组层、交互层三块,才算能交付。这套方案适合正在做移动端课设、毕设、需要一个可复用选择器组件的同学,也适合要给已有应用补一个选城市入口的从业者。它不依赖第三方 UI 库,核心代码两百行上下,但边界、缓存、异常兜底得自己交代清楚。
2. 需求拆解与数据底座:城市数据从哪来、怎么组织
先把需求摊开。一个合格的 Android 城市选择器要同时满足四类诉求:当前定位城市要能一键选中;热门城市要给不想翻列表的人兜底;最近选过的城市要能被记住;全量城市要能按 A–Z 分组、按拼音和首字母搜到。这四块在界面上是四段,在数据结构上却是同一种东西,所以第一件事不是画布局,是定数据模型。
2.1 数据模型与字段设计
城市数据的来源通常是行政区划表,字段包含编码、名称、所属省份、拼音、首字母。我一般会把展示字段和状态字段严格分开:CityBean只负责「城市是什么」,选中态、是否置顶这些「城市此刻怎样」全部放到外部,避免同一份数据被多个页面修改后互相污染。
// 城市实体:只放静态信息,不放任何 UI 状态 data class CityBean( val code: String, // 行政区划编码,唯一键,重名城市靠它区分 val name: String, // 展示名,如「杭州」 val province: String, // 所属省份,搜索结果里做副标题 val fullPinyin: String, // 全拼小写,如「hangzhou」,搜索用 val firstLetter: Char, // 首字母大写,分组用 val hotRank: Int = 0 // 大于 0 表示热门城市,数值即顺序 )这里有两个坑值得提前说。第一,唯一键一定用code,不要用name。行政区划里重名的情况不少,同一个名字在不同层级都会出现,用名字做 DiffUtil 的 key 会导致列表刷新时闪烁甚至错位。第二,不要把isSelected写进CityBean里。选择器可能支持多选,选中集合用MutableSet<String>单独维护,回传时只传 code 列表,页面销毁后这份状态自然消失,不会留下脏数据。
hotRank的设计也值得单独说一句。热门城市不该在代码里写死一个字符串数组,因为写死之后排序、增删都要改代码。用「排序权重」表达,权重为 0 就不是热门城市,权重 1、2、3 决定展示顺序,改运营配置时只改数据文件不改逻辑。
2.2 拼音索引与排序规则
拼音转换是城市选择器的第一个技术选型点。常见方案有两个:词典全、体积大的转换库,和体积小、只覆盖常用汉字的轻量库。城市名加起来三百多个,汉字范围非常有限,用轻量的那个就够,打出来的包能少一两百 KB,对移动端来说是实打实的收益。
选型定了之后,多音字必须用白名单覆盖,不能指望自动转换。像「重庆」「厦门」「长沙」「六安」「蚌埠」这些地名,拼音转换库按常用读音处理会给出错误结果,用户搜 “chongqing” 永远搜不到。我的做法是维护一张地名多音字表,在数据构建阶段就覆盖掉,运行期不做任何判断。
// 地名多音字覆盖表:只处理城市名里真实出现的多音字 private val POLYPHONE = mapOf( "重庆" to "chongqing", "厦门" to "xiamen", "长沙" to "changsha", "六安" to "luan", "蚌埠" to "bengbu", "台州" to "taizhou" // 不是「tai」的阳平,避免和其他「台」字城市混 ) fun toPinyin(name: String): String { POLYPHONE[name]?.let { return it } return TinyPinyin.toPinyin(name, "").lowercase() }排序规则定三条就够用:A–Z 正序;非字母开头的城市归到#且排在最后;同一字母组内保持数据源原始顺序,不额外按拼音二次排序。第三条是为了稳定性——如果组内再按拼音排,用户在不同版本间会看到位置漂移,反而不好找。分组头本身也参与列表,它的位置必须由数据层算好,而不是让 Adapter 临时判断。
// 把城市列表切成「分组头 + 城市项」的扁平结构 fun buildIndexed(cities: List<CityBean>): List<CityListItem> { val result = mutableListOf<CityListItem>() cities.groupBy { it.firstLetter.uppercaseChar() } .toSortedMap() .forEach { (letter, group) -> result += CityListItem.Header(letter) group.forEach { result += CityListItem.City(it) } } return result }这里直接用SortedMap保证字母顺序,#因为字符编码排在字母之后,天然落在末尾,不用额外写特判。分组头的stableId用"h_$letter",城市项用"c_${code}",两者前缀不同,DiffUtil 不会把分组头和城市项误判成同一项。
2.3 数据裁剪与体积控制
城市数据放assets下的 JSON 里,字段名压到最短,能省一半体积。三百多个地级市加上热门标记,压缩后通常只有几十 KB,对安装包影响可以忽略。真正影响体验的不是文件大小,是解析时机。
我一般把解析放在首次进入选择器时异步执行,结果用@Volatile单例缓存,第二次打开直接命中内存。不要在Application.onCreate里解析——它会把启动时间拖长,而绝大多数用户根本不会进这个页面。
// 仓库层:一次解析,进程内复用 object CityRepository { @Volatile private var cache: List<CityBean>? = null fun load(context: Context): List<CityBean> { cache?.let { return it } val json = context.assets.open("cities.json") .bufferedReader().use { it.readText() } val list = parse(json) // 解析 + 拼音补全 + 多音字覆盖 cache = list return list } }更彻底的做法是在构建期就把拼音算好。写一个 Gradle task 或一次性的脚本,把原始行政区划表转成带fullPinyin、firstLetter的 JSON,运行期只做反序列化。这样启动耗时能再降一截,代价是数据更新时要记得重跑脚本。我在做毕设那版时选了运行期补全,因为改数据更顺手;后来做正式项目改成了构建期生成,因为那时数据一年才动一次。
3. 界面分层与交互骨架:一个列表撑起四类区域
数据有了,接下来是界面。很多人第一反应是「定位一块、热门一块、最近一块、全量一块,各写一个 RecyclerView」,这么做能跑,但滚动同步、索引条定位、搜索过滤三件事会立刻变得极其麻烦。我的做法是反过来的:整个页面只有一个 RecyclerView,四类区域全部拍平成一个列表,用 item 类型区分。
3.1 结构划分:定位、热门、最近、全量
拍平之后,列表的 item 类型可以用密封类表达,编译器会帮你检查分支是否写全:
// 列表项:类型即布局,Adapter 只按类型分发 sealed class CityListItem { abstract val stableId: String data class Header(val letter: Char) : CityListItem() { override val stableId = "h_$letter" // 字母分组头 } data class City(val bean: CityBean) : CityListItem() { override val stableId = "c_${bean.code}" // 城市项 } data class BlockTitle(val title: String) : CityListItem() { override val stableId = "t_$title" // 「定位」「热门」小标题 } data class Grid(val cities: List<CityBean>) : CityListItem() { override val stableId = "g_${cities.first().code}" // 三列宫格 } }定位城市和热门城市做成三列宫格,占一个 item;最近访问也做宫格,但只保留最近 5 个,超出的自动淘汰。宫格内部不参与字母索引,只有「全量城市」这一段响应索引条。这个边界要在数据结构上体现出来——索引条能跳转的目标,只能是Header类型的位置。
最近访问的存储用SharedPreferences就足够,存一串 code,读取时按 code 从内存列表里反查实体。存实体本身是不可取的,因为城市数据更新后旧实体可能已经不存在,反查时找不到就跳过,不会崩溃。
3.2 Adapter 多类型与索引条联动
Adapter 用DiffUtil而不是notifyDataSetChanged。选择器每次搜索都会整体换数据,用全量刷新会让列表闪一下,而且滚动位置会丢。DiffUtil配合稳定 id,能把刷新收窄到真正变化的项。
// 类型分发:数字越小越靠前,和视觉顺序无关,只看性能 override fun getItemViewType(position: Int): Int = when (list[position]) { is CityListItem.BlockTitle -> TYPE_BLOCK_TITLE is CityListItem.Grid -> TYPE_GRID is CityListItem.Header -> TYPE_HEADER is CityListItem.City -> TYPE_CITY } // 索引表:分组头字母 -> 列表位置,数据变化后重建 private val letterPositions = HashMap<Char, Int>() fun submit(items: List<CityListItem>) { val diff = DiffUtil.calculateDiff(CityDiff(list, items)) list = items letterPositions.clear() items.forEachIndexed { index, item -> if (item is CityListItem.Header) letterPositions[item.letter] = index } diff.dispatchUpdatesTo(this) }letterPositions这张表是索引条的核心。它必须在submit里重建,而不是在onBindViewHolder里顺手塞——后者只在可见项绑定时才执行,滚出屏幕的分组头就不会进表,索引条一跳就跳到错误位置。这个坑我在第一版里踩过,表现是「按 M 跳到 L 组」,排查了半天才定位到建表时机。
3.3 搜索态与浏览态的切换
搜索框一有输入,列表就要从「分组浏览」切成「结果列表」,清空后再切回来。两种状态共用同一个 Adapter,但数据源不同:
- 浏览态:
BlockTitle + Grid + Header + City的完整结构 - 搜索态:纯
City列表,不加分组头,命中项少于 20 条时直接铺开
切换时有个细节:进入搜索态要记住当前滚动位置,退出搜索态时恢复。否则用户翻了半天列表,随手点了下搜索框又清空,回到顶部会很不爽。恢复位置用LinearLayoutManager.scrollToPositionWithOffset,在数据提交后的下一帧调用。
// 进出搜索态:保存与恢复滚动位置 private var savedOffset = 0 private var savedPosition = 0 fun enterSearchMode() { val lm = rv.layoutManager as LinearLayoutManager savedPosition = lm.findFirstVisibleItemPosition() savedOffset = lm.findViewByPosition(savedPosition)?.top ?: 0 } fun exitSearchMode() { adapter.submit(browseItems) rv.post { (rv.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(savedPosition, savedOffset) } }rv.post是必要的。数据刚提交完,RecyclerView还没完成布局,这时候调滚动定位会落到旧位置或者被丢弃。放到下一帧,布局完成后再定位,成功率才稳定。这类「时序问题」在城市选择器里出现频率很高,后面单独用一章讲。
4. 核心实现:索引定位、搜索匹配与结果回传
界面骨架搭好后,剩下三块需要精细处理的实现:索引条的触摸定位、搜索的多字段匹配、以及选择结果的回传。这三块决定用户「按得准不准、搜得到搜不到、选了能不能拿到」。
4.1 索引条滑动定位
索引条不能用一堆 TextView 拼,那样触摸事件分发会很难受。自定义一个 View,把字母数组画出来,onTouchEvent里根据纵坐标算出当前字母即可。ACTION_DOWN和ACTION_MOVE都算触摸,ACTION_UP触发一次performClick保证无障碍可访问。
class LetterIndexBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { var letters: List<String> = listOf("↑", "定", "热", "近") + ('A'..'Z').map { it.toString() } var onLetterTouch: ((String) -> Unit)? = null private var lastIndex = -1 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 26f; textAlign = Paint.Align.CENTER } override fun onDraw(canvas: Canvas) { val itemHeight = height.toFloat() / letters.size letters.forEachIndexed { i, letter -> val y = itemHeight * (i + 1) - itemHeight / 2 - (paint.descent() + paint.ascent()) / 2 canvas.drawText(letter, width / 2f, y, paint) } } override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { val h = height.toFloat() / letters.size val index = ((event.y - paddingTop) / h).toInt() .coerceIn(0, letters.lastIndex) if (index != lastIndex) { lastIndex = index onLetterTouch?.invoke(letters[index]) // 只在字母变化时回调 } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { performClick() lastIndex = -1 } } return true } override fun performClick(): Boolean { super.performClick() return true } }这段代码里最值钱的是if (index != lastIndex)。ACTION_MOVE触发频率极高,手指在同一个字母上晃动也会连续回调,不加这个判断,滚动会被反复打断,滑动变成幻灯片式的一顿一顿。加完之后,只有跨字母时才触发定位,手感立刻不一样。
定位的动作本身只有一行,关键是取位置的方式:
fun scrollToLetter(letter: String) { val pos = adapter.positionOfLetter(letter[0]) // 查 3.2 里建的表 if (pos >= 0) { (rv.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(pos, 0) } }scrollToPositionWithOffset(pos, 0)让目标分组头贴顶,比scrollToPosition稳。后者只保证项可见,具体贴在哪由布局决定,用户看到的是「差不多到了」,不是「精确到了」。
4.2 搜索匹配:全拼、首字母、连续匹配
搜索的匹配字段有三个:城市名、全拼、首字母。用户可能输入中文(“杭州”),也可能输入全拼(“hangzhou”),还可能输入首字母缩写(“hz”)。三种都要能命中,且优先级依次递减——中文命中排最前,全拼次之,缩写最后。
// 返回命中权重,null 表示不命中;数值越小越靠前 fun matchWeight(city: CityBean, keyword: String): Int? { val kw = keyword.lowercase() if (city.name.startsWith(keyword)) return 0 // 中文前缀,最优 if (city.fullPinyin.startsWith(kw)) return 1 // 全拼前缀 if (city.fullPinyin.contains(kw)) return 2 // 全拼包含 if (initials(city.fullPinyin).startsWith(kw)) return 3 // 首字母缩写 return null } private fun initials(pinyin: String) = pinyin.first().toString()首字母缩写这里简化成「取全拼首字母」,能覆盖 “hz”→“hangzhou” 这类常见用法。如果要支持 “hangzh” 这种中段缩写,就得在构建期把全拼切分成音节序列,再逐段取首字母,数据结构上多存一个syllables字段。毕设做到单字母缩写就够,正式项目建议做多段,因为用户输入习惯差别很大。
搜索结果需要高亮命中部分。用SpannableString加ForegroundColorSpan,注意高亮范围要按原文长度算,不能按小写后的字符串算:
fun highlight(name: String, keyword: String): CharSequence { val start = name.indexOf(keyword, ignoreCase = true) if (start < 0) return name return SpannableString(name).apply { setSpan(ForegroundColorSpan(colorAccent), start, start + keyword.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } }关键词里的中文和全拼不在同一个字符串里,所以高亮只对「城市名命中」这类情况生效,拼音命中的项不高亮名字,只做排序。这是有意的取舍——强行在中文名上标拼音位置,视觉效果会很怪。
搜索输入要加防抖。每次输入都全量过滤三百条数据其实不慢,但输入法连打时回调会密集触发,列表频繁提交DiffUtil,观感上会抖。用Handler延迟 150ms 执行,新输入进来先取消上一个任务:
private val searchTask = Runnable { performSearch(input.text.toString()) } input.doAfterTextChanged { handler.removeCallbacks(searchTask) handler.postDelayed(searchTask, 150) }150ms 是个经验值:小于 100ms 防不住连打,大于 250ms 用户会觉得卡。另外,输入为空时要立刻切回浏览态,不能也等 150ms,否则清空搜索框会有明显延迟感。
4.3 结果回传与多选场景
回传用ActivityResultContracts.StartActivityForResult,不要再用废弃的startActivityForResult+onActivityResult。多选时把选中的 code 和 name 一起回传,调用方多数情况下需要展示名字,让它再查一次数据是多余的。
// 调用侧:单选用一个 code,多选用一个列表,语义清晰 private val pickCity = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { val code = result.data?.getStringExtra(EXTRA_CITY_CODE) val name = result.data?.getStringExtra(EXTRA_CITY_NAME) binding.tvCity.text = name ?: "请选择" viewModel.loadWeather(code) // 选完立刻联动业务 } } // 选择器侧:单选取第一个,多选取全部 private fun finishWithResult(codes: List<String>, names: List<String>) { setResult(Activity.RESULT_OK, Intent().apply { putExtra(EXTRA_CITY_CODE, codes.firstOrNull()) putExtra(EXTRA_CITY_NAME, names.firstOrNull()) putStringArrayListExtra(EXTRA_CITY_NAMES, ArrayList(names)) }) finish() }多选要限制数量时,把上限做成构造参数而不是写死常量,调用方传 3 就是最多选 3 个。超限时不要静默忽略点击,弹一个轻提示告诉用户已达上限,否则用户会以为点击失效——这类「点了没反应」的抱怨,一半以上是静默失败造成的。
5. 常见问题排查:让选择器「点了没反应」的四类坑
城市选择器的功能简单,导致很多问题看起来像玄学:明明代码没问题,就是不跳、就是搜不到、就是闪。这一章列四个我在不同项目里真实遇到过的情况,每条按现象、原因、解决写,方便对照排查。
5.1 索引条跳转错位或跳到错误分组
现象:点索引条上的 M,列表跳到 L 组的开头,或者跳到列表顶部不动,重进页面又正常。
原因几乎都出在位置表的构建时机。如果在onBindViewHolder里顺手往letterPositions里塞数据,只有可见项会被绑定,屏幕外的分组头永远进不了表,查不到就返回 -1,定位自然落空或者回落到顶部。另一种情况是数据提交后立刻调用滚动,此时RecyclerView还没完成布局,findViewByPosition返回 null。
解决分两步。位置表统一在submit里遍历全量数据重建,保证每个分组头都入表;滚动动作包在rv.post {}里,等下一帧布局完成再执行。如果数据量大、post有时还是拿不到位置,可以退一步用doOnPreDraw或者监听RecyclerView的onLayoutChange再定位。
5.2 搜索完退出,列表滚动位置丢失
现象:滚到 S 组,点搜索框输了一个字又删掉,列表弹回顶部。
原因是搜索态和浏览态共用一份数据源,切换时直接submit了新列表,DiffUtil认为这是一次大规模替换,滚动位置被重置。也有一种情况是位置保存得太晚——在TextWatcher里保存,此时列表可能已经被过滤过一轮了。
解决是把「保存位置」提前到搜索框获得焦点的那一刻,用findFirstVisibleItemPosition加可见项top两个值一起存,退出时用scrollToPositionWithOffset恢复。注意恢复动作必须放在数据提交后的下一帧,否则会被尚未完成的布局覆盖掉。
5.3 定位城市一直转圈或始终不显示
现象:进入页面时「当前城市」区域显示加载中,等十几秒也不出结果;或者用户拒绝定位权限后,这个区域永远空着。
原因有三种常见情况:定位回调没有做超时兜底,底层迟迟不回调,UI 就永远等在加载态;权限被拒后没有降级分支,代码还在等一个永远不会来的坐标;回调持有 Activity 引用,页面销毁后回调才到,更新的是一个已经不可见的视图。
解决是给定位加一个 3 秒超时,超时就降级到「默认城市 + 热门城市」,不要让它无限等。权限检查放在请求定位之前,拒绝就走降级,并且在定位区域显示一个「手动选择」的入口,用户不会卡死在这一屏。回调用弱引用持有视图,或者在onDestroy里主动取消请求。
5.4 快速滑动列表时索引条高亮不同步
现象:手指快速滑动城市列表,右侧索引条的当前字母高亮不跟着变,或者疯狂闪烁。
原因是只做了单向联动——索引条控制列表,列表不反向通知索引条。补上addOnScrollListener,在onScrolled里取第一个可见项的位置,反查它所属的分组头字母,再回传给索引条。但这里有个陷阱:索引条自身的触摸也会引起列表滚动,滚动的回调又去改索引条高亮,两者互相触发就会抖。
解决是加一个「程序化滚动中」的标志位,索引条触摸时置为 true,滚动停止后(onScrollStateChanged到 IDLE)置回 false,反向同步逻辑在标志位为 true 时直接跳过。这样用户手指按索引条时,高亮跟着手指走;手指滑列表时,高亮跟着列表走,两边不打架。
5.5 首帧卡顿、列表滑动掉帧
现象:第一次打开选择器,页面白一下才出列表;快速滑动时能明显感觉到掉帧。
原因是在onBindViewHolder或者数据加载完成后的主线程回调里做拼音转换。三百多个城市逐个调转换库,每次几十微秒,加起来就是一两百毫秒的主线程阻塞,首帧自然卡。另一种是全量notifyDataSetChanged,列表每帧都在重新测量。
解决是把拼音转换放到数据构建阶段(构建期脚本或者子线程),主线程只做反序列化。列表刷新统一走DiffUtil,稳定 id 用 code,避免整表重绘。如果城市数据里包含区县,条目会到几千条,这时再加一层分页加载或者只加载地级市,别一次性全铺。
6. 验收与答辩:用一张表和三个提问把方案讲圆
方案做完,怎么证明它是可交付的?我一般不看「功能能不能点」,看一张验收表。下面这五项是我在项目里固定要过的:
| 验收项 | 判定标准 | 检查方式 |
|---|---|---|
| 数据完整性 | 城市数量与数据源一致,无重复 code | 加载后比对总数与去重后总数 |
| 索引准确性 | 26 个字母逐一点击,列表首项为该字母分组头 | 手动或脚本遍历索引条 |
| 搜索召回 | 中文、全拼、首字母三种输入均能命中目标城市 | 抽 20 个城市各测三种输入 |
| 多音字覆盖 | 重庆、厦门、长沙等按正确拼音可搜到 | 定点验证覆盖表里的每一条 |
| 首帧性能 | 冷启动进入页面,首帧渲染低于 300ms | 打开布局检查工具看耗时 |
表格之外,答辩时最容易被问到的其实是三个问题,提前想清楚就能稳住:
第一个是「为什么不用现成的选择器组件」。答案要落在具体收益上——现成组件的体积、默认交互、数据源绑定方式未必匹配你的项目,自己实现可以把包体控制在几十 KB、把多音字表和热门配置握在手里,改起来不用等第三方发版。
第二个是「多音字怎么保证不出错」。这里要能说出覆盖表的存在和它的边界:表里是城市名里真实出现的多音字,不是通用字典;新增城市时如果名字含多音字,必须同步补表;测试用例直接按表逐条验证。能讲清「边界在哪、怎么维护」,比声称「已经全覆盖」可信得多。
第三个是「数据变了怎么办」。答案分两层:数据文件是外部资源,换文件不影响逻辑;拼音在构建期生成,换数据要重跑脚本。这两层分开讲,评审就知道你想过维护成本,而不是只做了个能跑的 demo。
我最早做这套东西时,验收全靠手点,一个字母一个字母点过去,点完一遍要十几分钟,改一行代码又得重来。后来养成一个习惯:索引定位和搜索召回这两项,必须做成可重复执行的检查,哪怕是几条简单的断言脚本,也比人眼靠谱。城市选择器这种东西,功能看着浅,坑全在边界上——每次改完数据源,我都会强制把多音字表和索引表在脑子里过一遍,再点一次最边上的字母 Z。希望帮到你。
本文还有配套的精品资源,点击获取