본문 바로가기

Android

안드로이드 문자리소스 Google Spread Sheet로 관리하기 (Android string resource with Google spread sheet)

안드로이드에서 리소스(resources)라 불리는 것들은 앱 내에서 사용하는 이미지, 문자열, 애니메이션, 레이아웃, 폰트, 아이콘등을 의미합니다. 이 글에서는 효율적인 문자열 관리를 위해 문자열을 Google spread sheet로 관리하는 방법에 대해 설명합니다. 문자열을 안드로이드 프로젝트 내부가 아닌Google spread sheet와 같은 외부요소로 관리하면 기획자 혹은 디자이너와 커뮤니케이션이 활발해지거나 잔 작업을 줄일 수 있습니다. 기획자가 직접 Google spread sheet에 접근하여 문자열을 수정할 수 있고 개발자는 별다른 수정없이 빌드만 하면 자동으로 수정된 문자열을 확인할 수 있습니다.


일반적인 안드로이드 문자열 리소스 관리 방법

기본적으로 안드로이드에서는 문자열을 res/values/strings.xml 파일로 관리합니다. 아래와 같이 한줄에 하나의 문자열을 태그를 사용해 키, 값으로 정의할 수 있습니다.

<string name="tv_hello">안녕하세요.</string>
<string name="tv_welcome">환영합니다.</string>
... 

정의한 문자열을 사용할때에는 키값을 사용하여 문자열을 적용시킬 수 있습니다.

// MainActivity.kt
override onCreate(...){
    val tempString = getString(R.string.tv_hello) // "안녕하세요"
    ....
}

strings.xml 파일을 통해 문자열을 관리하는 이유는 문자열 수정이 필요할 때 strings.xml파일에서만 검색해서 수정할 수 있기 때문입니다. 이렇게 되면 프로젝트 내부의 코드를 일일이 찾아볼 필요가 사라집니다.


strings.xml 의 단점

strings.xml을 사용했을 경우 장점이 많지만 단점도 존재합니다. 모든 문자열을 안드로이드 프로젝트 내부에 있는 파일로 관리하기 때문에 모든 추가/수정 작업은 개발자에 의해서 진행되어야 합니다. 앱 내부의 문자열 수정이 피드백이 들어오면 기획자 혹은 디자이너가 개발자에게 수정사항을 전달하고 개발자가 전달받은 사항들을 strings.xml파일에 적용시키고 난 뒤에 빌드를 해야하는 기나긴 과정을 거쳐야 합니다.


strings.xml 을 Google Spread Sheet로 관리!!

문자열 파일을 기획자 혹은 디자이너도 함께 수정할 수 있도록 개발자의 잔작업량이 줄어들고 일의 효율성이 증가할 수 있습니다. 이를 위해 Google Spread Sheet를 사용합니다. Google Spread Sheet에서 제공하는 기능중 Script기능과 Google Spread Sheet API를 사용하면 Spread sheet에 존재하는 데이터들을 strings.xml에 존재하는 데이터 형태로 변환시킬 수 있습니다. 변환되는 과정은 다음과 같습니다.

  1. Google Spread Sheet API를 사용해 시트를 csv포맷 형태로 다운받는다.

  2. csv파일을 파싱하여 strings.xml파일과 동일한 형식으로 새로운 strings.xml파일을 생성한다.

위 두 과정이 앱이 빌드될때마다 자동으로 실행된다면 strings.xml 파일은 항상 최신으로 유지되기 때문에 개발자가 따로 신경을 쓸 필요가 없어집니다. 빌드될때마다 위 과정을 실행시키기 위해선 Gradle에 사용자 정의한 함수가 필요합니다.


Google Spread Sheet 설정

새로운 Google Spread Sheet를 하나 생성하여 도구 -> 스크립트 편집기 실행하여 다음과 같은 스크립트를 작성합니다.

시트 생성 후 스크립트 편집기 진입

function onOpen() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet();
  var entries = [{
    name : "iOS Resource sheet",
    functionName : "iOSSheet"
  }, {
    name : "Android Resource sheet",
    functionName : "AndroidSheet"
  }];
  sheet.addMenu("리소스 만들기", entries);
};


function AndroidSheet(){
  var FileName = SpreadsheetApp.getActiveSpreadsheet().getName()+"_and";
  var files = DriveApp.getFilesByName(FileName);
  
  var sheet;
  // Check we found a sheet with the name
  while (files.hasNext()){
    sheet = files.next();
    if(sheet.getName() == FileName){
      Logger.log("Opened Sheet: " + FileName);
      return makeAndroidResource(sheet.getId());
    }
  }
  
  // We didn't find the file, so create it.
  sheet = SpreadsheetApp.create(FileName);
  Logger.log("Created new Sheet for: " + FileName);
  
  
  return makeAndroidResource(sheet.getId());
  
}

