☰
Android城市选择器源码解析:字母索引、拼音搜索与定位回显实现
2026/10/20 20:39:02 网站建设 项目流程

简介:这份资源是面向Android开发者的城市选择器组件,模仿美团城市选择界面,帮助在电商、旅游、社交或生活服务类应用中快速实现城市选择功能,适合从初学者到有经验开发者集成使用。资源包共285个文件,约908KB,以164个class编译文件、31个java源码、40个xml布局与配置、22个png图片资源为主,另含json、jar、db等辅助文件,结构完整便于直接引入项目。已有929人学习下载,说明其参考价值得到一定验证。组件基于高德地图API实现定位,具备美观界面、模块化设计与简洁API,开发者可通过Gradle依赖引入,在Activity或Fragment中初始化并设置回调,触发选择器后接收城市信息,还能按需修改城市数据或调整UI样式。借助这些源码与示例,读者可快速掌握城市选择功能的集成与定制思路,减少重复开发成本。

1. 城市选择器:从三千个城市里捞出你要的那一个

做本地生活、电商收货地址、出行票务这类 App,绕不开一个看似简单却极容易翻车的组件——城市选择器。用户要在全国几百个城市、几千个区县里精准定位到自己那一格,还得支持搜索、拼音首字母、定位回显、历史记录。我见过太多项目在这块栽跟头:列表卡顿、索引错位、拼音搜不到、定位漂移。这份 Android 城市选择器源码,就是冲着这些痛点来的,它把数据加载、字母索引、拼音检索、定位回显这几件事拆得比较清楚,适合正在做地址模块、又不想从零造轮子的 Android 开发者。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲一遍。

2. 项目定位与技术选型:它到底解决了哪些地址交互问题

2.1 功能边界:不止是选城市

先明确这份源码覆盖的范围。它不是一个只弹个列表让你点的玩具,而是一套完整的地址选择方案,核心能力包括:

  • 全国省市区三级数据联动,支持只选到市或选到区两种粒度
  • 右侧 A-Z 字母索引条,滑动定位、点击跳转
  • 顶部搜索框,支持中文、拼音全拼、拼音首字母三种匹配方式
  • 定位城市回显,配合系统定位拿到当前城市后高亮展示
  • 热门城市与历史选择记录,减少重复操作

这几点基本覆盖了地址选择场景里 90% 的交互诉求。很多开源实现只做了列表和索引,搜索和定位是缺失的,二次开发时还得自己补,这份源码把这几块都留了接口。

2.2 技术栈与依赖取舍

从工程结构看,这是一个标准的 Android 原生项目,没有引入 Compose,走的是传统 View 体系。技术选型上几个关键点:

层面选型说明
语言Java / Kotlin 混合核心逻辑以 Java 为主,便于老项目接入
列表控件RecyclerView替代 ListView,支持局部刷新
索引条自定义 View手绘字母,处理触摸事件
数据存储本地 JSON / assets城市数据随包内置,避免网络依赖
定位系统 LocationManager不强制绑定第三方地图 SDK

这里有个取舍值得说:城市数据放在 assets 里而不是走接口。好处是离线可用、首屏快,坏处是数据更新要发版。常见做法是内置一份基础数据,再留一个可选的远程更新入口,这份源码目前是纯本地,二次开发时可以自己加。

2.3 目录结构速览

拿到源码先看目录,心里有个地图。典型的模块划分大致是这样:

app/ src/main/ java/com/xxx/citypicker/ CityPickerActivity.java // 选择器主界面 SearchActivity.java // 搜索页 adapter/ CityListAdapter.java // 城市列表适配器 SearchResultAdapter.java // 搜索结果适配器 widget/ SideBar.java // 右侧字母索引条 IndexBar.java // 索引触摸处理 model/ City.java // 城市实体 Province.java // 省份实体 util/ PinyinUtil.java // 拼音转换与匹配 CityDataHelper.java // 数据加载 assets/ cities.json // 城市数据 res/ layout/ // 布局文件

这个结构不复杂,但职责分得清:widget 管交互,adapter 管渲染,util 管数据和拼音。二次开发时改哪块基本一目了然。

3. 跑起来:从导入到首屏的完整步骤

3.1 环境准备与导入

先把项目跑起来再谈改。环境要求不苛刻:

  • Android Studio 4.0 以上
  • minSdkVersion 21,targetSdkVersion 30 左右
  • Gradle 版本跟随项目自带配置即可

导入步骤:打开 Android Studio,选择 Open an existing project,定位到源码根目录,等待 Gradle 同步完成。如果同步报错,八成是 Gradle 版本和本地缓存不匹配,改一下gradle-wrapper.properties里的 distributionUrl 指向本地已有版本即可。

3.2 数据加载:城市 JSON 怎么读进来

首屏能不能秒开,关键看数据加载。这份源码把城市数据放在 assets 里,启动时异步读取解析。核心逻辑大致是这样:

