Edited at

[Unity 2018.2] AssetBundleのキャッシュを完全に理解する


はじめに

AssetBundleのキャッシュと言えば、Unity5.xくらいまでは使いにくいともっぱらの評判でした。

バージョン管理の仕様がよく分からなかったり、個別削除ができなかったり…。

そのあたりの話は、以下の記事が詳しくまとまっています。

しかし、上記記事でも触れられているように、Unity2017からはCachingクラスにAPIが追加され、個別削除などができるようになった様です。

そこで、現時点においてAssetBundleのキャッシュ周りってどんな感じなの?というのを、改めて全体的に調査してまとめようと思ったのがこの記事です。


調査環境


  • Unity 2018.2.4f1

  • MacBook Pro (15-inch, 2016) macOS Sierra

  • iPhone6S iOS 11.4.1 (15G77)

  • HUAWEI Ascend G620S Android 4.4.4

2017.1からは特にAPIが変更されたりはしていないようなので、バグでもない限りは動作も変わっていないと思われます。

Macの場合、UnityEditorでのAssetBundleのキャッシュは /Users/{USER_NAME}/Library/Caches/Unity/ に保存されるため、それを見て確認しています。


キャッシュを利用するための準備

キャッシュを利用するには、Caching.readyがtrueになるのを待つ必要があります。

コルーチンか何かで適当に値を監視しておくのが良いでしょう。

キャッシュの数が増えるとこれが結構遅くなる、という話もありましたが、今はどうなんでしょうね。暇があったらベンチマークを取りたい。

取ってみました [Unity2018.2] Caching.readyはなぜ遅いのか? & ベンチマークとってみた


AssetBundleがキャッシュされる条件

以下のAPIを使ってロードした時にキャッシュが行われます。



  • WWW.LoadFromCacheOrDownload


    • LoadFromCacheOrDownload (string url, int version, uint crc);

    • LoadFromCacheOrDownload (string url, Hash128 hash, uint crc);

    • LoadFromCacheOrDownload (string url, CachedAssetBundle cachedBundle, uint crc);




  • UnityWebRequestAssetBundle.GetAssetBundle


    • GetAssetBundle (string uri, uint version, uint crc);

    • GetAssetBundle (Uri uri, uint version, uint crc);

    • GetAssetBundle (string uri, Hash128 hash, uint crc);

    • GetAssetBundle (Uri uri, Hash128 hash, uint crc);

    • GetAssetBundle (string uri, CachedAssetBundle cachedAssetBundle, uint crc);

    • GetAssetBundle (Uri uri, CachedAssetBundle cachedAssetBundle, uint crc);



つまり、ロード時にuint versionHash128 hashCachedAssetBundle cachedAssetBundleを渡すとキャッシュが行われます。

