Addressables.DownloadDependenciesAsync() によるダウンロードを、アセットバンドルごとに分けて行う

はじめに

Addressables のダウンロードを行う場合、普通にコードを書くと以下のようになります。

// ※ラベル名を指定してダウンロードする場合

// Addressable アセットのラベル名
string labelName = "LABEL_NAME";

// 指定したラベルを持つすべての Addressable アセットの IResourceLocation を取得する
IList<IResourceLocation> locations = await Addressables.LoadResourceLocationsAsync(labelName);

// 取得した IResourceLocation が含まれるアセットバンドルをすべてダウンロードする
await Addressables.DownloadDependenciesAsync(locations);

この場合、ダウンロードするアセットバンドルが複数あっても一度にダウンロードを行うため、以下のような問題が起こります。

  • 現在ダウンロード中のアセットバンドルの情報が取得できない。
    • 例えば、「全体のうち何 % のダウンロードが完了したか」は取得できるが、「今ダウンロードしているアセットバンドルのうち何 % のダウンロードが完了したか」は分からない。
  • サーバーの設定によっては、ダウンロードに時間がかかりすぎるとサーバー側から通信を打ち切られる場合がある。

アセットバンドルごとにダウンロードする

こうした問題を防ぐため、各アセットバンドルごとに別々にダウンロードを行うようにします。

これは、以下のようなコードで実現できます。

// Addressable アセットのラベル名
string labelName = "LABEL_NAME";

// 指定したラベルを持つすべての Addressable アセットの IResourceLocation を取得する
IList<IResourceLocation> locations = await Addressables.LoadResourceLocationsAsync(labelName);

// DependencyHashCode でグルーピングする
foreach (IGrouping<int, IResourceLocation> groupedLocations in locations.GroupBy(x => x.DependencyHashCode))
{
    // グループごとに IResourceLocation のダウンロードを行う
    await Addressables.DownloadDependenciesAsync(groupedLocations.ToList());
}

解説

IResourceLocation は、 DependencyHashCode というパラメータを持ちます。

これは他の IResourceLocation との依存関係を示すハッシュコードなのですが、依存関係が同じ=所属しているアセットバンドルが同じなので、この値をもとにグループ分けすれば、アセットバンドルごとに IResourceLocation を分けることができます。

あとは、分けたグループごとに Addressables.DownloadDependenciesAsync() を実行してやれば OK です。

補遺

これを応用すると、以下のようなダウンロード画面を実装できます。

f:id:Gigacee:20211228195551g:plain
プログレスバー 2 本のダウンロード画面。上:全体の進行度、下:アセットバンドルごとの進行度

Addressables.UpdateCatalogs() でカタログのアップデートに失敗しても try ~ catch をすり抜ける問題

発生した問題

サーバーに、新しいコンテンツカタログの hash だけあり、json は無いという状況で Addressables.UpdateCatalogs() を実行すると、エラーは発生するもののなぜか try ~ catch をすり抜けてしまう。

try
{
    // これがエラーになっても……
    await Addressables.UpdateCatalogs();
}
catch
{
    // ここに到達しない
    Debug.LogError("コンテンツカタログのアップデートに失敗しました。");
}

解決方法

アップデートに失敗した場合、Result の List<IResourceLocator> が空なので、以下のようにリストの中身の有無で成功したかどうかを判断する。

// カタログをアップデートする
List<IResourceLocator> locators = await Addressables.UpdateCatalogs();

// リストが空なら失敗扱いにする
if (!locators.Any())
{
    Debug.LogError("コンテンツカタログのアップデートに失敗しました。");
}

説明

新しいコンテンツカタログのハッシュが見つかった時点で、 Addressables.UpdateCatalogs() は成功扱いになるようです。返ってくる AsyncOperationHandle の Status も Succeeded になります。なぜ……。

AsyncOperationHandle の Result にはアップデートされたカタログの IResourceLocator のリストが入るのですが、実際にはカタログのアップデートには失敗しているので、空のリストが入ってきます。なのでこのリストの中身を見れば、本当にカタログがアップデートできたのかが判断できます。

補遺