function iOSSheet(){
  var FileName = SpreadsheetApp.getActiveSpreadsheet().getName()+"_iOS";
  var files = DriveApp.getFilesByName(FileName);
  
  var sheet;
  // Check we found a sheet with the name
  while (files.hasNext()){
    sheet = files.next();
    if(sheet.getName() == FileName){
      Logger.log("Opened Sheet: " + FileName);
      return makeiOSResource(sheet.getId());
    }
  }
  
  // We didn't find the file, so create it.
  sheet = SpreadsheetApp.create(FileName);
  Logger.log("Created new Sheet for: " + FileName);
  
  
  return makeiOSResource(sheet.getId());
  
}

function makeAndroidResource(fileId){  
  // Get Spreadsheets
  var SourceSpreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var Targetspreadsheet = SpreadsheetApp.openById(fileId); //새로운 파일
  // Set Sheets
  var SourceSheet = SourceSpreadSheet.getSheets()[0];
  var TargetSheet  = Targetspreadsheet.getSheets()[0];
  
  TargetSheet.clear();
  
  // Get Source last row
  var last_row = SourceSpreadSheet.getLastRow();

  // Set Ranges
  var source_range = SourceSpreadSheet.getRange("B1:C"+last_row);
  var target_range = Targetspreadsheet.getRange("B1:C"+last_row);
  // 안드로이드 키 값
  var source_key_range = SourceSpreadSheet.getRange("A1:A"+last_row);
  var target_key_range = Targetspreadsheet.getRange("A1:A"+last_row);
  
  // Fetch values
  var values = source_range.getValues();
  var key_values = source_key_range.getValues();
  
  // Save to spreadsheet
  target_range.setValues(values);
  target_key_range.setValues(key_values);
  
}

function makeiOSResource(fileId){  
  // Get Spreadsheets
  var SourceSpreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var Targetspreadsheet = SpreadsheetApp.openById(fileId); //새로운 파일
  // Set Sheets
  var SourceSheet = SourceSpreadSheet.getSheets()[0];
  var TargetSheet  = Targetspreadsheet.getSheets()[0];
  
  TargetSheet.clear();
  
  // Get Source last row
  var last_row = SourceSpreadSheet.getLastRow();

  // Set Ranges
  var source_range = SourceSpreadSheet.getRange("A1:C"+last_row);
  var target_range = Targetspreadsheet.getRange("A1:C"+last_row);
  // ios 키 값
  var source_key_range = SourceSpreadSheet.getRange("D1:D"+last_row);
  var target_key_range = Targetspreadsheet.getRange("D1:D"+last_row);
  
  // Fetch values
  var values = source_range.getValues();
  var key_values = source_key_range.getValues();
  
  //ignore header
  for(var i=1; i< values.length; i++){
    values[i][0] = "\""+key_values[i]+"\"=\""+ values[i][0] +"\";";
    values[i][1] = "\""+key_values[i]+"\"=\""+ values[i][1] +"\";";
    values[i][2] = "\""+key_values[i]+"\"=\""+ values[i][2] +"\";";        
  }
  
  
  // Save to spreadsheet 
  target_range.setValues(values);
  //target_key_range.setValues(key_values);
  
}

 localization.gs를 작성하고 저장한 뒤 스프레드 시트를 새로고침 하면 아래 그림과 같이 가장 오른쪽에 새로운 메뉴가 생성된 것을 확인할 수 있습니다. 해당 메뉴에서 Android Resource sheet 혹은 iOS Resource sheet 버튼을 누르면 스크립트가 실행되고 스프레드 시트가 위치한 구글 드라이브에 [파일명_and] 라는 새로운 스프레드 시트가 생성된 것을 확인 할 수 있습니다. 새로 생성된 파일이 안드로이드에서 실제로 strings.xml파일로 사용할 데이터 입니다.

스크립트 작성 후 새로 생기는 메뉴

새로운 시트파일을 만드는 것은 보안적인 이유 입니다. 우리가 실제 수정하는 시트가 바로 앱에 적용된다면 실수로 인한 부작용이 많이 나타날 수 있으므로 하나의 시트를 더만들어 보완합니다.


Android Project Setting

안드로이드에서는 해야할 일은 구글 스프레드 시트를 csv형태로 다운받고 파싱한 뒤, strings.xml파일로 저장하는 과정을 진행합니다. 해당 과정은 Python 스크립트로 작성되어있고 Python 스크립트를 Gradle함수로 등록하여 사용합니다.

import os
import sys
import urllib

reload(sys)
sys.setdefaultencoding('utf-8')
import csv;

import gdata.docs.service
import gdata.spreadsheet.service

from xml.etree.ElementTree import ElementTree, Element


