第07章 - 瓦片调度与网络加载

原生渲染引擎里,”地图卡”和”地图慢”是两件事。卡是渲染线程的事,慢几乎总是瓦片调度的事:请求发多了、该复用父瓦片时却去请求子瓦片、缓存太小导致来回拖动重复下载、网络层并发配置不合理。

这一章沿一条真实链路走:视口 → 需要哪些瓦片 → 谁去请求 → 请求走哪个 FileSource → 失败与降级怎么处理。涉及的类都在 include/mln 与 src/mln 下,命名空间是 mln(旧版本的 mbgl 已整体改名)。

7.1 三种瓦片 ID

同一块瓦片在引擎里有三个身份,混淆它们是读源码时的第一个障碍:

类型 定义位置 含义 用途
CanonicalTileID include/mln/tile/tile_id.hpp 规范化 z/x/y,x 已取模到 [0, 2^z) 数据源寻址、缓存键
OverscaledTileID 同上 带 overscaledZ,可与 canonical 不同 渲染调度、金字塔索引
UnwrappedTileID 同上 再带上世界副本序号 wrap 屏幕投影、同一瓦片的多副本

关键字段是 overscaledZ。地图缩放级别高于数据源最大层级时,引擎不去请求不存在的 z15,而是把 z14 的父瓦片按 z15 的位置放大绘制——这就是 overzoom。此时 OverscaledTileID 的 overscaledZ 是 15,而 canonical.z 仍是 14。

世界环绕是第二个维度:经度 ±180° 两侧是同一块数据的两个副本,CanonicalTileID 相同、UnwrappedTileID 的 wrap 不同。TilePyramid::handleWrapJump(float lng) 负责在地图跨过 ±180° 时修正所有瓦片的 wrap 值,避免整屏瓦片被判定为”全部换新”。

7.2 TilePyramid:每帧的瓦片账本

TilePyramid(src/mln/renderer/tile_pyramid.hpp)是每个图层源持有的瓦片集合。它只存三个容器:

std::map<OverscaledTileID, std::unique_ptr<Tile>> tiles;               // 当前帧仍在用的
TileCache cache;                                                        // 移出视野的,可复用
std::map<UnwrappedTileID, std::reference_wrapper<Tile>> renderedTiles;  // 本帧要画的

每帧调用一次 update(),签名如下(逐字摘自头文件):

void update(const std::vector<Immutable<style::LayerProperties>>& visibleLayers,
            bool needsRendering,
            bool needsRelayout,
            const TileParameters&,
            const style::Source::Impl&,
            uint16_t tileSize,
            Range<uint8_t> zoomRange,
            std::optional<LatLngBounds> bounds,
            std::function<std::unique_ptr<Tile>(const OverscaledTileID&, TileObserver*)> createTile);

几个参数值得单独说:

参数 实际作用
visibleLayers 决定瓦片需要解析哪些图层的 bucket;只改可见性时不会触发重新下载
needsRelayout 为 true 时先 cache.clear(),因为旧瓦片里的布局数据(文字排版、碰撞结果)已经失效
sourceImpl 提供 getPrefetchZoomDelta()、getMaxOverscaleFactorForParentTiles()、getMinimumTileUpdateInterval()、isVolatile() 等源级策略
bounds 超出 TileJSON bounds 的瓦片直接不创建,createTile 返回 nullptr
createTile 工厂函数,第二个参数是 TileObserver*,瓦片状态变化要回调给它

update() 的前半段处理”本帧不渲染”:把所有瓦片标记为 TileNecessity::Optional 后放进缓存,然后清空 tiles 与 renderedTiles 直接返回。这个分支的意义是不可见时不要触发网络请求——Optional 状态在 OnlineFileRequest 里对应”允许用过期的缓存”。

渲染分支里依次做:handleWrapJump() → 用 util::coveringZoomLevel() 算数据层级 → util::tileCover() 算出理想瓦片集 → algorithm::updateRenderables() 逐个决定保留/创建/降级。

7.2.1 coveringZoomLevel 的取整差别

src/mln/util/tile_cover.cpp 里的实现只有几行,但它解释了”为什么栅格图放大时边界会跳”:

int32_t coveringZoomLevel(double zoom, style::SourceType type, uint16_t size) noexcept {
    zoom += util::log2(util::tileSize_D / size);
    if (type == style::SourceType::Raster || type == style::SourceType::RasterDEM || type == style::SourceType::Video) {
        return static_cast<int32_t>(std::round(zoom));
    }
    return static_cast<int32_t>(std::floor(zoom));
}

