前语

flutter_map 是一个基于leaflet开发的flutter包,用于在flutter运用中加载瓦片地图,但是默许并不供给本地缓存功用——这就意味着运用每次重新启动,所有瓦片都要重新下载,这显然会花费大量的流量,在网络不良的情况下也会影响运用的正常作业。

其实已经有开发者为flutter_map写了一个插件 flutter_map_tile_caching 来供给瓦片图层缓存服务,但是恕我愚钝,愣是没看懂这玩意怎样用,所以就自己完成了一个带缓存功用的TileProvider

剖析

flutter_map 的FlutterMapTileLayerStatefulWidget抽象类的子类,后者能够被增加为前者的children,例如,咱们能够这样完成一个最简略的 flutter_map:

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  static const String _title = 'flutter map example';
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(
          title: const Text(_title),
        ),
        body: FlutterMap(
          options: MapOptions(),
          children: [
            TileLayer(
              urlTemplate: "",
              userAgentPackageName: 'flutter_map_example',
            ),
          ],
        ),
      ),
    );
  }
}

FlutterMap类实际上只是供给了一个空间,或者说一个坐标系,用来放置地图图层,所以它与咱们要处理的瓦片地图缓存无关。而TileLayer类才是显现地图图层的组件,它的结构函数有十分多的参数:

  TileLayer({
    super.key,
    this.urlTemplate,
    double tileSize = 256.0,
    double minZoom = 0.0,
    double maxZoom = 18.0,
    this.minNativeZoom,
    this.maxNativeZoom,
    this.zoomReverse = false,
    double zoomOffset = 0.0,
    Map<String, String>? additionalOptions,
    this.subdomains = const <String>[],
    this.keepBuffer = 2,
    this.backgroundColor = const Color(0xFFE0E0E0),
    this.errorImage,
    TileProvider? tileProvider,
    this.tms = false,
    this.wmsOptions,
    this.opacity = 1.0,
    Duration updateInterval = const Duration(milliseconds: 200),
    Duration tileFadeInDuration = const Duration(milliseconds: 100),
    this.tileFadeInStart = 0.0,
    this.tileFadeInStartWhenOverride = 0.0,
    this.overrideTilesWhenUrlChanges = false,
    this.retinaMode = false,
    this.errorTileCallback,
    this.templateFunction = util.template,
    this.tileBuilder,
    this.tilesContainerBuilder,
    this.evictErrorTileStrategy = EvictErrorTileStrategy.none,
    this.fastReplace = false,
    this.reset,
    this.tileBounds,
    String userAgentPackageName = 'unknown',
  })

这里面许多参数都是见名知义的,比如tileSizeminZoommaxZoom等等,能够注意到在上面的示例中只供给了urlTemplate一个参数,这是由于TileLayer类默许运用的TileProviderNetworkNoRetryTileProvider,它依据url从网络上的在线地图服务获取地图数据,假如不供给urlTemplate,运行时会报Unexpected null value.

NetworkNoRetryTileProviderTileProvider抽象类的子类,TileLayer类也供给了可选的tileProvider参数供咱们指定其它的TileProvider

阅读TileProvider抽象类和NetworkNoRetryTileProvider子类的代码(如下)

abstract class TileProvider {
  Map<String, String> headers;
  TileProvider({
    this.headers = const {},
  });
  /// Retrieve a tile as an image, based on it's coordinates and the current [TileLayerOptions]
  ImageProvider getImage(Coords coords, TileLayer options);
  /// Called when the [TileLayerWidget] is disposed
  void dispose() {}
  /// Generate a valid URL for a tile, based on it's coordinates and the current [TileLayerOptions]
  String getTileUrl(Coords coords, TileLayer options) {
    final urlTemplate = (options.wmsOptions != null)
        ? options.wmsOptions!
            .getUrl(coords, options.tileSize.toInt(), options.retinaMode)
        : options.urlTemplate;
    final z = _getZoomForUrl(coords, options);
    final data = <String, String>{
      'x': coords.x.round().toString(),
      'y': coords.y.round().toString(),
      'z': z.round().toString(),
      's': getSubdomain(coords, options),
      'r': '@2x',
    };
    if (options.tms) {
      data['y'] = invertY(coords.y.round(), z.round()).toString();
    }
    final allOpts = Map<String, String>.from(data)
      ..addAll(options.additionalOptions);
    return options.templateFunction(urlTemplate!, allOpts);
  }
  double _getZoomForUrl(Coords coords, TileLayer options) {
    var zoom = coords.z;
    if (options.zoomReverse) {
      zoom = options.maxZoom - zoom;
    }
    return zoom += options.zoomOffset;
  }
  int invertY(int y, int z) {
    return ((1 << z) - 1) - y;
  }
  /// Get a subdomain value for a tile, based on it's coordinates and the current [TileLayerOptions]
  String getSubdomain(Coords coords, TileLayer options) {
    if (options.subdomains.isEmpty) {
      return '';
    }
    final index = (coords.x + coords.y).round() % options.subdomains.length;
    return options.subdomains[index];
  }
}
class NetworkNoRetryTileProvider extends TileProvider {
  NetworkNoRetryTileProvider({
    Map<String, String>? headers,
    HttpClient? httpClient,
  }) {
    this.headers = headers ?? {};
    this.httpClient = httpClient ?? HttpClient()
      ..userAgent = null;
  }
  late final HttpClient httpClient;
  @override
  ImageProvider getImage(Coords<num> coords, TileLayer options) =>
      FMNetworkNoRetryImageProvider(
        getTileUrl(coords, options),
        headers: headers,
        httpClient: httpClient,
      );
}

