-
궁극의 구글 스프레드시트 자동 데이터 불러오기 / Ultimate Google Spreadsheet Auto Calling Data유니티 2024. 8. 29. 17:01
완성본 미리보기
- 구글 스프레드시트 그대로 유니티에서 클래스 생성없이 모든게 자동화 됩니다.
- 기획자의 수많은 수정이 유니티에 그대로 반영되어 개발 노가다가 매우 감소합니다.
- 구글 앱스스크립트로 작성된 api와 통신해 json으로 모든 시트와 데이터를 불러오기 때문에,
csv나 tsv통신은 읽기 길이에 제한이 있지만 이것은 길이 제한이 없습니다. - 원하는 시트들만 클래스로 만들도록 선택할 수 있습니다.
- 변수 생성 시 int, float, bool, string 중 하나로 파싱됩니다.
- csv에서 파싱 중 문제가 있었던 문자 , ' " 에 대해서도 파싱이 가능합니다.
- 버그 안정성을 위해 빌드된 플랫폼에서는 로컬에 저장된 스크립터블 오브젝트를 이용합니다.
그래서 이것은 궁극기입니다!
구글 스프레드시트 셋팅
구글 스프레드시트(https://docs.google.com/spreadsheets/) 에서 빈 스프레드시트를 만듭니다.
오른쪽 위 공유에서 일반 액세스를 제한됨으로 하여 다른 사람이 볼 수 없게 합니다.
시트 이름이 클래스의 이름이므로 영어로 변경합니다.
1행은 클래스에 들어갈 변수 이름이므로 영어로 적어줍니다.
2행부터는 한 열의 형태를 int, float, bool, string 중 하나로 통일합니다.
- 열 형태 예시
int : 1, 2, 3
float : 0.5, 1.5, 2 (열에 최소 하나의 소수점이 필요합니다)
bool : TRUE, FALSE
string : hi, 안녕하세요쓰지 않는 부분은 행 또는 열을 선택하고 Ctrl + Shift + 방향키로 끝까지 전체 선택 후 우클릭 삭제를 해줍니다.
숨기기가 아닙니다.
기획자가 빈 곳에 글을 쓰다가 데이터를 지워도 남는 경우가 있어 json 파싱에러를 미연에 방지합니다.
이렇게 데이터 테이블을 구상하였습니다.
Note시트는 클래스로 안 만들 것이기 때문에 기획자가 형식에 안 맞추고 자유롭게 써도 됩니다.
확장 프로그램 탭 - Apps Script를 들어가 자바스크립트 기반인 앱스스크립트를 편집합니다.
제목을 JsonParser로 변경하겠습니다.
JsonParser.gs
function exportSheetToJsonWithArrays() { var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var sheets = spreadsheet.getSheets(); var result = {}; sheets.forEach(function(sheet) { var sheetName = sheet.getName(); if (sheetName.startsWith("NOEX_")) return; var data = sheet.getDataRange().getValues(); if (data.length > 0) { var headers = data[0]; var sheetData = []; for (var i = 1; i < data.length; i++) { var row = data[i]; var rowData = {}; var arrays = {}; headers.forEach(function(header, index) { if (header.startsWith("NOEX_")) return; var value = row[index]; if (header.startsWith("arr")) { var arrName = header.replace(/arr\d+_/, "arr_"); var arrIndex = parseInt(header.match(/arr(\d+)_/)[1], 10); if (!arrays[arrName]) { arrays[arrName] = []; } arrays[arrName][arrIndex] = value || null; } else { rowData[header] = value; } }); for (var arrName in arrays) { rowData[arrName] = arrays[arrName]; } sheetData.push(rowData); } result[sheetName] = sheetData; } }); var jsonOutput = JSON.stringify(result, null, 2); return jsonOutput; } function doGet() { var jsonOutput = exportSheetToJsonWithArrays(); return ContentService.createTextOutput(jsonOutput); }
exportSheetToJsonWithArrays 함수는 모든 시트를 배열로 만들어
1행을 키로, 2행부터는 값으로 json으로 만들어 주는 역할을 합니다.
doGet 함수는 http의 Get통신을 하면 이곳으로 들어오게 되어 파싱된 json을 반환해줍니다.
Ctrl + S를 눌러 저장 - 오른쪽 위 배포 탭 - 새 배포 - 유형 선택 톱니바퀴 버튼 - 웹 앱 -
액세스 권한이 있는 사용자를 모든 사용자로 변경 - 배포 -
액세스 승인 - 구글 아이디 선택 - 고급 - 안전하지 않음 이동 - 허용 - 웹 앱 URL을 복사합니다.
https://script.google.com/macros/s/your_script_id/exec 와 같은 형태가 나옵니다.
웹 앱 URL을 다시 보고 싶다면 오른쪽 위 배포 탭 - 배포 관리에서 확인할 수 있습니다.
유니티 셋팅
Window - Package Manager - + 버튼 - Install package by name... -
com.unity.nuget.newtonsoft-json 입력 - Install을 눌러 Newtonsoft Json 패키지를 임포트합니다.
그리고 Edit - Project Settings - Player - Other Settings - Api Compatibillity Level을
.NET Framework로 바꿔 에디터 상에서만 직렬화되는 버그를 제거합니다.
Newtonsoft Json이란?
복잡한 Json 데이터를 쉽고 빠르게 직렬화 역직렬화 동작을 수행합니다.
동적으로 string 키로 접근하는 부분이 강력합니다.GoogleSheetManager.cs
using System; using System.IO; using System.Net.Http; using System.Text; using System.Threading.Tasks; using System.Reflection; using System.Collections.Generic; using System.Collections; using UnityEngine; using Newtonsoft.Json.Linq; using System.Linq; public class GoogleSheetManager : MonoBehaviour { [Tooltip("true: google sheet, false: local json")] [SerializeField] bool isAccessGoogleSheet = true; [Tooltip("Google sheet appsscript webapp url")] [SerializeField] string googleSheetUrl; [Tooltip("Google sheet avail sheet tabs. seperate `/`. For example `Sheet1/Sheet2`")] [SerializeField] string availSheets = "Sheet1/Sheet2"; [Tooltip("For example `/GenerateGoogleSheet`")] [SerializeField] string generateFolderPath = "/GenerateGoogleSheet"; [Tooltip("You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`")] public ScriptableObject googleSheetSO; string JsonPath => $"{Application.dataPath}{generateFolderPath}/GoogleSheetJson.json"; string ClassPath => $"{Application.dataPath}{generateFolderPath}/GoogleSheetClass.cs"; string SOPath => $"Assets{generateFolderPath}/GoogleSheetSO.asset"; string[] availSheetArray; string json; bool refeshTrigger; static GoogleSheetManager instance; public static T SO<T>() where T : ScriptableObject { if (GetInstance().googleSheetSO == null) { Debug.Log($"googleSheetSO is null"); return null; } return GetInstance().googleSheetSO as T; } #if UNITY_EDITOR [ContextMenu("FetchGoogleSheet")] async void FetchGoogleSheet() { //Init availSheetArray = availSheets.Split('/'); if (isAccessGoogleSheet) { Debug.Log($"Loading from google sheet.."); json = await LoadDataGoogleSheet(googleSheetUrl); } else { Debug.Log($"Loading from local json.."); json = LoadDataLocalJson(); } if (json == null) return; bool isJsonSaved = SaveFileOrSkip(JsonPath, json); string allClassCode = GenerateCSharpClass(json); bool isClassSaved = SaveFileOrSkip(ClassPath, allClassCode); if (isJsonSaved || isClassSaved) { refeshTrigger = true; UnityEditor.AssetDatabase.Refresh(); } else { CreateGoogleSheetSO(); Debug.Log($"Fetch done."); } } async Task<string> LoadDataGoogleSheet(string url) { using (HttpClient client = new HttpClient()) { try { byte[] dataBytes = await client.GetByteArrayAsync(url); return Encoding.UTF8.GetString(dataBytes); } catch (HttpRequestException e) { Debug.LogError($"Request error: {e.Message}"); return null; } } } string LoadDataLocalJson() { if (File.Exists(JsonPath)) { return File.ReadAllText(JsonPath); } Debug.Log($"File not exist.\n{JsonPath}"); return null; } bool SaveFileOrSkip(string path, string contents) { string directoryPath = Path.GetDirectoryName(path); if (!Directory.Exists(directoryPath)) { Directory.CreateDirectory(directoryPath); } if (File.Exists(path) && File.ReadAllText(path).Equals(contents)) return false; File.WriteAllText(path, contents); return true; } bool IsExistAvailSheets(string sheetName) { return Array.Exists(availSheetArray, x => x == sheetName); } string GenerateCSharpClass(string jsonInput) { JObject jsonObject = JObject.Parse(jsonInput); StringBuilder classCode = new(); // Scriptable Object classCode.AppendLine("using System;\nusing System.Collections.Generic;\nusing UnityEngine;\n"); classCode.AppendLine("/// <summary>You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`</summary>"); classCode.AppendLine("public class GoogleSheetSO : ScriptableObject\n{"); foreach (var sheet in jsonObject) { string className = sheet.Key; if (!IsExistAvailSheets(className)) continue; classCode.AppendLine($"\tpublic List<{className}> {className}List;"); } classCode.AppendLine("}\n"); // Class foreach (var jObject in jsonObject) { string className = jObject.Key; if (!IsExistAvailSheets(className)) continue; var items = (JArray)jObject.Value; var firstItem = (JObject)items[0]; classCode.AppendLine($"[Serializable]\npublic class {className}\n{{"); // int > float > bool > string int itemIndex = 0; int propertyCount = ((JObject)items[0]).Properties().Count(); string[] propertyTypes = new string[propertyCount]; foreach (JToken item in items) { itemIndex = 0; foreach (var property in ((JObject)item).Properties()) { string propertyType = GetCSharpType(property.Value.Type); string oldPropertyType = propertyTypes[itemIndex]; if (oldPropertyType == null) { propertyTypes[itemIndex] = propertyType; } else if (oldPropertyType == "int") { if (propertyType == "int") propertyTypes[itemIndex] = "int"; else if (propertyType == "float") propertyTypes[itemIndex] = "float"; else if (propertyType == "bool") propertyTypes[itemIndex] = "string"; else if (propertyType == "string") propertyTypes[itemIndex] = "string"; } else if (oldPropertyType == "float") { if (propertyType == "int") propertyTypes[itemIndex] = "float"; else if (propertyType == "float") propertyTypes[itemIndex] = "float"; else if (propertyType == "bool") propertyTypes[itemIndex] = "string"; else if (propertyType == "string") propertyTypes[itemIndex] = "string"; } else if (oldPropertyType == "bool") { if (propertyType == "int") propertyTypes[itemIndex] = "string"; else if (propertyType == "float") propertyTypes[itemIndex] = "string"; else if (propertyType == "bool") propertyTypes[itemIndex] = "bool"; else if (propertyType == "string") propertyTypes[itemIndex] = "string"; } itemIndex++; } } itemIndex = 0; foreach (var property in firstItem.Properties()) { string propertyName = property.Name; string propertyType = propertyTypes[itemIndex]; classCode.AppendLine($"\tpublic {propertyType} {propertyName};"); itemIndex++; } classCode.AppendLine("}\n"); } return classCode.ToString(); } string GetCSharpType(JTokenType jsonType) { switch (jsonType) { case JTokenType.Integer: return "int"; case JTokenType.Float: return "float"; case JTokenType.Boolean: return "bool"; default: return "string"; } } bool CreateGoogleSheetSO() { if (Type.GetType("GoogleSheetSO") == null) return false; googleSheetSO = ScriptableObject.CreateInstance("GoogleSheetSO"); JObject jsonObject = JObject.Parse(json); try { foreach (var jObject in jsonObject) { string className = jObject.Key; if (!IsExistAvailSheets(className)) continue; Type classType = Type.GetType(className); Type listType = typeof(List<>).MakeGenericType(classType); IList listInst = (IList)Activator.CreateInstance(listType); var items = (JArray)jObject.Value; foreach (var item in items) { object classInst = Activator.CreateInstance(classType); foreach (var property in ((JObject)item).Properties()) { FieldInfo fieldInfo = classType.GetField(property.Name); object value = Convert.ChangeType(property.Value.ToString(), fieldInfo.FieldType); fieldInfo.SetValue(classInst, value); } listInst.Add(classInst); } googleSheetSO.GetType().GetField($"{className}List").SetValue(googleSheetSO, listInst); } } catch (Exception e) { Debug.LogError($"CreateGoogleSheetSO error: {e.Message}"); } print("CreateGoogleSheetSO"); UnityEditor.AssetDatabase.CreateAsset(googleSheetSO, SOPath); UnityEditor.AssetDatabase.SaveAssets(); return true; } void OnValidate() { if (refeshTrigger) { bool isCompleted = CreateGoogleSheetSO(); if (isCompleted) { refeshTrigger = false; Debug.Log($"Fetch done."); } } } #endif static GoogleSheetManager GetInstance() { if (instance == null) { instance = FindFirstObjectByType<GoogleSheetManager>(); } return instance; } }
GoogleSheetManager 스크립트를 만들고 위 내용을 복붙합니다.
하이어라키에 빈 게임오브젝트를 만들어 이름을 같게 해주고 스크립트를 컴포넌트로 붙힙니다.
각 변수에 마우스를 올려보면 팝업으로 설명이 뜨므로 참고해주세요.
- bool isAccessGoogleSheet
true: 구글 스프레드 시트에서 가져오기, false: 로컬 json 파일에서 가져오기
googleSheetSO 스크립터블 오브젝트 참조가 안되어 있거나 로컬에서 에러가 뜨면 로컬에서 가져오기로 불러옵니다.
- string googleSheetUrl
구글 스프레드시트 웹앱 URL입니다.
https://script.google.com/macros/s/your_script_id/exec와 같은 형태입니다.
- string availSheets
클래스를 만들기로 허용한 시트 이름만 `/` 로 구분해 줍니다.
여기서는 Sheet1/Sheet2로 하면 됩니다.
- string generateFolderPath
클래스, json파일, 스크립터블 오브젝트가 자동으로 생성될 폴더경로입니다.
앞에 `/`가 붙어야 하며 기본값은 /GenerateGoogleSheet입니다.
- ScriptableObject googleSheetSO
생성될 스크립터블 오브젝트입니다.
GoogleSheetSO형태로 사용해야 하므로 외부에서 참조할 때는
GoogleSheetManager.SO<GoogleSheetSO>()로 접근하면 됩니다.
프로젝트에 생성된 스크립터블 오브젝트를 인스펙터에 끌어다 놓아 사용하시지 말기 바랍니다.
스크립터블 오브젝트의 구조가 구글 스프레드시트에 따라 변하여 덮어씌워지기에 참조를 잃어버리기 때문입니다.값들을 정확히 넣었다면 스크립트 이름에 우클릭 - FetchGoogleSheet를 눌러줍니다.
Fetch done. 로그까지 뜨면 성공입니다.
프로젝트에 알아서 폴더가 만들어지고 클래스, json, 스크립터블 오브젝트 파일이 생성이 됩니다.
GoogleSheetClass.cs - 자동생성됨
using System; using System.Collections.Generic; using UnityEngine; /// <summary>You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`</summary> public class GoogleSheetSO : ScriptableObject { public List<Sheet1> Sheet1List; public List<Sheet2> Sheet2List; } [Serializable] public class Sheet1 { public int Stage; public string NameKr; public int Health; } [Serializable] public class Sheet2 { public string MonsterName; public float Percent; public bool IsWalk; }
GoogleSheetJson.json - 자동생성됨
{ "Sheet1": [ { "Stage": 1, "NameKr": "초원", "Health": 100 }, { "Stage": 2, "NameKr": "사막", "Health": 200 }, { "Stage": 3, "NameKr": "정글", "Health": 300 } ], "Sheet2": [ { "MonsterName": "Ork", "Percent": 0.5, "IsWalk": true }, { "MonsterName": "Goblin", "Percent": 0.3, "IsWalk": true }, { "MonsterName": "Bat", "Percent": 0.2, "IsWalk": false } ], "Note": [ { "이것은": "기획자가 쓰는" }, { "이것은": "노트입니다" }, { "이것은": "데이터 반영 X" } ] }
GoogleSheetSO.asset - 자동생성됨
완벽하게 작동합니다.
이제 기획이 수정되면 한숨을 쉬는 게 아니라 GoogleSheetManager 이름에 우클릭 -
FetchGoogleSheet로 딸깍딸깍 하면 클래스와 변수와 형식도 자동 생성됩니다!
Test.cs
using UnityEngine; public class Test : MonoBehaviour { GoogleSheetSO googleSheetSO; void Start() { googleSheetSO = GoogleSheetManager.SO<GoogleSheetSO>(); print(googleSheetSO.Sheet1List[0].Health); } }
사용을 하실때는 스크립터블 오브젝트를 인스펙터에 참조하지 말고
GoogleSheetManager.SO<GoogleSheetSO>()로 접근하여 캐싱 후 사용하시면 됩니다.
마무리
기획자와 협업할 때 밸런스 패치라던가 각종 수치들을 구글 스프레드시트로 많이 협업합니다.
디자인 패턴에서 MVP 패턴을 보면 모델을 분리합니다.
그래서 개발자는 더 이상 모델의 설계에 관하여 신경 쓸 필요가 없게 됩니다.
물론 스크립터블 오브젝트의 형태가 바뀌면 사용하는 부분에서는 바꿔줘야 하지요.
스크립터블 오브젝트의 데이터를 그대로 쓰셔도 되고,
새로운 클래스를 커스텀으로 만드셔서 스크립터블 오브젝트에서 필요한 부분만 가져오는 것도 괜찮은 방법입니다.
시간 절약에 도움이 되시길 바랍니다.
'유니티' 카테고리의 다른 글