矢量源取 floor、栅格源取 round。所以矢量瓦片在 5.5 级时先用 z5 的数据;栅格瓦片在 5.5 级时切到 z6。这也是为什么同一张地图里,矢量底图与栅格影像的”清晰度切换点”不在同一层级。

7.3 updateRenderables:父子瓦片降级算法

src/mln/algorithm/update_renderables.hpp 是整条调度链路的核心。它的逻辑可以概括成四步,顺序执行:

  1. 对每个理想瓦片,getTile() 拿已有的;没有就 createTile()。
  2. 如果瓦片 isRenderable()(已加载且 bucket 就绪),标记 Required 并加入渲染集。
  3. 否则先保留它(缺失的瓦片也要保留,避免下一帧反复创建),然后尝试降级:
    • 当 overscaledZ + 1 > zoomRange.max:找子瓦片(更细层级),可渲染就用,标记 Optional;
    • 否则检查四个子瓦片,四个都渲染就覆盖完整;
    • 子瓦片不完整就继续找父瓦片,按 maxParentOverscaleFactor 限制向上找几层。
  4. 最后把本帧没被 retain 的瓦片从 tiles 里移走,移入 cache 或直接销毁。

这段算法解释了三个常见现象:

  • 为什么放大时会看到模糊的父瓦片:子瓦片还没下载完,先用父瓦片占位。
  • 为什么某些瓦片一直白着:父瓦片与子瓦片都不可用,covered 为 false,本帧没有可画的。
  • 为什么 maxOverscaleFactorForParentTiles 有用:限制向父级回溯的层数,避免在极低缩放级别分享同一个 z0 瓦片导致整屏模糊。

7.4 TileCache:缓存的是”整块已解析数据”

TileCache(src/mln/tile/tile_cache.hpp)是个 LRU,但缓存键是 OverscaledTileID,值是 std::unique_ptr<Tile>——包含几何、bucket、纹理引用。所以一个 512×512 的矢量瓦片解析后可能占几百 KB 到几 MB,不是按图片大小算。

头文件里的接口很少:

void setSize(size_t);                        // 改容量
size_t getMaxSize() const;
void add(const OverscaledTileID&, std::unique_ptr<Tile>&&);
std::unique_ptr<Tile> pop(const OverscaledTileID&);
Tile* get(const OverscaledTileID&);
void deferredRelease(std::unique_ptr<Tile>&&);  // 延迟析构,避免阻塞主线程
void deferPendingReleases();

容量上限见 include/mln/util/constants.hpp:

constexpr uint64_t DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024;   // 50 MiB

这是总预算还是单源预算取决于接入方式;同时开多个矢量源时实际占用会成倍。低内存时应调 Renderer::reduceMemoryUse(),它最终走到 TilePyramid::reduceMemoryUse(),只有一行:

void TilePyramid::reduceMemoryUse() {
    cache.clear();
}

也就是说它清掉的是屏幕外缓存,不动正在显示的瓦片。这比直接 clearAll() 温和得多:清空后不会立刻黑屏,只是再拖回去要重新请求。

7.5 TileLoader:请求什么时候真的发出去

瓦片本身不决定何时联网,TileLoader(src/mln/tile/tile_loader.hpp + tile_loader_impl.hpp)负责这件事。它的状态机围绕 TileNecessity 展开:

enum class TileNecessity : bool {
    Optional = false,   // 只做低成本动作:读缓存、允许过期
    Required = true,    // 尽一切努力:联网、校验过期
};

构造函数里的策略值得完整看一遍,因为它决定了”能不能只读缓存”:

TileLoader(T& tile, const OverscaledTileID& id, const TileParameters& parameters, const Tileset& tileset)
    : tile(tile_),
      necessity(TileNecessity::Optional),
      resource(Resource::tile(tileset.tiles.at(0), parameters.pixelRatio,
                              id.canonical.x, id.canonical.y, id.canonical.z,
                              tileset.scheme, Resource::LoadingMethod::CacheOnly)),
      fileSource(parameters.fileSource) {
    if (!fileSource) { tile.setError(getCantLoadTileError()); return; }
    if (fileSource->supportsCacheOnlyRequests()) {
        loadFromCache();     // 先只读缓存,命中再说
    } else if (necessity == TileNecessity::Required) {
        loadFromNetwork();
    } else {
        // FileSource 不支持 cache-only 且当前是 Optional:什么都不做
    }
}

