第19章 - 平台适配与移植指南
19.1 移植要做的四件事
MapLibre Native 的平台层不是“把 core 编译一遍”那么简单。core 不直接调用任何操作系统 API,它把外部依赖收敛成四个接口,每个新平台都要给出实现:
| 接口 | 头文件 | 职责 |
|---|---|---|
gfx::RendererBackend |
include/mln/gfx/renderer_backend.hpp |
创建图形上下文、初始化 shader、提供默认渲染目标 |
FileSource |
include/mln/storage/file_source.hpp |
资源请求:HTTP、本地文件、asset、MBTiles、缓存 |
Scheduler / RunLoop |
include/mln/actor/scheduler.hpp、include/mln/util/run_loop.hpp |
线程与事件循环,任务投递 |
RendererFrontend |
include/mln/renderer/renderer_frontend.hpp |
把 core 的渲染树交给平台渲染循环 |
搬运顺序建议从下往上:先让 FileSource 能读一个本地 style.json,再接 RunLoop 让异步回调跑起来,然后是 RendererBackend 出第一帧,最后补离线缓存与手势。
19.2 RendererBackend:图形后端的接入点
RendererBackend 的公开接口只有五个纯虚函数(其余是受保护的上下文管理):
namespace mln::gfx {
enum class ContextMode : bool { Unique, Shared };
class RendererBackend {
protected:
explicit RendererBackend(ContextMode);
RendererBackend(ContextMode, const TaggedScheduler&);
public:
virtual ~RendererBackend();
TaggedScheduler& getThreadPool() noexcept { return threadPool; }
Context& getContext();
/// 默认渲染目标(通常是窗口的默认 framebuffer)
virtual Renderable& getDefaultRenderable() = 0;
/// 一次性 shader 初始化
virtual void initShaders(gfx::ShaderRegistry&, const ProgramParameters&) = 0;
protected:
virtual std::unique_ptr<Context> createContext() = 0;
virtual void activate() = 0; // GL context 激活
virtual void deactivate() = 0; // GL context 失活
};
} // namespace mln::gfx
getContext() 的实现很短,但两条约束必须遵守:
gfx::Context& RendererBackend::getContext() {
assert(BackendScope::exists());
std::call_once(initialized, [this] { context = createContext(); });
assert(context);
return *context;
}
- 只能在
BackendScope内调用。BackendScope在构造时调activate()、析构时调deactivate(),确保“用上下文”和“上下文当前有效”绑定。渲染一帧、销毁 GPU 资源都必须在 scope 里。 - 上下文延迟创建且只创建一次。
createContext()里做实际的后端初始化(创建 GL context、Vulkan device、Metal device),失败必须抛异常而不是返回空指针,否则后续assert(context)会触发。
ContextMode 是个性能提示:Unique 表示这个上下文不与其它代码共享,后端可以省掉状态校验;Shared 表示外部代码也会用它(例如嵌入到已有 GL 环境的宿主 App),每次状态切换都要假定脏状态。
现成的参考实现按后端划分:
| 后端 | 目录 | 典型平台 |
|---|---|---|
| OpenGL | platform/glfw/glfw_gl_backend.cpp、platform/linux/src/headless_backend_egl.cpp |
Linux、Android |
| Metal | platform/glfw/glfw_metal_backend.mm、platform/darwin |
iOS、macOS |
| Vulkan | platform/glfw/glfw_vulkan_backend.cpp、platform/linux/linux.cmake |
Linux、Android |
| WebGPU | platform/glfw/glfw_webgpu_backend.cpp |
实验性,Linux/macOS |
新增后端时最省力的路径是复制 platform/glfw/glfw_gl_backend.*,把 GL 调用换成目标 API;initShaders 负责把 core 里的 shader 源码注册进 ShaderRegistry,不同图形 API 的着色器语言不同(GLSL / MSL / SPIR-V / WGSL),这是移植中工作量最大的一块。
19.3 FileSource:资源请求
core 里所有资源(style、tile、glyph、sprite、字体、图片)都通过 FileSource 请求,接口只有一个必须实现的方法:
class FileSource {
public:
using Callback = std::function<void(Response)>;
/// 异步请求资源。回调在同一线程执行,该线程必须有活跃的 RunLoop。
/// 返回的 AsyncRequest 析构即取消请求。
virtual std::unique_ptr<AsyncRequest> request(const Resource&, Callback) = 0;
virtual bool canRequest(const Resource&) const = 0;
virtual void setResourceOptions(ResourceOptions) = 0;
virtual ResourceOptions getResourceOptions() = 0;
virtual void setClientOptions(ClientOptions) = 0;
virtual ClientOptions getClientOptions() = 0;
virtual bool supportsCacheOnlyRequests() const { return false; }
virtual void pause() {}
virtual void resume() {}
virtual void forward(const Resource&, const Response&, std::function<void()>) {}
};
三个约定决定移植正确性:
- 回调必须在请求发起的同一线程执行。这意味着实现内部要么用本线程的
RunLoop::invoke()回投,要么用AsyncRequest机制。直接在 IO 线程回调会破坏 core 的线程假设。 AsyncRequest析构 = 取消。core 大量依赖“取消请求”做优先级调度,实现里必须真的能中断(HTTP 请求 abort、文件读取跳过回调)。canRequest(Resource)必须与请求前缀一致。Resource的 URL 有http(s)://、file://、asset://、mbtiles://、pmtiles://等 scheme,每个 FileSource 只认自己那几种。
FileSourceManager 是单例注册表,按 (FileSourceType, ResourceOptions) 缓存实例:
enum FileSourceType : uint8_t {
Asset, Database, FileSystem, Network, Mbtiles, Pmtiles, ResourceLoader
};
auto* manager = FileSourceManager::get();
manager->registerFileSourceFactory(FileSourceType::Network, factory);
auto fs = manager->getFileSource(FileSourceType::Network, resourceOptions, clientOptions);
平台层的典型做法是在启动时注册全部工厂(platform/default/src/mln/storage/file_source_manager.cpp 就是默认平台的注册表),core 之后只面向 FileSourceType 取实例。
注意接口注释里的 TODO:FileSource 未来可能改名为 ResourceProvider,FileSourceType 改名为 ResourceProviderType。写移植代码时不要把这两个名字硬编码进太多地方。
19.4 线程与事件循环
core 要求宿主平台提供一个“能跑任务队列、能在其它线程唤醒它”的事件循环。RunLoop 已经实现了队列与唤醒逻辑,平台只需提供底层等待机制:
class RunLoop final : public Scheduler {
public:
static RunLoop* Get();
void run(); // 阻塞直到 stop()
void runOnce(); // 处理一轮
void stop();
void setPlatformCallback(std::function<void()> callback);
void addWatch(int fd, Event, std::function<void(int, Event)>&&);
void removeWatch(int fd);
};
三种典型接法:
- 桌面(Linux/Windows/macOS):核心 main loop 调
runOnce(),或用 fd watch 把 socket 事件接进来(libcurl 后端需要addWatch)。 - Android:渲染线程由
GLSurfaceView的GLThread驱动,setPlatformCallback里调requestRender(),runOnce()在 GL 线程执行。 - iOS/macOS:用 GCD 承载后台任务,主线程的
RunLoop::runOnce()挂在CADisplayLink或NSRunLoop上。
调度器有三档(src/mln/actor/scheduler.hpp):GetCurrent() 是当前线程,GetBackground() 是共享线程池,GetSequenced() 是单线程顺序队列。瓦片解析、网络回调走 Background,需要串行化的对象(部分 tile worker)走 Sequenced。core 从不自己创建线程,所以平台的线程池实现直接决定并发度。
19.5 RendererFrontend 与渲染循环
RendererFrontend 是 core 与平台渲染循环的接缝,两个实现最常用:
| 实现 | 头文件 | 用途 |
|---|---|---|
HeadlessFrontend |
include/mln/gfx/headless_frontend.hpp |
离屏渲染,出 PNG,用于 mbgl-render、快照、CI 渲染测试 |
| 平台自建 frontend | platform/glfw/glfw_renderer_frontend.cpp 等 |
绑定窗口系统 |
它的核心方法是 update(UpdateParameters) 与 render()。平台的渲染循环一般长这样:
每帧(或收到 onUpdate 通知时):
frontend.update(updateParameters) // 触发 core 的 diff 与瓦片调度
if (frontend.needsRepaint()):
BackendScope scope(backend) // activate 上下文
frontend.render() // 提交 draw call
swapBuffers()
BackendScope 的位置很关键:update() 不需要图形上下文(只算数据),render() 必须有。把 scope 放在 render() 外层能让整个绘制过程共享一次上下文激活,避免每个 drawable 各激活一次。
离屏渲染的最小示例可以照 bin/render.cpp 写:HeadlessFrontend + MapOptions().withMapMode(MapMode::Static) + ResourceOptions().withCachePath(...),渲染一帧后从 frontend 取 PremultipliedImage。
19.6 CMake 组织方式
core 的构建选项全部以 MLN_ 为前缀(这是 CHANGELOG 里明确记录的变更),常用组合:
| 选项 | 作用 |
|---|---|
MLN_WITH_OPENGL / MLN_WITH_VULKAN / MLN_WITH_METAL / MLN_WITH_WEBGPU |
选择渲染后端 |
MLN_WITH_WEBGPU_IMPL_DAWN / MLN_WITH_WEBGPU_IMPL_WGPU |
WebGPU 具体实现 |
MLN_WITH_NODE |
构建 Node.js 绑定 |
MLN_WITH_CLANG_TIDY |
静态检查 |
MLN_WITH_COVERAGE / MLN_WITH_SANITIZER |
覆盖率 / sanitizer |
MLN_DARWIN_USE_LIBUV |
Apple 平台事件循环实现选择 |
MLN_WITH_X11 / MLN_WITH_WAYLAND |
Linux 窗口系统 |
预设(CMakePresets.json)把常用组合固化下来,命名规则是“平台-后端”:
cmake --preset linux-opengl
cmake --preset linux-vulkan
cmake --preset macos-metal
cmake --preset ios-metal
cmake --preset linux-webgpu-wgpu
新增平台时,CMake 层的改动通常是三处:
- 在
platform/<name>/加CMakeLists.txt,定义平台 target(可执行文件或库)。 - 在根
CMakeLists.txt的条件分支里 add_subdirectory,并声明平台专属的MLN_WITH_<PLATFORM>选项。 - 在
CMakePresets.json加若干<platform>-<backend>预设,固定binaryDir、生成器与CMAKE_SYSTEM_NAME。
参考 platform/glfw/CMakeLists.txt 的写法:按后端选项分段 target_sources,把 MLN_WITH_OPENGL/VULKAN/METAL/WEBGPU 分别映射到对应的 *_backend.cpp,并只在对应选项打开时 target_link_libraries 链第三方库(如 mbgl-vendor-metal-cpp、mbgl-vendor-dawn)。
平台目录和 core 的依赖方向必须单向:platform/* 可以 include <mln/...>,core 不能 include platform/*。CI 里各平台 job 只构建自己的 target,但共用 mbgl-core,这条边界一旦破坏,其它平台立刻编译失败。
19.7 移植检查清单
| 阶段 | 检查项 | 通过标准 |
|---|---|---|
| 构建 | mbgl-core 能独立编译 |
无平台无关代码被条件编译掉 |
| 资源 | FileSource::request 能读 style.json |
map.loadStyle 回调成功 |
| 事件循环 | RunLoop::runOnce() 被周期调用 |
异步回调按时执行,无忙等 |
| 图形 | RendererBackend 能创建上下文并初始化 shader |
initShaders 不报错 |
| 首帧 | HeadlessFrontend/自定义 frontend 出图 |
图像非全黑,瓦片可见 |
| 交互 | 相机 API(jumpTo/easeTo)生效 |
视图按预期变化 |
| 瓦片 | 多级缩放无空洞 | TilePyramid 按 prefetch 顶替 |
| 离线 | DatabaseFileSource 可存取 |
断网后仍能出图 |
| 清理 | map.remove()/frontend 析构无泄漏 |
重复创建销毁不涨内存 |
19.8 排错
Map container not found 类似的空指针崩溃。 平台层把尺寸为 0 的 surface 传给了 MapOptions.withSize()。窗口最小化或后台时尺寸为 0,要跳过渲染而不是继续提交。
首帧全黑但无错误日志。 检查 BackendScope 是否包住了 frontend.render();以及 getDefaultRenderable() 返回的是不是当前帧的 framebuffer(双缓冲后可能指向了上一帧)。
异步回调永远不执行。 FileSource 的回调被投递到了 IO 线程,而该线程没有 RunLoop。改成 RunLoop::Get()->invoke(...) 回投到请求线程。
请求取消后仍然收到回调。 AsyncRequest 析构没有真正中断底层请求。core 依赖取消语义做优先级调度,这不只是资源浪费,还会导致已销毁对象的回调被调用。
Vulkan 后端在同一进程创建第二个 Map 时崩溃。 检查 ContextMode:多个 Map 共享一个 device 时应用 Shared,并确保 device/queue 的创建是线程安全的(现有代码里存在对 headless 显示单例初始化的线程安全修复记录)。
Android 上渲染线程与 UI 线程互相阻塞。 相机操作应投递到渲染线程串行执行;core 提供了 Scheduler::runOnRenderThread,平台层不要绕过它直接改 TransformState。
Metal 后端 shader 编译失败。 initShaders 注册的是 core 的 shader 源,需要按 MSL 预处理(shaders/ 下的源可能需要转换)。逐条看 ShaderRegistry 报的错误名,通常是某个变体缺失。
MLN_WITH_* 选项开了但后端没被编译。 检查 CMakePresets.json 的 preset 是否把该选项写进 cacheVariables,以及 target_sources 的条件分支是否覆盖。
19.9 本章小结
| 要点 | 结论 |
|---|---|
| 四个接口 | RendererBackend、FileSource、Scheduler/RunLoop、RendererFrontend |
| 图形后端 | 五个纯虚函数:默认渲染目标、shader 初始化、上下文创建、激活/失活 |
| 上下文约束 | getContext() 只能在 BackendScope 内调用,call_once 延迟创建 |
| 资源 | 回调必须在请求线程执行;AsyncRequest 析构即取消 |
| 注册表 | FileSourceManager 按 (FileSourceType, ResourceOptions) 缓存实例 |
| 线程 | core 不创建线程,只依赖 Scheduler 三档与平台事件循环 |
| CMake | 选项统一 MLN_*,preset 命名 <platform>-<backend> |
| 依赖方向 | platform/* 依赖 <mln/...>,反向禁止 |
| 验收 | 构建 → 读样式 → 事件循环 → 上下文 → 首帧 → 交互 → 离线 → 清理 |