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

Scriptable Object - 개념: 인스턴스와 에셋 생성 (2)

by PlaneK 2020. 5. 29.

스크립터블 오브젝트

ScriptableObject MonoBehaviour와 같이 Unity.Object를 상속하며 Inspector뷰에 노출된다. Unity.Object SerializedProperty로 캐스팅될 수 있는데 이는 곧 직렬화가 가능해서 Disk의 한 파일로 저장될 수 있다는 의미다.

SerializedProperty는 Editor 클래스가 참조하는 프로퍼티로 Inspector뷰를 구성할 때 쓰인다.

Editor 클래스로 별도의 작업을 안해도 Inspector 뷰에 프로퍼티가 보이는 이유는 MonoBehaviour에 대해 적용된, 보이지 않는 디폴트 Editor 클래스가 있는 것으로 추정해볼 수 있다.

예를 들면, MonoBehaviour.cs에서 선언된 List는 생성자 호출 없이 inspector뷰에 노출된다. 어디선가 Editor 클래스가 몰래 뒤에서 생성해주고 MonoBehaviour.cs의 해당 프로퍼티에 할당해 주는 것이다. 단, public 한정자나 [SerializedField]가 명시된 필드에만 해당한다.

 

스크립터블 오브젝트가 모노비해비어와 다른점은 컴포넌트가 될 수 없다는 것이다. Scriptable Object는 MonoBehaviour.cs가 하나의 필드로써 정의해주면 그 컴포넌트의 프로퍼티 형태로 있을 순 있지만 어떤 개체의 Add Component를 눌렀을 때 볼 수 있는 컴포넌트 목록에는 포함될 수 없다. 대신 Asset의 형태로 Disk에 저장될 수 있다는 특징이 있다. 단, 에디터 상과 다르게 배포된 빌드 상에서는 변경된 값을 에셋으로 저장할 수 없다.

스크립터블 오브젝트가 Asset의 형태로만 직렬화되진 않는다. 스크립터블 오브젝트의 인스턴스를 MonoBehaviour가 링크(할당, 참조)하고 있다면 Scene.meta 파일(인스턴스는 MonoBehaviour의 inspector뷰에 노출되기 때문)에 직렬화 될 수 있다.

단, Scene.meta파일은 MonoBehaviour의 inspector뷰의 내용을 기록하는데 이는 곧 ScriptableObject의 인스턴스를 Inspector뷰 단에서 생성가능해야 함을 의미한다. 따라서 Editor 클래스의 별도 작업이 필요하다.

간단하게 플로우를 설명하면 이렇다. 1) Editor클래스에서 스크립터블 오브젝트의 instance를 생성하고 2) Editor클래스가 그 인스턴스를 MonoBehaviour의 프로퍼티에 저장한다. 3) 그리고 Editor클래스는 해당 프로퍼티를 Inspector뷰에 노출시킨다.

 

인스턴스와 에셋 생성

1
2
3
4
5
6
7
8
using UnityEngine;
 
public class Condition : ScriptableObject
{
    public string description;
    public bool satisfied;
    public int hash;
}

Condition이라는 ScriptableObject 클래스가 있다. 메모리에 인스턴스를 생성하고 에셋 파일로 직렬화해서 디스크에 저장해보자.

1
2
3
4
5
6
7
8
9
using UnityEngine;
 
[CreateAssetMenu]
public class Condition : ScriptableObject
{
    public string description;
    public bool satisfied;
    public int hash;
}
 

첫 번째 방법은 해당 스크립터블 오브젝트에 [CreateAssetMenu]를 선언하는 것이다. 에셋 메뉴가 생성되서 컨텍스트 메뉴 'Asset > Create > Condition' 를 통해 바로 에셋을 생성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Start()
{
    // 인스턴스를 메모리에 생성하고 참조값을 반환한다.
    Condition newCondition = ScriptableObject.CreateInstance<Condition>();
    
    if (isSubAsset) 
        // 생성된 인스턴스를 기존 에셋의 하위 에셋으로 저장한다.
        AssetDatabase.AddObjectToAsset(newCondition, AllConditions.Instance);
    else
        // 생성된 인스턴스를 에셋으로 저장한다.
        AssetDatabase.CreateAsset(newCondition, "Assets/NewCondition.asset");
 
    // 임포트해서 추가된 에셋을 가시화 한다.
    AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(newCondition));    
 
    // 하위 에셋의 경로 : "Assets/Resources/AllConditions.asset"
    Debug.Log(AssetDatabase.GetAssetPath(newCondition));
}
 

두 번째 방법은 코드 상에서 'CreateInstance'를 호출하고 생성된 인스턴스를 에셋으로 변환하는 방법이다.

싱글톤 에셋 AllConditions.asset

1
2
3
4
5
6
7
8
9
10
11
12
13
[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];
}

