You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS Safari下OpenLayers地图Canvas内存超限问题求助

iOS Safari中OpenLayers地图Canvas内存超限问题解决方案

问题描述

我在网站中使用OpenLayers展示地图,并基于经纬度添加不同类型的标记点,当前代码如下:

const urlArgs = new URLSearchParams(document.location.search)
const layersToUse = 5;

const disposeInternal = VectorTileLayer.prototype;

let customFeatures = {}
let toiletFeatures = []
let gastroFeatures = []

let allFeatures = {}

function addToLayer(mt, pt) {
    let ft = new Feature({
        geometry: new Point(
            fromLonLat([
                parseFloat(pt.longitude),
                parseFloat(pt.latitude)
            ])
        ),
        name: mt.name,
        iconUrl: mt.image,
        popupText: mt.popup_text,
        popupLinkText: mt.popup_link_text,
        popupLink: mt.popup_link,
    });

    if (mt.imported_type === "toilet") {
        toiletFeatures.push(ft)
    } else if (mt.imported_type === "gastro") {
        gastroFeatures.push(ft)
    } else {
        if (customFeatures[mt.marker_index % layersToUse] === undefined) {
            customFeatures[mt.marker_index % layersToUse] = [];
        }
        customFeatures[mt.marker_index % layersToUse].push(ft)
    }
    key = pt.longitude+"/"+pt.latitude;
    allFeatures[key] = ft;
}

let htmlMarkers = Array.from(document.querySelectorAll('marker'))
markers.forEach(
    (mt) => {
        Array.from(mt.points).forEach(
            (pt) => {
                addToLayer(mt, pt)
            }
        )
    }
)

let customSources = [];
for (let i = 0; i < layersToUse; i++) {
    customSources.push(
        new VectorSource({
            features: customFeatures[i]
        })
    )
}


let toiletSource = new VectorSource({
    features: toiletFeatures,
    cacheSize: 10,
})

let gastroSource = new VectorSource({
    features: gastroFeatures,
    cacheSize: 10,
})

let toiletLayer = new VectorLayer({
    source: toiletSource,
    style: (feat) => {
        return new Style({
            image: new Icon({
                anchorOrigin: "bottom-left",
                anchor: [0.5, 0.5],
                width: 35,
                height: 35,
                anchorXUnits: 'fraction',
                anchorYUnits: 'fraction',
                src: feat.get("iconUrl"),
            })
        })
    },
    declutter: true
})

let gastroLayer = new VectorLayer({
    source: gastroSource,
    style: (feat) => {
        return new Style({
            image: new Icon({
                anchorOrigin: "bottom-left",
                anchor: [0.5, 0.5],
                width: 35,
                height: 35,
                anchorXUnits: 'fraction',
                anchorYUnits: 'fraction',
                src: feat.get("iconUrl"),
            })
        })
    },
    declutter: true
})

let customLayers = [];
for (let i = 0; i < layersToUse; i++) {
    customLayers.push(
        new VectorLayer({
            source: customSources[i],
            style: (feat) => {
                return new Style({
                    image: new Icon({
                        anchorOrigin: "bottom-left",
                        anchor: [0.5, 0.5],
                        width: 35,
                        height: 35,
                        anchorXUnits: 'fraction',
                        anchorYUnits: 'fraction',
                        src: feat.get("iconUrl"),
                    })
                })
            }
        })
    );
}

let zoneSource = new VectorSource({
    url: '/static/map_assets/map_zones.kml',
    cacheSize: 10,
    format: new KML({
        extractStyles: false
    }),
})

let zoneLayer = new VectorLayer({
    source: zoneSource,
    style: (feat) => {
        const zoneColors = {
            "zone_1": 'rgba(244, 174, 108, 0.6)',
            "zone_2": 'rgba(227, 148, 193, 0.6)',
            "zone_3": 'rgba(161, 140, 217, 0.6)',
            "zone_gastro": 'rgba(115, 211, 121, 0.6)',
            "zone_arena": 'rgba(198, 26, 12, 0.8)',
            "zone_industrie": 'rgba(253, 215, 64, 1)',
        }
        return new Style({
            fill: new Fill({
                color: zoneColors[feat.id_],
            })
        })
    },
})

