Unityのプレハブに含まれるスクリプトを別のスクリプトに置き換える

2021/12/17 : 初稿
Unity : 2021.3.15f1

# やりたいこと

例えば、`UnityEngine.UI.Button`を継承した
独自の`CustomButton`コンポーネントを作ったとします。
(「Buttonコンポーネントは使うな」的な話は置いといて）

```cs:CustomButton.cs
///
/// @file   CustomButton.cs
/// @author KYukimoto
/// @date   
///
/// @brief　なんか拡張したButton
///
using UnityEngine.UI;

namespace Utils
{
    public class CustomButton : Button
    {
    }
}
```

それまでに作ったプレハブのButtonを全部これに置き換えたい。

# 実現方法

もちろん、プロジェクト内のアセット全部調べて、
GetComponentしてRemoveComponentしてAddComponentすればいいのですが、
gitのdiffがごちゃっとします。

なので、.prefabファイルをテキストとして開いて、
ButtonのGUIDをCustomButtonのGUIDで置換してしまえという
暴挙をやってみました。


```cs:ScriptReplacer.cs
///
/// @file   ScriptReplacer.cs
/// @author KYukimoto
/// @date   
///
/// @brief　直接.prefabファイルを開いてスクリプトのGUIDを別のスクリプトに置き換える
///
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace Utils
{
    public static class ScriptReplacer
    {
#if UNITY_EDITOR
        // プロジェクト内の.prefab全部
        [MenuItem("Utils/Component置換/Button/プロジェクト内の.prefabファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInAllPrefabs()
        {
            ReplaceComponentsInAllPrefabs<Button, CustomButton>(null);
        }

        // 選択した.prefabのみ
        [MenuItem("Utils/Component置換/Button/選択した.prefabファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInSelectedPrefab()
        {
            ReplaceComponentsInSelectedPrefab<Button, CustomButton>();
        }

        // 選択したフォルダ内の.prefab全部
        [MenuItem("Utils/Component置換/Button/選択したフォルダ内の.prefabファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInSelectedFoldersPrefabs()
        {
            ReplaceComponentsInSelectedFolders<Button, CustomButton>("t:GameObject");
        }

        // プロジェクト内の.unity全部
        [MenuItem("Utils/Component置換/Button/選択した.unityファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInSelectedScene()
        {
            ReplaceComponentsInSelectedScene<Button, CustomButton>();
        }

        // 選択した.unityのみ
        [MenuItem("Utils/Component置換/Button/プロジェクト内の.unityファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInAllScenes()
        {
            ReplaceComponentsInAllScenes<Button, CustomButton>(null);
        }

        // 選択したフォルダ内の.unity全部
        [MenuItem("Utils/Component置換/Button/選択したフォルダ内の.unityファイル内のButtonをCustomButtonに置き換え")]
        static void ReplaceButtonInSelectedFoldersScenes()
        {
            ReplaceComponentsInSelectedFolders<Button, CustomButton>("t:Scene");
        }

        // Prefab取得
        static T GetPrefab<T>(T instance) where T : Component
        {
            if (instance == null) {
                return null;
            }
            bool isPrefab = PrefabUtility.IsPartOfAnyPrefab(instance);
            if (isPrefab) {
                return PrefabUtility.GetCorrespondingObjectFromOriginalSource(instance);
            }
            var stage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage();
            if (stage == null) {
                return null;
            }
            var assetPath = stage.assetPath;
            return AssetDatabase.LoadAssetAtPath<T>(assetPath);
        }

        // 複数のReimport
        static void ReimportAssets(Object[] objects)
        {
            Selection.objects = objects;
            EditorApplication.ExecuteMenuItem("Assets/Reimport");
        }

        // スクリプトのGUIDを調べる
        static string GetScriptGUID(MonoScript monoScript)
        {
            string GUID;
            long localId;
            AssetDatabase.TryGetGUIDAndLocalFileIdentifier(monoScript, out GUID, out localId);
            return GUID;
        }
        static string GetScriptGUID<T>(T instance = null) where T : MonoBehaviour
        {
            GameObject go = null;
            if (instance == null) {
                go = new GameObject();
                instance = go.AddComponent<T>();
            }
            var script = MonoScript.FromMonoBehaviour(instance);
            var guid = GetScriptGUID(script);
            if (go != null) {
                Object.DestroyImmediate(go);
            }
            return guid;
        }

        // ShouldSkip
        static bool ShouldSkip(string assetPath, List<string> limitedPaths)
        {
            if (assetPath.StartsWith("Packages/")) {
                return true;
            }
            if (limitedPaths != null) {
                bool find = false;
                foreach (var limitedPath in limitedPaths) {
                    if (assetPath.StartsWith(limitedPath)) {
                        find = true;
                        break;
                    }
                }
                if (!find) {
                    return true;
                }
            }
            return false;
        }

        // プロジェクト内のプレハブ内のスクリプトを置き換え
        static void ReplaceComponentsInAllPrefabs<FROM, TO>(List<string> limitedPaths) where FROM : MonoBehaviour where TO : FROM
        {
            ReplaceComponentsInProject<FROM, TO>("t:GameObject", limitedPaths);
        }

        // プロジェクト内のシーン内のスクリプトを置き換え
        static void ReplaceComponentsInAllScenes<FROM, TO>(List<string> limitedPaths) where FROM : MonoBehaviour where TO : FROM
        {
            ReplaceComponentsInProject<FROM, TO>("t:Scene", limitedPaths);
        }

        // プロジェクト内のスクリプトを置き換え
        static void ReplaceComponentsInProject<FROM, TO>(string findAssets, List<string> limitedPaths) where FROM : MonoBehaviour where TO : FROM
        {
            var guids = AssetDatabase.FindAssets(findAssets, null);
            ReplaceComponentsInProject<FROM, TO>(guids, limitedPaths);
        }

        // プロジェクト内のスクリプトを置き換え
        static void ReplaceComponentsInProject<FROM, TO>(string[] guids, List<string> limitedPaths) where FROM : MonoBehaviour where TO : FROM
        {
            int sum = 0;
            List<Object> objs = new List<Object>();
            foreach (var guid in guids) {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                if (ShouldSkip(path, limitedPaths)) {
                    continue;
                }
                var replaced = ReplaceComponentGUIDs<FROM, TO>(path);
                if (replaced < 0) {
                    EditorUtility.DisplayDialog("エラー", "失敗", "OK");
                    continue;
                }
                sum += replaced;
                var obj = AssetDatabase.LoadAssetAtPath<Object>(path);
                objs.Add(obj);
            }
            ReimportAssets(objs.ToArray());
            EditorUtility.DisplayDialog("成功", sum + "箇所を置換しました", "OK");
        }

        // 選択したプレハブ内のスクリプトを置き換え
        static void ReplaceComponentsInSelectedPrefab<FROM, TO>() where FROM : MonoBehaviour where TO : FROM
        {
            var root = Selection.activeGameObject;
            if (root == null) {
                EditorUtility.DisplayDialog("エラー", "プレハブを一つ選択してください", "OK");
                return;
            }
            int num = 0;
            var texts = root.GetComponentsInChildren<FROM>(true);
            foreach (var text in texts) {
                var t = text.GetType();
                if (t != typeof(TO)) {
                    string goName = text.gameObject.name;
                    var go = text.gameObject.transform.parent;
                    while (go != null) {
                        goName = go.name + "/" + goName;
                        go = go.parent;
                    }
                    num++;
                    Debug.LogWarning(typeof(FROM).Name + "が見つかりました:" + goName);
                }
            }
            Debug.LogWarning("見つかった" + typeof(FROM).Name + "の数:" + num);

            // .assetを開いて、
            // m_Script: { fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
            // を
            // m_Script: { fileID: 11500000, guid: 3ff6a5e6c65fc684ea1043447c7faa6d, type: 3}
            // に置換
            var prefab = GetPrefab(root.transform);
            if (prefab == null) {
                EditorUtility.DisplayDialog("エラー", "プレハブが特定できません", "OK");
                return;
            }
            var path = AssetDatabase.GetAssetPath(prefab);
            var replaced = ReplaceComponentGUIDs<FROM, TO>(path);
            if (replaced < 0) {
                EditorUtility.DisplayDialog("エラー", "失敗", "OK");
                return;
            }
            AssetDatabase.ImportAsset(path);
            EditorUtility.DisplayDialog("成功", replaced + "箇所を置換しました", "OK");
        }

        // 選択した.unity内のスクリプトを置き換え
        static void ReplaceComponentsInSelectedScene<FROM, TO>() where FROM : MonoBehaviour where TO : FROM
        {
            var obj = Selection.activeObject;
            if (obj == null) {
                EditorUtility.DisplayDialog("エラー", "シーンファイル(.unity)を一つ選択してください", "OK");
                return;
            }
            var path = AssetDatabase.GetAssetPath(obj);
            if (string.IsNullOrEmpty(path) || !path.EndsWith(".unity")) {
                EditorUtility.DisplayDialog("エラー", "シーンファイル(.unity)を一つ選択してください", "OK");
                return;
            }
            Debug.Log("obj:" + obj.name + " path:" + path);
            var replaced = ReplaceComponentGUIDs<FROM, TO>(path);
            if (replaced < 0) {
                EditorUtility.DisplayDialog("エラー", "失敗", "OK");
                return;
            }
            AssetDatabase.ImportAsset(path);
            EditorUtility.DisplayDialog("成功", replaced + "箇所を置換しました", "OK");
        }

        // アセットファイルYAMLをテキストで開いてコンポーネントのGUIDを強引に書き換える
        static int ReplaceComponentGUIDs<FROM, TO>(string assetPath) where FROM : MonoBehaviour where TO : FROM
        {
            if (string.IsNullOrEmpty(assetPath)) {
                Debug.LogError("アセットパスが不正です");
                return -1;
            }
            var asset = System.IO.File.ReadAllBytes(assetPath);
            if (asset == null) {
                Debug.LogError("ファイルが読み込めません:" + assetPath);
                return -1;
            }
            Debug.LogWarning("asset size:" + asset.Length);
            var fromGUID = GetScriptGUID<FROM>();
            var toGUID = GetScriptGUID<TO>();
            var oldStr = System.Text.Encoding.UTF8.GetBytes(fromGUID);
            var newStr = System.Text.Encoding.UTF8.GetBytes(toGUID);
            int replaced = 0;
            using (var writer = new System.IO.BinaryWriter(new System.IO.FileStream(assetPath, System.IO.FileMode.Create))) {
                int startIndex = 0;
                AssetDatabase.StartAssetEditing();
                while (true) {
                    int index = FindBytes(asset, startIndex, oldStr);
                    if (index < 0) {
                        writer.Write(asset, startIndex, asset.Length - startIndex);
                        break;
                    }
                    replaced++;
                    if (index > startIndex) {
                        writer.Write(asset, startIndex, index - startIndex);
                    }
                    writer.Write(newStr);
                    startIndex = index + oldStr.Length;
                }
                AssetDatabase.StopAssetEditing();
                Debug.LogWarning("置換回数:" + replaced);
            }
            return replaced;
        }
        static int FindBytes(byte[] src, int startIndex, byte[] target)
        {
            if (src == null || target == null || target.Length <= 0) {
                return -1;
            }
            int srcLen = src.Length;
            int targetLen = target.Length;
            for (int i = startIndex; i < srcLen - targetLen; i++) {
                bool isMatch = true;
                for (int j = 0; j < targetLen; j++) {
                    if (src[i + j] != target[j]) {
                        isMatch = false;
                        break;
                    }
                }
                if (isMatch) {
                    return i;
                }
            }
            return -1;
        }

        // 選択中のフォルダにあるアセットに対する処理
        static void ReplaceComponentsInSelectedFolders<FROM, TO>(string findAssets) where FROM : MonoBehaviour where TO : FROM
        {
            var folderPath = GetActiveFolderPath();
            if (string.IsNullOrEmpty(folderPath)) {
                return;
            }
            ReplaceComponentsInProject<FROM, TO>(findAssets, new List<string>() { folderPath, });
        }

        // 選択中のフォルダ取得
        enum ViewMode
        {
            OneColumn,
            TwoColumns
        }
        static string GetActiveFolderPath()
        {
            EditorWindow window = Resources.FindObjectsOfTypeAll<EditorWindow>().FirstOrDefault(w => w.GetType().Name == "ProjectBrowser");
            if (window == null) {
                return null;
            }
            int columnMode = (int)window.GetType().GetField("m_ViewMode", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(window);
            if (columnMode == (int)ViewMode.OneColumn) {
                Object obj = Selection.activeObject;
                if (obj == null) {
                    return null;
                }
                var path = AssetDatabase.GetAssetPath(obj.GetInstanceID());
                if (!System.IO.Directory.Exists(path)) {
                    return null;
                }
                List<string> folders = path.Split('/').ToList();
                folders.RemoveAt(folders.Count - 1);
                return string.Join("/", folders);
            }
            if (columnMode == (int)ViewMode.TwoColumns) {
                MethodInfo methodInfo = window.GetType().GetMethod("GetActiveFolderPath", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance);
                return (string)methodInfo.Invoke(window, null);
            }
            return null;
        }
#endif
    }
}
```

ちょっとコード散らかってるけどまあいいか。
なお、アセットをバイナリ形式で保存している場合は通用しませんのでご容赦を。