第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;
}
  1. 只能在 BackendScope 内调用。BackendScope 在构造时调 activate()、析构时调 deactivate(),确保“用上下文”和“上下文当前有效”绑定。渲染一帧、销毁 GPU 资源都必须在 scope 里。
  2. 上下文延迟创建且只创建一次。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()>) {}
};

三个约定决定移植正确性:

  1. 回调必须在请求发起的同一线程执行。这意味着实现内部要么用本线程的 RunLoop::invoke() 回投,要么用 AsyncRequest 机制。直接在 IO 线程回调会破坏 core 的线程假设。
  2. AsyncRequest 析构 = 取消。core 大量依赖“取消请求”做优先级调度,实现里必须真的能中断(HTTP 请求 abort、文件读取跳过回调)。
  3. 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 层的改动通常是三处:

  1. 在 platform/<name>/ 加 CMakeLists.txt,定义平台 target(可执行文件或库)。
  2. 在根 CMakeLists.txt 的条件分支里 add_subdirectory,并声明平台专属的 MLN_WITH_<PLATFORM> 选项。
  3. 在 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/...>,反向禁止
验收 构建 → 读样式 → 事件循环 → 上下文 → 首帧 → 交互 → 离线 → 清理