AllConditions.asset은 AllConditionsEditor.cs의 Static 함수에서 생성된다. 해당 함수는 컨텍스트 메뉴 'Assets > Create > AllConditions' 를 통해 호출된다. 생성된 에셋의 인스턴스는 AllConditions.Instance에 저장된다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static AllConditions Instance
{
    get
    {
        if (!instance)
            instance = FindObjectOfType<AllConditions> ();
        if (!instance)
            instance = Resources.Load<AllConditions> (loadPath);
        if (!instance)
            Debug.LogError ("AllConditions has not been created yet. Go to Assets > Create > AllConditions.");
        return instance;
    }
    set { instance = value; }
}
 

AllConditions.Instance는 AllCoditions.asset의 인스턴스이다. public static 이기 때문에 언제든 누구나 참조 가능하다. 싱글톤 매니저의 역할로써 자신의 하위 에셋들을 매개해 준다.

AllConditions.Instance의 역할

AllConditions.Instance가 갖는 하위 에셋들은 Player가 참조하고 변경한다. 하위 에셋들을 참조하는 다른 개체들은 기능을 동작시키기 위해 읽어들이기만 한다. Player에겐 write/read 권한을, 다른 개체들에겐 readonly 권한만 주어진 것과 같다.

예를 들어, '출입문'개체는 Player가 문을 열 수 있는지 체크하기 위해 AllConditions.Instance를 참조해서 하위 에셋들을 읽어들인다. 이 때 '출입문'은 AllConditions.asset의 하위 에셋에 해당하는 스크립터블 오브젝트의 인스턴스를 생성해 가지고 있다. 그 인스턴스는 Asset으로 만들어지지 않으며 '출입문' 개체가 조건을 명시하기 위해 만든 것이므로 Player의 상태와 비교할 때 쓰인다.

"정확히 말하면 출입문이 생성한 인스턴스도 직렬화로 Scene.meta 파일에 저장되기 때문에 디스크에 저장된 Asset 파일과 크게 다를 바 없다."

디펜던시

위 '출입문'과 Player관계를 언급했는데 이 Player는 '출입문'의 디펜던시라고 볼 수 있다. '출입문' 개체의 문을 열고 닫는 기능은 Player의 상태(열쇠 유무 등)에 달렸지만 그 것을 스스로 소유하거나 생성하고 제어할 수 없기 때문이다.

구체적으로, '출입문'은 자신의 Open 함수 내용에 Player의 필드(열쇠 유무) 값을 참조해서 비교하는 구문이 포함됐을 것이다. 그리고 '출입문'으로써는 Player의 필드 값이 디펜던시일 수 밖에 없다. 왜냐하면 '출입문'은 Player의 일부가 아니고 서로 독립된 개체이기 때문이다. 즉, 해당 필드를 생성하고 제어할 수 있는 Player만이 가질 수 있는 프로퍼티인 것이고 '출입문'은 그럴 수 없으므로 디펜던시인 것이다.

'출입문'은 기존 방식처럼 PlayerManager.Instance에 접근해서 참조하거나 아키텍쳐가 지향하는 방식(Asset 파일의 인스턴스 접근)으로 참조할 수 있다. (feat. 디펜던시 인젝션)

 

콜백

스크립터블 오브젝트도 콜백 메서드가 있다. OnEnable(), OnDisable(), OnDestroy() 이 3가지가 대표적이다. OnEnable은 인스턴스가 생성되면 호출되고 OnDisable()과 OnDestroy()는 메모리에서 Unload 되어 참조카운트가 0이 되면 호출된다.

ISerializationCallbackReceiver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using UnityEngine;
 
[CreateAssetMenu]
public class SomeData : ScriptableObject, ISerializationCallbackReceiver
{
    public float InitialValue;
    
    [System.NonSerialized] //직렬화 대상 배제 및 인스펙터 뷰에 노출 안됨
    public float RuntimeValue;   
 
    // 역직렬화 후 호출
    public void OnAfterDeserialize()    
    {
        Debug.Log("OnAfterDesrialize " + RuntimeValue);
        RuntimeValue = InitialValue;
    }
 
    // 역직렬화 직전 호출
    public void OnBeforeSerialize()
    {
        Debug.Log("OnBeforeSerialize " + RuntimeValue);
    }
}
 

OnAfterDeserialize() = ".asset파일을 선택해서 Inspector뷰에 노출시킬 때"
OnBeforeDeserialize() = "프로젝트 저장 시 호출된다"

Editor 상 = "해당 .asset 파일을 참조시키면 OnBeforeDeserialize()가 먼저 호출되고 그다음 OnAfterDeserialize()가 호출된다."

빌드 앱 상= "활성화된 것 중 OnAfterDeserialize()만 호출된다."

댓글