☰
OSMDroid切换地图源不更新?三层缓存与同名瓦片源根因解析
2026/10/6 3:34:39 网站建设 项目流程

先说结论:这个问题十有八九不是OSMDroid没接收到新瓦片源,而是瓦片加载链路里某个环节还在用旧数据。我在做户外轨迹App时,地图层用的OSMDroid 6.1.10,支持标准路网图、卫星图和离线地形图三套源切换。用户反馈从设置页切换地图类型后,回到地图页画面纹丝不动,必须杀掉App重进才生效。这个现象稳定复现,而且规律很诡异:第一次切换往往能生效,第二次、第三次就完全无效。我排查了大概一个下午,从onResume生命周期一路查到磁盘缓存目录,最后发现是瓦片源命名和该层的磁盘缓存命中规则在作怪。这篇把完整现象、底层原理、排查链路和最终方案都整理出来,给被OSMDroid缓存坑过的同行做个参考。

1. 先还原一下现场:Spinner选完地图类型,画面纹丝不动

1.1 复现步骤和最初观察到的诡异规律

地图页就是常见的底部导航结构,地图类型放在设置页,用RadioGroup存到SharedPreferences。回到地图页时,onResume里读取偏好并调用切换函数,代码长这样:

override fun onResume() { super.onResume() val type = prefs.getString("map_type", "normal") val source = when (type) { "satellite" -> TileSourceFactory.USGS_SATELLITE "terrain" -> getTerrainSourceFromAssets() else -> TileSourceFactory.MAPNIK } mapView.setTileSource(source) mapView.invalidate() }

这个逻辑看起来没有任何问题。第一次切换确实能生效,比如从标准图切成卫星图,几秒后画面变成卫星影像。但第二次切换,从卫星图切回标准图,画面就卡在卫星图不动了;再切第三次,依旧不动,所有网络请求都没有新变化。杀掉App重进,画面却是正确的——说明SharedPreferences里存的选项没问题,MapView上应用的source也没问题,问题只出在"进程存活时的一次动态切换"上。

1.2 一个关键细节:拖动地图后旧图开始被替换

我在复现时随手拖动了一下地图,发现一个非常关键的细节:手指按住地图拖动的瞬间,屏幕上很多瓦片开始加载,旧图一块一块被新瓦片替换,大概两三秒后整个屏幕就变成新地图了。这个细节基本锁定了方向——切换开关本身执行成功,只是切换动作没有触发一次完整的地图重绘和瓦片重新请求。拖动地图相当于人为触发了一次"可视区域变化",OSMDroid被迫重新计算当前屏幕需要哪些瓦片,这才让新瓦片源真正走了申请逻辑。

也就是说,setTileSource之后缺少一个能让MapView主动刷新当前瓦片集合的动作。直接调用invalidate()只触发View层面的重绘,但TileLayer在onDraw里拿到的瓦片Bitmap,可能还是旧的。要理解这背后为什么,得先看清楚OSMDroid取一张瓦片要经过哪些环节。

2. 根因剖析:瓦片加载链路里三层"缓存"在捣乱

2.1 OSMDroid取一张瓦片要经过的三层关卡

OSMDroid的瓦片加载不是"当前缺哪块就立刻去网上拉哪块"这么简单。它内部是一条链式结构,每个Provider只负责链路中的一段。默认情况下大概是这样:

  • MapTileCache:内存LRU缓存,保存最近用过的Bitmap,命中后直接返回,速度最快。
  • MapTileFilesystemProvider:磁盘文件缓存,目录通常在/data/data/包名/files/osmdroid下,按瓦片坐标存成PNG文件,命中后把文件读成Bitmap。
  • MapTileDownloadProvider:真正发起网络请求的环节,下载完成后会同时写入内存缓存和磁盘缓存。

用生活化类比就是:内存缓存是办公桌上的常用文件,磁盘缓存是抽屉里的归档文件,网络才是资料室。你每次要一份资料,先看桌上有没有,再看抽屉里有没有,都没有才跑去资料室拿新的。问题在于,抽屉里的归档文件是按一定的命名规则放的,如果两套瓦片源在命名上互相重叠,就会出现"抽屉里明明放着旧资料,却被当成新资料直接递给你"的情况。

2.2 setTileSource()内部到底做了什么

我翻了6.1.10的源码,MapView.setTileSource(ITileSource)最终会调用到MapTileProviderBase.setTileSource()。这个方法里面有个关键判断:只有当新source和旧source不是同一个对象时,才会重建内部的MapTileProviderChain,并调用clearTileCache()清空内存LRU缓存。所以从设计上讲,正常切换地图源后,内存缓存会被清掉,不该出现旧图残留。

