본문 바로가기
Unity/스크립터블오브젝트

Scriptable Object - 응용: ConditionEditor, AllConditionsEditor, 프로젝트뷰 정리 (12)

by PlaneK 2020. 6. 9.

ConditionEditor

AllConditionsEditor와 ConditionCollectionEditor는 각각 Condition을 직접 생성한다.

AllConditionsEditor는 .asset파일로 생성하고, ConditionCollectionEditor는 인스턴스만 생성한다.

Condition의 인스턴스 생성은 ConditionEditor에서 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
using UnityEngine;
using UnityEditor;
 
[CustomEditor(typeof(Condition))]
public class ConditionEditor : Editor
{
    public enum EditorType
    {
        ConditionAsset, AllConditionAsset, ConditionCollection
    }
 
    public EditorType editorType;
    public SerializedProperty conditionsProperty;
 
    private SerializedProperty descriptionProperty;
    private SerializedProperty satisfiedProperty;
    private SerializedProperty hashProperty;
    private Condition condition;
 
    private const float conditionButtonWidth = 30f;
    private const float toggleOffset = 30f;
    private const string conditionPropDescriptionName = "description";
    private const string conditionPropSatisfiedName = "satisfied";
    private const string conditionPropHashName = "hash";
    private const string blankDescription = "No conditions set.";
 
    private void OnEnable ()
    {
        condition = (Condition)target;
 
        if (target == null)
        {
            DestroyImmediate (this);
            return;
        }
 
        descriptionProperty = serializedObject.FindProperty(conditionPropDescriptionName);
        satisfiedProperty = serializedObject.FindProperty(conditionPropSatisfiedName);
        hashProperty = serializedObject.FindProperty (conditionPropHashName);
    }
 
 
    public override void OnInspectorGUI ()
    {
        // editorType은 생성 호출 시 호출자가 직접 초기화한다.
        // "생성 함수의 파라미터로부터 초기화하는 게 더 낫지 않을까?"
        switch (editorType)
        {
            case EditorType.AllConditionAsset:
                AllConditionsAssetGUI ();
                break;
            case EditorType.ConditionAsset:
                ConditionAssetGUI ();
                break;
            case EditorType.ConditionCollection:
                InteractableGUI ();
                break;
            default:
                throw new UnityException ("Unknown ConditionEditor.EditorType.");
        }
    }
 
    /* 각 OnInspectorGUI 중략 */
 
    /* 혼란을 야기하므로 함수 오버로딩보다는 전용 함수로 분리하는 것이 낫지 않을까?*/
    // ConditionCollectionEditor가 호출하는 생성 함수.
    public static Condition CreateCondition()
    {
        Condition newCondition = CreateInstance<Condition>();
        string blankDescription = "No conditions set.";
        // AllConditions의 첫번째 Condition을 추출한다.
        Condition globalCondition = AllConditionsEditor.TryGetConditionAt(0);
        // 추출한 Condition이 있으면 그 값으로 초기화하고 없으면 디폴트 값으로 초기화한다.
        newCondition.description = globalCondition != null ? globalCondition.description : blankDescription;
        SetHash (newCondition);
        return newCondition;
    }
 
    // AllConditionsEditor가 호출하는 생성 함수.
    public static Condition CreateCondition (string description)
    {
        Condition newCondition = CreateInstance<Condition>();
        newCondition.description = description;
        SetHash(newCondition);
        return newCondition;
    }
 
 
    private static void SetHash (Condition condition)
    {
        // .asset파일의 Condition과 동적 생성된 Condition을 매핑하기 위해
        // Animator의 api를 통해 해쉬값을 얻는다.
        // string으로 비교하는 것보다 int형인 Hash값으로 비교하는 것이 훨씬 수월하다.
        condition.hash = Animator.StringToHash (condition.description);
    }
}
 

CondtionEditor.cs. OnInspectorGUI 부분은 중략했다.

Condition은 AllConditions.asset과 각 .asset파일, ConditionCollection에서 각기 다른 모습을 보인다.

