Open4

Flutter flame🔥でアプリ起動時のリソースダウンロードとImageCacheフローを考える

NoriyoshiSatoNoriyoshiSato

モチベーション

Flutterのflameという2Dゲームエンジンのpackageを使ってゲームを作っています。
https://docs.flame-engine.org/latest/

アプリを起動した際に

  • 画像リソースのS3からのダウンロード
  • ローカルディレクトリへの保存
  • FlameGame内でのキャッシュ

を行うための一連の流れを作成したのでまとめていきます。

NoriyoshiSatoNoriyoshiSato

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.

https://pub.dev/documentation/path_provider/latest/path_provider/getApplicationSupportDirectory.html

// 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を成形して指定ディレクトリに保存します

NoriyoshiSatoNoriyoshiSato

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/ 配下のファイルを参照するようにデフォルト設定されていることがわかります
公式にも該当ディレクトリに配置することが記載されています。
https://docs.flame-engine.org/latest/flame/structure.html

こちらの 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.

https://api.flutter.dev/flutter/services/AssetBundle-class.html

今回はあくまで 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');
NoriyoshiSatoNoriyoshiSato

最後に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);
      }
    }
}