第20章 Overwriter - エディター拡張入門

第20章 Overwriter

Finder や Explorer では、ファイルをドラッグ&ドロップした先に同名のファイルがある場合、上書きを行うかどうかのダイアログを表示することができます。

Finder で同じ名前のファイルがある時に表示されるダイアログ(Mac)

図20.1: Finder で同じ名前のファイルがある時に表示されるダイアログ(Mac)

Unity でもこのようなダイアログを表示したいのですが、Unity にはこのようなファイルの上書き機能はありません。同名のファイルがドラッグ&ドロップされた場合、ファイル名の最後に数字を付けて別ファイルとして扱われます。

hoge 1.png というように別ファイル扱い

図20.2: hoge 1.png というように別ファイル扱い

ファイルの上書きをしたい場合は、Unity上ではなく Finder 経由で上書きを行わなければなりません。しかし、Finder上で操作するのは、アセットが壊れてしまうかもしれない大きなリスクを抱えてしまいます。Unity エディターの管理外でアセットの変更を行うことは非推奨とされているからです。

Unity の管理外でファイルの変更が非推奨なワケ

 これは、Unity が管理しているデータベースと実際のアセットとの整合性が取れなくなってしまうためです。

 各アセットには GUID が割り振られ、これを key としてデータベースで管理されています。この GUID は.meta ファイルに記述されています。

fileFormatVersion: 2
guid: 4498d464658a84c7c8998b6b66709951
TextureImporter:
  fileIDToRecycleName: {}
  serializedVersion: 2

この GUID が変更されると 図20.3 のように参照が「Missing」状態になります。

コンポーネントでのオブジェクト参照も GUID で行われています

図20.3: コンポーネントでのオブジェクト参照も GUID で行われています

意図的に Missing を発生させるには、Finder上で対象ファイルを移動させます。

hoge.png を Folder に移動させます

図20.4: hoge.png を Folder に移動させます

こうすると Unity は

  • hoge.png が削除された
  • hoge.png が新しく Folder の中にインポートされた

と判断され、同じ hoge.png でもまったく別物として扱われます。

ファイルを別物として扱わないようにするには.meta ファイルも一緒に移動させれば良い話なのですが、Unity 独特の仕様なため、.meta ファイルを一緒に移動させるのを忘れがちです。(忘れたまま Unity エディターに戻った場合、.meta ファイルが自動で削除/生成されて悲惨なことに...)

ファイルの上書き問題を Unity上で解決したものが本章で作成する Overwriter です。

20.1 Overwriter の使い方

同じ名前のアセットが存在するとダイアログが出る

図20.5: 同じ名前のアセットが存在するとダイアログが出る

説明するまでもないと思いますが、アセットをインポートしようとした時、すでに同じファイル名が存在した場合にダイアログが表示されます。「置き換える」を選択した場合は上書きされ、「両方とも残す」を選択した場合は 図20.6 のように別アセットとしてインポートされます。

hoge 1.png としてインポートされている

図20.6: hoge 1.png としてインポートされている

20.2 Overwriter の作り方

以降は Overwriter を作成するまでの手順を説明していきます。

20.3 ファイルのインポート監視

まずは、アセットの上書き処理に移るために、Unity エディターに何のアセットをインポートしようとしているか、何がインポートされたかを知らなければいけません。アセットのインポートに関する処理は AssetPostprocessor クラスを使うことで実装できます。

using UnityEditor;

public class Overwriter : AssetPostprocessor
{
    static void OnPostprocessAllAssets (
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromPath)
    {
        foreach (var assetPath in importedAssets) {
            //インポートされたアセットを監視
        }
    }
}

本章ではアセットを上書きすることを目的としていますが、処理的には「別アセットとしてインポートした後、置き換える」処理となります。これは Unity の仕様の関係で、インポート時に、同じ階層に同名のファイルがあった場合にはすでにリネームした状態でインポートしてしまうためです。例えば、hoge.png をインポートするときに、すでに同名のアセットがあれば hoge 1.png となります。

仕様上、同名とならないように数値がふられてしまう

図20.7: 仕様上、同名とならないように数値がふられてしまう

この仕様を受けて、Overwriter の処理の順番は次のとおりです。

  1. とりあえずインポートする。ここで hoge.png というアセットがすでにある場合は hoge 1.png となる。
  2. 別アセットとしてインポートされた時に末尾についた「<半角スペース><数字>」が存在する場合、アセットを上書きしたいものとして判断する。
  3. 上書きするかどうかのダイアログを出し、ユーザーの確認が取れれば上書きする。

DragAndDrop クラスでリネーム前のパスを取得する

同名のファイルをインポートする際、リネームされた状態でインポートされます。ですが、DragAndDrop.paths にはリネーム前のファイルパスが格納されて残っています。

//hoge.png がある状態で hoge.png をインポート
static void OnPostprocessAllAssets (
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromPath)
    {
        if (Event.current.type != EventType.DragPerform)
                return;

        //リネーム後の hoge 1.png でインポートされていることを確認
        var hoge1 = AssetDatabase.LoadAssetAtPath<Texture2D> ("Assets/hoge 1.png");
        Debug.Log (hoge1);

        //hoge.png のパスが格納されていることを確認
        foreach (var path in DragAndDrop.paths) {
            Debug.Log (path);
        }
    }

20.4 ドラッグした時のみ処理を実行する

