보통 안드로이드 게임들은 초기에 웹에서 추가 리소스를 받는 경우가 상당히 많다. 앱 용량이 많이 나가면 받지 않는 경우가 있기 때문이다. 추가 리소스를 다운로드 받게 하려면 PHP 기반 웹 서버와 간단한 HTTP 프로토콜을 이용하면 된다. 이를 통해 데이터를 송수신을 할 수 있게 된다. 모바일에서는 다소 느릴 수 있지만 구체적으로 예를 들면 안드로이드에서는 보통 java.net의 HttpURLConnection 객체로 직접 데이터를 웹에 요청하여 수신하는 방법이 있다. 데이터 요청 시 서버가 반환하는 데이터를 한 줄씩 받아오면서 진행률을 표시하고 실제 파일로 저장하는 것은 어려운 일이 아니다.

 

또한 이러한 리소스를 웹에서 내려 받게 되면, 앱을 4MB 미만의 초 저용량으로 배포할 수 있다는 굉장한 장점이 생기게 된다. 처리 과정은 다음과 같다. 앱 실행 이후에 버전을 비교한 후 압축된 리소스를 내려 받는다. 그리고 압축을 푼 후 실행하는 것이다. RPG Maker MV에서 도저히 구현할 수 없을 것 같지만, 코르도바를 이용하면 자바에서 직접 네이티브 확장을 하지 않더라도 자바스크립트 만으로도 쉽게 구현을 할 수 있다.

 

아래 동영상은 코르도바를 이용하여 구현한 실제 모바일 테스트 화면이다. 초기 진행률 표시기의 UI는 시멘틱 UI와 제이쿼리를 이용하여 만들었다. 실제 파일은 웹에 있고 버전 비교를 한 후, 안드로이드 웹뷰가 실제 웹에서 내려 받은 로컬 HTML 파일을 실행하면 게임이 실행되게 된다. 웹뷰에서는 메인 페이지를 기점으로 상대 경로로 파일에 접근할 수 있기 때문에 이러한 작업이 편리해진다.

 

 

소스 코드 분석은 나중에 하겠다. 사용 방법부터 설명하자면 먼저 세팅을 해야 한다. 세팅은 Github에서 MV-Android-Updater를 clone을 하는 것부터 시작한다. 압축된 파일로 내려 받고 압축을 해제해도 상관은 없다. 깃은 쉽게 설치할 수 있고 윈도우즈에서는 기본적으로 설치가 되어있진 않지만, 한 번 설치 해놓으면 이러한 소스를 내려받을 때 유용하게 사용할 수 있다.

git clone https://github.com/biud436/MV-Android-Updater.git

 

https://github.com/biud436/MV-Android-Updater.git

 

biud436/MV-Android-Updater

Contribute to biud436/MV-Android-Updater development by creating an account on GitHub.

github.com

다음에는 리소스 파일을 만들어야 한다. RPG Maker MV를 실행하고 미사용 리소스를 제거한 후, Android/iOS로 게임을 배포한다. 이렇게 배포된 파일을 ZIP로 통째로 압축하고 드랍박스에 업로드 하면 된다. 드랍박스는 통상적으로 하루에 20GB의 일일 트래픽을 제공한다. 게임이 정말 메가히트를 치지 않는 이상 이러한 트래픽을 하루에 소비하기란 어려울 수 있다. 아무튼 리소스를 드랍박스에 업로드를 해두고 파일을 공유하고 주소를 복사해야 한다. 

 

let config = {
    packageName : "me.biud436.fileapi",    
    resource : {
        "url" : "https://www.dropbox.com/s/g6yfac905flmna6/Simplify.zip?dl=1",
        "fileUrl" : `cdvfile://localhost/persistent/{packageName}/downloads/Simplify.zip`
    },
    version : {
        "url" : "https://www.dropbox.com/s/j5323z8kv1ln13t/VERSION.txt?dl=1",
        "fileUrl" : `cdvfile://localhost/persistent/{packageName}/VERSION.txt`
    }
};

 

