メモリ状態比較用エディタ拡張

環境

Unity2022.2.1f1

概要

プログラマ以外がMemoryProfilerを使うのも難しい事だろうと思い、簡単に比較できるように作ってみました。

コードは下の方にあります。

手順

  • 適当なシーンを開く

今回の例ではNewSceneにCubeを1つ追加したものを使用しました。

  • メニューのTools>EasyMemoryProfileDifferを押下し、ウィンドウを開く

小さい場合は広げてください。

  • ウィンドウの左上の「TakeSample and ExportFile」ボタンを押下

メモリの状態を知りたい時にボタンを押します。

Unityのプロジェクトのフォルダに「MemoryLogs」というフォルダができ、中にその時点でのcsvが出来ます。

  • 再度、ウィンドウの左上の「TakeSample and ExportFile」ボタンを押下

比較には2つのメモリ状態が必要なので、再度メモリ状態を知りたい時にボタンを押します。

今回の例では最初のシーンにさらにCubeを1つ追加した状態で押しました。

  • ウィンドウの「LogA Path」と「LogB Path」に保存されているcsvを読みこむ

「...」ボタンを押してファイルを選択します。

2つとも読み込むと「DiffB - A(Add)」に追加されたもの「DiffA - B(Sub)」に削除されたものが表示されます。

今回の例ではCubeが1つ追加されたので「DiffB - A(Add)」の「Root/Scene Memory/GameObject/Cube(1)」やその他関連データが増えています。

コード

EasyMemoryProfileDifferWindow.cs

using UnityEngine;
using UnityEditor;
using System.IO;

public class EasyMemoryProfileDifferWindow : EditorWindow
{
    [MenuItem("Tools/EasyMemoryProfileDiffer")]
    public static void Create()
    {
        var window = GetWindow<EasyMemoryProfileDifferWindow>("EasyMemoryProfileDiffer");
        window.Show();
    }

    private EasyMemoryProfileDiffer _obj = null;

    private void OnEnable()
    {
        var ms = MonoScript.FromScriptableObject(this);
        var path = AssetDatabase.GetAssetPath(ms);
        if(string.IsNullOrEmpty(path) == false)
        {
            path = path.Replace(Path.GetFileName(path), "");
            path = path + "EasyMemoryProfileDiffer.asset";
        }

        _obj = AssetDatabase.LoadAssetAtPath<EasyMemoryProfileDiffer>(path);
        if(_obj == null)
        {
            var assets = AssetDatabase.LoadAllAssetsAtPath(path);
            if(assets.Length > 0)
                _obj = assets[0] as EasyMemoryProfileDiffer;
        }
        if(_obj == null)
        {
            _obj = ScriptableObject.CreateInstance<EasyMemoryProfileDiffer>();
            AssetDatabase.CreateAsset(_obj, path);
            AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ImportRecursive);
        }
        _obj.Setup();
    }

    private void OnDisable()
    {
        if(_obj == null)
            return;
        EditorUtility.SetDirty(_obj);
        AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(_obj), ImportAssetOptions.ForceUpdate | ImportAssetOptions.ImportRecursive);
    }

    private void OnGUI()
    {
        _obj.OnGUI();
        Repaint();
    }
}

EasyMemoryProfileDiffer.cs

using UnityEngine;
using UnityEditor.IMGUI.Controls;
using UnityEditor;
using System.IO;
using System.Collections.Generic;
using System.Collections;
using System.Reflection;
using System;
using System.Text;

class EasyMemoryProfileDiffer : ScriptableObject
{
    [System.Serializable]
    public class TreeParam
    {
        public string path = null;
        public TreeViewState treeViewState = null;
        public Dictionary<MemoryParam, IDictionary> treeRoot = null;
        public MemoryTreeView treeView = null;
        public Rect treeViewRect;
        public Vector2 scrollPos = Vector2.zero;
    }
    public TreeParam[] _treeParams = new TreeParam[4];

    public void Setup()
    {
        for(int i = 0; i < _treeParams.Length; i++)
            _treeParams[i].treeView = new MemoryTreeView(_treeParams[i].treeViewState);
        for(int i = 0; i < 2; i++)
        {
            if(string.IsNullOrEmpty(_treeParams[i].path) == false)
                LoadLogCsvFile(_treeParams[i]);
        }
    }

