第12章 - 跨平台绑定与生态集成
12.1 一个 core,若干层薄绑定
MapLibre Native 的仓库是 monorepo:include/ 与 src/ 是跨平台 C++ core,platform/ 下每个目录是一种交付形态。理解生态集成只需要记住一件事——SDK 层很薄,厚的是 core。
应用层 (Android App / iOS App / Node 服务 / Qt 桌面 / 自定义渲染器)
|
+-- platform/android Kotlin/Java + JNI -> AAR
+-- platform/ios|macos Objective-C/Swift + core -> XCFramework / SPM
+-- platform/node N-API (C++) -> .node 原生模块
+-- platform/qt Qt/QML 集成 -> CMake 构建
+-- platform/glfw 桌面 demo / 冒烟测试 -> 可执行文件
+-- platform/ohos NAPI/XComponent (实验) -> OpenHarmony 应用
|
core: include/mln + src/mln (actor / style / tile / renderer / gfx / storage)
绑定层要做的事只有四类:把平台的线程与事件循环接到 core 的 RunLoop,把平台文件系统与网络接到 FileSource,把平台图形上下文接到 RendererBackend,把 core 的状态通知转回主线程。其余(样式解析、瓦片调度、标注布局、渲染管线)全在 core 里。
这也解释了为什么各平台功能总有几个月的落差:core 加一个 API,四个绑定要分别透出。看某个功能「有没有 iOS 版」时,先查 include/mln/style/style.hpp 有没有,再查各平台目录有没有转出。
12.2 版本口径:没有统一的 5.4.0
上游按平台打 tag,仓库里不存在一个叫 5.4.0 的全局版本:
| 产物 | tag 形态 | 示例 |
|---|---|---|
| Android | android-v* |
android-v13.5.4 |
| iOS | ios-v* |
ios-v7.0.0 |
| Node | node-v* |
node-v6.4.1 |
| Qt | qt-v* |
qt-v2.0.1 |
仓库根 package.json 的 version 是 5.4.0,它服务的是根目录的 Node 工具链(脚本、样式规范版本),不等于某个 SDK 的发布号。写依赖时按平台读各自的 tag,别在 issue 里写「5.4.0 的 Android 包」。
同理,本教程以 main 分支为准。main 上命名空间已经完成一次重命名:
// 旧(早期版本,目录 include/mbgl)
#include <mbgl/style/style.hpp>
namespace mbgl { class Map; }
// 新(main,目录 include/mln)
#include <mln/style/style.hpp>
namespace mln { class Map; }
改名的动因是 mbgl 与旧 Mapbox GL 生态纠缠太深,mln 把「MapLibre Native」独立出来。迁移是机械替换:namespace mbgl → namespace mln、<mbgl/...> → <mln/...>、MBGL_ 宏前缀在某些位置保留(如 Android 的 MBGL_MODULE_OFFLINE_DISABLE),不要全局无脑替换。
12.3 绑定层的四个契约
12.3.1 RunLoop:线程归属
core 的 Map 对象由创建它的线程独占。core 调度后台任务(每个 Style 四个 worker 线程做瓦片解析、文本整形、几何处理),结果通过 RunLoop 回到主线程。绑定的职责是提供一个「空闲时执行回调」的 run loop:Android 用 Looper/Choreographer,iOS 用 CFRunLoop,Qt 用 QEventLoop,Node 用 libuv 的 uv_async_t。
include/mln/util/run_loop.hpp 里的 util::RunLoop 是个同步实现的简易版本,只适合命令行工具。渲染循环由各平台自己驱动:platform/glfw 的 glfw_view.cpp 里是 while(!glfwWindowShouldClose) { view->run(); },Android 是 Choreographer 回调,iOS 是 CADisplayLink。
12.3.2 FileSource:取数
每个平台都要实现或注册这几类 FileSource:网络(Android 的 HttpFileSource 走 OkHttp,iOS 走 NSURLSession,Qt 走 QNetworkAccessManager)、本地文件、asset(asset://)。SQLite 那一类由 platform/default 提供,各平台复用。
Android 侧的注册在 platform/android/MapLibreAndroid/src/cpp/file_source.cpp,通过 FileSourceManager::get()->registerFileSourceFactory(...) 把平台实现挂上。自定义网络层(加签名、换 CDN)就改这里,不用动 core。
12.3.3 RendererBackend:图形上下文
include/mln/gfx/renderer_backend.hpp 定义的抽象只有几个纯虚函数:getDefaultRenderable()、initShaders()、createContext(),再加受保护的 activate() / deactivate()。ContextMode 分 Unique 与 Shared:Shared 表示 GL/Metal 上下文与宿主程序共用,可以省资源但要小心状态污染。
平台实现乐观地分成一组文件:platform/glfw/glfw_gl_backend.cpp、glfw_metal_backend.mm、glfw_vulkan_backend.cpp;platform/android/MapLibreAndroid/src/cpp/android_gl_renderer_backend.cpp、android_vulkan_renderer_backend.cpp、android_webgpu_renderer_backend.cpp;iOS 是 platform/ios/src/MLNMapView+Metal.mm、MLNMapView+OpenGL.mm、MLNMapView+WebGPU.mm 三个分类文件,公共部分在 platform/darwin/src/MLNBackendResource.mm。
12.3.4 通知:core → 主线程
MapObserver、RendererObserver、Log 的回调都在 core 的线程上触发。绑定必须自己排队回主线程,否则就是随机崩溃。Android 的 MapRendererRunnable、iOS 的 MLNMapView 内部队列都在干这件事。
12.4 Node 绑定:无窗口渲染
platform/node 是最好上手的绑定,因为不需要图形界面。它导出 N-API 原生模块 @maplibre/maplibre-gl-native,包版本在 platform/node/package.json 里是 6.5.0-pre.1(1.x 时代起这个包就没跟平台 tag 对齐,读 changelog 比读版本号可靠)。支持 Ubuntu 24.04、macOS、Windows 的 amd64/arm64,Node 22/24/26。
npm install @maplibre/maplibre-gl-native sharp
12.4.1 静态图最小示例
const mbgl = require('@maplibre/maplibre-gl-native');
const sharp = require('sharp');
const map = new mbgl.Map({
// 所有资源请求都从这里出去,返回 { data, modified, expires, etag }
request: (req, callback) => {
// req = { url, kind },kind: 1 style 2 source 3 tile 4 glyphs 5 spriteImage 6 spriteJSON
fetch(req.url)
.then((r) => r.arrayBuffer())
.then((buf) => callback(null, { data: Buffer.from(buf) }))
.catch((err) => callback(err));
},
ratio: 2, // 默认 1
mode: 'static', // 'static' | 'tile'
});
map.load(require('./style.json'));
map.render({
zoom: 12, center: [116.39, 39.9], bearing: 0, pitch: 0,
width: 1024, height: 768,
}, (err, buffer) => {
if (err) throw err;
map.release(); // 必须释放,否则原生内存不回收
sharp(buffer, { raw: { width: 1024, height: 768, channels: 4 } })
.png().toFile('out.png').then(() => console.log('out.png'));
});
RenderOptions 的默认值来自 index.d.ts:zoom 0、width/height 512、center [0,0]、bearing 0、pitch 0、classes []。渲染结果是 RGBA 裸缓冲,宽高各乘 ratio,所以 ratio: 2 + width: 1024 得到 2048×2048 的位图——这是最容易算错的一点。
12.4.2 运行时改样式
Map 暴露的方法覆盖了常用的 runtime styling,全是同步调用:
| 方法 | 作用 |
|---|---|
addSource(id, source) / removeSource(id) |
添加/删除数据源 |
addLayer(layer, beforeId?) / removeLayer(id) |
图层增删,beforeId 控制插入位置 |
setLayoutProperty(id, name, value) |
布局属性,值可以是表达式数组 |
setFilter(id, filter) |
过滤器 |
setLayerZoomRange(id, min, max) |
图层可见级别 |
addImage(id, image, options) / removeImage(id) |
雪碧图 |
setCenter / setZoom / setBearing / setPitch / setSize |
相机 |
setGlobalStateProperty(name, value) |
global-state 表达式 |
addSource + addLayer 之后不需要重新 load,下一次 render 就生效。这在服务端批量出图时很有用:一个 Map 实例复用样式,只换 source 数据。
12.4.3 批处理与内存
每次 render 都会走完整的瓦片调度与布局,Map 实例不共享状态。批量出 1000 张图时的稳妥做法是并发 2–4 个进程、每进程一个 Map,每个实例出几十张后 release() 重建。单进程串行复用能省内存,但符号布局缓存与图片缓存会缓慢增长,长跑进程要定期重建。
12.5 Android:JNI 那层做了什么
platform/android/MapLibreAndroid/src/cpp/main.cpp 只有几行:
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *) {
mln::android::registerNatives(vm);
return JNI_VERSION_1_6;
}
registerNatives 在 jni_native.cpp 里逐个类注册方法表,Kotlin 侧的 external fun 就对应这里的函数指针。想确认某个 API 是「Kotlin 独有」还是「core 透出」,看这两处:Kotlin 类里有没有 external,.cpp 里有没有对应注册。
编译期开关用宏而不是 CMake option 控制模块:MBGL_MODULE_OFFLINE_DISABLE、MBGL_MODULE_SNAPSHOT_DISABLE 分别把离线与快照模块从注册表里摘掉,减小 AAR。需要裁剪体积时改这个,别删 Kotlin 文件——external 声明还在,一调就 UnsatisfiedLinkError。
构建产物按 flavor 分(docs/mdbook/src/platforms/android/android-build-flavors.md):
| flavor | 后端 | 插件 API | Maven 坐标 |
|---|---|---|---|
opengl |
OpenGL ES | 否 | org.maplibre.gl:android-sdk-opengl |
vulkan |
Vulkan | 否 | org.maplibre.gl:android-sdk-vulkan(默认) |
multiBackend |
双后端运行时选 | 否 | org.maplibre.gl:android-sdk-vulkan-opengl |
*WithPluginApi |
同上 | 是 | ...-with-plugin-api |
每个 flavor 只编译一个渲染后端,multiBackend 例外——它同时装 OpenGL ES 与 Vulkan 的 native 库,运行时挑一个。插件 API 不支持 WebGPU。
12.6 iOS / macOS:Objective-C 包装
Apple 平台用 MLN 前缀(旧版是 MGL),公开头文件在 platform/ios/src/ 与 platform/macos/src/,共享部分在 platform/darwin/src/。分层是:MLNMapView(UIView 子类)持有 MLNMapView+Impl,Impl 再持有 core 的 Map 与一个 backend。三个渲染后端各一个分类文件:MLNMapView+Metal.mm、MLNMapView+OpenGL.mm、MLNMapView+WebGPU.mm,构建时按 CMake 变量二选一。
发行渠道是 URL:CocoaPods 的 MapLibre pod、Swift Package maplibre-gl-native-distribution。包管理器的版本号跟 ios-v* tag 走,与本仓库根 package.json 无关。
12.7 Qt 与 GLFW:桌面与嵌入
Qt 绑定在 platform/qt,用 CMake 构建(-DMLN_WITH_QT=ON)。它负责把 Qt 的 QNetworkAccessManager、QImage、QEventLoop 接到 core:platform/qt/src/mln/http_file_source.cpp、image.cpp、run_loop.cpp。Qt 适合桌面 GIS 应用,但渲染循环与 QML 侧组件要自己管。
platform/glfw 不是给人用的 SDK,是冒烟测试与参考实现:一个可执行程序,验证「core + 某个后端」能不能跑起来。它有两个价值:看最小初始化代码(glfw_view.cpp),以及移植新平台时当模板。
cmake --preset linux-opengl # 生成构建目录
cmake --build build-linux-opengl --target mbgl-glfw
./build-linux-opengl/mbgl-glfw # 打开一个可拖动的窗口
12.7.1 构建开关矩阵
CMakeLists.txt 顶部一组 MLN_* 选项(旧版本前缀是 MBGL_)决定编哪些后端与能力:
| 选项 | 默认 | 作用 |
|---|---|---|
MLN_WITH_CORE_ONLY |
OFF |
只编 core,不编平台代码 |
MLN_WITH_OPENGL |
OFF |
OpenGL 后端 |
MLN_WITH_EGL |
OFF |
EGL 后端(headless Linux/Android) |
MLN_WITH_VULKAN |
OFF |
Vulkan 后端 |
MLN_WITH_METAL |
OFF |
Metal 后端(iOS/macOS) |
MLN_WITH_WEBGPU |
OFF |
WebGPU 后端,配合 MLN_WEBGPU_IMPL_DAWN 或 MLN_WEBGPU_IMPL_WGPU |
MLN_WITH_GLFW |
ON |
GLFW 桌面目标 |
MLN_WITH_QT |
OFF |
Qt 绑定 |
MLN_WITH_NODE |
OFF |
Node 绑定 |
MLN_WITH_PMTILES |
ON |
PMTiles 支持 |
MLN_WITH_PLUGINS |
OFF |
原生插件图层 API |
MLN_TEXT_SHAPING_HARFBUZZ |
ON |
HarfBuzz 复杂文本整形 |
MLN_USE_RUST |
OFF |
启用 Rust 组件 |
MLN_WITH_WERROR |
ON |
告警即错误 |
所有渲染后端默认关闭,必须显式开一个。忘了开就是链接期一堆 undefined reference to mln::gfx::Context——这是构建最常见的失败。
MLN_USE_RUST 值得单独说:rustutils/src/color.rs 用 cxx bridge 实现 parse_css_color(),把 CSS 颜色解析交给 csscolorparser crate。打开它需要 cxxbridge(cargo install cxxbridge-cmd@1.0.202 --locked)并预生成桥接代码(rustutils/generate.sh)。默认关闭时同一功能由 C++ 实现,两者对非法颜色串的处理有细微差异——测试里比较颜色时要留意。
12.8 生态集成:三个接入点
12.8.1 换瓦片服务
TileServerOptions 决定 URL 模板怎么拼,四个预置配置:
TileServerOptions::DefaultConfiguration(); // maplibre://maps/style -> demotiles
TileServerOptions::MapLibreConfiguration(); // MapLibre demo tiles
TileServerOptions::MapTilerConfiguration(); // maptiler://maps/streets 等
TileServerOptions::MapboxConfiguration(); // mapbox://styles/mapbox/...
接自建服务时链式改模板。注意每个 setter 的签名都是 (模板, 域名, 版本前缀 optional),第二个参数不是可省的装饰:
auto opts = TileServerOptions::DefaultConfiguration()
.withBaseURL("https://tiles.example.com")
.withUriSchemeAlias("example")
.withApiKeyParameterName("key")
.withTileTemplate("/tiles/{z}/{x}/{y}.pbf", "example.com", std::nullopt)
.withSourceTemplate("/sources/{source}.json", "example.com", std::nullopt)
.withStyleTemplate("/styles/{style}.json", "example.com", std::nullopt)
.withSpritesTemplate("/sprites/{style}", "example.com", std::nullopt)
.withGlyphsTemplate("/fonts/{fontstack}/{range}.pbf", "example.com", std::nullopt);
auto resourceOptions = ResourceOptions()
.withApiKey("YOUR_KEY")
.withTileServerOptions(opts);
domainName 决定「哪个 URL 算规范 URL」:只有请求 URL 的域名包含这个字符串,才会被模板反向解析。填错域名,canonicalizeTileURL 匹配不上,样式里写死完整 URL 的 source 就不会走这套模板。withUriSchemeAlias("example") 让样式里的 example://maps/streets 展开到 base URL。
12.8.2 自定义 FileSource
要加签名头、走内网代理或读加密包,就实现一个 FileSource 并注册。AsyncRequest 是纯虚基类、只有虚析构,取消语义全靠返回值的生命周期:
class SignedRequest final : public mln::AsyncRequest {
public:
std::shared_ptr<AbortFlag> flag = std::make_shared<AbortFlag>();
};
class SignedFileSource final : public mln::FileSource {
public:
std::unique_ptr<mln::AsyncRequest> request(const mln::Resource& res,
Callback callback) override {
auto req = std::make_unique<SignedRequest>();
// res.url 已规范化;拼签名后发请求,回调必须回到发起的线程
httpGet(sign(res.url), req->flag,
[callback](mln::Response r) { callback(std::move(r)); });
return req; // 销毁即取消,回调保证不会再触发
}
bool canRequest(const mln::Resource& res) const override {
return res.url.rfind("https://tiles.example.com/", 0) == 0;
}
void setResourceOptions(mln::ResourceOptions) override {}
mln::ResourceOptions getResourceOptions() override { return {}; }
void setClientOptions(mln::ClientOptions) override {}
mln::ClientOptions getClientOptions() override { return {}; }
};
mln::FileSourceManager::get()->registerFileSourceFactory(
mln::FileSourceType::Network,
[](const mln::ResourceOptions&, const mln::ClientOptions&) {
return std::make_unique<SignedFileSource>();
});
canRequest 必须足够窄。注册成 Network 会顶掉内置 HTTP 源,返回 true 的请求全被你接管;写宽了(比如一律 return true)会让 file://、asset:// 一起失效。
12.8.3 客户端标识
ClientOptions 只有 withName() 与 withVersion() 两个字段,会进请求头与日志。多租户场景里把产品名带上,日志侧才分得清是谁在拉瓦片。
12.9 排错:绑定层最容易错的三处
UnsatisfiedLinkError / undefined symbol:编译期模块被裁掉了。Android 查 MBGL_MODULE_OFFLINE_DISABLE,C++ 嵌入查 CMake 有没有开 MLN_WITH_* 对应后端。Kotlin 的 external fun 声明还在、native 侧注册没了,就会在第一次调用时炸。
回调不在预期线程:core 的 observer 回调与 FileSource::Callback 都在调用线程执行,而下载/解析在后台线程。绑定层如果没有把回调 post 到主线程,就会出现「UI 偶尔闪一下」这类难复现的问题。判据是:任何碰 View/UIView 的语句前面必须有一次线程切换。
Fractional zoom 下截图错位:Node 出图时 ratio 与 width/height 是两个独立维度的乘积。ratio: 2 得到 2 倍像素的位图,若用原尺寸建 sharp raw buffer,图像会被切成斜的哈希状——这是 scale 与 buffer 尺寸不匹配的典型表现。
std::bad_alloc 在批量出图时出现:Node 的 Map 实例不释放就不会回收原生内存,render 完成后必须 release();长期运行的进程要周期性重建实例。
12.10 生产建议
优先选官方绑定的最新 tag,别跟着根目录版本号走。Android 用 android-v*,iOS 用 ios-v*;跨平台项目里各端版本不一致是常态,把「各端实际版本」写进 Bug 模板,比写一个统一的 5.4.0 有用得多。
绑定层不要自己解析样式。所有样式处理走 Style API,包括运行时的 addLayer/setPaintProperty。自己拼 JSON 再 load 会绕开 core 的 diff 机制,导致「改了属性但地图没动」。
自定义 FileSource 只接管自己的域名。canRequest 里写前缀匹配,并在注册前确认没有覆盖内置源;多来源场景按 FileSourceType 分开注册,不要把所有请求塞进一个兜底实现。
桌面端直接编 core,不要包一层解释器。GLFW 目标证明了 core 可以嵌进任何 C++ 程序;需要 Python/Java 等语言时走 FFI,而不是在进程里再起一层。
跨平台差异记在仓库里。同一份样式在 Android 的 Vulkan 与 iOS 的 Metal 上,fill-extrusion 的深度处理、line-width 的抗锯齿都可能不同。发现差异时先确认渲染后端,再确认 core 版本,最后才怀疑样式本身。
12.11 本章要点
| 结论 | 依据 |
|---|---|
| 仓库没有全局 5.4.0,发布按平台打 tag | android-v13.5.4、ios-v7.0.0、node-v6.4.1、qt-v2.0.1 |
根 package.json 的 5.4.0 只是仓库工具链版本 |
根目录 package.json |
命名空间已从 mbgl 迁到 mln |
include/mln、src/mln,头文件写 <mln/...> |
| 绑定层只负责 RunLoop / FileSource / RendererBackend / 线程回切 | renderer_backend.hpp 的纯虚函数只有四个 |
Android 一个 flavor 只编一个后端,multiBackend 例外 |
android-build-flavors.md |
Node 的 render 结果是 RGBA 裸缓冲,尺寸是 width × ratio |
platform/node/index.d.ts |
| 后端 CMake 选项默认全关,必须显式开 | CMakeLists.txt 的 option(MLN_WITH_* OFF) |
自定义 FileSource 的取消语义靠销毁 AsyncRequest |
include/mln/util/async_request.hpp |