ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OSMDroid切换底图不更新?缓存与刷新机制解析

OSMDroid切换底图不更新?缓存与刷新机制解析 前两天有位朋友私信我说他们的 Android 地图应用用了 OSMDroid最近加了一个“切换底图”的功能卫星图、街道图、离线地形图三个源切来切去。但实际跑起来就见了鬼——点击按钮切换后地图界面经常纹丝不动偶尔动一下也只刷新了半边甚至要杀进程重进 App 才能看到新地图。他在代码里明明调了setTileSource()为什么地图还是“不更新”这个场景我太熟了。OSMDroid 是 Android 上最常用的开源离线地图框架之一底层基于 OpenStreetMap 瓦片规范好处是免费、可定制、完全掌控瓦片源。但正因为“完全掌控”它对瓦片缓存、线程调度、视图刷新的要求也比普通地图 SDK 高得多。切换地图不更新十有八九不是 SDK 坏了而是我们没搞懂它的缓存和刷新机制。这篇文章我想把这几年调 OSMDroid 的经验整理出来重点讲清楚为什么setTileSource()之后地图不刷新怎么正确切换地图源、控制缓存遇到白屏、旧瓦片、内网离线包更新不生效之类的问题该怎么排查。无论你是刚接触 OSMDroid 的新手还是被这个问题折磨过几天的老开发这篇都值得看完。1. 先搞清楚你遇到的是哪种“不更新”1.1 症状速查从界面纹丝不动到新旧瓦片混叠先别急着改代码把“不更新”拆开看。我见过太多人把不同的问题混在一起排查结果越改越乱。根据症状分最常见的就这四种。第一种点击切换后界面完全没反应连加载圈都不转。这种通常是setTileSource()根本没生效或者生效了但没有触发视图重绘。第二种界面有反应但显示的还是旧瓦片而且你知道旧瓦片是从缓存里读出来的因为断网也能看到。这种是缓存键没变OSMDroid 认为你要的瓦片已经在缓存里了直接拿旧图给你。第三种切换后一片白屏或者只有几个孤零零的瓦片。这种经常是新源的最大缩放级别设置不对或者新源的瓦片格式、尺寸和当前地图不匹配。第四种地图上同时出现新旧两套瓦片一块一块地“马赛克式”混叠。这种多半是切换途中旧的下载线程还在回调新瓦片和旧瓦片交错返回。以上四种我都踩过而且有时候一次能踩中两三个。文章后面会分别给对应的处理办法但在这之前我们得先把根因理解透。1.2 根因分析缓存键、TileSource 与刷新链路的三角关系OSMDroid 的瓦片加载链路大致是这样的MapView 从当前 TileSource 拿到瓦片坐标先查内存缓存再查磁盘缓存都没有就发网络请求下载下载完成后写入磁盘缓存和内存缓存最后回调到绘制层把 Bitmap 画上去。这个链路里有一个最容易被忽略的概念TileSource 的名字name()就是缓存键的一部分。OSMDroid 磁盘缓存按“TileSource 名/缩放级别/列号/行号”来组织文件数据库缓存也是每个 TileSource 对应一个“名称.db”。你以为你切换了一个新地图源但如果两个源的名字相同或者你自定义源时没注意 name 的取值缓存系统就会认为“这个源还是原来那个”于是直接把旧瓦片返回给你根本不理会你底层 URL 是不是已经换了。这就解释了为什么很多人自定义 TileSource 时明明换了 URL切过去却还是旧图。根源在于你换了“内容”没换“身份标识”。另一个关键点是刷新链路。setTileSource()内部确实会触发 invalidate但 invalidate 只是请求 Android 在下一帧重绘 View不代表 TileProvider 手里的 TileSource 引用一定已经换了。如果你直接给 MapView 塞了一个自定义 MapTileProvider或者在某些生命周期节点比如地图不可见时切换就会出现“已经重新绘制了但绘制用的还是旧 TileSource”的现象。除了这两点还有线程问题。OSMDroid 的瓦片下载和写缓存都是异步的切换源的一瞬间旧源的下载线程可能还在跑旧瓦片写完后又把数据库锁占住了新源想写缓存就得排队看起来就像“卡住了”。所以解决“切换地图不更新”的思路并不是找到一个神奇 API而是把缓存、线程、刷新三个环节都收拾干净。2. 切换地图源的正确做法与缓存策略2.1 setTileSource 只解决一半问题先说基础。OSMDroid 官方切换地图源的写法很简单mapView.setTileSource(TileSourceFactory.MAPNIK);或者换成你自己的源mapView.setTileSource(myCustomTileSource); mapView.invalidate();这段代码在“源从未切换过”或“项目里只有两个不同 name 的源”时通常没问题。但如果你在实际项目里做过多次切换、动态切换、离线包切换就会发现它只解决了一半问题——它更新了 MapView 持有的 TileSource 引用但没解决缓存错配和异步线程遗留问题。这里还要注意一个容易踩的坑setTileSource()必须在主线程调用。如果你在子线程里切换轻则瓦片不刷新重则直接抛异常。很多同事在网络请求回调里直接切源结果就是“偶现不更新”其实就是主线程问题。另外如果项目中同时存在多个 MapView 实例或你手动给 MapView setTileProvider 过情况会更复杂。setTileSource()会调用现有mTileProvider.setTileSource()如果你的自定义 provider 没有正确实现这个逻辑新源自然不会生效。2.2 组合拳清理内存缓存、磁盘缓存、触发重绘我建议把切换地图源封装成一个固定流程顺序很重要。第一步先清理内存缓存让 MapView 丢弃当前显示的所有瓦片mapView.getTileProvider().clearTileCache();第二步调用setTileSource()切换源。第三步强制触发一次重绘和重排mapView.invalidate(); mapView.requestLayout();如果切源后你希望所有瓦片重新下载而不是从磁盘缓存里读还需要处理磁盘缓存。OSMDroid 的默认磁盘缓存在应用私有目录/data/data/你的包名/osmdroid/tiles/下每个 TileSource 对应一个.db文件。你可以按需删除对应源的文件File dbFile new File(Configuration.getInstance().getOsmdroidBasePath(), tiles/ newSource.name() .db); if (dbFile.exists()) { dbFile.delete(); }注意删除数据库文件前最好确认没有正在执行的读写线程否则可能出现 SQLite 数据库损坏。稳妥的做法是在onPause()或切换前等待一下或者用下面 2.3 的“版本号”方案从源头避开旧缓存。结合以上步骤一个相对完整的切换方法大致长这样private void safeSwitchTileSource(ITileSource newSource) { // 1. 清理内存缓存 mapView.getTileProvider().clearTileCache(); // 2. 可选清理磁盘数据库缓存强制重新下载 File dbFile new File( Configuration.getInstance().getOsmdroidBasePath(), tiles/ newSource.name() .db ); if (dbFile.exists()) { dbFile.delete(); } // 3. 切换源必须主线程 mapView.setTileSource(newSource); // 4. 手动触发刷新 mapView.invalidate(); mapView.requestLayout(); }这套组合拳能覆盖大多数“切源不更新”场景。如果还不够往下看。2.3 使用 TileSource 名称做“版本号”绕开旧缓存说实话直接删数据库有点暴力而且容易误伤正在写缓存的线程。更优雅的做法是把 TileSource 的名字当成一个“版本号”来用。OSMDroid 的磁盘缓存以 TileSource 的name()为目录或数据库标识只要名字变化旧缓存天然就“找不到”了地图就会老老实实去下载新瓦片。这在离线包更新、内网资源替换的场景下特别有用。我举个真实例子。项目里有一份内网部署的瓦片包地图服务端偶尔会更新内容。以前我直接沿用同一个 TileSource 名字结果服务端瓦片内容已经换了App 端却一直显示旧图因为缓存全命中。后来我把源名加上版本号String sourceName 内网街道图_v versionCode;每次服务端更新只要把 versionCode 变一下客户端不需要清任何缓存切源后自然强制加载新内容。这个方案我在几个生产项目里用得很稳。你可能会问那内存缓存怎么办不用担心name()变化后TileSource 对象本身就成了一个新对象旧的内存缓存项在 LRU 淘汰或 clearTileCache 时会被处理掉。磁盘缓存就更不用说了文件名和数据库标识都变了。3. 实战封装一套可以直接拿去用的切换方案3.1 自定义 TileSource 的正确姿势实际项目中很少直接用TileSourceFactory里的默认源更多的是自己定义源。基础用法是继承XYTileSource或者自己实现ITileSource。我建议优先用XYTileSource因为它已经把 URL 模板替换封装好了。new XYTileSource( 高德卫星图, 1, // 最小缩放级别 20, // 最大缩放级别 256, // 瓦片像素尺寸 .jpg, new String[] { https://webst01.is.autonavi.com/appmaptile?style6x{x}y{y}z{z} } )这样定义完直接传给setTileSource()就能用。注意两个细节第一最大缩放级别一定要给对。如果你给 19但实际切到一个只有到 15 级的源用户放大地图时会白屏反过来当前层级超过新源最大层级切过来那一刻看到的就是一片空白。第二URL 模板里的{x}、{y}、{z}是 XYTileSource 自动替换的但如果你遇到某些特殊源需要自定义请求头比如带 token、带 cookieXYTileSource就不够用了需要重写getTileURLString()并自己处理网络层 Header。OnlineTileSourceBase customSource new OnlineTileSourceBase( 自定义源, 1, 20, 256, .png, new String[] { https://your-server.com/tiles/ } ) { Override public String getTileURLString(long pMapTileIndex) { long x org.osmdroid.util.MapTileIndex.getX(pMapTileIndex); long y org.osmdroid.util.MapTileIndex.getY(pMapTileIndex); long z org.osmdroid.util.MapTileIndex.getZoom(pMapTileIndex); return getBaseUrl() z / x / y getImageFilenameEnding(); } };自定义源还有一个常见的坑在创建源时name 参数必须唯一且稳定。如果你同一个底图创建了两个 name 一样的源只是 URL 不同那切换的时候就会出现本文开头说的“旧缓存串场”。3.2 安全切换方法完整实现下面给一个我实际项目里用的封装注释比较全可以直接抄public class MapTileSwitcher { private final MapView mapView; public MapTileSwitcher(MapView mapView) { this.mapView mapView; } public void switchTo(ITileSource newSource, boolean forceRefresh) { if (newSource null) return; // 必须在主线程 if (Looper.myLooper() ! Looper.getMainLooper()) { mapView.post(() - doSwitch(newSource, forceRefresh)); return; } doSwitch(newSource, forceRefresh); } private void doSwitch(ITileSource newSource, boolean forceRefresh) { // 1. 清空内存缓存避免旧瓦片回调覆盖新瓦片 mapView.getTileProvider().clearTileCache(); // 2. 需要强制刷新时删除对应磁盘缓存数据库 if (forceRefresh) { File dbFile new File( Configuration.getInstance().getOsmdroidBasePath(), tiles/ newSource.name() .db ); if (dbFile.exists()) { dbFile.delete(); } } // 3. 切换源 mapView.setTileSource(newSource); // 4. 触发刷新 mapView.invalidate(); mapView.requestLayout(); } }需要说明的是clearTileCache()清的是内存缓存它是同步方法调用完再切换源基本不会出现“旧瓦片写到一半”的情况。但如果你之前用 SqlTileWriter 写过大量缓存删除.db文件时可能正好有写入线程在操作严谨一点可以先在onPause()里调用mapView.onPause()让瓦片线程挂起再删文件最后onResume()恢复。还有一个小技巧切换完如果发现地图没刷新可以主动改变一下缩放级别强制 OSMDroid 重新计算瓦片范围mapView.getController().setZoom(mapView.getZoomLevel() 0.01f);这个办法属于“物理唤醒”虽然不够优雅但在某些版本上实测非常好使。3.3 离线与内网瓦片更新后如何强制刷新很多做政务、工地的项目地图服务部署在内网瓦片包是 OTA 推给客户端的。这种场景下“切换地图不更新”还有个变种App 里已经下载了新瓦片文件但地图界面死活不重新加载。原因依然是缓存但这里不只是 OSMDroid 的缓存还可能涉及 Android 文件系统对同名文件的缓存。排查思路是这样的先确认新瓦片文件确实解压或下载到目标目录了看文件大小和修改时间然后检查 OSMDroid 的缓存数据库里是不是还保留着旧数据。两者都确认后再回到setTileSource()的调用链。我踩过的最隐蔽的坑是离线瓦片用ZipArchiveTileSource或自实现ArchiveTileSource读取切源后 OSMDroid 把 zip 包内的路径当缓存键的一部分。如果你只是把 zip 包内容更新了但 zip 文件名和内部结构没变OSMDroid 会认为缓存没变继续读旧数据。解决办法还是老一套要么改 TileSource 的 name要么在更新完文件后手动删掉对应.db缓存。有一个能少踩坑的经验在 App 里加一个“地图缓存清理”入口或者每次 App 冷启动时对比瓦片包版本号版本变化就自动清缓存。这样即使将来 OSMDroid 更新了缓存机制你的业务也不受影响。4. 常见问题与排查技巧实录4.1 白屏、黑屏、花屏与“只显示一半”切源后最常见的异常就是白屏。按照我前面的说法先检查新源的最大缩放级别。可以把当前缩放级别临时调小比如mapView.getController().setZoom(3)如果缩小后有瓦片显示基本就是级别配置问题。黑屏通常是绘制层异常。比如你用的是自定义 Overlay或者 MapView 的setLayerType设置不当。我遇到过真机上硬件加速导致的黑屏切源后画面撕裂最后通过在切换时短暂切换到软件层解决mapView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);等瓦片加载完成后再切回硬件层。这不是常规操作但确实解决过个别 ROM 上的渲染问题。“只显示一半”或“马赛克式混叠”多是线程竞争。旧源的下载线程还没结束新源的瓦片开始填充两者交错返回导致。这种情况可以在切换前调用mapView.getTileProvider().clearTileCache()同时确保接口回调里不要去操作 MapView让所有切换动作集中在一个方法里。如果并发很严重可以给切换方法加一个简单锁保证同一时刻只有一个切源操作在进行。4.2 切源后层级混乱、分辨率错乱有的地图源支持到 18 级有的只到 16 级。从 18 级切到只支持 16 级的源OSMDroid 不会自动把当前层级拉到 16它会尝试加载第 18 级的瓦片然后发现没有于是白屏或者在缩放动画期间出现一片模糊。解决方法是切换源后根据新源设置 MapView 的最大最小缩放级别mapView.setMinZoomLevel(newSource.getMinimumZoomLevel()); mapView.setMaxZoomLevel(newSource.getMaximumZoomLevel()); // 如果当前层级超出范围拉回来 if (mapView.getZoomLevel() newSource.getMaximumZoomLevel()) { mapView.getController().setZoom(newSource.getMaximumZoomLevel()); }还有个别源瓦片尺寸不是 256 的有 512 的。OSMDroid 的 MapView 默认是 256 的瓦片尺寸如果你切到一个 512 的源不更新或显示模糊都很正常。好在XYTileSource构造方法的第三个参数可以指定瓦片大小定义源时写对应值就行。但同一套 MapView 里混用 256 和 512 的源OSMDroid 的表现会不太稳定能避免就避免必要时不同尺寸的源放到不同 MapView 里处理。4.3 诡异的“不更新”线程、数据库锁与生命周期最后聊几个不那么容易一眼看穿的问题。第一个是 SqlTileWriter 的锁异常。OSMDroid 6.x 默认用 SqlTileWriter 写磁盘缓存它是单例。如果上一个源的下载线程还在写数据你这时删除.db文件或者切源可能出现attempt to re-open an already-closed object: SQLiteDatabase之类的异常。切换后地图就像死了一样不再请求任何瓦片。遇到这种问题先检查 logcat 里有没有 SQLite 相关的红色异常有的话最简单的处理是在onPause()中让 MapView 暂停瓦片线程再执行切源和删缓存切完再onResume()。第二个是 Android 系统的 WebView 组件更新导致的地图不刷新问题这在我项目里出现过一次。当时我们主界面嵌了一个 WebView 做业务 H5OSMDroid 是原生 View两者没直接关系但 WebView 更新后整个 App 的硬件渲染出现异常地图切源后不重绘。最终是给 MapView 所在的布局设置了软件层过渡或者干脆重建 MapView 才解决。这个问题比较玄不用深究遇到时知道有这种可能性就行。第三个是生命周期问题。如果你在onPause()之后比如弹窗、跳转页面时调用了切源OSMDroid 的瓦片线程已经挂起切换动作不会立即生效等你返回页面恢复时界面上显示的还是旧地图。这种情况不倒代码在onResume()里再补一次强制刷新即可Override public void onResume() { super.onResume(); mapView.onResume(); if (needRefreshOnResume) { mapView.invalidate(); needRefreshOnResume false; } }还有一个很笨但很有效的排查技巧调试时给 MapView 套一个 BackgroundTint 或者重写onDraw()打日志确认重绘到底有没有发生。很多时候不是“地图没更新”而是“它根本就没被要求重绘”。把重绘和 TileSource 切换两件事分开验证比盯着屏幕猜要快得多。最后我再分享一个个人体会。刚接触 OSMDroid 那会儿我每次遇到“不更新”就想换一个新的 MapView 实例重建重建确实有效但代价是地图状态丢失、内存抖动甚至闪屏。后来才明白OSMDroid 的切换地图本质上是一个“缓存一致性”问题源、缓存、刷新三者的关系理顺了大部分诡异现象都能解释清楚。如果你现在还在被这个问题困扰我建议按这个顺序做先打印当前 TileSource 的 name 和缓存路径确认你的源身份标识没变然后在切换方法里把 clearTileCache 放在 setTileSource 前面还不行就试试给源名加版本号。这套流程至少能解决九成的“切换地图不更新”。剩下的一成多半是线程和生命周期的锅。把setTileSource收敛到主线程把切源操作集中到一个方法必要时在 onPause、onResume 之间加一层控制基本就能稳住。真要是遇到某个 ROM 上的诡异渲染问题别硬刚软件层过渡、重建 MapView 都是可接受的退路。毕竟我们最终要的是用户能稳定看到正确的地图而不是跟 SDK 较劲。
返回列表