def get_gdoc_information_android():
    # email = raw_input('Email address:')
    # password = getpass('Password:')
    # gdoc_id = raw_input('Google Doc Id:')
    gdoc_id = sys.argv[1]
    downloadpath = sys.argv[2]
    try:
        file_path = download(gdoc_id)
        readCSV(downloadpath, file_path)
    except Exception, e:
        print ":::::::::::::ERROR:::::::::::::"
        print(e)
        # raise e


def download(gdoc_id, download_path=None, ):
    print "Downloading the CVS file with id %s" % gdoc_id

    gd_client = gdata.docs.service.DocsService()

    # auth using ClientLogin
    gs_client = gdata.spreadsheet.service.SpreadsheetsService()
    # gs_client.ClientLogin(email, password)

    # getting the key(resource id and tab id from the ID)
    resource = gdoc_id.split('/')[0]
    tab = gdoc_id.split('#')[1].split('=')[1]
    resource_id = 'spreadsheet:' + resource

    if download_path is None:
        download_path = os.path.abspath(os.path.dirname(__file__))

    file_name = os.path.join(download_path, '%s.csv' % (resource))

    print 'download_path : %s' % download_path;
    print 'Downloading spreadsheet to %s' % file_name

    # docs_token = gd_client.GetClientLoginToken()
    # gd_client.SetClientLoginToken(gs_client.GetClientLoginToken())
    # gd_client.Export(resource_id, file_name, gid=tab)
    # gd_client.SetClientLoginToken(docs_token)

    url = 'https://docs.google.com/spreadsheet/ccc?key=%s&output=csv&gid=%s' % (resource, tab)
    urllib.urlretrieve (url, file_name)

    print "Download Completed!"

    return file_name


def readCSV(savepath, file_name):
    print "read CSV file : %s" % file_name
    SourceCSV = open(file_name, "r")
    csvReader = csv.reader(SourceCSV)
    header = csvReader.next()
    androidkey_idx = header.index("key")
    eng_idx = header.index("en")
    kor_idx = header.index("ko")
    # jap_idx = header.index("ja")

    # Make an empty Element
    resources_eng = Element("resources")
    resources_kor = Element("resources")
    # resources_jap = Element("resources")

    # Loop through the lines in the file and get each coordinate

py파일을 생성한 뒤 [프로젝트 루트 디렉토리]/script/ 에 위치시킵니다. (script 디렉토리는 생성) 위에서 작성한 파이썬 파일을 실행시키기 위한 Gradle함수를 정의합니다. (앱모듈 Gradle파일에 정의)

Gradle(app)

android {
    ... 
} 
dependencies {
...
} 
ext.download_res_folder = 'src/main/res_down'
ext.script_path = 'script/languageResource.py'
ext.GoogleDocId = '스프레드 시트의 중간 id값'
task StringResourceSync(type: Exec) {
  doFirst {
    println ":::" + "Start to get String Resources..." + ":::"
  }
  commandLine = ['python', script_path, GoogleDocId, download_res_folder]
  doLast {
    println ":::" + "Finish to get String Resources..." + ":::"
  }
}
preBuild.dependsOn StringResourceSync

.gradle파일을 위와같이 작성하고 중간에 ext.GoogleDocId 에 들어갈 값은 아래 사진과 같이 새로 생성된 스프레드 시트(Android Resource Sheet메뉴를 눌러서 만들어진 시트)의 URL 중 /d/ 이후의 값을 입력해줍니다. 사용자 정의 함수 작성을 완료하고 Sync를 하면 Gradle함수에 StringResourceSync라는 이름으로 함수가 하나 생성됩니다.

https://docs.google.com/spreadsheets/d/GoogleDocId위치


스프레드 시트 작성

이제 스프레드 시트에 아래와 같은 형태로 한줄에 하나의 string을 정의 해봅니다. 사용할 문자열들을 모두 정의한 뒤 리소스 만들기 -> Android Resource sheet 메뉴를 눌러주고 안드로이드 프로젝트에서 빌드를 하면 strings.xml파일이 자동으로 생성됩니다.

[strings.xml]
<string name="tv_hello">안녕하세요.</string>
<string name="tv_welcome">환영합니다.</string>
...

결론

궁긍적으로는 개발자의 잔 작업을 줄이기 위해 이런 도구들을 만들지만 도구를 만들때에도 만만치 않은 작업이 들어갑니다. 물론 장기적으로 본다면 이런 자동화 도구를 사용하는 것이 좋을 것이라 예상합니다. 자잘한 커뮤니케이션의 양을 줄일 수 있고 귀찮은 작업을 할필요도 없어지니까요! 이런 문자열 관리 도구 뿐만아니라 다른 리소스(예를 들면 이미지라던지 폰트 등등)도 자동으로 관리해 줄 수 있는 도구들도 구현이 가능한지 알아보고 싶습니다.