前语
flutter_map 是一个基于leaflet开发的flutter包,用于在flutter运用中加载瓦片地图,但是默许并不供给本地缓存功用——这就意味着运用每次重新启动,所有瓦片都要重新下载,这显然会花费大量的流量,在网络不良的情况下也会影响运用的正常作业。
其实已经有开发者为flutter_map写了一个插件 flutter_map_tile_caching 来供给瓦片图层缓存服务,但是恕我愚钝,愣是没看懂这玩意怎样用,所以就自己完成了一个带缓存功用的TileProvider
。
剖析
flutter_map 的FlutterMap
和TileLayer
是StatefulWidget
抽象类的子类,后者能够被增加为前者的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',
})
这里面许多参数都是见名知义的,比如tileSize
、minZoom
、maxZoom
等等,能够注意到在上面的示例中只供给了urlTemplate
一个参数,这是由于TileLayer
类默许运用的TileProvider
是NetworkNoRetryTileProvider
,它依据url从网络上的在线地图服务获取地图数据,假如不供给urlTemplate
,运行时会报Unexpected null value.
。
NetworkNoRetryTileProvider
是TileProvider
抽象类的子类,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
。
resolve
是ImageProvider
露出给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();
}
NetworkNoRetryTileProvider
的getImage
办法回来的是FMNetworkNoRetryImageProvider
的实例,这是flutter_map自己完成的一个ImageProvider
子类,无妨就让咱们的ImageProvider
继承它。
完成
一开端,咱们就遇到了一个大麻烦,path_provider 包供给的获取缓存途径的getTemporaryDirectory()
办法是异步的,而TileProvider
的getImage
办法是同步的,无法在后者中调用前者,因而,我创立了一个静态类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());
}
下面,咱们创立两个子类,继承NetworkNoRetryTileProvider
和FMNetworkNoRetryImageProvider
:
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: "",
),
],
),
),
);
}
}
通过实际测验,未缓存区域的加载速度与默许状况没有可感知的差别,已缓存区域的加载速度显着快于默许状况。检查手机文件系统,能够看到,访问过的瓦片图层都已被缓存,断网状况下,已缓存的区域仍然能够显现地图:
免责声明
一些在线地图服务供给者不允许开发者在本地储存自己的地图数据,请在运用时仔细阅读地图服务供给者的答应协议,并仅在服务供给者允许的前提下储存数据。对于读者运用本文代码下载未经答应的地图数据的行为,一概与本文作者无关。