Run Docker + Jenkins for Android Build

이번 글에서는 Vultr VC2 2core 4GB instance 에 Jenkins 를 올려 안드로이드 앱을 빌드하고 테스트하려 한다.

사용할 인스턴스는 기존에 사용중이던 Artifcatory 인스턴스이지만 사양을 올려 사용할 것이기 때문에, Docker 기본 설정 같은 것들은 이전 글인 ‘Upload Android Library into Gradle with Artifactory‘ 를 참조하면 된다.

DockerFile 커스텀하기

대부분 인터넷에 나온 CI/CD 적용기를 보면 이 단계부터 설명하는 글이 많은데, 처음 도커를 접하는 유저라면 상당히 골치아픈 작업이기도 하다. Dockerfile 자체가 자체 포맷으로 되어있기도 해서 그걸 익혀야 되는 문제점이 있다.

그리고 이번 글의 중점 취지는 아닌 것 같아 제작했던 Dockerfile 를 공유해서 바로 적용할 수 있게 했고, 그것이 WindSekirun/Jenkins-Android-Docker 이다.

DockerHub에도 공유되어 있으니, 바로 pulling 를 받으면 최신 안드로이드 환경 (API 28 + build tools 28.0.3) 을 사용할 수 있게 된다.

따라서 이번 글에서는 미리 제작된 도커 이미지로 이 과정을 대체한다.

먼저 실행되야 될 작업

본격적으로 이미지를 풀링 받기 전에, 이 이미지를 실행하기 위한 커맨드를 살펴볼 필요가 있다. (버전 1.0.1 같은 경우 2018-12-18 기준 최신으로, 가장 최신은 릴리즈 페이지를 참고하면 된다.)

sudo docker run -d -p 8080:8080 -p 50000:50000 -v /data/jenkins-android-docker:/var/jenkins_home windsekirun/jenkins-android-docker:1.0.1

여기에서 -d (백그라운드 작업) 과 -p(포트 바인딩) 은 넘겨도 되나 /data/jenkins-andorid-docker:/var/jenkins_home 부분에 신경을 써야 한다. 이 부분은 ‘실제 저장소내 공간:도커 컨테이너 공간’ 의 형식을 가지고 있는 디렉토리 바인딩 부분으로, 실제 저장소내 공간 부분에 작성한 폴더는 실제 존재하는 폴더여야 한다.

따라서 sudo 권한으로 아래 커맨드를 실행하면 된다.

mkdir /data/jenkins-android-docker
sudo chown -R 1000:1000 /data/jenkins-android-docker

이 ‘실제 저장소내 공간’에 모든 데이터가 들어가므로, 도커 컨테이너를 지워도 이 폴더가 남아있다면 데이터 또한 그대로 보존되게 된다.

위 mkdir 와 chown 을 시작했다면, 맨 위의 shell script 를 실행하면 된다.

귀찮은 사람들은 WindSekirun/Jenkins-Android-Docker 를 VPS 안에서 clone 받아서 sudo sh runImage.sh 를 실행하면 된다. mkdir 부터 docker run 까지 다 된다.

처음 관리자 설정하기

명령어로 도커를 시작했다면, 서버 주소:8080 으로 들어가면 Jenkins home 이 보일 것이다.

Jenkins 를 설정하는 사람이 관리자인지 확인하는 과정인데, 이를 확인하기 위해서는 도커 컨테이너에 접근할 필요가 있다.

SSH 에서 docker container ls 를 입력하면 현재 실행중인 컨테이너 정보가 나오는데, 그 곳에서 Jenkins-Android-Docker 를 찾는다.

위 정보에 따르면 jenkins-android-docker 가 설치된 컨테이너의 id는 d88376885153 이고, 이 컨테이너 id를 이용해 도커 컨테이너의 bash로 접근할 수 있다. 명령어는 docker exec -i -t [컨테이너 id] /bin/bash이다.

bash에 접근했으면 cat /var/jenkins_home/secrets/initialAdminPassword 로 도커 초기 관리자 비밀번호를 알아낸다.

아래 비밀번호를 복사해서 칸에 넣고 Continue 를 누르면 된다.

