
先说结论这个问题十有八九不是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打到logcatval 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或者上述方案都因为其他自定义逻辑生效不彻底可以直接暴力重建整个providerval 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刷新。上线两个月这类切换不更新的问题再没出现过。