yotiky Tech Blog

とあるエンジニアの備忘録

Unity - UPMのローカルパッケージ参照で特殊フォルダーを共有する

TL;DR

動的にロードされるアセットだけが含まれるフォルダー(ResourcesやStreamingAssets)を対象とした場合、AssetPostprocessorやPackage Managerのイベントが利用できる可能性がある。 条件が厳しいのはUPMにアセットを含める関係上、元のGUIDを維持できないため。更に細かい挙動が面倒くさく、正直使い勝手が良いとは言えない。Addressablesが使える場面ではAddressables、または大人しくシンボリックリンクなどを使ったほうが事故が少ないかもしれない。 今回は特殊フォルダーを共有するというテーマだが、他にもパッケージをインストールする際になにか処理を加えたい場合には応用できそう。

目次

開発環境

  • Unity 2022.3.23f1

AssetPostprocessor

AssetPostprocessor を利用するとプロジェクトのアセットに変更が加わった際にスクリプトを実行することができるようになる。 これはAssets配下のみならず、Packages配下にも有効。

docs.unity3d.com

基本的な構造は以下の通り。

public class Postprocessor : AssetPostprocessor
{
    static void OnPostprocessAllAssets(
        string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
    {
        // 新規作成や更新されたAssetは imported
        foreach (var asset in importedAssets)
            Debug.Log("imported:" + asset);
        // 削除されたら deleted
        foreach (var asset in deletedAssets)
            Debug.Log("deleted:" + asset);
        // 移動されたら movedFrom から moved へ
        foreach (var asset in movedAssets)
            Debug.Log("moved:" + asset);
        foreach (var asset in movedFromAssetPaths)
            Debug.Log("movedFromAssetPaths:" + asset);
    }
}

OnPostprocessAllAssetsはすべてのアセットのインポートが完了した後に呼び出される。
他にOnPostprocessAnimationOnPostprocessAudioなど、アセットの種類毎に呼び出されるメソッドも存在する。

パラメータ 内容
importedAssets 新たに追加されたり既存のアセットが更新されたりしたものが入ってくる
deletedAssets 削除されたもの
movedAssets 移動されたもの
movedFromAssetPaths 上記の移動元、移動先&移動元は対になる

特殊フォルダーを共有する場合

注意する点は以下の通り。

  • UPMで共有した場合、Assets配下にコピーすると元のアセット(Packages配下)と同じものが2重で存在することになる
  • File.CopyしてもmetaファイルのGUIDは新しい値に更新される
  • GUIDが維持できないため、GUIDを参照するようなものはAddressablesかシンボリックリンクにするのが良さそう
  • インストールするパッケージに同梱した場合、実行順序が定まらないのでAssetPostprocessorが認識される前にインポートされたアセットはイベントをフックできない
  • 何かしらエラーでコケた場合、最初からや続きからの再実行ができない
  • アセットの変更に随時反応するのでエディタが重くなる

以上を踏まえサンプルは以下の仕様で実装されている。

  • 動的にロードされるアセットが含まれるフォルダーを対象とする
    • Resources
    • StreamingAssets
  • アセットに変更があった場合は、ディレクトリ単位で洗い替える
  • 再実行可能なようにEditor拡張などを用意しておく
  • 総じて使い勝手は微妙

サンプルコード

public class Importer : AssetPostprocessor
{
    private static readonly string packageName = "com.xxx.yyy";
    private static readonly string packageRootDir = $"Packages/{packageName}";

    private static readonly Dictionary<string, string> targets = new()
    {
        //{ $"{packageRootDir}/Plugins", $"Assets/Plugins/{packageName}" },
        { $"{packageRootDir}/StreamingAssets", $"Assets/StreamingAssets/{packageName}" },
        { $"{packageRootDir}/Resources", $"Assets/Resources/{packageName}" },
    };
    
    static void OnPostprocessAllAssets(
        string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
    {
        var copied = new List<string>();

        Action<string> replace = path =>
        {
            if (!path.StartsWith(packageRootDir)) return;

            var kvp = targets.FirstOrDefault(x => path.StartsWith(x.Key));
            if (kvp.Key != null && !copied.Contains(kvp.Key))
            {
                ReplaceDirectory(kvp.Key, kvp.Value, true);
                copied.Add(kvp.Key);
                AssetDatabase.Refresh();
            }
        };

        foreach (var asset in importedAssets)
            replace(asset);
        foreach (var asset in deletedAssets)
            replace(asset);
        foreach (var asset in movedAssets)
            replace(asset);
    }

    static void ReplaceDirectory(string sourceDir, string destinationDir, bool recursive)
    {
        DeleteDirectory(destinationDir);
        CopyDirectory(sourceDir, destinationDir, recursive);
    }

    static void DeleteDirectory(string path)
    {
        var dir = new DirectoryInfo(path);
        if (dir.Exists)
            dir.Delete(true);
    }

    static void CopyDirectory(string sourceDir, string destinationDir, bool recursive)
    {
        // Get information about the source directory
        var dir = new DirectoryInfo(sourceDir);

        // Check if the source directory exists
        if (!dir.Exists)
            throw new DirectoryNotFoundException($"Source directory not found: {dir.FullName}");

        // Cache directories before we start copying
        DirectoryInfo[] dirs = dir.GetDirectories();

        // Create the destination directory
        Directory.CreateDirectory(destinationDir);

        // Get the files in the source directory and copy to the destination directory
        foreach (FileInfo file in dir.GetFiles())
        {
            string targetFilePath = Path.Combine(destinationDir, file.Name);
            file.CopyTo(targetFilePath);
        }

        // If recursive and copying subdirectories, recursively call this method
        if (recursive)
        {
            foreach (DirectoryInfo subDir in dirs)
            {
                string newDestinationDir = Path.Combine(destinationDir, subDir.Name);
                CopyDirectory(subDir.FullName, newDestinationDir, true);
            }
        }
    }

    public static void CopyDirectoryAll()
    {
        foreach (var kvp in targets)
        {
            if (Directory.Exists(kvp.Key))
                ReplaceDirectory(kvp.Key, kvp.Value, true);
        }

        AssetDatabase.Refresh();
    }
}

エディタ拡張。

public class ReplaceResourceFolder : Editor
{
    [MenuItem("PackageSample/ReplaceResourceFolder ")]
    static void Execute()
    {
        Importer.CopyDirectoryAll();
    }
}

Package Manager イベント

Package Manager では、EventsクラスにregisteringPackagesregisteredPackagesの2つのイベントが用意されている。

docs.unity3d.com

docs.unity3d.com

ノート: Package Manager はパッケージ内のストリーミングアセットをサポートしていません。代わりに Addressable パッケージをご利用ください。

registeringPackagesはPackage Managerが依存関係のリストを変更する前に発生する。 基本的な構造は、Events.registeringPackagesイベントハンドラーを登録するだけ。

public class EventSubscribingExample_RegisteringPackages
{
    public EventSubscribingExample_RegisteringPackages()
    {
        Events.registeringPackages += RegisteringPackagesEventHandler;
    }

