第20章 - 实战案例与生产实践
20.1 两个可落地的形态
MapLibre Native 的 core 是渲染库,不是产品。实际工程里最常见的两种集成形态:
| 形态 | 形态说明 | 典型技术栈 |
|---|---|---|
| 离线地图 App | 打包或按需下载区域瓦片,弱网/无网出图 | Android/iOS 绑定 + 离线数据库 |
| 桌面/服务端渲染服务 | 接收 style + 相机参数,返回 PNG | C++ core + HeadlessFrontend + HTTP 服务 |
两种形态共用同一套 core,差别只在 MapMode、FileSource 组合与资源生命周期。下面各给一个端到端骨架。
20.2 案例一:离线地图 App
20.2.1 数据准备
离线能力由 OfflineTilePyramidRegionDefinition 定义:一个 style URL、一个地理范围、一个缩放级别区间、一个设备像素比。
#include <mln/storage/offline.hpp>
// 区域定义:style、bounds、minZoom、maxZoom、pixelRatio、是否包含 CJK 字形
mln::OfflineTilePyramidRegionDefinition region(
"https://example.com/style.json", // styleURL
mln::LatLngBounds::hull({39.86, 116.28}, {39.96, 116.46}), // 北京城区
10.0, // minZoom
16.0, // maxZoom
2.0f, // pixelRatio
true // includeIdeographs
);
三条约束来自头文件注释,踩过的人不少:
minZoom与maxZoom都必须 ≥ 0,且maxZoom >= minZoom。maxZoom可以是无穷大,表示”每个数据源取到它自己的最大级别”。pixelRatio通常取 1.0 或 2.0,取 2.0 会把 @2x 瓦片一起下载,体积约 4 倍。
下载本身是异步的,通过 OfflineRegionObserver 拿进度与错误:
#include <mln/storage/offline.hpp>
class ProgressObserver final : public mln::OfflineRegionObserver {
public:
void statusChanged(mln::OfflineRegionStatus status) override {
if (status.complete()) {
onDone(status.completedResourceSize);
return;
}
// requiredResourceCountIsPrecise 为 false 时总数只是下界,进度条会跳
const double ratio = status.requiredResourceCountIsPrecise
? static_cast<double>(status.completedResourceCount) / status.requiredResourceCount
: -1.0; // 未知,显示不确定态
onProgress(ratio, status.completedTileCount, status.requiredTileCount,
status.completedResourceSize);
}
void responseError(mln::Response::Error error) override {
// 多数错误会自动退避重试,这里只做上报,不要立刻放弃整个区域
onError(static_cast<int>(error.reason));
}
};
三个字段值得单独记:requiredResourceCountIsPrecise 在下载早期是 false(总数只是下界),此时进度条会来回跳,应显示”不确定进度”;complete() 的定义是 completedResourceCount >= requiredResourceCount,不是”没有待下载任务”;statusChanged 在数据库线程执行,绑定层必须把它转到主线程再更新 UI。UI 上不要只显示”下载中”,要把”已下载瓦片数 / 总数 / 已占字节”都暴露出来——离线包体积动辄几十上百 MB,没有进度用户会以为卡死。
20.2.2 运行时预加载
App 在线时用地域缓存把热点区域的瓦片提前拉下来,进山/进地铁后仍可出图:
#include <mln/storage/database_file_source.hpp>
#include <mln/storage/file_source_manager.hpp>
#include <mln/storage/resource_options.hpp>
auto options = mln::ResourceOptions()
.withCachePath("/data/data/com.example.app/files/cache.db") // 缓存数据库
.withAssetPath("/data/data/com.example.app/files/assets/") // asset:// 根目录
.withMaximumCacheSize(512ull * 1024 * 1024); // 512 MB 上限
auto* manager = mln::FileSourceManager::get();
auto fs = manager->getFileSource(mln::FileSourceType::Database, options);
withCachePath 与 withMaximumCacheSize 是最关键的两个参数:前者决定离线数据的落盘位置,后者决定”缓存满了之后 LRU 淘汰”的上限。上限太小会导致已下载区域被淘汰,用户体验是”昨天还好用,今天就白了”。
20.2.3 渲染与生命周期
启动 → 创建 FileSource(注册工厂)
→ 创建 MapOptions(Continuous)
→ 创建 Map + RendererFrontend
→ loadStyleURL
→ 首帧回调里隐藏 loading
进入后台 → 暂停渲染循环 / 释放部分 GPU 资源
回到前台 → 恢复渲染,invalidate
退出 → Map 析构 → 关闭 database,提交 WAL
MapMode::Continuous 是交互式 App 的默认选择:数据到达、相机变化都会触发重绘。只有截图/导出场景才用 Static。
20.2.4 体积估算
离线包体积 ≈ 瓦片数量 × 单瓦片平均体积 × 像素比因子。瓦片数量随级别按 4 倍增长,所以 maxZoom 每加一级,体积大约翻两番——这是离线功能最常见的设计失误来源:
城市级 bbox(约 0.2° × 0.2°)在后瓦片数近似:
z=12 约 1 张 z=14 约 16 张
z=13 约 4 张 z=15 约 64 张 z=16 约 256 张
@2x(pixelRatio=2)把每张瓦片换成 4 张高分辨率瓦片,体积再 ×4
所以”支持到 16 级”的承诺,实际体积是”支持到 14 级”的 20 倍以上。产品侧要按级别给用户选择,默认只下到 14,热点区域再允许 16。
20.3 案例二:桌面渲染服务
20.3.1 直接用 mbgl-render
仓库自带 bin/render.cpp,构建出 mbgl-render,命令行参数就是一套最小渲染 API:
./build-linux-opengl/bin/mbgl-render \
--style https://example.com/style.json \
--output /tmp/out.png \
--bounds "39.86 116.28 39.96 116.46" \
--width 1024 --height 768 \
--ratio 2 \
--cache /var/cache/maplibre/render-cache.sqlite \
--assets /srv/maplibre/assets
参数与源码一一对应:
| 参数 | 含义 | 默认值 |
|---|---|---|
-s, --style |
style URL / 文件 / -(stdin) |
MapTiler 默认样式 |
-o, --output |
输出文件,- 表示 stdout |
out.png |
-w, --width / -h, --height |
输出尺寸(像素) | 512 × 512 |
-r, --ratio |
pixelRatio | 1 |
-z, --zoom -x, --lon -y, --lat |
相机 | 0 |
--bounds |
north west south east,与 center 二选一 |
— |
-b, --bearing -p, --pitch -R, --roll |
姿态角 | 0 |
-c, --cache |
SQLite 缓存路径 | cache.sqlite |
-a, --assets |
asset:// 根目录 |
. |
-t, --apikey |
API Key,也可用环境变量 MLN_API_KEY |
空 |
-m, --mode |
static / tile / continuous |
static |
--backend |
渲染后端 | 构建时默认 |
服务化时的关键取舍:
- 进程模型:
mbgl-render是一次性进程,每次调用都要初始化后端与 shader(几十到几百毫秒)。高并发场景要么池化进程,要么自己写一个常驻服务复用RendererBackend与HeadlessFrontend。 - 缓存共享:多个进程共用一个 SQLite 缓存文件会锁竞争。按进程分片(
cache-{pid}.sqlite)或统一到一个只读的离线库 + 每进程独立小缓存。 - 字体与 sprite:
--assets必须指向包含 glyph/sprite 的目录,否则中文标签会缺字。生产环境把字体预处理进离线包,比每次请求远程字体稳定。
20.3.2 常驻服务骨架
#include <mln/gfx/headless_frontend.hpp>
#include <mln/map/map.hpp>
#include <mln/map/map_options.hpp>
#include <mln/storage/resource_options.hpp>
#include <mln/util/run_loop.hpp>
mln::PremultipliedImage renderOne(const std::string& styleURL,
const mln::CameraOptions& camera,
uint32_t width, uint32_t height) {
mln::util::RunLoop loop; // 每线程一个
mln::HeadlessFrontend frontend({width, height}, 1.0f);
mln::Map map(frontend,
mln::MapObserver::nullObserver(),
mln::MapOptions()
.withMapMode(mln::MapMode::Static)
.withSize(frontend.getSize())
.withPixelRatio(1.0f),
mln::ResourceOptions()
.withCachePath("/var/cache/maplibre/render-cache.sqlite")
.withAssetPath("/srv/maplibre/assets"));
map.loadStyleURL(styleURL);
map.jumpTo(camera);
return frontend.render(map).image; // 阻塞直到一帧完成
}
几个必须遵守的点:
- 每个线程一个
RunLoop。core 的回调投递到请求线程,没有RunLoop就不执行。 MapMode::Static不会自己重绘,必须显式render();这也让每次请求的结果可复现。Map与RendererBackend的创建销毁很贵。常驻服务应把 backend 与 frontend 池化,Map复用(换相机 +loadStyleURL)比每请求新建快一个量级。- 在
BackendScope内做 GPU 资源操作,服务端多线程时尤其要注意上下文归属。
20.4 上线检查清单
| 维度 | 检查项 | 不合格的表现 |
|---|---|---|
| 版本 | 平台 tag 与根仓库版本区分清楚(android-v* / ios-v* 等) |
按根 package.json 推断 SDK 版本 |
| 命名 | 头文件 <mln/...>,目标 mbgl-*,选项 MLN_* |
混用导致编译失败 |
| 渲染后端 | 与目标设备匹配(移动端 GLES/Vulkan/Metal) | 首帧全黑、shader 编译失败 |
| 离线 | 缓存上限、区域定义、进度与取消 | 缓存被淘汰后地图变白 |
| 资源 | glyph/sprite/字体随包或可控 CDN | 中文标签缺字 |
| 线程 | 一个线程一个 RunLoop,相机操作投递到渲染线程 |
偶发崩溃、相机跳变 |
| 内存 | 长时平移缩放内存稳定 | RSS 单调上升 |
| 生命周期 | 进后台暂停、退前台恢复、退出关数据库 | 后台耗电、数据库损坏 |
| 安全 | API Key 走环境变量或配置,不入库 | 密钥泄漏 |
| 可观测 | 瓦片失败率、首帧耗时、渲染耗时、缓存命中率 | 出问题只能靠猜 |
20.5 排错表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 首帧全黑 | BackendScope 缺失 / 尺寸为 0 / map 未设相机 |
补 scope;窗口尺寸为 0 时跳过;显式 jumpTo |
| 地图局部空白 | 瓦片请求失败或离线库缺数据 | 看瓦片错误回调;检查离线区域是否覆盖该级别 |
| 中文标签缺字 | glyph/sprite 资源缺失 | 检查 assetPath;离线包加 includeIdeographs |
| 缩放卡顿 | 瓦片预取不足 / 后端为软件渲染 | 调 prefetchZoomDelta;确认 GPU 后端已启用 |
| 内存持续上涨 | 图层/瓦片未释放;缓存无上限 | 设 withMaximumCacheSize;检查重复创建 Map |
| 异步回调不执行 | 线程没有 RunLoop |
回调投递回请求线程 |
| 取消后仍回调 | AsyncRequest 未真正中断 |
实现里 abort 底层请求并跳过回调 |
| 服务并发下崩溃 | 多线程共享一个 backend 未加约束 | 按线程池化 backend,或串行化 |
| 切换 style 后花屏 | sprite/glyph 异步未就绪 | 等 onDidFinishLoadingStyle 再渲染 |
| 后台耗电 | Continuous 模式仍在跑循环 | 进后台暂停渲染循环 |
20.6 生产上的取舍
要不要自己写常驻服务。 单机 QPS 低于 1,直接用 mbgl-render 进程最省事;QPS 上到几十,进程启动开销占比过高,必须做常驻服务并池化 backend。先量“单次渲染耗时”,再决定。
要不要全量离线。 离线包体积 = 瓦片数 × 平均瓦片体积 × pixelRatio 因子。城市级 10–16 级、@2x 通常是几十 MB 到几百 MB。只对“无网可用”有硬需求的场景做全量,其余用“在线优先 + 地域缓存”。
矢量 vs 栅格。 栅格瓦片离线包简单直接,但缩放时清晰度受限;矢量瓦片包更小、样式可换,代价是客户端需要字体与 sprite,且首帧要做几何解析。移动端做多主题切换选矢量,做固定底图选栅格。
渲染后端选型。 OpenGL 兼容性最好,Vulkan/Metal 性能与特性更好但设备覆盖与驱动质量参差。移动端按系统版本分档,桌面端优先 Vulkan/Metal,CI 保持两个后端都跑。
可观测性优先于调优。 首帧耗时、瓦片 P95、缓存命中率、峰值内存四个指标先埋上,再看 prefetchZoomDelta、tileLod*、缓存大小这些参数。没有数据时的性能调优基本靠猜。
20.7 本章小结
| 要点 | 结论 |
|---|---|
| 两种形态 | 离线 App(Continuous + 离线库)、渲染服务(Static + HeadlessFrontend) |
| 离线定义 | OfflineTilePyramidRegionDefinition:style + bounds + zoom 区间 + pixelRatio |
| 缓存 | withCachePath + withMaximumCacheSize,上限过小会被 LRU 淘汰 |
| 命令工具 | mbgl-render 一次一进程,参数覆盖 style/相机/尺寸/缓存/资源目录 |
| 常驻服务 | 池化 backend/frontend,复用 Map,每线程一个 RunLoop |
| 首帧 | Static 模式需显式 render();BackendScope 不能缺 |
| 资源 | glyph/sprite/字体必须随包或可靠 CDN,否则中文缺字 |
| 上线 | 版本、后端、离线、线程、内存、生命周期、安全、可观测八项检查 |
| 调优顺序 | 先埋指标(首帧、瓦片 P95、缓存命中、峰值内存),再调参数 |