能够发现,除了结构函数之外,NetworkNoRetryTileProvider仅重写了TileProvider抽象类的getImage一个办法,它的回来值是一个ImageProvider实例。咱们知道ImageProvider的主要用途是作为Image组件的image参数的类型,用于Image组件中图片的获取和加载。

因而,咱们就有了一个完成缓存功用的思路,完成一个自己的TileProvider并重写getImage办法,以伪代码办法描述如下:

@override
ImageProvider getImage(Coords<num> coords, TileLayer options) {
	file = File(getPath(coords));
	if (file.exists()){
		return FileImage(file); // 假如文件存在,回来 FileImage
	} else {
		url = getTileUrl(coords, options);
		networkImage = NetworkImage(url)
		saveImage(file, networkImage); // saveImage是一个异步函数,运用resolve办法从ImageProvider中获取数据流;
		return networkImage;
	}
}

我最开端就是这样完成的,但是这样做的缺陷十分显着:每张图片都被下载了两次,流量什么的却是非必须的了,主要问题是服务器端持续报429 Too Many Requests,终究导致运用强制关闭。那么是否能够这样修改呢:

@override
ImageProvider getImage(Coords<num> coords, TileLayer options) {
	file = File(getPath(coords));
	if (file.exists()){
		return FileImage(file); // 假如文件存在,回来 FileImage
	} else {
		url = getTileUrl(coords, options);
		download = downloadImage(file, url); // 同步函数,等待下载完成后再回来值;
		if (download.success){
			return FileImage(file); // 下载成功,回来 FileImage
		} else {
			return null;
		}
	}
}

这样做的缺陷也很显着:图片被下载到内部存储中之后,再从内部存储中读取,完全是多此一举,浪费时间,还要消耗额定的内存等运行资源。

所以,咱们想到ImageProvider类是以数据流ImageStream的方式向Image组件供给图片,那么咱们能够重写某个涉及到ImageStream的办法,为其增加一个Listener

resolveImageProvider露出给Image组件的主入口办法,通过阅读代码,能够发现它的stream来自createStream办法。createStream办法显着比resolve更适合重写,代码的注释中也这样建议(Subclasses should override this instead of [resolve] if they need to …)。

  @nonVirtual
  ImageStream resolve(ImageConfiguration configuration) {
    assert(configuration != null);
    final ImageStream stream = createStream(configuration);
    // Load the key (potentially asynchronously), set up an error handling zone,
    // and call resolveStreamForKey.
    _createErrorHandlerAndKey(
      configuration,
      (T key, ImageErrorListener errorHandler) {
        resolveStreamForKey(configuration, stream, key, errorHandler);
      },
      (T? key, Object exception, StackTrace? stack) async {
        await null; // wait an event turn in case a listener has been added to the image stream.
        InformationCollector? collector;
        assert(() {
          collector = () => <DiagnosticsNode>[
            DiagnosticsProperty<ImageProvider>('Image provider', this),
            DiagnosticsProperty<ImageConfiguration>('Image configuration', configuration),
            DiagnosticsProperty<T>('Image key', key, defaultValue: null),
          ];
          return true;
        }());
        if (stream.completer == null) {
          stream.setCompleter(_ErrorImageCompleter());
        }
        stream.completer!.reportError(
          exception: exception,
          stack: stack,
          context: ErrorDescription('while resolving an image'),
          silent: true, // could be a network error or whatnot
          informationCollector: collector,
        );
      },
    );
    return stream;
  }
  /// Called by [resolve] to create the [ImageStream] it returns.
  ///
  /// Subclasses should override this instead of [resolve] if they need to
  /// return some subclass of [ImageStream]. The stream created here will be
  /// passed to [resolveStreamForKey].
  @protected
  ImageStream createStream(ImageConfiguration configuration) {
    return ImageStream();
  }

NetworkNoRetryTileProvidergetImage办法回来的是FMNetworkNoRetryImageProvider的实例,这是flutter_map自己完成的一个ImageProvider子类,无妨就让咱们的ImageProvider继承它。