WWW.LoadFromCacheOrDownloadは内部的にUnityWebRequestAssetBundleを呼び出しており(ソースコード

UnityWebRequestAssetBundleは内部的に素のUnityWebRequestにDownloadHandlerAssetBundleを付けている(ソースコード)だけなので、実際のキャッシュ処理はDownloadHandlerAssetBundleの中で行われています。

WWWクラスは将来的に廃止される予定(ソース)のため、使わないほうが良さそうです。

追記 : 2018.3でついにObsolete入りした模様

詳しくは後述しますが、現在ではUnityWebRequestAssetBundle.GetAssetBundleCachedAssetBundleを渡すパターンを用いるのがベストだと思われます。


キャッシュの保存場所

デフォルトではApplication.persistentDataPathとかApplication.temporaryCachePathあたりになるのかなと思ったら、どちらでもありませんでした。

保存場所はCaching.currentCacheForWriting.pathで取得できます。

試しにビルドして確認してみた所、以下のようになっていました。

デバイス
パス

Mac (UnityEditor)
/Users/{USER_NAME}/Library/Caches/Unity/{CompanyName}_{ProductName}

Mac (StandAlone)
/Users/{USER_NAME}/Library/{Application.identifier}

iOS (iPhone6S, 11.4.1)
/var/mobile/Containers/Data/Application/{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}/Library/UnityCache/Shared

Android (4.4.4)
/storage/emulated/0/Android/data/{Application.identifier}/files/UnityCache/Shared

Androidの場合は恐らくOSバージョンやSDカードの有無などによって変わると思います。(恐らくApplication.persistentDataPath以下になると考えて良い)

基本的にOSによって自動削除されない領域を使っているようですね。

iOSは通常iCloudにバックアップされてしまうパスになっていますが、Unityが自動的にバックアップ対象外にするフラグを立ててくれています。(5.x系では一時期そのフラグがつかないバグが発生していたようです)

個々のキャッシュは{CACHE_DIR}/{cacheKey}/{versionHash}に保存されます。

cacheKeyとversionHashは、ロード時に使用するAPIによって変わります。(後述)


キャッシュから読み込まれる条件

ロード時に既にキャッシュが存在すればそれが読み込まれる訳ですが、使用するAPIによってキャッシュの保存場所が変わります。

そこで、それぞれどのようにしてキャッシュの保存場所が決まるかを調べてみました。


versionを指定した場合

URLから抽出されたファイル名指定したバージョン番号から保存場所が決まります。

UnityWebRequestAssetBundle.GetAssetBundleに書いてある情報によると


Cached AssetBundles are uniquely identified solely by the filename and version. All domain and path information in url is ignored by Caching.


ということで、URLのドメイン名などは無視されるようです。

実際にURLに対してキャッシュ時にどのように格納されるか調べてみた所、以下のような結果になりました。

URL
保存場所

http://localhost/OSX/hoge
{CACHE_DIR}/hoge/{version}

http://localhost/OSX/hoge.bundle
{CACHE_DIR}/hoge/{version}

http://localhost/OSX/hoge/hoge
{CACHE_DIR}/hoge/{version}

http://localhost/OSX/hoge/fuga
{CACHE_DIR}/fuga/{version}

http://localhost/OSX/hoge?t=123456789
{CACHE_DIR}/hoge/{version}

確かに、拡張子を除いたファイル名がキャッシュキーに使われているみたいです。

つまり、hogehoge/hogeは同じ場所に保存され、versionが同じ場合、衝突が発生します。これは危険ですね…

運用上よくありなシチュエーションとしては、Cards/Illust/{id}Cards/Icon/{id}みたいにAssetBundleのファイル名がIDになるパターンが考えられますが、その場合同じIDのAssetBundleが全て同じ場所にキャッシュされることになります。その上versionが同じなら完全に衝突します!!

version番号指定は使わないのが良さそうですね。

ちなみに、Unityのソースコードの該当箇所を読むと分かるのですが、実は内部的にはuintからHash128に変換されています

つまり、バージョン番号が変わったときの挙動などは、ハッシュ値の場合と同じだと言えますね。


hashを指定した場合

versionと同じく、URLから抽出されたファイル名ハッシュ値から保存場所が決まります。

つまりは同じく衝突の可能性があります。が、AssetBundleのファイルから計算したハッシュ値を使っていれば基本的には衝突しないでしょう。(が、キャッシュを個別削除する時に困る…詳しくは後述)

ハッシュ値は、通常はAssetBundleManifest.GetAssetBundleHashで取得できる値を使うのが一般的です。

ちなみにGetAssetBundleHashで取得できるハッシュ値は、AssetBundleと同時に生成される.manifestファイルに書かれているAssetFileHashにあたります。

このAssetFileHashについてもやや注意が必要です。(参考:AssetFileHashが一貫性を失うタイミングについて

ちなみに、このAPIに渡すハッシュ値はあくまでバージョンチェック用のもので、ダウンロードされたAssetBundleのハッシュ値が指定したものと同じかどうかというのはチェックされません。(versionがハッシュ値に変換されているのを見ても分かる通り)

つまりは、AssetBundle自体のハッシュ値でなくても、AssetBundleが更新された時に変わる値であればなんでも良いということになります。

逆に言えば、新しいハッシュ値を指定しても、サーバー上に配置されたAssetBundleが更新されていなかった場合、同じ内容のAssetBundleがキャッシュされてしまうという危険性があります。

なお、ダウンロードされたAssetBundle自体が正しいデータであるかどうかを検証するには、CRCを指定します。(後述)

また、このAPIも内部的には後述のCachedAssetBundleでname

が空だった場合と等価(ソースコード)になっています。

つまり、どのAPIを使っても、バージョンチェック用の値が変わったときの挙動は同じだと言えます。


cachedAssetBundleを指定した場合

そもそものCachedAssetBundleというのは、AssetBundleのキャッシュに用いるためのキー名バージョンチェック用のハッシュ値をペアにした構造体です。(定義

public struct CachedAssetBundle

{
private string m_Name;
private Hash128 m_Hash;

public CachedAssetBundle(string name, Hash128 hash)
{
m_Name = name;
m_Hash = hash;
}

public string name
{
get { return m_Name; }
set { m_Name = value; }
}

public Hash128 hash
{
get { return m_Hash; }
set { m_Hash = value; }
}
}

nameという命名からは分かりにくいですが、これで指定したnameがキャッシュの保存場所になります。(要は{CACHE_DIR}/{name}/{hash}がキャッシュの保存場所になる)

つまり、同じname同じバージョンチェック用のハッシュ値を指定した場合にキャッシュからロードされます。

versionとhashとの違いは、URLから抽出されたファイル名ではなく、別途指定した名前でキャッシュできるという点です。

nameにはスラッシュも使えます。スラッシュを使うとキャッシュの保存場所にも自動的に同じディレクトリ構造が作成されます。

これを使えばAssetBundleと同じ階層構造でキャッシュでき、hoge/1fuga/1みたいな構造でも衝突しないようにできます。

これによりキャッシュが大分取扱いやすくなりました。

このAPIも2017.1から追加されていたみたいです。


まとめ

つまり、各APIによってそれぞれ以下のようにキャッシュの保存場所が決まり、キャッシュの保存場所が一致した場合にそれがロードされるという仕組みになっています。

指定方法
キャッシュの保存場所
備考

uint version
{CACHE_DIR}/{AssetBundleのファイル名}/{new Hash128(0, 0, 0, version).ToString()}
衝突の危険性大

Hash128 hash
{CACHE_DIR}/{AssetBundleのファイル名}/{hash.ToString()}
個別削除をしたい場合は非推奨

CachedAssetBundle cachedAssetBundle
{CACHE_DIR}/{cachedAssetBundle.name}/{cachedAssetBundle.hash.ToString()}
保存場所を自由に指定できる

繰り返しますが、上記のAssetBundleのファイル名というのはURLから抽出されたファイル名であり、AssetBundle名では無いので注意が必要です。

ひとまず、CachedAssetBundleを使っておけば間違いないでしょう。


キャッシュが削除される条件

前提として、上記でキャッシュが読み込まれる条件を満たさなかった場合(同じURL、もしくは名前に対して、違うバージョン番号、ハッシュ値が指定された場合)、新しいバージョンのAssetBundleがダウンロードされますが、この時古いバージョンのキャッシュは削除されません

相変わらずこの直感的でない動作は変わっていないようですね。

また、キャッシュを個別削除するためにハック的に用いられていた「誤ったCRCを指定することで対象のキャッシュが削除される」という挙動も変わっていないようです。

キャッシュが削除されるパターンは、以下の3つがあります。


  • 期限切れによる削除

  • 容量制限による削除

  • 手動での削除


期限切れによる削除

最後にキャッシュがロードされた時間からexpirationDelayで指定した時間以上経過すると削除されます。

期限切れになった瞬間に削除されるのではなく、恐らく何らかのタイミングでチェックが走っていますが、どういうタイミングやトリガーでチェックされているのかは分かりません。UnityEditor上では、シーン再生終了時に削除が行われていることを確認しました。

2017.1から微妙にAPIが変わっており、expirationDelayを指定するには以下のようにCacheを取得してやる必要があります。(詳しくは後述)

var cache = Caching.currentCacheForWriting;

cache.expirationDelay = 12960000;

expirationDelayのデフォルト値は12,960,000(150日間)で、それ以上伸ばすことはできません。0を指定した場合、次のチェックのタイミングですぐに消えます。

設定は保存されないため、起動時に毎回設定してやる必要があります。

また、Caching.MarkAsUsedを使うことで、ロードせずにキャッシュのタイムスタンプを更新する事ができます。これにより、指定したキャッシュの有効期限を引き伸ばすことが可能です。

リファレンスではObsoleteと書かれていますが、リファレンスには載っていないCachedAssetBundleやHash128を引数に取るオーバーロードが存在し、そちらはObsoleteになっていないので使う事ができます。

uintのバージョン指定はもう使うな、という事なんでしょうね。


容量制限による削除

キャッシュの容量がmaximumAvailableStorageSpaceを超えた場合、タイムスタンプが古い順にキャッシュが削除されます。

maximumAvailableStorageSpaceの単位はbyteで、デフォルト値はlong.MaxValue = 9223372036854775807になっています。

プラットフォームによって自動的に最適な値が設定される、みたいな気の利いた機能は無いです。また、設定は保存されないため、起動時に毎回設定してやる必要があります。

maximumAvailableStorageSpaceもexpirationDelayと同じく、以下のように設定できます。

var cache = Caching.currentCacheForWriting;

cache.maximumAvailableStorageSpace = 4 * 1024 * 1024 * 1024; // 4.0GB

削除が行われるタイミングは、UnityEditor上では新しくAssetBundleのキャッシュが作成される時でした。

また、MarkAsUsedを使ってタイムスタンプを更新すれば、容量制限による自動削除も回避できるのかな…と思ったのですが、なんだかよくわからない挙動をしました…(MarkAsUsedを使った後に新しくキャッシュしようとすると何故か全部削除される?詳しくは未調査)

追記 : どうも同一コルーチン内でキャッシュに読み書きが走ると変な挙動をする様です。詳しくは調査中。


手動での削除

Unity 2017.1からCachingに追加されたAPIで、

が削除できます。バージョン指定はHash128のみなので、uintのことは最早考慮されていなさそうですね。

引数名はassetBundleNameとなっていますが、これは正しくはキャッシュのキー名を指定するもので、versionやHashのAPIを使った場合はAssetBundleのファイル名、CachedAssetBundleのAPIを使った場合はCachedAssetBundle.nameが対応するキーとなります。


  • 例1: ロード時のURLが"http://localhost/hoge/fuga.bundle"で、ロード時にuintかHash128を渡した場合、ClearAllCachedVersions("fuga")で削除が可能

  • 例2: ロード時に指定したCachedAssetBundle.nameが"hoge/fuga"の場合、ClearAllCachedVersions("hoge/fuga")で削除が可能(URLに依存しない)

ここで注意が必要なのが、AssetBundleのファイル名がキャッシュキーに用いられている(ロード時にuintもしくはHash128を指定した)場合、hogehoge/hogeのキャッシュが同じキーで扱われているため、例えば「hogeの最新のキャッシュ以外を削除したい」という時に、ClearOtherCachedVersionsにhogeとhogeの最新のバージョンハッシュを与えると、hogeに対応するバージョンハッシュが一致しないキャッシュが全て削除されてしまうため、hoge/hogeのキャッシュまで巻き込んで消えてしまいます。

キャッシュの個別削除を行いたい場合は、CachedAssetBundleによるキャッシュキーの指定を使うのが良さそうです。

通常の運用では、ClearOtherCachedVersionsが一番使う機会が多そうですね。

例えば、以下のようにすればロード時に古いキャッシュを消すことができそうです。

public static IEnumerator LoadAssetBundle(string uri, string assetBundleName, Hash128 hash, uint crc = 0)

{
var cachedAssetBundle = new CachedAssetBundle(assetBundleName, hash);

Caching.ClearOtherCachedVersions(cachedAssetBundle.name, cachedAssetBundle.hash);

var request = UnityWebRequestAssetBundle.GetAssetBundle(uri, cachedAssetBundle, crc);
yield return request.SendWebRequest();
}

ただし、キャッシュの削除は同期処理のため、これではAssetBundleをロードする前に毎回キャッシュ削除の待ち時間が発生してしまい、パフォーマンス的にはあまりよろしく無さそうですね。

やるとすれば、AssetBundle名とHashのリストを用意して、どこかのタイミングで一括で処理してやるのが良さそうです。


キャッシュの存在確認

Caching.GetCachedVersionsで、現在保存されているキャッシュのバージョンハッシュのリストが取得できます。

これも引数がassetBundleNameとなっていますが、削除時と同じく、正しくはキャッシュのキー名を指定します。

つまり、これもまたキャッシュキーの衝突による影響を受けてしまいます。

また、Caching.IsVersionCachedで指定したバージョンがキャッシュされているか確認できます。

これもリファレンスではObsoleteになっていますが、リファレンスに載っていないオーバーロードが使えます。


CRCについて

AssetBundleのロード時に引数に渡すCRCは、ダウンロード、もしくはキャッシュからロードしたAssetBundleが正しいものであるかどうかを検証するためのチェックサムです。(キャッシュには関係ありません)

CRCが違っていた場合、ロードはエラーになります。また、キャッシュからロードしていた場合、そのキャッシュは削除されます。

0を指定するとチェックはスキップされます。


CRCの取得方法

残念ながら、AssetBundleManifestからはCRCを取得することはできません。

AssetBundleをビルドした後、BuildPipeline.GetCRCForAssetBundleで取得できます。

(BuildPipelineという名前に反して、ただのstaticメソッドなので、いつでも取得可能です。)

そのため、ビルド時にAssetBundle名とCRCの対応表を書き出しておき、それを読み込んで使うのが一般的です。


キャッシュを複数種類に分ける

今までキャッシュは一つのディレクトリに全てまとめられていましたが、Unity2017.1から複数に分けることができるようになりました。

これにより、例えば高画質モードと低画質モードで異なるAssetBundleを利用するといった時に、キャッシュの切り替えを簡単に行う事ができます。

また、それに伴ってCachingクラスのAPIが変わり、expirationDelayなど一部のプロパティについてはCacheクラスのインスタンスを取得することが必要になりました。

今まで通り1種類のキャッシュ空間しか利用しない場合は、Caching.currentCacheForWritingで取得すれば良いです。

詳しい使い方は、リファレンスにあるExample2が参考になります。


まとめ


  • uintでのバージョン指定は非推奨


    • キャッシュが衝突する可能性がある



  • ロード時にCachedAssetBundleでキャッシュのキーを自由に指定可能


    • キャッシュの個別削除を可能にしたい場合はこちらを使うほうが良い



  • 相変わらず古いキャッシュは自動では削除されない


  • ClearOtherCachedVersionsで指定したバージョン以外のキャッシュが削除可能

  • リファレンスに不正確な情報が多い

まだ微妙にAPIが統一されてなくてモヤっとしますが、キー指定と個別削除ができるようになったのは大きな進歩では無いでしょうか。


参考資料

AssetBundleについて、いつの間にかいい感じにまとまった公式のマニュアルが用意されていたので、基礎的な所はこれを読むと良さげです。

アセットバンドルを使いこなす - Unity マニュアル