커스텀 에디터를 통해 어떻게 구현됐는지 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
private void AllConditionsAssetGUI ()
{
    EditorGUILayout.BeginHorizontal(GUI.skin.box);
    EditorGUI.indentLevel++;
 
    EditorGUILayout.LabelField(condition.description);
 
    if (GUILayout.Button("-", GUILayout.Width(conditionButtonWidth)))
        AllConditionsEditor.RemoveCondition(condition);
 
    EditorGUI.indentLevel--;
    EditorGUILayout.EndHorizontal();
}
 
 
private void ConditionAssetGUI ()
{
    DrawDefaultInspector();
}
 
 
private void InteractableGUI ()
{
    serializedObject.Update ();
 
    float width = EditorGUIUtility.currentViewWidth / 3f;
 
    EditorGUILayout.BeginHorizontal();
    
    // condition은 ConditionEditor의 target 인스턴스다.
    // condition의 값은 생성 시 .asset파일의 첫번째 condition의 값으로 초기화 됨.
    // condition과 일치하는 .asset파일의 index 추출
    int conditionIndex = AllConditionsEditor.TryGetConditionIndex (condition);
    
    if (conditionIndex == -1)
        conditionIndex = 0;
 
    // EditorGUILayout.Popup(컨테이너 요소의 인덱스, 팝업 컨텍스트를 구성하는 각 메뉴의 이름 값의 컨테이너)
    // Popup함수는 현재 선택된 요소의 index를 반환한다.
    conditionIndex = EditorGUILayout.Popup (conditionIndex, AllConditionsEditor.AllConditionDescriptions,
        GUILayout.Width (width));
    // index에 해당하는 Condition 추출
    Condition globalCondition = AllConditionsEditor.TryGetConditionAt(conditionIndex);
    // 해당 컨테이너 요소의 값으로 초기화
    descriptionProperty.stringValue = globalCondition != null ? globalCondition.description : blankDescription;
 
    hashProperty.intValue = Animator.StringToHash (descriptionProperty.stringValue);
 
    EditorGUILayout.PropertyField(satisfiedProperty, GUIContent.none, GUILayout.Width(width + toggleOffset));
 
    if (GUILayout.Button("-", GUILayout.Width(conditionButtonWidth)))
    {
        // conditions는 ConditionCollectionEditor의 프로퍼티다.
        // ConditioncollectionEditor가 condition을 생성하면서 초기화시켜준 프로퍼티다.
        conditionsProperty.RemoveFromObjectArray(condition);
    }
 
    EditorGUILayout.EndHorizontal();
 
    serializedObject.ApplyModifiedProperties ();
}
 

CondtionEditor.cs. OnInspectorGUI 부분만 보여준다.

 

AllConditionsEditor

AllConditions는 Condition을 생성하고 conditions에 저장한다.

그리고 Condition만 쥐고 있다가 conditions의 길이를 통해 각 Condition의 에디터들을 항상 새로 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
using UnityEngine;
using UnityEditor;
 
[CustomEditor(typeof(AllConditions))]
public class AllConditionsEditor : Editor
{
    // 싱글톤이므로 static을 사용해도 무방하다.
    // 팝업을 구성하기 위해 초기화되는 컨테이너다.
    public static string[] AllConditionDescriptions
    {
        get
        {
            if (allConditionDescriptions == null)
            {
                SetAllConditionDescriptions ();
            }
            return allConditionDescriptions;
        }
        private set { allConditionDescriptions = value; }
    }
 
    private static string[] allConditionDescriptions;
    // OnInspectorGUI를 직접 호출하기 위해 subEditors로써 역할하는 컨테이너.
    // PropertyDrawer 대신 Editor를 사용하는 이유는 '-'버튼 생성 때문이다.
    private ConditionEditor[] conditionEditors;
    private AllConditions allConditions;
    private string newConditionDescription = "New Condition";
 
 
    private const string creationPath = "Assets/Resources/AllConditions.asset";
    private const float buttonWidth = 30f;
 
    // 해당 .asset파일이 선택되면 호출된다.
    private void OnEnable ()
    {
        // allConditions는 AllConditions.Instnace와 같음.
        allConditions = (AllConditions)target;
 
        if(allConditions.conditions == null)
            allConditions.conditions = new Condition[0];
 
        if (conditionEditors == null)
        {
            CreateEditors ();
        }
    }
 
