-
유니티 쉽게 배우는 디자인 패턴 - MVC, MVP, MVVM 패턴 / Unity Design Pattern - MVC, MVP, MVVM Pattern유니티 2024. 8. 23. 00:33
개요
게임을 개발하다보면 기획에 따라 과정 중 UI의 변경이 참 많습니다.
이때 로직과 뷰를 분리하면 효과적으로 수정에 대응할 수 있습니다.
저도 로직과 뷰를 분리하여 개발했을 때와 안 했을 때 효율의 차이가 났습니다.
그럼 MVC, MVP, MVVM 패턴에 대해 알아보겠습니다.
위 셋은 MV가 앞에 공통으로 붙는데요.
데이터를 담는 Model과 화면에 보여주는 View를 공통으로 가지며
Model과 View를 어떻게 연결할 것이냐에 따라 Controller, Presenter, ViewModel인지를 구분합니다.
Model은 여기서는 단순히 스크립트에서 데이터를 표현하였지만
스크립터블 오브젝트, 서버에 올라간 값, 로컬 텍스트로 저장된 값 등을 의미합니다.
View는 가장 많이 쓰는 곳이 UI이지만
3D 모델, 애니메이션, 커스텀 인스펙터 등을 의미합니다.
샘플 UI 구성
Canvas 하위에 HP, MP, TestHPSlider를 만들었습니다.HP의 자식으로 HPSlider, HPText, HPMinusBtn, HPPlusBtn을 만들었습니다.
MP의 자식으로 MPSlider, MPText, MPMinusBtn, MPPlusBtn을 만들었습니다.
(TextMeshPro가 아닌 Legacy의 Text와 Button입니다.)
이때 모든 슬라이더는 자식의 Handle Slide Area 게임오브젝트를 지우고
Interactable을 끄고 MaxValue를 100으로 하였습니다.
HPMinusBtn은 HP를 10 감소
HPPlusBtn은 HP를 10 증가
MPMinusBtn은 MP를 10 감소
MPPlusBtn은 MP를 10 증가
TestHPSlider는 HP를 현재값으로 만들도록 설계하겠습니다.
Extension.cs
using UnityEngine.UI; using UnityEngine.Events; public static class Extension { public static void AddEvent(this Button btn, UnityAction action) { btn.onClick.AddListener(action); } public static void RemoveEvent(this Button btn) { btn.onClick.RemoveAllListeners(); } }
확장 메서드를 만들기 위한 Extension 클래스에는
버튼 클릭시 이벤트를 달아주는 AddEvent와
버튼 이벤트를 제거하는 RemoveEvent를 만들어 주었습니다.
이것은 btn.onClick.AddListener와 btn.onClick.RemoveAllListeners의 길이를 줄여줍니다.
MVC 패턴
Model-View-Controller로
데이터를 담는 Model, 화면에 보여주는 View, 둘을 조작하는 Controller로 이루어진 패턴입니다.
뷰를 분리한 패턴 중 가장 초기 패턴입니다.
Model.cs
using UnityEngine; public class Model : MonoBehaviour { [SerializeField] View view; [SerializeField] int hp; [SerializeField] int mp; public int HP { get => hp; set { hp = value; view.SetHP(value); } } public int MP { get => mp; set { mp = value; view.SetMP(value); } } }
View.cs
using UnityEngine; using UnityEngine.UI; public class View : MonoBehaviour { [SerializeField] Slider hpSlider; [SerializeField] Text hpText; [SerializeField] Slider mpSlider; [SerializeField] Text mpText; [field: SerializeField] public Slider TestHPSlider { get; private set; } [field: SerializeField] public Button HpPlusBtn { get; private set; } [field: SerializeField] public Button HpMinusBtn { get; private set; } [field: SerializeField] public Button MpPlusBtn { get; private set; } [field: SerializeField] public Button MpMinusBtn { get; private set; } public void SetHP(int hp) { hpSlider.value = hp; hpText.text = hp.ToString(); } public void SetMP(int mp) { mpSlider.value = mp; mpText.text = mp.ToString(); } }
Controller.cs
using UnityEngine; public class Controller : MonoBehaviour { [SerializeField] Model model; [SerializeField] View view; void Start() { view.HpPlusBtn.AddEvent(() => OnClickHP(true)); view.HpMinusBtn.AddEvent(() => OnClickHP(false)); view.MpPlusBtn.AddEvent(() => OnClickMP(true)); view.MpMinusBtn.AddEvent(() => OnClickMP(false)); view.TestHPSlider.onValueChanged.AddListener(OnSliderHPChanged); } void OnDestroy() { view.HpPlusBtn.RemoveEvent(); view.HpMinusBtn.RemoveEvent(); view.MpPlusBtn.RemoveEvent(); view.MpMinusBtn.RemoveEvent(); view.TestHPSlider.onValueChanged.RemoveAllListeners(); } void OnClickHP(bool isPlus) { int changeHP = isPlus ? 10 : -10; model.HP = Mathf.Clamp(model.HP + changeHP, 0, 100); } void OnClickMP(bool isPlus) { int changeMP = isPlus ? 10 : -10; model.MP = Mathf.Clamp(model.MP + changeMP, 0, 100); } void OnSliderHPChanged(float value) { model.HP = Mathf.Clamp((int)value, 0, 100); } }
각각의 스크립트를 각각의 빈 게임오브젝트에 넣어주고 인스펙터에는 이름에 맞는 걸 넣어주시면 됩니다.
Model은 hp와 mp 데이터를 보관하며 View를 참조합니다.
외부에서는 프로퍼티로 접근가능하며 set을 할 때 View를 업데이트 해줍니다.
View는 화면에서 변화하는 UI를 모두 참조합니다.
이때 사용자로부터 입력을 받는 UI는 앞에 [field: Serializefield] 를 붙혀 프로퍼티가 인스펙터에 보이게 합니다.
그리고 뒤에 { get; private set; }을 붙혀 외부에서는 가져올수만 있게 합니다.
SetHP와 SetMP로 뷰를 매개변수로 업데이트 되도록 public 함수로 만들었습니다.
(앞으로 설명할 MVP와 MVVM 패턴에서도 View의 코드는 동일합니다.)
Controller는 Model과 View를 참조합니다.
View에서 들어온 사용자의 입력을 여기서 처리합니다.
Start에서 각 버튼 클릭시와 슬라이더 조정시 이벤트를 등록하고, OnDestroy에서 이벤트를 해제합니다.
OnClickHP, OnClickMP, OnSliderHPChanged는 Model의 값을 조작하는 로직이 들어가면 됩니다.
여기서 왜 버튼을 인스펙터의 OnClick에서 조작하지 않았냐면
실제 조작되는 부분을 명확하게 해 주면서 함수를 public으로 선언하지 않고 숨길 수 있기 때문입니다.
MVP 패턴
Model-View-Presenter로
데이터를 담는 Model, 화면에 보여주는 View, 둘을 조작하는 Presenter로 이루어진 패턴입니다.
MVC와의 차이점으로는 Model과 View가 서로 모른다는 차이가 있습니다.
유니티에 뷰를 보여주는 가장 적합한 패턴입니다.
Model.cs
using System; using UnityEngine; public class Model : MonoBehaviour { public event Action<int> HPChanged; public event Action<int> MPChanged; [SerializeField] int hp; [SerializeField] int mp; public int HP { get => hp; set { hp = value; HPChanged?.Invoke(value); } } public int MP { get => mp; set { mp = value; MPChanged?.Invoke(value); } } }
Presenter.cs
using UnityEngine; public class Presenter : MonoBehaviour { [SerializeField] Model model; [SerializeField] View view; void Start() { view.HpPlusBtn.AddEvent(() => OnClickHP(true)); view.HpMinusBtn.AddEvent(() => OnClickHP(false)); view.MpPlusBtn.AddEvent(() => OnClickMP(true)); view.MpMinusBtn.AddEvent(() => OnClickMP(false)); view.TestHPSlider.onValueChanged.AddListener(OnSliderHPChanged); model.HPChanged += view.SetHP; model.MPChanged += view.SetMP; } void OnDestroy() { view.HpPlusBtn.RemoveEvent(); view.HpMinusBtn.RemoveEvent(); view.MpPlusBtn.RemoveEvent(); view.MpMinusBtn.RemoveEvent(); view.TestHPSlider.onValueChanged.RemoveAllListeners(); model.HPChanged -= view.SetHP; model.MPChanged -= view.SetMP; } void OnClickHP(bool isPlus) { int changeHP = isPlus ? 10 : -10; model.HP = Mathf.Clamp(model.HP + changeHP, 0, 100); } void OnClickMP(bool isPlus) { int changeMP = isPlus ? 10 : -10; model.MP = Mathf.Clamp(model.MP + changeMP, 0, 100); } void OnSliderHPChanged(float value) { model.HP = Mathf.Clamp((int)value, 0, 100); } }
Model은 hp와 mp 데이터를 보관합니다.
외부에서 set을 할 때 이벤트를 날리게 되며 이를 Presenter가 구독합니다.
View는 MVC 패턴에서와 코드와 내용이 동일합니다.
Presenter는 Model과 View를 참조합니다.
View에서 들어온 사용자의 입력을 여기서 처리합니다.
Start에서 각 버튼 클릭시와 슬라이더 조정시 이벤트를 등록하고, OnDestroy에서 이벤트를 해제합니다.
OnClickHP, OnClickMP, OnSliderHPChanged는 Model의 값을 조작하는 로직이 들어가면 됩니다.
더불어 Model의 값이 변하는 이벤트를 View에 반영하도록 구독하여 둘을 중재합니다.
반응형 프로그래밍과 R3 설치
(R3는 MVVM을 설명드리기 위해 먼저 설치가 필요합니다.)
변수가 바뀔 때 자동으로 UI가 바뀔 수 없을까? 고민을 많이 해 보셨을 겁니다.
이러한 고민은 유니티 뿐 아니라 웹이나 다른 언어에서도 같은 고민을 하였습니다.
그래서 반응형 프로그래밍(Reactive Extensions)이 유행하기 시작했습니다.
각 언어마다 이러한 패러다임이 생겨났으며 유니티에는 초기에 UniRx가 존재했습니다.
현재는 UniRx에서 발전한 R3라는 이름으로 더 간결하고 직관적인 게 등장하였습니다.
(유니티 자체로는 반응형 프로그래밍이 없으며 UIToolkit에서는 현재 등장했다고 합니다.)
그러면 R3를 설치해 보겠습니다.
R3는 NuGet을 통해 설치해야합니다.
- UniRx
유니티에 도입된 최초의 반응형 프로그래밍 무료에셋입니다.
에셋스토어에서 UniRx를 검색하여 다운받을 수 있습니다.
- NuGet
.NET 개발환경에서 널리 사용되는 외부 라이브러리나 프레임워크를 설치할 수 있는 패키지 통합관리 도구입니다.
- R3
관련문서는 깃허브에 있습니다.
https://github.com/Cysharp/R3NuGetForUnity (https://github.com/GlitchEnzo/NuGetForUnity) - Releases -
가장 최신버전의 .unitypackage 파일을 유니티에 임포트합니다.
NuGet 탭 - Manage NuGet Packages - R3 검색 - R3창의 Install로 설치하시면 됩니다.
MVVM 패턴
Model-View-ViewModel로
데이터를 담는 Model, 화면에 보여주는 View, ViewModel이 갱신되면
자동으로 View도 갱신되는 데이터 바인딩으로 이루어진 패턴입니다.
Model.cs
using UnityEngine; using R3; public class Model : MonoBehaviour { [SerializeField] int hp; [SerializeField] int mp; public ReactiveProperty<int> HP; public ReactiveProperty<int> MP; void Awake() { HP = new(hp); MP = new(mp); HP.Subscribe(x => hp = x); MP.Subscribe(x => mp = x); } void OnValidate() { if (HP == null) return; HP.Value = hp; MP.Value = mp; } void OnDestroy() { HP?.Dispose(); MP?.Dispose(); } }
ViewModel.cs
using UnityEngine; using R3; public class ViewModel : MonoBehaviour { [SerializeField] Model model; [SerializeField] View view; void Start() { view.HpPlusBtn.AddEvent(() => OnClickHP(true)); view.HpMinusBtn.AddEvent(() => OnClickHP(false)); view.MpPlusBtn.AddEvent(() => OnClickMP(true)); view.MpMinusBtn.AddEvent(() => OnClickMP(false)); view.TestHPSlider.onValueChanged.AddListener(OnSliderHPChanged); model.HP.Subscribe(x => view.SetHP(x)); model.MP.Subscribe(x => view.SetMP(x)); } void OnDestroy() { view.HpPlusBtn.RemoveEvent(); view.HpMinusBtn.RemoveEvent(); view.MpPlusBtn.RemoveEvent(); view.MpMinusBtn.RemoveEvent(); view.TestHPSlider.onValueChanged.RemoveAllListeners(); } void OnClickHP(bool isPlus) { int changeHP = isPlus ? 10 : -10; model.HP.Value = Mathf.Clamp(model.HP.Value + changeHP, 0, 100); } void OnClickMP(bool isPlus) { int changeMP = isPlus ? 10 : -10; model.MP.Value = Mathf.Clamp(model.MP.Value + changeMP, 0, 100); } void OnSliderHPChanged(float value) { model.HP.Value = Mathf.Clamp((int)value, 0, 100); } }
Model은 hp와 mp 데이터를 보관합니다.
ReactiveProperty는 new를 해 초기값을 넣어주어야 하며, 값이 바뀌면 Subscribe를 통해 반응합니다.
Awake에서는 ReactiveProperty가 바뀔 때 변수가 자동으로 바뀌는 단방향 바인딩이며,
OnValidate에서는 인스펙터에서 변수가 바뀔 때 ReactiveProperty가 자동으로 바뀌는 단방향 바인딩으로
상호 바인딩이 되어 있습니다.
OnDestroy에서 메모리에서 해제합니다.
View는 MVC 패턴에서와 코드와 내용이 동일합니다.
ViewModel은 Model과 View를 참조합니다.
View에서 들어온 사용자의 입력을 여기서 처리합니다.
Start에서 각 버튼 클릭시와 슬라이더 조정시 이벤트를 등록하고, OnDestroy에서 이벤트를 해제합니다.
또 Model의 ReactiveProperty 값이 바뀌는 것을 View에 바인딩합니다.
OnClickHP, OnClickMP, OnSliderHPChanged는 Model의 값을 조작하는 로직이 들어가면 됩니다.
마무리
MVC 패턴은 참조관계가 얽혀있는 조금 난잡한 패턴
MVP 패턴은 뷰와 모델이 분리된 유니티에 가장 적합한 패턴
MVVM 패턴은 반응형 프로그래밍이 들어간 패턴
이라고 평가를 내려볼 수 있겠습니다.
기획이 자주 바뀌는 UI 작업시 큰 도움이 될 것입니다.
'유니티' 카테고리의 다른 글