그 다음 Plugin 창이 나올텐데, 그 곳에서 Install Suggested Plugin를 누른다. 차후에 다시 설치가 가능하니, 지금은 기본만 설치한다.

설치가 다 되고, 관리자 계정을 만들면 Jenkins 를 사용할 준비가 모두 끝난다. 이제 blueocean 플러그인을 설치하여 첫 안드로이드 빌드를 해보도록 한다.

Blueocean 설치하기

Blueocean 은 Jenkins 에서 나온 새 UI/UX 툴로, 기존 Jenkins 가 다소 전문가의 영역에 가깝다고 하면 Blueocean 는 이를 좀 더 간결하고 알아보기 쉽게 만든 것이다.

Jenkins 메인에 접속되면 Manage Jenkins > Manage Plugin > Available 의 검색창에서 Blueocean 을 검색한다.

여기에서 Install without restart 를 누르면 플러그인 설치 페이지로 이동하는데, 여기에서 맨 마지막의 체크박스를 체크해서 바로 재시작될 수 있도록 한다.

여기까지 끝내면 첫 안드로이드 프로젝트를 빌드를 할 모든 준비가 완료된다.

프로젝트 빌드하기

다시 젠킨스 메인으로 돌아와서 옆의 Open Blueocean 을 누른다. 그러면 이제까지 보지 못했던 새로운 Jenkins 가 보이게 된다.

여기에서 ‘Create a new Pipeline’ 를 누른다.

프로젝트 저장소를 선택하고, 가져올 프로젝트를 맨 밑에서 설정한다. 만일 해당 프로젝트에 Jenkinsfile 가 없다면 설정하는 메뉴로 갈 것이고, 이미 있다면 바로 빌드를 시도할 것이다. 이번에는 Jenkins 로 연동해보지 않은 프로젝트를 설정했다.

그러면 이 페이지로 나오게 될텐데, 이 곳이 Jenkins 가 한 빌드당 거칠 파이프라인을 설정하는 곳이다. 가운데의 +를 누르게 되면 새 작업을 추가할 수 있다.


일단 여기에서는 간단히 빌드만 성공하는지 테스트할 것이므로, Add step 에서 Shell script 를 선택하고 ./gradlew assembleDebug --stacktrace를 입력해준다.

그 다음 위 Save를 누르면 파이프라인 저장 다이얼로그가 표시되고, Save&run 을 누르면 바로 빌드가 시작된다.

빌드 지켜보기

이제 프로젝트가 빌드될 때 까지 기다리는 것 만 남았다.

만일 오류가 나온다면 왜 오류가 나오는지 이제 구글링을 열심히 해볼 차례다. 아래는 지금까지 겪은 CI 오류를 정리해본 것이다.

흔한 오류

local.properties (No such file or directory)

assembleDebug 전에 ‘echo “sdk.dir=/opt/android-sdk-linux” >> local.properties’ 를 추가한다. 또는 젠킨스 관리 > Configure System > 맨 하단의 Android SDK Path 에 /opt/android-sdk-linux 를 적어준다.

File google-services.json is missing.

이 글을 참고하되 Environment Variable 를 Jenkins 내부에서 설정해주면 된다.