const klotenBaseView = new View({
    center: fromLonLat([8.57303, 47.46117]),
    rotation: -0.7,
    constrainRotation: true,
    zoom: 16,
    minZoom: 16,
    maxZoom: 18,
    // Matches full map size at minZoom
    extent: [952802.0356325043, 6016519.755591137, 955663.7673070097, 6018872.403698619]
})

let zoomCtrl = new Zoom()
let attribution = new Attribution()

const map = new olMap({
    controls: [zoomCtrl, attribution],
    target: 'map-container',
    layers: [
        new VectorTileLayer({
            declutter: true,
            preload: 0,
            source: new VectorTileSource({
                projection: 'EPSG:3857',
                format: new MVT(),
                url: 'pbf_url',
                maxZoom: 14,
                cacheSize: 10,
            })
        })
    ],
    view: klotenBaseView,
});

apply(
    map,
    "style.json_url"
).then((t) => {


    map.getLayers().extend([
        zoneLayer,
        toiletLayer,
        gastroLayer
    ]);
    customLayers.forEach((l) => {
        map.addLayer(l);    
    });
})

if (urlArgs.get("lat") && urlArgs.get("lon")) {
    let urlPos = fromLonLat([
        parseFloat(urlArgs.get("lon")),
        parseFloat(urlArgs.get("lat"))]
    );
    for(var key in allFeatures) {
        var value = allFeatures[key];
        var lat_long = key.split("/");
        var lat = parseFloat(lat_long[1]);
        var lon = parseFloat(lat_long[0]);
        var featLatLong = fromLonLat([lon, lat]);
        if (toStringXY(featLatLong) === toStringXY(urlPos)) {
            if (value && value.getGeometry().getType() === "Point") {
                showModal(
                    value.get("name"),
                    value.get("popupText"),
                    value.get("popupLinkText"),
                    value.get("popupLink")
                )
                break;
            }
        }
        
    }
    
    map.once('postrender', function() {
        var centerPx = map.getPixelFromCoordinate(urlPos);
        var newCenterPx = [centerPx[0] , centerPx[1]+ 80];
        var newCenter = map.getCoordinateFromPixel(newCenterPx);
        klotenBaseView.setCenter(newCenter);
        // klotenBaseView.setCenter(fromLonLat(urlLongLat));
        klotenBaseView.setZoom(18)

    });
}

let lastZoom = map.getView().getZoom()

map.on('moveend', (_event) => {
    let newZoom = map.getView().getZoom();
    if (newZoom < 17.3) {
        gastroLayer.setVisible(false)
        customLayers.forEach((l) => {l.setVisible(false)})
        
    } else if (newZoom > 17.3) {
        gastroLayer.setVisible(true)
        customLayers.forEach((l) => {l.setVisible(true)})
    }
    lastZoom = newZoom
})

map.on('click', (event) => {

    if (event.pixel) {
        const clickedFeat = map.forEachFeatureAtPixel(event.pixel, (feat) => {
            return feat
        })
        if (clickedFeat && clickedFeat.getGeometry().getType() === "Point") {
            showModal(
                clickedFeat.get("name"),
                clickedFeat.get("popupText"),
                clickedFeat.get("popupLinkText"),
                clickedFeat.get("popupLink")
            )
        }
    event.preventDefault()
    }
})

map.once('rendercomplete', (event => {
    const areaSelectors = document.querySelectorAll('.area_selector')

    areaSelectors.forEach((el) => {
        el.addEventListener('click', (ev) => {
            const zoneFeature = zoneSource.getFeatureById(el.dataset.zone)
            const zonePoly = zoneFeature.getGeometry()
            klotenBaseView.fit(zonePoly)
        })
    })

    if (urlArgs.get("zone")) {
        const zoneFeature = zoneSource.getFeatureById(urlArgs.get("zone"))
        const zonePoly = zoneFeature.getGeometry()
        klotenBaseView.fit(zonePoly)
    }

}))

该地图在多数浏览器中运行正常,但在iOS Safari中触发如下错误:

Total canvas memory use exceeds the maximum limit (384 MB).

已知Safari存在Canvas缓存相关问题,但不清楚如何在现有代码中修改以解决,求技术方案。


解决方案

