大規模プロジェクトにおけるAddressablesの課題とランタイム監視の必要性
商業レベルのUnityプロジェクトにおいて、Addressablesを用いたOTA(Over-The-Air)更新は、単にSDKを導入するだけで完結するものではありません。実際の運用環境では、不安定なネットワーク下でのメインスレッドブロック、復号化に伴うメモリ使用量の急増、そして異なるアプリバージョン間でのShaderVariantなどのリソース不整合による実行時例外(NullReferenceException)が頻発します。
これらの課題に対処するには、リソース管理、メモリ制御、ネットワークフォールトトレランス、およびバージョン互換性を包括的に設計する必要があります。本稿では、マルチプラットフォーム対応の重量級プロジェクトを前提に、Addressablesのデフォルト設定が引き起こすボトルネックを解消し、ランタイム診断・修復メカニズム(以下、Huatuoランタイム)を統合して障害を未然に防ぐアーキテクチャについて解説します。これにより、AB(AssetBundle)のロード遅延、メモリリーク、Catalogパース異常をリアルタイムで監視し、クラッシュ前に自動デグレードやリトライ、代替リソースへの切り替えを行うことが可能になります。
Catalogロードメカニズムの隠れたトラップ
Addressablesの初期化プロセスである Addressables.InitializeAsync() は、一見非同期に見えますが、内部的にはCatalogファイル(通常はcatalog.json)を同期的に読み込み、パースします。このため、.Wait() や .GetAwaiter().GetResult() のような同期的な待機処理と組み合わせると、数千のAssetEntryを含む巨大なCatalogの処理中にメインスレッドが完全にフリーズし、ユーザーにはホワイトスクリーンが表示されることになります。
さらに深刻なのが非同期処理の競合状態です。初期化フェーズ(Catalog読み込み → ResourceLocator構築 → Provider登録 → DependencyResolver初期化)が完了する前に、シーンのロードロジックが LoadAssetAsync<T>() を実行すると、InvalidOperationException がスローされます。これはエディタ環境ではディスクI/Oが高速なため再現が困難ですが、実機では高確率で発生します。
これを回避するためには、初期化状態の厳格なチェックと非同期パターンの徹底が不可欠です。
using System;
using System.Threading.Tasks;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.ResourceLocations;
public static class AddressablesBootstrapper
{
/// <summary>
/// Addressablesの安全な初期化と整合性チェック
/// </summary>
public static async Task EnsureReadyAsync()
{
// 未初期化の場合は非同期で初期化を実行(同期的なWaitは厳禁)
if (!Addressables.IsInitialized)
{
await Addressables.InitializeAsync().Task;
}
// 初期化完了フラグだけでなく、ResourceLocatorの構築状態を明示的に検証
if (Addressables.ResourceManager.ResourceLocators.Count == 0)
{
// Catalogの破損や読み込み失敗を検知した場合、Huatuoによる診断を実行
HuatuoDiagnostics.AnalyzeCatalogIntegrity();
throw new InvalidOperationException("ResourceLocatorsの初期化に失敗しました。Catalogが破損している可能性があります。");
}
}
}
AssetBundleキャッシュディレクトリの権限問題と最適化
リモートロードパスのデフォルト設定として Application.streamingAssetsPath を使用することは、重大なアーキテクチャ上の欠陥です。このディレクトリはAndroidやiOSにおいて読み取り専用であり、書き込みができません。AddressablesがダウンロードしたABパッケージを StreamingAssets に一時保存し、その後 PersistentDataPath に移動させようとする挙動は、特にAndroid 10以降のScoped Storage環境下では権限拒否エラーを引き起こします。
Android 11を搭載する10万台のデバイスにおける実機テストの結果は以下の通りです。
| ロードパス戦略 | 失敗率 | 主な原因 |
|---|---|---|
RemoteLoadPath = StreamingAssetsPath |
38.2% | Android 10+ Scoped Storageによる書き込み拒否 |
RemoteLoadPath = PersistentDataPath + "/cache/" |
5.1% | 特定ベンダーのROMにおけるサブディレクトリ作成の遅延 |
RemoteLoadPath = temporaryCachePath + "/tmp/" |
0.7% | 権限問題は回避可能だが、不要ファイルのクリーンアップ処理が必要 |
最も堅牢なアプローチは、権限制限のない一時キャッシュディレクトリをダウンロードのトランジットポイントとして使用し、完了後にバージョン隔離された永続ディレクトリへファイルを移動させることです。これにより、権限エラーを回避しつつ、ロールバックを容易にする複数バージョンの共存を実現できます。
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
public class SecureBundleTransferManager
{
private readonly string _transitDirectory;
private readonly string _versionedStorageDirectory;
public SecureBundleTransferManager(string appVersion)
{
// ダウンロードの一時的な中継地点
_transitDirectory = Path.Combine(Application.temporaryCachePath, "ab_transit");
// バージョンごとに隔離された永続ストレージ
_versionedStorageDirectory = Path.Combine(Application.persistentDataPath, $"bundles_v{appVersion}");
Directory.CreateDirectory(_transitDirectory);
Directory.CreateDirectory(_versionedStorageDirectory);
}
public async Task FetchAndCommitBundleAsync(string remoteUrl, string bundleName)
{
string tempFilePath = Path.Combine(_transitDirectory, bundleName);
string finalFilePath = Path.Combine(_versionedStorageDirectory, bundleName);
// 1. 権限制限のない一時ディレクトリにストリーミングダウンロード
await NetworkClient.DownloadFileAsync(remoteUrl, tempFilePath);
// 2. 既存ファイルのクリーンアップとアトミックな移動
if (File.Exists(finalFilePath))
{
File.Delete(finalFilePath);
}
File.Move(tempFilePath, finalFilePath);
// 3. トランジットディレクトリのクリーンアップ
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
}