소개

이 튜토리얼에서는 프로페셔널과 엔터프라이즈 에디션에서 사용할 수 있는 Realm의 이벤트 프레임워크를 IBM의 머신러닝 서비스인 Watson Bluemix 서비스와 통합하는 방법을 보여드립니다. 이 서비스는 사용자가 제공하는 이미지의 텍스트와 얼굴 인식 프로세싱을 진행해서 머신 러닝 알고리즘을 통해 이미지에서 찾아낸 얼굴이나 텍스트을 반환합니다.

이제부터 만들 앱은 스캐너 입니다. 매우 간단한 단일 뷰 애플리케이션으로, 사용자가 사진을 찍거나 포토 라이브러리에서 사진을 가져오면 Watson이 확인한 세부 정보에 대해 볼 수 있습니다. 구현은 두 단계로 진행됩니다.

  • 사용자가 휴대폰으로 사진을 찍을 수 있는 모바일 클라이언트, 이미지는 리모트의 Realm 오브젝트 서버와 동기화됩니다.

  • 모바일 클라이언트가 찍은 새 이미지를 확인하는 Realm 오브젝트 서버의 이벤트 핸들러, 새 이미지를 감지하면 Bluemix 서비스를 통해 IBM의 Watson에 전달합니다.

스캐너는 iOS와 안드로이드 네이티브 앱으로 구현됩니다. 소스 코드를 공개한 Github 저장소를 튜토리얼 마지막에 볼 수 있지만, Swift (iOS)나 Java (안드로이드) 코드를 보면서 함께 앱을 만드는 과정을 따라 하시는 것을 권장합니다.

서버 측은 하나의 JavaScript 파일로 구현되며 이 튜토리얼에서 함께 설명합니다.

필요사항

이 튜토리얼을 시작하고 실행하기 위한 준비 사항이 몇 가지 있습니다. 먼저 개발 도구를 설정하고 이미지 프로세싱을 위해 IBM Bluemix 서비스의 무료 API Key에 가입해야 합니다. 모든 준비 단계는 다음과 같습니다.

iOS를 위해서는 Xcode 8.1 사용자와 실제 기기에서 실행하기 위한 Apple 개발자 계정이 필요합니다. 안드로이드를 위해서는 2.2 이상의 Android Studio가 필요합니다.

Realm 오브젝트 서버를 위해서는 macOS 10.11 이상의 매킨토시나 CentOS 6이나 7, Ubuntu 16.04 중 하나를 실행하는 Linux 서버가 필요합니다. 프로페셔널이나 엔터프라이즈 에디션에 대한 설명서는 이메일을 통해 제공됩니다. 프로페셔널 에디션은 여기에서 첫 60일의 시험기간을 신청할 수 있습니다.

Realm 오브젝트 서버에 등록할때 생성한 관리자 계정과 비밀번호를 메모해 두세요. 이 튜토리얼의 데모 애플리케이션 사용자에 이 정보를 사용합니다.

IBM의 Watson visual recognition 시스템을 사용하려면 Bluemix 시험사용 계정을 다음 URL에서 등록해야 합니다. https://console.ng.bluemix.net/registration/ 이 단계에서 이메일 주소로 계정을 활성화하고 확인하는 절차가 필요하며, Bluemix 서비스의 Watson 컴퍼넌트를 위한 API 키를 생성해야 합니다. 아래 절차와 같습니다.

  1. 상위 링크를 통해 IBM Bluemix 계정 생성

  2. 확인 메일 수신 후 메일 내 링크를 클릭해 확인

  3. IBM Bluemix portal에 로그인

  4. 시드니, 영국, 미국 남부 중 가장 가까운 지역 선택

  5. 워크스페이스 명명

  6. “I’m Ready”를 클릭해서 워크스페이스 생성

  7. 앱 페이지에서 좌상단의 “hamburger menu”를 클릭 후 “services”를 클릭한 다음 “Watson“으로 이동

  8. Watson 페이지에서 “Create Watson Service” 클릭

  9. Watson Services 페이지에서 “Visual Recognition” 클릭

  10. 다음 페이지의 상단에서 “free” 플랜 선택 후 “create” 클릭

  11. “view credentials” 클릭 후 api_key를 포함한 JSON 블럭 복사 (이후의 과정에서 필요)

오브젝트 서버 실행 및 AdminToken 탐색

튜토리얼을 본격적으로 시작하기 전에, Realm 오브젝트 서버를 시작하고 구현 섹션에서 필요한 관리자 토큰을 만드는 것이 좋겠습니다.

Realm 플랫폼 macOS 버전을 다운로드했다면 다운로드 킷의 서버 시작 안내문을 참조하세요.

서버가 시작됐다면 시작 프로세스 중 터미널 윈도우에 표시되는 “admin token”을 복사해둬야 합니다. 아래와 같은 모습입니다.

Your admin access token is: ewoJImlkZW50aXR5IjogImFkbWluIiwKCSJhY2Nlc3MiOiBbInVwb
G9hZCIsICJkb3dubG9hZCIsICJtYW5hZ2UiXQp9Cg==:A+UUfCEap6ikwX5nLB0mBcjHx1RVqBQjmYJ3j
BNP00xum65DT0tZV/oq2W8VIeuPiASXt3Ndn5TkahTU6a5UxQCnODfu0aGclgBuHmv5CKXwm4qr4bwL+
yd80WlRojdmYrYUf5jjbQjuBLUXkhyX554TjHOmANYzw1fv6sp1YXKDuKDkCHpH8+GIG5u0Xjp6IUK6F
tPtMbiPS1mMZ3YnxHm5BB2RQH3ywGxlsYLFnA9l4+Dc++sEQGWviYCCNBL9fD49zFPdvfBoc1WqsFi3P
KKcqyXfGdnyYucrDfo/4Rn8mT95lAqJGCcIRWwiNYKI805uHcI+JFv6/YXJB0wEMw==

이 토큰은 Your admin access token is: 텍스트 뒤에 따르는 텍스트로 == 문자열로 끝납니다.

Linux을 설치했다면 서버가 자동으로 시작되고 관리자 토큰을 /etc/realm/admin_token.base64 에서 찾을 수 있습니다.

구조

클라이언트와 서버의 구조는 매우 유사합니다.

Realm Scanner Tutorial Architecture

주요 구성 요소는 다음 세 가지입니다.

Realm 모바일 클라이언트

클라이언트는 사용자가 사진을 찍어서 Realm 데이터베이스에 이미지를 저장하고 Realm 오브젝트 서버와 통신하는 작은 애플리케이션입니다.

Realm 오브젝트 서버

모바일 클라이언트 사이에서 데이터를 동기화해줍니다.

Realm 이벤트 프레임워크

Realm 오브젝트 서버의 일부로 Realm 데이터베이스의 변화에 반응하고 이에 기반한 행동을 실행합니다. 이 튜토리얼의 경우 모바일 클라이언트의 이미지를 사용해서 IBM Bluemix recognition API와 작용하고 이미지에서 인식한 텍스트와 사람의 설명을 클라이언트의 Realm에 반환합니다.

동작

시스템의 동작은 간단합니다.

클라이언트 기기가 사진을 찍고 Realm 데이터베이스에 저장하면 Realm 오브젝트 서버와 동기화됩니다. 클라이언트의 Realm을 관찰하고 있던 Realm 오브젝트 서버의 이벤트 리스너가 새로운 사진을 감지합니다. 리스너가 이 이미지들을 IBM Watson recognition 서비스로 보내 프로세스를 진행하도록 합니다. 결과가 도착하면 스캔된 텍스트가 특정 클라이언트의 Realm에 업데이트되고, 이 결과가 다시 모바일 클라이언트에 동기화돼서 사용자에게 보여집니다.

모델과 Realm

Realm 플랫폼은 정보를 공유하기 위해 클라이언트와 서버 사이에 공유된 모델 혹은 스키마를 사용합니다. Realm 모델은 정의하기 쉽고 모든 기본 데이터 유형을 사용할 수 있습니다. 예를 들어 boolean, integer - Int8, Int16, Int32, Int64, – Double, Float, and String 등 뿐만이 아니라 List와 같은 컬렉션 타입, date, 바이너리 데이터 타입 등 고차원 유형의 타입이 포함됩니다.

지원되는 유형과 모델 정의에 관한 자세한 내용은 Realm 모바일 데이터베이스 문서에서 확인할 수 있습니다. 이 링크는 Swift 용이지만 Java나 다른 언어 역시 지원하고 있습니다.

이 텍스트 스캐너 앱을 만들기 위해서는 모델이 하나 필요합니다.