Addressables.UpdateCatalogs(true) というように第一引数に true を指定すると、カタログのアップデート後に使われなくなったアセットバンドルのキャッシュの削除を行ってくれます。*1

通常、カタログのアップデートが行われないとキャッシュの削除も行われないのですが、今回のように実際は失敗していても内部的には成功扱いになってしまっている場合、キャッシュの削除処理も走ってしまい、以下のエラーが発生します。

OperationException : UnityEngine.AddressableAssets.CleanBundleCacheOperation, result='', status='Failed', status=Failed, result=False catalogs updated, but failed to clean bundle cache.

現状、これを回避する方法は無さそうなので、以下のようにアップデートが本当に成功したかどうかを確認してから別途キャッシュクリアを行うようにするのが良いでしょう。

// カタログをアップデートする
List<IResourceLocator> locators = await Addressables.UpdateCatalogs();

// リストが空なら失敗扱いにする
if (!locators.Any())
{
    Debug.LogError("コンテンツカタログのアップデートに失敗しました。");

    // 処理を打ち止め
    return false;
}

// キャッシュをクリアする
Addressables.CleanBundleCache();

*1:バージョン 1.19.4 以降。

Addressables.CheckForCatalogUpdates() で返ってくるリストが常に空になる問題

発生した問題

サーバーに置いてある Addressables のコンテンツカタログをアップデートしてから Addressables.CheckForCatalogUpdates() を実行しても、常に空のリストしか返ってこないという問題に遭遇しました。

解決方法

先に Addressables.InitializeAsync() を明示的に実行して、初期化が完了するのを待ってから Addressables.CheckForCatalogUpdates() を呼ぶ。

説明

Addressables の初期化は、Addressables API を初めて叩いたときに自動的に行われるので、普通はわざわざ Addressables.InitializeAsync() を実行する必要はありません。

なので、いきなり Addressables.CheckForCatalogUpdates() を呼んでもエラーにはなりません。

しかし、この時点では Addressables の初期化はまだ「完了」はしていないので、カタログの更新のチェックを行うことができないようです。

エラーにはならないが、カタログ更新のチェックも行えないので、結果として空のリストが返ってくる……ということのようです。

普通にエラーを吐いてくれたほうがありがたいんですが、どうしてこういう仕様になっているんですかね……。

補遺

なお、Addressables の設定の「Disable Catalog Update on Startup」が OFF の場合*1、Addressables の初期化時に自動でカタログのアップデートを行うようになるので、この場合も Addressables.CheckForCatalogUpdates() の返り値は常に空のリストになります。

f:id:Gigacee:20211222175947p:plain

参考

forum.unity.com

*1:デフォルトでは OFF になっています。

パッケージを一括アップデートする

Unity の Package Manager でインストールしたパッケージを一括アップデートする方法です。

  1. manifest.json から、アップデートしたいパッケージの行を削除する。
  2. Unity Editor に戻る。
    1. おそらくコンソールにエラーが出ると思いますが気にせず次へ。
  3. manifest.json の削除した行を元に戻す。
  4. 再び Unity Editor に戻る。

これで、一旦削除したパッケージが最新のものになります。

※ 元々バージョンを指定してインストールしていた場合はアップデートされません。

Unity でサービスロケーターを使う

はじめに

例えば GameManager のような、どこからでもアクセスしたい・一つだけ存在していてほしいクラスを Unity で実装する場合、よく使われるのは【シングルトン】というデザインパターンです。

シングルトンは非常にシンプルでお手軽に実装でき、小規模なプロジェクトでは大変便利なものではありますが、LevelManager とか SaveLoadManager とか LanguageManager とかといった感じでマネージャークラスが増えてくると、いろんなシングルトンがいろんなクラスから好き勝手にアクセスされるようになり、クラス間の依存関係がぐちゃぐちゃになってしまいがちです。*1

そこで登場するのが【サービスロケーター】で、簡単に言うと、

  • どこからでもアクセスできる「格納場所*2」を一つだけ用意し、
  • GameManager や LevelManager などをそこに登録し、
  • 他のクラスからは GameManager などに直接アクセスするのではなく、「格納場所」を経由してアクセスするようにする