위 자바스크립트 코드를 보면 config라는 변수가 있는데 패키지 명과 버전, 리소스 URL을 전부 자신의 것으로 수정해야 한다. 프로그래밍 언어에서 변수가 무엇을 의미하는 지 몰라도 상관은 없다. 하지만 모른다면 이 글을 읽기가 조금 불편할 수 있다. 언급된 config 변수는 www/js/config.js에 있다. 이름에서 알 수 있듯 설정 파일을 의미한다. 필자는 설정 파일의 이름을 보통 config라고 명명한다. 이런 config.js 파일을 VSCode나 Notepad++ 같은 텍스트 에디터로 열고, resource의 url 부분에 공유된 드랍박스 파일 링크를 붙여넣으면 된다. 사실 JSON 파일로 만드는 게 정석이지만, 필자는 정석대로 하지 않고 그냥 변수로 선언을 해버렸다.

 

버전을 비교하려면 버전이 기록된 텍스트 파일이 서버에 있어야 한다. 파일의 이름을 VERSION.txt라고 짓고, 내용에 버전 문자인 0.0.1을 추가하면 된다. 버전 표기 방법에 대해 자세히 서술하진 않겠지만, 게임이 정식으로 출시된 버전이라면 1.0.0 등이 되어야 할 것이다. 사실 버전 파일도 JSON 규격이 정석이지만, 필자는 그냥 텍스트 파일로 간단히 버전만 표기했다. 

 

실시간 업데이트는 노드 패키지 관리자(npm)에서 코르도바와 기타 여러가지 코르도바 플러그인을 설치해야 동작한다. 따라서 컴퓨터에 Node.js가 설치되어있어야 한다. 이 포스트를 읽는 사람들 중에 혹 Node.js가 아직도 설치되어있지 않은 사람들이 있을 거라고 믿고 싶진 않지만 다운로드는 https://nodejs.org/ko/download/에서 할 수 있다. 다운로드 후 설치가 끝났으면 실행에서 cmd을 입력하면 명령 프롬프트가 실행된다.

 

명령 프롬프트를 열어본 적이 없는가? 조금 불친절한 설명이라고 생각될 수 있겠지만 명령 프롬프트를 실행하는 방법은 어렵지 않다. Windows에서 ctrl + r를 누르면 실행 창이 열리게 되고, 거기에 cmd를 입력하면 열리게 된다. 그리고 아래 명령을 작성하고 Enter 키를 누르면 설치가 진행된다. 글을 쓰는 시점에서 코르도바 최신 버전은 9.0.0 버전이다.

npm install -g cordova

코르도바 설치가 완료되면 cd 명령을 사용하여 MV-Android-Updater 폴더로 현재 디렉토리를 옮겨야 한다. cd 명령을 사용해본 적이 없다면 난관이 펼쳐질 수 있는데 파일 탐색기에서 주소 복사를 누르면 폴더 주소를 복사할 수 있다.

명령 프롬프트에서 다음과 같이 하면 현재 폴더를 변경할 수 있다.

필자의 경우, 반디집에 있는 기능을 사용하여 해당 폴더에서 바로 명령 프롬프트를 열고 있다. 

명령 프롬프트를 우여곡절 끝에 열었다면 다음 명령을 실행한다. 

npm install

그러면 다음과 같이 실행에 필요한 패키지들이 자동으로 설치된다. 

세팅 과정은 이게 끝이고, 빌드 과정만 남아있다. 빌드 과정은 이 포스트의 범위 바깥이므로 따로 설명하진 않겠다. MV 앱 빌더나 코르도바 CLI를 이용하여 APK 파일을 릴리즈나 디버그 모드로 만들기만 하면 끝이 나기 때문이다. 다만, 앱 아이콘 파일은 변경해야 할 것이다.

 

소스 코드 분석

www/js/index.js 파일을 보면 웹 표준에는 없는 여러가지 추가 기능이 있다는 것을 알 수 있다. 안드로이드에서 파일을 다운로드 받거나 파일의 압축을 풀려면 일반적인 방법으로는 불가능하며 기능도 없다. 따라서 코르도바 플러그인에서 제공하는 기능을 사용해야 한다. 그래서 필자는 다음과 같은 세 가지 플러그인을 추가했다.

 

cordova-plugin-file
cordova-plugin-file-transfer
cordova-plugin-zip

 

cordova-plugin-file은 파일을 쓰거나 읽는 기능을 제공한다. 나머지는 파일을 다운로드 받거나 압축 풀기 등의 기능을 제공한다. 안드로이드는 파일 경로가 내부 저장소가 있고 마이크로 SD 카드의 저장소도 존재한다. 그리고 전체 경로가 복잡하고 길기 때문에 코르도바 파일 플러그인에서는 cdvfile라는 프로토콜을 사용하여 경로를 쉽게 접근할 수 있게 해준다. cdvfile 프로토콜을 사용하면 APK 파일의 패키지 명으로 접근할 수 있게 된다.. 

 