Watson Bluemix image recognition 서비스에 의해 반환된 상태와 새 사진이 찍히는 이벤트를 식별하기 위한 “scan id” 두 개의 문자열 필드와, 모바일 기기와 Realm 오브젝트 서버간에 동기화될 사진의 원본 바이너리 데이터 타입의 필드를 가지는 “Scan” 모델입니다.

클라이언트가 시작되면 이 모델이 클라이언트와 서버, 두 군데에서 동시에 존재하며 동기화됩니다. 모델은 a Realm 이라는 엔티티로 액세스되며, 이 예제에서는 Realm이 단일 모델을 가지지만, Realm은 다수의 모델을 가질 수 있으며, 단일 앱이 여러 개의 Realm에 액세스할 수도 있습니다. 여기서는 Realm scanner라고 칭하겠습니다.

Realm Path

사진이 선택되는 클라이언트 측에는 로컬 디바이스에 저장되는 Realm을 scanner라고 합니다. Realm 오브젝트 서버에는 모바일 기기 마다 Realm 하나가 있는데 역시 scanner라고 하죠. 이들은 모바일 기기와 서버간 동기화되는 데이터를 지닙니다. 데이터는 객체로 추가하거나 삭제하고 업데이트할 수 있죠.

오브젝트 서버에서 scanner Realm은 Realm의 계층구조 내에 존재하는데, 이는 root (“/”)로 시작되고 각 모바일 사용자를 긴 숫자 스트링으로 표현하는 user ID가 있으며, 모델과 데이터를 가진 Realm의 이름으로 이어지는 파일 구조와 유사합니다. 즉, path + a user ID + realm-name 의 조합이 Realm Path로 파일 시스템 path나 URL과 유사한 형태죠.

실제 어떻게 쓰이는지 이해하기 위해 가상 사용자 두 명이 Scanner 앱을 쓴다고 생각해 보겠습니다. 각 사용자의 Realm은 오브젝트 서버에 다음과 같이 위치합니다.

/12345467890/scanner
/9876543210/scanner

Realm 오브젝트 서버를 사용하면 Realm을 URL로 접속할 수 있으므로 아래 주소처럼 모바일 앱 내에서 동기화된 Realm을 참조할 수 있습니다.

realm://127.0.0.1:9080/~/scanner

“realm://127.0.0.1:9080”이 나타내는 것은 액세스 스키마(“realm://”)와, 서버 IP 주소나 DNS 호스트이름, Realm 오브젝트 서버의 포트 넘버입니다. “~” 문자는 “my user ID”를 축약한 것이고, “scannner”는 앞서 말했듯 Scan 모델을 포함하는 Realm의 이름입니다.

이 Realm URL 컨셉에 대해서는 클라이언트와 서버를 구현하면서 계속 설명드리겠습니다.

Realm 이벤트 프레임워크

마지막 컨셉이자 데모를 이끌어가는 원동력은 Realm 이벤트 프레임워크입니다. 이를 통해 오브젝트 서버가 자신이 관리하는 Realm의 변화에 반응할 수 있습니다. Realm의 특정 리스너와는 달리 이 API는 개발자가 여러 Realm의 변화를 수신할 수 있게 합니다.

전역 이벤트 리스너는 2개의 기본 매개변수를 전달하는 함수로 JavaScript 안에서 작성되며, Node.js 애플리케이션 컨텍스트에서 실행됩니다.

이벤트 콜백 변경 - 변경 사항이 감지되면 수행할 작업을 지정합니다. 예제에서는 IBM Bluemix recognition API를 호출하고 원격 서버에서 돌아온 결과를 처리합니다.

Regex 패턴 - 리스너가 적용할 서버의 Realm을 지정합니다. 여기서는 “.*/scanner”와 일치하는 모든 Realm이나 스캐너 앱의 각 사용자가 만든 Realm을 수신합니다.

변경 이벤트 콜백의 예를 보여드리겠습니다.