三个分支对应三种实现能力:支持 supportsCacheOnlyRequests() 的(DatabaseFileSource、MBTilesFileSource)先查缓存;不支持的只能等 Required 信号。

setNecessity() 从 Optional 变成 Required 时调 makeRequired(),反之调 makeOptional()。updateRenderables 里对降级用的父/子瓦片一律标 Optional,对理想瓦片标 Required——这条规则直接决定并发请求量。

7.6 FileSource:请求的出口

所有资源(样式、瓦片、字形、sprite、图片)都通过 FileSource 抽象发出,类型由 FileSourceType 枚举区分(include/mln/storage/file_source.hpp):

enum FileSourceType : uint8_t {
    Asset,          // Android assets
    Database,       // SQLite 离线库
    FileSystem,     // file://
    Network,        // HTTP/HTTPS
    Mbtiles,        // MBTiles 容器
    Pmtiles,        // PMTiles 容器
    ResourceLoader  // 代理,负责把请求分派给上面几种
};

获取实例走单例 FileSourceManager(include/mln/storage/file_source_manager.hpp):

class FileSourceManager {
public:
    using FileSourceFactory = std::function<std::unique_ptr<FileSource>(const ResourceOptions&, const ClientOptions&)>;
    static FileSourceManager* get() noexcept;
    [[nodiscard]] std::shared_ptr<FileSource> getFileSource(FileSourceType,
                                                            const ResourceOptions&,
                                                            const ClientOptions& = ClientOptions()) noexcept;
    virtual void registerFileSourceFactory(FileSourceType, FileSourceFactory&&) noexcept;
    virtual FileSourceFactory unRegisterFileSourceFactory(FileSourceType) noexcept;
};

它按 (type, options) 元组缓存实例,工厂注册在 platform/default/src/mln/storage/file_source_manager.cpp:

实现文件 对应类型 说明
platform/default/src/mln/storage/http_file_source.cpp Network libcurl multi 句柄,无阻塞、无线程池
.../local_file_source.cpp FileSystem 本地文件
.../asset_file_source.cpp Asset Android assets
.../database_file_source.cpp Database SQLite 缓存与离线库
.../mbtiles_file_source.cpp Mbtiles MBTiles
.../pmtiles_file_source.cpp Pmtiles PMTiles
.../main_resource_loader.cpp ResourceLoader 聚合层:先缓存后网络

HTTP 实现用的是 CURLM(multi 接口)而不是”每请求一线程”。源码注释写得很直接:CURL multi handle that we use to request multiple URLs at the same time, without having to block and spawn threads。这意味着单个 FileSource 内部天然是异步的,请求并发由调度层控制而不是由 socket 数控制。

7.6.1 并发上限

include/mln/util/constants.hpp 与 include/mln/storage/file_source.hpp 里有一对常量:

constexpr uint32_t DEFAULT_MAXIMUM_CONCURRENT_REQUESTS = 20;
constexpr const char* MAX_CONCURRENT_REQUESTS_KEY = "max-concurrent-requests";

OnlineFileSource 在构造时调用 setMaximumConcurrentRequests(util::DEFAULT_MAXIMUM_CONCURRENT_REQUESTS),并暴露为可运行时修改的属性,键就是 max-concurrent-requests。默认 20 是一个折中:太低首屏慢,太高在小内存设备上会堆出几十个半完成请求。

离线下载单独有一套批处理常量(platform/default/src/mln/storage/offline_download.cpp):资源批大小 kResourcesBatchSize = 64、标记批 kMarkBatchSize = 200,并发数同样读 max-concurrent-requests。

7.7 优先级与过期

Resource(include/mln/storage/resource.hpp)把请求的所有策略字段集中在一起:

字段 取值 作用
kind Style / Source / Tile / Glyphs / SpriteImage / SpriteJSON / Image 资源类型
priority Regular / Low 低优先级用于预取与不可见瓦片
usage Online / Offline 离线区域下载时标 Offline
loadingMethod Cache / Network 的位掩码 决定只读缓存还是必须联网
storagePolicy Permanent / Volatile 是否允许写入持久缓存
minimumUpdateInterval Duration 两次更新之间的最小间隔
priorEtag / priorExpires — 条件请求与过期判断

loadingMethod 是位掩码,定义了三个常量:

enum class LoadingMethod : uint8_t {
    None = 0b00,
    Cache = 0b01,
    Network = 0b10,
    CacheOnly = Cache,
    NetworkOnly = Network,
    All = Cache | Network,
};

