flutter_map 瓦片圖層本地快取踩坑記。

語言: CN / TW / HK

前言

flutter_map 是一個基於leaflet開發的flutter包,用於在flutter應用中載入瓦片地圖,但是預設並不提供本地快取功能——這就意味著應用每次重新啟動,所有瓦片都要重新下載,這顯然會花費大量的流量,在網路不良的情況下也會影響應用的正常工作。

其實已經有開發者為flutter_map寫了一個外掛 flutter_map_tile_caching 來提供瓦片圖層快取服務,但是恕我愚鈍,愣是沒看懂這玩意怎麼用,於是就自己實現了一個帶快取功能的TileProvider

分析

flutter_map 的FlutterMapTileLayerStatefulWidget抽象類的子類,後者可以被新增為前者的children,例如,我們可以這樣實現一個最簡單的 flutter_map:

```dart 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: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", userAgentPackageName: 'flutter_map_example', ), ], ), ), ); } } ```

FlutterMap類實際上只是提供了一個空間,或者說一個座標系,用來放置地圖圖層,所以它與我們要處理的瓦片地圖快取無關。而TileLayer類才是顯示地圖圖層的元件,它的建構函式有非常多的引數:

dart 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子類的程式碼(如下)

```dart abstract class TileProvider { Map 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]; } } ```

```dart class NetworkNoRetryTileProvider extends TileProvider { NetworkNoRetryTileProvider({ Map? headers, HttpClient? httpClient, }) { this.headers = headers ?? {}; this.httpClient = httpClient ?? HttpClient() ..userAgent = null; }

late final HttpClient httpClient;

@override ImageProvider getImage(Coords coords, TileLayer options) => FMNetworkNoRetryImageProvider( getTileUrl(coords, options), headers: headers, httpClient: httpClient, ); } ```

可以發現,除了建構函式之外,NetworkNoRetryTileProvider僅重寫了TileProvider抽象類的getImage一個方法,它的返回值是一個ImageProvider例項。我們知道ImageProvider的主要用途是作為Image元件的image引數的型別,用於Image元件中圖片的獲取和載入。

因此,我們就有了一個實現快取功能的思路,實現一個自己的TileProvider並重寫getImage方法,以虛擬碼方式描述如下:

dart @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,最終導致應用強制關閉。那麼是否可以這樣修改呢:

dart @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 …)。

```dart @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 = () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Image configuration', configuration), DiagnosticsProperty('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,我們知道靜態類是單例的,因此可以讓路徑一次獲取,全域性呼叫。

```dart 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(); } } ```

我們需要修改主函式,以在應用啟動時確保獲取到系統路徑:

```dart void main() async { WidgetsFlutterBinding.ensureInitialized();

while (AppDir.data.path.isEmpty || AppDir.cache.path.isEmpty) { await AppDir.setDir(); } runApp(const MyApp()); } ```

下面,我們建立兩個子類,繼承NetworkNoRetryTileProviderFMNetworkNoRetryImageProvider

```dart 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 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 _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,更新後主檔案如下:

```dart 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: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", ), ], ), ), ); } }

```

經過實際測試,未快取區域的載入速度與預設狀態沒有可感知的差別,已快取區域的載入速度明顯快於預設狀態。檢視手機檔案系統,可以看到,訪問過的瓦片圖層都已被快取,斷網狀態下,已快取的區域依然可以顯示地圖:

image.png

免責宣告

一些線上地圖服務提供者不允許開發者在本地儲存自己的地圖資料,請在使用時仔細閱讀地圖服務提供者的許可協議,並僅在服務提供者允許的前提下儲存資料。對於讀者使用本文程式碼下載未經許可的地圖資料的行為,一概與本文作者無關。