第11章 - 离线地图与缓存机制
11.1 缓存、离线区域、离线数据源是三件事
MapLibre Native 里「离线」覆盖三套机制,混着用一定把容量算错:
| 机制 | 存储位置 | 容量约束 | 淘汰 | 典型场景 |
|---|---|---|---|---|
| 环境缓存 ambient cache | SQLite 的 resources / tiles 表 |
DEFAULT_MAX_CACHE_SIZE = 50 MB |
LRU,随时可删 | 在线看过的瓦片,二次启动更快 |
| 离线区域 offline region | 同一个 SQLite 的 regions + 两张归属表 |
只受磁盘限制,不占 50 MB | 不参与淘汰,只能显式删 | 用户手动下载一个城市 |
| 离线数据源 offline source | 独立 .mbtiles / .pmtiles |
文件大小 | 只读,无淘汰 | 随 App 分发的底图包 |
三者的共同点是都挂在 FileSource 这层统一取数入口上,区别在生命周期:环境缓存是可随时牺牲的加速层,离线区域是用户下载过、承诺不删的数据,离线数据源根本不是缓存而是一个只读数据库文件。
这不是概念洁癖。clearAmbientCache() 的删除语句是 DELETE FROM resources WHERE id NOT IN (SELECT resource_id FROM region_resources),清理时对离线区域一条都不碰;50 MB 统计也只算没被任何区域引用的行。理解这点才能解释「清完缓存磁盘没降」。
11.2 FileSource 抽象与请求瀑布
include/mln/storage/file_source.hpp 把取数通道分成七类:
enum FileSourceType : uint8_t {
Asset, // asset:// 打包资源
Database, // SQLite:环境缓存 + 离线区域
FileSystem, // file://
Network, // HTTP(S)
Mbtiles, // mbtiles://
Pmtiles, // pmtiles://
ResourceLoader // 代理,按顺序委派给上面几类
};
FileSourceManager 是单例,按 (类型, ResourceOptions) 元组缓存实例;ResourceLoader 返回的 MainResourceLoader 是所有请求的真正入口。
platform/default/src/mln/storage/main_resource_loader.cpp 的 request() 是一串写死的 if / else if:
Resource 请求
+--> assetFileSource -> asset:// 命中直接返回
+--> mbtilesFileSource -> mbtiles:// 命中
+--> pmtilesFileSource -> pmtiles:// 命中
+--> localFileSource -> file:// 命中
+--> databaseFileSource -> 先查缓存,再决定是否联网
+--> onlineFileSource -> 纯网络
顺序解释了几个现象:.mbtiles 优先于环境缓存,挂上去后同名瓦片不走网络;asset:// 最优先,打包进 App 的字体和雪碧图一定命中;file:// 排在缓存之前,所以绝对路径的样式文件绕过缓存。
走到 Database 分支还有两层逻辑。loadingMethod == CacheOnly 时只查库,查不到就失败,这是「宁可空白不联网」的语义;默认的 LoadingMethod::All 走 cache-then-revalidate:
tasks[req] = databaseFileSource->request(resource, [=, this](const Response& response) {
Resource res = resource;
if (!response.noContent) {
if (response.isUsable()) {
callback(response); // 仍在有效期,直接给
res.setPriority(Resource::Priority::Low); // 后续重验证降优先级
} else {
res.priorData = response.data; // 过期数据留着,等 304 复用
}
res.priorModified = response.modified;
res.priorExpires = response.expires;
res.priorEtag = response.etag;
}
tasks[req] = requestFromNetwork(res, std::move(tasks[req]));
});
Response::isUsable() 是 !mustRevalidate || (expires && *expires > now),isFresh() 是 expires ? *expires > now : !error:前者答「HTTP 上能不能用」,后者答「数据新不新」。服务端给 Cache-Control: must-revalidate 时过期数据一律作废,必须等 ETag 比对。网络结果回来后 requestFromNetwork 会 forward 给数据库顺手写缓存,这是二次启动变快的原因。
11.3 SQLite 数据模型:五张表,user_version = 6
schema 唯一来源是 platform/default/include/mln/storage/offline_schema.sql,offline_schema.js 把它转成 C++ 常量 offlineDatabaseSchema。改表要改 .sql 再重新生成,别手改产出的 .hpp。
| 表 | 唯一约束 | 存什么 |
|---|---|---|
resources |
UNIQUE(url) |
样式、source、glyphs、sprite image/json、image |
tiles |
UNIQUE(url_template, pixel_ratio, z, x, y) |
矢量与栅格瓦片 |
regions |
id 自增 |
区域定义 JSON 与用户 metadata BLOB |
region_resources |
UNIQUE(region_id, resource_id) |
资源归属,ON DELETE CASCADE |
region_tiles |
UNIQUE(region_id, tile_id) |
瓦片归属,ON DELETE CASCADE |
resources.kind 与 Resource::Kind 对齐:1 style、2 source、3 tile、4 glyphs、5 sprite image、6 sprite JSON、7 image。瓦片虽然 kind=3,实际存 tiles 表,OfflineDatabase 对 Kind::Tile 会分流到 getTile / putTile。
tiles 唯一键带 pixel_ratio,这是体积估算的关键:同一地理范围,pixelRatio = 2.0 存的是独立的一套 512 px 瓦片,和 1.0 的 256 px 瓦片不共享。
新建库在 createSchema() 之后写 PRAGMA user_version = 6。mergeOfflineRegions() 校验两侧 user_version,不一致就抛 Merge database has incorrect user_version,迁移函数只有 3、5、6 三条历史路径,跨版本合并必失败。连接建立时 PRAGMA foreign_keys = ON,journal_mode 用 DELETE、synchronous 用 FULL——比 WAL 慢,但离线下载动辄几百 MB,掉电安全优先。
直接查库排查:
sqlite3 offline.db 'SELECT id, length(definition), length(description) FROM regions;'
sqlite3 offline.db 'SELECT count(*) AS tiles, sum(length(data)) AS bytes FROM tiles;'
sqlite3 offline.db 'SELECT count(*) FROM region_tiles WHERE region_id = 1;'
sqlite3 offline.db 'PRAGMA user_version; PRAGMA page_size; PRAGMA page_count; PRAGMA freelist_count;'
page_size * page_count 是文件真实大小,减 freelist_count * page_size 才是有效数据量。删区域后文件不缩,就是 freelist 变大而 page_count 没变——SQLite 不会自动把页还给文件系统。
11.4 环境缓存:50 MB 怎么算、怎么淘汰
include/mln/util/constants.hpp 里三个直接相关的常量:
constexpr uint64_t DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB
constexpr uint32_t DEFAULT_MAXIMUM_CONCURRENT_REQUESTS = 20; // 网络并发
constexpr uint8_t DEFAULT_PREFETCH_ZOOM_DELTA = 4; // 预取跨度
ResourceOptions 默认 cachePath = ":memory:"、maximumSize 取上面这个值。C++ 嵌入时忘了 withCachePath(),缓存全在内存,进程退出即丢;Android/iOS SDK 会自己填好路径。
容量不是读文件大小,而是 initAmbientCacheSize() 对未被任何区域引用的行做 SUM(LENGTH(col) + ...):data 加上 id、url、z/x/y、etag、expires 这些字段的十进制长度。估算值比真实页占用略小,换掉每次写入都查 PRAGMA page_count 的开销。evict() 的循环:
newAmbientCacheSize = 当前统计值 + 本次新增字节 + pageSize
while (newAmbientCacheSize > maximumAmbientCacheSize) {
取最旧的 50 条 accessed 值(只看不属于任何 region 的行)
DELETE FROM resources WHERE ... AND accessed <= 阈值
DELETE FROM tiles WHERE ... AND accessed <= 阈值
用 bytesReleased() 扣减;一条都没删掉则返回 false
}
批量大小写死在语句里,LIMIT ?1 绑定 50;区域内的资源被 LEFT JOIN region_resources WHERE resource_id IS NULL 排除,永远不会被这个循环删到。
写入前先试 deflate:util::compress(*response.data),压缩结果更小才置 compressed = 1。文本类 JSON 和矢量瓦片压得动,已压过的 PNG/WebP 基本原样存。回收空间时 vacuum() 先看 PRAGMA auto_vacuum,不等于 2(INCREMENTAL)就设上并做一次全量 VACUUM,否则跑 PRAGMA incremental_vacuum。删区域、清缓存、改上限之后,autopack 为真(默认 true)都会触发;pack() 无条件 vacuum。
setMaximumAmbientCacheSize(size) 先改上限,超了立即 evict(0, stats) 再按需 vacuum;传 0 表示「没有离线区域时禁用缓存」。头文件注释说「上限 50 MB、区域占 40 MB,缓存实际只剩 10 MB」,那是预期语义;实现里淘汰只对非 region 行计数,真实可用空间取决于区域已占的页。
11.5 离线区域:定义、状态机、进度
include/mln/storage/offline.hpp 用 std::variant 包两种定义:
using OfflineRegionDefinition = std::variant<OfflineTilePyramidRegionDefinition,
OfflineGeometryRegionDefinition>;
| 类型 | 形状参数 | 适用 |
|---|---|---|
| 矩形 | styleURL、LatLngBounds bounds、minZoom、maxZoom、pixelRatio、includeIdeographs |
城市、园区,瓦片数好估 |
| 几何 | styleURL、Geometry<double> geometry、同上四项 |
行政边界、走廊 |
maxZoom 可以取正无穷,此时每个 source 按自身能提供的最大级别下满;pixelRatio 通常 1.0 或 2.0;includeIdeographs 为 true 才下 CJK 字形,schema 注释直说这会显著增大数据库体积。OfflineRegionMetadata 是 std::vector<uint8_t>,核心库完全不解释,绑定层只负责存取——注释明确说未来想让离线库跨平台可移植,平台私有格式会破坏这一点。
OfflineRegionDownloadState 只有 Inactive / Active,且与「数据齐不齐」正交:createOfflineRegion() 返回后是 Inactive,不显式 setOfflineRegionDownloadState(..., Active) 就永远不动。complete() 的实现是 completedResourceCount >= requiredResourceCount,而 requiredResourceCountIsPrecise 在下载早期为 false,那时 requiredResourceCount 只是下界;刚建区域那几秒两个数可能都是 0,complete() 返回 true——照这个隐藏进度条,用户会看到「下载完成但地图空白」。
三个回调 statusChanged()、responseError()、mapboxTileCountLimitExceeded() 都在数据库线程执行,绑定层必须转主线程,C++ 里也别在回调做重活。
体积估算:单级别瓦片数约 (Δlon/360 × 2^z) × (ΔmercatorLat/360 × 2^z),累计到 maxZoom 约为最高级别的 4/3;再乘两个系数——pixelRatio = 2.0 让每块像素数变 4 倍,includeIdeographs = true 额外拉 CJK 字形。按矢量瓦片 15–40 KB/块粗算,maxZoom = 15 的市域矩形在几百 MB 量级。
11.6 命令行:mbgl-offline 与 mbgl-cache
bin/offline.cpp 构建出 mbgl-offline,是最快验证「一个 bbox 要下多少数据」的工具:
| 参数 | 默认值 | 说明 |
|---|---|---|
-s, --style <URL> |
MapTiler 配置下首个默认样式 maptiler://maps/streets |
样式 URL |
-o, --output |
offline.db |
输出数据库 |
--north/--west/--south/--east |
37.2 / -122.8 / 38.1 / -121.7 | 矩形范围,四个一起给 |
--geojson |
无 | 用几何代替矩形 |
--minZoom / --maxZoom |
0 / 15 |
级别范围 |
--pixelRatio / --includeIdeographs |
1.0 / false |
像素比 / 是否下 CJK 字形 |
-t, --token |
环境变量 MLN_API_KEY |
API key |
-i, --input 与 -m, --merge |
无 | 合并模式,两个必须同时给 |
./build/mbgl-offline --north 39.70 --west 116.10 --south 39.55 --east 116.65 \
--minZoom 8 --maxZoom 14 --pixelRatio 1.0 \
--style https://demotiles.maplibre.org/style.json --output beijing.db
进度行来自 Observer::statusChanged():
Style: https://demotiles.maplibre.org/style.json
12 / 12 resources | 0 / 341 tiles (indeterminate); 1048576 bytes downloaded (52428 bytes/sec)
12 / 12 resources | 341 / 341 tiles | 18874368 bytes downloaded (3120000 bytes/sec)
Finished Download
(indeterminate) 说明 requiredResourceCountIsPrecise 仍为 false,341 是下界;样式和 source 落地后标记消失、数字才准。Ctrl-C 走 SIGINT 处理,把区域切成 Inactive 再退出,已下资源保留,这是断点续传的基础。
bin/cache.cpp 构建 mbgl-cache,往缓存库塞任意一条资源,复现「缓存命中但渲染错」:--type 取 style/source/tile/glyphs/sprite-image/sprite-json/image,-t 是 ETag,-e/-m 是 expires 与 modified 的时间戳,瓦片必须同时给 -x -y -z。
11.7 Android:OfflineManager 与 OfflineRegion
绑定在 org.maplibre.android.offline 包下,入口是单例 OfflineManager:
private val offlineManager by lazy { OfflineManager.getInstance(this) }
private fun downloadRegion() {
val bounds = LatLngBounds.Builder()
.include(LatLng(39.55, 116.10))
.include(LatLng(39.70, 116.65))
.build()
// 第 6 个参数 includeIdeographs:中文标注必须 true,否则离线后汉字缺字形
val definition = OfflineTilePyramidRegionDefinition(
STYLE_URL, bounds, 8.0, 14.0, /* pixelRatio = */ 2.0f, /* includeIdeographs = */ true)
offlineManager.createOfflineRegion(definition, "北京演示区|v3".toByteArray(),
object : OfflineManager.CreateOfflineRegionCallback {
override fun onCreate(region: OfflineRegion) {
region.setObserver(object : OfflineRegion.OfflineRegionObserver {
override fun onStatusChanged(status: OfflineRegionStatus) {
runOnUiThread { // 回调在数据库线程,必须切主线程
val pct = if (status.requiredResourceCount > 0)
status.completedResourceCount * 100 / status.requiredResourceCount else 0L
progressBar.progress = pct.toInt()
tvInfo.text = "${status.completedTileCount} 块 / " +
"${status.completedResourceSize / 1024 / 1024} MB"
}
if (status.isRequiredResourceCountPrecise && status.isComplete) {
region.setDownloadState(OfflineRegion.STATE_INACTIVE)
}
}
override fun onError(error: OfflineRegionError) {
Log.e(TAG, "offline ${error.reason}: ${error.message}")
}
override fun mapboxTileCountLimitExceeded(limit: Long) {
Log.w(TAG, "超出离线瓦片上限 $limit")
}
})
region.setDownloadState(OfflineRegion.STATE_ACTIVE)
}
override fun onError(error: String) = Log.e(TAG, error)
})
}
OfflineRegionError.reason 只可能是五个字符串常量:REASON_SUCCESS、REASON_NOT_FOUND、REASON_SERVER、REASON_CONNECTION、REASON_OTHER,按字符串分支,别比较枚举序号。
resources.displayMetrics.density 在 3x 屏是 3.0,而 pixelRatio 实际只有 1.0 / 2.0 两档,传 3.0 会按 3 倍像素下一批肉眼无差别的瓦片,直接写 2.0f。setDeliverInactiveMessages(false) 能在 App 退后台时暂停状态回调;区域列表用 listOfflineRegions(),OfflineRegion.metadata 就是当初塞进去的 ByteArray。环境缓存四个方法也在同一类上:invalidateAmbientCache()、clearAmbientCache()、setMaximumAmbientCacheSize()、packDatabase(),回调都是 FileSourceCallback。
11.8 C++ 与 Darwin 侧接口
嵌入自己的渲染程序时 DatabaseFileSource 是唯一入口,接口全异步、回调在数据库线程:
#include <mln/storage/database_file_source.hpp>
#include <mln/storage/file_source_manager.hpp>
#include <mln/storage/resource_options.hpp>
#include <mln/util/run_loop.hpp>
using namespace mln;
util::RunLoop loop;
auto options = ResourceOptions()
.withCachePath("/var/lib/app/offline.db")
.withMaximumCacheSize(200ull * 1024 * 1024);
auto source = std::static_pointer_cast<DatabaseFileSource>(
FileSourceManager::get()->getFileSource(FileSourceType::Database, options));
OfflineTilePyramidRegionDefinition def(
"https://demotiles.maplibre.org/style.json",
LatLngBounds::hull(LatLng(39.55, 116.10), LatLng(39.70, 116.65)),
8.0, 14.0, 2.0f, /* includeIdeographs = */ false);
source->createOfflineRegion(def, OfflineRegionMetadata{},
[&](expected<OfflineRegion, std::exception_ptr> result) {
if (!result) {
std::cerr << util::toString(result.error()) << '\n';
loop.stop();
return;
}
auto region = std::make_unique<OfflineRegion>(std::move(*result));
source->setOfflineRegionObserver(*region, std::make_unique<MyObserver>(loop));
source->setOfflineRegionDownloadState(*region, OfflineRegionDownloadState::Active);
});
loop.run(); // MyObserver::statusChanged 里 status.complete() 为真时 loop.stop()
Darwin 侧入口是 MLNOfflineStorage.sharedOfflineStorage,比 C++ 多一层「离线包」概念,常用成员:packs、addPackForRegion:withContext:completionHandler:、removePack:withCompletionHandler:、addContentsOfFile:(侧加载打好的 .db)、setMaximumAmbientCacheSize:、clearAmbientCacheWithCompletionHandler:、setMaximumAllowedMapboxTiles:、countOfBytesCompleted。
addContentsOfFile: 在发布场景很好用:CI 里用 mbgl-offline 生成 beijing.db 打进 bundle,首次启动直接导入,用户不用等下载。代价是 App 变大,且数据过期时间由打包时服务器的响应头决定。
11.9 参数速查
| 参数 | 位置 | 默认值 | 备注 |
|---|---|---|---|
DEFAULT_MAX_CACHE_SIZE |
util/constants.hpp |
50 MB | 环境缓存预算 |
cachePath |
ResourceOptions |
:memory: |
不设就是内存库 |
DEFAULT_OFFLINE_TILE_COUNT_LIMIT |
util/mapbox.cpp |
6000 | 仅对 Mapbox 源生效 |
DEFAULT_MAXIMUM_CONCURRENT_REQUESTS |
util/constants.hpp |
20 | 网络并发 |
DEFAULT_PREFETCH_ZOOM_DELTA |
util/constants.hpp |
4 | 预取跨度 |
autopack |
OfflineDatabase |
true |
删除/清理后自动 vacuum |
schema user_version |
offline_schema.sql |
6 | 合并要求两侧一致 |
includeIdeographs |
区域定义 | false |
中文标注必须开 |
11.10 排错清单
区域一直 0/0 或瞬间「完成」:requiredResourceCountIsPrecise == false 时计数只是下界。判定完成要 isRequiredResourceCountPrecise && isComplete 两个条件一起看,或改看 completedTileCount 的增长。
下载完了,离线打开还是空白:样式 URL 与下载时不一致(缓存按 url 精确匹配,多一个查询参数就不命中);pixelRatio 与设备解析出的不一致(tiles 唯一键里带它);maxZoom 小于用户放大到的级别。
sqlite3.OperationalError: attempt to write a readonly database:READ_ONLY_MODE_KEY(属性名 "read-only-mode",bool)被设上了,整个连接只读,离线下载全失败。查初始化代码有没有无条件设置它。
清了缓存磁盘没降:clearAmbientCache() 只删行不缩文件。靠 autopack(默认开)或显式 packDatabase();调用过 runPackDatabaseAutomatically(false) 就必须手动 pack。
Merge database has incorrect user_version:待合并库 PRAGMA user_version 与主库不一致,用 sqlite3 side.db 'PRAGMA user_version;' 确认后按主库版本重新生成,别强改数字。
离线包导入后中文变方框:includeIdeographs = false 时 CJK 靠客户端本地字体,部分设备没有对应字体就出方框。打开开关(体积涨)或在样式里指定随包字体栈。
11.11 生产建议
先定分发形态,再谈缓存。底图固定、范围可控(园区、城市、行业专题)就预置 .mbtiles 随包分发,省掉下载失败率和客服成本;底图常更新、范围由用户决定就走离线区域,并把「下载 200 MB 要多久」如实告诉用户。两者可并存:底图走 mbtiles://,业务专题走离线区域。
pixelRatio 只写 1.0 或 2.0。3x、4x 屏用 2.0 视觉够用,按真实 density 传会多存一倍以上瓦片,几乎没人看得出差异。
给每个区域记版本。metadata 里放 {"schema":3,"builtAt":"..."}。样式改版后旧区域瓦片仍有效(URL 没变),但字形、雪碧图可能失效;用 invalidateOfflineRegion() 强制重验证比删掉重下便宜,服务器返 304 时不重传数据。
容量按「区域数 × 单区域体积 × 1.3」估。重叠区域共享瓦片实际占用更少,但索引与 freelist 会多,1.3 是保守系数。数据库放应用私有目录,别放外置存储。
别在启动路径同步打开数据库。FileSourceManager::getFileSource() 是同步创建,放到 Application.onCreate() 之后的空闲线程预热。
CI 固化离线包产物。固定版本 mbgl-offline + 固定样式 URL 生成 .db,把文件 sha256 写进发布清单,线上问题才能追溯到具体哪版数据。
11.12 本章要点
| 结论 | 依据 |
|---|---|
| 环境缓存与离线区域同在 SQLite,但淘汰规则不同 | evict() 用 LEFT JOIN region_* 排除区域归属行 |
环境缓存 50 MB,超了按 accessed 最旧 50 条一批删 |
DEFAULT_MAX_CACHE_SIZE、evict() 的 LIMIT 50 |
| 请求顺序 asset → mbtiles → pmtiles → file → database → network | main_resource_loader.cpp 的 if/else 链 |
| 缓存过期但可用时先返回旧数据,再低优先级重验证 | Response::isUsable() 加 priorEtag 传递 |
新建区域是 Inactive,不调 setDownloadState 永远不动 |
createOfflineRegion 行为与 CLI 流程 |
requiredResourceCountIsPrecise 为假时不能判定完成 |
OfflineRegionStatus 注释与 complete() 实现 |
| schema 版本 6,跨版本合并直接失败 | PRAGMA user_version = 6、mergeDatabase 校验 |
| 删除数据不缩文件,要 pack / vacuum | vacuum()、packDatabase() |