但这套机制有个前提——它比较的是Java对象引用,不是URL,也不是name。如果你切换的两个TileSource实例虽然内容不同,但被某种逻辑复用了同一个对象,或者Provider内部重建时机还没执行完就被绘制线程抢先取瓦片,就会看到旧图。更常见的场景是磁盘缓存层,这一层不会因为你调用setTileSource()就被自动清理。clearTileCache()清的是内存LRU,磁盘里那些已经存在的瓦片文件依然在。

2.3 磁盘文件命中的隐藏条件:瓦片源的name就是目录名

OSMDroid磁盘缓存的文件路径大致是:

{osmdroidTileCache}/{tileSource.name()}/{zoom}/{x}/{y}.png

注意中间那个tileSource.name()。磁盘缓存是否命中,完全取决于这个name对应的目录下有没有对应坐标的文件,它不会去校验你当前使用的瓦片源URL和缓存文件里的URL是否一致。也就是说,如果两套源的name都叫"Online"或都叫"my_tiles",那它们共享同一个磁盘目录。切换过去之后,OSMDroid从网络下载新瓦片需要时间,于是先把磁盘里已有文件拿出来顶着,这些文件全是旧源的,画面自然看起来就没变。

我之前踩的坑正是这个:自定义卫星源和离线地形源在初始化时,偷懒给name都传了"online",导致磁盘目录互相污染。当我从卫星切回标准图时,标准图的目录里没有多余东西,按说该正常下载,但因网络较慢,而View又没强制刷新,就出现了短暂的"看起来没变";再切一次,因为旧图Bitmap还在内存/磁盘里被循环利用,就成了永久纹丝不动。如果你也遇到切换后地图没变化,可以先去看磁盘目录里是不是有两个源共用了同一个name目录——这个概率比你想的大得多。

3. 逐步缩小范围的排查链路

3.1 先确认切换代码真的执行且provider真的换掉了

排查第一步不是看缓存,而是先确认代码执行链路没断。在switchTileSource函数入口和onResume里都加上日志:

Log.d("MapSwitch", "switchTileSource called, target=${source.name()}") Log.d("MapSwitch", "current provider source=${mapView.tileProvider?.tileSource?.name()}")

重点看两点:第一,日志是否在每次切换时都打印;第二,执行完setTileSource后,tileProvider.tileSource.name()是不是已经变成新值。如果name已经变了,说明OSMDroid上层已经认可切换动作;如果name没变,那大概率是切换代码被生命周期覆盖,比如Fragment重新onCreateView时又用XML里的默认tilesource重建了MapView。

3.2 用瓦片URL日志判断有没有发起新源请求

确认provider已经换成新源后,下一步要搞清楚有没有真的发起新瓦片请求。最简单起见,自定义一个TileSource,重写getTileURLString,把URL打到logcat:

val source = object : OnlineTileSourceBase( "my_satellite", 1, 20, 256, ".png", arrayOf("https://example.com/tiles/satellite") ) { override fun getTileURLString(pMapTileIndex: Long): String { val url = super.getTileURLString(pMapTileIndex) Log.d("MapSwitch", "requesting url=$url") return url } }

切换后观察日志:

  • 如果打印的是新源的URL,说明请求链路已经走起来,只是画面显示层还在吃旧缓存,重点检查内存和磁盘缓存。
  • 如果完全没有新URL,说明MapView没有被真正触发重绘,或者MapTileProviderChain里的MapTileDownloadProvider没被激活,继续往View刷新和Overlay叠加方向查。

我当时的情况是:第一次切换有少量新URL,后面切换几乎没有任何新URL。这基本说明provider chain在切换后没有主动重新请求当前屏幕的瓦片集合,直到拖动地图才触发。

3.3 检查磁盘缓存目录,一击命中要害

第三步,直接看磁盘。开发包可以这样:

adb shell run-as com.your.package ls files/osmdroid

正常情况下你应该看到每个tileSource.name()对应的一个目录。如果发现期望的目录没出现,或者只有一个目录在承载好几个源的数据,就找到了问题核心。更致命的是目录里文件名完全一样、内容却是旧源的瓦片,这会让OSMDroid误以为"新瓦片已经下载过"并直接读旧文件。

3.4 清空缓存目录的AB测试