注意 TileLoader 构造时传的是 CacheOnly。这不是”只读缓存”的意思,而是”第一阶段只读缓存”——后续 makeRequired() 会把 loadingMethod 提升到 All。

7.8 预取:setPrefetchZoomDelta

接口在 include/mln/map/map.hpp,注释给出了完整语义:

/// When loading a map, if PrefetchZoomDelta is set to any number greater
/// than 0, the map will first request a tile for zoom - delta in a attempt
/// to display a full map at lower resolution as quick as possible.
/// It will get clamped at the tile source minimum zoom. The default delta is 4.
void setPrefetchZoomDelta(uint8_t delta);
uint8_t getPrefetchZoomDelta() const;

默认值 4 在 include/mln/util/constants.hpp:constexpr uint8_t DEFAULT_PREFETCH_ZOOM_DELTA = 4;。含义是:当前 z12 时先请求 z8,一屏低清快速铺满,再补高清。

改法很简单,但要注意它接受的是 uint8_t:

map.setPrefetchZoomDelta(4);                      // 恢复默认
uint8_t current = map.getPrefetchZoomDelta();

源码里 Map::setPrefetchZoomDelta 做了一次相等判断后直接赋值并触发 onUpdate(),没有范围校验——传 255 也不会报错,但会把首屏压到极低层级。

7.9 请求链路排错

现象 根因 排查方法
瓦片 404 刷屏 数据源只到 z14,相机缩放超过它 在 source 上补 maxzoom,同时限制相机 setMaxZoom;注意 coveringZoomLevel 对矢量取 floor,声明要准
放大后一片白 父瓦片与四个子瓦片都不可用 打开 MapDebugOptions 的瓦片边界,确认 covered 为 false 的区域
来回拖动重复请求 TileCache 容量过小或被 reduceMemoryUse() 清空 检查是否在 onLowMemory 里调了 clearAll() 而不是 reduceMemoryUse()
请求数远超预期 同一 URL 被多个 source 引用 样式里合并 source;引擎按 source 去重,不按 URL
首屏很慢 prefetchZoomDelta 被设成 0,或 max-concurrent-requests 太低 恢复默认 4;检查是否被平台层改写为个位数
离线区域下载卡住 单 region 串行、批大小固定 拆成多个 OfflineRegion;确认 max-concurrent-requests 未被压到 1
样式加载成功但瓦片全 Pending FileSource 未注册或 fileSource 为空 FileSourceManager::getFileSource() 返回 null 时 TileLoader 直接 setError

7.10 生产建议

样式里写全 minzoom / maxzoom。 调度层完全依赖这两个值判断 overzoom 边界。缺少 maxzoom 时,引擎会按相机层级一直请求;补上之后,超出范围的部分自动走父瓦片放大。

一个 source 服务多个图层。 TilePyramid 是按 source 建的,同一 source 下的图层共享瓦片与解析结果。样式里重复定义两个 URL 相同的 source,等于磁盘与网络都翻倍。

低内存用 reduceMemoryUse(),不要用 clearAll()。 前者只清屏幕外缓存,后者会连当前瓦片一起清空,直接黑屏重载。

并发按设备分档。 默认 20,低端机可降到 10–12;不要低于 4,否则排队时间会超过下载时间。

别在渲染帧里同步等瓦片。 TilePyramid::update() 只创建对象、发起异步请求、返回本帧可画的瓦片;”等下载完再继续”会卡死渲染线程。

7.11 本章小结

要点 事实(main 分支)
三种 ID CanonicalTileID / OverscaledTileID(带 overscaledZ)/ UnwrappedTileID(带 wrap)
调度核心 TilePyramid::update() + algorithm::updateRenderables()
取整差异 矢量 floor、栅格/视频 round(coveringZoomLevel)
缓存 TileCache 存整块已解析瓦片;DEFAULT_MAX_CACHE_SIZE = 50 MiB
降内存 Renderer::reduceMemoryUse() → TilePyramid::reduceMemoryUse() → cache.clear()
请求时机 TileLoader 依据 TileNecessity(Optional/Required)决定是否联网
出口 FileSourceManager::getFileSource(FileSourceType, ResourceOptions, ClientOptions)
并发 DEFAULT_MAXIMUM_CONCURRENT_REQUESTS = 20,键名 max-concurrent-requests
预取 setPrefetchZoomDelta(),默认 4