하단에 첨부한 코드에는 utils라는 객체가 있다. utils 객체에는 echoTextFile(), errorCallback(), checkVersion(callback), testDownloadZip()라는 4개의 메소드가 있다. 파일 플러그인에는 Node.js의 File System API와는 달리 사용하기 쉽게 Wrapping된 함수가 없고, 웹 표준 형식으로 제공된다. 따라서 폴더의 존재를 확인하거나, 파일이 이미 있는 지를 확인하려면 최소 수 십줄의 코드를 작성해야 한다. 그리고 동기 개념이 없기 때문에, 비동기 콜백 함수를 고려해야 한다. 안드로이드 용 File 객체를 만들고 수십 줄의 코드를 그 안으로 래핑하는 것이 좋은 선택이다. 필자처럼 날 것을 그대로 사용하면 나중에 API가 업데이트 될 때, 수십 줄의 코드를 고쳐야 한다. 그러나 래핑된 함수를 만들어뒀다면 래핑된 함수 하나면 고치면 된다. 

let utils = {

    echoTextFile() {

        jQuery.ajax({
            url: "./settings.json",
            type: "get",
            dataType: "JSON",
            success: (data) => {
                const packageName = config.packageName;

                window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, (fs) => {
                    
                    // 폴더 생성 (폴더가 없으면 자동으로 생성한다)
                    fs.root.getDirectory(packageName, {create:true, exclusive:false}, dirEntry => {
        
                    }, this.errorCallback);

                    // 파일 생성
                    fs.root.getFile(`${packageName}/testfile.txt`, {create: true, exclusive: false}, 
                        parent => { 
                            parent.createWriter(writer => {
                                writer.onwriteend = function() {
                                    console.log("Completed to write a text file called testfile.txt");
                                };

                                const data = new Blob(["Hi...."], {type : 'text/plain'});

                                writer.write(data);                                
                            }, this.errorCallback);
                        }, 
                    this.errorCallback);

                    // 파일 읽기
                    fs.root.getFile(`${packageName}/testfile.txt`, {create: true, exclusive: false}, 
                        parent => { 
                            parent.file(file => {

                                /**
                                 * @type {FileReader}
                                 */
                                const reader = new FileReader();
                                reader.onload = function() {
                                    console.log(this.result);
                                };

                                reader.readAsText(file);
                                
                            }, this.errorCallback);
                        }
                    , this.errorCallback);
                    
                    // 끝

                }, this.errorCallback);
            },
            error: (xhr, status, error) => {

            }
        });

    },
    
    errorCallback(err) {
        console.warn(err);
    },

    checkVersion(callback) {
        let uri = encodeURI(config.version.url);
        let fileUri = config.version.fileUrl.replace("{packageName}", config.packageName);

        let fileTransfer = new FileTransfer();

        fileTransfer.download(uri, fileUri, (entry) => {

            entry.file(file => {

                var reader = new FileReader();
        
                reader.onloadend = function () {
                    callback(this.result);
                };
        
                reader.readAsText(file);  

            }, this.errorCallback);
        }, this.errorCallback, false, {});

        window.resolveLocalFileSystemURL(fileUri, 
            /**
             * @param {FileEntry} entry
             */
            entry => {
                entry.file(file => {
                    var reader = new FileReader();
        
                    reader.onloadend = function () {
                        callback(this.result);
                    };
            
                    reader.readAsText(file);                      
                }, this.errorCallback);
        }, this.errorCallback);
    },

    testDownloadZip() {
        
        let fileTransfer = new FileTransfer();
        let uri = encodeURI(config.resource.url);
        let fileUri = config.resource.fileUrl.replace("{packageName}", config.packageName);
        const packageName = config.packageName;

        fileTransfer.onprogress = function(progressEvent) {
            var percent =  Math.round((progressEvent.loaded / progressEvent.total) * 100);
            $(".ui.text.loader").text(`Downloading... ${percent} %`);
        };

        fileTransfer.download(uri, fileUri, 

            /**
             * @param {FileEntry} entry
             */
            entry => {
                console.log("download complete: " + entry.toURL());

                let zipPath = fileUri;
                let extractDir = `cdvfile://localhost/persistent/${packageName}/www/`;

                // 파일의 압축을 해제한다.
                window.zip.unzip(zipPath, extractDir, status => {
                    switch(status) {
                        case 0: // 압축 해제 성공 시
                            $(".ui.text.loader").text("Successed to uncompress...");                            
                            $(".ui.text.loader").hide();

                            window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, (fs) => {
                                fs.root.getDirectory(`${config.packageName}/downloads/`, {create:true, exclusive:false}, dirEntry => {
                                    dirEntry.removeRecursively(() => {
                                        console.log("Removed the resource file from downloads folder");
                                        window.open(`cdvfile://localhost/persistent/${packageName}/www/index.html`, "_self");
                                    }, this.errorCallback);
                                }, this.errorCallback);
                            }, this.errorCallback);

                            break;
                        default: // 압축 해제 실패
                            console.log("Failed to uncompress...");
                            break;
                    }
                }, function(progressEvent) {
                    var percent =  Math.round((progressEvent.loaded / progressEvent.total) * 100);
                    $(".ui.text.loader").text(`Uncompressing... ${percent} %`);                    
                });

            },
            error => {
                console.log("download error source " + error.source);
                console.log("download error target " + error.target);
                console.log("download error code" + error.code);
            },
            false, 
            {}
        );


    }

}

 

