【Unity】Addressablesの危ない使い方

コラム

Unityのリソース管理と言えば、アセットバンドルです。
アセットバンドルのアクセスにはAddressablesを使用します。
Addressablesもリリースされて結構日が経ち、バージョンも頻繁に上がって機能も増えていっています。
リリース当初は機能が足りなかったり、動作が不安定だったりもしましたが、今ではよほど古いプロジェクトや独自のアセットマネージャが無い限り、Addressablesを利用しているのではないかと思います。
await機能が利用できるようになり、アセットの読み込みはだいぶスッキリしたコードを書けるようになりました。

とはいえ、リソース読み込み周りは、昔からトラブルが起きやすいところです。
特に中規模以上の開発では、アセットを必要に応じて外部(ネットワークやディスクなど)から読み込むようになり、内包していた時には起きなかったエラーやバグに遭遇したりします。
しかも再現性が高くないケースや特定の条件下でのみ発生したり、対応に苦労するケースも少なくありません。

この辺りのトラブルに強いかどうかが、プロとしてのウデの見せ所になってくるように思います。
今回は、身近で起きたケースを元に、Addressablesの危ない使い方を紹介したいと思います。

スポンサーリンク

「Addressablesの使い方おかしくない?」

スタッフが書いたアセットの読み込み周りのコードをふと眺めた時に、「おや?」と思えるコードを見つけました。

GameObject obj = null;
Addressables.LoadAssetAsync(assetReferences).Completed += op =>
{
    obj = op.Result;
    Addressables.Release(op);
};
await UniTask.WaitUntil(() => obj != null);
var go = Instantiate(obj);

LoadAssetAsyncをawaitを使わずにわざわざデリゲートで処理してたりとかもモヤモヤするんですが、一番の問題点はアセットを読み込んだあとすぐReleaseしているところです。
LoadAssetAsyncが返すAsyncOperationHandleは、その名の通りハンドルです。
ハンドルは、アセットを管理するための管理札のようなもので、アセットを使っている間、保持・保管しておき、アセットを使い終わったら返却します。

しかしこのコードでは、アセットを読み込んですぐハンドルを返却してしまっています。
ハンドルを返却すると、そのアセットは使用済みとしてアセットが解放されてしまいます。
解放したアセットを使ってInstantiateをしようとしたら、当然挙動がおかしくなるはず。

「でもちゃんと動いてますよ?」

これ大丈夫なの?
とスタッフに聞いたところ、「問題なく動いてますよ」とのこと。
んなバカな…。

というわけで、もう少しちゃんと調べることにしました。

「シミュレーターモードなんじゃないの?」

真っ先に疑ったのは、Addressablesの動作モードです。
Addressablesには、PlayModeScriptという動作モードが3種類あります。(2023年12月時点)

これは開発スピードを上げるために、アセットバンドル化しなくてもアセット読み込みが出来るようにする便利機能です。
開発中のデフォルトは「Use Asset Database」です。
アセットバンドルを作成しなくてもアセットの変更がすぐに反映されるため、開発中は重宝するモードです。

ただしこのモードではAddressablesの本来の挙動とは大きく異なった動きをします。
なので最初はこのモードのせいなのではないかと思いました。
本来の挙動にするには「Use Existing Build」のモードを使用することになります。
このモードは予めアセットバンドルを作成しておく必要があり、実行時にはホスティングサービスを起動しておくか、どこかのサーバーにアセットバンドルをアップロードしておく必要があります。

ただ本来の挙動で試してみても、ちゃんとインスタンスが生成できてしまいます。
あれー?

「すぐに解放されずにキャッシュが残ってる?」

こういうリソース管理は解放後にすぐにまた読み込まれるケースを想定して、解放されたとしても一定の間メモリにとどまって再利用できるようにしてあるケースなどがあります。
メインメモリが潤沢で、リソースの読み込みに時間がかかるような実行環境などです。
PCの場合はSSDなどでアクセスが高速なため、あまり気になりませんが、DVDなどのディスク媒体やUSB・SDカードなども、比較的読み込みに時間がかかる部類に入り、そういった環境ではメモリキャッシュによる高速化は結構効果的です。

そういった何かしらのキャッシュが効いていて、たまたま動いているのかもしれないと思い、Resources.UnloadUnusedAssetsを実行してみることにしました。

GameObject obj = null;
Addressables.LoadAssetAsync(assetReferences).Completed += op =>
{
    obj = op.Result;
    Addressables.Release(op);
};
await UniTask.WaitUntil(() => obj != null);
await Resources.UnloadUnusedAssets(); // 未使用のアセットをアンロードする
var go = Instantiate(obj);

ちなみによく似たメソッドで、AssetBundle.UnloadAllAssetBundles とかありますけど、こっちは使っているアセットまで強制的にアンロードしたりするので注意です。
さて、試したところ変化無し。利用できてしまいます。
うーん。

