ゲーム開発の備忘録

趣味のゲーム開発でのノウハウや、技術的に嵌ったポイントを忘れないように書き記しておくブログです。

libGDXでPlay Asset Deliveryに対応する

はじめに

Androidアプリをリリースする場合、Google Play Storeにて、AAB(Android App Bundle)を提出する必要があります。しかし、AABにはサイズ制限があり、150MBを超過すると、そのままではアップロードできなくなってしまいます。

以前は、APK拡張という方法でリソースをパッチに分割する方法がありましたが、それは廃止され、AABで一元的に管理する流れになっていました。
deep-verdure.hatenablog.com

では、APK拡張亡き現在ではどのように対処するのかというと、PAD(Play Asset Delivery)という手段で、リソースをAsset Packに移動します。
PADに対応することで、Android App Bundleのサイズのうち、リソースのサイズが除外され、Google Play Storeにリリースできるようになります。

PADにつきましては、以下の公式リファレンスを参照ください。
developer.android.com


さて、今回の問題は、やはりlibGDXプロジェクトでPAD対応するための情報が一切存在しないことでした。
色々試行錯誤して対応したのですが、今後のために知見をまとめておくことにします。

配信モードについて

PADには以下の3つの配信モードがあります。

・install-time
・fast-follow
・on-demand

今回は、このうちのinstall-timeで対応しました。install-timeは、アプリ起動直後からリソースにアクセス可能となるタイプの配信モードです。最も実装が簡単になるため、今回はこの配信モードを選択しました。
なお、fast-followはアプリダウンロード時にリソースを同時にダウンロードします。アプリ側では、リソース側のダウンロードが完了したかどうかの確認等の制御ロジックを組み込む必要が出てきます。
また、on-demandはその名の通りオンデマンド配信が可能になります。非常にリッチな機能ですが、fast-followと同様にダウンロード状況の制御ロジックが必要となります。fast-followとon-demandを用いる場合は、fast-follow、on-demandを利用する前提での設計を開発初期の段階から進めないと厳しい印象があります。

今回は趣味で3年近くメンテナンスしているプロジェクトへの対応となりましたので、install-time以外の配信モードでの対応は無謀と考えました。(といってもinstall-timeの対応も十分難しいのですが……)

PAD対応したAABのビルド

まずは、PADに対応したAABをビルドする必要があります。基本的には、以下の公式リファレンスの手順に沿って進めれば問題ありません。
今まで、android/assetsに配置していたアセットを、全てAsset Packに移動させます。
developer.android.com

libGDX特有の注意点として、ios-moeやdesktop等の他のプロジェクトでは、ワーキングディレクトリがandroid/assetsに設定されています。他プロジェクトのワーキングディレクトリをAsset Packのディレクトリに指し直すことで、最小限の修正で他のプロジェクトのビルドへの影響を無くすことができます。

コード上でのリソースの読込み

PAD対応したAABがビルドできたら、コード上で実際にリソースを読み込めるようにします。リソースはInputStream経由で読み込むことになりますが、libGDXでは、リソースの読込みはファイルからの読込みしか標準で対応されていません。リソースローダを自作することで対応が一応可能ですが、デフォルトのローダが実装している非同期処理を全て自前で実装しなければならないため、非常にコストがかかります。そこで、InputStreamをもとに一旦ファイルとして出力し、そのファイルをリソースとして読込むことにしました。

ここで問題になるのが、libGDXのファイルタイプです。libGDXではファイルをinternal, external, local, absoluteの4つのファイルタイプに分けて扱います。libGDXのチュートリアル通りに進めると、リソースは全てinternalで扱うべきと習うはずです。しかし、internalファイルはワーキングディレクトリ(デフォルトではandroid/assets)に、事前に配置されているアセットからしか生成することができません。今回の場合、Asset Packからリソースを読込むところからのスタートとなるため、internalファイルでは対応できないのです。今回は、一時ファイルとしてlocalファイルにAsset Packから読込んだリソースを出力し、それをlibGDX側で読込ませることにしました。

まずは、core側からリソースロード/アンロードを指示できるようにインターフェイスを用意します。

interface IAssetLoader {
 fun loadAsset(assetFilePath: String): String
 fun unloadAsset(tmpFilePath: String)
}

インターフェイスのメソッドの実装は、androidプロジェクト側に記述します。まずはloadAsset()の実装です。

