第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()