简介一套高仿淘宝收货地址的Android源码面向需要在Eclipse环境实现省市区联动选择的初中级开发者提供完整工程与全国省市、城市、小区数据资源。包内共135个文件以Java源码、XML布局、PNG图片为主另有APK、class、jar、文本说明等整体约2.64MB结构精简、目录清晰便于导入学习。已有6275人学习下载。资源覆盖省市联动选择、地址数据解析、自定义Adapter与监听器、输入校验等关键实现包含数据库相关类及地址选择界面逻辑同时涉及地理编码、网络与位置权限声明、常用地址本地持久化等延伸内容。对照源码可快速理清树形地理数据的组织方式掌握Spinner与EditText的适配用法了解Eclipse项目构建、版本控制配置与单元测试思路。适合作为课程设计或商业项目中的地址模块参考直接借鉴或二次开发。1. 仿淘宝收货地址最值得花时间的不是交互而是数据资源做电商前台时收货地址几乎是每个项目里最容易被低估的模块。用户真正在意的不是省市区三级联动那一下动画而是“我要的某某小区能不能被搜到、能不能一选就准”。仿淘宝收货地址的上限其实由内置全国各个省市的 城市 和小区的资源来托底。省市区这类行政区划数据相对规整小区数据却来自地图、楼盘字典、物业名录等多个渠道脏、乱、缺是常态。这篇内容会从数据分层、清洗合并、前端按需加载到一个最小可运行的选择器把这条路完整走一遍。适合正在做电商 H5、小程序或社区团购提货点选择的开发者参考。2. 地址资源设计省市区与小区怎么分层、按什么结构拆2.1 数据层级行政区划是骨架小区资源是血肉先明确一件事省市区和小区不是同一种数据。省市区属于行政区划来源稳定全国不过几百个地级单位、几千个区县单位完全可以直接打包。小区是生成逻辑的末端数量大、命名乱、更新快比如“望京西园”这类名字往往只属于某一个区需要先挂到对应区县下才有意义。我一般会把地址做成四级概念省级、地市级、区县级、小区级。乡镇和街道这一层不做最外层联动而是作为小区数据的归属字段保留下来。原因很简单用户在淘宝上填地址最终落点是小区加门牌号绝大多数用户不关心“街道办事处”叫什么。把街道放得太靠前会在直辖市和省直辖县上引发一大堆解释成本。顺序上先戳破一个坑行政区划数据带过来的是“国标层级”北京会拆成“北京市 → 北京市市辖区 → 朝阳区”。直辖市的“市辖区”是一层占位显示出来用户会误以为数据写错了。设计数据时就要预留规则位方便前端把这层占位收起来。同理河南、湖北这类地方还有省直辖县级行政区层级会和普通地级市不一致不能简单按“省→市→区”硬套。小区资源挂接的最小维度是区县级不要直接挂到地级市下。同一个城市里有大量同名小区比如“阳光花园”“金色家园”这种名字几乎每个区都有。挂接在区县下联动选择时才能给出唯一解释搜索时也能靠“小区名 区县名”做消歧不会让用户选完发现根本不在目标区域。2.2 适合搜索和联动的 JSON 结构嵌套树 扁平索引数据结构的核心矛盾是联动适合嵌套树搜索适合扁平索引。只做一棵大嵌套树搜索时要递归遍历代码难写且慢只做扁平表级联时每点一层都要循环过滤全量数据。正确做法是两个结构都保留由构建脚本一次性生成。嵌套树服务于省市区三级联动大概长这样{ version: 2025.01, provinces: [ { code: 110000, name: 北京市, children: [ { code: 110100, name: 北京市市辖区, children: [ { code: 110105, name: 朝阳区 } ] } ] } ] }这里没有把小区塞进 children原因很实际一个小区的子节点挂在区县下会让单个 JSON 膨胀到几兆首屏加载直接卡死。联动到这里已经足够用户选中区县后再单独加载该区的小区文件才是可控节奏。扁平索引服务于搜索字段尽量精简[ { name: 望京西园, districtCode: 110105, streetName: 望京街道, pinyin: wjxy }, { name: 国美第一城, districtCode: 110105, streetName: 平房地区, pinyin: gmdyc } ]字段越多加载量越大实际只要这四个名称、所属区码、归属街道、拼音索引。pinyin 字段不一定要全拼首字母缩写就够用因为用户搜索“wjxy”的意图通常集中在热门小区。全拼会显著增加索引体积收益并不明显。2.3 按需加载的资源拆分region.json、社区索引和分区分片资源拆分是这套方案里影响性能最大的决策。我常用的拆法如下文件数据层级加载时机用途data/region.json省市区三级进入地址选择器时级联联动data/community_index.json全国小区扁平索引用户触发搜索时模糊搜索data/communities/{区县code}.json单个区下的小区列表用户选中区县后小区点选列表上述拆分能保证默认进入地址面板时只加载一份可能几十KB的 region.json。全国小区索引平时不加载用户主动搜索才请求。left on the page所以不需要预加载。这样的好处是每个文件都被“使用时刻”驱动而不是在页面初始化时一股脑加载。移动端 H5 尤其明显弱网环境下把全国几万个小区一次性拉进来用户点开地址页会长时间白屏体验非常差。分区分片后每个网络请求的目标都很明确失败重试也容易定位。另一个细节region.json 和 community_index.json 要带 version 字段或文件名版本号。小区数据会持续修正同一个区今天 500 个小区下周可能变 530 个。前端发版不重启CDN 缓存也会干预版本号是防止“数据明明改了线上看到的还是老地址”的唯一后悔药。3. 把原始省市区和小区数据清洗成前端资源合并脚本与裁剪逻辑3.1 原始数据的合并关系与主键拿到原始数据时最常见的是几份 CSV 或 Excel省表、市表、区表、小区表。主键关系通常是这样的市表里有省 code区表里有市 code小区表里有区 code。官方行政区划有公开的区划代码格式相对统一小区数据则来源多样可能是地图 POI 导出也可能是楼盘字典整理字段名五花八门。我在开始写脚本前会先做一件事标准化字段名。无论原始字段叫 area_code、district_id 还是 region_code统一映射成 district_code。字段名不统一是后面代码反复返工的主要原因。代码里写的字段和 CSV 表头对不上时报错往往是 KeyError排查起来非常伤神。下面是一个可运行的 Python 构建脚本假设原始文件放在 raw_data 目录下输出到 public/data 目录。这个脚本只做合并和清洗不负责下载原始数据原始数据的获取路径按你手上资源的情况来行政区划用公开区划代码小区用采购或自有的渠道。import csv import json import re from collections import OrderedDict, defaultdict READ_PATH raw_data OUT_PATH public/data def read(path): with open(path, encodingutf-8-sig) as f: return list(csv.DictReader(f)) provinces read(f{READ_PATH}/province.csv) cities read(f{READ_PATH}/city.csv) districts read(f{READ_PATH}/district.csv) communities read(f{READ_PATH}/community.csv) def clean(s): # 去掉常见空格和全角空格名字里带空格会显示成“望京 西园” return re.sub(r[\s\u3000], , str(s or )) def build_tree(): province_map OrderedDict() for p in provinces: province_map[p[code]] { code: p[code], name: clean(p[name]), children: [] } city_map defaultdict(list) for c in cities: city_map[c[province_code]].append(c) district_map defaultdict(list) for d in districts: district_map[d[city_code]].append(d) for pc, pnode in province_map.items(): for c in city_map.get(pc, []): cnode {code: c[code], name: clean(c[name]), children: []} for d in district_map.get(c[code], []): cnode[children].append({code: d[code], name: clean(d[name])}) pnode[children].append(cnode) return province_map def build_index(): index [] for c in communities: district_code c.get(district_code, ) name clean(c.get(name, )) if not district_code or not name: continue index.append({ name: name, districtCode: district_code, streetName: clean(c.get(street_name, )), pinyin: clean(c.get(pinyin, )) }) return index if __name__ __main__: tree build_tree() index build_index() with open(f{OUT_PATH}/region.json, w, encodingutf-8) as f: json.dump({ version: 2025.01, provinces: list(tree.values()) }, f, ensure_asciiFalse) with open(f{OUT_PATH}/community_index.json, w, encodingutf-8) as f: json.dump(index, f, ensure_asciiFalse) grouped defaultdict(list) for item in index: grouped[item[districtCode]].append(item) for district_code, items in grouped.items(): with open(f{OUT_PATH}/communities/{district_code}.json, w, encodingutf-8) as f: json.dump(items, f, ensure_asciiFalse)读取 CSV 时用了utf-8-sig因为从 Windows 导出的大部分 CSV 文件会带 BOM 头不处理会在第一列字段名上多出\ufeff合并时必然翻车。clean 函数统一去掉中文里的空格和全角空格避免同一个小区因为空格差异被当成两个名字。输出文件有三个目标region.json 是级联专用只保留省市区三级community_index.json 是搜索专用扁平结构方便filter和find按区拆分的文件则给用户选完区之后的点选列表使用。按区分片还有一个隐藏好处如果一个小区的原始数据出现重复后面清洗时可以按(districtCode, name, streetName)去重不影响其他区的数据。3.2 清洗规则去重、去空格、非法字符、同名消歧清洗不是一次性动作而是贯穿整个构建流程的过滤器。第一层是格式清洗去掉空白字符、全角空格、零宽字符、不可见控制字符。第二层是业务清洗处理重复、纯符号、明显不是小区的词条比如“物流园”“停车场”“收费站”。同一源里最常见的重复场景有三种同名字重复、同名字同区重复、同名字跨区重复。同名字同区重复在清洗时直接保留一条用小区名加街道名判断是否同一实体。同名字跨区重复不要删除因为本来就是两个不同地理实体保留区分码即可。去重逻辑我会写在 SQL 或预处理脚本里而不是写在这个构建脚本里因为手动在 CSV 里改动更容易发现历史问题。非法字符处理要格外小心不要把所有非中英文字符都删掉。小区名里合法的阿拉伯数字太多“万科城市花园二期”“保利心语花园123号院”都不能乱动。我一般只替换正则匹配到的空白、不可见字符和非法标点具体规则列举如下ILLEGAL_PAT re.compile(r[\x00-\x08\x0b\x0c\x0e-\x1f\u200b\u200c\ufeff]) def strict_clean(s): s clean(s) return ILLEGAL_PAT.sub(, s)同名消歧要放在数据生成阶段完成。如果搜索“金色家园”全城命中 20 个记录用户不知道选哪个体验会很差。消歧策略是在展示结果时带出“金色家园朝阳区”“金色家园海淀区”。这个展示素材来自小区记录里的区名而不是用户输入所以构建索引时要把 districtName 也冗余一份进去避免前端反复查区表。3.3 生成资源的 Python 脚本和参数说明上面的构建脚本里最具业务价值的参数是READ_PATH和OUT_PATH它们决定你的原始数据目录和前端资源目录。原数据可能来自多个部门要确保同一版本的多表之间 code 关系完整不要出现“区表里引用的市 code 在市表里不存在”这种情况。脚本跑完后要立刻检查两件事输出文件总数与区县数是否一致community_index.json 里有没有空字段记录。写一个最小检查脚本能省掉大量交付问题import json from collections import Counter idx json.load(open(public/data/community_index.json)) empty [x for x in idx if not x[name] or not x[districtCode]] code_count Counter(x[districtCode] for x in idx) print(index total:, len(idx)) print(empty records:, len(empty)) print(district count in index:, len(code_count))这里 print 的不是接入指标而是交付风险区县数量对不上说明原始数据有断开或映射错误空记录绝对不能直接发布它们会让前端渲染出空行、搜索出空结果用户以为系统坏了。代码块里的这些检查都很粗糙真正上线前我会再补一层抽查比如单独取某几个区和全量总数做交叉对比。数据量是资源型项目的原罪不做这道工序后面修起来就是血泪。4. 仿淘宝收货地址选择器的最小实现级联、搜索和键盘处理4.1 交互拆分三级联动、自由搜索、手动输入兜底淘宝收货地址的交互拆开看其实只有三条按行政区划逐级点选、按小区名直接搜索、手动填写详细地址作为兜底。绝大多数用户走的是前两条点选路径适合不确定具体小区的用户搜索路径适合小区名明确、不想多点的用户。点选路径的交互顺序是进入后默认加载省份列表 → 点省出市 → 点市出区 → 点区出小区。直辖市要做一层特殊处理也就是前面说的收起“市辖区”占位否则“北京 → 北京市市辖区 → 朝阳区”的三级路径很长看起来不简洁。搜索路径则独立于级联状态用户可以在一进入页面就搜索“望京西园”不需要先选北京市朝阳区。命中结果直接显示后台拿到小区名后反查区县信息点击提交时把省市区信息补齐。这个“反查补齐”的逻辑非常重要只回传一个小区名没有意义收货地址必须是一个完整的省市区域字段。手动输入兜底也不能省。无论内置全国各个省市的 城市 和小区的资源做得有多全总有新建楼盘、老旧院落、单位园区不在库里。搜索不到时应该允许用户输入详细地址而不是卡在“无匹配”上让用户退出页面。4.2 最小可运行组件JS 代码一览下面这个组件是我实际项目中常用的最小形态只依赖原生 JS无框架、无第三方库适合嵌入任意页面。div idaddress-picker/divclass AddressPicker { constructor({ el, regionUrl, communityUrl }) { this.el document.querySelector(el); this.regionUrl regionUrl || /data/region.json; this.communityUrl communityUrl || /data/communities/; this.level 0; // 0: 省, 1: 市, 2: 区, 3: 小区 this.cache new Map(); this.selected {}; this.indexCache null; this.renderShell(); this.init(); } renderShell() { this.el.innerHTML div classaddr-head input classaddr-search placeholder搜索小区名称如望京西园 / ul classaddr-list/ul /div; this.listEl this.el.querySelector(.addr-list); this.searchEl this.el.querySelector(.addr-search); this.searchEl.addEventListener(input, (e) this.onSearch(e.target.value)); this.listEl.addEventListener(click, (e) this.onListClick(e)); } async init() { try { this.region await fetch(this.regionUrl).then((r) r.json()); this.showLevel(0); } catch (err) { this.listEl.innerHTML li classaddr-empty地址数据加载失败请重试/li; } } async onListClick(e) { const li e.target.closest(li); if (!li || li.classList.contains(addr-empty)) return; const { code, name } li.dataset; if (this.level 3) { this.selected.community name; this.commit(); return; } await this.selectLevel(this.level, { code, name }); } async selectLevel(level, item) { if (level 0) { this.selected.province item; this.selected.city null; this.selected.district null; } if (level 1) { this.selected.city item; this.selected.district null; } if (level 2) { this.selected.district item; } this.level level 1; this.showLevel(this.level); } async showLevel(level) { if (level 0) return this.renderList(this.region.provinces); if (level 1) { const p this.region.provinces.find((x) x.code this.selected.province.code); return this.renderList(p ? p.children : []); } if (level 2) { const p this.region.provinces.find((x) x.code this.selected.province.code); const c p.children.find((x) x.code this.selected.city.code); return this.renderList(c ? c.children : []); } if (level 3) { const code this.selected.district.code; let list this.cache.get(code); if (!list) { list await fetch(${this.communityUrl}${code}.json).then((r) r.json()); this.cache.set(code, list); } return this.renderList(list.map((x) ({ name: x.name, code: x.districtCode }))); } } renderList(items) { this.listEl.innerHTML items.length ? items.map((x) li>const vv window.visualViewport; if (vv) { vv.addEventListener(resize, () { const picker document.getElementById(address-picker); picker.style.height ${vv.height}px; }); }这段代码的思路是把地址面板高度约束在可视区域内而不是跟着整页滚动。不处理键盘问题用户会很频繁地遇到“输入完想看结果结果被键盘挡住”的局面这类问题在真机上很难在开发者工具里复现必须拿实体机验证。5. 内置全国省市小区资源的落地避坑缓存、拼址与低端机的五个翻车点5.1 现象一全量小区数据打成一个 JSON页面直接白屏初次搭建时最容易图省事把小区索引和 region.json 合并成一个文件。现象是地址模块首次打开要卡 3 到 5 秒旧安卓机上白屏概率非常高。原因就是全国几万条小区记录全在一份 JSON 里JSON.parse 和后续 DOM 拼接共同构成了性能黑匣子多数卡顿发生在 parse 阶段。解决办法是把数据拆回三份按 Chapter 2 的表格执行。region.json 只保留省市区小区索引单独构建按区分片。只要每个文件的体积控制在几百 KB 量级低端机也能接受。5.2 现象二“北京市市辖区”让运营误以为数据配置错误现象是测试报告里频繁出现“地址选择器有问题北京下面有个北京市市辖区”。原因不是你数据错误而是国标行政区划里的直辖市层级天然如此。如果不处理用户和运营都会当成缺陷提交。解决方式是在前端展示时做层级合并判断。当一级节点只有一个子节点且子节点名称包含“市辖区”时直接把这一层吞掉让北京直接展示下辖区县。这也是 commit 方法里用replace(市辖区, )的原因。要注意合并判断不能只匹配“市辖区”还要处理“省直辖县级行政区划”否则河南、湖北等省份的联动会漏掉若干县级单位。5.3 现象三小区能搜到但拼接出来的地址缺了街道或乡镇用户搜索“望京西园”提交后地址变成“北京市朝阳区望京西园”看起来完整实际上缺少乡镇街道层级下游发货或配送会因地址不完整而二次确认。原因是小区索引里没有保存 streetName 字段前端拼接时只能从省市区三级取数。解决方法是构建索引时保留 streetName 字段并在 commit 时把街道名拼进地址。假如你的下游系统不认街道也要保证“区县 小区”能唯一对应不能出现同区同名但不同街道的小区否则会出现拼错归属的风险。5.4 现象四更新数据发布后线上用户拿到的还是旧地址现象是数据包重新生成并上传后自己手机强制刷新能看到新数据大部分线上用户一周内仍是旧小区。原因是静态资源缓存。region.json 和 community_index.json 这类请求默认会走协商缓存或强缓存发布同名文件不一定会让客户端重新拉取。解决方式是在文件名或查询参数上带版本号例如region.20250101.json。前端通过接口或配置拿到当前版本号拼进 URL确保更新后所有客户端请求到新资源。版本号不能随手写要用构建脚本里的数据版本字段统一生成否则每次手工改链接总会漏掉一两个客户端。5.5 现象五输入关键词时列表跳动用户点错小区移动端搜索时弹起键盘结果列表在每次 input 事件里被重新渲染键盘收起后列表框跳到底部或顶部用户点到的不是自己刚才看到的那一项。原因是 input 事件高频触发DOM 整体替换导致滚动位置丢失键盘高度变化又触发视觉视口 resize。解决方法是给 input 做至少 200ms 的防抖搜索渲染前记录当前列表的 scrollTop渲染后恢复。更稳妥的做法是搜索结果区域固定为独立滚动容器不跟随页面主体滚动。真机上还要配合 visualViewport 的 resize 逻辑合并键盘处理后才真正稳定。6. 交付前的细节打磨数据验收清单与淘宝体验对齐6.1 数据完整性与漏配检查脚本资源型项目用户对缺失数据的容忍度极低我会在每次发版前跑一个简单的验收脚本import json from collections import Counter region json.load(open(data/region.json)) index json.load(open(data/community_index.json)) district_codes set() for p in region[provinces]: for c in p[children]: for d in c[children]: district_codes.add(d[code]) idx_codes Counter(x[districtCode] for x in index) missing district_codes - set(idx_codes) duplicate_names [k for k, v in Counter(x[name] for x in index).items() if v 5] print(f区县数: {len(district_codes)}) print(f小区记录数: {len(index)}) print(f无小区数据的区县: {len(missing)}) print(f全国同名超过5次的小区名数量: {len(duplicate_names)})这个脚本不负责判断数据对错只负责告诉你“哪些区域没有覆盖到”。无小区数据的区县不一定是错的比如部分偏远区县可能确实没有可列的小区但你要知道哪些地方在依赖手动兜底。同名过多则提醒你搜索展示要做消歧否则用户会被同名列表淹没。6.2 手工验收清单按淘宝用户习惯过一遍自动检查之外我坚持保留一份手工验收清单。数据资源再全真实体验永远是试出来的。验收点操作方式预期结果直辖市选择北京市不出现“市辖区”占位直达区县省直辖县选择河南省济源等县级单位能正常联动进入小区搜索大热词输入“阳光花园”结果带区名消歧列表不卡顿搜索无结果输入一个不存在的小区显示手动填写的兜底提示键盘交互Android 真机输入关键词键盘收起后列表位置不跳动缓存更新覆盖数据文件后刷新客户端拉到新版本小区数据最后说点个人的习惯每次交付这类地址选择器我都不只看模拟器而是用一台旧安卓机完整走一遍搜索。数据资源的构建脚本可以反复跑但触屏、输入法、键盘高度这些交互问题只有真机才会给你真实反馈。早年我吃过一次亏在开发者工具里验证一切正常结果用户手机上一选小区就跳回顶部这些玄学般的问题后来都归因到 DOM 重绘和键盘事件。现在我会把“真机搜索”当成固定验收动作宁可多花半小时也不愿意上线后被用户反复反馈。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?