Flutter flame🔥でアプリ起動時のリソースダウンロードとImageCacheフローを考える
モチベーション
Flutterのflameという2Dゲームエンジンのpackageを使ってゲームを作っています。
アプリを起動した際に
- 画像リソースのS3からのダウンロード
- ローカルディレクトリへの保存
- FlameGame内でのキャッシュ
を行うための一連の流れを作成したのでまとめていきます。
zipの解凍とローカルディレクトリへの保存
まずはローカル上で画像ファイルを含むzipファイルの解凍とアプリローカルディレクトリへの保存機構を考えます。
zipの解凍には archive というpackageを使います。
$ flutter pub add archive
また今回は /assets/zips
ディレクトリに配置した .zipを参照できるようにします
// /assets/zips 以下のzipファイルを解凍
// ZIPファイルのパス
final zipFilePath = 'assets/zips/local.zip';
// 解凍先のディレクトリ
final outputDir = (await getApplicationSupportDirectory()).path;
debugPrint('ZIPファイルを解凍します $zipFilePath');
// ZIPファイルを読み込む
final value = await rootBundle.load(zipFilePath);
final wzzip =
value.buffer.asUint8List(value.offsetInBytes, value.lengthInBytes);
final archive = ZipDecoder().decodeBytes(wzzip);
// 解凍先のディレクトリを作成
Directory(outputDir).createSync(recursive: true);
// 各ファイルを解凍
for (final file in archive) {
final filename = file.name;
final filePath = '$outputDir/$filename';
if (file.isFile) {
final data = file.content as List<int>;
File(filePath)
..createSync(recursive: true)
..writeAsBytesSync(data);
} else {
await Directory(filePath).create(recursive: true);
}
}
final outputDir = (await getApplicationSupportDirectory()).path;
解凍先は getApplicationSupportDirectory()
を指定します。
ユーザーにみられることを想定しないファイルを保存するのに利用するディレクトリ
Use this for files you don’t want exposed to the user. Your app should not use this directory for user data files.
// ZIPファイルのパス
final zipFilePath = 'assets/zips/local.zip';
// ZIPファイルを読み込む
final value = await rootBundle.load(zipFilePath);
rootBundle.load
でassetsに配置したリソースを読み込みます。
補足
ガイドでは なるべくrootBundleは使わないで DefaultAssetBundle.ofを使ってね と案内がありますが今回はFlameGameでBuildContextの外のお話なのと、FlameGame自体のデフォルトasset dirがrootBundleなので気にせず使っています
final wzzip =
value.buffer.asUint8List(value.offsetInBytes, value.lengthInBytes);
final archive = ZipDecoder().decodeBytes(wzzip);
あとはarchiveを使って解凍されたByteDataを成形して指定ディレクトリに保存します
FlameGameでのキャッシュ
ローカルに保存した画像ファイルを扱うためには Images.load()
が必要ですが、都度読み込む形から
なるべくアプリの起動時に必要なファイルを事前読み込みしてランタイムでの動作を軽量にしたいです。
- FlameGame classのonLoadで画像リソースをキャッシュ
- 利用箇所で
Images#fromCache()
から画像を表示できるようにする
を目指します
そのためにImagesのデフォルトでの動作を追いましょう
/// images.dart
class Images {
Images({
String prefix = 'assets/images/',
AssetBundle? bundle,
}) : _prefix = prefix,
bundle = bundle ?? Flame.bundle
コンストラクタを見ると assets/images/
配下のファイルを参照するようにデフォルト設定されていることがわかります
公式にも該当ディレクトリに配置することが記載されています。
こちらの prefix
を変更することでImagesで管理するファイルの構造を変更できます。
また第二引数の bundle ですが、デフォルトでは Flame.bundle
が指定されており、こちらが前述の rootBundle
が参照されています。
rootBundleはあくまで assets/
配下を参照するためのものであり、今回のユースケースでは別のディレクトリを指定する必要があるため、bundleの参照を変更するのがまずは第一歩となりそうです。
AssetBundleとはアプリケーションで使われる画像や文字列、json情報などasset情報を管理するクラスです。
またデフォルトのPlatformAssetBundleではなくネット上のファイルを参照するNetworkAssetBundleなどもあります。
Asset bundles contain resources, such as images and strings, that can be used by an application. Access to these resources is asynchronous so that they can be transparently loaded over a network (e.g., from a NetworkAssetBundle) or from the local file system without blocking the application's user interface.
今回はあくまで getApplicationSupportDirectory()
の領域を参照したいため以下のようなカスタムAssetBundleを作成しました
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
class ApplicationSupportDirectoryAssetBundle extends AssetBundle {
Future<ByteData> load(String key) async {
final savedPath = await getApplicationSupportDirectory();
final file = File('${savedPath.path}/$key');
if (file.existsSync()) {
final bytes = await file.readAsBytes();
return ByteData.view(bytes.buffer);
}
throw ArgumentError('File not found: $key');
}
}
上記のローカルディレクトリを参照するAssetBundleをImagesに渡すことで、該当ディレクトリのリソースファイルを読み込むことができます
key引数には Images.load() で指定したパスがそのまま渡される挙動になります
final localImages = Images(
bundle: ApplicationSupportDirectoryAssetBundle(),
);
あとは必要なリソースファイル名を指定して Images#loadAll
を呼ぶことでキャッシュへの保存を実現します
final localImages = Images(
bundle: ApplicationSupportDirectoryAssetBundle(),
);
final fileNames = await Directory(outputDir).list().map((e) {
if (e is File) {
return e.path.split('/').last;
}
return '';
}).toList();
await _localImages.loadAll(fileNames);
※ ディレクトリ構成によって prefix
を付与してください
あとは通常通り fromCache
で利用箇所からリソースパスを指定するだけで完了します
final cacheImage = game.localImages.fromCache('local_image.png');
最後にS3バケットからのファイルのDLですが、今回は主題から逸れるので割愛・簡略化しますがシンプルにhttpでzipをダウンロードしてこれまでの処理に載せるだけになります
今回はS3の署名付きURLを使って時間限定になりますがURLからzipをダウンロードします
Future<void> onLoad() async {
final response = await http.get(Uri.parse('S3の署名付きURL'));
await _unzip(
byteData: ByteData.view(response.bodyBytes.buffer),
outputDir: outputDir,
);
}
Future<void> _unzip({
required ByteData byteData,
required String outputDir,
}) async {
final wzzip = byteData.buffer.asUint8List(
byteData.offsetInBytes,
byteData.lengthInBytes,
);
final archive = ZipDecoder().decodeBytes(wzzip);
// 解凍先のディレクトリを作成
Directory(outputDir).createSync(recursive: true);
// 各ファイルを解凍
for (final file in archive) {
final filename = file.name;
final filePath = '$outputDir/$filename';
if (file.isFile) {
final data = file.content as List<int>;
File(filePath)
..createSync(recursive: true)
..writeAsBytesSync(data);
} else {
await Directory(filePath).create(recursive: true);
}
}
}