    public void OnGUI()
    {
        using(new GUILayout.HorizontalScope())
        {
            if(GUILayout.Button("TakeSample and ExportFile", GUILayout.Width(200)) == true)
            {
                Export(true);
            }
            if(GUILayout.Button("ExportFile", GUILayout.Width(200)) == true)
            {
                Export(false);
            }
            if(GUILayout.Button("Profiler", GUILayout.Width(200)) == true)
            {
                GetProfilerWindow();
            }
        }
        var width = 300.0f;
        using(new GUILayout.HorizontalScope())
        {
            for(int i = 0; i < 2; i++)
            {
                using(var scrollView = new GUILayout.ScrollViewScope(_treeParams[i].scrollPos))
                {
                    _treeParams[i].scrollPos = scrollView .scrollPosition;
                    using(new GUILayout.VerticalScope())
                    {
                        GUILayout.Label((i == 0) ? $"LogA Path" : $"LogB Path", GUILayout.Width(width));
                        Rect pathRect;
                        using(new GUILayout.HorizontalScope())
                        {
                            var prevPath = _treeParams[i].path;
                            _treeParams[i].path = EditorGUILayout.TextField("", _treeParams[i].path, GUILayout.Width(width - 50));
                            pathRect = GUILayoutUtility.GetLastRect();
                            if(GUILayout.Button("...", GUILayout.Width(50)) == true)
                                _treeParams[i].path = EditorUtility.OpenFilePanel("Select Log CsvFile", GetMemoryLogFolder(), "csv");
                            if(_treeParams[i].path != prevPath)
                                LoadLogCsvFile(_treeParams[i]);

                        }
                        if(Event.current.type == EventType.Repaint && _treeParams[i].treeView != null)
                            _treeParams[i].treeViewRect = new Rect(pathRect.x, pathRect.y + pathRect.height, width, _treeParams[i].treeView.totalHeight);
                        if(_treeParams[i].treeView != null)
                            _treeParams[i].treeView.OnGUI(_treeParams[i].treeViewRect);
                        var area = GUILayoutUtility.GetRect(new GUIContent( string.Empty ), GUIStyle.none, GUILayout.Width(_treeParams[i].treeViewRect.width), GUILayout.Height(_treeParams[i].treeViewRect.height));
                    }
                }
            }
            for(int i = 0; i < 2; i++)
            {
                using(var scrollView = new GUILayout.ScrollViewScope(_treeParams[2 + i].scrollPos))
                {
                    _treeParams[2 + i].scrollPos = scrollView .scrollPosition;
                    using(new GUILayout.VerticalScope())
                    {
                        GUILayout.Label((i == 0) ? $"Diff B - A (Add)" : $"Diff A - B (Sub)", GUILayout.Width(width));
                        var lastRect = GUILayoutUtility.GetLastRect();
                        if(_treeParams[2 + i].treeRoot != null)
                        {
                            if(Event.current.type == EventType.Repaint && _treeParams[2 + i].treeView != null)
                                _treeParams[2 + i].treeViewRect = new Rect(lastRect.x, lastRect.y + lastRect.height, width, _treeParams[2 + i].treeView.totalHeight);
                            if(_treeParams[2 + i].treeView != null)
                                _treeParams[2 + i].treeView.OnGUI(_treeParams[2 + i].treeViewRect);
                            var area = GUILayoutUtility.GetRect(new GUIContent( string.Empty ), GUIStyle.none, GUILayout.Width(_treeParams[2 + i].treeViewRect.width), GUILayout.Height(_treeParams[2 + i].treeViewRect.height));
                        }
                    }
                }
            }
        }
    }

    public class MemoryParam
    {
        public string name = null;
        public string value = null;
        public bool Equals(MemoryParam other)
        {
            if(ReferenceEquals(null, other)) return false;
            if(ReferenceEquals(this, other)) return true;
            return other.name == name;
        }