그리고 추가할 Shell script 는 echo $GOOGLE_SERVICES_JSON | base64 --decode --ignore-garbage > /app/google-services.json“` 이다.

Gradle build daemon disappeared unexpectedly

제일 골치아픈 문제로, 서버의 램 용량이 부족해서 Gradle 데몬이 죽는 현상이다. 이를 해결하기 위해서는 빌드 커맨드를 ./gradlew --no-daemon assembleDebug --stacktrace 로 설정하거나, 아니면 아예 메모리 제한을 거는 방법도 있다.

메모리 제한을 거는 방법은 현재 실행중인 컨테이너를 docker container kill [컨테이너 id]  – docker container stop [컨테이너 id] 로 삭제하고 (데이터는 상기했듯이 남아있다.) 컨테이너 실행할 때 -m 2500m 를 삽입한다. 2500m은 2.5g로 k, m, g 가 사용이 가능하다. 자신의 서버 환경에 맞게 적절히 조정하면 된다.

예제: sudo docker run -d -m 2500m -p 8080:8080 -p 50000:50000 -v /data/jenkins-android-docker:/var/jenkins_home windsekirun/jenkins-android-docker:1.0.1

빌드 성공

CI상 오류를 전부 해결하면 빌드 성공이 나오며, 이제야 첫 프로젝트의 빌드가 끝난 셈이다. 이제 다른 프로젝트를 연동하거나, 좀 더 심화해서 유닛 테스트나 마켓 업로드 기능 들을 구현하면 된다.

마지막으로 위 프로젝트의 빌드에 성공한 JenkinsFile는 다음과 같다. 다른 프로젝트의 루트 폴더에 똑같은 파일 이름으로 만들고 Jenkins 에서 추가하면 바로 인식이 된다.

pipeline {
  agent any
  stages {
    stage('Make Environment') {
      parallel {
        stage('Touch local.properties') {
          steps {
            sh 'echo "sdk.dir=/opt/android-sdk-linux" >> local.properties'
          }
        }
        stage('Touch google-services.json') {
          steps {
            sh 'echo $GOOGLE_SERVICES_JSON | base64 --decode --ignore-garbage > demo/google-services.json'
          }
        }
        stage('Display directory') {
          steps {
            sh 'ls -la'
          }
        }
      }
    }
    stage('assembleDebug') {
      steps {
        sh './gradlew --no-daemon assembleDebug --stacktrace'
      }
    }
  }
  environment {
    GOOGLE_SERVICES_JSON = ''
  }
}

Android Base64 Performance Benchmark

도입

안드로이드에서 String나 ByteArray를 Base64 로 인코딩 / 디코딩 하는 라이브러리는 많다. 기본적으로 안드로이드가 제공하는 Base64도 있고, Apache의 Commons-codec에도 있고, JDK 1.8에 (이제야) 구현된 Base64가 있다.

그러면 ‘어떤 것을 사용하는 것이 더 성능이 나올까?’ 라는 단순한 질문이 나올 수 있는데, 이미 자바로는 2014년에 비교한 Base64 encoding and decoding performance 라는 글이 있다.

다만 해당 글에는 안드로이드에선 사용하지 못하는 DataTypeConverter 나 sun 패키지가 있어 참고로 하기에는 어려운 점이 많다.

그래서 이번 기회로 안드로이드에서 사용할 수 있는 Base64 라이브러리 6종에 대해 성능 비교를 하려고 한다.

비교 대상

성능 비교 방법

비교 방법으로는 크게 3가지가 있다.

  • ByteArray <-> ByteArray 인코딩/디코딩
  • ByteArray <-> String 인코딩/디코딩
  • 이미지 / 비디오 파일 인코딩

ByteArray <-> ByteArray 인코딩/디코딩

fun testBytes(bufferSize: Int): HashMap<String, TestResult> {
    val r = Random(125)
    val buffers = ArrayList<ByteArray>()
    for (i in 0 until bufferSize) {
        val buf = ByteArray(bufferSize)
        r.nextBytes(buf)
        buffers.add(buf)
    }

    val results = HashMap<String, TestResult>()
    for (codec in byteCodecList) {
        val name = codec.javaClass.simpleName
        results[name] = testByteCodec(codec, buffers)
    }

    return results
}

@Throws(IOException::class)
private fun testByteCodec(codec: Base64ByteCodec, buffers: List<ByteArray>): TestResult {
    val encoded = ArrayList<ByteArray>()
    val result = ArrayList<ByteArray>()

    val encodeTime =
        measureTimeStopWatch { for (buf in buffers) encoded.add(codec.encodeBytes(buf)) }

    val decodeTime =
        measureTimeStopWatch { for (ar in encoded) result.add(codec.decodeBytes(ar)) }

    return TestResult(encodeTime.toDouble(), decodeTime.toDouble())
}

buffersize 만큼의 사이즈를 가지는 ByteArray를 bufferSize개 만큼 생성하고, 이를 각 라이브러리마다 인코딩과 디코딩 시간을 측정한다. 예를 들어 1024로 지정했다면 length가 1024인 ByteArray를 1024개를 생성하여 인코딩 시간과 디코딩 시간을 측정하는 방식이다.

여기서 Base64ByteCodec 는 인터페이스로 이 인터페이스를 구현하는 클래스는 AndroidImpl, ApacheImpl, IHarderImpl, Java8Impl, MiGImpl 총 5개로 Guava는 아쉽게도 지원하지 않는다.

class AndroidImpl : Base64Codec, Base64ByteCodec {
    private val flag = Base64.DEFAULT

    override fun decodeBytes(base64: ByteArray): ByteArray {
        return Base64.decode(base64, flag)
    }

    override fun encodeBytes(data: ByteArray): ByteArray {
        return Base64.encode(data, flag)
    }

    override fun encode(data: ByteArray): String {
        return Base64.encodeToString(data, flag)
    }

    override fun decode(base64: String): ByteArray {
        return Base64.decode(base64, flag)
    }
}

내부 구현체는 이런 식으로 testByteCodec 메서드에서 decodeBytes, encodeBytes 등의 메서드를 호출하면 각 라이브러리의 코드가 호출되는 형식이다.

ByteArray <-> String 인코딩/디코딩

위 ByteArray <-> String 와 비슷한 방식이나 호출되는 메서드만 encode, decode가 불린다.

이미지 / 비디오 파일 인코딩

fun testFile(file: File): HashMap<String, TestResult> {
    val results = HashMap<String, TestResult>()
    val fileBytes = file.readBytes()

    for (codec in byteCodecList) {
        val name = codec.javaClass.simpleName
        results[name] = testByteCodecFile(codec, fileBytes)
    }

    // Guava doesn't support ByteArray -> ByteArray. so we ignore them.
    return results
}

@Throws(IOException::class)
private fun testByteCodecFile(codec: Base64ByteCodec, buffer: ByteArray): TestResult {
    val encodeTime = measureTimeStopWatch { codec.encodeBytes(buffer) }
    return TestResult(encodeTime.toDouble(), 0.0)
}

파일을 객체로 받아서 각 라이브러리마다 인코딩 시간만을 측정한다. (디코딩은 측정하지 않았다.)

벤치마크 결과

테스트 기기는 Galaxy S8, 데이터의 정확성을 위해서 전체 테스트 셋트를 3번 반복하여 나온 평균치로 산정했다.

ByteArray <-> ByteArray 인코딩/디코딩

전체적으로 Java8Impl 와 MiGImpl 가 비슷한 속도를 보이고, AndroidImpl는 디코딩에서는 강점을 보이는 반면 인코딩에서는 Apache와 다를바가 없는 속도를 보여주었다.

ByteArray <-> String 인코딩/디코딩

ByteArray <-> ByteArray를 지원하지 않는 Guava가 추가되었는데, 수치가 많이 튄 것을 볼 수 있다. 어떤 한번만 그런 것이 아닌 지속해서 발생하는 것으로 봐서는 테스트 방법이 적절치 않았다고 판단하거나 Guava가 다른 라이브러리와는 조금 다른 구현체를 가지고 있는 것 같다.

ByteArray <-> ByteArray때와 마찬가지로 인코딩 때는 Java8Impl, MiGImpl가 강점을 보이고 디코딩 때에는 Java8Impl 와 AndroidImpl가 강점을 보였다.

이미지 인코딩 (1.5MB JPG / 5MB PNG / 10MB JPG)

1.5MB에서는 5개 라이브러리가 큰 차이를 보이고 있지는 않았으나, 5MB와 10MB에는 Java8Impl – MiGImpl 가 서로 비슷하고, AndroidImpl – IHarderImpl > ApacheImpl 가 서로 비슷했다.

비디오 인코딩 (10MB MP4 / 30MB MP4)

이미지 인코딩의 5MB, 10MB 이미지와 큰 차이는 보이지 않았다. 마찬가지로 Java8Impl 와 MiGImpl가 서로 비슷했다.

정리

결론은 아래와 같이 정리된다.

  • JDK 1.8을 사용하고,  minSDKVersion을 26 이상으로 설정할 수 있다면 JDK 1.8 Base64가 평균적으로 제일 나은 성능을 보여준다.
  • 현실적으로  minSDKVersion를 그정도까지 높이는 것이 어렵다면, MiG Base64 가 제일 현실적이다.
  • Base64 디코딩을 주로 한다면 Android Base64가 그나마 대안이 될 수 있다. 다만 인코딩은 Apache와 크게 다를바가 없음을 알아두자. 그리고 클라이언트 입장에서는 인코딩을 자주 하지, 디코딩을 자주 하지는 않는다.
  • Apache, IHarder는 사용하지 않는 것으로 한다.
  • Guava의 경우 이번 테스트에서는 수치가 튀었지만, 구성하는 데에 문제가 없다면 굳이 바꿀 필요는 없을 것 같다.

벤치마크시에 사용된 샘플 앱은 Github에 공개되어 있다.

Android Jetpack – Lifecycle in JDK8

도입

Android Jetpack 중에서 Lifecycle-Aware Components라는 컴포넌트가 있다. 이 컴포넌트는 Activity나 Fragment 같은 안드로이드의 자체 Lifecycle를 가지는 클래스의 Lifecycle 이벤트를 비-Activity 클래스, 즉 ViewModel 이나 Manager 등에 전달할 수 있는 역할을 가진다.

사용 방법은 대체로 간단한데, 받는 쪽에서는 LifecycleObserver 를 구현하고 @OnLifecycleEvent(ON_RESUME) 의 어노테이션을 부착하면 되고, Activity 에서는 lifecycle.addObserver(viewModel) 를 사용하면 ViewModel 등에서 ON_RESUME, ON_PAUSE, ON_DESTROY 등의 이벤트를 받을 수 있다.

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
open fun onDestroy() {
    compositeDisposable.clear()
}

다만 이 Lifecycle-aware Components를 사용할 때에 JDK 8를 이상으로 하고 JDK 8의 언어 지원 기능을 사용하는 프로젝트라면 사용 방법이 다른데, 이에 대해 알아보려 한다.

JDK 8 언어 지원 기능 사용시

Lifecycle 의 클래스 주석에는 아래와 같은 내용이 있다.

 * If you use <b>Java 8 Language</b>, then observe events with {@link DefaultLifecycleObserver}.
 * To include it you should add {@code "androidx.lifecycle:common-java8:<version>"} to your
 * build.gradle file.
 * <pre>
 * class TestObserver implements DefaultLifecycleObserver {
 *     {@literal @}Override
 *     public void onCreate(LifecycleOwner owner) {
 *         // your code
 *     }
 * }
 * </pre>

Java 8에서는 Default Interface라고 하는, 인터페이스에 기본적인 코드 형태를 가질 수 있게 하는 기능이 신규로 나오면서 더 이상 Annotation Processor와 Reflection을 기반으로 하는 @OnLifecycleEvent가 필요가 없어진 것이다. (또한 Java 8이 안드로이드의 메인 스트림이 되면 @OnLifecycleEvent를 Deprecated 할 예정이라고도 한다.)

기존에 사용하던 OnLifecycleEvent를 DefaultLifecycleObserver로 변경하려면 Gradle에 아래 의존성을 추가한다.

 implementation group: 'android.arch.lifecycle', name: 'common-java8', version: '1.1.1'

문서 작성일(2018-12-04) 기준 최신 버전은 1.1.1로,  Maven Repository 사이트에서 최신 버전을 참고하면 된다.

의존성을 추가한 뒤에는 LifecycleObserver를 구현하는 대신 DefaultLifecycleObserver 를 구현받고 onResume, onPause 등을 오버라이드 하면 된다.

override fun onDestroy(owner: LifecycleOwner) {
        super.onDestroy(owner)
        compositeDisposable.clear()
}

오류:  Super call to java default methods are prohibited in jvm target 1.6

이는 Kotlin compiler가 JVM 타겟을 1.8이 아닌 1.6으로 잡고 있어서 발생하는 상황이다. 해결을 위해서는 아래 옵션을 build.gradle 의 android 섹션 밑에 두면 된다.

kotlinOptions {
        jvmTarget = '1.8'
}