var change_event_callback = function(change_object) {
// Called on when changes are made to any Realm which match the given regular expression
//
// The change_object has the following parameters:
// path: The path of the changed Realm
// realm: The changed realm
// oldRealm: The changed Realm at the old state before the changes were applied
// changes: The change indexes for all added, removed, and modified objects in the changed Realm.
//          This object is a hashmap of object types to arrays of indexes for all changed objects:
//          {
//            object_type_1: {
//              insertions:    [indexes...],
//              deletions:     [indexes...],
//              modifications: [indexes...]
//            },
//            object_type_2:
//              ...
//          }

전역 이벤트 리스너를 등록하려면 아래 API를 호출합니다.

Realm.Sync.addListener(server_url, admin_user, regex, change_callback) {};

제공되는 나머지 매개변수에는 Realm 오브젝트 서버의 URL과 관리자 토큰으로 사용되는 관리 사용자 자격 증명이 포함됩니다.

더 큰 프레임워크에서 Realm 이벤트가 처리되는 방법에 대한 예제들이 많고, 스캐너 애플리케이션의 서버를 구현할 때 이 구문에 대해 더 다루게 될 겁니다.

위 세 가지 컨셉으로 이제부터 스캐너 앱을 구현하겠습니다.

구현

서버 애플리케이션

iOS, 안드로이드 등 어떤 클라이언트 애플리케이션에서나 필요하므로 먼저 Realm 오브젝트 서버 구현부터 시작하겠습니다. 다음 사항이 준비돼 있어야 합니다.

  • Realm 플랫폼 프로페셔널이나 엔터프라이즈 에디션의 다운로드 및 설치

  • 서버 시작 및 서버에 관리자 토큰으로 액세스 가능

  • Realm 오브젝트 서버가 운영되는 Linux 서버에 로그인 가능하거나 Realm 오브젝트 서버가 운영되는 Mac에 액세스 가능

  • IBM Bluemix Watson 서비스를 위한 API 키 준비

서버 부분 스크립트 생성

디렉토리 생성 - 서버, 혹은 맥에서 운영 중이면 맥에 새 디렉토리를 만듭니다. ScannerServer 라고 명명한 이 디렉토리에는 Node.js 패키지 디펜던시 파일이 포함됩니다. 이 디렉토리로 이동해서 package.json라는 파일을 생성하거나, 이미 있으면 수정하세요. 이 파일은 Node.js 컨벤션으로 노드 애플리케이션의 특정 외부 패키지 디펜던시를 관리하고, 이름이나 버전 정보 등 애플리케이션의 정보도 관리합니다. 수정 내용은 다음과 같습니다.

{
  "name": "Scanner",
  "version": "0.0.1",
  "description": "Use Realm Object Server's event-handling capabilities to react
                  to uploaded images and send them to Watson for image recognition.",
  "main": "index.js",
  "author": "Realm",
  "dependencies": {
    "realm": "file:realm-1.0.0-BETA-3.0-professional.tgz",
    "watson-developer-cloud": "^2.11.0"
  }
}

서버를 위해서는 2가지 디펜던시를 추가해야 합니다.

  • 디스크 상에서 우리 서버 코드와 같은 디렉토리에 있으며 npm이 참조할 파일을 알려주는 “file:” 디펜던시 - 이 패키지는 필수 사항이며 만약 없다면 설치 프로세스가 실패합니다. 서버 앱이 Realm 오브젝트 서버와 통신할 수 있게 해주는 Node.js SDK입니다.

  • Watson 서비스를 위한 Node.js 모듈 - npm이 Node.js 패키지 매니저를 통해 자동으로 다운받아 줍니다.

다운받은 Realm 플랫폼 배포에서 Realm 모듈을 복사할 수 있으며, Watson API도 포함됩니다.

Mac에서 RMP/PE의 로컬 복사본을 운영 중이라면 Finder나 Unix cp 명령어를 이용해 이를 복사할 수 있습니다. realm-mobile-platform-professional 배포 폴더의 SDK/node 디렉토리 안에 있습니다. 앞서 수정한 package.json 파일과 같은 곳에 복사해서 이름을 realm-1.0.0-BETA-3.0-professional.tgz로 지정하세요.

Linux에서의 프로세스 역시 동일합니다. ScannerServer 디렉토리의 같은 패키지를 복사해야 합니다. Linux 시스템에서는 저희 패키지 배포 시스템에서 패키지를 받아야 합니다. Realm 플랫폼 프로페셔널 에디션에 등록할 때 저희가 보낸 메일에 관련 설명이 포함돼 있습니다.

이 패키지는 스캐너 서버가 Realm 오브젝터 서버와 통신할 때 필요한 Node.js SDK입니다.

정확한 위치에 복사했다면 다음 명령어를 입력하세요.

npm install

모든 모듈을 다운로드하고 압축을 풀어서 구성하는 명령어입니다.

같은 디렉토리에서 index.js라는 파일을 만드세요. 이는 클라이언트 Realm의 변경 사항을 감시하고 Watson Recognition API로 프로세싱하도록 보내는 Node.js 애플리케이션입니다.

파일은 아래 코드에 포함되는데 내용이 길기 때문에 방금 만든 index.js 파일 내에 복사 후 붙여넣기를 하는 것이 좋습니다. 이 애플리케이션이 제대로 동작하려면 여러 중요 정보들이 수정돼야 합니다. 주석을 잘 확인해 주세요.

'use strict';

var fs = require('fs');
var Realm = require('realm');
var VisualRecognition = require('watson-developer-cloud/visual-recognition/v3');

// Insert the Realm admin token
// Linux: `cat /etc/realm/admin_token.base64`
// macOS (from within zip): `cat realm-object-server/admin_token.base64`
var REALM_ADMIN_TOKEN = "INSERT_YOUR_REALM_ADMIN_TOKEN";

// API KEY for IBM Bluemix Watson Visual Recognition
// Register for an API Key: https://console.ng.bluemix.net/registration/
var BLUEMIX_API_KEY = "INSERT_YOUR_API_KEY";

// The URL to the Realm Object Server
var SERVER_URL = 'realm://127.0.0.1:9080';

// The path used by the global notifier to listen for changes across all
// Realms that match.
var NOTIFIER_PATH = ".*/scanner";

/*
Common status text strings

The mobile app listens for changes to the scan.status text value to update
it UI with the current state. These values must be the same in both this file
and the mobile client code.
*/
var kUploadingStatus = "Uploading";
var kProcessingStatus = "Processing";
var kFailedStatus = "Failed";
var kClassificationResultReady = "ClassificationResultReady";
var kTextScanResultReady = "TextScanResultReady";
var kFaceDetectionResultReady = "FaceDetectionResultReady";
var kCompletedStatus = "Completed";

// Setup IBM Bluemix SDK
var visual_recognition = new VisualRecognition({
    api_key: BLUEMIX_API_KEY,
    version_date: '2016-05-20'
});

/*
Utility Functions

Various functions to check the integrity of data.
*/
function isString(x) {
    return x !== null && x !== undefined && x.constructor === String
}

function isNumber(x) {
    return x !== null && x !== undefined && x.constructor === Number
}

function isBoolean(x) {
    return x !== null && x !== undefined && x.constructor === Boolean
}

function isObject(x) {
    return x !== null && x !== undefined && x.constructor === Object
}

function isArray(x) {
    return x !== null && x !== undefined && x.constructor === Array
}

function isRealmObject(x) {
    return x !== null && x !== undefined && x.constructor === Realm.Object
}

function isRealmList(x) {
    return x !== null && x !== undefined && x.constructor === Realm.List
}

var change_notification_callback = function(change_event) {
    let realm = change_event.realm;
    let changes = change_event.changes.Scan;
    let scanIndexes = changes.insertions;

    console.log(changes);

    // Get the scan object to processes
    var scans = realm.objects("Scan");

    for (var i = 0; i < scanIndexes.length; i++) {
        let scanIndex = scanIndexes[i];
        // Retrieve the scan object from the Realm with index
        let scan = scans[scanIndex];
        if (isRealmObject(scan)) {
          if (scan.status == kUploadingStatus) {
            console.log("New scan received: " + change_event.path);
            console.log(JSON.stringify(scan))

            realm.write(function() {
                scan.status = kProcessingStatus;
            });

            try {
                fs.unlinkSync("./subject.jpeg");
            } catch (err) {
                // ignore
            }

            var imageBytes = new Uint8Array(scan.imageData);
            var imageBuffer = new Buffer(imageBytes);
            fs.writeFileSync("./subject.jpeg", imageBuffer);
            var params = {
                images_file: fs.createReadStream('./subject.jpeg')
            };

            function errorReceived(err) {
                console.log("Error: " + err);
                realm.write(function() {
                    scan.status = kFailedStatus;
                });
            }

            // recognize text
            visual_recognition.recognizeText(params, function(err, res) {
                if (err) {
                    errorReceived(err);
                } else {
                    console.log("Visual Result: " + res);
                    var result = res.images[0];
                    var finalText = "";
                    if (result.text && result.text.length > 0) {
                        finalText = "**Text Scan Result**\n\n";
                        finalText += result.text;
                    }
                    console.log("Found Text: " + finalText);
                    realm.write(function() {
                        scan.textScanResult = finalText;
                        scan.status = kTextScanResultReady;
                    });
                }
            });

            // classify image
            /*{
                "custom_classes": 0,
                "images": [{
                    "classifiers": [{
                        "classes": [{
                            "class": "coffee",
                            "score": 0.900249,
                            "type_hierarchy": "/products/beverages/coffee"
                        }, {
                            "class": "cup",
                            "score": 0.645656,
                            "type_hierarchy": "/products/cup"
                        }, {
                            "class": "food",
                            "score": 0.524979
                        }],
                        "classifier_id": "default",
                        "name": "default"
                    }],
                    "image": "subject.jpeg"
                }],
                "images_processed": 1
            }*/
            visual_recognition.classify(params, function(err, res) {
                if (err) {
                    errorReceived(err);
                } else {
                    console.log("Classify Result: " + res);
                    var classes = res.images[0].classifiers[0].classes;
                    console.log(JSON.stringify(classes));
                    realm.write(function() {
                        var classificationResult = "";
                        if (classes.length > 0) {
                            classificationResult += "**Classification Result**\n\n";
                        }
                        for (var i = 0; i < classes.length; i++) {
                            var imageClass = classes[i];
                            classificationResult += "Class: " + imageClass.class + "\n";
                            classificationResult += "Score: " + imageClass.score + "\n";
                            if (imageClass.type_hierarchy) {
                                classificationResult += "Type: " + imageClass.type_hierarchy + "\n";
                            }
                            classificationResult += "\n";
                        }
                        scan.classificationResult = classificationResult;
                        scan.status = kClassificationResultReady;
                    });
                }
            });

            // Detect Faces
            visual_recognition.detectFaces(params, function(err, res) {
                if (err) {
                    errorReceived(err);
                } else {
                    console.log("Faces Result: " + res);
                    console.log(JSON.stringify(res));
                    realm.write(function() {
                        var faces = res.images[0].faces;
                        var faceDetectionResult = "";
                        if (faces.length > 0) {
                            faceDetectionResult = "**Face Detection Result**\n\n";
                            faceDetectionResult += "Number of faces detected: " + faces.length + "\n";
                            for (var i = 0; i < faces.length; i++) {
                                var face = faces[i];
                                faceDetectionResult += "Gender: " + face.gender.gender + ", Age: " + face.age.min + " - " + face.age.max;
                                faceDetectionResult += "\n";
                            }
                        }
                        scan.faceDetectionResult = faceDetectionResult;
                        scan.status = kFaceDetectionResultReady;
                    });
                }
            });
          }
        }
    }
};

//Create the admin user
var admin_user = Realm.Sync.User.adminUser(REALM_ADMIN_TOKEN);

//Callback on Realm changes
Realm.Sync.addListener(SERVER_URL, admin_user, NOTIFIER_PATH, 'change', change_notification_callback);

console.log('Listening for Realm changes across: ' + NOTIFIER_PATH);
// End of index.js

서버 스크립트 실행

서버 스크립트를 실행하려면 먼저 앱에 필요한 디펜던시를 설치하고 package.json 파일에 지정해야 합니다. 터미널 창에서 아래 명령어를 사용하세요.

npm install

다음으로 수정해둔 index.js 파일로 교체하고 REALM_ADMIN_TOKENBLUEMIX_API_KEY 부분을 자신의 관리자 토큰과 IBM Bluemix 시험 사용 때 지정한 API 키로 교체합니다.

관리자 토큰과 API를 수정했으면 아래 명령어로 스캐너 서버 스크립트를 실행하세요.

node index.js

서버를 시작하면 모바일 클라이언트의 접속과 변경 사항을 기다리는 상태가 됩니다.

다음으로 OCR 서비스를 사용하는 간단한 iOS나 안드로이드 앱을 만들겠습니다.

모바일 클라이언트 설치

iOS/Android 스캐너 앱 완성 소스 코드

만약 튜토리얼을 건너뛰고 완성된 프로젝트를 받고 싶다면 Realm의 Github에서 받을 수 있습니다.

iOS 스캐너 앱

준비사항

이 프로젝트는 Cocoapods를 사용합니다. 설치하려면 sudo gem install cocoapods 명령어를 사용하시고, 자세한 정보는 http://cocoapods.org를 확인하세요.

Part I - Xcode로 스캐너 프로젝트 생성 및 설정

Xcode를 열고 새 “Single View iOS application”을 만듭니다. 애플리케이션 이름은 “Scanner”로 하고 원하는 장소로 지정하세요.

  • Xcode를 종료합니다.

  • 터미널 창을 열고 새로 만든 Scanner 디렉토리로 이동합니다. pod init 명령어를 입력해서 Cocoapods 시스템을 초기화하면 새 Podfile이 생성됩니다.

  • Podfile을 편집합니다. Xcode 등 아무 텍스트 에디터로도 할 수 있습니다. use_frameworks! 아랫 줄에 아래와 같은 코드를 추가합니다.

pod 'RealmSwift'

변경 사항을 파일에 저장합니다. Xcode로 편집했다면 Xcode를 다시 종료하세요.

터미널 창에서 pod update 명령어를 입력합니다. CocoaPods가 RealmSwift 모듈을 다운로드하고 구성하며, 새 Xcode Workspace 파일을 만듭니다. 이 파일은 스캐너 앱을 생성하는데 필요한 모든 외부 모듈을 함께 묶어서 구성해주는 파일입니다.

새로 만들어진 Scanner.xcworkspace 파일을 엽니다. 이제부터는 Scanner.xcodeproj 파일 대신 이 파일을 사용해야 합니다.

샘플 이미지와 아이콘을 설치합니다. 이 튜토리얼에는 다음 두 추가 리소스가 필요합니다. 아래 링크에서 다운로드 받고 압축을 푼 후 한 번에 한 파일씩 Xcode 프로젝트 창에 드래그한 다음 “copy resource if needed” 체크박스를 클릭해서 Xcode에서 프로젝트로 리소스를 복사하도록 허용하세요.

Image Resources

애플리케이션 권한 설정(Entitlements) 을 합니다. 이 앱은 키체인 공유와 iPhone 카메라 접근이 필요합니다. 소스 브라우저의 스캐너 프로젝트 아이콘을 클릭해고 아래 내용을 만들거나 추가하세요.

  • Capabilities 섹션에서 Keychain Sharing 스위치를 켭니다.

    Keychain Sharing

    • Info 섹션에서 Custom iOS Target properties에 두 개의 새로운 키, “Privacy - Photo Library Usage Description”과 “Privacy - Camera Usage Description”를 추가합니다. 설명 문자열은 사용자에게 왜 이 애플리케이션이 카메라와 포토 라이브러리에 접근해야 하는지 설명하는 곳입니다. 권한을 묻는 대화상자에서 이 문자열이 보여집니다.

      Camera & Photo Library Keys

    • Application Signing - 여기서 팀이나 프로파일을 선택합니다. 어떤 경우 필요한 프로비저닝 프로파일을 설정해주는 것것과 같이 Xcode가 자동으로 이슈를 “fix”해줄 수도 있습니다.

    어떤 옵션을 선택해도 좋습니다. 이 예제의 경우 iOS 시뮬레이터에서 애플리케이션을 돌려도 무방하지만 Xcode가 사이닝 정보를 요청할 수도 있습니다.

기본적인 애플리케이션 설정이 끝났으니 이제 앞에서 만들어 둔 Realm 이벤트 핸들러를 사용하는 앱을 구현할 준비가 끝났습니다.

Part II - 기본 뷰 템플릿을 스캐너 앱으로 구현

UIImage에 클래스 익스텐션을 추가합니다. - 몇몇 유틸리티 메서드를 추가해서 쉽게 이미지를 리사이즈하고 Realm 데이터베이스에 저장할 수 있도록 인코딩할 예정입니다.

프로젝트에 새 Swift 소스 파일을 만들고 UIImage+Encoding.swift으로 이름짓습니다. 프로젝트 폴더 안에 두면 되는데, “Extensions”이라는 폴더에 이런 익스텐션 파일을 모아 두거나, 프로젝트의 다른 구현 파일과 함께 두는 것이 컨벤션입니다. 다음 코드를 파일에 추가하고 저장한 후 창을 닫습니다.

import Foundation
 import UIKit

 extension UIImage {
  func resizeImage(_ image: UIImage, size: CGSize) -> UIImage {
      UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
      image.draw(in: CGRect(origin: CGPoint.zero, size: size))
      let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
      UIGraphicsEndImageContext()
      return resizedImage!
  }

 func data() -> Data {
     var imageData = UIImagePNGRepresentation(self) {
    // Resize the image if it exceeds the 2MB API limit
   if (imageData?.count)! > 2097152 {
       let oldSize = self.size
       let newSize = CGSize(width: 800, height: oldSize.height / oldSize.width * 800)
       let newImage = self.resizeImage(self, size: newSize)
       imageData = UIImageJPEGRepresentation(newImage, 0.7)
       }
       return imageData!
 }

 func base64EncodedString() -> String {
     let imageData = self.data()
     let stringData = imageData.base64EncodedString(options: .endLineWithCarriageReturn)
     return stringData
     }

 }

Realm 모델을 만듭니다. 스캐너 앱을 위한 모델은 매우 단순합니다. 새 Swift 소스 파일을 프로젝트에 만들고, Scan.swift라고 이름짓습니다. 아래 내용을 파일에 복사해서 붙여넣은 후 저장하세요.

import UIKit
 import RealmSwift

 class Scan: Object {
 dynamic var scanId = ""
 dynamic var status = ""
 dynamic var textScanResult:String?
 dynamic var classificationResult:String?
 dynamic var faceDetectionResult:String?
 dynamic var imageData: Data?
 }

대부분의 필드의 역할을 이름으로 알 수 있습니다. 이 모델은 로컬 기기에서 자동으로 초기화되며, 사용자가 사진을 찍거나 선택하고 앱에 스캔을 명령함에 따라 Realm 오브젝트 서버와 동기화됩니다.

  1. View Controller 업데이트하기 - 기존에 자동 생성된 앱 템플릿을 제거하고 우리가 만든 단순한 뷰로 교체해서 기기의 포토 라이브러리나 카메라에서 이미지를 로드하고 데이터를 세이브해서 오브젝트 서버가 우리의 동기화된 이미지에서 텍스트를 스캔할 수 있도록 할 예정입니다.

    단순하지만 기능적인 레이아웃을 사용할 예정으로, 아래와 같은 모습입니다.

iOS Scanner Layout

3개의 핵심 영역으로 나뉩니다. 이미지 디스플레이 영역, 결과를 보여줄 텍스트 영역, 이미지를 선택/처리하거나 이미지 선택을 초기화하고, 현재 이미지 프로세싱 오퍼레이션 상태를 보여주는 버튼입니다.

  1. 뷰 설정과 디스플레이 코드에 추가하기 - ViewController를 열고 viewDidLoaddidRecevieMemoryWarning 메서드를 지웁니다. 맨 마지막에 닫히는 중괄호까지 지우지 않도록 조심하세요. 찾기 어려운 디버그 에러가 발생할 수 있습니다.

ImagePicker 프로토콜 적용 - 파일의 상단에서 클래스 선언부를 UIViewController에서 UIImagePickerControllerDelegate”로 아래처럼 변경합니다. 이제 picker 뷰가 이미지를 선택할 수 있습니다.

class ViewController: UIViewController, UIImagePickerControllerDelegate

클래스 선언부 다음에서 아래 코드를 추가해서 ViewController가 보여줄 UI 엘리먼트와 동기화 및 스캐닝 프로세스에 필요한 Realm 변수를 선언합니다.

// UI Elements
 let userImage           = UIImageView()
 let resultsTextView     = UITextView()
 let statusTextLabel     = UILabel()
 let scanButton          = UIButton()
 let resetButton         = UIButton()

 var imageLoaded = false
 let backgroundImage = UIImage(named: "TakePhotoButton@3x")
 //Realm variables
 var realm: Realm?
 var currentScan: Scan?

*ViewController 생명주기 메서드 추가 - 이 메서드들이 뷰를 애플리케이션 생명주기에 맞게 설정하고 업데이트해줍니다. 마지막 메서드는 상태 바를 감춰서 이미지를 보다 잘 보이게 해줍니다.

override func viewDidLoad() {
  super.viewDidLoad()
  setupViewAndConstraints()
  }

 override func viewDidAppear(_ animated: Bool) {
  updateUI()
  }

 override var prefersStatusBarHidden: Bool {
 return true
 }

오토레이아웃과 뷰 관리 메서드 추가 - 파일 하단에서 엘리먼트들을 설정하고 관리할 코드를 추가합니다. 사실 Realm 오브젝트 서버를 사용하는 것과는 큰 관계가 없는 긴 분량의 코드로, 오토레이아웃을 사용해서 뷰와 버튼 등을 설정하는 기능과 뷰 업데이트를 관리할 몇몇 유틸리티 메서드를 포함합니다.

// MARK: View Setup and management
  func setupViewAndConstraints() {
      let allViews: [String : Any] = ["userImage": userImage, "resultsTextView": resultsTextView, "statusTextLabel": statusTextLabel, "scanButton": scanButton, "resetButton": resetButton]
      var allConstraints = [NSLayoutConstraint]()
      let metrics = ["imageHeight": self.view.bounds.width, "borderWidth": 10.0]

      // all of our views are created by hand when the controller loads;
      // make sure they are subviews of this ViewController, else they won't show up,
      allViews.forEach { (k,v) in
          self.view.addSubview(v as! UIView)
      }

      // an ImageView that will hold an image from the camers or photo library
      userImage.translatesAutoresizingMaskIntoConstraints = false
      userImage.contentMode = .scaleAspectFit
      userImage.isHidden = false
      userImage.isUserInteractionEnabled = false
      userImage.backgroundColor = .lightGray
      // a label to hold text (if any) found by the OCR service
      resultsTextView.translatesAutoresizingMaskIntoConstraints = false
      resultsTextView.isHidden = false
      resultsTextView.alpha = 0.75
      resultsTextView.isScrollEnabled = true
      resultsTextView.showsVerticalScrollIndicator = true
      resultsTextView.showsHorizontalScrollIndicator = true
      resultsTextView.textColor = .black
      resultsTextView.text = ""
      resultsTextView.textAlignment  = .left
      resultsTextView.layer.borderWidth = 0.5
      resultsTextView.layer.borderColor = UIColor.lightGray.cgColor
      // the status label showing the state of the backend ROS Event service or OCR API status
      statusTextLabel.translatesAutoresizingMaskIntoConstraints = false
      statusTextLabel.backgroundColor = .clear
      statusTextLabel.isEnabled = true
      statusTextLabel.textAlignment = .center
      statusTextLabel.text = ""
      // Button that starts the scan
      scanButton.translatesAutoresizingMaskIntoConstraints = false
      scanButton.backgroundColor = .darkGray
      scanButton.isEnabled = true
      scanButton.setTitle(NSLocalizedString("Tap to select an image...", comment: "select img"), for: .normal)

      scanButton.addTarget(self, action:  #selector(selectImagePressed(sender:)), for: .touchUpInside)
      // Button to reset and pick a new image
      resetButton.translatesAutoresizingMaskIntoConstraints = false
      resetButton.backgroundColor = .purple
      resetButton.isEnabled = true
      resetButton.setTitle(NSLocalizedString("Reset", comment: "reset"), for: .normal)
      resetButton.addTarget(self, action:  #selector(resetButtonPressed(sender:)), for: .touchUpInside)

      // Set up all the placement & constraints for the elements in this view
      self.view.translatesAutoresizingMaskIntoConstraints = false

      let verticalConstraints = NSLayoutConstraint.constraints(
          withVisualFormat: "V:|-[userImage(imageHeight)]-[resultsTextView(>=100)]-[statusTextLabel(21)]-[scanButton(50)]-[resetButton(50)]-(borderWidth)-|",
          options: [], metrics: metrics, views: allViews)
      allConstraints += verticalConstraints

      let userImageHConstraint = NSLayoutConstraint.constraints(
          withVisualFormat: "H:|[userImage]|",
          options: [],
          metrics: metrics,
          views: allViews)
      allConstraints += userImageHConstraint

      let resultsTextViewHConstraint = NSLayoutConstraint.constraints(
          withVisualFormat: "H:|-[resultsTextView]-|", options: [],
          metrics: metrics, views: allViews)
      allConstraints += resultsTextViewHConstraint

      let statusTextlabelHConstraint = NSLayoutConstraint.constraints(
          withVisualFormat: "H:|-[statusTextLabel]-|",
          options: [],
          metrics: metrics,
          views: allViews)
      allConstraints += statusTextlabelHConstraint

      let scanButtonHConstraint = NSLayoutConstraint.constraints(
          withVisualFormat: "H:|-[scanButton]-|",
          options: [],
          metrics: metrics,
          views: allViews)
      allConstraints += scanButtonHConstraint

      let resetButtonHConstraint = NSLayoutConstraint.constraints(
          withVisualFormat: "H:|-[resetButton]-|",
          options: [],
          metrics: metrics,
          views: allViews)
      allConstraints += resetButtonHConstraint


      self.view.addConstraints(allConstraints)
  }


  func updateImage(_ image: UIImage?) {
      DispatchQueue.main.async( execute: {
          self.userImage.image = image
          self.imageLoaded = true
      })
  }

  func updateUI(shouldReset: Bool = false){
      DispatchQueue.main.async( execute: {
          if (shouldReset == true && self.imageLoaded == true) || self.imageLoaded == false {
              // here if just launched or the user has reset the app
              self.userImage.image = self.backgroundImage
              self.imageLoaded = false
          } else {
              // just update the UI with whatever we've got from the back end for the last scan
              self.statusTextLabel.text = self.currentScan?.status
              // NB: there's a chance that the currentScan has been nil'd out by a user reset;
              // in this case just srt the text label to empty, otherwise we'll crash on a nil dereferrence
              self.resultsTextView.text = [self.currentScan?.classificationResult, self.currentScan?.faceDetectionResult, self.currentScan?.textScanResult]
                  .flatMap({$0}).joined(separator:"\n\n")
          }
      })
  }

마지막으로 Realm 오브젝트 서버와 전역 이벤트 리스너와 상호작용하는 코드를 추가합니다.

// MARK: Realm Interactions
 func submitImageToRealm() {
     SyncUser.logIn(with: .usernamePassword(username: "ds@realm.io", password: "cinnabar21"), server: URL(string: "http://\(kRealmObjectServerHost)")!, onCompletion: {
         user, error in
         DispatchQueue.main.async {
             guard let user = user else {
                 let alertController = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: error?.localizedDescription, preferredStyle: .alert)
                 alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: "Try Again"), style: .default, handler: { (action) in
                     self.submitImageToRealm()
                 }))
                 alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel"), style: .cancel, handler: nil))

                 self.updateUI(shouldReset: true)

                 self.present(alertController, animated: true)
                 return
             }

             // Open Realm
             let configuration =  Realm.Configuration(
                 syncConfiguration: SyncConfiguration(user: user, realmURL: URL(string: "realm://\(kRealmObjectServerHost)/~/scanner")!))
             self.realm = try! Realm(configuration: configuration)

             // Prepare the scan object
             self.prepareToScan()
             self.currentScan?.imageData = self.userImage.image!.data()
             self.saveScan()
         }
     })
 }

 func beginImageLookup() {
     updateResetButton()
     submitImageToRealm()
 }

 func prepareToScan() {
     if let realm = currentScan?.realm {
         try! realm.write {
             realm.delete(currentScan!)
         }
     }

     currentScan = Scan()
 }



 func saveScan() {
     guard currentScan?.realm == nil else {
         return
     }

     statusTextLabel.text = "Saving..."

     try! realm?.write {
         realm?.add(currentScan!)
         currentScan?.status = Status.Uploading.rawValue
     }

     statusTextLabel.text = "Uploading..."

     self.currentScan?.addObserver(self, forKeyPath: "status", options: .new, context: nil)
 }

 override func observeValue(forKeyPath keyPath: String?, of object: Any?,
                            change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
     guard keyPath == "status" && change?[NSKeyValueChangeKey.newKey] != nil else {
         return
     }

     let currentStatus = Status(rawValue: change?[NSKeyValueChangeKey.newKey] as! String)!
     switch currentStatus {
     case .ClassificationResultReady, .TextScanResultReady, .FaceDetectionResultReady:
         self.updateUI()
         self.updateResetButton()

         try! self.currentScan?.realm?.write {
             self.currentScan?.status = Status.Completed.rawValue
         }

     case .Failed:
         self.updateUI()

         try! self.currentScan?.realm?.write {
             realm?.delete(self.currentScan!)
         }
         self.currentScan = nil

     case .Processing, .Completed:
         self.updateUI()

     default: return
     }
 }

