Android城市选择器开发实战:数据分组、索引联动与搜索优化

📅 发布时间:2026/10/13 8:59:34
Android城市选择器开发实战:数据分组、索引联动与搜索优化
简介这份资源是面向Android开发者的城市选择器组件模仿美团城市选择界面帮助在电商、旅游、社交或生活服务类应用中快速实现城市选择功能适合初学者与有经验的开发者集成使用。压缩包共285个文件约908KB以164个class编译文件、40个xml布局与资源、31个java源码为主另含22个png图片、6个json配置、3个jar依赖及数据库、gradle等工程文件结构完整可直接引入项目。该组件基于高德地图引擎进行定位保证位置数据准确实时并采用模块化设计只需添加依赖、初始化并设置回调即可弹出选择器API简洁易改支持自定义城市数据与UI样式。目前已有929人学习下载读者可借此省去从零编写城市选择逻辑的时间快速获得一套美观、可复用且便于二次定制的城市选择方案。1. 城市选择器一个被低估的 Android 基础组件做 Android 开发这些年我拆过不少开源组件城市选择器属于那种「看起来简单、真做起来全是细节」的典型。你打开任何一个电商、外卖、出行类 App几乎都有「选择城市」这个入口但真正自己从零写一个能用的会发现要处理的东西远比想象中多拼音索引、首字母悬浮、热门城市、定位城市、历史记录、搜索结果高亮、数据源格式……每一项单独拎出来都不难凑在一起就是一堆琐碎的边界问题。这份 Android 城市选择器资源核心解决的就是「让开发者不用重复造轮子」。它把城市数据的组织、列表的索引联动、搜索过滤、回调返回这几件事封装成一套可直接集成的模块。适合两类人一是正在做 App 需要快速接入城市选择功能的一线开发二是想学习列表索引联动、数据分组这类经典交互实现思路的 Android 学习者。下面我按「它是什么 → 怎么接 → 坑在哪 → 怎么改」的顺序把这份资源拆开讲清楚。2. 城市选择器的核心机制数据分组与索引联动2.1 城市数据的组织方式城市选择器最底层的东西是数据。常见做法是把城市列表按拼音首字母分组每组有一个字母标题组内是城市名。数据结构一般长这样{ A: [安庆, 鞍山, 安阳], B: [北京, 包头, 蚌埠], C: [成都, 重庆, 长沙] }但实际项目里不会直接用这种纯 JSON因为还需要支持搜索、热门城市、定位城市这些额外区域。所以更常见的做法是定义一个 City 实体带上拼音、首字母、是否热门等字段public class City { private String name; // 城市名如北京 private String pinyin; // 全拼如beijing private String firstLetter; // 首字母如B private boolean isHot; // 是否热门城市 private boolean isLocated; // 是否定位城市 }这样设计的好处是列表展示时按 firstLetter 分组搜索时用 pinyin 做匹配热门和定位区域直接过滤 isHot / isLocated 字段。一个实体把三种场景都覆盖了不用维护多份数据。提示拼音字段建议在数据准备阶段就处理好不要在运行时用工具类转换。运行时转换在低端机上会有明显卡顿尤其是城市数量上千的时候。2.2 索引联动与悬浮字母的实现思路城市选择器最核心的交互是右侧字母索引条和左侧列表的联动。手指在索引条上滑动列表要跟着跳到对应分组列表滚动时索引条上的字母要高亮当前分组。这个交互的实现关键在两点第一列表要用RecyclerView配合ItemDecoration来画分组标题。分组标题不是独立的 item而是通过装饰器绘制在每组第一个 item 上方。这样做的好处是分组标题会「顶上去」——当下一组标题推上来时当前组标题被顶出屏幕这是城市选择器最经典的视觉效果。第二索引条的高亮和列表滚动位置要双向同步。索引条滑动时通过LayoutManager.scrollToPositionWithOffset()跳转列表滚动时通过findFirstVisibleItemPosition()反查当前分组更新索引条高亮。// 索引条滑动回调跳转到对应分组 indexBar.setOnIndexChangedListener(letter - { int position cityList.getPositionForLetter(letter); layoutManager.scrollToPositionWithOffset(position, 0); }); // 列表滚动监听更新索引条高亮 recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView rv, int dx, int dy) { int firstPos layoutManager.findFirstVisibleItemPosition(); String letter cityList.getLetterForPosition(firstPos); indexBar.setSelectedLetter(letter); } });这里有个参数要注意scrollToPositionWithOffset(position, 0)的第二个参数是偏移量传 0 表示分组标题刚好贴在列表顶部。如果你发现跳转后标题被遮住了一点可以把这个值调成负的分组标题高度。2.3 搜索过滤与结果高亮搜索功能看起来简单但城市选择器的搜索有两个容易翻车的点。一是拼音搜索用户输入「bj」要能匹配到「北京」输入「beijing」也要能匹配。二是搜索结果高亮匹配到的字符要变色。拼音匹配的常见做法是给每个城市预生成一个「搜索键」把城市名、全拼、首字母拼在一起// 预生成搜索键 city.setSearchKey(city.getName() city.getPinyin() city.getFirstLetter()); // 搜索时统一转小写做 contains 匹配 String keyword input.toLowerCase().trim(); boolean matched city.getSearchKey().toLowerCase().contains(keyword);高亮则需要在TextView上用SpannableString设置ForegroundColorSpan。注意高亮的位置要基于原始城市名计算不能基于搜索键否则会错位。// 在城市名中查找关键词位置并高亮 SpannableString spannable new SpannableString(city.getName()); int start city.getName().toLowerCase().indexOf(keyword); if (start 0) { spannable.setSpan(new ForegroundColorSpan(Color.RED), start, start keyword.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } textView.setText(spannable);注意搜索键方案在数据量大的时候会占用额外内存如果城市数据超过几千条建议改用 Trie 树或者倒排索引不过一般城市数据量在几百条直接 contains 完全够用。3. 把选择器接进项目从依赖到回调的完整流程3.1 集成方式与目录结构这份资源拿到手后先别急着往项目里塞。我一般会先看它的目录结构判断是纯源码模块还是已经打包好的 aar。如果是源码模块通常包含这几个部分目录/文件作用citypicker核心模块包含列表、索引条、搜索框CityPickerActivity选择器页面对外暴露的入口CityPickerConfig配置类控制主题、是否显示定位等assets/city.json城市数据源db/CityDbHelper如果需要本地数据库缓存集成方式有两种一是直接把citypicker模块复制进项目在settings.gradle里 include二是如果资源提供了 aar放到libs目录下依赖。我一般倾向源码集成因为城市选择器这种组件你大概率要改 UI 细节源码在手改起来方便。// settings.gradle 中引入模块 include :app, :citypicker // app/build.gradle 中依赖 dependencies { implementation project(:citypicker) }3.2 启动选择器与接收结果集成之后启动选择器一般用startActivityForResult或者ActivityResultLauncher。新项目建议用后者避免onActivityResult的嵌套判断。// 注册结果回调 ActivityResultLauncherIntent cityLauncher registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result - { if (result.getResultCode() RESULT_OK result.getData() ! null) { String cityName result.getData().getStringExtra(city_name); String cityCode result.getData().getStringExtra(city_code); // 更新 UI tvCity.setText(cityName); } } ); // 启动选择器 Intent intent new Intent(this, CityPickerActivity.class); intent.putExtra(show_located, true); // 是否显示定位城市 intent.putExtra(show_hot, true); // 是否显示热门城市 cityLauncher.launch(intent);这里的参数说明show_located控制顶部是否显示定位城市区域如果你的 App 没有定位权限或者不需要定位功能传 false 可以省掉一块 UI。show_hot控制热门城市区域一般建议保留因为用户大多数时候选的就是那几个大城市。3.3 自定义配置与主题适配城市选择器的 UI 通常需要跟 App 主题保持一致。这份资源一般会提供一个配置类让你在启动前设置颜色、文字大小这些。CityPickerConfig config new CityPickerConfig.Builder() .setTitle(选择城市) // 标题栏文字 .setTitleBgColor(Color.WHITE) // 标题栏背景 .setTitleTextColor(Color.BLACK) // 标题文字颜色 .setIndexBarColor(Color.parseColor(#F5F5F5)) // 索引条背景 .setIndexBarTextColor(Color.BLACK) // 索引条文字颜色 .setSearchBgColor(Color.parseColor(#EEEEEE)) // 搜索框背景 .build(); intent.putExtra(config, config);如果资源没有提供配置类那就直接改布局文件里的颜色值。我一般会先把colors.xml里的颜色抽出来改成引用 App 主题色这样以后换肤也方便。提示改主题的时候注意状态栏颜色。城市选择器通常是全屏页面状态栏如果还是默认的深色跟白色标题栏搭配会很突兀。记得在 Activity 里设置WindowCompat或者fitsSystemWindows。4. 避坑指南城市选择器集成中的五个血泪教训4.1 定位城市不显示或显示错误现象启动选择器后定位城市区域是空的或者显示的城市跟实际位置不符。原因定位城市依赖外部传入的经纬度和逆地理编码结果。如果 App 没有在启动选择器前拿到定位或者传的经纬度是默认值0,0选择器自然无法显示正确城市。解决在启动选择器之前先确认定位权限和定位结果。常见做法是在 App 启动时就把定位城市缓存到本地选择器直接从缓存读。如果定位失败给一个默认城市比如北京兜底不要让区域空着。4.2 拼音搜索匹配不到现象用户输入「chongqing」搜不到「重庆」输入「cq」也搜不到。原因重庆的拼音是「chongqing」但很多人会以为是「zhongqing」。如果数据源里的拼音字段本身有误搜索自然匹配不到。另外首字母「CQ」如果没预生成输入缩写也匹配不了。解决检查数据源里每个城市的拼音字段尤其是多音字城市重庆、长沙、厦门、六安等。建议用成熟的拼音库批量生成一遍再人工校对多音字。首字母字段确保是拼音的第一个字母大写。4.3 列表滑动卡顿现象城市列表快速滑动时掉帧索引条跟手性差。原因常见原因是onBindViewHolder里做了耗时操作比如实时计算拼音、频繁创建SpannableString、图片加载没做缓存。解决把所有能预计算的字段拼音、首字母、搜索键在数据加载阶段就算好onBindViewHolder里只做赋值。高亮用的SpannableString也只在搜索模式下才创建普通列表展示不需要。4.4 索引条字母与列表分组对不上现象点击索引条上的「L」列表跳到了「K」开头的分组或者跳到了空白区域。原因索引条上的字母列表和实际数据里的分组列表不一致。比如数据里没有「I」「U」「V」开头的城市但索引条上画了这些字母点击就会跳错。解决索引条的字母列表必须从实际数据里动态生成不能写死 26 个字母。数据里有哪些首字母索引条就画哪些。// 从数据中提取实际存在的首字母 ListString letters new ArrayList(); for (City city : cityList) { if (!letters.contains(city.getFirstLetter())) { letters.add(city.getFirstLetter()); } } Collections.sort(letters); indexBar.setLetters(letters);4.5 返回结果时 Intent 数据丢失现象选择城市后返回onActivityResult里拿到的data是 null或者getStringExtra拿不到值。原因选择器页面在finish()之前没有调用setResult(RESULT_OK, intent)或者 key 写错了。还有一种情况是用了singleTask启动模式导致onActivityResult不回调。解决检查选择器页面的finish()逻辑确保先setResult再finish。启动模式用默认的standard不要用singleTask。key 建议定义成常量两边引用同一个常量避免手写字符串写错。5. 进阶玩法把城市选择器改造成可复用的基础组件5.1 抽离成独立模块的边界划分如果你不止一个项目要用城市选择器建议把它抽成独立模块。抽离的时候注意边界模块内部只依赖 Android 基础库和RecyclerView不要依赖你 App 里的网络库、图片库、工具类。数据源通过接口注入而不是写死在模块里。// 定义数据源接口由调用方实现 public interface CityDataSource { ListCity loadCities(); // 加载城市列表 ListCity loadHotCities(); // 加载热门城市 String getLocatedCity(); // 获取定位城市 }这样模块就变成了一个纯粹的 UI 组件数据从哪来、怎么存都由调用方决定。你甚至可以在不同项目里用不同的数据源实现模块本身不用改。5.2 用单元测试验证拼音匹配逻辑城市选择器最容易出 bug 的地方是拼音匹配。我一般会写一组单元测试把多音字、缩写、全拼、大小写这些情况都覆盖一遍。Test public void testPinyinMatch() { City chongqing new City(重庆, chongqing, C); assertTrue(match(chongqing, chongqing)); assertTrue(match(chongqing, cq)); assertTrue(match(chongqing, CQ)); assertTrue(match(chongqing, 重庆)); assertFalse(match(chongqing, zhongqing)); // 错误拼音不应匹配 } private boolean match(City city, String keyword) { return city.getSearchKey().toLowerCase() .contains(keyword.toLowerCase().trim()); }这组测试跑一遍基本能覆盖 90% 的搜索场景。每次改数据源或者改匹配逻辑先跑测试比手动点界面靠谱得多。5.3 数据源更新的平滑方案城市数据不是一成不变的行政区划调整、新城市设立都会导致数据需要更新。如果你的 App 把城市数据写死在 assets 里每次更新都要发版。更好的做法是assets 里放一份基础数据兜底启动时检查服务端有没有新版本有就下载更新到本地数据库。// 检查更新 if (remoteVersion localVersion) { downloadAndSave(remoteUrl); // 下载新数据写入数据库 localVersion remoteVersion; } // 加载时优先从数据库读数据库为空则读 assets ListCity cities dbHelper.queryAll(); if (cities.isEmpty()) { cities loadFromAssets(); dbHelper.insertAll(cities); }这个方案的好处是首次安装有兜底数据不会白屏后续更新不用发版服务端推一份新 JSON 就行。代价是要维护一个简单的版本比对和下载逻辑不过对于城市选择器这种基础组件这点投入是值得的。从那以后我每次集成城市选择器都会先把数据源的拼音字段跑一遍校验脚本再写一组搜索匹配的单元测试最后才接 UI。这套流程走下来基本没再出现过「搜不到城市」这种低级问题。希望帮到你。本文还有配套的精品资源点击获取