// CityDataHelper.java public static List<City> loadCities(Context context) { List<City> result = new ArrayList<>(); try { // 从 assets 读取城市 JSON InputStream is = context.getAssets().open("cities.json"); BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); } reader.close(); // 解析成实体列表 JSONArray array = new JSONArray(sb.toString()); for (int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); City city = new City(); city.setName(obj.getString("name")); city.setPinyin(obj.getString("pinyin")); city.setFirstLetter(obj.getString("firstLetter")); result.add(city); } } catch (Exception e) { e.printStackTrace(); } return result; }

逻辑说明:先拿到 assets 的输入流,逐行读成字符串,再用 JSONArray 解析。每个城市对象至少要有 name、pinyin、firstLetter 三个字段,分别对应显示名、全拼、首字母。参数上要注意编码必须指定 UTF-8,否则中文城市名会乱码,这是最常见的坑。

提示:如果城市数据量大,建议把解析放到子线程,解析完再回主线程刷新列表,否则首屏会白屏一两秒。

3.3 字母索引条:触摸事件怎么算

右侧索引条是城市选择器的灵魂,也是最容易出 bug 的地方。它的核心是根据手指的 Y 坐标算出当前落在哪个字母上:

// SideBar.java @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: // 计算每个字母占的高度 float itemHeight = getHeight() * 1.0f / letters.length; // 当前触摸位置对应的索引 int index = (int) (event.getY() / itemHeight); if (index < 0) index = 0; if (index >= letters.length) index = letters.length - 1; // 回调给 Activity 去滚动列表 if (listener != null) { listener.onLetterChanged(letters[index], index); } break; case MotionEvent.ACTION_UP: // 松手后隐藏提示气泡 if (listener != null) { listener.onTouchUp(); } break; } return true; }

逻辑说明:itemHeight 是每个字母的均分高度,用触摸点 Y 除以它得到索引,再做边界钳制防止越界。回调把字母和索引抛给 Activity,由 Activity 去调 RecyclerView 的 scrollToPosition。参数上 letters 是 A-Z 加 # 的数组,顺序必须和列表数据的分组顺序一致,否则点了 A 跳到 B,这就是索引错位的根源。

3.4 拼音搜索:三种匹配怎么统一

搜索框要同时支持中文、全拼、首字母,实现思路是给每个城市预先算好拼音字段,匹配时逐个比对:

// PinyinUtil.java public static boolean match(City city, String keyword) { if (keyword == null || keyword.length() == 0) return false; String lower = keyword.toLowerCase(); // 中文直接匹配城市名 if (city.getName().contains(keyword)) return true; // 全拼匹配 if (city.getPinyin().toLowerCase().startsWith(lower)) return true; // 首字母匹配 if (city.getFirstLetter().toLowerCase().startsWith(lower)) return true; return false; }

逻辑说明:三个分支分别对应中文包含、全拼前缀、首字母前缀。用 startsWith 而不是 contains,是因为搜索场景下前缀匹配更符合直觉,输入「bei」应该出北京而不是「湖北」的某个区。参数上 keyword 要先转小写,城市拼音字段也统一小写存储,避免大小写不一致导致搜不到。

4. 核心模块拆解:列表渲染与定位回显的实现细节

4.1 RecyclerView 多类型布局

城市列表里其实混着好几种条目:分组标题(字母)、普通城市、热门城市、定位城市。用 RecyclerView 的多 ViewType 来处理:

// CityListAdapter.java @Override public int getItemViewType(int position) { // 根据数据类型返回不同 ViewType return dataList.get(position).getType(); } @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { if (viewType == TYPE_HEADER) { // 热门城市 / 定位城市头部 View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_city_header, parent, false); return new HeaderHolder(v); } else if (viewType == TYPE_LETTER) { // 字母分组标题 View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_letter, parent, false); return new LetterHolder(v); } else { // 普通城市条目 View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_city, parent, false); return new CityHolder(v); } }

逻辑说明:getItemViewType 返回数据自带的类型标记,onCreateViewHolder 根据类型加载不同布局。这样热门城市、定位城市、字母标题、普通城市可以共存在一个列表里。参数上 dataList 是已经排好序的扁平列表,字母标题是插在对应分组前面的伪数据,点击时需要过滤掉。

4.2 定位城市回显

定位这块源码没有强绑地图 SDK,而是留了一个回调接口,让使用方自己决定怎么拿定位:

// CityPickerActivity.java public interface OnLocationListener { void onLocationSuccess(String cityName); void onLocationFailed(); } // 使用方在外部拿到定位后回传 cityPicker.setLocationListener(new OnLocationListener() { @Override public void onLocationSuccess(String cityName) { // 把定位城市插到列表顶部并高亮 cityPicker.updateLocationCity(cityName); } @Override public void onLocationFailed() { // 定位失败就隐藏定位模块 cityPicker.hideLocation(); } });