중요한 역할을 4가지 핵심 메서드를 설명드리겠습니다.

  • prepareToScan() 새로운 scan 객체를 만듭니다. 바로 이 객체가 Realm 오브젝트 서버와 동기화됩니다.

  • submitImageToRealm() 애플리케이션을 인증하고 “scanner” Realm으로 로그인합니다. “YOU USERNAME”과 “YOUR PASSWORD” 부분을 RMP/PE에 등록할때 사용한 관리 사용자 이름과 비밀번호로 변경해야 합니다.

  • saveScan() 선택된 이미지를 받아서 이를 동기화가 가능한 데이터 포맷으로 바꾸고 prepareToScan()에서 만든 Scan 객체에 저장합니다. 이미지가 저장되면 Realm Global Sync Notifier를 사용해서 저장된 Scan 객체의 옵저버를 설정하고 ScannerServer가 호출한 Watson 서비스로부터 결과가 오는지 관찰합니다.

  • observeValueForkeyPath() Realm에 특정된 메서드는 아니지만 Cocoa 런타임 기능으로 Key-Value Observation(“KVO”)을 사용해서 객체의 프로퍼티와 데이터 구조의 변화를 관찰하도록 등록하는 옵저버를 사용합니다. 예제에서는 saveScan() 메서드 내에서 변화를 동기화한 scan의 상태를 런타임이 알려주도록 합니다. 변화 알림을 받으면 코드가 상태 레이블을 변경하고 Watson 서비스에서 반환된 결과값을 추가합니다.

