# 問題意識
ある程度の規模のゲームアプリを運用する上では、アセットダウンロード機能は必須な機能要件である。機能を一言で表すと、「アプリが使用するアセットをアプリ更新なしで取得・更新する機能」だが、これにまつわる非機能な要求がいくつかある。また、往々にしてこの非機能な要求が切実でかつ対応が難しい。
ここでの「難しい」という意味は、ある非機能な要求に応えるためには
- 他の非機能な要求とトレードオフな部分もある
- 世の中的にも難易度の高いソリューションしか見当たらない
- Unityにおけるアセット配布のスタンダードでは非機能な要求を満たしにくい(融通が効きにくい、改善の余地が少ない)
- 部分的な実装の差し替えが出来ず、大規模・広範囲の自前実装が求められる
- 特に通信部分を自前実装する場合、ネイティブ(iOS, Android)の実装が存在しないため、この部分の技術的な難易度が高い
- 上記のようなランタイム周辺の知識だけでなく、非ランタイム(ビルド&デプロイ環境)も含めての知識など、求められる技術的な知識の範囲が特段広い
- そういった事前知識だけでなく実際の検証から得られる知識も必要であり、少なくとも「クライアントエンジニアは対応できて当たり前」とは絶対に言えない
- 特に端末の性能から最大のパフォーマンスを出すためのチューニングは事前知識と多くの実験が必要になる
といった、絶対的な正解がなく、要求を叶えるためには思った以上に(※1)大きなコストをかけなければならないという意味である。
<aside>
(※1)少なくともアセットダウンロードのシステムを作る・知る前と後でそこに大きなギャップがあるだろうと感じる。詳しく知らない状態だと、上のように「難しさ」を掻い摘んで説明されてしまうため、「よくわからないけど、思った通り本当によくわからなかったから諦める」ということになりがちである。そもそも問題を捉えること・捉えてもらうことにすら労力が思った以上にかかるということである。
それ以前に、そもそも開発期に明確に要求されることすらなく、言わなくても当たり前に必要なレベルな機能であると認識されているし、認識している。その割に非機能な要求は多く、それらが複雑に絡み合い難しい、かつ運用が始まらないと非機能な問題が顕在化しない、運用が始まると制約が増え余計に改善が難しくなるという厄介さがある。
----
# 非機能な要求
ここではどのような要求があるかを列挙するだけに留める。
## ランタイム
- キャッシュウォームアップにかかる時間
- ハッシュ比較にかかる時間
- ハッシュ比較時にかかる負荷(FPSの低下、端末の温度上昇)
- ダウンロードにかかる時間
- ダウンロード時にかかる負荷(FPSの低下、端末の温度上昇)
- ダウンロードするファイルのサイズ(通信量)
- ファイルを端末に保存するのにかかる時間
- ファイルを端末に保存する時にかかる負荷(FPSの低下、端末の温度上昇)
- ハッシュを端末に保存するのにかかる時間
- 端末に保存するファイルのサイズ(使用ストレージ)
- キャッシュの寿命延長処理にかかる時間
- キャッシュの寿命延長処理時にかかる負荷(FPSの低下、端末の温度上昇)
- アセットのロードにかかる時間
- アセットのロード時にかかる負荷(FPSの低下、端末の温度上昇)
以下は開発アプリのみに要求される機能
- ブランチ別にアセットをダウンロードできる
- 端末内に保存されたアセット > ダウンロードすべきアセット一覧の場合に、端末内の不要なアセットを削除できる
## 非ランタイム
これに関しては別プロジェクトとして記載するので省略。
## ランタイムの課題
- アセットバンドルのダウンロードをUnityにお作法に則って実装するとパフォーマンスが悪い
- パフォーマンスをチューニングしようにもI/Fの役割が大きすぎたり、UnityAPIがメインスレッド以外で実行できないなど制約が大きすぎる
---
# 課題の解決手段
- UnityAPIに頼らず自前でアセットバンドルをダウンロードしたい!
- UnityWebRequestAssetBundleやCachingを使わない
- 通信、保存、ハッシュ比較・保存を自前でやる
- アセットバンドルのダウンロード通信はHttpClient(HTTP/2.0対応させる)、保存はSystem.IO
- ハッシュはCSVにSystem.IOで書き込み、比較時はそれをロードする
- これらが非UnityAPI置き換えられるためワーカースレッドで並列で動かせるようになり速度が劇的に改善される
- Cachingはアセットバンドルの数が増えるとパフォーマンスの劣化が激しいが、その問題からも解放される
- LZMA->LZ4への再圧縮と(必要に応じての)CRCの比較はAssetBundle.RecompressAssetBundleAsyncを使う
- UnityWebRequestAssetBundleはアセットバンドルのロードも兼ねているが、Caching外のパスに保存することになるのでロード時はAssetBundle.LoadFromFileAsyncを使うようにする
## 副次的な恩恵
- Caching.readyの待機もしなくてよくなる!
- これが完了しないとアセットバンドルをロードできない
- アセットバンドルの数に比例して時間がかかる
- 運用が続くとかなり待たされるようになっちゃう
- スペックによるけど数分もあり得る
- 端末内のアセットバンドルが自動で削除されなくなる!
- Caching管理のアセットバンドルはN日で寿命を迎え勝手に削除されちゃう
- 最大で150日に指定することができる
- 最後にそのアセットバンドルをロードした時刻からN日後に削除される
- 既存案件では定期的に全アセットバンドルのタイムスタンプを更新することでそれを回避している
- この更新処理も結構重いのでそもそも勝手に消えないならそっちの方がいい!
---
# 成果
- この対応は新規開発プロジェクト①・②と運用中のタイトル「ツイステッドワンダーランド」に適用した
- 新規開発プロジェクトではまだアセットの数が少なく効果測定をしても効果を感じにくいため、「ツイステッドワンダーランド」で効果測定をした
- どの環境でも速度は改善し、特にAndroidでは4倍以上の速度が出るパターンもあった
- アセットバンドル化しないアセット(具体的にはCRIWareの音声や動画)のダウンロードに関しても別途実装したが、HTTP/2.0化、ワーカースレッドで並列処理に関しては同じ基盤機能を利用した
- アセットバンドル化しないアセットをRawDataと呼び、これのダウンロード時間も計測してある
## ダウンロードファイル数・サイズ・所要時間
### テストパターン
| | アプリバージョン | ワーカースレッド数(AssetBundle・RawData) | OS | OSバージョン | 端末モデル | ネットワーク環境 |
| --- | --- | --- | --- | --- | --- | --- |
| パターン101 | 対応前 | | iOS | 15.0 | iPhone 13 Pro Max | オフィス |
| パターン102 | 対応後 | 20・20 | iOS | 15.0 | iPhone 13 Pro Max | オフィス |
| パターン103 | 対応後 | 50・20 | iOS | 15.0 | iPhone 13 Pro Max | オフィス |
| パターン111 | 対応前 | | iOS | 15.0 | iPhone 13 Pro Max | 自宅 |
| パターン112 | 対応後 | 20・20 | iOS | 15.0 | iPhone 13 Pro Max | 自宅 |
| パターン113 | 対応後 | 50・20 | iOS | 15.0 | iPhone 13 Pro Max | 自宅 |
| パターン201 | 対応前 | | Android | 12 | Xiaomi 12T Pro | オフィス |
| パターン202 | 対応後 | 20・20 | Android | 12 | Xiaomi 12T Pro | オフィス |
| パターン203 | 対応後 | 50・20 | Android | 12 | Xiaomi 12T Pro | オフィス |
| パターン211 | 対応前 | | Android | 12 | Xiaomi 12T Pro | 自宅 |
| パターン212 | 対応後 | 20・20 | Android | 12 | Xiaomi 12T Pro | 自宅 |
| パターン213 | 対応後 | 50・20 | Android | 12 | Xiaomi 12T Pro | 自宅 |
### iOS
#### パターン101
対応前アプリ、iOS,、iPhone 13 Pro Max、オフィス
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28042 | 4922 |
| サイズ | 3.7GB | 2.7GB |
| 所要時間 | 4分58秒 | 1分35秒 |
#### パターン102
対応後アプリ、iOS,、iPhone 13 Pro Max、オフィス、ワーカースレッド数(20・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28051 | 4922 |
| サイズ | 3.7GB | 2.7GB |
| 所要時間 | 4分11秒 | 1分54秒 |
#### パターン103
対応後アプリ、iOS,、iPhone 13 Pro Max、オフィス、ワーカースレッド数(50・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28051 | 4922 |
| サイズ | 3.7GB | 2.7GB |
| 所要時間 | 2分24秒 | 1分26秒 |
#### パターン111
対応前アプリ、iOS,、iPhone 13 Pro Max、自宅
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27834 | 4907 |
| サイズ | 3.7BG | 2.7GB |
| 所要時間 | 8分12秒 | 4分23秒 |
#### パターン112
対応後アプリ、iOS,、iPhone 13 Pro Max、自宅、ワーカースレッド数(20・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27843 | 4907 |
| サイズ | 3.7GB | 2.7GB |
| 所要時間 | 6分21秒 | 4分11秒 |
#### パターン113
対応後アプリ、iOS,、iPhone 13 Pro Max、自宅、ワーカースレッド数(50・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27843 | 4907 |
| サイズ | 3.7GB | 2.7GB |
| 所要時間 | 5分41秒 | 4分9秒 |
### Android
#### パターン201
対応前アプリ、Android、Xiaomi 12T Pro、オフィス
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28042 | 4922 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 19分11秒 | 5分51秒 |
#### パターン202
対応後アプリ、Android、Xiaomi 12T Pro、オフィス、ワーカースレッド数(20・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28051 | 4922 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 5分15秒 | 2分52秒 |
#### パターン213
対応後アプリ、Android、Xiaomi 12T Pro、オフィス、ワーカースレッド数(50・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 28051 | 4922 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 5分17秒 | 2分4秒 |
#### パターン211
対応前アプリ、Android、Xiaomi 12T Pro、自宅
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27832 | 4907 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 21分47秒 | 4分39秒 |
#### パターン212
対応後アプリ、Android、Xiaomi 12T Pro、自宅、ワーカースレッド数(20・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27843 | 4907 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 6分30秒 | 6分35秒 |
#### パターン213
対応後アプリ、Android、Xiaomi 12T Pro、自宅、ワーカースレッド数(50・20)
| | AssetBundle | RawData |
| --- | --- | --- |
| ファイル数 | 27843 | 4907 |
| サイズ | 3.0GB | 2.7GB |
| 所要時間 | 4分52秒 | 4分9秒 |
---
# 実現したカスタマイズ
- アセットバンドルのダウンロードに使う通信クライアントをHttpClientに載せ替える
- 非同期かつキャンセル可能であること
- アセットバンドルの保存をCaching使わずに行う
- .NetのIOで保存する
- LZMAからLZ4に再圧縮する
- [https://docs.unity3d.com/ja/2019.4/Manual/AssetBundles-Cache.html](https://docs.unity3d.com/ja/2019.4/Manual/AssetBundles-Cache.html)
- [https://docs.unity3d.com/ja/2019.4/ScriptReference/AssetBundle.RecompressAssetBundleAsync.html](https://docs.unity3d.com/ja/2019.4/ScriptReference/AssetBundle.RecompressAssetBundleAsync.html)
- ハッシュ比較・書き込み・保存をCachingを使わずに行う
- .NetのIOで保存する
- ダウンロードすべきアセットの数・サイズをCaching使わずに取得する
---
# 実装した内容
デフォルトの実装の概要をまず知ってほしい。
その上で変えた内容のポイントを説明する。
<aside>
**デフォルト(AssetBundleResource)の実装の概要**
- ダウンロードすべきかの判定
- AssetBundleRequestOptions(Location.Dataそのもの)にComputeSizeというvirtualメソッドがありそれがダウンロードサイズを返している
- Caching.IsVersionCacheを使ってそのハッシュのバンドルが端末に保存済みか確認し、保存済みの場合は0を返している
- 保存済みではない場合(持ってないもしくはハッシュが変わっている場合)、ビルド時に計算したサイズを返している
- 返した値が0より大きいかで判定している
- リモートバンドルの場合は端末内にあろうがなかろうがUnityWebRequestAssetBundleを使ってロード・ダウンロードしてる
- Locationが持つ情報(バンドルのURL、CRC、ハッシュなど完全な情報を持ってる)を使ってUnityWebRequestAssetBundleを発行してWebRequestQueueにそれを積む
- WebRequestQueueは順次溜まっているリクエストを送信していく
- このリクエストでUnityWebRequestAssetBundleが以下を全部やってくれる
- アセットバンドルのバイナリダウンロードおよびCachingのパスに保存
- 保存時にLZMAからLZ4へ再圧縮
- サーバー上のアセットバンドルのCRCとリクエストに付与したCRCを比較し、違った場合はリクエストをエラー扱いに
- リクエストの完了をWebRequestQueueOperationを介して受け取り、成功か・エラーかでハンドリング
- 成功の場合
- リクエストからアセットバンドルを取り出す
- コールバックを蹴り上層のオペレーションに返す
- エラーの場合、リトライ回数に応じてリトライ
</aside>
**CustomAssetBundleResourceにおける変更内容概要**
- デフォルト実装のI/F、登場人物の役割をなるべく変えず、実装を置き換えたイメージ
- 人物名は変えてあるのでそれに倣ってI/Fの名前が少し変わってる部分もある
- ダウンロードすべきかの判定
- AssetBundleRequestOptionsを継承したCustomAssetBundleRequestOptionsを実装
- ComputeSizeをoverride
- File.IOによりアセットバンドルが保存済みか、ハッシュが変わっているかで判定
- Locationが持つ情報(バンドルのURL、CRC、ハッシュなど完全な情報を持ってる)を使ってHttpClientに依頼するHttpRequestを発行してAssetBundleDownloadRequestQueueにそれを積む
- AssetBundleDownloadRequestQueueは順次溜まっているリクエストをHttpClientに投げていく
- 取得したアセットバンドルのバイナリ、通信例外をHttpRequestQueueOperationを介してCustomAssetBundleResourceに返す
- CustomAssetBundleResourceはそれを受け取り、以下を行う
- バイナリをアセットバンドルとして端末(Application.persistentDataPath)に保存
- 保存後、LZMAからLZ4へ再圧縮
- この時にCRC比較
- 結果が返ってくるので、成功以外のステータスの場合例外を投げる
- CRCが異なる場合、ステータスはNotMatchingCrc
- 詳細はUnityEngine.AssetBundleLoadResultを見て
- ハッシュをCSVに保存
## **上記で登場する非同期処理のキャンセルの実現方法**
- あるべき論としては非同期メソッドはCancellationTokenを受け付けるべきである
- AddressablesはAPIはキャンセルできるようになってない
- CancellationTokenを渡していくようにし、OperationCanceledExceptionを投げるように内部に手を入れていくのは大変すぎる
- UniTaskでラップすることガワとしてはキャンセルできる(OperationCanceledExceptionが投げられ次のステップに進める)
```csharp
handle = UnityEngine.AddressableAssets.Addressables.DownloadDependenciesAsync(key);
await _taskAgent.Run(this,
ct => handle.ToUniTask(Progress.Create(onProgress), cancellationToken: ct),
cancellationToken);
```
- しかし、このhandle自体の進行は止まっておらず、あくまで購読をやめただけである
- そこでAssetBundleDownloadRequestQueueにTerminateのI/Fを実装して通信、保存を中断するようにした
```csharp
try
{
await _taskAgent.Run(this,
ct => handle.ToUniTask(Progress.Create(onProgress), cancellationToken: ct),
cancellationToken);
}
catch (Exception ex)
{
GlobalContainer.Resolve<AssetBundleDownloadRequestQueue>().Terminate();
if (ex is OperationCanceledException)
{
throw;
}
throw new AssetBundleDownloadException(ex.Message, ex);
}
```
- AssetBundleDownloadRequestQueueにCancellationTokenSourceを持たせておき、Terminateでキャンセルを発火する
- そのトークンはアセットバンドルのダウンロード通信や保存などの非同期処理に渡してあるのでTerminateをきっかけにしてそれが中断できる
- 中断時はエラーコールバックを蹴り上層のオペレーションに返す(オペレーション自体もエラーとして終わらせる)
---
## デフォルトをコピーや継承してカスタムしたクラス一覧
**public class CustomAssetBundleProvider : ResourceProviderBase**
AssetBundleResource→CustomAssetBundleResourceを扱うように変更。
**public class CustomAssetBundleResource : IAssetBundleResource**
上記概要参照。
**public class CustomBundledAssetProvider : ResourceProviderBase**
現状ただのコピー。
実装の差し替えの余地を残すために一応差し替えてある。
**public class CustomAssetBundleRequestOptions : AssetBundleRequestOptions**
ComputeSizeをoverrideしてる。
**public class CustomBuildScriptPackedMode : BuildScriptBase**
アセットバンドルのビルドスクリプト。
カタログに記載するAssetBundleRequestOptionsを作っている部分をCustomAssetBundleRequestOptionsを作るように変更。
それだけのために2000行近いクラスをコピーしてる。
継承も考えたがvirtualメソッドの実装がデカすぎて結局デフォルト側に手を入れる箇所が増えてしまうのでコピーにした。