    // 해당 .asset파일이 선택해제되면 호출된다.
    private void OnDisable ()
    {
        for (int i = 0; i < conditionEditors.Length; i++)
        {
            DestroyImmediate (conditionEditors[i]);
            // undo가 누락한 allConditions.conditions의 길이를
            // 재조정하는 것을 정의하자.
            if(!allConditions.conditions[i])
                ArrayUtility.RemoveAt(ref AllConditions.Instance.conditions, i);
 
        }
 
        // conditionEditors를 매번 Clean시킨다.
        conditionEditors = null;
    }
 
 
    private static void SetAllConditionDescriptions ()
    {
        AllConditionDescriptions = new string[TryGetConditionsLength()];
 
        for (int i = 0; i < AllConditionDescriptions.Length; i++)
        {
            AllConditionDescriptions[i] = TryGetConditionAt(i).description;
        }
    }
 
 
    public override void OnInspectorGUI ()
    {
        if (conditionEditors.Length != TryGetConditionsLength ())
        {
            for (int i = 0; i < conditionEditors.Length; i++)
            {
                DestroyImmediate(conditionEditors[i]);
            }
            
            CreateEditors ();
        }
 
        for (int i = 0; i < conditionEditors.Length; i++)
        {
            conditionEditors[i].OnInspectorGUI ();
        }
 
        if (TryGetConditionsLength () > 0)
        {
            EditorGUILayout.Space ();
            EditorGUILayout.Space ();
        }
 
        EditorGUILayout.BeginHorizontal ();
        newConditionDescription = EditorGUILayout.TextField (GUIContent.none, newConditionDescription);
        if (GUILayout.Button ("+", GUILayout.Width (buttonWidth)))
        {
            AddCondition (newConditionDescription);
            newConditionDescription = "New Condition";
        }
        EditorGUILayout.EndHorizontal ();
    }
 
 
    private void CreateEditors ()
    {
        conditionEditors = new ConditionEditor[allConditions.conditions.Length];
 
        for (int i = 0; i < conditionEditors.Length; i++)
        {
            conditionEditors[i] = CreateEditor(TryGetConditionAt(i)) as ConditionEditor;
            conditionEditors[i].editorType = ConditionEditor.EditorType.AllConditionAsset;
        }
    }
 
 
    [MenuItem("Assets/Create/AllConditions")]
    private static void CreateAllConditionsAsset()
    {
        if(AllConditions.Instance)
            return;
 
        AllConditions instance = CreateInstance<AllConditions>();
        AssetDatabase.CreateAsset(instance, creationPath);
 
        AllConditions.Instance = instance;
 
        instance.conditions = new Condition[0];
    }
 
 
    private void AddCondition(string description)
    {
        if (!AllConditions.Instance)
        {
            Debug.LogError("AllConditions has not been created yet.");
            return;
        }
 
        Condition newCondition = ConditionEditor.CreateCondition (description);
        newCondition.name = description;
 
        // Undo 스택리스트에 "Created new Condition"이란 이름으로 기록.
        // Undo를 실행했더니 Condition만 지워지고 allConditions.conditions의 길이는 그대로여서 null참조 에러 발생.
        // Undo는 숙련되지 않으면 사용하지 말자.
        Undo.RecordObject(newCondition, "Created new Condition");
 
        AssetDatabase.AddObjectToAsset(newCondition, AllConditions.Instance);
        AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(newCondition));
 
        // Arrayutility.add(배열, 삽입할 요소) 해당 요소를 배열에 추가한다.
        // 새로운 길이의 배열을 생성 후 AllConditions.Instance.condtions에 저장한다.
        // "배열 대신 리스트를 사용하는게 낫지 않을까?"
        ArrayUtility.Add(ref AllConditions.Instance.conditions, newCondition);
 
        EditorUtility.SetDirty(AllConditions.Instance);
 