    void RegisteringPackagesEventHandler(PackageRegistrationEventArgs packageRegistrationEventArgs)
    {
        Debug.Log("The list of registered packages is about to change!");

        // Install時
        foreach (var asset in packageRegistrationEventArgs.added)
            Debug.Log("added:" + asset.name);
        // Remove時
        foreach (var asset in packageRegistrationEventArgs.removed)
            Debug.Log("removed:" + asset.name);
        // バージョン変更時(Up/Down)、FromとToがペアになる
        foreach (var asset in packageRegistrationEventArgs.changedFrom)
            Debug.Log("changedFrom:" + asset.name);
        foreach (var asset in packageRegistrationEventArgs.changedTo)
            Debug.Log("changedTo:" + asset.name);
    }
}

registeredPackagesはインポートが終わってコンパイルされた後に発生する。 こちらはInitializeOnLoadMethod属性が必要になる。

public class EventSubscribingExample_RegisteredPackages 
{
    // イベントをサブスクライブするには '[InitializeOnLoadMethod]' か '[InitializeOnLoad]' を使う必要があります
    [InitializeOnLoadMethod]
    static void SubscribeToEvent()
    {
        Events.registeredPackages += RegisteredPackagesEventHandler;
    }

    static void RegisteredPackagesEventHandler(PackageRegistrationEventArgs packageRegistrationEventArgs)
    {
        // ここで実行されるコードは、エディターが一連の新しいパッケージのコンパイルを完了したと安全に想定できます。
        Debug.Log("The list of registered packages has changed!");
        
        // ローカルのパッケージの中身が更新されただけだと呼ばれない
        // Install時
        foreach (var asset in packageRegistrationEventArgs.added)
            Debug.Log("added:" + asset.name);
        // Remove時
        foreach (var asset in packageRegistrationEventArgs.removed)
            Debug.Log("removed:" + asset.name);
        // バージョン変更時(Up/Down)、FromとToがペアになる
        foreach (var asset in packageRegistrationEventArgs.changedFrom)
            Debug.Log("changedFrom:" + asset.name);
        foreach (var asset in packageRegistrationEventArgs.changedTo)
            Debug.Log("changedTo:" + asset.name);
    }
}
PackageRegistrationEventArgsのプロパティ 内容
added Package ManagerでInstallした時
removed Removeした時
changedFrom バージョンが変更(Up/Down)した時の前のパッケージ情報
changedTo 上記の変更後のパッケージ情報、前後は対で提供される

特殊フォルダーを共有する場合

基本的にパッケージレベルの話なので、アセット(ディレクトリ)毎にどうのこうのの話ではなさそう。

注意する点は以下の通り。

  • UPMのローカルパッケージ参照の場合、パッケージのアップデートをしなくても中身は更新されるが、そのような状況ではイベントは発生しない
  • そのため使い勝手がだいぶ悪い
  • インストールするパッケージに同梱した場合、registeredPackagesであればインストールの最後に走るので誤爆はなさそう
  • パッケージをいじらなければ実行されないのでエディタが重いということはない
  • その他GUIDやエラーでコケた場合などはAssetPostprocessorと同様
  • AssetPostprocessorと一緒に覚えておくと何かに使えるか

シンボリックリンクを使用する

PluginsのようにプロジェクトのSerializeFieldなどでGUIDを参照してしまうようなものは、これまでの方法は使えない。 そもそもローカルパッケージを参照するために、ディレクトリの相対パスを持っているはずなので、同様にシンボリックリンクを貼ってしまう方が楽かもしれない。

Windowsの場合。

mklink /D destDir srcDir

その他参考。

yotiky.hatenablog.com

まとめ

UPMのローカルパッケージ参照で特殊フォルダーを共有する方法を探ってきた。 GUIDを必要としない、動的にロードされるアセットだけが含まれるフォルダー(ResourcesやStreamingAssets)を対象とした場合に限り、AssetPostprocessorやPackage Managerのイベントが利用できる可能性がある。 正直使い勝手が良いとは言えないため、Addressablesが使える場面ではAddressables、または大人しくシンボリックリンクなどを使ったほうが事故が少ないかもしれない。 今回は特殊フォルダーを共有するというテーマだったが、他にもパッケージをインストールする際になにか処理を加えたい場合には応用できそうである。