「アセットバンドルの設定の問題か?」

アセットバンドルの設定も見ていきます。
Addressablesはグループ毎に様々な設定を行います。
今回はこの設定の中にある「Bundle Mode」を見てみます。
デフォルトでは、Pack Togetherに設定されています。
この設定では、同じグループ内に設定されたリソースが1つのアセットバンドルファイルとして生成されます。

これをPack Separatelyに変更します。
Pack Separatelyにすると、各リソース毎にアセットバンドルファイルが生成されます。

すると症状が発生!

いつまで経ってもUniTask.WaitUntilから抜けることが出来ないか、抜けたとしてもInstantiate時に「MissingReferenceException: The object of type ‘GameObject’ has been destroyed but you are still trying to access it.」といったエラーが発生します。

「どういうこと?」

実は読み込もうとしていたアセットが入っていたグループには、別のアセットが含まれており、そのアセットが既に読み込まれていたのです。
そのためAddressables.Releaseを行っても、アセットバンドルが解放されず、アクセスが可能だったという訳です。
Pack Separatelyに変更したことにより、アセットバンドルが独立して解放されるようになったため、不具合が発生するようになりました。
Pack Togetherでも、パッキングされたアセットをどれも読み込んでなかった場合は、同じように解放されるため不具合が発生します。
つまり設定や状況によって出たり出なかったりするのです。
こわー!!

その他にも色々な問題点が

・キャンセル対応

例えば読み込み中に処理そのものがキャンセルされた場合でも、キャンセルトークンが使用されていないため、Instantiateが行われてしまいます。
読み込みが早いような環境では問題が出にくいですが、読み込みが完了していないのに大元の画面が閉じられた場合など、別の画面でいきなりプレハブが表示されたりしてしまいます。
特に読み込みに時間がかかるような環境の場合は注意が必要です。
開発中はエミュレーションモードで開発するため、不具合の発見が遅れるケースです。

・ハンドルの使いまわし

AsyncOperationHandleを独自にキャッシュして使いまわしていました。
しかも参照カウンターまで用意して管理を行っていました。

AsyncOperationHandleは原則1インスタンス1ハンドルの運用が安全です。
つまり同じリソースを複数使う場合も、1つ1つLoadAsyncしてハンドルを複数個持ちます。
そしていらなくなったら個々にハンドルをReleaseします。
ただしこれは複数のインスタンスが別々のタイミングで生成・破棄される場合で、タイミングが予測しにくいケースの場合です。
必ずまとめていっぺんに生成・破棄されるような場合では、1つのハンドルで運用しても大丈夫です。

既にアセットが読まれている場合、後発のLoadAsyncは読み込み処理を行わず、既存のアセットをシェアします。
AsyncOperationHandleは内部に参照カウンターを持っています。
なので外部でさらにカウンターを持つ必要など無いのです。

・ハンドルの開放時の例外

AsyncOperationHandleは構造体のためnullチェックが出来ないので、予め用意したAsyncOperationHandleの変数が使用中かどうか判別がしにくいです。
未使用のハンドルをAddressables.Release()すると例外を吐きます。
とはいえ、OnDestroy時などは使っている・いないに関わらず、念のためAddressables.Release()しておきたいケースなどがあります。
リリース時にはtry/catchで括っておくのが良いと思います。

・読み込みエラー対応

エミュレーションモードで開発中はまず読み込み中にエラーが起きません。
(アドレスが存在しないというエラーは良く出ますが、それはただの登録忘れやスペルミスなどなのですぐ解決できます)
なので結構エラー対応が疎かになりがちです。
特に実行ファイルに内包するLocalLoadPathアセットの場合、読み込みで失敗するケースはほぼ無いので気にならないですが、RemoteLoadPathに切り替えた途端、たまにアプリが止まってしまうなど、再現性の低い不具合に悩まされてしまう場合があります。

結論「よく分かってない人に直接Addressablesを触らせてはいけない」

Addressablesは従来のアセットバンドルアクセスに比べて、かなり使いやすい仕組みになりました。
Resourcesのように気軽に扱えるぐらいまでにはなっていると思いますが、やはりそこはアセットバンドルです。
外部読み込みはやっぱり気を遣う必要があります。
特にPCのように贅沢な実行環境ではない、スマホやコンシューマ機では、大量のアセットの管理にはノウハウが必要ですし、扱いにも多少クセが出ます。
各プログラマーにAddressablesを直接触らせるのではなく、ラッパーを用意したりして、変な実装がやトラブルが起きないようにするのがプログラムリーダーの責務だと思います。

まあ小規模開発の場合は全部LocalLoadPathでいけるし、開放忘れてようがリークしようが大して影響はないので、何も考えずに好きに使えば良いと思います。
とりあえず使わないと覚えられないですし。

ある程度慣れたら、ちゃんと学びなおすということをするのが良いと思います。

コメント

タイトルとURLをコピーしました