多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Android城市选择器实战:省市区三级联动、Gson解析与滚轮避坑指南

Android城市选择器实战:省市区三级联动、Gson解析与滚轮避坑指南 简介Android城市选择器是一款仿美团风格的移动端城市选择组件面向需要快速集成城市选择功能的Android应用开发者尤其适合电商、旅游、社交、生活服务等常见App场景。组件基于高德地图引擎实现精准定位同时采用模块化设计对外暴露的接口简洁明确方便开发者在不同项目中直接复用、修改与二次定制。资源压缩包共285个文件约908KB其中包含164个class编译文件、31个java源码、40个xml布局配置、22张png图标以及jar包、gradle构建脚本等辅助文件完整覆盖了组件的实现代码、界面布局与资源图标便于开发者查看核心逻辑。已有929人学习了该资源。通过学习这份源码可以掌握高德地图定位接入、城市数据组织、列表与弹窗布局设计等关键技巧同时理解模块化封装思路进而快速迁移到自己的项目中减少重复开发提升整体效率。1. 城市选择器藏在收货地址和注册页里的三级联动组件做 Android 开发的人迟早会接到这样一个需求注册页选所在地区、下单页填收货地址、后台给门店配服务区域。这个「省—市—区」三级联动的城市选择器乍一看就是三个滚轮真做起来才发现数据采集、顺序保持、联动刷新、状态恢复全是细节。这套资源是一份完整的 Android 城市选择器实现自带全国省市区三级数据、Gson 解析器、底部弹窗滚轮 UI 和联动逻辑用 Android Studio 直接导入就能跑。适合在电商、O2O、用户资料这类场景里做地址录入的开发者想搞懂滚轮联动内部逻辑的人也能拿它当例子拆着看比从零开始采数据、调滚动手感省太多事。2. 数据地基省市区JSON组织、Bean设计与解析顺序先碰数据。不管 UI 做得多顺滑省市区数据一旦出错后面全是白搭。下面从数据源选型、数据组织方式到解析顺序把地基铺稳。很多项目把时间花在滚轮动画上结果上线后用户切到某个省发现区级选项是空的体验比样式丑还致命。2.1 数据源选型内置JSON、SQLite还是网络下发做选择器之前得先决定数据放在哪。常见的有三种各有各的边界数据源优势代价适合场景assets目录内置JSON离线可用、无需权限、解析后可整体缓存每次发版才能更新区划绝大多数App的注册页、资料页SQLiteRoom支持模糊搜索、大数据量查询稳定建库和导入成本高需要输入关键词搜城市、数据量上万网络下发区划可跟随行政区划调整更新需要缓存策略、接口容错物流、政务等对区划时效敏感的场景我一般首选 assets 内置 JSON原因很实际省市区全量数据压缩后大约几百 KB一次解析成对象放内存整个进程生命周期内都复用。对大多数 App 来说区划一年改不了几次没必要为了「理论上可以更新」引入网络层真要更新等发版把 JSON 换掉就行。选型时有个容易被忽略的点用数据库方案初始化成本会摊到每个页面JSON 方案的问题是首次解析要占用主线程时间这个在第 5 章单独说。先记住结论JSON 缓存到内存是投入产出比最高的方案。2.2 区划数据怎么组织children嵌套与线性parentId的取舍数据组织层面有两种主流格式。第一种是线性表每条记录带 parentId[ { id: 440000, name: 广东省, parentId: null, level: 1 }, { id: 440100, name: 广州市, parentId: 440000, level: 2 }, { id: 440106, name: 天河区, parentId: 440100, level: 3 } ]第二种是 children 嵌套把省的下级直接挂进去{ id: 440000, name: 广东省, level: 1, children: [ { id: 440100, name: 广州市, level: 2, children: [ { id: 440106, name: 天河区, level: 3 }, { id: 440104, name: 越秀区, level: 3 } ] } ] }线性表符合数据库规范化查子级时做一次 parentId 过滤children 嵌套和界面交互一一对应选中省直接拿节点下的 children 刷新市滚轮不需要额外过滤。这套资源用的是 children 嵌套因为三级联动最频繁的操作就是「选中某一级取出它的直接下级」嵌套天然贴合这个动作。做成嵌套后有个隐患JSON 文件里层级不能乱如果某个省下面漏了市级节点联动时拿到 null 或空列表滚轮会出问题。所以解析前要做一层防御检查发现某级 children 为 null 就用空列表补齐。这一步看着简单能在上线前省掉一大类空指针崩溃。2.3 Gson解析与顺序保持别把省市区顺序搞丢了数据文件有了接下来是解析。很多人在这一步栽跟头用 HashMap 去装省份加载完发现「北京市」跑到列表最后去了。Map 默认不保证顺序而选择器滚轮必须按数据文件里的顺序展示。我用 List 保序配合一个按 id 索引的 Map 做快速查找两个结构同时维护。对应 Bean 定义public class RegionBean { private String id; // 行政区划代码如 440106 private String name; // 名称如 天河区 private int level; // 1省 2市 3区 private ListRegionBean children; // 下级列表叶子节点为 null public String getId() { return id; } public String getName() { return name; } public int getLevel() { return level; } public ListRegionBean getChildren() { return children null ? Collections.emptyList() : children; } }children 的 getter 里做了一次空保护解析到叶子节点时不会返回 null调用方不用每次判空联动代码会干净很多。解析逻辑public class RegionRepository { private static volatile RegionRepository instance; private final ListRegionBean provinceList new ArrayList(); private final MapString, ListRegionBean childIndex new LinkedHashMap(); public void init(Context context) { String json loadFromAssets(context, region.json); Type type new TypeTokenListRegionBean() {}.getType(); ListRegionBean provinces new Gson().fromJson(json, type); provinceList.addAll(provinces); for (RegionBean province : provinces) { childIndex.put(province.getId(), province.getChildren()); } } public ListRegionBean getProvinces() { return provinceList; } public ListRegionBean getChildren(String parentId) { return childIndex.get(parentId); } }loadFromAssets 就是 AssetManager 打开 region.json 读字符串的常规封装注意要指定 UTF-8 编码某些 Windows 环境下文件若另存为 GBK解析出来会乱码。Gson 的 fromJson 解析成 List 时会按 JSON 数组的书写顺序填充所以省份顺序和文件里一致。childIndex 用 LinkedHashMap遍历时保持插入顺序查找时 O(1) 拿到子级正好配合滚轮「选省级→取市级」的刷新。到这里数据层就结束了下一章把这些数据喂给 UI 做联动。3. 弹窗式选择器实现滚轮UI与三级联动逻辑数据准备好后接下来是交互层。城市选择器最常见的形态是底部弹出的三级滚轮下面先说为什么选滚轮再拆联动逻辑和弹窗组装。3.1 滚轮样式选型WheelView、NumberPicker与自绘ListViewAndroid SDK 里没有现成的 iOS 风格滚轮做三级联动主要有三条路。第一条是用系统自带的 NumberPicker不需要额外依赖但样式偏原生单列滚动联动刷新时 setDisplayedValues 有肉眼可见的闪烁而且默认不支持循环滚动。第二条是自绘 ListView 加滚动换算可控性最强但手势惯性、回弹、边界都要自己处理工作量大适合要做完全定制视觉的团队。第三条是用 WheelView 思路封装的自定义滚轮社区方案成熟支持循环、自定义行高和字体底部弹窗场景基本开箱即用。这套资源里使用的就是这类自定义滚轮组件好处是联动时能保留中间选中项的视觉状态刷新子级列表不会跳来跳去。选型标准我给三条一是有没有循环滚动需求二是默认样式和你们 UI 差距大不大三是刷新数据时会不会闪烁。NumberPicker 在第三条上输得很惨所以多数项目最后都会落到 WheelView 上。3.2 联动核心选中省级后刷新市级并重置区级联动是城市选择器的核心也是新手最容易翻车的地方。先看省级滚轮的回调provinceWheel.setOnItemSelectedListener((wheel, index) - { RegionBean selectedProvince repository.getProvinces().get(index); ListRegionBean cityList repository.getChildren(selectedProvince.getId()); if (cityList.isEmpty()) { // 直辖市或数据缺失市级直接清空区级也清空 cityWheel.refreshData(Collections.emptyList()); districtWheel.refreshData(Collections.emptyList()); return; } cityWheel.refreshData(cityList); cityWheel.setSelectedIndex(0, false); String firstCityId cityList.get(0).getId(); ListRegionBean districtList repository.getChildren(firstCityId); districtWheel.refreshData(districtList); districtWheel.setSelectedIndex(0, false); });这里的 provinceWheel、cityWheel、districtWheel 都是资源里自定义的滚轮控件不是系统组件。关键细节有两个。第一refreshData 之后必须调用 setSelectedIndex(0, false)把市级滚轮强制回到第一项否则滚轮还停留在上一省的某个城市位置但数据已经换掉展示和数据结构就错位了。第二第二个参数传 false 表示不触发联动回调避免「重置到第一项」又递归触发一遍区级刷新造成重复计算和滚动抖动。市级滚轮的回调同理选中市后立刻取市的 children 刷新区级并把区级归零。三个滚轮的回调之间要保证「数据刷新」和「选中归零」的顺序一致。我习惯统一封装成 refreshWheelWithFirstSelected(wheel, data) 这样一个方法三个滚轮共用避免每个回调里各写一遍逻辑后续排查也方便。3.3 底部弹窗的组装DialogFragment、高度控制与Android 12适配三级滚轮联动起来后要把它装进底部弹窗。常见做法是用 DialogFragment好处是旋转屏幕时由 Fragment 托管状态恢复比裸 Dialog 稳。弹窗布局里放三个滚轮时用 LinearLayout 横向均分每个滚轮宽度 match_parent 加 weight1。滚轮高度一般取 180dp 到 220dp加上顶部的标题栏和取消确认按钮整个弹窗高度控制在屏幕高度的 40% 左右比较合适。超过 50% 在大屏上会有悬浮感低于 30% 三个滚轮被压扁手感会差。Android 12 上有两个适配点容易踩。第一个是系统默认的窗口水波纹和圆角自定义背景为圆角白色 Drawable 时注意写清楚 backgroundTint 和 cornerRadius不然部分机型上弹窗四角会露出黑色描边。第二个是 Dialog 的 window 背景要单独设成透明否则弹窗底部会出现系统默认阴影。这两个问题在 Android 12 模拟器上很容易复现真机上部分厂商 ROM 会帮你抹掉但不代表可以不管。弹窗交互还有两个小习惯点击遮罩关闭时如果用户已经选了省市区关闭前最好把当前选择结果通过回调返回取消按钮和确定按钮的热区高度不要低于 48dpAndroid 12 上低于这个尺寸的点击目标在无障碍检测里会被标红。4. 避坑笔记城市选择器里我踩过的五个典型坑下面这几条都是从实际联调里攒下来的血泪经验按「现象 → 原因 → 解决」记录照着排查能省不少时间。4.1 切换省份后区级列表没清空现象从「广东省-广州市-天河区」切到「山东省」市级变成济南等区级却还显示天河区、越秀区。原因省级滚轮回调里只刷新了市级没处理区级的数据更新。区级滚轮的数据还停留在上一个省的最后选择。解决省级回调里把市级和区级看作一个整体市级 refreshData 之后区级也要用新市级的第一项 children 刷新。上面 3.2 的代码里我在省级回调中先判断 cityList 是否为空为空时把区级也清空不为空时取新市级第一个区的数据填充区级两级联动一次完成。4.2 直辖市与特别行政区的省市区三级不齐现象选「北京市」市级只有「北京市」一项区级是东城区、西城区再选「澳门特别行政区」市级数据格式和内地完全对不上。原因直辖市的层级比其他省少一级如果界面写死「省、市、区」三个滚轮直辖市会出现名字重复特别行政区则可能只有两级。解决数据层保留原始层级不要强行补一个同名市级节点让三级「看起来整齐」。UI 层在省级选中项为直辖市时市级滚轮照常显示但只给一项或在展示层隐藏市级滚轮把布局动态改成两级。用 setVisibility 切换时要注意 LinearLayout 的 weight 会在隐藏一个滚轮后重新分配我习惯用 gone 而不是 invisible并在切换后重新请求测量。4.3 Activity重建后选择状态丢失现象用户把选择器弹窗打开旋转屏幕选好的省市区被重置回默认从后台切回前台弹窗还在但数据错乱。原因Dialog 是依附 Activity 的 Window 对象Activity 重建后 Dialog 跟着销毁而选择状态没有持久化。解决用 DialogFragment 代替裸 Dialog省市区选中的 id 存进 Fragment 的 arguments。配合 ViewModel 更好把选中的省市区 id 作为 LiveData 或 StateFlow 保存在 ViewModel 里重建后重新 setSelectedRegion 恢复。弹窗的初始选中值不要在 Dialog.show() 之后才设置要在 Fragment onStart 之前设置否则滚轮滚动位置会闪一次。4.4 滚动回调里做UI刷新导致卡顿现象手指快速滑动省级滚轮界面掉帧明显低端机上甚至出现卡死几秒。原因滚轮滚动过程中选中回调被频繁触发每次回调都去刷市、区两级滚轮刷新里还带着布局重新测量。解决把联动刷新做成「滚动停止后再响应」。滚轮控件一般有选中回调和滚动状态回调只处理滚动停止后的最终选中项必要时加一个 150ms 的防抖 Runnable滚动过程中清除停止后延迟执行保证用户看到的是稳定后的结果。4.5 默认选中值传错时静默兜底现象外部传入一个「4401060000」这种错误区划码组件返回了某个默认省但 UI 上既没提示也没回退用户以为选的是自己所在地区。原因对外接口只做了非空校验没有校验 id 是否存在找不到匹配项时直接用了默认首位。解决setInitialRegion(provinceId, cityId, districtId) 里逐个查找找不到就 fallback 到第一个省份并打印一条明确日志。组件层至少要把失败暴露出来不能无声吞掉。查 id 时用 childIndex 这种 O(1) 结构不要在省级回调里写全遍历。5. 性能与并发把首次解析和列表刷新控制在可接受范围数据、UI、联动都跑通后剩下的问题是性能。这一章把首次解析、进程内缓存和方案升级的边界说清楚。5.1 assets读取与解析耗时不能放进主线程assets 里的 JSON 读取加 Gson 解析在数据量约 3000 条时中端机器一次要 80~200ms。这个时间放任何页面的 onCreate 里都能被启动检测抓出来导致页面从点击到出现明显卡顿。所以初始化必须移到子线程。我通常的做法是做一个懒加载App 启动时不预加载弹窗第一次打开前才触发 initinit 内部在子线程完成完成后再弹窗。public void loadIfNeeded(Context context, Runnable onReady) { if (provinceList.size() 0) { onReady.run(); return; } ExecutorService executor Executors.newSingleThreadExecutor(); executor.submit(() - { init(context); new Handler(Looper.getMainLooper()).post(onReady); }); }这套资源里使用的也是类似逻辑。第二次调用 loadIfNeeded 时provinceList 已有数据onReady 被直接投递到主线程执行弹窗无需再等。线程池用单线程就够避免多个页面同时请求初始化产生并发解析同一份 JSON 的问题。5.2 进程内单例缓存与滚轮数据复用解析结果必须做成进程级单例。我见过有人每次打开弹窗都重新读一遍 assets、重新 Gson 解析线上高峰期一次地址页打开多消耗几百毫秒还附带大量临时对象触发 GC。用 volatile 修饰的单例持有 provinceList 和 childIndex第一次解析后整个进程内复用内存里两份结构加起来才几十 KB换来的收益是后面每次弹窗都是毫秒级打开。如果担心内存抖动可以把 RegionBean 里的 children 引用做成不可变视图解析完成后不允许业务方修改。否则某个页面往 children 里塞了自定义数据整个进程里的选择器行为都会受影响。这一层不依赖特定 Android Framework 版本从 4.x 到 12 的工程都能直接复用。5.3 什么时候必须从JSON换到Room或网络下发JSON 方案不是万能的。数据量一旦超过 1 万条Gson 首次解析耗时会明显上升要在选择器里加搜索框输入「广州」直接命中「广州市」「天河区」等结果时JSON 需要在内存里全量遍历虽然 1 万条也能接受但做模糊匹配时正则和 contains 的损耗会叠加在每次输入上。这个场景建议换 Room把区划数据导入数据库表用 LIKE 查询搜索响应能压到 10ms 内。网络下发 JSON 则是另一类需求区划跟随行政区划调整更新。做物流或政务项目的团队我建议网络下发加本地缓存而不是每次发版同步数据。换方案的信号也明确一是区划数据开始超过 1 万条且需要搜索二是产品要求选择器带「最近使用」或「热门城市」排序三是每次发版更新 JSON 的流程已经拖累业务上线节奏。满足任意一条就该考虑数据库方案了。6. 接入与验证把选择器组件化并给上线前的自己留一道验收清单组件要接进业务至少提供初始值和确认回调。把第 3 章的弹窗封装成一句话调用CityPickerDialog dialog new CityPickerDialog(this); dialog.setInitialRegion(440000, 440100, 440106); dialog.setOnConfirmListener((province, city, district) - { tvAddress.setText(province.getName() city.getName() district.getName()); }); dialog.show();setInitialRegion 的返回值建议设计成 booleanfalse 表示传入的 id 未匹配方便业务方做提示。对外不要暴露 RegionBean 内部字段避免调用方跟数据实现耦合。我习惯在联调前跑四个验证场景把全国省级滚轮从头到尾滑一遍确保每个省的市级列表都非空选直辖市后再切回普通省份确认区级被重置而不是残留弹窗打开时旋转屏幕确认选中状态恢复首次弹窗在低端机上从点击到出现动画不超过 1 秒超时先查是不是解析放在了主线程。几个常用参数也顺手列出来参数建议值可调范围滚轮行高40dp30dp50dp可见条目537弹窗高度屏幕40%35%55%选中项字体16sp14sp18sp这些参数都收敛在组件的配置入口里改完不需要动联动代码。从那以后我每次接入城市选择器都强制自己把上面四个验证场景跑一遍再顺手把参数表交给 UI 看一遍至少能少改两个来回。如果你也在做地址录入拿到这套资源跑一遍上面的流程比自己从采集数据开始做要省心得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表