简介:面向Android应用开发者的城市选择器组件,适合在电商、旅游、生活服务等场景中快速接入城市选择功能。该组件界面模仿美团,并借助高德地图API完成定位与城市数据展示,兼具美观度和实用性。压缩包内共285个文件,大小约908KB,以Java源码、class编译文件、XML布局及配置、PNG资源为主,另有少量jar包与数据库文件,基本覆盖从界面到定位的完整实现,方便直接集成或二次修改。目前已有929人学习/浏览,说明其具备一定参考价值。对开发者而言,既可直接引入模块使用,也可借鉴其中的模块化封装思路、高德定位接入方式以及城市列表交互逻辑,适合需要快速实现城市选择功能或希望学习组件设计的中初级Android开发者。
1. 城市选择器:Android 里最容易被低估的联动组件
在 Android 里做过收货地址、出发地这类需求的人,对“城市选择器”不会陌生。它表面是三个滚轮拼在一起,选完省自动刷市、选完市自动刷区,但真正动手时,数据结构、滚轮绘制、触摸事件、直辖市边界情况每一项都会把工期拖长。这份资源是完整可跑的 Android 端三级联动城市选择器,包含自绘滚轮、省市区 JSON 数据、弹窗封装和全套样式参数,适合正在做电商、外卖、旅游类 App 的开发者,也适合想搞懂联动组件原理的初级工程师。我拆完整个工程后的结论是:组件本身不难,难的是数据边界和滚动状态处理,这两块做好了,其他都是体力活。
2. 核心实现拆解:从滚轮 UI 到省市区三级联动
2.1 三种实现方案:为什么选了自绘 WheelView
先聊选型。实现城市选择器,业内常见方案有三条路。
第一条路:直接用系统 NumberPicker 拼三个滚轮。NumberPicker 是原生控件,省事,但它的样式和高度天生难自定义,文字大小、分隔线、惯性滑动都受系统版本影响,在全面屏和折叠屏上显示效果不一致。而且它的选中回调时机偏早,滚动未停稳就会触发,做联动时容易出现城市列表刷新早于滚轮落位的问题。
第二条路:用现成的开源 Picker 框架。这类框架封装度高,调起来快,但问题在于省市区联动逻辑通常被藏在框架内部,我想在选省后重置市区的选中索引,往往需要翻源码找内部接口,或者直接改框架代码。一旦升级版本,改动就大概率冲突。我在模拟项目 X 里曾经因为框架联动接口不开放,被迫二次封装,最后改的东西比自写一个还多,从那以后对黑匣子组件都会多留个心眼。
第三条路:自绘 WheelView 加 RecyclerView 数据列表,再把联动逻辑放在外部由自己控制。这条路前期工作量稍大,但换来的是每个环节都可调,滚动参数、选中判定、样式全部暴露给自己。资源包选的就是这条方案,核心滚轮代码只有两三百行,维护成本远低于预期。如果你的需求只是演示 Demo 而不是上线级组件,第一条路也够用,但想要可控的交互细节,自绘是长期更省事的路径。
2.2 滚轮的内核:绘制、滑动与选中判定
滚轮的本质,就是一张画布上把列表内容按位移量画出来。每次手指滑动或惯性滚动时改变一个位移偏移量,再触发重绘,视觉上就是列表在滚。核心绘制逻辑如下。
// WheelView.java —— 自绘滚轮核心(精简版) public class WheelView extends View { private List<String> items; // 滚轮上显示的数据 private int selectedIndex; // 当前选中下标 private int itemHeight; // 每一项高度,单位 px private int visibleCount; // 可见 item 数,建议 3 或 5 private float scrollOffset; // 滚动位移量 @Override protected void onDraw(Canvas canvas) { int dy = (int) (scrollOffset / itemHeight); // 以中线为基准,向上向下各画一半 item for (int i = -visibleCount / 2; i <= visibleCount / 2; i++) { int index = selectedIndex + i + dy; if (index < 0 || index >= items.size()) continue; // 距离中线越远,透明度越低,形成弧形滚轮效果 float alpha = 1f - Math.abs(i) / (float)(visibleCount / 2 + 1); paint.setAlpha((int)(255 * alpha)); Paint.FontMetrics fm = paint.getFontMetrics(); float baseline = getHeight() / 2f + i * itemHeight - (scrollOffset % itemHeight) + itemHeight / 2f - (fm.ascent + fm.descent) / 2f; canvas.drawText(items.get(index), getWidth() / 2f, baseline, paint); } // 固定画上下两条分割线,位置不随滚动偏移 drawDivider(canvas); } }这里的关键是坐标换算:scrollOffset / itemHeight得到整体位移了几个 item,取余得到当前帧的像素偏移量。baseline的计算保证文字始终垂直居中于每个 item 的网格。visibleCount用奇数,是为了让中线正好落在一个完整的 item 上,不出现两个 item 各占一半的尴尬。paint.setAlpha是我测试下来花最久调的地方,alpha 衰减系数太大会让两边文字几乎看不清,太小又没有滚轮感,默认值 0.5 左右表现最自然。
2.3 三级联动触发链:选省刷市、选市刷区
滚轮本身不关心数据是省还是区,它只负责画列表、触发选中事件。联动的编排全部放在外部,这是资源包设计的核心思路:三个 WheelView 实例完全一样,区别只是数据不同,中间用适配器和事件回调串起来。
// MainActivity 里的联动逻辑(简化) wheelProvince.setOnWheelSelectedListener((index, name) -> { CityNode province = dataList.get(index); // 刷新市级滚轮数据,并强制回到第一项 wheelCity.setNewData(province.children); wheelCity.setSelectedIndex(0); // 区级滚轮同步刷新为第一个市的区列表 wheelDistrict.setNewData(province.children.get(0).children); wheelDistrict.setSelectedIndex(0); }); wheelCity.setOnWheelSelectedListener((index, name) -> { CityNode city = wheelCity.getSelectedNode(); wheelDistrict.setNewData(city.children); wheelDistrict.setSelectedIndex(0); });联动中最容易忽略的是“重置顺序”。选完省后,市和区都必须同步重置,顺序不能反:先重置市级的数据和索引,再拿重置后的市级第一项去刷区级数据。如果先把区级刷了再重置市级,区级数据对应的还是旧省份的区,界面就出现省份和区对不上的脏数据。我最初实现时顺序写反,测试时发现选择 A 省再看 B 省,区列表还停留在 A 省的区,排查了很久才意识到是刷新顺序问题。
另外setSelectedIndex(0)要触发一次重绘,并且在联动回调里不能走默认的选中回调接口,否则会死循环。工程里的做法是给 WheelView 增加一个setSelectedIndexSilent()方法,重置时只改状态不发事件,需要通知外部时再调setSelectedIndex()。
3. 数据源处理:JSON 结构、解析策略与内存控制
3.1 一份能用的省市区 JSON 长什么样
城市选择器的地基是数据源。结构设计得好,联动的代码量能少一半。资源包里的city_data.json采用标准的树形结构,每个节点包含 id、name、children 三字段,没有多余的展示字段,文件体积因此控制得很小。下面是脱敏后的结构示例。
[ { "id": "100000000000", "name": "东山市", "children": [ { "id": "100000100000", "name": "东山市市辖区", "children": [ { "id": "100000100001", "name": "锦城区" }, { "id": "100000100002", "name": "梧桐区" } ] } ] } ]注意两个设计细节。
一是 id 用字符串而不是数字。原因是行政区划代码有的以 0 开头,如果用数字类型解析,开头的 0 会被丢掉,后续做索引匹配时会出问题。字符串虽然多占一点内存,但安全得多。
二是叶子节点的 children 字段不设置,解析时默认成空列表,而不是 null。这一点直接决定了联动代码里是否可以放心地写node.children.get(0)。如果数据集里有 null,每一层联动都要判空,代码会变得又长又容易漏。资源包里的解析器会在 children 不存在时统一返回空ArrayList,把空值和 null 的差异在数据入口处抹掉。
还有一个要考虑的点:真实行政区划数据存在大量历史遗留,比如某些城市下辖的区县在数据版本升级后会改名,旧数据和新数据混用会导致用户选择时出现不存在的搭配。我一般会在解析阶段加一个校验统计,遍历树节点时打印每层节点数量,发现异常层级再回头查原始数据源。数据源质量是城市选择器最大的隐藏成本,宁可多花时间校验,不要等上线后用户反馈再补。
3.2 解析与缓存策略:运行时解析还是编译期预处理
数据解析有两条路线可选。
一条是运行时用 Gson 一次性解析全量 JSON,缓存成树结构。优点是代码简单,数据更新方便,替换 assets 下的 JSON 文件即可生效。缺点是解析过程在低端机上会有明显卡顿,而且全量解析出来的对象数量很多,内存峰值高。
另一条是编译期把 JSON 转成紧凑的自定义二进制格式,运行时用流式读取。这条路性能最好,但工具链成本高,普通项目没有必要。
资源包采用折中方案:运行时解析,但用JsonReader流式解析,而不是一次性fromJson到 Java 对象再递归转换。流式解析的好处是边读边建树,不会产生中间 JSON 对象。解析过程放在子线程,完成后通过回调切回主线程刷新 UI。
// CityDataParser.java —— 使用 JsonReader 流式解析 public static ArrayList<CityNode> parseCityData(AssetManager am) { ArrayList<CityNode> rootList = new ArrayList<>(); JsonReader reader = null; try { InputStream is = am.open("city_data.json"); reader = new JsonReader(new InputStreamReader(is, "UTF-8")); rootList = readNodeArray(reader); reader.close(); } catch (IOException e) { // 解析失败时返回空列表,界面走空态提示 return new ArrayList<>(); } return rootList; }底层递归方法每次读入一个{ "id": "...", "name": "...", "children": [...] }对象,children 缺失时手动补一个空列表。
private static ArrayList<CityNode> readNodeArray(JsonReader reader) throws IOException { ArrayList<CityNode> list = new ArrayList<>(); reader.beginArray(); while (reader.hasNext()) { list.add(readNode(reader)); } reader.endArray(); return list; }解析逻辑本身没有复杂计算,真正需要注意的是一次性解析全量数据后要不要缓存。我建议在这个组件内部做单例缓存,避免每次打开弹窗都重新读 assets 和解析。如果你直接在外部持有这个解析结果,要注意避免在 Fragment 销毁后还持有它的引用。子线程解析时很多项目踩过的坑是:线程还没跑完,Activity 已经销毁,回调里直接操作了 RecyclerView 导致崩溃。工程里用WeakReference持有外部回调,解析结束后先判断引用是否有效再刷新,这个坑基本能堵住。
3.3 四段联动的数据扩展:从省市区到街道
很多业务场景不满足于省市区,比如外卖平台要求选到街道级甚至社区级。这份资源的数据模型对层级深度没有任何限制,因为每个 CityNode 都是一个复合节点,children 里可以继续套 children。加载逻辑也是通用的:任意节点被选中后,直接把它下面的 children 丢给下一级滚轮。
// 通用加载下级列表:不管第几级,逻辑完全一致 public void loadChildren(CityNode parent, WheelView wheel) { if (parent.children == null || parent.children.isEmpty()) { wheel.setEmptyState("暂无下级区域"); return; } wheel.setNewData(parent.children); wheel.setSelectedIndex(0); }这就是当初设计数据模型时没有单独定义 Province、City、District 三个类的原因——如果分了三个类,扩展到街道级就要新增一个 Street 类,所有接口都要跟着改。用一个复合节点加层级深度,数据加载逻辑天然适配 N 级联动,这是我拆过一些框架后比较认可的设计。
4. 封装成可复用组件:接口设计、布局与弹窗集成
4.1 三段回调与数据返回:调用方三行代码接入
资源包把城市选择器封装成CityPickerDialog,外部使用方不需要关心滚轮实现和数据解析,只依赖一个回调接口。我比较喜欢这种窄接口设计:对外暴露的越少,调用方接入成本越低,后面调整内部实现也不至于影响外部代码。
// 调用方接入示例 CityPickerDialog dialog = new CityPickerDialog(this); dialog.setInitValue("东山市", "梧桐区", "锦城区"); dialog.setOnCityPickedListener((province, city, district) -> { // province / city / district 都是 CityNode,可直接取 id 和 name String address = province.name + " " + city.name + " " + district.name; tvAddress.setText(address); }); dialog.show();setInitValue是回显功能。编辑历史地址时,用户已保存过省市区,再次打开选择器应该直接定位到对应选项,而不是每次都从第一项开始滚。这里的定位实现是:把传入的名称转成索引,再让滚轮以动画滚动到目标位置,动画时长建议 300ms 以内,太长了用户会不耐烦。
回调里的三个 CityNode 是数据节点本身,持有 id、name 和 children。接口建议只暴露这三个节点,不要暴露整个 dataList 引用,否则调用方可以修改内部数据,出问题后排查成本极高。回调触发时机也很讲究:必须在滚轮停止后再触发,不能边滚边回调,否则用户还没看清选项回调就更新了地址,体现在 UI 上就是地址栏内容在滚动过程中跳来跳去,感官很差。
4.2 弹窗布局与遮罩:底部弹出细节整理
城市选择器通常用底部弹出式布局,与各 App 确定地址的交互习惯一致。弹窗整体结构是:半透明遮罩层叠一个底部容器,容器里安放三列滚轮和底部确认取消按钮。容器高度不能写死,我一般设置为不超过屏幕高度的 60%,再根据数据项多少微调。如果只显示省市区三级,高度占 40% 到 50% 即可;加了街道级联动,视觉上需要更大的滚动区域,可以上调到 70%。
遮罩层透明度建议在 0.4 到 0.6 之间,太浅压不住背景,太深又显得压抑。弹窗弹出动画用位移动画,从屏幕底部滑入,时长 200ms 左右,配合setFillAfter(true)保证动画结束后布局保持原位。关闭时用同样的动画反向执行。
还有两个细节容易漏:一是底部状态栏区域,部分机型需要额外判断状态栏高度并设置 padding,否则按钮会顶到导航区;二是弹窗外的点击关闭行为,用户可能想点击遮罩直接取消,这个逻辑要在确认按钮的联动判断中处理干净,不要出现弹窗已关闭但回调数据还在更新的情况。
4.3 自定义样式:颜色、字体、分割线参数全量暴露
资源包里把滚轮的视觉参数全部抽到了attrs.xml中,使用方可以通过布局文件或代码设置。下面表格是核心参数及调参建议,黑色字体描述参考值,实际要根据设计稿来。
| 参数名 | 类型 | 默认参考值 | 实际作用 |
|---|---|---|---|
| wheelItemHeight | dimension | 44dp | 每行高度,决定滚轮视觉密度,太大则三列滚轮很高,太小则文字密集 |
| wheelVisibleCount | integer | 5 | 可见 item 数,必须为奇数,常规用 3 或 5 |
| wheelSelectedColor | color | 深色 | 中线选中项文字颜色 |
| wheelNormalColor | color | 灰色 | 未选中项文字颜色 |
| wheelDividerColor | color | 浅灰 | 上下分割线颜色,一般与背景色接近 |
| wheelTextSize | dimension | 16sp | 字体大小,选中项通常比未选中项大 2sp 以内 |
| wheelDividerHeight | dimension | 1dp | 分割线粗度,太粗像系统 bug,太细看不清 |
这些参数在WheelView构造时从 typedArray 读取,改了之后调用invalidate()即可生效。如果你在项目里接的是深色主题,建议把wheelSelectedColor和wheelNormalColor从硬编码色值改成主题色引用,避免每个页面单独调一遍。样式参数这块做得越开放,后面接不同 UI 主题时越省事。
5. 避坑清单:省市区选择器最容易翻车的 5 个地方
5.1 滚轮快速滑动时选中项与中线错位
现象:快速滑动滚轮并松手后,回调返回的省份和滚轮中线上的省份不是同一个,界面瞬间跳变。
原因:ACTION_UP里立即触发选中回调,但此时Scroller的惯性动画还没结束,滚轮仍在中线附近滑动,而回调取的是当前瞬时位置,自然和最终落位不一致。这个问题我在第一版里就踩了,测的时候上下快速滑动必现。
解决:把选中回调推迟到滚动完全停止后执行。在computeScroll()里判断!scroller.isFinished()后不做回调,直到动画结束才通知外部。为了兜底,可以在回调前加一个 100ms 的Handler.postDelayed,防止某些 ROM 上 scroller 状态异常导致回调一直不触发。注意这个延时必须是单任务,每次滚动前把前一个移除,否则连续滚动会产生多个重复回调。
5.2 直辖市数据层级缺失:市=区导致的空列表
现象:切换到部分省级节点时,市滚轮列表为空,或者区滚轮显示异常,弹窗一片空白。
原因:部分直辖市在标准数据源里没有“市辖区”这一级,省级节点直接挂区,或者市和区同名,children 为空。联动逻辑在处理空 children 时直接用children.get(0),空列表越界就崩了。
解决:在数据清洗阶段做兜底。解析数据时,如果发现某个节点的 children 为空,给它补一个占位节点,name 用父节点名加“-”,保证列表不为空。这个占位节点不参与业务数据提交,联动逻辑只管取children.get(0),数据层保证了每层都有值。如果是从网上找的第三方数据源,建议先写个脚本跑一遍树遍历,统计空 children 的节点,再决定要不要统一处理。血泪经验是不要相信任何数据源的完整性,自己校验一遍最稳。
5.3 RecyclerView 复用导致的选中态串位
现象:快速滑动滚轮时,item 文字出现重影,或者某个 item 的选中样式出现在了完全不相干的位置。
原因:滚轮如果基于 RecyclerView 实现,item 在做复用时会带走上一次的绘制状态,尤其是setAlpha、setTextSize这类属性没有在绑定前重置。自绘方案里如果是自定义 view 加 adapter,也可能因为复用旧的画布状态导致残留。
解决:每次绑定数据时强制初始化所有视觉属性,不能假设上一个位置的状态是干净的。比如onBindViewHolder里先调paint.setAlpha(255)、paint.setTextSize(defaultSize),再根据当前位置重新计算。同时给 itemView 设置一个 position tag,选中态切换时用 tag 做定位,不要用 view 的内存地址。
5.4 触摸冲突:滚动被父容器吞掉
现象:弹窗内的滚轮滑动生涩,有时手指移动了但滚轮纹丝不动,或者滑动一段后突然卡住。
原因:弹窗如果放在 ScrollView、RecyclerView 等可滚动容器里,纵向滑动手势会被父容器拦截。尤其是在底部弹出的布局中,整个容器经常被包在一个可上下滑动的根布局里,滚轮拿到事件的机会就不多了。
解决:在滚轮的onTouchEvent的ACTION_DOWN和ACTION_MOVE分支中,主动调用getParent().requestDisallowInterceptTouchEvent(true),强制父容器让出事件处理权。需要注意在ACTION_UP时重置这个标志,否则弹窗内其他区域的滚动也会受影响。这个接口行为在不同 ROM 上表现略有差异,如果发现某个机型仍然卡顿,可以尝试把滚轮的触摸监听放在父容器事件分发之前。
5.5 内存占用膨胀:解析数据后不释放引用
现象:打开选择器后内存上涨明显,多次开关后占用持续走高,最后导致 OOM 或卡顿。
原因:两个不当操作叠加。一是解析全量省市区 JSON 时使用传统递归转对象方式,产生大量临时对象且没有释放;二是弹窗持有外部回调或 Activity 的强引用,弹窗关闭后引用链没有断,导致数据缓存无法回收。
解决:解析走流式方案,控制在子线程;数据缓存使用单例,内部只持有数据节点,不持有 Context 或回调对象。弹窗在dismiss()方法里主动把外部回调指针置空,阻止让内存堆积。此外,如果项目是 K 线图、地图这类重内存页面,建议在选择器打开前先做一次TRIM_MEMORY_UI_HIDDEN级别的内存瘦身,规避低端机上的闪退风险。
6. 让它更快一步:预编译索引与组件化调试技巧
6.1 用 HashMap 建索引:把城市查找从遍历变成取
省市区联动有一个常见增量需求:用户输入拼音或关键字快速定位城市,类似搜索框模式。默认的搜索实现是遍历整个树,每个字母组合都匹配一遍 name 前缀,数据量小还好,数据量大的时候,尤其是街道级联动场景下,耗时能明显感知到。解决方式是在解析完成后预构建一个扁平索引,把每个叶子节点直接映射成一路路径。
// 构建索引:key 为节点 id,value 为完整路径(省/市/区) public void buildIndex(ArrayList<CityNode> dataList) { indexMap.clear(); for (CityNode province : dataList) { for (CityNode city : province.children) { for (CityNode district : city.children) { indexMap.put(district.id, new String[]{ province.name, city.name, district.name }); } } } }这段代码把原来需要 O(n) 次树遍历才能找到的路径,变成了 O(1) 的 HashMap 查询。实际项目中,用户搜索“锦城”时,直接遍历 indexMap 的 value 匹配含有关键字的字段,再把匹配到的省市区分别设置到三个滚轮的选中索引上,整个过程几十毫秒完成。这套索引对搜索框、地址回显、深链接跳转定位都能复用,是我每次做选择器都会顺手加上的基础设施。
6.2 验证联动结果的四个调试手段
联动组件的调试不能只靠肉眼。我在工程里固定了几个验证流程,每次改完必走一遍。
第一,回调打日志。在onPicked回调里打印三个节点的 id 和 name,对比界面视觉选中项和日志输出是否一致,不一致就说明选中回调时机或索引匹配有问题。
第二,adb shell screencap截图归档。手动把滚轮滚到不同位置后截屏,回看图片确认中线落位、分割线位置、文字清晰度,很多 UI 玄学问题在真机截图里一眼就能看出原因。
第三,数据一致性校验。跑一个遍历方法,统计每层节点数量和空 children 数量,异常数据会在日志里打印醒目标记。这个校验要做成独立方法,放在buildIndex()前调用,数据有问题时直接中断后续流程,避免带病运行。
第四,边界场景测试。切换省级、从最后一个省滚回第一个省、快速甩动滚轮后松手,这三个场景对应了索引越界、数据刷新顺序、惯性停止回调三类最容易出错的地方。每个场景都要走一遍才算测试完成。
从那以后,我每次接新的联动选择需求,都先把数据结构和边界数据列出来再动手,索引、样式参数、回调时机这些设计定下来后,开发效率明显高了一截。希望帮到你。
本文还有配套的精品资源,点击获取