전체 통합

서버 애플리케이션 섹션의 후반부에서 우리는 Realm Global Sync Listener 앱을 포함하는 작은 Node JS를 만들고 실행했으며, 이 서버가 아직 우리 iOS 앱이 연결돼서 이미지를 동기화하기를 기다리고 있을 겁니다. 이제 스캐너 앱을 실행시키고 잘 동작하는지 확인해 보겠습니다.

앱을 실행하는 것은 Build/Run을 누르면 됩니다. 문법 에러나 오타가 없다면 Xcode가 iOS 시뮬레이터에 스캐너 앱을 빌드하고 실행해줍니다. 앱이 실행되면 “Tap to select an image…” 버튼을 누르고 “Choose from Library…“를 선택하세요. 애플리케이션이 이전에 템플릿 애플리케이션을 만들 때 다운로드해서 내장한 데모 이미지를 사용하도록 하는 버튼입니다.

앱에서 이미지 파일이 Realm 오브젝트 서버로 동기화되면, 앞서 만든 Global Sync Notifier 애플리케이션이 이미지를 IBM의 Watson 서비스로 보냅니다. 잠시 후 결과가 전송되고 아래와 같이 앱에 표시됩니다.

iOS Scanner App - Image processing Results

만약 Apple 개발자 계정이 있다면 실제 기기에서 이 앱을 실행해서 자신의 이미지를 사용해 볼 수 있습니다.