iOS Safari对单页面Canvas内存有严格限制,你的代码存在图标资源重复创建、图层拆分过多、无效绘制逻辑等问题,导致内存占用超标。以下是针对性修改:

1. 复用图标资源,避免重复加载

原代码中每个Feature的Style都新建Icon实例,即使图标URL相同也会重复缓存。新增图标缓存逻辑:

// 全局图标缓存对象
const iconCache = {};

// 复用Icon样式的工具函数
function getIconStyle(iconUrl) {
  if (!iconCache[iconUrl]) {
    iconCache[iconUrl] = new Style({
      image: new Icon({
        anchorOrigin: "bottom-left",
        anchor: [0.5, 0.5],
        width: 35,
        height: 35,
        anchorXUnits: 'fraction',
        anchorYUnits: 'fraction',
        src: iconUrl,
      })
    });
  }
  return iconCache[iconUrl];
}

将所有标记图层的style替换为调用该函数,比如:

// toiletLayer的style修改
style: (feat) => getIconStyle(feat.get("iconUrl"))

gastroLayer、customLayers的style同理替换。

2. 合并冗余图层,减少Canvas上下文数量

原代码将自定义标记拆分为5个图层,加上toilet、gastro图层共7个标记图层,每个图层对应独立的Canvas上下文,大幅增加内存。合并为单个自定义图层:

  • 修改addToLayer函数中的自定义标记逻辑:
let customFeatures = []; // 改为数组而非对象
// ...
else {
  customFeatures.push(ft); // 直接加入统一数组
}
  • 创建单个自定义图层:
let customSource = new VectorSource({
  features: customFeatures
});

let customLayer = new VectorLayer({
  source: customSource,
  style: (feat) => getIconStyle(feat.get("iconUrl")),
  declutter: true
});
  • 添加图层时,替换原customLayers.forEach逻辑,仅添加这个customLayer。

3. 优化图层可见性与资源清理

在图层隐藏时,触发重绘以释放无用Canvas资源:

map.on('moveend', (_event) => {
  let newZoom = map.getView().getZoom();
  const shouldShow = newZoom > 17.3;
  gastroLayer.setVisible(shouldShow);
  customLayer.setVisible(shouldShow);
  
  // 隐藏时触发图层变更,清理缓存
  if (!shouldShow) {
    gastroLayer.changed();
    customLayer.changed();
  }
  lastZoom = newZoom;
});

同时,给标记图层的VectorSource设置匹配视图的maxZoom,避免加载超出范围的资源:

let toiletSource = new VectorSource({
  features: toiletFeatures,
  cacheSize: 5,
  maxZoom: 18 // 与视图maxZoom一致
});

4. 修正KML图层样式的无效绘制

原代码中feat.id_是错误写法,改为OpenLayers标准的getId()方法,避免无效绘制:

style: (feat) => {
  const zoneColors = {
    "zone_1": 'rgba(244, 174, 108, 0.6)',
    "zone_2": 'rgba(227, 148, 193, 0.6)',
    "zone_3": 'rgba(161, 140, 217, 0.6)',
    "zone_gastro": 'rgba(115, 211, 121, 0.6)',
    "zone_arena": 'rgba(198, 26, 12, 0.8)',
    "zone_industrie": 'rgba(253, 215, 64, 1)',
  }
  return new Style({
    fill: new Fill({
      color: zoneColors[feat.getId()] || 'rgba(0,0,0,0)', // 默认透明避免无效绘制
    })
  })
},

5. 优化URL参数坐标匹配逻辑

避免重复执行坐标转换,直接用Feature的Geometry进行比较:

if (urlArgs.get("lat") && urlArgs.get("lon")) {
  let urlPos = fromLonLat([
    parseFloat(urlArgs.get("lon")),
    parseFloat(urlArgs.get("lat"))]
  );
  // 直接遍历Feature对象,无需重复转换坐标
  for(const feat of Object.values(allFeatures)) {
    if (feat.getGeometry().getType() === "Point" && 
        feat.getGeometry().equals(new Point(urlPos))) {
      showModal(
        feat.get("name"),
        feat.get("popupText"),
        feat.get("popupLinkText"),
        feat.get("popupLink")
      );
      break;
    }
  }
  // ... 其余代码不变
}

内容的提问来源于stack exchange,提问作者Ilian Gion Häsler

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 13:09:50