という代物です。こうすることで、クラス間の依存を「格納場所」に集約でき、依存関係が追いやすくなるというわけです。

(👇この解説動画が分かりやすかったです)

www.youtube.com

実装

それでは実装です。

サービスロケーター本体のクラス

まず、サービスロケーター本体のスクリプトは以下のようになります。

using System;
using System.Collections.Generic;
using UnityEngine;

public static class ServiceLocator
{
    /// <summary>
    /// インスタンスを登録する辞書。
    /// </summary>
    static readonly Dictionary<Type, object> instances = new Dictionary<Type, object>();

    /// <summary>
    /// インスタンスの登録をすべて解除します。
    /// </summary>
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Initialize()
    {
        instances.Clear();
    }

    /// <summary>
    /// インスタンスを登録します。すでに同じ型のインスタンスが登録されている場合は登録できませんので、先に Unregister を行ってください。
    /// </summary>
    /// <param name="instance">登録するインスタンス。</param>
    /// <typeparam name="T">登録するインスタンスの型。</typeparam>
    public static void Register<T>(T instance) where T : class
    {
        var type = typeof(T);

        if (instances.ContainsKey(type))
        {
            Debug.LogWarning($"すでに同じ型のインスタンスが登録されています:{type.Name}");
            return;
        }

        instances[type] = instance;
    }

    /// <summary>
    /// インスタンスの登録を解除します。インスタンスが登録されていなかった場合は警告が出ます。
    /// </summary>
    /// <param name="instance">登録を解除するインスタンス。</param>
    /// <typeparam name="T">登録を解除するインスタンスの型。</typeparam>
    public static void Unregister<T>(T instance) where T : class
    {
        var type = typeof(T);

        if (!instances.ContainsKey(type))
        {
            Debug.LogWarning($"要求された型のインスタンスが登録されていません:{type.Name}");
            return;
        }

        if (!Equals(instances[type], instance))
        {
            Debug.LogWarning($"登録されている要求された型のインスタンスと渡されたインスタンスが一致しません:{type.Name}");
            return;
        }

        instances.Remove(type);
    }

    /// <summary>
    /// 指定された型のインスタンスがすでに登録されているかをチェックします。
    /// </summary>
    /// <typeparam name="T">登録を確認するインスタンスの型。</typeparam>
    /// <returns>指定された型のインスタンスがすでに登録されている場合は true を返します。</returns>
    public static bool IsRegistered<T>() where T : class
    {
        return instances.ContainsKey(typeof(T));
    }

    /// <summary>
    /// 渡されたインスタンスがすでに登録されているかをチェックします。
    /// </summary>
    /// <param name="instance">登録を確認するインスタンス。</param>
    /// <typeparam name="T">登録を確認するインスタンスの型。</typeparam>
    /// <returns>渡されたインスタンスが既に登録されている場合は true を返します。</returns>
    public static bool IsRegistered<T>(T instance) where T : class
    {
        var type = typeof(T);

        return instances.ContainsKey(type) && Equals(instances[type], instance);
    }

    /// <summary>
    /// インスタンスを取得します。取得できなかった場合はエラーになります。
    /// </summary>
    /// <typeparam name="T">取得したいインスタンスの型。</typeparam>
    /// <returns>取得したインスタンスを返します。取得できなかった場合は null を返します。</returns>
    public static T GetInstance<T>() where T : class
    {
        var type = typeof(T);

        if (instances.ContainsKey(type))
        {
            return instances[type] as T;
        }

        Debug.LogError($"要求された型のインスタンスが登録されていません:{type.Name}");
        return null;
    }

    /// <summary>
    /// インスタンスを取得し、渡された引数に代入します。取得できなかった場合は null が入ります。
    /// </summary>
    /// <param name="instance">取得したインスタンスを入れる変数。</param>
    /// <typeparam name="T">取得したいインスタンスの型。</typeparam>
    /// <returns>取得が成功したら true を返します。</returns>
    public static bool TryGetInstance<T>(out T instance) where T : class
    {
        var type = typeof(T);

        instance = instances.ContainsKey(type) ? instances[type] as T : null;

        return instance != null;
    }
}

Dictionary<Type, object> instances が「格納場所」で、ここに GameManager などを詰めていきます。

