Android城市选择器实现指南:数据模型、索引列表与避坑实践

发布时间:2026/10/10 17:19:47
Android城市选择器实现指南:数据模型、索引列表与避坑实践 简介一款仿美团界面的Android城市选择器组件资源包面向需要在Android应用中快速集成城市选择功能的开发者可解决城市列表展示、热门城市排序、定位获取以及选择结果回调等常见需求省去从零搭建的时间和成本。组件基于高德地图引擎实现位置定位内置简洁API与模块化结构支持自定义城市数据和UI样式适配电商、旅游、社交、生活服务等多种应用场景。资源包共285个文件其中164个class为编译产物31个java源代码便于阅读与修改40个xml用于界面布局和配置22个png为界面素材另有gradle、jar等工程配置文件整体仅908KB结构紧凑便于集成。已有929人学习下载。压缩包内提供完整可运行的模块代码、调用示例及接入说明可参考其高德地图初始化、城市数据组织、选择器弹窗交互等实现思路帮助快速理解并二次扩展对初中级开发者尤其友好。1. 城市选择器不是单个 View它要解决的三件事与两套典型形态新 App 验收前一天产品和你说收货地址页那个城市选择器现在打开要卡两秒连按 C 字母还定位不到长春。这种场面在 Android 开发里不算少见——城市选择器看着只是一个弹窗背后却同时压着三千多个行政区划数据的加载、中文转拼音与索引跳转、省市区三级联动、定位失败降级四件事。它不是一个能用 TextView 堆出来的控件而是一套需要提前设计数据模型和交互链路的小型业务模块。这篇文章按一线落地顺序讲清楚数据怎么组织、列表和滚轮怎么选、搜索定位怎么凑齐、哪些坑值得提前焊死适合正在做地址模块、切城市页或者任何要省市区联动的同学照着复现。2. 数据先行省市区三级 JSON 的组织、解析与内存缓存怎么做城市选择器最容易翻车的地方不在 UI而在数据。很多团队第一版直接把后端接口返回的省市区列表塞进 Spinner结果下拉数据不全、顺序错乱、两级城市直接变空列表。这里的关键是先确定数据模型再谈界面。2.1 省市区三级数据结构用递归 Region 而不是三层硬编码类常见做法是维护一份全国省市区数据放在 assets 下的 JSON 文件里。先看数据结构怎么设计。如果把省、市、区各写一个 Kotlin 类比如 Province、City、District初看直观但遇到直辖市和特别行政区就会很难受北京市下面没有“市”这一级或者“北京市 - 北京市 - 朝阳区”这种两级结构会让联动逻辑写出一堆 if 特判。我一般会用一个递归的Region类统一表达省、市、区核心是children列表data class Region( val name: String, // 名称如广东省 val code: String? null, // 行政区划代码后端回传时要用 val children: ListRegion? null // 下级区域没有下级就是叶子节点 )对应的 JSON 文件组织方式是这样[ { name: 广东省, code: 440000, children: [ { name: 深圳市, code: 440300, children: [ { name: 南山区, code: 440305 }, { name: 福田区, code: 440304 } ] }, { name: 广州市, code: 440100, children: [ { name: 天河区, code: 440106 } ] } ] }, { name: 北京市, code: 110000, children: [ { name: 北京市, code: 110100, children: [ { name: 朝阳区, code: 110105 } ] } ] } ]选择递归模型而不是省市区三张平表原因是它天然兼容两类真实数据一类是广东这种标准的三级结构另一类是北京、天津、港澳这种只有两级的行政区。联动界面拿到一个Region如果children不为空就继续展开下一级如果为空就直接把整条链路回传给业务方不需要任何特判。code字段必须保留因为最终提交订单、设置收货地址时后端要的是行政区划代码不是中文名。真实业务里这份 JSON 文件压缩后通常也就一两百 KB对 APK 体积没有任何可见压力完全没必要为它专门建一个 Room 数据库。2.2 异步加载与解析assets 文件不能在主线程读确定好结构后下一步是加载和解析。assets 文件读取属于磁盘 IOGson 反射解析在低端机上跑一次也要几十毫秒放主线程会造成进入页面时掉帧甚至 ANR。我通常把加载逻辑放在一个单例 Provider 里用协程切到 IO 线程并把解析结果缓存在内存中object CityProvider { private var root: ListRegion? null suspend fun load(context: Context): ListRegion { root?.let { return it } // 内存缓存命中 return withContext(Dispatchers.IO) { val input context.assets.open(region.json) val json input.bufferedReader().use { it.readText() } val type object : TypeTokenListRegion() {}.type Gson().fromJsonListRegion(json, type).also { root it } } } }这段代码的逻辑说明root是进程级缓存第二次进入页面直接返回避免重复解析同一份 JSONwithContext(Dispatchers.IO)保证文件读取和 JSON 解析都不占用主线程bufferedReader().use { it.readText() }会在读取结束后自动关闭流防止 assets 句柄泄漏。参数说明里有两个值得注意的点一是TypeToken必须写成ListRegion的匿名内部类形式否则 Gson 泛型擦除后拿不到真实类型二是use块的作用域要覆盖整个读取过程不能只包open那一步就提前释放流。内存缓存放单例有一个隐患进程被杀重建时单例会被清空这没问题但如果你的 APK 里同时存在多套数据源比如测试环境和正式环境的 region.json 不同单例缓存会把第一次加载的数据带到第二次页面造成“换环境不生效”的假象。解决方式是把CityProvider的控制权交给你项目里的依赖注入容器或者干脆在Activity的ViewModel里持有加载结果按页面生命周期管理缓存。数据量本身不大即使每次进页面重新解析也就几十毫秒到一两百毫秒的成本优先保证逻辑简单和可测试。2.3 要不要上数据库或网络下发按团队能力决定assets 内置 JSON 不是唯一方案这里把常见的三种数据源做个对比方便你按项目情况选方案优点缺点适用场景assets 内置 JSON离线可用、启动快、无接口依赖城市区划调整后需要发版更新大部分国内业务够用Room / SQLite支持增量更新、按名称索引查询快开发成本高、首次建库耗时超大数据量、复杂查询场景接口下发区划调整即时生效、可灰度依赖网络、有失败态、要设计降级强运营、行政区划频繁变化的业务我见过最稳妥的组合是assets 内置一份完整 JSON 作为兜底同时提供一个轻量接口做增量更新服务端返回“省市区版本号”客户端本地缓存的版本号低于服务端时再拉新数据。这个方案成本主要在接口设计UI 层的解析逻辑完全不用动。如果你们团队只有两三个人且没有服务端资源老实把数据放 assets 里等真有区划调整时再发个版本比为了更新去做一套增量协议更划算。3. 列表还是滚轮选型对比与带索引列表的 RecyclerView 落地实现数据层就绪后界面形态的选择会成为第一个争论点。产品可能直接丢来一句“参考 iOS 的滚轮”但 Android 上滚轮和字母索引列表是两套完全不同的交互范式各自有适配场景和实现成本需要先想清楚再动手。3.1 滚轮联动和字母索引列表的适用场景省市区三级滚轮的核心价值是“结构化选择”用户从省开始逐级缩小范围适合收货地址、户籍所在地这类必须选完整三级结构的表单。缺点是操作步骤多选完一个省还要再拨两个滚轮如果用户目标是“深圳市南山区”他得至少滑动三次。字母索引列表的价值是“目标明确时的快速选择”右侧一划直接跳到拼音首字母配合搜索框从打开到选中可能只要几秒。它更适合切换城市、选择出发地、绑定常驻城市这类只选一层、不强制省市区串成完整链路的场景。商用 App 里你看到的大部分“城市选择器”其实是字母索引列表加搜索框滚轮反而更多出现在表单里的“省市区”三联动控件中。这两个方案不冲突。如果一个页面既要快速选城市又需要省市区完整回传可以按下级联动的思路把它们串起来先用字母索引列表选中省再进入该省的城市列表这样既保留了快速检索又天然完成了三级选择。但要注意不要在一个页面里同时放滚轮和索引列表用户会迷惑到底该用哪个交互成本反而翻倍。3.2 带右侧字母索引的城市列表RecyclerView 落地写法针对“切换城市”这个最常见的标题需求我一般选择字母索引列表方案。它的核心由三部分组成按拼音排序并分组的数据列表、右侧 26 个字母的索引条、以及索引条和列表的联动。先看列表适配器怎么写。城市数据经过预处理后会变成两类行字母分组标题行和城市行适配器内部用一个密封类来表示sealed class Row { data class Header(val letter: String) : Row() data class City(val region: Region, val pinyin: String, val shortPinyin: String) : Row() } class CityAdapter( private val onCityClick: (Region) - Unit ) : RecyclerView.AdapterRecyclerView.ViewHolder() { private val rows mutableListOfRow() private val indexMap mutableMapOfString, Int() // 字母 - 列表position fun submit(data: ListRegion) { rows.clear() indexMap.clear() data.groupBy { it.firstLetter() } // 按首字母分组 .toSortedMap() .forEach { (letter, cities) - indexMap[letter] rows.size rows.add(Row.Header(letter)) cities.forEach { rows.add(Row.City(it, it.fullPinyin(), it.firstLetter())) } } notifyDataSetChanged() } override fun getItemViewType(position: Int): Int if (rows[position] is Row.Header) TYPE_HEADER else TYPE_CITY // onCreateViewHolder / onBindViewHolder 按类型 inflate 对应布局并绑定点击 }这段代码的逻辑说明submit是数据入口先把城市列表按首字母分组toSortedMap保证从 A 到 Z 的展示顺序indexMap记录每个字母第一次出现的列表 position索引条滑动时直接用这个映射做跳转getItemViewType让 RecyclerView 可以复用两种不同的 item 布局分组标题行显示一个灰色字母背景城市行显示城市名和拼音。参数说明里最关键的是indexMap的更新时机每次submit时必须先清空再写入否则索引条跳转到错误位置很难排查。右侧索引条的自定义 View 是联动逻辑的另一半我通常直接复用一个轻量控件核心是触摸事件处理和回调class IndexBar JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val letters (A..Z).toList() var onLetterChange: ((String) - Unit)? null override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP - { val y event.y.coerceIn(0f, height.toFloat()) val index (y / height * letters.size).toInt().coerceIn(0, letters.size - 1) onLetterChange?.invoke(letters[index].toString()) return true } } return false } }逻辑说明letters固定为 26 个英文字母滑动时通过event.y占height的比例换算成字母索引coerceIn两处都是防越界一处是手指滑出控件顶部或底部时把坐标拉回边界内另一处是计算出的字母下标不能超过 25ACTION_UP也回调一次是为了保证手指离开时列表一定停在目标位置否则快速滑动后突然抬手MOVE 事件没来得及触发最后一次跳转列表就会停在半路。参数说明coerceIn(0f, height.toFloat())这里的下限必须写 0f 而不是 1f否则首尾两个字母永远无法通过滑动命中的 bug 很难发现。调用方在 Activity 里拿到回调字母后用indexMap[letter]找到目标 position再调用layoutManager.scrollToPositionWithOffset(position, 0)完成跳转。3.3 滚轮联动的实现思路用 RecyclerView 代替自绘 WheelView如果你最终选了滚轮方案不建议从零自绘 WheelView。自绘控件的坑集中在惯性计算和回弹动画这两块非常容易翻车而且各厂商 ROM 对触摸事件的处理不一致会出现“小米上滑得飞快、华为上回弹不到位”的玄学差异。常见做法是用横向排列的三个 RecyclerView每个 RecyclerView 搭配一个 SnapHelper让系统 RecyclerView 的惯性滚动去处理滚轮效果自绘部分只剩下画选中线、放大选中项文字这种纯装饰逻辑。三级联动的核心是数据源切换省级 RecyclerView 的选中项变化后市级 RecyclerView 的所有 item 换成当前省下的children并把选中位置重置为 0市级选中项变化后区级同理。这里有一个必须处理的细节当某一级的children为空时应该直接回调整个链路并关闭选择器而不是让界面停留在空白状态。这就是递归Region模型带来的便利联动代码里只需要判断item.children.isNullOrEmpty()不需要针对直辖市写额外条件。如果你用三层独立的 Province/City/District 类这里的特判会散落到每一级联动逻辑里维护成本立刻上去。4. 搜索、热门城市与定位把选择器做成“能用”的四个交互细节列表和索引解决了“按字母找”的问题但真实用户更常做的是输入汉字、全拼或首字母搜索比如输入“shenzhen”“sz”甚至直接输“深圳”。同时热门城市和定位城市这两个产品标配交互也会直接影响选择器的完整度。本章把这些交互拆开讲每一块都有明确的实现参数和失败降级策略。4.1 支持中文、全拼、首字母的搜索匹配函数搜索匹配不能只做contains(城市名)。完整的匹配策略要同时覆盖三种输入中文名、完整拼音、拼音首字母。匹配函数长这样fun matches(region: Region, keyword: String): Boolean { val kw keyword.trim().lowercase() if (kw.isEmpty()) return true if (region.name.contains(kw)) return true // 中文直接包含 val fullPinyin region.fullPinyin().lowercase() // 全拼shenzhen val shortPinyin region.shortPinyin().lowercase() // 首字母sz return fullPinyin.startsWith(kw) || shortPinyin.startsWith(kw) || fullPinyin.contains(kw) }逻辑说明先用name.contains(kw)覆盖中文输入这是最快的一条分支再用全拼和首字母做前缀匹配。我把fullPinyin.contains(kw)放在最后是为了兼顾“记忆不全只输入中间几个字母”的情况但这种模糊匹配会引入更多噪音如果你们城市列表很长建议去掉这一条只保留前缀匹配。参数说明里有三个细节keyword必须 trim 再比较用户输入带空格时不会误过滤转小写要放在比较前否则 “ShenZhen” 会匹配失败城市名里如果有英文或数字比如“阿坝”“那曲”这种带声调差异的名字拼音转换库要先处理声调否则shànghǎi和shanghai对不上。搜索结果的展示方式也有讲究。不要用另一个全新的 RecyclerView 去替换主列表而是复用一个过滤后的数据源这样搜索结果项点击后的回调逻辑与列表中点击一致。搜索框的防抖也值得做用户每敲一个字符就过滤一次是没问题的因为城市列表最多几千条本地过滤在内存里跑一遍也就是几十毫秒但如果数据源来自网络接口必须添加至少 300ms 的防抖再发请求避免每敲一个字母都触发一次网络流量。4.2 热门城市不要动态算内置固定列表更省心热门城市的作用是减少 90% 以上用户的点击路径。实现上不需要复杂逻辑在数据列表顶部固定插入一行“热门城市”分组里面放六到九个高频城市即可。我通常的写法是在submit时把热门城市列表作为固定前置数据插入val hotCities listOf( Region(北京, 110100), Region(上海, 310100), Region(广州, 440100), Region(深圳, 440300), Region(杭州, 330100), Region(成都, 510100) ) fun submit(data: ListRegion) { rows.clear() rows.add(Row.Header(热门)) hotCities.forEach { rows.add(Row.City(it, it.fullPinyin(), it.firstLetter())) } rows.add(Row.Header(定位)) // ... 定位城市失败时不展示此分组 // ... 再加载 alphabet 数据 }逻辑说明热门城市分组固定存在不参与字母排序放在列表最前端点击后的onCityClick回调走的还是同一个Region对象后续提交逻辑不需要区分热门和普通城市。参数说明热门城市列表最好由产品经理配置而不是开发写死因为业务活动经常调整入口城市但开发侧至少要内置一份默认值否则接口没下发时热门区域就空白了。这里有一个容易踩的地方热门城市里“重庆市”的全拼是chongqing不是zhongqing在生成拼音时就要解决好否则热门城市的数据也会被搜索功能带偏。4.3 定位当前城市Android 12 的模糊定位已经够用定位城市的主流交互是页面打开时申请定位权限拿到坐标后反查到城市名展示在“当前定位”这一行。反查城市名一般要接高德或百度定位 SDK因为它内部已经集成了逆地理编码如果不想引入第三方 SDK只靠系统LocationManager拿坐标还得自己写逆地理编码接口不划算。这里重点说系统层面的权限适配。Android 6.0 以后定位权限是动态申请Android 12API 31开始引入了模糊定位权限。对于城市选择器这个场景根本不需要精确权限直接用模糊定位就够了这样既降低用户授权心理门槛也避免被应用商店审核时问“为什么需要精确定位”。权限申请的核心代码private fun requestLocationPermission() { val permission if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { Manifest.permission.ACCESS_COARSE_LOCATION } else { Manifest.permission.ACCESS_FINE_LOCATION } if (checkSelfPermission(permission) PackageManager.PERMISSION_GRANTED) { locateOnce() } else { requestPermissions(arrayOf(permission), REQUEST_LOCATION) } }逻辑说明Android 12 上只申请ACCESS_COARSE_LOCATION系统会返回一个模糊位置精度大概在几百米到几公里对于“定位到城市”已经完全够用Android 11 及以下仍然申请ACCESS_FINE_LOCATION因为老版本上精细定位权限更容易被用户信任。参数说明requestPermissions的请求码REQUEST_LOCATION需要是 Activity 级别的常量回调里要区分isGranted和shouldShowRequestPermissionRationale两种拒绝情况前者直接降级为“定位失败请手动选择”后者需要引导用户去设置页打开权限。拿到坐标后切到后台线程做逆地理编码成功则更新“当前定位”行失败则隐藏该分组。我一般还会加一个 5 秒超时定位 SDK 返回慢时不能一直让用户等超时后直接把当前定位区域隐藏不阻塞整个列表的展示。5. 城市选择器避坑清单多音字、两级城市与索引错位的排查记录城市选择器看起来只有几百行代码但实际接入业务后踩过的坑一个接一个。下面这五条是按真实发生频率排序的血泪经验每一条我都按“现象 → 原因 → 解决”的方式记录下来方便你直接对照排查。5.1 多音字把“重庆”排进 Z 组搜索 C 找不到现象索引条上点 C列表里没有重庆搜索 “chongqing” 也无结果但搜索 “zhongqing” 反而能搜到。原因默认拼音转换库把“重”读成 zhòng重庆被转成了zhongqing首字母变成 Z。这个问题在“长安”cháng 安、“厦门”xià 门等城市名上同样存在。解决声明一个城市名到正确拼音的例外表在生成全拼和首字母时做后处理。城市数量有限只需要维护几十个高频例外词即可比如重庆 - chongqing、长安 - changan、厦门 - xiamen。注意例外表不能只覆盖全拼首字母也必须同步修正。这个表建议放在CityProvider里和城市数据一起加载后续运营要加新城市时不用改代码。5.2 直辖市和特别行政区只有两级三级联动出现空白区现象滚轮方案里选完“北京市 - 北京市”后第三个滚轮一片空白列表方案里点进北京只看到一级城市就结束不知道到底该不该继续往下点。原因数据结构里如果强制省市区三层数组直辖市没有独立“市辖区”这一层时第二层直接就是区联动逻辑拿不到预期层级就渲染空白。解决用第二章的递归Region模型后这里天然不会出错——children为空就该回调不为空就继续展开。关键在于联动代码里禁止写死“必须选完三层才能确认”而是以“某个节点没有子节点”为结束条件。如果你们用的还是老的三层硬编码结构至少要在省选中时判断该省下是否只有一个市级节点且该市级节点直接包含区级数据遇到这种情况把省级选择结果直接当成市级结果回传。5.3 索引条快速滑动时列表停在半路继续滑到 Z 没反应现象手指从索引条顶部快速划到底部RecyclerView 跳到 Y 附近就停了再往上滑一次才生效。原因索引条的ACTION_MOVE事件频率很高但 MOVE 过程中可能因为触摸事件被系统拦截导致最后一次跳转没有触发另外scrollToPositionWithOffset如果目标 position 是分组标题列表会停在标题位置而不是城市位置视觉上就差了一个 item。解决两个地方要一起改。第一ACTION_UP时必须再回调一次当前字母确保抬手瞬间列表一定跳到位。第二跳转位置不能直接用分组标题的 position而是用该字母下第一个城市的 position如果indexMap记录的是 Header 的 position跳转时要手动加 1。如果列表顶部还有搜索框或热门城市还要把这部分固定 header 的高度折算进 offset否则列表总会整体偏移一段距离。5.4 异步解析完成后 Activity 已销毁更新列表直接崩现象快速进出页面后偶发IllegalStateException或 NPE日志指向adapter.notifyDataSetChanged()调用处。原因CityProvider.load是异步方法协程结果返回时 Activity 可能已经走完onDestroy还在用旧 Activity 引用的 RecyclerView 去刷新数据。解决把数据加载放在ViewModel里用viewModelScope启动协程数据就跟随 ViewModel 生命周期而不是 Activity 生命周期。Activity 里只负责观察liveData结果并更新 UI当 Activity 销毁时观察者自动移除不会出现“数据回来了但页面没了”的情况。更早期项目如果还在用单例回调和接口实现没有 ViewModel至少要在异步回调里包一层if (activity.isDestroyed) return做防御。5.5 为读本地城市数据申请存储权限却被应用商店驳回现象assets 里明明放着 region.json代码里却申请了READ_EXTERNAL_STORAGE上架审核被驳回提示“权限与功能不符”。原因assets 目录属于应用私有资源读取它压根不需要任何存储权限。很多新手把“读本地文件”和“申请存储权限”划等号不知道 assets 是个特殊入口。Android 11 之后存储权限本身还多了分区存储的限制申请了也未必读得到公共目录数据。解决城市数据放 assets 永远是零权限方案。如果一定要做网络下发更新下载的 JSON 文件放到context.filesDir应用私有目录里同样不需要申请存储权限。这条不属于功能 bug但却是最容易在上架环节翻车的合规问题提前写出来提醒。6. 验证与进阶用 ADB 脚本和 UI Automator 守住选择器关键路径城市选择器这类纯 UI 组件的回归测试手工点一遍很费时间而且三个关键链路——索引跳转、搜索命中、杭州点击回调——任何一个断了都不容易在开发自测阶段发现。我交付出城市选择器之前一定会用 ADB 加 UI Automator 跑一遍核心用例这是 Android 测试成本最低的验收方式。先写一个验证脚本核心是用uiautomator dump导出当前页面的控件树再用input tap模拟点击。我这里给你一个可持续扩展的最小脚本框架# 1. 打开城市选择器页面包名和 Activity 按你自己的工程替换 adb shell am start -n com.example.app/.CityPickerActivity sleep 1 # 2. 在搜索框输入 shenzhen验证深圳出现 adb shell input tap 500 150 adb shell input text shenzhen sleep 1 adb shell uiautomator dump /sdcard/search.xml adb shell cat /sdcard/search.xml | grep -q 深圳 echo 搜索命中 # 3. 清除搜索模拟右侧索引条从底部滑到顶部 adb shell input keyevent KEYCODE_MOVE_END adb shell input swipe 900 1800 900 300 300 sleep 1 adb shell uiautomator dump /sdcard/index.xml adb shell cat /sdcard/index.xml | grep -q 重庆 echo 索引跳转命中脚本的逻辑说明input tap的坐标要按真机分辨率换算不同屏幕密度下同一控件位置差异很大所以这组脚本适合在固定测试机上跑不适合跨机型通用uiautomator dump导出的 XML 里包含当前屏幕所有可访问节点的text和content-desc属性搜索验证本质上就是检查关键城市名是否出现在节点树里。参数说明里有一个经验值每个步骤之间的sleep不要小于 1 秒尤其从input text到uiautomator dump之间搜索是异步过滤的dump 太早会拿到旧界面。跑完脚本后我还会手动再验证一个自动化容易覆盖不到的点点击“重庆市”之后回调里带着的行政区划代码必须是500100而不是500000。自动化能验证 UI 展示了什么但验证不了回调数据的正确性这一步我会在点击后打一行日志确认链路最终上报的 code 和名称一致再进真机看一次日志。另外模拟器上跑这种脚本时偶尔会遇到点击落空多半是动画还没结束就触发了下一次点击脚本里适当加等待是对的但等待时间别无脑拉长间隔太长会让整个验证流程变得迟钝也掩盖不了真实问题。这条脚本还有一个进阶用法把它挂到 CI 上配合构建产物做冒烟测试城市选择器后续再怎么改回归成本都控制在一次命令内。我现在的习惯是接到任何列表类组件的需求先把这类验证脚本建好再开始写 UI。这样开发过程中每改一次布局和联动逻辑都能在两分钟内确认最关键的路径没断。希望帮到你。本文还有配套的精品资源点击获取