개발 조각글

Unity - Component / Controller / Data로 분리된 UI System.

BaekNohing 2024. 1. 2. 01:06

들어가며

이전 프로젝트의  UI 구조에서 느낀 문제점을 개선해보기 위해 시험삼아 만들어본 사이드 프로젝트입니다.

문제점

class Page 
{	
    object pageData; // It can be any other types
    Text titleLabel;
    
    public void Init(object condition)
    {
    	pageData = SetPageData(condition);
        Refresh();
    }
    
    public void Refresh()
    {
    	titleLabel.text = GetTitle(pageData);
    }
}

이전 프로젝트에는 다음과 같은 형태로 페이지개체가 설계되어 있습니다 (페이지 개체가 하위 컴포넌트들을 관리하는 방식입니다, 모사 코드이므로 object와 같은 부분들은 적당한  형태를 넣어서 생각해주시면 됩니다). 실제로 그럭저럭 돌아가는 이 페이지 개체의 형태는 무난하고 흔한 모습인데다 눈에 띄는 결함도 없어보이지만, 쓰다보면 다음 문단에서 후술할 몇가지  불편함을 느끼게 됩니다. 


첫번째 불편함. 페이지 개체에 데이터를 복사해야한다.

상기한 구조에서 Init당시 object condition을 전달하면서 Page개체에 정보를 저장해두었습니다. (pageData) 그리고 각 컴포넌트들은 이 저장된 정보에 기반하여 값을 갱신했습니다. 

문제는, 이 condtion에 대한 데이터가 Page외에 따로 존재하고 있었다는 점 입니다. 즉 이 구조에서 Page는 언제나 현재-과거 사이의 condition 데이터를 가지고 있을 수 밖에 없습니다. (=pageData가 항상 최신임이 보장되지 않았습니다)


두번째 불편함. 갱신이 수동이다.

public void Refresh()에서 해당 함수가 public으로 열려있다는 것은, 어딘가에서 이 페이지 개체에 접근해 해당 함수를 실행할 수 있도록 했다는 것입니다. 갱신되어야 하는 모든 순간에 잘 갱신이 이루어졌다면 문제가 없었겠지만. 메뉴얼한 모든 옵션은 실수를 전제하고 있기 때문에 불편한 부분이었습니다. 

  • 이 문제가 Refresh가 늦게 호출되거나 호출되지 않아서, 이름이 조금 늦게 갱신되는 정도라면, 솔직히 큰 문제로 번지지는 않을 것입니다. 하지만 Refresh가 호출되지 않거나 늦게 호출되어서 소모되었어야 하는 재화가 소모되지 않았다고 표시된다면 어떨까요? 눌려선 안되는 버튼이 여전히 누를 수 있다고 표시된다면...? 이렇듯 페이지의 수동 갱신은 매 업데이트마다 이런 파멸적인 오류가 발생할 수 있는 가능성을 내포하고 있는 구조입니다. 

세번째 불편함. 연관성이 낮은 요소도 한 페이지에 있으면 한번에 갱신된다.

 

 


핵심 원인

이 문제의 원인은 Data, Component, Refresh(=Aciton)이 한 클래스 안에서 관리되고 있기 때문이라고 판단했습니다. 그리고 이 세 요소를 적절하게 분리해 줄 필요가 있다고 생각했습니다. 

개선

이것을 개선하기 위하여, 한 개체에 담기는 구성요소들을 적절히 분해하였다.

  • Page는 각 Component를 Init하면서 각각의 Refresh 규칙을 주입하며.
  • 각 Component들은 자신이 바라봐야하는 Data객체에 이 Refresh를 등록한다.
  • Data는 자신의 데이터가 변경되면 등록된 Refresh 들을 일제히 실행한다.

각 개체의 세부 구현 내용은 다음과 같다

AData.cs

  • SetValue<T>(T value, string targetName)을 통해 값을 변경하며, 등록된 Refresh 실행됨. 
더보기
public interface IData
{
    public void SetValue<T>(T value, string targetName);
}

public abstract class AData : ScriptableObject, IData, UIObject.IViewData
{
    public abstract void Initialized();

    public ComponentAction RefreshActions { get; } = new();
    public void AddRefreshAction<T>(Action action) where T : class
    {
        if (action == null)
            throw new ArgumentNullException($"ViewBase.InitComponent: component is null");

        RefreshActions.SetAction<T>(action);
    }