サービスロケーターに格納するクラス(各種マネージャーなど)

お次は、GameManager などといった外部からアクセスしたいクラスを、サービスロケーターに登録する手順です。

using UnityEngine;

/// <summary>
/// マネージャークラス。
/// </summary>
public class GameManager : MonoBehaviour, IGameManager
{
    /// <summary>
    /// 外部から使いたいメソッド。
    /// </summary>
    public void DoSomething()
    {
        Debug.Log("I am GameManager!");
    }

    void Awake()
    {
        // サービスロケーターに自身を登録
        ServiceLocator.Register<IGameManager>(this);
    }

    void OnDestroy()
    {
        // サービスロケーターから自身の登録を解除
        ServiceLocator.Unregister<IGameManager>(this);
    }
}

/// <summary>
/// マネージャークラスのインターフェイス。
/// </summary>
public interface IGameManager
{
    /// <summary>
    /// 外部から使いたいメソッド。
    /// </summary>
    void DoSomething();
}

登録する型が GameManager ではなく IGameManager というインターフェイスになっていますが、こうすることで外部からアクセスできるメソッドを限定することができるので、なるべくインターフェイスを定義して実装するのをおすすめします。

この例だと DoSomething() しかないので恩恵を感じられませんが、実際にはいろいろなメソッドやプロパティが実装されるはずなので、アクセス可能な対象を限定しておくことは決して悪いことではありません。

外部からサービスロケーターにアクセスするクラス

最後に、サービスロケーターへアクセスする外部のクラスです。

using UnityEngine;

public class Foo : MonoBehaviour
{
    void Start()
    {
        var gameManager = ServiceLocator.GetInstance<IGameManager>();

        gameManager.DoSomething();
    }
}

ServiceLocator.GetInstance<IGameManager>() で、格納された GameManager を取得できます。取得できたら、あとは普通にメソッドを実行することができます✨

いちいち GetInstance でインスタンスを取得しないといけないのが面倒に感じるかもしれませんが、逆に言うと GetInstance が無い場合はマネージャークラスとは無関係だということが保証されるので、スクリプトの理解がしやすくなります。

なお、GameManager の登録は Awake() で行うので、他のクラスからは Start() 以降にアクセスする必要があります。

補遺

インスタンスを一つのみに制限する

さて、サービスロケーターを使うことで「どこからでもアクセスしたい」は実現できましたが、「一つだけ存在していてほしい」は実現できていません。

これは、「サービスロケーターへの登録時に、すでに同じ型のインスタンスが登録されていたら自身を破棄する」という処理を追加することで実現できます。

具体的にはこのように書きます。

public class GameManager : MonoBehaviour, IGameManager
{
    public void DoSomething()
    {
        Debug.Log("I am GameManager!");
    }

    void Awake()
    {
        // すでに IGameManager がサービスロケーターに登録されていたら、自身を破棄して終了
        if (ServiceLocator.IsRegistered<IGameManager>())
        {
            Destroy(gameObject);
            return;
        }

        ServiceLocator.Register<IGameManager>(this);
    }

    void OnDestroy()
    {
        ServiceLocator.Unregister<IGameManager>(this);
    }
}

GetInstance と TryGetInstance

今回紹介したスクリプトでは、 GetInstance でインスタンスを取得できなかった場合はエラーになるようにしています。

Unity でよく使われる GetComponent などは取得に失敗してもエラーにならないので、それに倣ったほうが良いという意見もあるとは思うのですが、自分のコーディングだと、

  • 基本的に GetComponent は必ずそのコンポーネントを取得できる想定の時しか使わない
  • 取得できなかった場合も処理を続行したい場合は TryGetComponent を使う

という理由から、今回はエラーになる仕様を採用しました。なお、TryGetComponent に相当するメソッドとして TryGetInstance を用意しています。

Awake と Start

記事中にも書きましたが、サービスロケーターへの登録は Awake() で行っているので、アクセスは Start() 以降でないとできません。

これが嫌だという方もいらっしゃるかもしれませんが、 Awake() の時点では他のオブジェクトの初期化が完了していない場合があるので、今回の例に限らず、そもそも Awake() 内で他のオブジェクトに働きかけるのは避けたほうが良いでしょう。

