拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android高德地图离线瓦片缓存实现:从卫星图接入到自定义TileOverlay

简介面向Android开发者的高德卫星地图离线缓存示例工程针对谷歌中国卫星地图无法访问的现状提供基于高德地图API的替代方案。资源内含AMapTest示例项目覆盖地图初始化、卫星图层切换、瓦片监听下载与本地缓存机制适合需要集成离线地图功能的中初级开发者参考。压缩包共124个文件以75个xml配置、7个java源码、10个png图片为主并包含gradle构建脚本、jar依赖库及bin缓存文件整体仅803KB结构轻量。已有492人学习下载说明其实用性得到初步验证。通过该工程可快速理解卫星瓦片图的组织方式学会在无网络环境下读取本地瓦片并拼接显示同时掌握分区域、分等级的缓存清理与存储策略。对于地图类应用开发而言这是一份可直接运行的落地参考。1. 谷歌卫星图断供之后高德瓦片离线缓存是怎么补位的国内不少地图应用早期直接拉谷歌中国的卫星瓦片但这两年谷歌中国卫星图在国内已经拿不到稳定数据很多项目被迫换数据源。高德的卫星影像覆盖相对完整API 也是公开的问题在于高德官方 SDK 默认只提供在线瓦片断网后地图直接空白。这个 zip 里的 Android 工程解决的就是这件事它把高德卫星图切成 256x256 的瓦片在相机移动时异步下载到本地再通过自定义 TileOverlay 把磁盘缓存的瓦片回填给地图离线重放时不依赖网络。适合做野外巡检、冷链车辆离线调度以及需要在隔离网络环境里保留卫星底图的场景。2. Android 高德地图接入与卫星图层切换从依赖到 MapType要谈离线缓存先得把高德地图跑起来。这个工程是标准的 Android Gradle 项目解压后能看到gradlew.bat和build/目录下的一堆fileHashes.bin、executionHistory.bin、last-build.bin之类的构建缓存说明它在本机构建过项目结构完整。下面按通用接入步骤展开具体 SDK 版本以工程内build.gradle为准不要盲目升级。2.1 Gradle 依赖与 AndroidManifest 初始化在app/build.gradle中添加高德地图 SDK 依赖通常是这样dependencies { // 地图渲染 SDK负责 MapView 和高德底层渲染 implementation com.amap.api:map3d:latest.integration // 定位 SDK用于后续以当前位置为中心预取瓦片 implementation com.amap.api:location:latest.integration }这里使用latest.integration只是为了快速验证因为工程里可能已经锁定了具体版本。如果你自己维护项目不建议用这种写法它会在每次构建时检查远程仓库里的最新版本导致昨天跑得好好的工程今天编译不过。把版本号固定下来才能保证离线缓存逻辑的稳定性。高德地图的 Key 必须配置在AndroidManifest.xml的application节点里同时声明必要权限uses-permission android:nameandroid.permission.INTERNET/ uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE/ uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION/ uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION/ application meta-data android:namecom.amap.api.v2.apikey android:value你的高德Key/ /application说明INTERNET是地图加载的底线没有它瓦片请求直接失败ACCESS_FINE_LOCATION用于定位虽然离线瓦片本身不依赖定位但高德地图在某些定制图层上会检查定位权限缺少时地图视图可能出现网格。另外一个很常见的坑是签名不一致你用 debug keystore 构建却申请了 release 签名对应的 Key地图会一直白屏logcat 里没有任何错误信息。把签名配置调一致后再切卫星图层问题才会消失。2.2 MapView 生命周期与 MapType 切换高德的MapView继承自FrameLayout必须把 Activity 的生命周期事件同步转发给它。布局文件写com.amap.api.maps.MapView android:idid/map android:layout_widthmatch_parent android:layout_heightmatch_parent/在 Activity 里初始化public class MainActivity extends AppCompatActivity { private MapView mapView; private AMap aMap; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mapView findViewById(R.id.map); // 必须调用否则地图渲染不出来 mapView.onCreate(savedInstanceState); aMap mapView.getMap(); // 切换为卫星影像图层 aMap.setMapType(AMap.MAP_TYPE_SATELLITE); // 关掉默认缩放按钮腾出更多屏幕给离线瓦片 aMap.getUiSettings().setZoomControlsEnabled(false); } Override protected void onResume() { super.onResume(); mapView.onResume(); } Override protected void onPause() { super.onPause(); mapView.onPause(); } Override protected void onDestroy() { super.onDestroy(); mapView.onDestroy(); } }高德的MapType不止一种下面的表格是常用类型类型常量值说明适用场景MAP_TYPE_NORMAL1标准路网图日常导航显示MAP_TYPE_SATELLITE2卫星影像离线缓存、巡检MAP_TYPE_NIGHT3夜间模式夜间驾驶MAP_TYPE_NAVI4导航动效模式导航界面可以看到MAP_TYPE_SATELLITE正是我们要的。但要注意MapView必须放在android:layout_height非wrap_content的容器里否则地图渲染尺寸为 0。很多人把MapView放到ScrollView中导致高德引擎在滑动时频繁重绘瓦片缓存线程可能被触发数十次这个在离线缓存场景里尤其致命。3. 卫星瓦片离线缓存的实现TileOverlay、文件存储与四叉树目录在线模式只解决了「能看」离线缓存要解决「断网也能看」。高德官方 SDK 不主动导出一张张瓦片所以工程里通常自己实现一层TileProvider它负责从本地文件系统返回瓦片字节流同时在地图相机移动结束后把当前可视区域的瓦片从网络下载到本地。这样既绕开了高德内置在线瓦片缓存又能完全掌控缓存生命周期。3.1 自定义 TileProvider 与瓦片坐标拼接高德的TileOverlay接受一个TileProvider接口核心方法是getTile(int x, int y, int zoom)。三个参数对应瓦片坐标系的列、行和缩放级别高德要求返回一张 256x256 的 PNG 数据。下面是一个从本地文件读取瓦片的实现Override public Tile getTile(int x, int y, int zoom) { // 本地路径tilecache/{zoom}/{x}/{y}.png File file new File(cacheDir, zoom / x / y .png); if (file.exists()) { byte[] data FileUtils.read(file); // 256x256 是瓦片的标准尺寸 return new Tile(256, 256, data); } // 返回 null 表示没有瓦片高德会显示底图网格 return null; }逻辑说明每次高德渲染引擎请求瓦片时会先查本地文件。这里有一个容易被忽略的问题即使文件存在每次getTile都会进行一次磁盘exists()判断。在地图快速拖动时高德可能一秒内调几十次getTile大量磁盘 IO 会掉帧。改进方式是在内存里维护一个已缓存瓦片路径的HashSet在下载完成后加入集合getTile先查内存集合再决定要不要访问磁盘。拼接网络瓦片 URL 时要注意高德官方瓦片地址通常带 Key 和签名参数不能长期依赖公共接口。示例工程为了演示离线逻辑一般会写成String url https://webrd0s.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x x y y z zoom;这串 URL 里的style8表示卫星影像style6是带路网标注的矢量。公共接口的稳定性没有 SLA如果你要用于生产申请高德 Key 后把key参数加进去。比较稳妥的做法是在getTile中拦截 zoom 越界当 zoom 小于 3 或大于 18 时直接返回 null避免向一个不存在的瓦片地址发请求。3.2 本地文件缓存与 LRU 淘汰瓦片按zoom/x/y存放天然形成了一棵四叉树目录。zoom 每增加一级瓦片数量变成原来的四倍例如 zoom 15 下某个城市就有数千张瓦片。如果不对磁盘做上限控制缓存就会无限膨胀。工程里通常用 LRU 策略来限制总量public class TileCache { private final File rootDir; private final long maxSize; // 磁盘上限单位字节 private final LinkedHashMapFile, Long lastAccess new LinkedHashMap(); public synchronized void put(File tileFile, byte[] data) { if (tileFile null || data null) return; tileFile.getParentFile().mkdirs(); write(tileFile, data); // 实际落盘 lastAccess.put(tileFile, System.currentTimeMillis()); // 超过上限时从链表头部删除最久未访问的瓦片 while (getDirSize() maxSize) { File oldest lastAccess.keySet().iterator().next(); if (oldest null || !oldest.delete()) break; lastAccess.remove(oldest); } } private long getDirSize() { // 遍历 rootDir 下的所有 png 文件累加长度 } }参数说明maxSize建议根据场景设置。只缓存一个城市 18 级别细节500MB 差不多如果缓存整个省份的 10-14 级概览可能要到 1GB。LinkedHashMap作为一个简单 LRU 结构够用但生产环境适合用DiskLruCache它会在每条缓存记录前写入元数据即使 app 被 kill 也能恢复 LRU 状态。这里同步方法是为了避免多线程同时写同一个瓦片文件造成部分写入。3.3 监听地图移动动态下载有了存储层还需要一个触发下载的入口。最常见的做法是注册高德的相机移动监听器在地图移动结束后计算当前可视区域覆盖了哪些瓦片aMap.setOnCameraChangeListener(new AMap.OnCameraChangeListener() { Override public void onCameraChange(CameraPosition position) {} Override public void onCameraChangeFinish(CameraPosition position) { int zoom (int) position.zoom; // 获取当前视野的经纬度范围 VisibleRegion region aMap.getProjection().getVisibleRegion(); LatLngBounds bounds region.latLngBounds; int xmin lonToTileX(bounds.southwest.longitude, zoom); int xmax lonToTileX(bounds.northeast.longitude, zoom); int ymin latToTileY(bounds.northeast.latitude, zoom); int ymax latToTileY(bounds.southwest.latitude, zoom); // 提交下载任务到线程池 exec.submit(new DownloadTask(xmin, xmax, ymin, ymax, zoom)); } });对应的经纬度转瓦片坐标公式private int lonToTileX(double lon, int zoom) { return (int) Math.floor((lon 180.0) / 360.0 * (1 zoom)); } private int latToTileY(double lat, int zoom) { double sinLat Math.sin(Math.toRadians(lat)); return (int) Math.floor((0.5 - Math.log((1 sinLat) / (1 - sinLat)) / (4 * Math.PI)) * (1 zoom)); }逻辑说明经纬度换算成瓦片坐标后bounds.southwest和bounds.northeast围起来的长方形就是相机可视区域。注意 Y 轴方向和经纬度是反的维度越高瓦片行号越小所以ymin要用northeast.latitude算ymax用southwest.latitude算。这里写反的话下载任务会把相邻区域瓦片全部拉进来缓存里全是不需要的图块。DownloadTask里遍历xmin到xmax、ymin到ymax对每张瓦片先检查本地文件是否存在不存在再 HTTP 下载。下载线程池建议用Executors.newFixedThreadPool(4)流量环境下 8 个线程会让带宽打满地图本身还有其他请求会互相争抢。实际体验下来2 到 4 个线程就够。4. 缓存策略参数调优分级、预取与磁盘边界卫星瓦片不是下载得越多越好。zoom 15 的一张瓦片大概覆盖 4.9 公里见方zoom 18 只覆盖 600 米见方如果对缩放级别和范围不加约束离线缓存会迅速写满磁盘也会让onCameraChangeFinish带来的下载任务不断堆积。这一章把参数和边界理清。4.1 分级缓存与范围白名单通常把离线缓存划分为三个层级每个层级有不同的目标和磁盘预算层级zoom 范围用途参考上限概览层3-10全国或省际范围10MB城市层11-14城市周边路网200MB细节点层15-18道路、建筑细节800MB实现时用一棵有序表记录每层上限public class CachePolicy { public static final TreeMapInteger, Integer LEVEL_LIMIT new TreeMap(); static { LEVEL_LIMIT.put(10, 10 * 1024 * 1024); // zoom 10 以内 LEVEL_LIMIT.put(14, 200 * 1024 * 1024); // zoom 11-14 LEVEL_LIMIT.put(18, 800 * 1024 * 1024); // zoom 15-18 } public static boolean insideChina(int x, int y, int zoom) { // 粗略边界实际可换成准确国界多边形 return x 66 x 148 y 0 y 60; } }参数说明TreeMap的 key 是某一层的最高 zoomvalue 是这一层的磁盘预算。清点时按目录名取 zoom累加同层所有瓦片大小。insideChina的目的是防止用户在地图边缘快速拖动时误缓存国外瓦片——高德国外瓦片往往没有真实影像下载回来全是灰色块占据宝贵空间。4.2 预取任务优先级与队列限流地图滑动场景会频繁触发onCameraChangeFinish如果每次任务都直接丢给线程池用户快速滑动时队列里可能积压上千个无关瓦片。一个常用的做法是给下载任务增加优先级队列PriorityBlockingQueueTileRequest queue new PriorityBlockingQueue( 512, Comparator.comparingInt(req - Math.abs(req.x - currentX) Math.abs(req.y - currentY)) );currentX/currentY是当前视野中心瓦片坐标。优先级按曼哈顿距离计算离中心越近的瓦片下载越早。同时要限制队列长度如果队列里的任务数超过当前视野内瓦片总数就把不在视野范围内的请求丢弃。否则在低端机上滑动地图内存会先被任务对象占满。4.3 延迟清理与访问时间排序磁盘空间达到上限后单纯在put方法里做 LRU 删除不够充分因为瓦片有很强的时间局部性用户从北京到上海北京旧瓦片可能几周都不会再访问。所以除容量外还要记录访问时间定期做一次全量清理public void evictIfNeeded() { if (getTotalSize() maxSize) return; ListCacheEntry entries queryAllEntries(); // 先按最后访问时间升序再按创建时间升序 entries.sort(Comparator .comparingLong(CacheEntry::getLastAccess) .thenComparingLong(CacheEntry::getCreateTime)); for (CacheEntry entry : entries) { if (getTotalSize() maxSize) break; deleteEntry(entry); } }这段代码的要点是排序键的选择lastAccess是每次地图渲染命中瓦片时更新的时间戳createTime是瓦片首次下载时间。两者组合起来能避免一个场景瓦片昨天刚被下载但用户已经不在那个区域了没有命中记录不会被优先删除。清理动作必须在子线程执行因为queryAllEntries()要遍历整个缓存目录在上千个文件时耗时可到数百毫秒放主线程直接导致掉帧。清理的触发点建议放在onCameraChangeFinish后延迟 2000ms。一次滑动可能拉取几十张瓦片如果每放一张都检查容量会频繁触发while循环和文件删除。延迟触发能把这几十次写入合并成一次清理对闪存寿命也更友好。5. 验证离线瓦片正确命中的三个技巧与常见 zip 坑离线缓存最怕「看起来在空转」断网后地图能显示地层但显示的是高德内置底图缓存不是我们的瓦片池。验证必须有明确信号不能只看地图上有没有图。5.1 用日志确认瓦片来源在TileProvider的getTile方法里加一个开关日志Override public Tile getTile(int x, int y, int zoom) { // BuildConfig.DEBUG 自动跟随 buildType if (BuildConfig.DEBUG) { File f new File(cacheDir, zoom / x / y .png); Log.d(TileCache, hit f.exists() zxy zoom / x / y); } ... }在 logcat 里过滤TileCache标签如果断网状态下hittrue的日志持续出现说明缓存已经正确供图如果全是hitfalse检查线程池有没有执行、缓存目录路径是不是被系统清理了。注意日志本身也有 IO 开销release 包里要关闭。5.2 adb 拉取瓦片校验尺寸用 adb 把缓存目录拉到本地验证每张瓦片是否为 256x256adb pull /sdcard/Android/data/com.your.app/files/tilecache/ ./tilecache再运行一个简单的 Python 脚本from PIL import Image from pathlib import Path # 遍历瓦片目录检查尺寸 for p in Path(./tilecache).rglob(*.png): w, h Image.open(p).size if (w, h) ! (256, 256): print(bad tile:, p)这个脚本能发现两类问题一类是高德返回的默认占位图它们可能不是 PNG另一类是部分机型上 Bitmap 解码异常生成的 512x512 图片。高德渲染引擎会自动缩放这类非标瓦片但显示会很模糊而这种模糊在在线模式下感觉不出来只有离线时才会察觉。5.3 解压 zip 后构建报错排查很多人在解压这个资源后直接构建遇到error read zip archive或 Gradle 构建报 zip 相关错误。常见原因有三个第一gradle/wrapper/gradle-wrapper.properties指定的 Gradle 发行版 zip 损坏删除~/.gradle/wrapper/dists/下对应缓存后重新构建第二整个压缩包解压时把.gradle目录也解了出来而其中的.gradle/8.10/buildOutputCleanup等文件是机器相关的缓存会干扰新环境直接删掉即可第三Android Studio 使用的 JDK 版本与该工程不匹配报错信息里通常会出现Unsupported class file major version把 JDK 切到 17 或 21 重试。如果想先验证 zip 文件本身完整性运行unzip -t 下载的包名.zip比任何修复工具都直观。另一个值得注意的验证点是高德引擎自己也有网络缓存它会掩盖本地瓦片缺失的问题。在高德 SDK 支持的情况下初始化时调用aMap.setDiskCacheEnabled(false)把 SDK 内置磁盘缓存关掉。这样测试断网时,地图显示的内容只能来自你的TileProvider命中率数据才是真实的。如果 SDK 版本没有这个方法可以在断网前清空高德自带的缓存目录再测试效果是一样的。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门