简介Android城市选择器是一套面向Android开发者的可复用组件仿照美团城市选择界面并借助高德地图完成定位与城市数据获取适合电商、旅游、社交、生活服务等应用快速补齐城市选择能力。压缩包共285个文件、约908KB以164个class编译文件、31个java源码、40个xml布局与资源、22个png图片为主另含jar依赖、json数据、db数据库及Gradle配置兼顾直接集成与二次修改。已有931人学习下载说明其在同类组件中具有一定参考价值。读者可从中获得独立的城市选择模块、简洁的调用接口和可定制UI示例理解定位、城市列表展示、选择回调与配置调整的组织方式减少从零编写和测试城市选择功能的时间。1. 用户划一下就走的交互背后是三千条数据的排序问题一个 Android 城市选择器最容易被低估的地方在于它看起来只有“列表 搜索框 右侧字母条”三个元素但真把全国省市区的数据铺进去三千多条记录、一次分组、一次拼音转换、一次搜索匹配每一环都会左右体验。用户从 A 划到 Z列表要跟手输入 “hz” 要出“杭州”输入“重庆”不能变成“zhongqing”选完之后回传给业务方的还不能是个空对象。这套方案解决的就是这三件事数据怎么组织、索引怎么建、结果怎么稳定交出去。适合正在做 Android 课程设计、毕设或者要在业务里补一个地址选择模块的人参考代码层面的东西可以直接拿走改。2. 需求拆解与技术选型本地数据加内存索引为什么更稳2.1 四类选择场景决定数据形态城市选择看着是一个功能实际至少分四种用法数据形态要提前对齐否则写到一半会发现结构撑不住第一种是单城市切换比如首页左上角的“当前城市”用户只需要选一个市级单位省和区县都不关心第二种是省市区三级联动收货地址、发票抬头这类场景选完省才能选市、选完市才能选区县最终要拿到一个区县编码第三种是热门城市加定位城市列表顶部固定放几个高频城市加一个“当前定位”的城市入口第四种是纯搜索优先用户明确知道自己要哪个城市打开就想直接打字。这四种场景对数据的诉求不一样前两种要求层级关系明确后两种要求扁平、好查。我一般的做法是一份扁平数据 parentCode 建层级还是用一张cities表每条记录带code、parentCode、levellevel1是省、2是市、3是区县。三级联动靠parentCode过滤单城市选择靠level过滤搜索则直接在扁平列表上跑。一份数据覆盖四种场景不用为每个入口准备一套。数据集本身量级不大全国省级 34 条、市级 300 多条、区县 2800 多条加起来三千出头。这个体量决定了后面所有选型——它小到可以整个塞进内存又大到不能用线性遍历去做实时搜索。2.2 预置 JSON、预置 SQLite、服务端接口三条路的取舍数据放哪儿是这套方案第一个要拍板的地方。常见三条路方案首次可用耗时搜索实现常驻内存数据更新预置 JSON启动时全量解析3080ms可用子线程缓存规避内存遍历可控排序权重约 13MB换包或下载后覆盖预置 SQLite查询走 SQL首查 1020msLIKE / FTS排序能力弱由页缓存控制通常更小同左可增量服务端接口按需拉取依赖网络秒级波动服务端实现极小实时城市数据十年级别才变一次最近一次大的调整也就是个别区县撤并。这意味着“实时更新”这个卖点在这里几乎不值钱反而要付出网络失败的代价——用户在地铁里填收货地址转圈十秒最后告诉你加载失败这种翻车现场不需要多解释。SQLite 的优点是不占内存、可以随时查但城市选择器的搜索是每敲一个字符就要出结果的高频交互LIKE %hz%这种查询既用不上索引也没法按“中文命中 全拼命中 首字母命中”的权重排序。要在 SQL 里做这件事得额外建 FTS 表、维护分词复杂度比内存索引高一个数量级。所以我选的是预置 JSON 首次加载后构建内存索引。JSON 用assets放压缩后的文本体积在几百 KB 量级对 APK 影响可以接受解析放在子线程配合一个简单的“加载中”占位主线程不做重活。真正需要更新的场景加一个version字段服务端下发新版本号时下载新 JSON 覆盖本地缓存目录下次启动优先读缓存读不到再回落到 assets。提示JSON 里不要存省市区的中文全路径字符串比如“浙江省杭州市西湖区”那会让文件体积翻好几倍。层级关系靠parentCode现场拼路径展示在 UI 层拼一次就行。2.3 索引表设计首字母、全拼、多音字三张表怎么排内存索引不是简单地把 list 存起来得按查询方式倒排。真实用到的查询有四种按首字母分组展示AZ 分组列表→ 需要字母 → 城市列表输入中文片段命中 → 需要名称 → 城市输入全拼片段命中hangzhou、hang→ 需要全拼 → 城市列表输入首字母串命中hz→ 需要名称首字母串 → 城市列表前三张表在构建时一次遍历就能全部填好第四张表要在数据预处理阶段算出来因为它不是从单个字推出来的而是把每个字的拼音首字母连起来。数据结构大致是这样object CityIndex { // 首字母 - 城市列表构建时已按拼音排好序UI 层直接取用 val groupIndex LinkedHashMapChar, MutableListCity() // 全拼 - 城市列表支持「hang」「hangzhou」前缀命中 val fullPinyinIndex HashMapString, MutableListCity() // 名称 - 城市中文精确命中权重最高 val nameIndex HashMapString, City() // 首字母串 - 城市列表「hz」这类输入直接查这张表 val initialIndex HashMapString, MutableListCity() fun build(list: ListCity) { groupIndex.clear(); fullPinyinIndex.clear() nameIndex.clear(); initialIndex.clear() // 一次遍历填四张表避免四次全量扫描 list.sortedWith(compareBy({ it.firstLetter }, { it.pinyin })) .forEach { city - groupIndex.getOrPut(city.firstLetter) { mutableListOf() }.add(city) fullPinyinIndex.getOrPut(city.pinyin) { mutableListOf() }.add(city) nameIndex[city.name] city initialIndex.getOrPut(city.initialKey) { mutableListOf() }.add(city) } } }这里有个细节值得单独说多音字。拼音转换库给“重庆”算出来的默认结果是zhongqing给“长沙”算出来是zhangsha这两个错误会直接导致用户搜不到。通用的解法是维护一份短语级的多音字修正表并且按词长倒序匹配——先匹配“重庆”再匹配单个字避免短词覆盖长词// 多音字短语修正表长词优先构建索引前先过一次 private val POLYPHONE linkedMapOf( 重庆 to chongqing, 长沙 to changsha, 厦门 to xiamen, 六安 to luan, 蚌埠 to bengbu, 丽水 to lishui, 亳州 to bozhou, 台州 to taizhou ) fun fixPinyin(name: String, raw: String): String { // 整名命中修正表时直接用修正值不再交给逐字转换 POLYPHONE[name]?.let { return it } return raw }这张表的维护成本很低但收益极高。我一般会在构建索引的单元测试里固定断言“重庆”“长沙”“厦门”三个词的pinyin和initialKey谁改坏了立刻红。3. 总体架构设计三层结构与五个模块的职责边界3.1 数据层城市库的加载、缓存与版本升级数据层要回答三个问题从哪儿读、什么时候读、读完放哪儿。读取顺序是本地缓存 assets 预置。缓存放filesDir/city/city.json启动时先看缓存文件存不存在、version字段是否大于 assets 里的版本两个条件都满足才用缓存否则回落到 assets 并把 assets 内容写一份到缓存目录。这样既保证首装可用又给后续热更留了口子。解析放在Dispatchers.IO解析完立刻构建索引构建完把State切成Ready再通知 UI。整个链路只做一次用单例或object持有不要在每个 Activity 里重复解析。数据层对外只暴露两个方法suspend fun load(): CityRepository和fun queryLevel(parentCode: String, level: Int): ListCity。三级联动就是拿parentCode反复调queryLevelUI 层不碰原始 JSON。3.2 索引层拼音索引的构建流程索引层是纯计算模块不持有 Context方便单测。构建流程分四步第一步反序列化拿到原始ListCity第二步对每条记录跑一次多音字修正得到正确的pinyin第三步从pinyin取首字母得到firstLetter从name逐字取首字母拼接得到initialKey第四步一次性填四张倒排表。这四步里第三步最容易出问题。pinyin的每个字之间要用分隔符隔开否则多音节词没法拆。比如“西安”转出来是xian如果不加分隔符取首字母会变成一个x而正确结果应该是xa——这直接影响用户输入xa能不能命中西安。通常的做法是转换时保留音节边界比如xian或者干脆返回String[]音节数组首字母拼接在数组上做。// 音节数组形式更安全避免 xian / xian 这类歧义 fun buildCity(name: String, code: String, parent: String, level: Int): City { val syllables PinyinHelper.toPinyinArray(name) // 逐字转保留边界 val full syllables.joinToString().lowercase() val fixed fixPinyin(name, full) // 多音字修正 val initial syllables.joinToString() { it.take(1) }.lowercase() return City( code code, name name, parentCode parent, level level, pinyin fixed, firstLetter fixed.first().uppercaseChar(), initialKey initial ) }注意多音字修正之后pinyin变了但initialKey是基于逐字音节算的两者要对同一份音节数据操作否则会出现“重庆市”的全拼是chongqing、首字母串却是zq的错位。3.3 UI 层列表、索引条、搜索框与弹窗容器UI 层拆成四个部分各自职责清晰互不越界容器一个BottomSheetDialogFragment或者全屏 Activity负责进场动画、沉浸式状态栏、返回键处理。容器不碰数据。列表RecyclerView 单 Adapter用两种itemViewType区分分组头和城市条目分组头做粘性效果。索引条右侧一条自绘View只负责把触摸的 y 坐标换算成字母回调给列表做scrollToPosition。搜索框顶部EditText输入时把关键字抛给索引层结果直接替换列表数据源。这里我倾向于搜索状态和浏览状态共用同一个 Adapter只是数据源从“分组列表”换成“搜索结果列表”。好处是复用一套 ViewHolder切换时不用销毁重建代价是搜索结果里没有分组头Adapter 要能处理“当前是哪种模式”。用一空ListAny做扁平化能同时容纳两种模式比开两套 Adapter 干净。3.4 对外接口一次调用拿到 CityResult业务方调用的时候不应该关心你里面怎么实现的最好一行代码完成// 业务侧发起选择一行拿结果 val launcher registerForActivityResult(CityPickContract()) { city - city ?: returnregisterForActivityResult // 用户取消拿到 null binding.tvCity.text city.name } launcher.launch(CityPickArgs(level 2, withHot true, withLocation true))CityPickArgs里控制三件事选到哪一级1 省 / 2 市 / 3 区县、要不要热门城市栏、要不要定位入口。返回值统一是City?取消时为 null避免业务侧拿到半成品对象。用ActivityResultContract而不是直接startActivityForResult是因为它把请求参数的构造和结果解析都收在一个类里调用方不用记requestCode也不用写onActivityResult的 if-else。4. 关键实现从分组列表到混拼搜索的落地代码4.1 数据模型与 JSON 结构数据模型直接决定了后面所有代码的写法。字段不用多但每个都要有用data class City( val code: String, // 行政区划编码全局唯一回传业务方的就是它 val name: String, // 展示名如「杭州」 val parentCode: String, // 上级编码省级固定为 0 val level: Int, // 1省 2市 3区县 val pinyin: String, // 全拼小写多音字已按城市名规则修正 val firstLetter: Char, // 分组用的大写首字母 val initialKey: String, // 名称逐字首字母拼接如杭州 - hz val hot: Boolean false // 是否进热门城市栏 ) : Parcelable对应的 JSON 就按这个结构平铺不要嵌套省市区三层嵌套会让解析和搜索都变麻烦{ version: 3, cities: [ {code:330000,name:浙江省,parentCode:0,level:1,pinyin:zhejiang,firstLetter:Z,initialKey:zj,hot:false}, {code:330100,name:杭州,parentCode:330000,level:2,pinyin:hangzhou,firstLetter:H,initialKey:hz,hot:true} ] }字段说明上code必须保证唯一它是回传给业务的唯一标识用名称回传会出现重名问题全国有多个“朝阳”hot只给市级用热门城市栏不需要省和区县version不参与业务逻辑只给缓存更新做判断。4.2 分组列表与粘性头分组列表的核心是把LinkedHashMapChar, ListCity拍平成一个ListAnyAdapter 靠itemViewType区分。拍平的好处是scrollToPosition只需要一个下标不用自己算分组偏移。class CityAdapter : RecyclerView.AdapterRecyclerView.ViewHolder() { companion object { const val TYPE_HEADER 0; const val TYPE_CITY 1 } // 扁平数据源元素要么是 Char分组头要么是 City private val flat mutableListOfAny() // 记录每个字母对应的首个下标索引条滚动要用 private val letterOffset HashMapChar, Int() fun submit(grouped: LinkedHashMapChar, MutableListCity) { flat.clear(); letterOffset.clear() grouped.forEach { (letter, cities) - letterOffset[letter] flat.size // 先记位置再放分组头 flat.add(letter) flat.addAll(cities) } notifyDataSetChanged() } override fun getItemViewType(position: Int) if (flat[position] is Char) TYPE_HEADER else TYPE_CITY override fun getItemCount() flat.size }letterOffset这张表是整个列表滚动的关键。索引条被划到某个字母时列表不需要遍历查找直接scrollToPositionWithOffset(letterOffset[letter], 0)就跳过去了。这里的第二个参数是偏移量传 0 表示分组头贴顶传分组头高度表示让第一条城市贴顶——粘性头开启的时候用后者更自然。粘性效果有两种做法一种是ItemDecoration自己画一个浮动的头复杂度高但性能好另一种是列表外面叠一个固定头TextView滑动时按第一个可见 item 的字母更新它的内容。三千条数据用后者完全够用写起来也简单在RecyclerView.OnScrollListener里算一下就行了。4.3 索引条触摸定位与字母换算索引条不要用 26 个TextView拼那样触摸响应会很迟钝而且布局层级深。自绘一个 View把字母位置算成比例触摸时反算下标class SideBar JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { var onLetterSelected: ((Char) - Unit)? null private val letters ABCDEFGHIJKLMNOPQRSTUVWXYZ.toCharArray() override fun onDraw(canvas: Canvas) { // 每个字母均分高度居中绘制 val step height.toFloat() / letters.size letters.forEachIndexed { i, c - canvas.drawText(c.toString(), step / 2f, step * i step / 2f, paint) } } override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE - { // y 坐标反算字母下标越界时钳位避免手指滑出控件后失效 val index (event.y / height * letters.size).toInt() .coerceIn(0, letters.size - 1) onLetterSelected?.invoke(letters[index]) } MotionEvent.ACTION_UP - performClick() } return true } }要注意的是onTouchEvent必须返回true否则收不到后续的ACTION_MOVE手指一划就断。另外coerceIn那一步不能省手指滑到控件外面时event.y可能是负数或者大于高度不钳位会数组越界。再加一个细节滑动时在屏幕中央显示一个放大的字母提示手感会好很多。这个用TextView配合alpha动画做不做也不影响功能但体验差别很明显。4.4 中文、全拼、首字母混输的匹配策略搜索是整个选择器里最值得花时间的地方。用户输入是随机的有人打“杭州”有人打“hangzhou”有人打“hz”还有人打“hz州”这种中英混杂。匹配顺序按权重来中文精确 中文前缀 全拼前缀 首字母串fun search(keyword: String): ListCity { val key keyword.trim().lowercase() if (key.isEmpty()) return emptyList() val result LinkedHashSetCity() // 保序去重同一城市可能被多条规则命中 // 1. 中文精确命中权重最高直接返回单条 CityIndex.nameIndex[key]?.let { return listOf(it) } // 2. 中文包含命中如输入「杭」命中「杭州」 CityIndex.nameIndex.forEach { (name, city) - if (name.contains(key)) result.add(city) } // 3. 全拼前缀如「hang」命中 hangzhou CityIndex.fullPinyinIndex.forEach { (py, cities) - if (py.startsWith(key)) result.addAll(cities) } // 4. 首字母串精确命中如「hz」命中杭州 CityIndex.initialIndex[key]?.let { result.addAll(it) } return result.toList() }四个步骤的顺序不能乱。如果先跑全拼前缀再跑中文会出现“重庆”先被chongqing的结果占位权重排序就乱了。LinkedHashSet在这里起到的是“保序去重”的作用因为一个城市可能既被中文前缀命中、又被全拼前缀命中。还有一个容易被忽略的点输入英文时不该触发中文包含匹配。第 2 步遍历nameIndex是全量扫描如果每次敲键盘都跑一遍三千条输入延迟会明显。所以第 2 步外面通常加一个判断if (key.any { it.code 127 })只有输入含非 ASCII 字符时才做中文包含。这个判断能挡掉大部分无谓开销。4.5 定位城市、热门城市与结果回传定位城市不是一个必选项但加上体验会好一截。实现路径是LocationManager或系统的定位能力拿到经纬度再逆地理编码成城市名然后反查nameIndex拿到City对象。定位拿不到时不要报错弹窗静默隐藏这一栏就好——用户是来选城市的不是来修定位的。热门城市栏用RecyclerView的头部 item 承载数据来源是hot true的市级记录通常放 8 个左右两行四列。这一块可以整段做成一个自定义的HeaderView通过RecyclerView的多布局能力挂上去不要用一个独立的GridView拼在列表外面那样滚动会脱节。结果回传就是前面提到的ActivityResultContract核心在parseResult里做空值兜底class CityPickContract : ActivityResultContractCityPickArgs, City?() { override fun createIntent(context: Context, input: CityPickArgs) Intent(context, CityPickActivity::class.java) .putExtra(level, input.level) .putExtra(withHot, input.withHot) override fun parseResult(resultCode: Int, intent: Intent?): City? { // 用户按返回键取消时 resultCode 是 CANCELED这里统一返回 null if (resultCode ! Activity.RESULT_OK) return null return intent?.getParcelableExtra(city) } }City必须实现Parcelable用Parcelize注解一行就够。传Serializable也能跑但跨进程和性能都不如Parcelable既然是新写的模块没必要留这个坑。5. 避坑与排查城市选择器最容易翻车的五件事5.1 现象搜“重庆”搜不出来搜“zhongqing”反而能出原因拼音转换库默认按单字转换“重”字在绝大多数语境下读zhong库没法从单字判断语境于是“重庆”被转成zhongqing全拼索引里存的也是这个错值。解决加一份短语级多音字修正表在构建索引之前对整名做一次替换命中就跳过逐字转换。表里至少要有重庆、长沙、厦门、六安、蚌埠、丽水、亳州、台州这几个高频错例。修完之后在单元测试里固定断言这几条的pinyin和initialKey防止后续改动回退。5.2 现象索引条划到 Z列表停在中间不动或者跳到了分组头但下面空一大截原因scrollToPosition传的是分组头下标但列表项高度不一致热门城市栏高度远大于普通条目LinearLayoutManager无法准确估算目标项的位置只能先滚到附近再微调视觉上就是“卡住”。解决改用scrollToPositionWithOffset(position, 0)把目标项强制贴顶。同时保证letterOffset记录的是分组头下标而不是第一条城市的下标。如果列表里有HeaderView还要把头部高度从偏移量里扣掉否则粘性头会盖住第一行。5.3 现象首次进入页面白屏几百毫秒低端机上更明显原因JSON 解析、拼音转换、四张倒排表的构建全放在主线程三千条记录串下来足够卡出一个 ANR 边缘的帧。很多实现还是在onCreate里同步做的。解决整条链路挪到Dispatchers.IOUI 先渲染骨架屏数据Ready之后notifyDataSetChanged。另外拼音转换本身是纯文本计算可以把转换结果在打包阶段就预生成好写进 JSON运行时只做解析不做转换启动耗时能砍掉一大半。这一招用一次就回不去了。5.4 现象旋转屏幕或者切到后台再回来列表回到顶部搜索框内容也没了原因Activity 重建时数据源重新建了一遍RecyclerView的滚动位置和EditText的内容没有做状态保存。很多实现只在ViewModel里存了数据没存 UI 状态。解决数据放ViewModelUI 状态用onSaveInstanceState存搜索关键字和firstVisiblePosition重建后在数据Ready的回调里恢复。RecyclerView自身的LinearLayoutManager会保存一部分滚动位置但前提是 Adapter 的数据在onRestoreInstanceState之前就已经填好顺序反了照样丢失。5.5 现象调用方拿到了 City但name是空字符串或者干脆拿到 null原因Intent传Parcelable时字段没写全或者用了getParcelableExtra的旧签名在某些系统版本上返回 null也有一种情况是用户在三级联动里只选了省就按了返回业务方按“必须选到区县”的预期去处理直接空指针。解决City用Parcelize生成完整的writeToParcel不要手写。parseResult里对RESULT_OK之外的情况统一返回 null让业务方用?:兜底。三级联动场景要在 Activity 内部维护一个“已选层级”状态没选满不允许setResult或者在返回前主动提示用户补全别让半个结果流到外面去。6. 验收与答辩怎么证明这个选择器是能用的验收这件事很多同学的做法是把 App 跑起来当着老师的面点两下说“能选、能搜、能用”。这种演示在答辩现场很容易被追问崩——老师随便输一个“厦门”你搜不出来后面讲什么都白搭。我的建议是提前把验收拆成可量化的几项用一张表过一遍验收项验证方法合格线分组完整性从 A 划到 Z记录每组是否有数据26 个字母无空缺分组空组不渲染多音字正确性逐个输入重庆、长沙、厦门、六安全拼与首字母两种输入均命中搜索响应连续输入 8 个字符观察首帧延迟单次输入到结果刷新 ≤ 100ms索引条定位划过 10 个字母检查列表顶部字母与手指所在字母一致误差为 0状态恢复选到一半旋转屏幕关键字与滚动位置均保留结果正确性选一个区县打印回传对象code、name、parentCode 三项非空且匹配冷启动耗时清进程后进入页面首屏可交互 ≤ 500ms中端机这张表可以直接当答辩 PPT 的一页每一项都有明确的数据来源比“运行流畅、体验良好”这种话有说服力得多。答辩时被问到“为什么不用数据库”别去背 SQLite 的优缺点讲你自己的判断数据量三千条、十年不变、搜索是高频交互内存倒排表的匹配速度和权重控制都优于LIKE查询这是权衡后的选择不是不会用数据库。被问到“数据更新怎么办”答版本号 缓存覆盖路径把filesDir那条链路说清楚。被问到“三万多条数据会不会卡”要能立刻说出“当前数据量三千如果涨到三万JSON 全量解析就不合适了要换成按需加载 SQLite FTS”让对方看到你想过边界。有一个小技巧提前准备一份“故意搜不到”的用例主动演示给老师看比如输入一个生僻的区县名然后解释这里为什么没命中、你会怎么补。主动暴露一个已知边界比被当场问出来强十倍答辩的节奏也就稳了。说个我自己的教训。早些年做类似模块我习惯先把 UI 写完再回头接数据结果列表、索引条、搜索全写完才发现拼音转换那块对不上返工把 Adapter 和搜索逻辑改了两遍血泪经验就是先跑通数据管线再动 UI。从那以后我每次做这类选择器都强制先写一个纯 Kotlin 的单元测试把“重庆→chongqing”“杭州→hz”这两条断言跑绿再开始画界面。这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站