Unity上でアセットのインポートが行われた時に、再インポートが行われるため、実装した OnPostprocessAllAssets が呼び出されてしまいます。そこでマウスでドラッグしてインポートした時のみ処理を行うようにします。

static void OnPostprocessAllAssets (
    string[] importedAssets,
    string[] deletedAssets,
    string[] movedAssets,
    string[] movedFromPath)
{

    //スクリプトの編集によるインポート時は Event.current は null なので、null チェックをする
    if (Event.current == null ||
        Event.current.type != EventType.DragPerform)
        return;

    foreach (var assetPath in importedAssets) {

    }
}

20.5 ダイアログの表示

ダイアログの表示には EditorUtility.DisplayDialogComplex を使用します。

今回は「置き換える」「両方とも残す」「中止」の3択が必要となるので DisplayDialogComplex となります。

図20.8: 今回は「置き換える」「両方とも残す」「中止」の3択が必要となるので DisplayDialogComplex となります。

同じ機能として <b>EditorUtility.DisplayDialog</b> がありますが、こちらはボタンが2つになります。「はい」「いいえ」の2択で表現できるときのみ使用しましょう。

図20.9: 同じ機能として EditorUtility.DisplayDialog がありますが、こちらはボタンが2つになります。「はい」「いいえ」の2択で表現できるときのみ使用しましょう。

var result = EditorUtility.DisplayDialogComplex (
    asset.originalAssetPath,
    overwriteMessage,
    "置き換える",    //選択時の戻り値は 0
    "両方とも残す",  //選択時の戻り値は 1
    "中止");        //選択時の戻り値は 2

if (result == 0) {
    asset.Overwrite ();
} else if (result == 2) {
    asset.Delete ();
}

20.6 上書き

AssetDatabase クラスには CopyAsset 関数がありますが、上書きするという機能はありません。厳密に言うと上書きできてしまうのですが、Unity のデータベースに不具合が生じます。詳しくは「TIPS「AssetDatabase.CopyAsset の不具合?」」で紹介します。 今回はアセットの上書きに FileUtil.ReplaceFile 関数を使用します。上書きコピーが完了した後、別アセットとしてインポートされたものは AssetDatabase.DeleteAsset で削除します。

重要: 最後に、System.IO.File によって外部からのデータ変更されたものを Unity が把握するために AssetDatabase.ImportAsset を実行します。

public void Overwrite ()
{
    FileUtil.ReplaceFile (assetPath, originalAssetPath);
    Delete ();
    AssetDatabase.ImportAsset (originalAssetPath);
}

public void Delete ()
{
    AssetDatabase.DeleteAsset (assetPath);
}

AssetDatabase.CopyAsset の不具合?

アセットをコピーして複製するにはこの関数を使用します。ですがこの関数はアセットの上書きを考慮しておらず、Unity 側のデータベースに不具合が生じます。

同じ名前の hoge アセットが2つ表示されている。この画像では正しくインスペクターに情報が表示されているが...

図20.10: 同じ名前の hoge アセットが2つ表示されている。この画像では正しくインスペクターに情報が表示されているが...

こちらではインスペクターに情報が表示されていない

図20.11: こちらではインスペクターに情報が表示されていない

Finder で見ると片方の画像しかないことがわかる

図20.12: Finder で見ると片方の画像しかないことがわかる

この不具合から元に戻すには、ReImport All をして Library にあるのデータベースを再生成しなければいけません。

using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;

public class OverwriteAsset
{
    public string originalAssetPath {
        get {
            return Path.Combine (directoryName, filename + "." + extension);
        }
    }

    public bool exists { get; private set; }

    public string filename { get; private set; }

    public string extension { get; private set; }

    private string directoryName;
    private string assetPath;
    const string pattern = "^(?<name>.*)\\s\\d+\\.(?<extension>.*)$";

    public OverwriteAsset (string assetPath)
    {
        this.assetPath = assetPath;
        directoryName = Path.GetDirectoryName (assetPath);
        var match = Regex.Match (Path.GetFileName (assetPath), pattern);

        exists = match.Success;

        if (exists) {
            filename = match.Groups ["name"].Value;
            extension = match.Groups ["extension"].Value;
        }
    }

    public void Overwrite ()
    {
        FileUtil.ReplaceFile (assetPath, originalAssetPath);
        Delete ();
        AssetDatabase.ImportAsset (originalAssetPath);
    }

    public void Delete ()
    {
        AssetDatabase.DeleteAsset (assetPath);
    }
}
using UnityEditor;
using UnityEngine;

public class Overwriter : AssetPostprocessor
{

  const string message = "\"{0}.{1}\"という名前のアセットがすでにこの場所にあります。アセットを置き換えますか?";

  static void OnPostprocessAllAssets (
    string[] importedAssets,
    string[] deletedAssets,
    string[] movedAssets,
    string[] movedFromPath)
  {
    if (Event.current == null || Event.current.type != EventType.DragPerform)
      return;

    foreach (var assetPath in importedAssets) {

      var asset = new OverwriteAsset (assetPath);

      if (asset.exists) {

        var overwriteMessage =
                string.Format (message, asset.filename, asset.extension);

        var result = EditorUtility.DisplayDialogComplex (asset.originalAssetPath,
                                      overwriteMessage,
                                      "置き換える",
                                      "両方とも残す",
                                      "中止");

        if (result == 0) {
          asset.Overwrite ();
        } else if (result == 2) {
          asset.Delete ();
        }

      }
    }
  }
}
第19章 GUI を自作する 第21章 パーティクルを制御する