为了验证是不是磁盘缓存在作怪,直接粗暴点,把所有缓存删掉再切一次:

adb shell run-as com.your.package rm -rf files/osmdroid

这一步风险很低,因为OSMDroid会自动重建目录,代价只是第一次加载慢一些。清空后如果切换恢复正常,那问题基本锁定磁盘缓存命中;如果清空后还不正常,说明跟缓存无关,回到第3.2步继续查绘制层。

3.5 别忽略Overlay叠加的可能

还有一种容易被忽略的情况:地图页面不止有MapView默认的TileLayer,你还手动添加过自定义TilesOverlay,而且它不透明或者层级在最上层。此时mapView.setTileSource()换掉的只是默认TileLayer的数据源,屏幕上层那张Overlay依然是旧图,你自然觉得"不更新"。

检查方式很简单:

for (overlay in mapView.overlays) { Log.d("MapSwitch", "overlay=${overlay.javaClass.simpleName}") }

看有没有TilesOverlay、TileOverlay之类的存在。有的话,要么把它移除,要么确保它透明度不为255并且在切换地图源时同步更新它的tile source。

4. 从一行代码到重建Provider的四套解法

4.1 方案A:手动刷新View,只治标

最不费劲的做法是:

mapView.setTileSource(newSource) mapView.invalidate()

invalidate()会让MapView在下一帧重绘,TileLayer会重新走一遍getMapTile。但对于"内存缓存里已有旧Bitmap"的情况,重绘后取到的还是旧图。这个方案只对"切换后压根没触发重绘"的场景有效,治标不治本,但作为第一行代码总没错。

如果invalidate()后仍然没有任何刷新,可以试试:

mapView.invalidate() mapView.postInvalidateDelayed(100)

有些机型上UI线程忙或者动画导致invalidate被合并,延迟提交一次能避免无效绘制。不过这只是辅助手段,不能作为主解法。

4.2 方案B:清除内存LRU缓存,治了大多数

接着加一步清内存缓存:

mapView.tileProvider.clearTileCache() mapView.setTileSource(newSource) mapView.invalidate()

clearTileCache()清的是MapTileCache里的LRU Bitmap,之后getMapTile在内存层再也找不到旧瓦片,只能往下层走。如果你的场景只是"内存缓存把旧图顶在上面",这一步就解决了。问题在于磁盘缓存依然可能命中,进一步引出方案C。

4.3 方案C:按name清理磁盘缓存,治本

针对磁盘目录冲突,最直接的办法是让每个源有独立的name,并且在切换时把目标源对应的磁盘目录清掉。获取缓存目录的方式:

val baseDir = Configuration.getInstance().osdroidTileCache val targetDir = File(baseDir, newSource.name()) if (targetDir.exists()) { targetDir.deleteRecursively() }

这里有个关键点:执行这段清理的正确时机是在setTileSource()之前。否则provider chain已经在用这个目录了,删除后它又会重新创建并继续读老文件。清理完再setTileSource()并invalidate(),就相当于让新源从一张白纸开始,既不会读到旧磁盘文件,也不会被内存缓存干扰。

如果你不想每次切换都清一遍磁盘,更优雅的方案是直接杜绝name冲突。每个自定义TileSource初始化时,把name改成带业务前缀的唯一值:

new TileSource("com.myapp.satellite", ...) new TileSource("com.myapp.terrain", ...)

磁盘目录从根源上分开了,大多数"切换不更新"的毛病会自动消失。

4.4 方案D:重建TileProvider,兜底方案

如果你用了很复杂的自定义provider chain,或者上述方案都因为其他自定义逻辑生效不彻底,可以直接暴力重建整个provider:

val newProvider = MapTileProviderBasic(context, newSource) mapView.setTileProvider(newProvider) mapView.invalidate()

setTileProvider()会替换MapView内部的整个瓦片获取链,包括内存缓存、磁盘缓存和下载器。新provider内部状态是干净的,不存在任何旧缓存继承问题。代价是开销略大,切换时可能看到短暂的地图空白,但对可靠性要求高的场景来说很值得。

4.5 我最终在项目里固定下来的切换函数

最终我采用的方案是C和D的折中,封装成一个通用函数:

