import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart' as http; /// ESRI World Imagery 底图地址(国内网络可能不可达) const String kEsriWorldImageryUrl = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; /// 网络超时时长(瓦片加载) const Duration _kTileTimeout = Duration(seconds: 8); /// ───────────────────────────────────────────────────────── /// 容错网络瓦片图 /// ───────────────────────────────────────────────────────── /// /// 加载失败(超时 / 断网 / 404 等)时返回 1×1 透明 PNG, /// 避免 SocketException 刷屏并保证地图其余部分正常渲染。 class ResilientNetworkImage extends ImageProvider { const ResilientNetworkImage(this.url); final String url; @override Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); @override ImageStreamCompleter loadImage( ResilientNetworkImage key, ImageDecoderCallback decode, ) { return MultiFrameImageStreamCompleter( codec: _loadAsync(decode), scale: 1.0, debugLabel: 'ResilientNetworkImage(${url.split('/').last})', ); } Future _loadAsync(ImageDecoderCallback decode) async { try { final client = http.Client(); final response = await client .get(Uri.parse(url)) .timeout(_kTileTimeout); if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { final buffer = await ui.ImmutableBuffer.fromUint8List( response.bodyBytes, ); return decode(buffer); } } catch (_) { // 超时 / 网络异常 / 解码失败 → 走下面的透明兜底 } // 1×1 透明 PNG,避免 Flutter 框架抛出 MissingImageException return decode( await ui.ImmutableBuffer.fromUint8List(_transparentPng), ); } @override bool operator ==(Object other) => other is ResilientNetworkImage && other.url == url; @override int get hashCode => url.hashCode; } /// ───────────────────────────────────────────────────────── /// 容错 TileProvider —— 直接在 TileLayer.tileProvider 中使用 /// ───────────────────────────────────────────────────────── /// /// ```dart /// TileLayer( /// urlTemplate: kEsriWorldImageryUrl, /// tileProvider: ResilientTileProvider(), /// minZoom: 0, /// maxZoom: 19, /// ) /// ``` class ResilientTileProvider extends TileProvider { @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { final url = getTileUrl(coordinates, options); return ResilientNetworkImage(url); } } /// 1×1 透明 PNG 字节(兜底用) final Uint8List _transparentPng = Uint8List.fromList([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1×1 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA 8bit 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk 0x54, 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, // compressed data 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, // IEND chunk 0x60, 0x82, ]);