Android 스캐너 앱

필요사항: 이 프로젝트는 안드로이드 스튜디오를 사용합니다. 자세한 정보는 다음 링크에서 확인하세요. https://developer.android.com/studio/index.html

Part I - Create and configure a Scanner Project with Android

안드로이드 스튜디오를 열고 “Start a new Android Studio Project”를 클릭한 후 앱 이름을 “Scanner”로 짓습니다. “Company Domain”에 원하는 이름을 넣고 “Project location”에 드라이브에서 원하는 위치를 지정합니다. “Next”을 눌러 다음으로 넘어갑니다.

폼 팩터 선택 창에서는 “Phone and Tablet”을 선택하고 “Next”를 눌러 다음 단계로 넘어갑시다. “Minimum SDK”는 크게 수정할 필요가 없습니다.

“Add an Activity to Mobile”이 뜨면 “Empty Activity”를 선택하고 “Next”를 누릅니다. 레이아웃은 변경할 예정이기 때문에 템플릿에 있는 모양을 선택할 필요가 없습니다.

“Activity Name”은 그대로 “MainActivity”로 두고 “Layout Name”도 “activity_main”로 그대로 두고 “Finish”를 눌러 마법사를 마칩니다. 이 튜토리얼에서 액티비티는 하나만 사용하며 이름은 크게 중요하지 않습니다.

빌드 스크립트 “build.gradle” 파일이 두 곳에 위치합니다. 하나는 프로젝트 루트에 위치하며 다른 하나는 “app” 디렉토리에 있습니다. Realm을 지원하기 위해 먼저 프로젝트 루트의 “build.gradle”을 다음과 같이 확장합니다. buildscript의 dependencies 블록에 “classpath ‘io.realm:realm-gradle-plugin:2.2.0’“를 추가하였고 Realm 플러그인을 이용할 준비를 마쳤습니다. 이 문서가 기술될 때 보다 더 높은 Realm Java 버전이 출시된다면 해당 버전을 사용하세요.

// Top-level build file where you can add configuration options common to all sub-projects/modules.
 buildscript {
     repositories {
         jcenter()
     }
     dependencies {
         classpath 'com.android.tools.build:gradle:2.2.2'
         classpath 'io.realm:realm-gradle-plugin:2.2.0'
     }
 }


 allprojects {
     repositories {
         jcenter()
     }
 }


 task clean(type: Delete) {
     delete rootProject.buildDir
 }

이제 “app” 수준의 빌드 스크립트 “build.gradle”를 확장합시다. 두가지를 먼저 수정하겠습니다. 첫째로는 “apply plugin: ‘com.android.application’“를 사용해서 Realm Java를 위한 의존성을 등록하는 과정입니다. 그리고 두번째로 “compileSdkVersion”과 “targetSdkVersion”를 23으로 변경한 것입니다. 이 과정은 Realm Java의 사용을 위해 필요한 부분은 아니지만 사진 촬영 요청 후 가져오는 코드가 API 레벨 24부터 어려워졌기 때문에 예제의 단순성을 위해 선택하였습니다.

apply plugin: 'com.android.application'

 android {
     compileSdkVersion 23
     buildToolsVersion "25.0.1"
     defaultConfig {
         applicationId "example.io.realm.scanner"
         minSdkVersion 15
         targetSdkVersion 23
         versionCode 1
         versionName "1.0"
     }
 // …

타겟 버전의 변경에 따라 appcompat 라이브러리의 버전을 수정해야할 수 있습니다. 해당 라이브러리의 메이저 버전은 API 레벨에 의존적입니다. 처음 값은 25 버전에 맞추어져 있습니다.

dependencies {
     ...
     compile 'com.android.support:appcompat-v7:23.4.0'
     ...
 }

앱은 접속할 서버의 주소가 필요합니다. 서버의 주소를 설정하는 부분 역시 빌드 스크립트에 추가합시다. 아래의 코드는 테스트에서 서버로 쓸 로컬 호스트의 주소를 가지고 와서 “BuildConfig.OBJECT_SERVER_IP” 상수로 추가하는 코드입니다. “buildConfigField”으로 추가된 항목들은 앱의 빌드 시 자바 객체 BuildConfig으로 변환이 되어 앱에 추가됩니다.

android {
   ...
   buildTypes {
       def host = InetAddress.getLocalHost().getCanonicalHostName()
       debug {
           buildConfigField "String", "OBJECT_SERVER_IP", "\"${host}\""
       }
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
           buildConfigField "String", "OBJECT_SERVER_IP", "\"${host}\""
       }
   }
 }

마지막으로 한가지 설정이 남아있습니다. “app/build.gradle” 파일의 마지막에 다음을 추가하세요. 이는 Realm Java의 동기화 기능을 활성화합니다. 이 옵션 없이는 동기화 기능을 사용할 수 없습니다.

realm {
     syncEnabled = true
 }

Part II - 모델과 설정 등록

스캐너를 위한 두가지 모델을 만들어야 합니다. “LabelScan”과 “LabelScanResult”이며 “LabelScan”을 채워 서버와 동기화를 하면 서버가 “LabelScanResult”에 데이터를 채워 동기화를 합니다. 먼저 첫번째 모델 “LabelScan”을 구현합시다.

public class LabelScan extends RealmObject{
     @Required
     private String scanId;
     @Required
     private String status;
     private LabelScanResult result;
     private byte[] imageData;


     public String getScanId() {
         return scanId;
     }


     public void setScanId(String scanId) {
         this.scanId = scanId;
     }


     public String getStatus() {
         return status;
     }


     public void setStatus(String status) {
         this.status = status;
     }


     public LabelScanResult getResult() {
         return result;
     }


     public void setResult(LabelScanResult result) {
         this.result = result;
     }


     public byte[] getImageData() {
         return imageData;
     }


     public void setImageData(byte[] imageData) {
         this.imageData = imageData;
     }
 }

다음으로 결과를 담을 “LabelScanResult”을 구현합시다.

public class LabelScanResult extends RealmObject {
     private String textScanResult;
     private String classificationResult;
     private String faceDetectionResult;


     public String getTextScanResult() {
         return textScanResult;
     }


     public void setTextScanResult(String textScanResult) {
         this.textScanResult = textScanResult;
     }


     public String getClassificationResult() {
         return classificationResult;
     }


     public void setClassificationResult(String classificationResult) {
         this.classificationResult = classificationResult;
     }


     public String getFaceDetectionResult() {
         return faceDetectionResult;
     }


     public void setFaceDetectionResult(String faceDetectionResult) {
         this.faceDetectionResult = faceDetectionResult;
     }
 }

다음으로 “Application”을 확장하여 “ScannerApplication”을 만듭시다. Realm Java의 초기화를 위한 코드와 로그를 위한 코드가 들어갑니다.