逻辑说明:把定位能力抽象成接口,选择器本身不关心你用系统定位还是第三方 SDK,只关心结果。updateLocationCity 会把城市名插到列表头部,并做一次匹配校验——如果定位返回的城市不在数据里,就忽略。参数上 cityName 建议传标准城市名,带「市」字或不带要统一,否则匹配不上。

4.3 历史记录与热门城市

历史记录用 SharedPreferences 存最近选择的几个城市,热门城市则写死在配置里或从数据里挑:

// 保存历史 SharedPreferences sp = getSharedPreferences("city_history", MODE_PRIVATE); String history = sp.getString("history", ""); // 新选的城市插到最前,去重,最多保留 5 个 List<String> list = new ArrayList<>(Arrays.asList(history.split(","))); list.remove(cityName); list.add(0, cityName); if (list.size() > 5) list = list.subList(0, 5); sp.edit().putString("history", TextUtils.join(",", list)).apply();

逻辑说明:用逗号拼接字符串存历史,读取时 split 还原。每次选择先 remove 再 add 到头部,保证最近选的排最前,再截断到 5 个。参数上要注意空字符串 split 会得到一个含空串的数组,读取时要过滤掉。

5. 避坑与常见问题:那些让我加班到深夜的细节

5.1 索引条点击跳转错位

现象:点右侧字母 A,列表却跳到了 B 的位置。原因通常是字母数组和列表分组顺序不一致,或者列表头部有热门城市、定位城市这些额外条目,导致 position 偏移。解决:维护一个字母到列表 position 的映射表,跳转时用映射值而不是字母索引本身。

5.2 拼音搜索搜不到

现象:输入「beijing」搜不到北京。原因多半是城市数据里的拼音字段为空,或者大小写没统一。解决:加载数据时校验 pinyin 字段,为空则用工具类现场转换补上;匹配前统一转小写。我一般会在数据加载完做一次完整性检查,把缺字段的城市打日志出来。

5.3 首屏白屏卡顿

现象:进入选择器后列表一两秒才出来。原因是城市 JSON 在主线程解析,数据量大时阻塞 UI。解决:把解析放到子线程,解析完通过 Handler 或 LiveData 回主线程刷新,加载期间显示一个 loading 占位。

5.4 定位城市匹配不上

现象:定位返回「北京市」,但列表里是「北京」,高亮失败。原因是命名不统一。解决:匹配时做一次归一化,去掉「市」「省」等后缀再比对,或者维护一张别名映射表。

5.5 滑动时索引气泡不消失

现象:手指离开索引条后,中间的字母提示气泡还挂着。原因是 ACTION_UP 或 ACTION_CANCEL 没处理全。解决:两个事件都要回调隐藏,并且给气泡加一个延时自动消失的兜底逻辑。

6. 二次开发:把选择器改成你项目需要的样子

6.1 换数据源:从本地 JSON 到远程接口

最常见的改造是把内置数据换成接口下发。思路是保留 CityDataHelper 的解析逻辑,只把数据来源从 assets 换成网络请求:

// 改造后的加载逻辑 public void loadFromRemote(String url, final Callback callback) { OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder().url(url).build(); client.newCall(request).enqueue(new okhttp3.Callback() { @Override public void onFailure(Call call, IOException e) { // 网络失败降级到本地 assets callback.onResult(loadFromAssets()); } @Override public void onResponse(Call call, Response response) throws IOException { String json = response.body().string(); // 复用原有解析逻辑 callback.onResult(parseJson(json)); } }); }

逻辑说明:网络请求成功走远程数据,失败降级到本地 assets,保证离线也能用。参数上 url 返回的 JSON 结构要和本地保持一致,否则解析逻辑要改。我一般会加一层缓存,把远程数据存到本地,下次启动直接用缓存。

6.2 改交互:支持多选与搜索历史

如果业务需要多选城市,改造点在 adapter 的点击回调里维护一个选中集合,选中项打勾,底部加一个确定按钮回传结果。搜索历史则可以在 SearchActivity 里单独存一份 SharedPreferences,和城市选择历史分开。

6.3 验证改造是否成功

改完别急着提测,按这几步自测一遍:

验证项操作预期
索引跳转点每个字母列表跳到对应分组
拼音搜索输入全拼/首字母结果正确
定位回显模拟定位返回城市高亮在顶部
历史记录连续选 6 个城市只保留最近 5 个
空数据清空城市数据不崩溃,显示空态

6.4 一个我踩过的坑

有次改完搜索逻辑,本地测试全过,上线后用户反馈搜「xi'an」搜不到西安。查了半天发现是拼音字段里存的是「xian」,用户输入带撇号就匹配不上。从那以后我每次改搜索匹配,都强制走一遍特殊字符的用例,撇号、空格、大小写混输全测一遍。城市选择器这东西看着简单,细节全是血泪。希望这份源码和上面的拆解,能帮你少走点弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询