    public void RemoveRefreshAction<T>(Action action) where T : class
    {
        if (action == null)
            throw new ArgumentNullException($"ViewBase.InitComponent: component is null");

        RefreshActions.RemoveAction<T>(action);
    }

    public void SetValue<T>(T value, string targetName)
    {
        var propertyInfo = GetPropertyInfo(targetName);
        var fieldInfo = GetFieldInfo(targetName);

        if (propertyInfo != null)
            SetAsProperty(value, propertyInfo);
        else if (fieldInfo != null)
            SetAsField(value, fieldInfo);
        else
            throw new ArgumentException($"ViewData.SetValue: {targetName} is not found");

        Refresh();
    }

    PropertyInfo GetPropertyInfo(string propertyName)
    {
        return GetType()?.GetProperty(propertyName,
            BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
    }

    FieldInfo GetFieldInfo(string fieldName)
    {
        return GetType()?.GetField(fieldName,
            BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
    }

    void SetAsProperty<T>(T value, PropertyInfo propertyInfo)
    {
        if (propertyInfo.GetValue(this) is not T targetProperty)
            throw new ArgumentException($"ViewData.SetValue: {propertyInfo.Name} is not found");

        if (EqualityComparer<T>.Default.Equals(targetProperty, value))
            return;

        propertyInfo.SetValue(this, value);
    }

    void SetAsField<T>(T value, FieldInfo fieldInfo)
    {
        if (fieldInfo.GetValue(this) is not T targetField)
            throw new ArgumentException($"ViewData.SetValue: {fieldInfo.Name} is not found");

        if (EqualityComparer<T>.Default.Equals(targetField, value))
            return;

        fieldInfo.SetValue(this, value);
    }

    void Refresh()
    {
        if (!this)
            return;

        if (RefreshActions == null)
            throw new Exception($"{this.name} ViewBase.Refresh: page is not initialized");

        RefreshActions.Invoke();
    }
}

UIComponent.cs

  • Refresh는 Evaluator(=상태 결정기)와 Draw(컴포넌트 드로우)로 나뉘어서 구성되었음.
더보기
public class UIComponent : MonoBehaviour, IPointerClickHandler, IEventSystemHandler
{
    public struct UIComponentInitData
    {
        public Type type;
        public IViewData data;
        public Func<IViewData, ComponentStatus, ComponentStatus> evaluator;
        public Func<UIBehaviour, IViewData, ComponentStatus, UIBehaviour> draw;
        public ViewBase parent;

        public UIComponentInitData(Type type, IViewData data, Func<IViewData, ComponentStatus, ComponentStatus> evaluator, Func<UIBehaviour, IViewData, ComponentStatus, UIBehaviour> draw, ViewBase parent)
        {
            if (type != typeof(TextMeshProUGUI) && type != typeof(Text) &&
                type != typeof(Image) && type != typeof(Button))
                throw new ArgumentException($"UIComponent.Init: type is not Text, Image or Button");

            if (data == null || evaluator == null || draw == null)
                throw new ArgumentNullException($"UIComponent.Init: argument is null");

            this.type = type;
            this.data = data;
            this.evaluator = evaluator;
            this.draw = draw;
            this.parent = parent;
        }
    }


    public ComponentStatus Status = ComponentStatus.Enable;
    [SerializeField] UIBehaviour ComponentBody;
    [SerializeField] ComponentAction Action = new();

    IViewData _viewData;
    ViewBase _parent = null;

    Func<IViewData, ComponentStatus, ComponentStatus> Evaluator { get; set; } = (data, status) =>
        throw new NotImplementedException("Evaluator is not implemented");
    Func<UIBehaviour, IViewData, ComponentStatus, UIBehaviour> Draw { get; set; } = (body, data, status) =>
        throw new NotImplementedException($"Draw is not implemented");

    public void Refresh()
    {
        if (!this) return;

        if (ComponentBody == null || _viewData == null || Evaluator == null || Draw == null)
            throw new NullReferenceException($"{this.gameObject.name} UIComponent.Refresh: component is not initialized");

        Status = Evaluator(_viewData, Status);
        Draw(ComponentBody, _viewData, Status);
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (_parent == null ||
            !_parent.IsTop() ||
            _parent.IsPaused)
            return;

        Utility.Logger.Log($"{_parent?.IsTop()} {gameObject.name} UIComponent.OnPointerClick: {eventData.button} button is clicked");

        Action?.Invoke();
    }

    public void Initialized(UIComponentInitData initData)
    {
        var body = transform.GetComponent(initData.type) as UIBehaviour;

        if (!body)
            throw new NullReferenceException($"{this.gameObject.name} UIComponent.Init: body is null");

        SetBody(body);
        SetParent(initData.parent);
        SetData(initData.data);
        SetEvaluator(initData.evaluator);
        SetDraw(initData.draw);
    }

    public void SetBody(UIBehaviour body)
    {
        Debug.Assert(body != null);
        ComponentBody = body;
    }

    public void SetParent(ViewBase parent)
    {
        Debug.Assert(parent != null);
        _parent = parent;
    }

    public void SetData(IViewData data)
    {
        Debug.Assert(data != null);

        if (_viewData != null && _viewData != data)
            _viewData.RemoveRefreshAction<UIComponent>(Refresh);
        else if (_viewData == data)
            return;

        data.AddRefreshAction<UIComponent>(Refresh);
        _viewData = data;
    }

    public void SetEvaluator(Func<IViewData, ComponentStatus, ComponentStatus> evaluator)
    {
        Debug.Assert(evaluator != null);
        Evaluator = evaluator;
    }

    public void SetDraw(Func<UIBehaviour, IViewData, ComponentStatus, UIBehaviour> draw)
    {
        Debug.Assert(draw != null);
        Draw = draw;
    }

    /// <summary>
    ///
    /// </summary>
    /// <typeparam name="T"> 해당 액션을 정의한 클래스의 출처 </typeparam>
    /// <param name="action"></param>
    public void SetAction<T>(Action action) where T : class
    {
        Debug.Assert(action != null);
        Action.SetAction<T>(action);
    }
}

DialogueView.cs (Example)

  • 각 컴포넌트 Init시 Evaluator와 Draw을 어떻게 주입하는지 보여주는 예시
더보기
public class DialogueView : ViewBase
{
    DialogueViewData DialogueViewData => CoreSystem.SystemRoot.Data.GetData<DialogueViewData>();

    [SerializeField] UIComponent _dialogueText;
    [SerializeField] UIComponent _dialogueButton;

    [SerializeField] TypewriteController _typewriteController;

    public override void Initialized()
    {
        SetDialogueText();
        SetDialogueButton();
    }

    void SetDialogueText()
    {
        UIComponent.UIComponentInitData _dialogueTextInitData = new(
            typeof(TextMeshProUGUI), DialogueViewData,
            ComponentUtility.EvaluatorImmutable,
            DrawDialogueText,
            this
        );

        InitComponent(_dialogueText, _dialogueTextInitData);
    }

    UIBehaviour DrawDialogueText(UIBehaviour body, IViewData data, ComponentStatus status)
    {
        ((TextMeshProUGUI)body).text = ((DialogueViewData)data).CurrentDialogueText;
        return body;
    }

    void SetDialogueButton()
    {
        UIComponent.UIComponentInitData _dialogueButtonInitData = new(
            typeof(Image), DialogueViewData,
            ComponentUtility.EvaluatorImmutable,
            ComponentUtility.DrawImmutable,
            this
        );

        InitComponent(_dialogueButton, _dialogueButtonInitData);

        _dialogueButton.SetAction<DialogueView>(DialogueAction);
    }

    void DialogueAction()
    {
        if (_typewriteController.IsTyping)
        {
            _typewriteController.SkipWriter();
        }
        else
        {
            DialogueViewData.Next();
            if (!DialogueViewData.IsDialogueEnd)
                _typewriteController.StartWriter();
        }
    }
}

 

 

기타

현재 진행중인 토이프로젝트 DolgojiAdventure > Scripts / UISystem에서 상세 코드를 확인할 수 있습니다. 

 

GitHub - BaekNothing/DolgojiAdventure: DolgogyAdventure by.baekNothing

DolgogyAdventure by.baekNothing. Contribute to BaekNothing/DolgojiAdventure development by creating an account on GitHub.

github.com