public class ScannerApplication extends Application {

     @Override
     public void onCreate() {
         super.onCreate();
         Realm.init(this);
         RealmLog.setLevel(Log.VERBOSE);
     }
 }

“AndroidManifest.xml” 파일을 다음과 같이 수정합시다. “ScannerApplication”을 “application” 항목에 등록하고 카메라를 위한 두가지 설정을 추가합니다.

<manifest package="io.realm.scanner"
           xmlns:android="http://schemas.android.com/apk/res/android">


     <uses-feature
         android:name="android.hardware.camera"
         android:required="true"/>


     <uses-permission
         android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>


     <application
         android:name="io.realm.scanner.ScannerApplication"
         // 

Part III - 레이아웃 만들기

전체 레이아웃 파일을 먼저 살펴보고 세부적인 부분을 알아보겠습니다.

<?xml version="1.0" encoding="utf-8"?>
 <FrameLayout
     android:id="@+id/activity_main"
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     tools:context="io.realm.scanner.MainActivity">


     <RelativeLayout
         android:id="@+id/capture_panel"
         android:layout_width="match_parent"
         android:layout_height="match_parent">


         <ImageButton
             android:id="@+id/take_photo"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_centerHorizontal="true"
             android:layout_marginTop="32dp"
             android:background="#F9F9F9"
             android:src="@drawable/take_photo"/>


         <TextView
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_below="@id/take_photo"
             android:layout_centerHorizontal="true"
             android:layout_marginTop="32dp"
             android:text="Select an Image to Begin"/>


     </RelativeLayout>


     <ScrollView
         android:id="@+id/scanned_panel"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:visibility="gone">


         <LinearLayout
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:orientation="vertical">


             <ImageView
                 android:id="@+id/image"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:adjustViewBounds="true"
                 android:scaleType="fitCenter"/>


             <TextView
                 android:id="@+id/description"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_below="@id/image"
                 android:layout_margin="16dp"/>
         </LinearLayout>
     </ScrollView>


     <RelativeLayout
         android:id="@+id/progress_panel"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:visibility="gone">


         <ProgressBar
             style="?android:attr/progressBarStyleLarge"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_centerInParent="true"
             android:layout_margin="36dp"/>
     </RelativeLayout>


 </FrameLayout>

“FrameLayout”에 3가지 레이아웃이 자식으로 포함되어 있습니다. “RelativeLayout”, “ScrollView”, “RelativeLayout”가 차례로 포함이 되어 있고 첫번째 “RelativeLayout”는 카메라 버튼이 포함된 뷰, 두번째 “ScrollView”는 촬영된 이미지와 결과를 출력하는 UI, 세번째 “RelativeLayout”는 로딩 동안 표시될 “ProgressBar”를 포함합니다.

Part 4 - 싱글 액티비티에서 스캐너 앱으로

서버 접속을 위한 상수를 “MainActivity”에 만듭시다.

private static final String REALM_URL = "realm://" + BuildConfig.OBJECT_SERVER_IP + ":9080/~/Scanner";
 private static final String AUTH_URL = "http://" + BuildConfig.OBJECT_SERVER_IP + ":9080/auth";
 private static final String ID = "scanner@realm.io";
 private static final String PASSWORD = "password";

버퍼 사이즈와 “onActivityResult”를 위한 상수를 위해 임의의 수를 사용할 것입니다. 이 상수는 충분히 크면서 흔하지 않은 수로 지정하기 위해 1000번째 소수를 사용한 것이며 그 이상의 의미는 없습니다.

private static final int REQUEST_SELECT_PHOTO = PRIME_NUMBER_1000th;

두개의 상수를 “onActivityResult”를 위해 사용할 것입니다. 이는 다른 액티비티에 요청을 보내고 데이터를 받아올 때 대상을 확인하기 위해 사용합니다.

private static final int REQUEST_SELECT_PHOTO = PRIME_NUMBER_1000th;
 private static final int REQUEST_IMAGE_CAPTURE = REQUEST_SELECT_PHOTO + 1;

액티비티에 필드들을 정의합시다.

private Realm realm;
 private LabelScan currentLabelScan;
 private ImageButton takePhoto;
 private ImageView image;
 private TextView description;


 private View capturePanel;
 private View scannedPanel;
 private View progressPanel;
 private String currentPhotoPath;


 // "MainActivity" 내에서 사용할 상수와 열거형을 추가로 정의합니다.


 enum Panel {
     CAPTURE, SCANNED, PROGRESS
 }


 class StatusLiteral {
     public static final String UPLOADING = "Uploading";
     public static final String FAILED = "Failed";
     public static final String CLASSIFICATION_RESULT_READY = "ClassificationResultReady";
     public static final String TEXTSCAN_RESULT_READY = "TextScanResultReady";
     public static final String FACE_DETECTION_RESULT_READY = "FaceDetectionResultReady";
     public static final String COMPLETED = "Completed";
 }

OnCreate 메서드에서 다음과 같이 뷰를 핸들링하는 코드를 추가합시다.

@Override
 protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     setContentView(R.layout.activity_main);


     capturePanel = findViewById(R.id.capture_panel);
     scannedPanel = findViewById(R.id.scanned_panel);
     progressPanel = findViewById(R.id.progress_panel);


     takePhoto = (ImageButton) findViewById(R.id.take_photo);
     image = (ImageView) findViewById(R.id.image);
     description = (TextView) findViewById(R.id.description);


     takePhoto.setOnClickListener(new View.OnClickListener() {
         @Override
         public void onClick(View view) {
             if (realm != null) {
                 showCommandsDialog();
             }
         }
     });


     takePhoto.setVisibility(View.GONE);
     takePhoto.setClickable(false);


     showPanel(Panel.CAPTURE);
     // …
 }

“showCommandsDialog” 메서드와 “showPanel” 메서드를 만듭시다. “showCommandsDialog”는 상황에 따라 카메라와 갤러리로 연결을 시키는 메서드 “dispatchTakePicture”와 “dispatchSelectPhoto”를 가지고 있습니다.

private void showCommandsDialog() {
 final CharSequence[] items = {
          "Take with Camera",
         "Choose from Library"
 };
 final AlertDialog.Builder builder = new AlertDialog.Builder(this);
 builder.setItems(items, new DialogInterface.OnClickListener() {
     @Override
     public void onClick(DialogInterface dialogInterface, int i) {
         switch(i) {
             case 0:
                 dispatchTakePicture();
                 break;
             case 1:
                 dispatchSelectPhoto();
                 break;
         }
     }
 });
 builder.create().show();
 }


 private void showPanel(Panel panel) {
     if (panel.equals(Panel.SCANNED)) {
         capturePanel.setVisibility(View.GONE);
         scannedPanel.setVisibility(View.VISIBLE);
         progressPanel.setVisibility(View.GONE);
     } else if (panel.equals(Panel.CAPTURE)) {
         capturePanel.setVisibility(View.VISIBLE);
         scannedPanel.setVisibility(View.GONE);
         progressPanel.setVisibility(View.GONE);
     } else if (panel.equals(Panel.PROGRESS)) {
         capturePanel.setVisibility(View.GONE);
         scannedPanel.setVisibility(View.GONE);
         progressPanel.setVisibility(View.VISIBLE);
     }
 }

카메라를 여는 “dispatchTakePicture”를 먼저 살펴봅시다. 이 코드는 인텐트를 이용해서 카메라 촬영을 요청하고 결과를 돌려받을 수 있도록 “startActivityForResult”를 사용합니다.

private void dispatchTakePicture() {
     Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
     if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
         File photoFile = null;
         try {
             photoFile = createImageFile();
         } catch (IOException e) {
             e.printStackTrace();
         }
         if (photoFile != null) {
             currentPhotoPath = photoFile.getAbsolutePath();
             Uri photoURI = Uri.fromFile(photoFile);
             takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
             startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE);
         }
     }
 }


 private File createImageFile() throws IOException {
     String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
     String imageFileName = "JPEG_" + timeStamp + "_";
     File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);
     File image = File.createTempFile(imageFileName, ".jpg", storageDir);
     return image;
 }

갤러리를 여는 메서드인 “dispatchSelectPhoto”를 만듭니다. 이 메서드는 파일을 생성할 필요가 없어 카메라에 비해 메서드가 간단합니다.

private void dispatchSelectPhoto() {
     Intent photoPickerIntent = new Intent(Intent.ACTION_PICK);
     photoPickerIntent.setType("image/*");
     startActivityForResult(photoPickerIntent, REQUEST_SELECT_PHOTO);
 }