おわりに

サービスロケーターを使えば、依存関係の複雑化を回避しつつ、どこからでもアクセスできるクラスを作ることができます。サービスロケーターを使いこなして、シングルトンを駆逐していきましょう!

*1:そもそもこんなに「○○マネージャー」を作るの自体が良くない? それはそう🙂

*2:Dictionary 型が使われることが多い気がします。

ルートに変更された Prefab があるかどうかを調べるエディター拡張

Prefab の Apply や Revert をし忘れていないかいつも心配になるので、それをチェックするエディター拡張を作成しました。

自分のワークフローだとルートに存在している Prefab だけをチェックするようにしたほうが都合が良かったのでそうしていますが、子孫含めたすべての Prefab をチェックしたい場合は、rootGameObjects = ...の部分を適宜書き換えてください。

using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Experimental.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;

public static class GcTools
{
    [MenuItem("GcTools/Check if Root Prefabs have Changed")]
    public static void CheckIfRootPrefabsHaveChanged()
    {
        IEnumerable<GameObject> rootGameObjects;

        var currentPrefabStage = PrefabStageUtility.GetCurrentPrefabStage();

        if (currentPrefabStage == null)
        {
            // Prefab Mode でない場合、シーンのルートにある GameObject を取得
            rootGameObjects = SceneManager.GetActiveScene().GetRootGameObjects();
        }
        else
        {
            // Prefab Mode の場合、ルートの Prefab の 1 階層下にある GameObject を取得 
            rootGameObjects = currentPrefabStage.prefabContentsRoot.transform.Cast<Transform>()
                .Select(child => child.gameObject);
        }

        // 変更されている Prefab Instance を抽出
        var overriddenPrefabInstances = rootGameObjects
            .Where(PrefabUtility.IsAnyPrefabInstanceRoot)
            .Where(x => PrefabUtility.HasPrefabInstanceAnyOverrides(x, false))
            .Select(x => (Object)x)
            .ToArray();

        foreach (var instance in overriddenPrefabInstances)
        {
            Debug.Log($"ルートの Prefab が変更されています:{instance}");
        }

        if (!overriddenPrefabInstances.Any())
        {
            Debug.Log("ルートに変更された Prefab はありませんでした。");
        }

        // 変更されている Prefab Instance が存在していたら、それらを選択
        Selection.objects = overriddenPrefabInstances;
    }
}

Unity WebGL の実行環境が PC かモバイル端末かを判別するスクリプトを公開しました

PC モバイル

はじめに

  • PC では Post Processing を有効にしたいけど、モバイル端末では重たいので無効にしたい。
  • モバイル端末の場合はOnPointer*を切ってInput.Touchesを使いたい。

……というように、PC とモバイル端末で処理を分けたいという場合がたまにあります。そんな時、このスクリプトを導入することで、実行環境が PC かモバイル端末かを判別することができます。

github.com

使い方

CheckIfMobileForUnityWebGL.jslib内のIsMobile()を呼ぶと、モバイル端末ならtrue、PC ならfalseが返ってきます。以下のように呼び出してください。

#if !UNITY_EDITOR && UNITY_WEBGL
    [System.Runtime.InteropServices.DllImport("__Internal")]
    private static extern bool IsMobile();
#endif

    private void CheckIfMobile()
    {
        var isMobile = false;

#if !UNITY_EDITOR && UNITY_WEBGL
        isMobile = IsMobile();
#endif

        GetComponent<Text>().text = isMobile ? "Mobile" : "PC";
    }

インストール

Package Manager

https://github.com/gigacee/CheckIfMobileForUnityWebGL.git?path=Assets/Plugins/CheckIfMobileForUnityWebGL

手動

Assets/Plugins/CheckIfMobileForUnityWebGL/CheckIfMobileForUnityWebGL.jslibを、自分のプロジェクトにコピーしてください。

※ 必ずAssets/Plugins/に配置してください。でないと機能しません。

The coloring of this site is Dracula PRO🧛🏻‍♂️
This website uses the FontAwesome icons licensed under CC BY 4.0.

2020 GIGA CREATION