Data Converter(이하 데이터 변환기)를 리팩터링하기로 결정했다. 1차 완성 후, 실제 사용하는데에 크게 문제가 없어서 추후에 수정하려고 마음먹었지만.. 거슬리는 부분이 많아서 바로 리팩터링 계획을 세웠다.
아래는 이번 리팩터링의 최소 목표다.
- 방대한 데이터 변환기 Editor 코드 분리하기
- 유니티 Import 시, 변환기 데이터가 날라가는 이슈 수정
- ScriptableObject 변환 시, List로 받을 수 있게 수정
총 3개의 목표를 세웠고 각자 해결하면서 얻은 바가 많아서.. 개발일지가 아니더라도 따로 빼내어 작성해볼 요량이다.
방대한 데이터 변환기 Editor 코드 분리하기
데이터 변환기는 Partial 클래스로 나뉜 DataConverterEditor와 DataConverterVariable 클래스가 있다. 각각의 역할은 다음과 같다.
- DataConverterEditor
: 데이터 변환기의 UI Node를 관리하고 캐스팅 및 기능 동작 메서드를 가진다
- DataConverterVariable
: 데이터 변환기에 사용되는 Path, Index, 등의 Data를 가진다.
C++의 헤더와 CPP를 생각하면서 분리했는데, Editor 클래스의 경우 여러개의 작업이 하나의 스크립트에 몰리게 되면서, 책임이 무거웠고 하나에 여러 작업이 몰리니 복잡해서 가독성에 불편함이 있었다. 2번과 3번 목표를 수정하려고 한다면, 불편한 가독성을 해결하는 것이 올바를 것 같아서 최우선적으로 수정을 진행했다.
웹 프로그래밍을 하면서 배웠던 내용을 조금 접목해보기로 했다. Atomic Design Pattern과 이벤트 주도적 설계를 고려했다.
먼저 데이터 변환기 Editor 코드에 포함된 Component 기능들을 따로 스크립트로 분리했다. 정말 Atomic이라면.. 더 세부적으로 분리하는 것이 맞겠지만 굵직한 기능을 위주로만 분리했다.
var nameField = rootVisualElement.Q<TextField>("ExcelNameField");
var loadToButton = rootVisualElement.Q<Button>("ExcelGetter");
_excelFileViewer = new ExcelFileViewer(nameField, loadToButton);
var sheetField = rootVisualElement.Q<DropdownField>("SheetField");
_sheetListViewer = new SheetListViewer(sheetField);
var columnField = rootVisualElement.Q<TextField>("ColumnField");
_columnListViewer = new ColumnListViewer(columnField);
_scriptPathField = rootVisualElement.Q<TextField>("ScriptPathField");
_soPathField = rootVisualElement.Q<TextField>("SOPathField");
_scriptPathField.isReadOnly = true;
_soPathField.isReadOnly = true;
실제로 코드를 보면 ExcelNameField, ExcelGetter, SheetField, ColumnField는 코드를 분리해서 제외한 것이 보이지만 ScriptPathField와 SOPathField는 그대로 데이터 변환기 Editor 코드에서 기능을 구현하고 사용된다.
using System;
using System.Collections;
using System.Collections.Generic;
using NPOI.SS.UserModel;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
public sealed class ExcelFileViewer
{
private TextField _excelTextField;
private Button _loadToExcelButton;
private IWorkbook _activeWorkbook;
public event Action<IWorkbook> onLoadFile;
public ExcelFileViewer(TextField field, Button button)
{
_excelTextField = field;
_loadToExcelButton = button;
_loadToExcelButton.clicked += OnClickLoadButton;
_excelTextField.isReadOnly = true;
}
public void LoadData()
{
var path = EditorPrefs.GetString("ExcelPath");
UpdateField(path);
}
private void OnClickLoadButton()
{
var path = OpenFileDialog(
"Title",
Application.streamingAssetsPath,
"xlsx"
);
UpdateField(path);
onLoadFile?.Invoke(_activeWorkbook);
}
private void UpdateField(string path)
{
EditorPrefs.SetString("ExcelPath", path);
_excelTextField.value = EditorPrefs.GetString("ExcelPath");
LoadExcelAtPath(_excelTextField.value);
}
private string OpenFileDialog(string title, string path, string extension)
{
return EditorUtility.OpenFilePanel(title, path, extension);
}
private void LoadExcelAtPath(string path)
{
_activeWorkbook = ExcelReader.OpenFile(path);
}
}
위 코드는 모든 문제가 해결된 코드라서 여러 목표가 달성된 후다. 이런 식으로 각 Component를 분리하고 책임지는 Script에서 역할을 수행하며, Event를 통해서 상태를 알릴 수 있도록 수정했다.
Component마다 책임지는 스크립트를 두면서 각자의 스크립트에서 주어진 데이터를 처리하도록 진행하여, 데이터 변환기 Editor 스크립트의 가독성을 살릴 수 있었다.
private void RegisterEvent()
{
_excelFileViewer.onLoadFile += SendSheetList;
_sheetListViewer.onChangedValue += UpdateField;
}
private void SendSheetList(IWorkbook workbook)
{
var results = Enumerable.Range(0, workbook.NumberOfSheets)
.Select(i => workbook.GetSheetAt(i).SheetName)
.ToArray();
_sheetListViewer.SetList(results);
_sheetListViewer.SetWorkbook(workbook);
}
데이터 변환기 Editor 스크립트의 일부인데, 이렇게 Event를 관리하고 데이터를 하위 컴포넌트의 인스턴스 메서드를 통해 전달하여, 작업을 수행할 수 있도록 한다.
유니티 Import 시, 변환기 데이터가 날라가는 이슈 수정
이 목표는 이전 목표에 비해서 조금 어려웠다. UI Toolkit을 사용하니, Unity에서 데이터가 변경되는 것을 방지하는 메서드나 Attribute가 있을 줄 알았는데.. 전혀 없다.
1. Static 사용, 직렬화하기
// 변경 전
private ExcelFileViewer _excelFileViewer;
// 변경 후 1)
[SerializeField] private ExcelFileVieer _excelFileViewer;
// 변경 후 2)
private static ExcelFileViewer _excelFileViewer;
Import 시에 여전히 날라간다. Reddit에서 Static과 직렬화를 통해서 해결할 수 있다는 이야기가 있어서 시도해봤지만, Static은 하나의 인스턴스를 두어서 상태를 공유하는 것인데 Import 초기화 사유와는 다른 부분이라서 혹시나 하면서 해봤다.
2. SaveChanges 메서드
SaveChanges 메서드는 저장이 되지 않았을 경우, MsgBox를 띄워주는 것이라서 상황과 맞지 않았다.
3. VisualTreeAsset을 직접 수정하기
지금은 EditorWindow 타입의 rootVisualElement 인스턴스를 가져와 따로 추가한 VisualTreeAsset 인스턴스를 Add 메서드를 통해 복사하여 포함한다.
이렇기 때문에 UI Toolkit의 UXML 파일 내부 값을 변경하기 위해서는 추가한 VisualTreeAsset 인스턴스를 수정하는 것이 되지 않는다. 직접적으로 UXML 파일에 접근하여 Value를 수정해야하지만, 접근할 수 있는 방법이 별도로 존재하지 않아 기각되었다.
4. EditorPrefs를 통해서 Save/Load 만들기 (해결!)
각자 Component 스크립트에서 사용자가 입력한 Data를 EditorPrefs 클래스를 사용하여 데이터를 보존하고 관리했다.
private void UpdateField(string path)
{
EditorPrefs.SetString("ExcelPath", path);
_excelTextField.value = EditorPrefs.GetString("ExcelPath");
LoadExcelAtPath(_excelTextField.value);
}
Import가 진행되면 EditorWindow 타입에서 CreateGUI 메서드를 실행하기 때문에 ReLoad 메서드를 만들어서 추가해주었다.
private void ReLoad()
{
if (!EditorPrefs.HasKey("ExcelPath"))
return;
_excelFileViewer.LoadData();
_sheetListViewer.LoadData();
var path = EditorPrefs.GetString("ExcelPath");
var workbook = ExcelReader.OpenFile(path);
var selectSheet = EditorPrefs.GetString("SelectSheet");
_sheet = workbook.GetSheet(selectSheet);
UpdateField(_sheet);
}
LoadData는 Data를 불러오는 메서드로 Interface를 따로 만들어서 뺄까.. 싶다가 안뺐다... 스크립트가 존재하는 객체는 LoadData로, 없으면 직접 EditorPrefs를 불러와서 값을 변경해준다.
그리고 저장된 EditorPrefs 값은 데이터 변환기 Window를 실행할 때 초기화해준다.
public static void ShowWindow()
{
DeleteCustomKey(editorKeys);
DataConverterEditor wnd = GetWindow<DataConverterEditor>();
wnd.titleContent = new GUIContent("DataConverterEditor");
wnd.minSize = new Vector2(400f, 500f);
wnd.maxSize = new Vector2(600f, 800f);
}
원래는 아무 생각 없이 EditorPrefs.DeleteAll 메서드를 사용했는데....... 생각하고 싶지 않다. 안전하게 사용한 Key만 제거할 수 있도록 Const String Array를 만들어서 Delete 메서드를 만들어주자.
private static void DeleteCustomKey(string[] keys)
{
var count = EditorPrefs.GetInt("Count");
for (int i = 0; i < count; ++i)
{
EditorPrefs.DeleteKey($"{keys[3] + i}");
}
foreach (var key in keys)
{
if (key == "SheetChoice")
continue;
EditorPrefs.DeleteKey(key);
}
}
ScriptableObject 변환 시, List로 받을 수 있게 수정
기존에는 하나의 데이터마다 ScriptableObject를 하나씩 생성하고 있었고 추후 데이터가 많아지면 관리가 어려울 것이라고 판단했다. 그래서 List로 받을 수 있도록 수정하기로 했는데...
뒤로 늦춘 목표일수록 난이도가 어려운 것 같다.
var obj = ScriptableObject.CreateInstance(fileType);
var listField = obj.GetType().GetField(Variable);
var addMethod = obj.GetType().GetMethod("AddData");
var fieldType = listField.FieldType.GetGenericArguments()[0];
var typeInstance = Activator.CreateInstance(fieldType);
많은 리플렉션 메서드를 포함하여 수정할 수 있었다..
인터넷에 리플렉션을 사용하여 List 변수에 값을 추가하는 방식이 많았는데 내 상황과는 모두 맞지 않아서 다른 방법으로 진행했다.
List<T>에서 T는 엑셀 파일에 따라서 타입이 생겨난다. T에 해당하는 타입으로 Instance를 생성해주고 다음과 같이 데이터를 입력한다.
foreach (var (keys, values) in excelValues)
{
for (int i = 0; i < names.Count; ++i)
{
object value = values[i];
switch (types[i])
{
case "int":
value = Convert.ToInt32(values[i]);
break;
case "string":
break;
case "float":
value = Convert.ToSingle(values[i]);
break;
default:
break;
}
typeInstance.GetType().GetField(names[i]).SetValue(typeInstance, value);
}
이후, for문이 끝나면 addMethod를 통해서 인스턴스를 추가해준다.
addMethod?.Invoke(obj, new object[] { typeInstance });
'활동 > 게임제작동아리 브릿지' 카테고리의 다른 글
대학생 연합 게임 제작 동아리 브릿지(BRIDGE) 12기 후기 (0) | 2024.07.11 |
---|---|
[Project Talisman] #4. Status 시스템 개선하기 (0) | 2024.06.19 |
[Project Talisman] #2. FSM 프레임워크 구현하기 (0) | 2024.04.20 |
[Project Talisman] #1. Data Converter 구현하기 (1) | 2024.04.19 |