ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android城市选择器:省市区三级联动与拼音混搜实现

Android城市选择器:省市区三级联动与拼音混搜实现

简介: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、level,level=1是省、2是市、3是区县。三级联动靠parentCode过滤,单城市选择靠level过滤,搜索则直接在扁平列表上跑。一份数据覆盖四种场景,不用为每个入口准备一套。

数据集本身量级不大:全国省级 34 条、市级 300 多条、区县 2800 多条,加起来三千出头。这个体量决定了后面所有选型——它小到可以整个塞进内存,又大到不能用线性遍历去做实时搜索。

2.2 预置 JSON、预置 SQLite、服务端接口三条路的取舍

数据放哪儿,是这套方案第一个要拍板的地方。常见三条路:

方案首次可用耗时搜索实现常驻内存数据更新
预置 JSON,启动时全量解析30~80ms(可用子线程+缓存规避)内存遍历,可控排序权重约 1~3MB换包或下载后覆盖
预置 SQLite,查询走 SQL首查 10~20msLIKE / FTS,排序能力弱由页缓存控制,通常更小同左,可增量
服务端接口按需拉取依赖网络,秒级波动服务端实现极小实时

城市数据十年级别才变一次,最近一次大的调整也就是个别区县撤并。这意味着“实时更新”这个卖点在这里几乎不值钱,反而要付出网络失败的代价——用户在地铁里填收货地址,转圈十秒最后告诉你加载失败,这种翻车现场不需要多解释。

SQLite 的优点是不占内存、可以随时查,但城市选择器的搜索是每敲一个字符就要出结果的高频交互,LIKE '%hz%'这种查询既用不上索引,也没法按“中文命中 > 全拼命中 > 首字母命中”的权重排序。要在 SQL 里做这件事,得额外建 FTS 表、维护分词,复杂度比内存索引高一个数量级。

所以我选的是预置 JSON + 首次加载后构建内存索引。JSON 用assets放,压缩后的文本体积在几百 KB 量级,对 APK 影响可以接受;解析放在子线程,配合一个简单的“加载中”占位,主线程不做重活。真正需要更新的场景,加一个version字段,服务端下发新版本号时下载新 JSON 覆盖本地缓存目录,下次启动优先读缓存,读不到再回落到 assets。

提示:JSON 里不要存省市区的中文全路径字符串(比如“浙江省杭州市西湖区”),那会让文件体积翻好几倍。层级关系靠parentCode现场拼,路径展示在 UI 层拼一次就行。

2.3 索引表设计:首字母、全拼、多音字三张表怎么排

内存索引不是简单地把 list 存起来,得按查询方式倒排。真实用到的查询有四种:

  • 按首字母分组展示(A~Z 分组列表)→ 需要字母 → 城市列表
  • 输入中文片段命中 → 需要名称 → 城市
  • 输入全拼片段命中(hangzhou、hang)→ 需要全拼 → 城市列表
  • 输入首字母串命中(hz)→ 需要名称首字母串 → 城市列表

前三张表在构建时一次遍历就能全部填好,第四张表要在数据预处理阶段算出来,因为它不是从单个字推出来的,而是把每个字的拼音首字母连起来。数据结构大致是这样:

object CityIndex { // 首字母 -> 城市列表,构建时已按拼音排好序,UI 层直接取用 val groupIndex = LinkedHashMap<Char, MutableList<City>>() // 全拼 -> 城市列表,支持「hang」「hangzhou」前缀命中 val fullPinyinIndex = HashMap<String, MutableList<City>>() // 名称 -> 城市,中文精确命中,权重最高 val nameIndex = HashMap<String, City>() // 首字母串 -> 城市列表,「hz」这类输入直接查这张表 val initialIndex = HashMap<String, MutableList<City>>() fun build(list: List<City>) { 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): List<City>。三级联动就是拿parentCode反复调queryLevel,UI 层不碰原始 JSON。

3.2 索引层:拼音索引的构建流程

索引层是纯计算模块,不持有 Context,方便单测。构建流程分四步:

第一步,反序列化拿到原始List<City>;第二步,对每条记录跑一次多音字修正,得到正确的pinyin;第三步,从pinyin取首字母得到firstLetter,从name逐字取首字母拼接得到initialKey;第四步,一次性填四张倒排表。

这四步里,第三步最容易出问题。pinyin的每个字之间要用分隔符隔开,否则多音节词没法拆。比如“西安”转出来是xian,如果不加分隔符,取首字母会变成一个x,而正确结果应该是xa——这直接影响用户输入xa能不能命中西安。通常的做法是转换时保留音节边界,比如xi'an,或者干脆返回String[]音节数组,首字母拼接在数组上做。

// 音节数组形式更安全,避免 xian / xi'an 这类歧义 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 要能处理“当前是哪种模式”。用一空List<Any>做扁平化能同时容纳两种模式,比开两套 Adapter 干净。

3.4 对外接口:一次调用拿到 CityResult

业务方调用的时候不应该关心你里面怎么实现的,最好一行代码完成:

// 业务侧发起选择,一行拿结果 val launcher = registerForActivityResult(CityPickContract()) { city -> city ?: return@registerForActivityResult // 用户取消,拿到 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 分组列表与粘性头

分组列表的核心是把LinkedHashMap<Char, List<City>>拍平成一个List<Any>,Adapter 靠itemViewType区分。拍平的好处是scrollToPosition只需要一个下标,不用自己算分组偏移。

class CityAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { const val TYPE_HEADER = 0; const val TYPE_CITY = 1 } // 扁平数据源:元素要么是 Char(分组头),要么是 City private val flat = mutableListOf<Any>() // 记录每个字母对应的首个下标,索引条滚动要用 private val letterOffset = HashMap<Char, Int>() fun submit(grouped: LinkedHashMap<Char, MutableList<City>>) { 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): List<City> { val key = keyword.trim().lowercase() if (key.isEmpty()) return emptyList() val result = LinkedHashSet<City>() // 保序去重,同一城市可能被多条规则命中 // 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 : ActivityResultContract<CityPickArgs, 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.IO,UI 先渲染骨架屏,数据Ready之后notifyDataSetChanged。另外拼音转换本身是纯文本计算,可以把转换结果在打包阶段就预生成好写进 JSON,运行时只做解析不做转换,启动耗时能砍掉一大半。这一招用一次就回不去了。

5.4 现象:旋转屏幕或者切到后台再回来,列表回到顶部,搜索框内容也没了

原因:Activity 重建时数据源重新建了一遍,RecyclerView的滚动位置和EditText的内容没有做状态保存。很多实现只在ViewModel里存了数据,没存 UI 状态。

解决:数据放ViewModel,UI 状态用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”这两条断言跑绿,再开始画界面。这个习惯救过我很多次,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进