第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;             // 阻塞直到一帧完成
}

几个必须遵守的点:

  1. 每个线程一个 RunLoop。core 的回调投递到请求线程,没有 RunLoop 就不执行。
  2. MapMode::Static 不会自己重绘,必须显式 render();这也让每次请求的结果可复现。
  3. Map 与 RendererBackend 的创建销毁很贵。常驻服务应把 backend 与 frontend 池化,Map 复用(换相机 + loadStyleURL)比每请求新建快一个量级。
  4. 在 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、缓存命中、峰值内存),再调参数