fun MapView.switchTileSource(newSource: ITileSource) { val oldName = tileProvider?.tileSource?.name() // 同名不同URL时,先清掉目标源的磁盘目录 if (oldName == newSource.name()) { val targetDir = File(Configuration.getInstance().osdroidTileCache, newSource.name()) if (targetDir.exists()) { targetDir.deleteRecursively() } } tileProvider?.clearTileCache() setTileSource(newSource) // 关键:强制当前缩放级别重新计算瓦片范围 controller.zoomTo(zoomLevel) invalidate() }

为什么最后要加controller.zoomTo(zoomLevel),而不是只调invalidate()?因为zoomTo会触发一次缩放级别设置,MapView内部会重新计算当前可视区域覆盖的瓦片坐标集合并重新请求。这个动作恰好补上了"拖动地图才会触发刷新"的缺口,能让切换动作立刻生效。实测下来,这行代码比单纯invalidate()可靠得多。

5. 与"切换不更新"强相关的几个相邻坑

5.1 同名瓦片源:最隐蔽的坑

承接前面提到的,自定义多个TileSource时最容易犯的错就是name重复。你在代码里看到的是两个对象,但OSMDroid在磁盘缓存里只认识它们name对应的目录。两个源name如果相同,磁盘上就是同一套文件在反复背锅。

建议给自定义源起名时带上项目标识,例如com.myapp.map.satellite、com.myapp.map.terrain。这样不但能从根源避免目录污染,后期排查时看一眼磁盘目录就知道当前在加载哪套源。还有个附带好处:如果以后别人要复用你的离线包,也不会因为名字太通用而和别的地图源发生目录重叠。

5.2 Fragment/ViewPager里setTileSource被生命周期覆盖

如果你把MapView放在Fragment里,并且在ViewPager中左右滑动,很容易遇到"设置页切完回来又被重置"的情况。原因通常是Fragment的onCreateView每次重建时,MapView从XML布局里读取了默认的tilesource属性,或者代码里初始化MapView时又设置了默认源,把你在其他页面做的切换覆盖掉了。

排查方法是在日志里同时打印Fragment生命周期和切换函数调用栈:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Log.d("MapSwitch", "onViewCreated, stack=" + Log.getStackTraceString(Throwable())) }

如果发现切换函数总是被onCreateView或onResume里的初始化代码覆盖,就把"读取偏好并设置source"的逻辑统一放到一个只在用户真正做出选择时才执行的方法里,不要放在默认初始化路径上。否则你每次切回来都会经历"切到新图然后立刻被旧配置重置"的尴尬。

5.3 内网或离线环境下的"假不更新"

内网环境有个特殊坑:切换到一个从未加载过的瓦片源时,如果这台设备无法访问该源对应的内网地址或域名没解析,下载会失败;而OSMDroid在下载失败后如果磁盘里存在同名旧文件,可能直接读取它顶替。表现依然是不更新或显示很奇怪的图片。

这个场景下排查思路完全不一样。优先检查:

  • mapView.useDataConnection是否为true,如果为false,网络瓦片源不会发起任何请求。
  • 新源的BaseUrl在内网是否能ping通/能访问。
  • 错误日志里有没有UnknownHostException、SocketTimeoutException,有的话跟缓存无关,是网络配置问题。

实际项目里,我们定位到一次"切换离线地形图不更新",最后发现是tile文件拷进了assets,但代码在释放文件时用了旧版本目录结构,新坐标系统下文件全没匹配上。这个虽然和缓存无关,但排查手段是共通的——看磁盘目录里到底有没有对应文件。

5.4 一个实用的调试技巧:打印当前屏幕的瓦片URL

如果你以后还要深入排查OSMDroid地图源相关的问题,强烈建议留一个调试开关,把当前屏幕左上角和右下角的瓦片URL打出来。大致思路:

val topLeft = mapView.boundingBox val zoom = mapView.zoomLevel val x = TileSystem.getXSFROMlongitude(topLeft.lonWest, zoom) val y = TileSystem.getYSFROMLatitude(topLeft.latNorth, zoom) val url = tileProvider.tileSource.getTileURLString(TileSystem.getTileIndex(x, y, zoom)) Log.d("MapSwitch", "topLeft url=$url")

这样能实时确认屏幕上那一块瓦片到底来自哪个源、哪个URL,一眼看出是"还在用旧源"还是"新源URL但缓存文件错位"。这个技巧帮我节省了大量反复清缓存验证的时间,也推荐你调试时尽量依赖数据而不是肉眼。

最后再分享一个小习惯:所有地图源切换相关的代码,我都强制走统一的switch函数,不允许业务侧直接调setTileSource()。函数内部固定三步——清内存缓存、同名就清磁盘缓存、强制zoomTo刷新。上线两个月,这类"切换不更新"的问题再没出现过。

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

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

立即咨询