“onCreate” 메서드 하단에 Realm 오브젝트 서버와 동기화를 위한 코드들을 삽입합니다. “SyncCredentials”를 사용하여 인증 정보를 전달하고 이전에 선언한 상수들을 사용하여 “SyncConfiguration”을 설정하여 Realm 인스턴스를 엽니다. 에러 처리는 간결한 예제를 위해 생략하였습니다.

final SyncCredentials syncCredentials = SyncCredentials.usernamePassword(ID, PASSWORD, false);
 SyncUser.loginAsync(syncCredentials, AUTH_URL, new SyncUser.Callback() {
     @Override
     public void onSuccess(SyncUser user) {
         final SyncConfiguration syncConfiguration = new SyncConfiguration.Builder(user, REALM_URL).build();
         Realm.setDefaultConfiguration(syncConfiguration);
         realm = Realm.getDefaultInstance();
         takePhoto.setVisibility(View.VISIBLE);
         takePhoto.setClickable(true);
     }


     @Override
     public void onError(ObjectServerError error) {
     }
 });

Realm을 해제하는 코드도 추가합시다.

@Override
 protected void onDestroy() {
     super.onDestroy();
     cleanUpCurrentLabelScanIfNeeded();
     if (realm != null) {
         realm.close();
         realm = null;
     }
     showPanel(Panel.CAPTURE);
 }

화면 우상단에 메뉴가 상황에 따라 그려질 수 있도록 코드를 추가합니다.

@Override
 public boolean onCreateOptionsMenu(Menu menu) {
     getMenuInflater().inflate(R.menu.main, menu);
     MenuItem item = menu.getItem(0);
     item.setEnabled(false);
     return true;
 }


 @Override
 public boolean onPrepareOptionsMenu(Menu menu) {
     final MenuItem item = menu.getItem(0);
     if (currentLabelScan != null) {
         final LabelScanResult scanResult = currentLabelScan.getResult();
         if (scanResult != null) {
             final String textScanResult = scanResult.getTextScanResult();
             final String classificationResult = scanResult.getClassificationResult();
             final String faceDetectionResult = scanResult.getFaceDetectionResult();
             if (textScanResult != null && classificationResult != null && faceDetectionResult != null) {
                 item.setEnabled(true);
                 return true;
             }
         }
     }
     item.setEnabled(false);
     return true;
 }


 @Override
 public boolean onOptionsItemSelected(MenuItem item) {
     if (item.getItemId() == R.id.refresh) {
         setTitle(R.string.app_name);
         cleanUpCurrentLabelScanIfNeeded();
         showPanel(Panel.CAPTURE);
         invalidateOptionsMenu();
     }
     return true;
 }


 private void cleanUpCurrentLabelScanIfNeeded() {
     if (currentLabelScan != null) {
         currentLabelScan.removeChangeListeners();
         realm.beginTransaction();
         currentLabelScan.getResult().deleteFromRealm();
         currentLabelScan.deleteFromRealm();
         realm.commitTransaction();
         currentLabelScan = null;
     }
 }

사용자가 사진을 찍거나 갤러리에서 이미지를 선택하면 서버로 이미지를 전달하는 코드를 작성합니다. 사진을 찍었을 때는 “REQUEST_IMAGE_CAPTURE”가 갤러리에서 이미지를 선택했을 때는 “REQUEST_SELECT_PHOTO” 부분이 호출합니다.

@Override
 protected void onActivityResult(int requestCode, int resultCode, Intent data) {
     super.onActivityResult(requestCode, resultCode, data);


     switch (requestCode) {
         case REQUEST_SELECT_PHOTO:
             if (resultCode == RESULT_OK) {
                 setTitle("Saving...");
                 final Uri imageUri = data.getData();
                 try {
                     final InputStream imageStream = getContentResolver().openInputStream(imageUri);
                     final byte[] readBytes = new byte[PRIME_NUMBER_1000th];
                     final ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
                     int readLength;
                     while ((readLength = imageStream.read(readBytes)) != -1) {
                         byteBuffer.write(readBytes, 0, readLength);
                     }
                     cleanUpCurrentLabelScanIfNeeded();
                     byte[] imageData = byteBuffer.toByteArray();
                     if (imageData.length > IMAGE_LIMIT) {
                         BitmapFactory.Options options = new BitmapFactory.Options();
                         options.inJustDecodeBounds = true;
                         BitmapFactory.decodeByteArray(imageData, 0, imageData.length, options);
                         int outWidth = options.outWidth;
                         int outHeight = options.outHeight;
                         int inSampleSize = 1;
                         while (outWidth > 1600 || outHeight > 1600) {
                             inSampleSize *= 2;
                             outWidth /= 2;
                             outHeight /= 2;
                         }
                         options = new BitmapFactory.Options();
                         options.inSampleSize = inSampleSize;
                         final Bitmap bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.length, options);
                         byteBuffer.reset();
                         bitmap.compress(Bitmap.CompressFormat.JPEG, 80, byteBuffer);
                         imageData = byteBuffer.toByteArray();
                     }
                     uploadImage(imageData);
                 } catch(FileNotFoundException e) {
                     e.printStackTrace();
                 } catch (IOException e) {
                     e.printStackTrace();
                 } finally {
                     showPanel(Panel.PROGRESS);
                     setTitle("Uploading...");
                 }
             }
             break;
         case REQUEST_IMAGE_CAPTURE:
             if (resultCode == RESULT_OK && currentPhotoPath != null) {
                 setTitle("Saving...");
                 BitmapFactory.Options options = new BitmapFactory.Options();
                 options.inJustDecodeBounds = true;
                 BitmapFactory.decodeFile(currentPhotoPath, options);
                 int outWidth = options.outWidth;
                 int outHeight = options.outHeight;
                 int inSampleSize = 1;
                 while (outWidth > 1600 || outHeight > 1600) {
                     inSampleSize *= 2;
                     outWidth /= 2;
                     outHeight /= 2;
                 }
                 options = new BitmapFactory.Options();
                 options.inSampleSize = inSampleSize;
                 Bitmap bitmap = BitmapFactory.decodeFile(currentPhotoPath, options);
                 final ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
                 bitmap.compress(Bitmap.CompressFormat.JPEG, 80, byteBuffer);
                 byte[] imageData = byteBuffer.toByteArray();
                 uploadImage(imageData);
                 showPanel(Panel.PROGRESS);
                 setTitle("Uploading...");
             }
             break;
     }
 }

마지막으로 서버에서 이미지 처리를 하고 결과를 반환할 때 처리하는 코드를 작성합니다. 이 콜백 메서드는 “uploadImage” 메서드의 “currentLabelScan.addChangeListener(MainActivity.this);” 부분에 의해 등록된 부분입니다.

@Override
 public void onChange(LabelScan labelScan) {
     final String status = labelScan.getStatus();
     if (status.equals(StatusLiteral.FAILED)) {
         setTitle("Failed to Process");
         cleanUpCurrentLabelScanIfNeeded();
         showPanel(Panel.CAPTURE);
     } else if (status.equals(StatusLiteral.CLASSIFICATION_RESULT_READY) ||
             status.equals(StatusLiteral.TEXTSCAN_RESULT_READY) ||
             status.equals(StatusLiteral.FACE_DETECTION_RESULT_READY)) {
         showPanel(Panel.SCANNED);
         final byte[] imageData = labelScan.getImageData();
         final Bitmap bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.length);
         image.setImageBitmap(bitmap);


         final LabelScanResult scanResult = labelScan.getResult();
         final String textScanResult = scanResult.getTextScanResult();
         final String classificationResult = scanResult.getClassificationResult();
         final String faceDetectionResult = scanResult.getFaceDetectionResult();


         StringBuilder stringBuilder = new StringBuilder();
         boolean shouldAppendNewLine = false;
         if (textScanResult != null) {
             stringBuilder.append(textScanResult);
             shouldAppendNewLine = true;
         }
         if (classificationResult != null) {
             if (shouldAppendNewLine) {
                 stringBuilder.append("\n\n");
             }
             stringBuilder.append(classificationResult);
             shouldAppendNewLine = true;
         }
         if (faceDetectionResult != null) {
             if (shouldAppendNewLine) {
                 stringBuilder.append("\n\n");
             }
             stringBuilder.append(faceDetectionResult);
         }
         description.setText(stringBuilder.toString());
         if (textScanResult != null && classificationResult != null && faceDetectionResult != null) {
             realm.beginTransaction();
             labelScan.setStatus(StatusLiteral.COMPLETED);
             realm.commitTransaction();
         }
     } else {
         setTitle(status);
     }
     invalidateOptionsMenu();
 }

이제 이미지를 촬영하거나 선택을 하여 Realm 오브젝트 서버를 통해 이미지 인식을 하는 안드로이드 앱을 완성했습니다. 전체 코드는 https://github.com/realm-demos/Scanner를 참고해주세요.