完成

一开端,咱们就遇到了一个大麻烦,path_provider 包供给的获取缓存途径的getTemporaryDirectory()办法是异步的,而TileProvidergetImage办法是同步的,无法在后者中调用前者,因而,我创立了一个静态类AppDir,咱们知道静态类是单例的,因而能够让途径一次获取,大局调用。

import 'dart:io';
import 'package:path_provider/path_provider.dart';
class AppDir {
  static Directory data = Directory('');
  static Directory cache = Directory('');
  static setDir() async {
    data = await getApplicationDocumentsDirectory();
    cache = await getTemporaryDirectory();
  }
}

咱们需要修改主函数,以在运用启动时保证获取到系统途径:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  while (AppDir.data.path.isEmpty || AppDir.cache.path.isEmpty) {
    await AppDir.setDir();
  }
  runApp(const MyApp());
}

下面,咱们创立两个子类,继承NetworkNoRetryTileProviderFMNetworkNoRetryImageProvider

import 'dart:async';
import 'dart:developer' as dev;
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_no_retry_image_provider.dart'; // this line will be warned as "Don't import Implementation files from other package", just ignore it.
import 'package:naturalist/entity/app_dir.dart';
import 'package:path/path.dart' as path;
class CacheTileProvider extends NetworkNoRetryTileProvider {
  String tileName;
  CacheTileProvider(
    this.tileName,{  // 这是新增加的参数,用于区别不同的瓦片图源;下面两个参数继承自NetworkNoRetryTileProvider
    super.headers,
    super.httpClient,
  });
  @override
  ImageProvider getImage(Coords<num> coords, TileLayer options) {
    File file = File(path.join(
        AppDir.cache.path,  // 运用缓存途径
        'flutter_map_tiles',  // 标明这是 flutter_map 运用的目录
        tileName,  // 以tileName区别不同的瓦片图源
        coords.z.round().toString(),
        coords.x.round().toString(),
        '${coords.y.round().toString()}.png'));
    if (file.existsSync()) {
      return FileImage(file);
    } else {
      return NetworkImageSaverProvider(
        getTileUrl(coords, options),
        file,
        headers: headers,
        httpClient: httpClient,
      );
    }
  }
}
class NetworkImageSaverProvider extends FMNetworkNoRetryImageProvider {
  File file;
  NetworkImageSaverProvider(
    super.url,
    this.file, {  // 新增加的参数,图片保存的目标文件。
    HttpClient? httpClient,
    super.headers = const {},
  });
  @override
  ImageStream createStream(ImageConfiguration configuration) {  // 重写createStream,为stream增加listener
    ImageStream stream =  ImageStream();
    ImageStreamListener listener = ImageStreamListener(imageListener);
    stream.addListener(listener);
    return stream;
  }
  void imageListener(ImageInfo imageInfo, bool synchronousCall){
    ui.Image uiImage = imageInfo.image;
    _saveImage(uiImage);
  }
  Future<void> _saveImage (ui.Image uiImage) async {  // 异步保存图片
    try {
      Directory parent = file.parent;
      if (! await parent.exists()){
        await parent.create(recursive: true);  // 假如目录不存在,逐级创立。
      }
      ByteData? bytes = await uiImage.toByteData(format: ui.ImageByteFormat.png);
      if (bytes != null) {
        final buffer = bytes.buffer;
        file.writeAsBytes(buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes));  // 将二进制数据写入图片文件。
      }
    } catch (e) {
      dev.log(e.toString());
    }
  }
}

更新TileLayer,更新后主文件如下:

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'entity/cache_tile_provider.dart';
import 'entity/app_dir.dart';
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  while (AppDir.data.path.isEmpty || AppDir.cache.path.isEmpty) {
    await AppDir.setDir();
  }
  runApp(const MyApp());
}
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  static const String _title = 'flutter map example';
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(
          title: const Text(_title),
        ),
        body: FlutterMap(
          options: MapOptions(),
          children: [
            TileLayer(
              tileProvider: CacheTileProvider('osm'),
              urlTemplate: "",
            ),
          ],
        ),
      ),
    );
  }
}

通过实际测验,未缓存区域的加载速度与默许状况没有可感知的差别,已缓存区域的加载速度显着快于默许状况。检查手机文件系统,能够看到,访问过的瓦片图层都已被缓存,断网状况下,已缓存的区域仍然能够显现地图:

flutter_map 瓦片图层本地缓存踩坑记。

免责声明

一些在线地图服务供给者不允许开发者在本地储存自己的地图数据,请在运用时仔细阅读地图服务供给者的答应协议,并仅在服务供给者允许的前提下储存数据。对于读者运用本文代码下载未经答应的地图数据的行为,一概与本文作者无关。