        SetAllConditionDescriptions ();
    }
 
    // AllCondition.Instance.conditions
    public static void RemoveCondition(Condition condition)
    {
        if (!AllConditions.Instance)
        {
            Debug.LogError("AllConditions has not been created yet.");
            return;
        }
 
        Undo.RecordObject(AllConditions.Instance, "Removing condition");
 
        ArrayUtility.Remove(ref AllConditions.Instance.conditions, condition);
 
        DestroyImmediate(condition, true);
        AssetDatabase.SaveAssets();
 
        EditorUtility.SetDirty(AllConditions.Instance);
 
        SetAllConditionDescriptions ();
    }
 
    // AllCondition.Instance.conditions로부터 Index 추출
    public static int TryGetConditionIndex (Condition condition)
    {
        for (int i = 0; i < TryGetConditionsLength (); i++)
        {
            if (TryGetConditionAt (i).hash == condition.hash)
                return i;
        }
 
        return -1;
    }
 
    // AllCondition.Instance.conditions로부터 Condition 추출
    public static Condition TryGetConditionAt (int index)
    {
        Condition[] allConditions = AllConditions.Instance.conditions;
 
        if (allConditions == null || allConditions[0== null)
            return null;
 
        if (index >= allConditions.Length)
            return allConditions[0];
 
        return allConditions[index];
    }
 
    // .asset파일의 참조값은 AllCondition.Instance.conditions에 할당된다.
    public static int TryGetConditionsLength ()
    {
        if (AllConditions.Instance.conditions == null)
            return 0;
 
        return AllConditions.Instance.conditions.Length;
    }
}
 

AllConditionsEditor.cs. Undo에 의해 null참조가 발생한다.

Undo를 배제하기 위해 Dirty 구문으로 Undo 대신 ApplyModifiedProperties나 SaveAssets을 사용하자.

Dirty 구문이 없어도 'Ctrl + S'를 누르거나 Editor 종료 시 변경사항이 직렬화 되므로 사용하지 않아도 된다.

굳이 사용하려면 Undo기능이 없는 SaveAssets를 사용하자.

변경사항 Disk 직렬화(저장) API

AssetDatabase.SaveAssets() : "'File > Save Project' 기능과 같다. 프로젝트 저장을 실제 수행한다"
EditorUtility.SetDirty('non-scene objects') : "저장해야할 파일로 mark한다. Project를 종료하면 직렬화된다"

Undo.RecordObject(object, name) : "SetDirty() 대신 사용한다. 저장 대상으로 mark되면서 동시에 Undo 항목으로 등록된다."
SerializedObject.ApplyModifiedProperties() : "SetDirty 대신 사용된다."

저장은 AssetModificationProcessor.OnWillSaveAssets() 콜백을 받아 각각 진행되는데, Dirty로 마크하지 않아도 Editor는 디폴트로 콜백호출 하는 것으로 추정. 왜냐하면 테스트 시엔 SetDirty 구문 또는 관련 api를 사용하지 않아도 에디터 종료 시 또는 프로젝트 저장 시 직렬화된다. 

대신 AssetDatabase.SaveAssets()은 프로젝트 저장을 실행하기 때문에 변경사항을 곧바로 가시화할 수 있다.

EditMode에서는 Destroy를 사용할 수 없다. DestroyImmediatly를 대신 사용한다.

 

프로젝트뷰 정리

빈 씬에서 프로파일러로 Memory 부분을 스냅샷하면 Ref count가 0인 Condition이 존재한다.

이런 Condition은 생성해놓고 제대로 지워지지 않은 것들이다. Condition의 참조값을 잃어버려서 코드상으로는 지우지 못한다.

Undo에 의해 Condition이 지워졌지만 conditions의 길이는 다시 축소되지 못해 null참조가 발생했다. 때문에 이후 생성되는 Condition에 대해 제대로 Add 처리가 되지 않아 참조값을 저장하지 못함.


Ref count가 0인 .asset파일을 삭제할 수 없을까?

Condition이 생성될 때 Project뷰에 해당 .asset파일이 바로 보이지 않는다.

'ImAssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(newCondition));' 이 구문을 통해 .asset파일이 곧바로 보이진 않고 AllConditions를 접었다 펴면 생성된 .asset파일이 보인다. AllConditions.asset파일에 직렬화 되지 않았기 때문에 에디터는 변경사항이 없는 것으로 인식하기 때문이다. 직렬화되지 않아도 보이는 이유는 ImportAsset()함수 덕분이다.

'AssetDatabase.SaveAsset()'을 호출하면 바로 보인다.

 

댓글