        public override bool Equals(object obj)
        {
            if(ReferenceEquals(null, obj)) return false;
            if(ReferenceEquals(this, obj)) return true;
            if(obj.GetType() != typeof (MemoryParam)) return false;
            return Equals((MemoryParam) obj);
        }

        public override int GetHashCode()
        {
            unchecked
            {
                return name.GetHashCode();
            }
        }
    }

    private void LoadLogCsvFile(TreeParam logParam)
    {
        var root = new Dictionary<MemoryParam, IDictionary>();

        string csv = null;
        if(File.Exists(logParam.path) == false)
        {
            Debug.LogWarning($"File Not Exists:{logParam.path}");
            return;
        }

        using(var fs = File.Open(logParam.path, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            using(TextReader reader = new StreamReader(fs, Encoding.UTF8))
            {
                csv = reader.ReadToEnd();
            }
        }

        if(csv == null)
            return;

        var lines = csv.Split(new string[]{"\r\n"}, System.StringSplitOptions.RemoveEmptyEntries);
        var itemCount = lines.Length;
        var nameCountDict = new Dictionary<string, int>();
        for(int i = 0; i < itemCount; i++)
        {
            var line = lines[i];
            var cells = line.Split(new string[]{"\""}, System.StringSplitOptions.RemoveEmptyEntries);
            var name = cells[0];
            var value = cells[2];
            name = name.Replace("\"", "");

            if(nameCountDict.TryGetValue(name, out int nameCount) == false)
            {
                nameCount = 0;
                nameCountDict.Add(name, nameCount);
            }
            var nameIndex = nameCount;
            nameCount++;
            nameCountDict[name] = nameCount;
            if(nameIndex > 0)
                name = $"{name}?{nameIndex}";

            var names = name.Split(new string[]{"\\"}, System.StringSplitOptions.RemoveEmptyEntries);
            var dict = root;
            MemoryParam key = null;
            for(int n = 0; n < names.Length; n++)
            {
                key = new MemoryParam(){name = names[n]};
                dict.TryGetValue(key, out IDictionary childDict);
                if(childDict == null)
                {
                    childDict = new Dictionary<MemoryParam, IDictionary>();   
                    dict.Add(key, childDict);
                }
                dict = childDict as Dictionary<MemoryParam, IDictionary>;
            }
            key.value = value;
        }
        logParam.treeRoot = root;
        logParam.treeView.Setup(root, true);

        if(_treeParams[0].treeRoot != null && _treeParams[1].treeRoot != null)
            CreateDiffTreeView();
    }

    private void CreateDiffTreeView()
    {
        //Add
        var root = new Dictionary<MemoryParam, IDictionary>();
        CopyTreeNodes(root, _treeParams[1].treeRoot);
        RemoveTreeNodes(root, _treeParams[0].treeRoot);
        _treeParams[2].treeRoot = root;
        _treeParams[2].treeView.Setup(root, false);
        //Sub
        root = new Dictionary<MemoryParam, IDictionary>();
        CopyTreeNodes(root, _treeParams[0].treeRoot);
        RemoveTreeNodes(root, _treeParams[1].treeRoot);
        _treeParams[3].treeRoot = root;
        _treeParams[3].treeView.Setup(root, false);
    }
    private void CopyTreeNodes(Dictionary<MemoryParam, IDictionary> dest, Dictionary<MemoryParam, IDictionary> src)
    {
        foreach(var srcPair in src)
        {
            var childDict = new Dictionary<MemoryParam, IDictionary>();
            CopyTreeNodes(childDict, srcPair.Value as Dictionary<MemoryParam, IDictionary>);
            dest.Add(srcPair.Key, childDict);
        }
    }
    private void RemoveTreeNodes(Dictionary<MemoryParam, IDictionary> dest, Dictionary<MemoryParam, IDictionary> src)
    {
        foreach(var srcPair in src)
        {
            var srcChildDict = srcPair.Value; 
            if(dest.TryGetValue(srcPair.Key, out IDictionary destChildDict) == true)
            {
                if(destChildDict != null)
                {
                    RemoveTreeNodes(destChildDict as Dictionary<MemoryParam, IDictionary>, srcChildDict as Dictionary<MemoryParam, IDictionary>);
                }
                if(destChildDict == null || destChildDict.Count == 0)
                    dest.Remove(srcPair.Key);
            }
        }
    }

    private string GetMemoryLogFolder()
    {
        return Path.GetFullPath(Path.Combine( Application.dataPath, "../MemoryLogs" ));
    }

    private static EditorWindow GetProfilerWindow()
    {
        var profilerWindowType = typeof(EditorWindow).Assembly.GetType("UnityEditor.ProfilerWindow");
        var profilerWindow = EditorWindow.GetWindow(profilerWindowType);
        return profilerWindow;
    }

    private static object GetMemoryProfilerModule()
    {
        var profilerWindow = GetProfilerWindow();
        var moduleFieldName =
#if UNITY_2021_2_OR_NEWER
            "m_AllModules";
#elif UNITY_2020_2_OR_NEWER
            "m_Modules";
#else
            "m_ProfilerModules";
#endif
        var moduleField = profilerWindow.GetType().GetField(moduleFieldName, BindingFlags.NonPublic | BindingFlags.Instance);
        if(moduleField == null) {
            // Unity2018はサポート外
            Debug.LogWarning("Not Supported Version.");
            return null;
        }

        var memoryProfilerModuleType = typeof(EditorWindow).Assembly.GetType("UnityEditorInternal.Profiling.MemoryProfilerModule");
        var moduleList = (IList)moduleField.GetValue(profilerWindow);
        foreach (var module in moduleList) {
            if(module.GetType() == memoryProfilerModuleType) {
                return module;
            }
        }

        Debug.LogWarning("Not find Memory Profiler Module");
        return null;
    }

    private static readonly Type _memoryElementType = typeof(EditorWindow).Assembly.GetType("UnityEditor.MemoryElement");
    private class MemoryElement
    {
        private static readonly FieldInfo _nameFiled                    = _memoryElementType.GetField("name", BindingFlags.Instance | BindingFlags.Public);
        private static readonly FieldInfo _totalMemoryField             = _memoryElementType.GetField("totalMemory", BindingFlags.Instance | BindingFlags.Public);
        private static readonly FieldInfo _totalChildCountField         = _memoryElementType.GetField("totalChildCount", BindingFlags.Instance | BindingFlags.Public);
        private static readonly FieldInfo _descriptionFiled             = _memoryElementType.GetField("description", BindingFlags.Instance | BindingFlags.Public);
        private static readonly FieldInfo _memoryElementChildrenFiled   = _memoryElementType.GetField("children", BindingFlags.Instance | BindingFlags.Public);

        private const double _kibiByte = 1024;
        private const double _mebiByte = _kibiByte * 1024;
        private const double _gibiByte = _mebiByte * 1024;

        public string _name = null;
        public long _totalMemory = 0;
        public int _totalChildCount = 0;
        public string _description = null;
        public List<MemoryElement> _children = null;

        public MemoryElement(object internalMemoryElement)
        {
            _name = (string)_nameFiled.GetValue(internalMemoryElement);
            _totalMemory = (long)_totalMemoryField.GetValue(internalMemoryElement);
            _totalChildCount = (int)_totalChildCountField.GetValue(internalMemoryElement);
            _description = (string)_descriptionFiled.GetValue(internalMemoryElement);
            _children = new List<MemoryElement>(_totalChildCount);

            var children = (IList)_memoryElementChildrenFiled.GetValue(internalMemoryElement);
            if(children != null && children.Count != 0)
            {
                foreach(var child in children)
                {
                    var element = new MemoryElement(child);
                    _children.Add(element);
                }
            }
        }
        public string GetMemorySize()
        {
            var unit = "";
            double memory = 0;
            if(_kibiByte > _totalMemory)
            {
                unit = "B";
                memory = _totalMemory;
            }
            else if(_mebiByte > _totalMemory)
            {
                unit = "KB";
                memory = _totalMemory / _kibiByte;
            }
            else if(_gibiByte > _totalMemory)
            {
                unit = "MB";
                memory = _totalMemory / _mebiByte;
            }
            else
            {
                unit = "GB";
                memory = _totalMemory / _gibiByte;
            }
            var memorySize = (memory == 0) ? "0" : memory.ToString(".0");
            return $"{memorySize} {unit}";
        }
    }
    private static void Export(bool isTakeSample)
    {
        var memoryProfilerModule = GetMemoryProfilerModule();
        //TakeSample
        if(isTakeSample == true)
        {
            var refreshMemoryData = memoryProfilerModule.GetType().GetMethod("RefreshMemoryData", BindingFlags.NonPublic | BindingFlags.Instance);
            refreshMemoryData.Invoke(memoryProfilerModule, null);
        }
        //MemoryElements
        var referenceListViewFieldInfo = memoryProfilerModule.GetType().GetField("m_MemoryListView", BindingFlags.NonPublic | BindingFlags.Instance);
        var referenceListView = referenceListViewFieldInfo.GetValue(memoryProfilerModule);
        var rootMemoryElementField = referenceListView.GetType().GetField("m_Root", BindingFlags.NonPublic | BindingFlags.Instance);
        var rootMemoryElement = rootMemoryElementField.GetValue(referenceListView);
        if(rootMemoryElement != null)
        {
            var rootElement = new MemoryElement(rootMemoryElement);
            rootElement._name = "Root";
            ExportFile(rootElement);
        }
        else
            Debug.LogWarning("Not TakeSample");
    }

    private static void ExportFile(MemoryElement rootElement)
    {
        var elementWriteInfos = new List<string>(rootElement._totalChildCount);
        CreateElementWriteInfoList(elementWriteInfos, rootElement, "");

        string projectFolder = Path.Combine( Application.dataPath, "../" );
        var exportFilePath = $"{projectFolder}/MemoryLogs/memory_{DateTime.Now.ToString("yyyy-MM-ddHHmmss")}.csv";
        if(Directory.Exists(Path.GetDirectoryName(exportFilePath)) == false)
            Directory.CreateDirectory(Path.GetDirectoryName(exportFilePath));

        using(StreamWriter writer = new StreamWriter(exportFilePath, false, Encoding.UTF8))
        {
            foreach (var info in elementWriteInfos)
                writer.WriteLine(info);
        }
        Debug.Log("Export Memory Csv Log File: " + exportFilePath);
    }
    private static void CreateElementWriteInfoList(List<string> elementInfo, MemoryElement element, string parent)
    {
        var name = string.IsNullOrEmpty(parent) ? element._name : parent + Path.DirectorySeparatorChar + element._name;
        var info = $"\"{name}\",\"{element.GetMemorySize()}\"";
        elementInfo.Add(info);

        foreach (var child in element._children)
            CreateElementWriteInfoList(elementInfo, child, name);
    }
}

MemoryTreeView.cs

using System.Collections;
using System.Collections.Generic;
using UnityEditor.IMGUI.Controls;

class MemoryTreeView : TreeView
{
    private List<TreeViewItem> _treeItems = new List<TreeViewItem>();
    public MemoryTreeView(TreeViewState treeViewState) : base(treeViewState)
    {
        Reload();
    }

    public void Setup(Dictionary<EasyMemoryProfileDiffer.MemoryParam, IDictionary> root, bool isValue)
    {
        _treeItems.Clear();
        CreateTree(root, isValue, 0);
        Reload();
    }
    private void CreateTree(Dictionary<EasyMemoryProfileDiffer.MemoryParam, IDictionary> dict, bool isValue, int depth)
    {
        foreach(var pair in dict)
        {
            var key = pair.Key;
            _treeItems.Add(new TreeViewItem {id = _treeItems.Count + 1, depth = depth, displayName = ((isValue == true) ? $"{key.name}_{key.value}" : key.name)});
            var childDict = pair.Value as Dictionary<EasyMemoryProfileDiffer.MemoryParam, IDictionary>;
            CreateTree(childDict, isValue, depth + 1);
        }
    }

    protected override TreeViewItem BuildRoot()
    {
        var root = new TreeViewItem {id = 0, depth = -1, displayName = "Root"};
        SetupParentsAndChildrenFromDepths(root, _treeItems);
        return root;
    }
}

感想

時間をかけずに適当に作りましたので、おかしい所や雑な所があると思います。

参考

Unity Profilerの表示情報をReflectionで取得してみる - Qiita