override fun loadAsset(assetFilePath: String): String {
 if (resourceIDMap.get(assetFilePath) != null) {
  return ""
 }

 //一時ファイルを識別するIDを生成する
 var id: Int
 do {
  id = MathUtils.random(0, Int.MAX_VALUE - 1)
 } while (resourceIDMap.containsValue(id, true))

 //一時ファイルにリソースを書出す
 val split = assetFilePath.split(".")
 val suffix = split[split.size - 1]
 val tmpFile = Gdx.files.local("./usc/$id.$suffix")
 val input = assetManager.open(assetFilePath)
 tmpFile.writeBytes(input.readBytes(), false)
 input.close()
 resourceIDMap.put(tmpFile.path(), id)

 return tmpFile.path()
}

localファイルは、Androidではアプリケーションの内部ストレージからの相対パスの位置に生成されます。書出し毎に内部ストレージにディレクトリ階層を作ることは現実的ではないため、内部ストレージのルートに一律ですべての一時ファイルを生成することにしました。従って、ファイル名が重複しないように、ファイル名をIDとして管理する必要があります。
loadAsset()では、もともとのアセットのパス(=Asset Packのルートからのアセットの相対パス。desktop等の他のプロジェクトにおけるワーキングディレクトリのルートからのアセットの相対パスに相当)をもとに、Asset Pack内のアセットを読込み、一時ファイルにアセットの内容を出力して、その一時ファイルのパスを返します。この戻り値をcore側で利用することになります。

unloadAsset()の実装は以下の通りです。

override fun unloadAsset(tmpFilePath: String) {
 val tmpFile = File(tmpFilePath)
 tmpFile.delete()
 resourceIDMap.remove(tmpFilePath)
}

core側から一時ファイルのパスを受け取り、それをもとにlocalファイルを削除します。

次に、core側でのリソースの管理部を修正します。まず初めに、AssetManagerのコンストラクタに、LocalFileHandleResolverのインスタンスを渡します。デフォルトでは、AssetManagerはinternalファイルしか扱うことができません。コンストラクタに、LocalFileHandleResolverのインスタンスを渡すことで、AssetManagerがlocalファイルを扱えるようになります。これを忘れると、AssetManager#update()でGdxRuntimeExceptionが送出されてしまいます。

private val assetManager =
            if (GameMain.platform == GameMain.Platform.ANDROID) {
                AssetManager(LocalFileHandleResolver())
            } else {
                AssetManager()
            }

core側でのリソース管理の例として、SEの管理を挙げます。まずは、SEの読込みです。

fun loadSE(key: String, path: String) {
 if (seMap.get(key) != null) {
  return
 }

 val actualPath =
                if (GameMain.platform == GameMain.Platform.ANDROID) {
                    GameMain.assetLoader!!.loadAsset(path)
                } else {
                    path
                }
 assetManager.load(actualPath, Sound::class.java)
 seMap.put(key, actualPath)
}

actualPathをもとに、SEを読込んでいます。Androidの場合は先程のloadAsset()を呼び出して、一時ファイルのパスを取得します。

次に、SEの破棄です。

private fun unloadSE(key: String) {
 if (seMap.get(key) == null) {
  return
 }

 assetManager.unload(seMap[key])
 if (GameMain.platform == GameMain.Platform.ANDROID) {
  GameMain.assetLoader!!.unloadAsset(seMap[key])
 }

 seMap.remove(key)
}

Androidの場合は、先程のunloadAsset()を呼び出して、一時ファイルも削除するようにしています。

今回の対応では、SEの再生や音量調整等の他の操作のコードには手を加えずに済みました。SEの他には、テクスチャ、BGM、CSVファイル、シェーダファイルの読込み/破棄のコードを同様に改修しています。BGM周りはもともとlibGDXのバグ対応で複雑な処理をしていたため、対応に手間取りました。また、テクスチャも、端末メモリを圧迫しないようにオンデマンドロードを実装していたのですが、その部分の改修に手を焼かされました。

APKのテスト

コードの改修も完了したら、改修版のAPKをテストして、問題が発生しないことを確認します。APKのテスト方法は、以下のリファレンス通りに実施すれば問題ありません。
developer.android.com

おわりに

今回の方法で、無事にAABをリリースすることができるようになりましたが、もしかするとパフォーマンスが悪化しているかもしれません。(シミュレータや手元の端末では特に問題なさそうでしたが。BGMのロードが少し遅い?)
パフォーマンスチューニングについては、継続して実施していく所存です。