위 utils 객체의 멤버 함수는 앱의 시작점인 app 객체에서 호출된다. 핵심은 window.open(indexFileUri, "_self"); 로 index.html 파일을 기준으로 상대 경로로 파일에 접근할 수 있게 되기 때문에 웹에서 압축 파일로 리소스를 받아도 잘 동작하게 된다. 다만 조금 효율이 떨어지는 면이 있다. 위에서 말했듯 코르도바 파일 플러그인은 사용하기가 매우 불편하며 기능 별로 래핑되어있지 않다. 따라서 래핑 없이 장기간 사용하게 되면 온갖 것들이 섞이게 되고, 가독성도 좋지 않고 API가 업데이트 돼 문제가 발생하면 수십 줄의 코드를 고쳐야 한다. 정신줄이 제대로 박혀있는 사람이라면 File이나 Directory 같은 클래스를 도입하는 것이 정신 건강에 좋을 수 있다.

 

예를 들면, File.exists(filename) 나 Directory.exists(filename) 같은 기능을 만들어서 사용하면 보기 좋을 것이다.

let app = {

    initialize() {
        document.addEventListener('deviceready', this.onDeviceReady.bind(this), false);      
    },
    
    onDeviceReady() {
        this.receivedEvent('deviceready');
    },

    onStart() {

        let errorCallback = err => {
            utils.testDownloadZip();
        };

        let indexFileUri = `cdvfile://localhost/persistent/${config.packageName}/www/index.html`;
        window.resolveLocalFileSystemURL(indexFileUri, 

            /**
             * @param {FileEntry} entry
             */
            entry => {
                
                // 해당 위치에 파일이 있으면 실행한다 => fs.existSync(indexFileUri)와 비슷
                entry.file(file => {
                    window.open(indexFileUri, "_self");                   
                }, err => { 
                    // 파일이 없을 경우, 다시 다운로드 한다.
                    utils.testDownloadZip();
                });

        }, errorCallback);

    },
    
    receivedEvent(id) {
        
        jQuery.ajax({
            url: "./settings.json",
            type: "get",
            dataType: "JSON",
            success: (data) => {

                let tempVersion = localStorage.getItem("version");
                if(!tempVersion) {
                    localStorage.setItem("version", data.version);
                    tempVersion = data.version;
                }
                let currentVersion = tempVersion;

                document.title = `${data.title} v${currentVersion}`;
                
                // 폴더 생성 및 삭제, 파일 생성이 가능한 지 확인한다.
                utils.echoTextFile();

                // 버전 비교 후 리소스 업데이트 진행
                utils.checkVersion(contents => {
                    if(/(\d+\.\d+\.\d+)/gm.exec(contents)) {
                        let targetVersion = RegExp.$1;
                        if(currentVersion < targetVersion) {
                            // 웹에서 리소스 파일을 내려 받아 압축을 해제한다.
                            utils.testDownloadZip();
                        } else {
                            this.onStart();
                        }
                    }
                });
            },
            error: (xhr, status, error) => {

            }
        });

    }

};
    
app.initialize();

티스토리 뉴 에디터의 글 간격이 조금 마음에 들지 않아 여러 번 수정을 거듭했는데 아무래도 CSS 파일을 더 수정해야 할 듯 하다.