오랫만에 데이터바인딩 설정을 잡을 일이 있어서 안드로이드 개발 문서를 살펴보던 중이었습니다.

https://developer.android.com/topic/libraries/data-binding/start?hl=ko

시작하기  |  Android 개발자  |  Android Developers

시작하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android 스튜디오의 데이터 결합 코드 지원을 비롯하여 개발 환경에서 데이터 결합 라이브러리를 함

developer.android.com

 

android {
        ...
        dataBinding {
            enabled = true
        }
    }

New Project로 빈 프로젝트를 만듭니다.

(최근의 Android Studio가 권장하는 Kotlin DSL (build.gradle.kts)를 선택하였습니다.)

그리고 개발 문서의 가이드대로 데이터바인딩 설정을 해봅니다.

하지만 build.gradle이 불이 붙은 채로 꺼지지 않습니다.

 

 

엇.. 뭐지? 이런 저런 것들을 건드려 보고, StackOverflow를 뒤져보고요.

구글께서 시키는 대로 하였는데, 왜 나에게 이런 시련을 주시지? 라는 믿음이 흔들리는 마음도 먹어봅니다.

불현듯 생각나서 개발 문서를 다시 확인해봅니다.

 

한국어???

설마???

 

오잉????

 

다시 영문 버전으로 바꿔봅니다.

https://developer.android.com/topic/libraries/data-binding/start?authuser=1

android {
    ...
    buildFeatures {
        dataBinding true
    }
}

 

코드가 달라졌습니다......

 

잊고 있었습니다.

많은 선후배 동료님들의 개발문서의 한글버전을 믿지 말라는 그 말을요....

 

히스토리를 찾아보니 Android Gradle Plugin 4.0 버전부터의 차이가 있었네요.

https://developer.android.com/build/releases/past-releases/agp-4-0-0-release-notes?authuser=1#buildFeatures

 

Android 스튜디오  |  Android Developers

Android Gradle 플러그인 4.0.0 출시 노트

developer.android.com

 

기존 방식은 deprecated가 되었고, buildFeatures의 dataBinding으로 설정하는 방법으로요.

제 어리석음을 탓하며 시간낭비 삽질을 한 케이스였습니다.

최근에야 많이들 Compose를 쓰시느라 볼 일이 별로 없으시겠지만요...

혹여나 저처럼 방황하실 분들을 위해 남겨봅니다. :)

이전 회사에서 네이버 지도를 사용하다 발생하였던 이슈에 대해서 정리를 한번 하고자 합니다.

개발 상에서는 이슈가 전혀 없던 앱이 상용 배포 후 Crashlytics를 통해 MissingLibraryException를 종종 발생한다는 이슈였었죠.

 

발단

네이버 지도를 붙인 기능이 배포된 후 얼마 후 Crashlytics를 통해서 이상한 오류를 잡아내기 시작합니다.

해당 오류에서 가르키는 부분들은 개발 중에는 본 적이 없는 키워드 들이었습니다.

Caused by com.getkeepsafe.relinker.MissingLibraryException
Could not find 'libnavermap.so'. Looked for: [arm64-v8a, armeabi-v7a, armeabi], but only found: [].

 

멀쩡히 잘 넣어둔 라이브러리가 실종이라니 처음에는 황당한 이슈였습니다.

 

분석

어쨋든 발생하고 있는 이슈이니 분석을 해봅니다.

발견 당시 기준으로 소수의 사용자들이 반복적으로 크래쉬를 경험하고 있었습니다.

단말도 넥서스, 픽셀나 에뮬레이터로 보이는 단말명들이었죠.

 

그래서 첫번째 추정을 하였습니다. 특정 칩셋을 쓰는 단말들에서만 발생하는건가??

그러면서 추가 수집된 정보로 arm, x86 단말들이 섞여 있었죠.

사무실에 가지고 있는 단말과 에뮬레이터로 테스트 해보았지만 이상이 없었습니다.

애초에 네이버 지도가 arm, x86을 다 지원하고 있으므로 문제가 될 것이 없었죠.

 

구글플레이에 업로드하였던 aab 파일을 받아서 다시 까보았습니다.

amr, x86 so 파일들이 다소곳이 잘 들어가 있었습니다.

첫번째 추정은 정답이 아니었습니다.

 

두번째 추정을 해보았습니다. 누군가 앱 설치 후 APK를 백업하여 재설치를 해본건가??

저희는 당시 apk가 아닌 aab로 버전을 업로드하였습니다.

aab는 split apk를 통해 다운받는 단말에 필요한 조각들로 앱이 설치됩니다.

https://developer.android.com/guide/app-bundle/app-bundle-format

 

Android App Bundle 형식  |  Android 개발자  |  Android Developers

Android App Bundle 형식 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android App Bundle은 Google Play에 업로드하는 파일(파일 확장자는 .aab)입니다. App Bundle은 그림 1

developer.android.com

하지만 설치된 단말과 백업한 앱을 다시 설치한 단말의 칫셉이 다르다면? 혹은 split apk의 base apk만 설치된다면??

한번 설치된 앱의 APK를 추출하여 다른 단말에 설치 후 실행해봅니다.

두둥. 재현이 되기 시작합니다.

테스트

당시의 테스트 방법이었습니다.

1. 일반
    1-1. 구글플레이를 통해 설치한 앱의 APK 를 추출.
        a. CX파일탐색기 같은 앱 사용하여 APK 추출. (구글플레이 검색)
        b. Android Studio의 Device File Explorer 등을 사용하여 APK 복사
    1-2. AAB로 업로드된 앱을 설치 후 1번 방법으로 APK 를 추출하면 정상적으로 설치가능한 APK가 나오지 않음.
    1-3. 추출한 APK 속의 base.apk 를 추출하여 단말에 설치.
    1-4. 앱 실행. (이때 크래시 발생)


2. aab로 split apks 변환 후 테스트시
    2-1. bundletool을 이용해 aab 파일을 split apks로 변환
        a. bundletool 다운로드 : https://github.com/google/bundletool/releases
        b. 아래 명령어로 변환

        java -jar bundletool-all-1.14.0.jar build-apks \
          --bundle=app-googlePlayStore-dev-debugWithProguard.aab \
          --output=output.apks \
          --connected-device

    2-2. output.apks 파일을 zip 파일로 확장자 변경 후 압축 해제
    2-3. splits 폴더 안의 base-master.apk 을 단말에 설치
    2-4. 앱 실행. (이때 크래시 발생)


3. split apks 설치 방법 (만약에 split apks를 정상적으로 설치해보고 싶다면!!)
    1. adb install-multiple *.apk

 

해결?

원인은 확인하였지만, 당시에 이슈를 바로 해결할 수가 없었습니다.

다음과 같은 이유에서였죠.

  • 확인 결과 MissingLibrary는 네이버 맵 SDK에서 사용하는 라이브러리에서 제공하는 것임.
  • 네이버 맵 SDK에서 해당 부분을 사용하는 부분은 static 영역인데, static 영역이 초기화 중에 exception이 발생하여 ExceptionInInitializerError가 발생함.
  • Exception이 아니라 Error라 핸들링할 수가 없음.

네이버 맵 SDK 에서 수정되지 않는 이상 Exception 핸들링도 불가하였고, 일반적이지 않고 극소수의 이슈라 우선순위도 낮아 다른 업무에 조금씩 치여가고 있었습니다.

그리고 네이버 클라우드 플랫폼에 해당 현상을 문의하였고, 답변에서도 비정상적 활동이고 빈도가 높지 않을 것으로 보이니 무시하는 것을 권장한다고 하였죠. 나름 뭉갤 수 있는 정당성이 부여되었다라고 죄책감을 조금 내려놓을 때 쯔음....

 

불현듯이 찾아온 컨퍼런스 영상 속에서 그 해답을 찾게 되었습니다.

[드로이드나이츠 2021] 차영호 - AppBundle 괴담 영상 속에서 제가 필요로 하던 답이 있었습니다.

https://youtu.be/EVYnTe6aXWQ?t=706

 

그리고 위 이슈를 SideLoading이라는 키워드로 부르는 것을 알게 되었고, 아래와 같은 포스팅에서도 도움을 얻게 되었습니다.

 

App Bundle and Sideloading: how to prevent crashes

Read here, why the new Google Android App Bundle format can cause sideloading crashes when using a native library. Learn how to fix it.

objectbox.io

 

 

덕분에 최종 수정은 아래와 같이 하였습니다.

  • 앱 실행시 split apk 체크로 문제가 인지되면, 토스트 띄우고 앱 종료함.
  • split apk 체크로직은 앱이 build 타입이 release일때만 체크하도록 함.
fun Context.checkSplitApks(): Boolean {
    val splitNames = packageManager.getPackageInfoCompat(packageName, 0).splitNames ?: emptyArray()

    // 네이버 지도 라이브러리는 arm/x86으로 split apk 확인이 가능하네 ㅎㅎ
    val armPattern = "arm"
    val x86Pattern = "x86"

    // split apk 추출 후 base or master apk만 설치시 dpi가 없으니 확인하자!!
    val dpiPattern = "dpi"
    return splitNames.any { it.contains(armPattern) || it.contains(x86Pattern) }
            && splitNames.any { it.contains(dpiPattern) }
}

 

추가로 해당 처리로 앱 종료 처리된 경우 Crashlytics 로그 추가하여 추이를 보기로 하였습니다.

위 수정사항으로 더이상 저런 헤괴망측한 오류는 나오지 않았습니다.

하지만 간헐적으로 종종 로그는 수집되고 있었답니다.

오늘의 괴담은 여기까지입니다. ^^

Jenkins로 Google Play에 APK, AAB를 업로드하기 위해서는 플러그인 설치 및 Google Play 콘솔에서 서비스 계정 설정이 필요함.

 

[Google Play] Google Play 콘솔 로그인 후 설정 - API 액세스 선택

 

[Google Play] 새 프로젝트 만들기 선택 후 저장

 

[Google Play] 프로젝트 보기 선택하여 Google Cloud Platform 으로 이동

 

[Google Cloud ] 메뉴 - IAM 및 관리자 - 서비스 계정 선택

 

[Google Cloud Platform] + 서비스 계정 만들기 선택

 

[Google Cloud Platform] 서비스 계정 세부 정보의 서비스 계정 이름, 서비스 계정 설명 등 기입 후 만들고 계속하기 선택

 

[Google Cloud Platform] 이 서비스 계정에 프로젝트에 대한 액세스 권한 부여 에서 역할소유자 선택 후 계속

 

[Google Cloud Platform] 사용자에게 이 서비스 계정에 대한 액세스 권한 부여 에서는 필요시 추가 후 완료

 

[Google Cloud Platform] 생성된 서비스 계정을 선택

 

[Google Cloud Platform] 키 탭을 선택 후 키 추가 선택

 

[Google Cloud Platform] 새 키 만들기 - JSON - 저장 선택하여 json 파일 저장

 

 

[Google Play] 다시 Google Play 콘솔로 넘어와서 설정 - API 액세스 에서 서비스 계정의 서비스 계정 새로고침 선택

 

[Google Play] 서비스 계정에서 권한 부여 선택

 

[Google Play] 사용자 초대에서 계정 권한 탭에서 출시 관련 항목들 선택

 

[Google Play] 앱 권한 탭 선택 후 **애플리케이션 추가**로 앱을 선택

 

[Google Play] 앱 추가시 권한 확인 후 적용 선택

 

[Google Play] 사용자 초대 선택

 

[Google Play] 사용자 초대 완료 후 추가된 사용자 확인

 

[Jenkins] Jenkins 플러그인 설정에서 Google Play Android Publisher Plugin 을 설치

 

[Jenkins] Add Credentials 에서 Google Service Account from private key 선택

 

[Jenkins] Project Name 기입 후 GCP에서 서비스 계정 생성시 저장한 json 파일을 선택한 후 OK 선택

 

[Jenkins] 업로드할 job 에서 플러그인과 추가한 Credentials로 업로드 설정

  • Pipeline 사용시 Jenkinsfile에 추가
stage('Publish artifacts') {
    steps {
        androidApkUpload googleCredentialsId: 'test',
            apkFilesPattern: '**/outputs/**/*.aab',
            trackName: 'internal',
            rolloutPercentage: '100'
    }
}
  • job 설정에서 직접 설정시
    • 빌드 후 조치에 Upload Android AAB/APKs to Google Play 추가
    • Google Play account - Specific credentials 선택 후 추가하였던 Credentials ****의 Project Name 선택
    • APK/AAB 위치 설정
    • 배포할 트랙 선택, 단계적 배포 수준, In-app Update priority 등을 설정
    • 업데이트 문구도 설정 가능함
    • 설정 저장 후 사용

  •  
결론부터 말하자면 View의 Background를 리소스로 설정시에 color 리소스가 아닌 drawable 리소스만을 사용해야 합니다.
(물론 대다수의 경우들은 drawable만 사용합니다....)

Android UI 작업을 하다보면 View의 Background를 설정하는 경우는 매우 흔한 일입니다.

이때 pressed 효과를 위해 StateListDrawable 형태의 drawable 리소스를 쓰는 경우도 많이 생깁니다.

그리고 Android OS 10에서부터는 ColorStateList 형태의 color 리소스도 View의 Background에서 사용이 가능해졌습니다.

(흔히 말하는 selector 리소스 형태들입니다.)

  • ColorStateList 리소스 : https://developer.android.com/guide/topics/resources/color-list-resource
    <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="<http://schemas.android.com/apk/res/android>">
        <item android:color="@color/white" android:state_selected="true" />
        <item android:color="@color/white" android:state_pressed="true" />
        <item android:color="@color/black" />
    </selector>
    color/selector_rect_color.xml
  • Drawable 리소스 : https://developer.android.com/guide/topics/resources/drawable-resource
    <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="<http://schemas.android.com/apk/res/android>">
        <item android:drawable="@color/white" android:state_selected="true" />
        <item android:drawable="@color/white" android:state_enabled="true" />
        <item android:drawable="@color/black" />
    </selector>
    drawable/selector_rect.xml

즉 최신 Android OS에서 ColorStateList 리소스로 View.setBackgroundResource() 를 사용할 수 있지만, OS 9 이하에서는 Resource NotFoundException을 겪게 됩니다.

1. 문제 재현해보기

먼저 문제를 재현해 보고, 정확히 문제가 되는 부분을 찾아봅니다.

try {
    val drawable = context.getDrawable(R.drawable.selector_rect)
    Log.d(TAG, "ResourceTest :: 0. $drawable")
    val drawable2 = context.getDrawable(R.color.selector_rect_color)
    Log.d(TAG, "ResourceTest :: 1. $drawable2")
} catch(e: Exception) {
    Log.d(TAG, "ResourceTest :: 2. $e")
}

앱에서 테스트할 부분에 위 코드를 추가 후 OS 10과 OS 9 기기에 올려서 확인해봅니다.

(기기가 없어도 에뮬레이터로 확인 가능합니다.)

각가 아래와 같은 로그를 얻을 수 있습니다.

  • Android OS 10
ResourceTest :: 0. android.graphics.drawable.StateListDrawable@ba98d70
ResourceTest :: 1. android.graphics.drawable.ColorStateListDrawable@c5c796e
  • Android OS 9
ResourceTest :: 0. android.graphics.drawable.StateListDrawable@e71743d
ResourceTest :: 2. android.content.res.Resources$NotFoundException: Drawable com.test.app:color/selector_rect_color with resource ID #0x7f0601c3

 

2. 문제 범위 확인

문제 발생시 CallStack을 확인하여 비교할 코드 범위를 좁혀봅니다.

android.content.res.Resources$NotFoundException: Drawable com.test.app:color/selector_rect_color with resource ID #0x7f0601c3
Caused by: android.content.res.Resources$NotFoundException: File res/color/selector_rect_color.xml from drawable resource ID #0x7f0601c3
    at android.content.res.ResourcesImpl.loadDrawableForCookie(ResourcesImpl.java:847)
    at android.content.res.ResourcesImpl.loadDrawable(ResourcesImpl.java:631)
    at android.content.res.Resources.getDrawableForDensity(Resources.java:888)
    at android.content.res.Resources.getDrawable(Resources.java:827)
    at android.content.Context.getDrawable(Context.java:626)
    ...
Caused by: org.xmlpull.v1.XmlPullParserException: Binary XML file line #3: <item> tag requires a 'drawable' attribute or child tag defining a drawable
    at android.graphics.drawable.StateListDrawable.inflateChildElements(StateListDrawable.java:190)
    at android.graphics.drawable.StateListDrawable.inflate(StateListDrawable.java:122)
    at android.graphics.drawable.DrawableInflater.inflateFromXmlForDensity(DrawableInflater.java:142)
    at android.graphics.drawable.Drawable.createFromXmlInnerForDensity(Drawable.java:1332)
    at android.graphics.drawable.Drawable.createFromXmlForDensity(Drawable.java:1291)
    at android.content.res.ResourcesImpl.loadDrawableForCookie(ResourcesImpl.java:833)
	... 20 more

위 로그로 ResourcesImpl.loadDrawableForCookie 에서 Exception을 발생함을 확인할 수 있습니다.

 

3. AOSP를 통한 소스 확인

이 부분은 aosp 소스로 그 차이를 확인할 수 있습니다.

	@Nullable
    private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
    ...
        Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, file);
        LookupStack stack = mLookupStack.get();
        try {
            // Perform a linear search to check if we have already referenced this resource before.
            if (stack.contains(id)) {
                throw new Exception("Recursive reference in drawable");
            }
            stack.push(id);
            try {
                if (file.endsWith(".xml")) {
                    final XmlResourceParser rp = loadXmlResourceParser(
                            file, id, value.assetCookie, "drawable");
                    dr = Drawable.createFromXmlForDensity(wrapper, rp, density, null);
                    rp.close();
                } else {
                    final InputStream is = mAssets.openNonAsset(
                            value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                    AssetInputStream ais = (AssetInputStream) is;
                    dr = decodeImageDrawable(ais, wrapper, value);
                }
            } finally {
                stack.pop();
            }
        } catch (Exception | StackOverflowError e) {
            Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
            final NotFoundException rnf = new NotFoundException(
                    "File " + file + " from drawable resource ID #0x" + Integer.toHexString(id));
            rnf.initCause(e);
            throw rnf;
        }
    ...

 

@Nullable
    private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value,
    ...
        Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, file);
        LookupStack stack = mLookupStack.get();
        try {
            // Perform a linear search to check if we have already referenced this resource before.
            if (stack.contains(id)) {
                throw new Exception("Recursive reference in drawable");
            }
            stack.push(id);
            try {
                if (file.endsWith(".xml")) {
                    if (file.startsWith("res/color/")) {
                        dr = loadColorOrXmlDrawable(wrapper, value, id, density, file);
                    } else {
                        dr = loadXmlDrawable(wrapper, value, id, density, file);
                    }
                } else {
                    final InputStream is = mAssets.openNonAsset(
                            value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                    AssetInputStream ais = (AssetInputStream) is;
                    dr = decodeImageDrawable(ais, wrapper, value);
                }
            } finally {
                stack.pop();
            }
        } catch (Exception | StackOverflowError e) {
            Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
            final NotFoundException rnf = new NotFoundException(
                    "File " + file + " from drawable resource ID #0x" + Integer.toHexString(id));
            rnf.initCause(e);
            throw rnf;
        }
    ...
    private Drawable loadColorOrXmlDrawable(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density, String file) {
        try {
            ColorStateList csl = loadColorStateList(wrapper, value, id, null);
            return new ColorStateListDrawable(csl);
        } catch (NotFoundException originalException) {
            // If we fail to load as color, try as normal XML drawable
            try {
                return loadXmlDrawable(wrapper, value, id, density, file);
            } catch (Exception ignored) {
                // If fallback also fails, throw the original exception
                throw originalException;
            }
        }
    }
    ...
    private Drawable loadXmlDrawable(@NonNull Resources wrapper, @NonNull TypedValue value,
            int id, int density, String file)
            throws IOException, XmlPullParserException {
        try (
                XmlResourceParser rp =
                        loadXmlResourceParser(file, id, value.assetCookie, "drawable")
        ) {
            return Drawable.createFromXmlForDensity(wrapper, rp, density, null);
        }
    }

 

위 소스 확인으로 OS 10 이후부터는 ColorStateList 리소스도 처리가 가능하지만 그 미만 버전에서 문제가 되는 경우가 발생하는 부분을 확인할 수 있습니다.

 

컴파일시 에러가 나거나 버전 분기가 필요한 부분으로 체크 되지도 않기 때문에 생각없이 지나치기 쉬운 부분이 될 수도 있습니다.

만약 앱의 minSdkVersion이 29 미만이라면 항상 위 내용을 주의할 필요가 있습니다.

그야말로 별 생각없이 단순작업을 하다 벌어진 일이었습니다.

Java로 작성된 코드들을 일부 Kotlin으로 변환하고 문법을 조금씩 손을 보는 작업을 진행 중이었죠.

컴파일 오류도 없고 빨간 줄 하나 없이 잘 빌드되었고 문제가 없는 줄 알았습니다.

하지만 앱에는 내부적으로 문제가 있었죠. 그 원인은 이런 것이었습니다.

 

Java 클래스 2개가 있었고 일부 동일한 멤버변수들이 가지고 있는 클래스들이었습니다.

이 클래스들을 서로 다른 레이어의 Model로 있으면 레이어 간 데이터 전달시 서로 Mapping되는 역할이 있었죠.

그러다보니 동일한 멤버들을 서로의 멤버에 부어서 변환을 해주는 메서드들이 있었고요.

여기서 apply로 빌더처럼 데이터를 전달하다가 문제가 발생하였습니다.

 

해당코드를 샘플로 다시 작성 해봤습니다.

class Human {
    var sleep: Int = 0
    var eat: Int = 0
    var play: Int = 0
}

class Student {
    var sleep: Int = 0
    var eat: Int = 0
    var study: Int = 0
    
    fun getHuman(): Human {
        return Human().apply {
            sleep = sleep
            eat = eat
        }
    }
}

Human 과 Student 클래스가 있고 Student 클래스를 Human으로 맵핑하면서 동일한 데이터를 전달하는 과정입니다.

아래를 보면 바로 Human 생성 후 return을 하는 것이 보이고 apply로 필요한 데이터를 전달하는 것을 볼 수 있습니다.

하지만 이때 sleep과 eat 변수들을 주의깊게 보질 않았던 것이 문제였습니다.

        return Human().apply {
            sleep = sleep
            eat = eat
        }

위에서 보이는 sleep과 eat는 apply 내에서 this 즉 Human의 멤버들을 뜻하게 됩니다.

그래서 제가 Human에 넘기려던 Student의 데이터들이 제대로 복사가 안되었던 것입니다.

this.sleep = sleep 도 결과는 동일하였습니다.

그래서 아래와 같이 변경하였습니다.

        return Human().also {
            it.sleep = sleep
            it.eat = eat
        }

apply를 also로 변경하고 Human은 it으로 명시적으로 지정을 해주었습니다.

Kotlin의 Scope 함수를 잘 이해하고 조금만 신경 썼으면 틀릴 부분은 아니었습니다.

다만 IDE에서도 문법적으로는 문제가 없어 컴파일 오류로 잡아주질 않아 자칫 잘못하면 헤매기 쉬웠던 문제였죠.

문법들이 편해지는 만큼 방심하기 쉬워지는 것을 다시 한번 느끼긴 했습니다.

 

// 오랫만에 남기는 포스팅이라 뭐라 끝을 맺을지는 모르겠습니다... 그냥 끝!!!

Android WebView는 OS 버전에 따라 몇번의 변화가 있었습니다.

Android 10에서도 또 한번의 변화가 있어 간단히 정리해봤습니다.

Android 4.4 (Kitkat) 이전

커스텀하게 내장된 엔진을 사용하였으며, OS 업데이트시만 업데이트 되었습니다.

Android 4.4 (Kitkat)

Chromium 특정 버전을 사용하며, OS 업데이트시만 업데이트 되었습니다.

Android 5 (Lollipop)

OS 번들이 아니라 System WebView 로 제공되었으며, Play Store를 통해 업데이트가 되었습니다.

Android 7 (Nouga)

Chrome 앱의 엔진이 WebView에 사용되었으며, Chrome 앱 업데이트로 업데이트시 WebView도 업데이트가 되었습니다.

기존 System WebView와 Chrome WebView 중 선택이 가능합니다.

Android 10 (Q)

다시 System WebView로 제공되었고 Play Store를 통해 업데이트가 되었습니다.

System WebView Canary/Dev/Beta 버전들이 제공되며, 선택이 가능합니다.

Chrome WebView는 제공하지 않습니다.

관련 링크

https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/prerelease.md#Android-10-Q

https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/channels.md

https://www.androidpolice.com/2019/10/24/android-10-no-longer-uses-chrome-app-to-render-pages-inside-apps/

https://www.xda-developers.com/google-chrome-no-longer-webview-provider-android-10/

참고링크 : https://developer.android.com/training/location/geofencing#kotlin

개요

Android 에서는 Geofencing 기능을 통해 현재 위치가 특정 위치 반경에 진입/머물기/이탈 등의 이벤트를 처리 가능합니다.

단말 유저 당, 앱 당 최대 100개의 geofence 를 사용할 수 있습니다.

You can have multiple active geofences, with a limit of 100 per app, per device user.

Location Services에 geofence 를 위한 이벤트들을 요청해놓을 수 있습니다.

요청한 변경들이 감지되면 BroadcastReciever 등을 통해 처리가 가능합니다.

  • 반경 진입
  • 반경 이탈
  • 반경 진입 후 지정한 시간 동안 이탈 안함
  • geofence 만료 시간 (만료 시간 후 Location Service는 geofence를 알아서 지웁니다.)
항목 설명
최대 등록 수 100 per app, per device user
만료시간 Geofence 마다 millisecond 로 지정.
NEVER_EXPIRE 옵션 지원.
시간 지정 후 만료시 자동으로 Geofence 제거됨.
영역 지정 Circle 형태로 등록 되며, 위경도와 반경(m) 지정.
최상의 결과는 반경 100~150m 사이로 설정해야 함.
Wi-FI 사용 가능시 20~40m 까지 가능.
실내 위치 사용시는 5m 정도로 작을 수도 있음.
Geofence 내부 위치를 알수 없다면 Wi-Fi 정확도는 약 50m라고 가정함.
Wi-Fi 위치 사용할 수 없다면 수백m ~ 수Km 편차 생길수도 있음.
머물기 시간 지정 Geofence 진입 후 머물기로 체크할 시간을 millisecond로 지정.
Geofence 감지할 변경 타입 진입 (GEOFENCE_TRANSITION_ENTER)
이탈 (GEOFENCE_TRANSITION_EXIT)
머뭄 (GEOFENCE_TRANSITION_DWELL)
응답시간 지정 응답 시간이 큰 경우 전력을 크게 절약하고
응답 시간을 낮춘다고 바로 알림을 받지는 않음.
Geofence 추가시 초기 알림 설정 진입 (INITIAL_TRIGGER_ENTER)
이탈 (INITIAL_TRIGGER_EXIT)
머뭄 (INITIAL_TRIGGER_DWELL)
Geofence 사용 이유 설명 앱의 백그라운드 위치에 액세스하므로 사용자에게 권한 설명 필요.
Geofence 재등록이 필요없는 경우 Google Play 서비스 업그레이드나 리소스 제한으로 종료 후 재시작시
Location 프로세스 충돌시
Geofence 재등록이 필요한 경우 단말 재부팅시 부팅 완료 시점을 받아 다시 등록해야 함.
앱이 제거 후 재 설치시
앱 데이터 삭제시
Google Play 서비스 데이터 삭제시
앱에 GEOFENCE_NOT_AVAILABLE 알림 수신시
(일반적으로 Android's Network Location Provider 비활성화시 발생함)
Geofence 진입이 잘 동작하지 않는 경우 (GEOFENCE_TRANSITION_ENTER) Geofence 정확한 위치를 사용할 수 없거나 반경이 너무 작은 경우
Wi-Fi가 꺼져 있는 경우 (SettingsClient를 사용해 장치 설정 체크 필요)
- 참고로 Android 10에서는 WifiManager.setEnabled() 호출 불가하므로 설정 패널 사용.
- 시스템 앱이나 Device policy controller 에서는 가능.
Geofence 내 안정적인 네트워크 연결이 없을 경우
단순히 알림이 늦는 경우.
- 지속적으로 위치 쿼리하는 형식이 아니라 대기 시간이 필요함.
- 일반적인 대기 시간은 2 분 미만이며 장치가 움직일 때는 훨씬 적음.
- 백그라운드 위치 제한 적용시 대기 시간은 평균 약 2~3분 정도.
- 단말이 장시간 정지된 경우 대기 시간이 증가할 수 있음. (최대 6분)

 

Android App 샘플 만들기

1. 권한 추가

먼저 권한을 아래와 같이 추가 후 앱에서 permission 체크 및 요청 로직을 추가합니다.

ACCESS_BACKGROUND_LOCATION 권한은 Android 10 부터 필요합니다.

App 권한 요청 처리

Background 위치 접근 권한 처리

AndroidManifest.xml

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

    <!-- Required if your app targets Android 10 (API level 29) or higher -->
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
    ...
</manifest>

MainActivity.kt

    private val MY_PERMISSIONS_REQ_ACCESS_FINE_LOCATION = 100
    private val MY_PERMISSIONS_REQ_ACCESS_BACKGROUND_LOCATION = 101

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        checkPermission()
    }

    override fun onRequestPermissionsResult(requestCode: Int,
                                            permissions: Array<String>, grantResults: IntArray) {
        when (requestCode) {
            MY_PERMISSIONS_REQ_ACCESS_FINE_LOCATION,
            MY_PERMISSIONS_REQ_ACCESS_BACKGROUND_LOCATION -> {
                grantResults.apply {
                    if (this.isNotEmpty()) {
                        this.forEach {
                            if (it != PackageManager.PERMISSION_GRANTED) {
                                checkPermission()
                                return
                            }
                        }
                    } else {
                        checkPermission()
                    }
                }
            }
        }
    }

    private fun checkPermission() {
        val permissionAccessFineLocationApproved = ActivityCompat
            .checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
                PackageManager.PERMISSION_GRANTED

        if (permissionAccessFineLocationApproved) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val backgroundLocationPermissionApproved = ActivityCompat
                    .checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
                        PackageManager.PERMISSION_GRANTED

                if (!backgroundLocationPermissionApproved) {
                    ActivityCompat.requestPermissions(
                        this,
                        arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
                        MY_PERMISSIONS_REQ_ACCESS_BACKGROUND_LOCATION
                    )
                }
            }
        } else {
            ActivityCompat.requestPermissions(this,
                arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                MY_PERMISSIONS_REQ_ACCESS_FINE_LOCATION
            )
        }
    }

2. dependency 추가

Location Service 및 Geofence 사용을 위해서 location 라이브러리를 추가합니다.

app/build.gradle

dependencies {
    ...
    implementation 'com.google.android.gms:play-services-location:17.0.0'
    ...
}

3. Geofence List 추가

테스트 용으로 추가할 Geofence 데이터를 생성합니다.

Geofence 객체 생성시 여러가지 설정을 할 수 있습니다.

ex) 위치 및 반경, 어떤 이벤트를 걸건지, 만료나 머무는 체크 시간을 얼마나 할지 등등

MainActivity.kt

    val geofenceList: MutableList<Geofence> by lazy {
        mutableListOf( 
            getGeofence("현대백화점", Pair(37.5085864,127.0601149)),
            getGeofence("삼성역", Pair(37.5094518,127.063603))
        )
    }

    private fun getGeofence(reqId: String, geo: Pair<Double, Double>, radius: Float = 100f): Geofence {
        return Geofence.Builder()
            .setRequestId(reqId)    // 이벤트 발생시 BroadcastReceiver에서 구분할 id
            .setCircularRegion(geo.first, geo.second, radius)    // 위치 및 반경(m)
            .setExpirationDuration(Geofence.NEVER_EXPIRE)        // Geofence 만료 시간
            .setLoiteringDelay(10000)                            // 머물기 체크 시간
            .setTransitionTypes(
                Geofence.GEOFENCE_TRANSITION_ENTER                // 진입 감지시
                        or Geofence.GEOFENCE_TRANSITION_EXIT    // 이탈 감지시
                        or Geofence.GEOFENCE_TRANSITION_DWELL)    // 머물기 감지시
            .build()
    }

4. Geofence Client 생성

Location API 사용을 위하여 Geofencing Client 인스턴스를 생성해야 합니다.

MainActivity.kt

    private val geofencingClient: GeofencingClient by lazy {
        LocationServices.getGeofencingClient(this)
    }

5. Geofencing Request 빌드

Geofence 지정 및 관련 이벤트 트리거 방식을 설정하기 위해 GeofencingRequest 를 빌드합니다.

MainActivity.kt

    private fun getGeofencingRequest(list: List<Geofence>): GeofencingRequest {
        return GeofencingRequest.Builder().apply {
            // Geofence 이벤트는 진입시 부터 처리할 때
            setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)                
            addGeofences(list)    // Geofence 리스트 추가
        }.build()
    }

6. Broadcast Receiver 추가

Geofencing 변경 이벤트를 받을 BroadcastReceiver 를 추가 후 등록해줍니다.

GeofenceBroadcastReceiver.kt

class GeofenceBroadcastReceiver: BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        val geofencingEvent = GeofencingEvent.fromIntent(intent)
        if (geofencingEvent.hasError()) {
            val errorMessage = GeofenceStatusCodes.getStatusCodeString(geofencingEvent.errorCode)
            Log.e("GeofenceBR", errorMessage)
            return
        }

        // Get the transition type.
        val geofenceTransition = geofencingEvent.geofenceTransition    // 발생 이벤트 타입

        // Test that the reported transition was of interest.
        if (geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER ||
        geofenceTransition == Geofence.GEOFENCE_TRANSITION_EXIT) {

            // Get the geofences that were triggered. A single event can trigger
            // multiple geofences.
            val triggeringGeofences = geofencingEvent.triggeringGeofences

            val transitionMsg = when(geofenceTransition) {
                Geofence.GEOFENCE_TRANSITION_ENTER -> "Enter"
                Geofence.GEOFENCE_TRANSITION_EXIT -> "Exit"
                else -> "-"
            }
            triggeringGeofences.forEach {
                Toast.makeText(context, "${it.requestId} - $transitionMsg", Toast.LENGTH_LONG).show()
            }

        } else {
            Toast.makeText(context, "Unknown", Toast.LENGTH_LONG).show()
        }
    }
}

AndroidManifest.xml

<application... >
    ...
    <receiver android:name=".GeofenceBroadcastReceiver"/>
</application>

MainActivity.kt

    private val geofencePendingIntent: PendingIntent by lazy {
        val intent = Intent(this, GeofenceBroadcastReceiver::class.java)
        PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
    }

7. Geofence Request 및 Callback 추가

Geofencing Client에 Geofence 정보 및 트리거 방식을 가지고 있는 Geofencing Request과 이벤트 발생시 처리할 Broadcast Receiver를 추가해줍니다.

MainActivity.kt

private fun addGeofences() {
    geofencingClient.addGeofences(getGeofencingRequest(geofenceList), geofencePendingIntent).run {
        addOnSuccessListener {
            Toast.makeText(this@MainActivity, "add Success", Toast.LENGTH_LONG).show()
        }
        addOnFailureListener {
            Toast.makeText(this@MainActivity, "add Fail", Toast.LENGTH_LONG).show()
        }
    }
}

준비는 끝났습니다. 앱을 실행해보아 동작을 확인합니다.

 

Android Developer Document에서 권장하는 가이드 내용입니다.

https://developer.android.com/jetpack/docs/guide

Android 앱에는 여러가지 구성요소가 포함되며, 이런 내용들은 Manifest 에서 선언되어 있습니다.

ex) Activity, Fragment, Service, Content Provider, Broadcast Receiver

 

사용자는 짧은 시간 내에 여러 앱과 상호작용 할 경우도 많습니다.

앱은 사용자 중심의 다양한 워크플로 및 작업에 맞게 조정될 수 있어야 합니다.

 

예를 들어 SNS 앱에서 사진 앱을 통해 사진을 공유하거나 앱 사용 중 전화나 알림으로 사용 환경이 중단될 수도 있죠.

또한 모바일 기기는 리소스가 제한되어 언제든 일부 앱 프로세스가 종료할 수 있습니다.

 

이러한 환경을 고려할 때, 앱 구성 요소는 개별적이고 비순차적 실행이 될 수 있으며 언제든 구성 요소가 제거 될 수 있습니다.

이러한 이벤트는 직접 제어가 불가하기 때문에 앱 구성 요소에는 앱 데이터나 상태를 저장하면 안됩니다.

그리고 앱 구성 요소가 서로 종속되면 안됩니다.

일반적인 아키텍처 원칙

앱 데이터와 상태를 앱 구성 요소에 저장할 수 없다면?

관심사 분리

Activity나 Fragment에 모든 코드를 작성하는 실수는 흔합니다.

UI 기반 클래스는 UI 및 OS 상호작용 등의 로직만 포함해야 합니다.

이러한 클래스를 최대한 가볍게 유지하면 생명 주기(LifeCycle) 관련 문제들을 피할 수 있습니다.

 

Activity 및 Fragment 구현은 소유의 대상이 아니며 Android OS 와 앱 사이의 계약을 나타내는 클래스일 뿐입니다.

언제든지 OS가 클래스를 제거할 수 있으니 이러한 클래스에 대한 의존성을 최소화 하는 것이 좋습니다.

Model에서 UI 구동

또 하나의 중요한 원칙은 Model에서 UI를 구동해야 한다는 것입니다.

가급적 지속성 있는 Model을 권장합니다.

 

Model은 앱의 데이터 처리를 담당하며, 앱의 View 객체 및 앱 구성요소로부터 독립되어 있습니다.

그래서 앱의 생명 주기 관련 문제의 영향을 받지 않습니다.

지속성 있는 Model이 이상적인 이유입니다.

  • Android OS에서 리소스를 확보하기 위해 앱을 제거해도 사용자 데이터는 삭제되지 않음.
  • 네트워크 연결이 취약하거나 연결되어 있지 않아도 앱이 계속 작동함.

데이터 관리 책임이 잘 정의된 Model 클래스를 기반으로 앱을 만들면 쉽게 테스트하고 일관성을 유지할 수 있습니다.

권장하는 앱 아키텍처

이 섹션에서는 Android Architecture Component 를 사용하여 앱을 구성하는 방법을 보여줍니다.

모든 시나리오에 최적화된 하나의 앱 작성 방법은 없지만 이 아키텍처가 유용한 출발점이 될 것입니다.

일반적인 아키텍처 원칙을 따르는 Android 앱 작성법을 사용하고 있으면 변경할 필요가 없습니다.

사용자 프로필 UI를 가정해보면, 비공개 백엔드 및 REST API를 사용하여 지정된 프로필 데이터를 가져옵니다.

개요

앱을 설계한 후 모듈들이 서로 어떻게 상호작용해야 하는지 다이어그램이 보여줍니다.

각 구성요소가 한 수준 아래의 구성요소에만 종속됨을 볼 수 있습니다.

Activity 및 Fragment는 ViewModel에만 종속됩니다.

 

Repository는 여러 개의 다른 클래스에 종속되는 유일한 클래스입니다.

Repository는 지속성 있는 데이터 모델과 원격 백엔드 데이터 소스에 종속됩니다.

이 설계는 일관되고 쾌적한 사용자 환경을 제공합니다.

 

사용자가 앱을 닫은 후 재사용시 앱이 로컬에 보존하는 사용자 정보가 바로 표시됩니다.

이 데이터가 오래된 경우 앱의 Repository 모듈이 백그라운드에서 데이터 업데이트를 시작합니다.

UI 빌드

UI 에 필요한 데이터는 ViewModel 아키텍처 구성요소에 기반한 ViewModel에서 정보를 유지합니다.

ViewModel 객체는 UI 구성요소에 관한 데이터를 제공하고 모델과 커뮤니케이션하기 위한 데이터 처리 비즈니스 로직을 포함합니다. ViewModel 은 UI 구성요소에 관해 알지 못하므로 구성 변경(세로 -> 가로모드)의 영향을 받지 않습니다.

ViewModel에서는 데이터 객체에 대한 변경 등을 View에 알려야 합니다.

여기에서 LiveData 가 사용됩니다.

 

LiveData는 객체 변경사항을 모니터링 할 수 있으며, Activity/Fragment/Service 와 같은 앱 구성요소의 생명 주기 상태를 고려한 로직을 포함합니다. (LiveData 필드는 생명 주기로 인해 더 이상 필요하지 않은 참조를 자동으로 정리합니다.)

RxJava와 같은 라이브러리를 사용 중이라면 그대로 사용해도 됩니다. 단 앱 생명 주기를 올바르게 처리해야 합니다.

Activity나 Fragment 에서 LiveData 객체를 observe 후 변경시에 대한 콜백 onChanged() 를 사용해 UI를 새로고침할 수 있습니다.

 

LiveData는 생명 주기를 인식하기 떄문에 Activity 나 Fragment의 onStop(), onDestroy() 메서드 등에 대한 처리하지 않아도 됩니다.

Data 가져오기

데이터를 가져오는 방식은 내부 데이터나 외부 API를 통한 데이터를 가져오는 방식들이 있습니다.

예제에서는 Retrofit 라이브러리를 통해 백엔드 REST API를 사용합니다.

 

ViewModel에서 직접 해당 라이브러리를 이용하여 데이터를 LiveData 객체에 할당하는 방법이 있습니다.

이 경우 ViewModel 클래스에 너무 많은 책임이 부여되어 관심사 분리 원칙을 위반하게 됩니다.

또한 ViewModel의 범위는 Activity나 Fragment 생명 주기에 연결되어 있으므로 데이터가 손실될 수도 있습니다.

그래서 Repository 모듈을 사용하여 데이터 작업을 처리합니다.

 

Repository는 지속적인 모델, 웹 서비스, 캐시 등 다양한 데이터 소스 간 중재자로 볼 수 있습니다.

이제 ViewModel이 데이터를 가져오는 방법을 직접 알지 못하기 때문에 Repository에서 서로 다른 데이터 가져오기 구현을 통한 데이터를 ViewModel에 제공할 수 있습니다.

구성요소 간 종속성 관리

Repository 클래스에서 WebService를 통해 데이터를 가져올 경우 WebService 클래스의 종속성을 알아야 합니다.

이러한 상황에서 다른 클래스에서도 WebService가 필요한 경우 코드를 복제해야 합니다.

이런 문제는 아래 설계 패턴을 사용하여 해결할 수 있습니다.

  • 종속성 주입(DI) : 클래스가 자신의 종속성을 구성할 필요 없이 종속성을 정의할 수 있습니다.

     

    런타임시 다른 클래스가 이 종속성을 제공해야 합니다.
    Android 앱에서는 Dagger 2 라이브러리 사용이 좋습니다.
    Dagger 2 는 종속성 트리를 따라 객체를 자동으로 구성하고 종속성을 컴파일 시간에 보장합니다.
  • 서비스 로케이터 : 클래스가 자신의 종속성을 구성하는 대신 종속성을 가져올 수 있는 레지스트리를 제공합니다.

DI 사용보다 서비스 레지스트리 구현이 쉬우므로 DI가 익숙치 않다면 서비스 로케이터 패턴을 사용해도 됩니다.

이 패턴은 코드를 복제하거나 복잡성을 추가하지 않아도 종속성을 관리하기 위한 명확한 패턴을 제공하므로 코드를 확장할 수 있습니다.

이러한 패턴을 사용하면 테스트 및 프로덕션 데이터 가져오기 구현 등을 신속하게 전환할 수 있습니다.

ViewModel 과 Respository 연결

ViewModel에서 DI (Dagger 2) 를 사용하여 Repository를 주입시켜 봅니다.

Repository가 WebService 객체 호출을 하지만 하나의 데이터 소스에만 의존하면 유연성이 떨어집니다.

따라서 매번 WebService를 호출하는 방식은 네트워크 낭비 및 새 쿼리 완료시까지 사용자가 기다려야 하는 문제가 있습니다.

이런한 문제를 해결하기 위해 Repository에 데이터를 캐시하는 소스를 추가해 봅니다.

지속되는 데이터

위에까지 구현 방식은 앱이 나갔다 들어오거나 하는 경우 Repository가 메모리 내 캐시에서 데이터를 가져옵니다.

하지만 장시간 프로세스를 종료 후 들어온다면 다시 네트워크에서 데이터를 가져와야 합니다.

이런 경우 Room 같은 라이브러리가 필요합니다.

이미 SQLite 같은 ORM을 사용 중이라면 꼭 Room으로 대체할 필요는 없습니다.

Room에서는 Annotation으로 로컬 스키마 정의가 가능합니다. (@Entity, @PrimaryKey)

RoomDatabase를 구현하여 추상 클래스를 만들면 Room에서는 자동으로 구현을 제공합니다.

 

데이터를 데이터베이스에 액세스하기 위해 데이터 액세스 객체 (DAO) 를 만듭니다.

Room 메서드에서 LiveData를 사용하면 데이터 변경시 자동으로 모든 Observer들에게 알리게 됩니다.

DAO를 정의했으면 데이터베이스 클래스에서 DAO를 참조 후 Repository 소스를 수정하면 됩니다.

단일 소스 Repository

다양한 REST API 엔드포인트는 흔히 동일한 데이터를 반환합니다.

일관선 확인 없이 Repository가 WebService 요청으로부터 응답을 있는 그대로 반환한다면 Repository 의 데이터 버전과 형식이 가장 최근에 호출된 엔드포인트에 종속되므로 UI에서 혼란스러운 정보를 표시할 수 있습니다.

 

그렇기에 Repository 구현에서는 데이터베이스에 WebService 응답을 저장합니다.

그러면 데이터베이스 변경시 LiveData 객체에 콜백이 트리거 됩니다.

 

이 모델을 사용하면 데이터베이스가 단일 소스 Repository 역할을 하며 앱의 다른 부분은 Repository를 사용하여 데이터베이스에 엑세스 합니다. 디스크 캐시 사용 여부와 상관없이 Repository에서 데이터 소스를 나머지 앱의 단일 소스 Repository 로 지정하는 것이 좋습니다.

진행 중인 작업 표시

SwipeRefresh 같은 사용 사례에서는 네트워크 작업시 UI 표시가 중요합니다.

다양한 이유로 데이터가 업데이트 될 수 있으므로 실제 데이터와 UI 작업을 분리하는 것이 좋습니다.

  • LiveData 유형의 객체를 반환하도록 변경하고, 이 객체에는 네트워크 작업 상태가 포함됩니다.
  • Repository 클래스에 데이터 새로고침 상태를 반환할 수 있는 다른 함수를 제공합니다.

각 구성요소 테스트

관심사 분리의 원칙을 따랐을 때 한 가지 중요한 이점은 테스트 가능성입니다.

  • UI 및 인터렉션 : Android UI Instrumentation Test 를 사용합니다. Espresso 라이브러리를 사용하는 것이 가장 좋으며, Mock으로 ViewModel을 제공할 수 있습니다. Activity나 Fragment는 ViewModel 하고만 통신하므로 이 클래스만으로도 충분히 UI 를 테스트 할 수 있습니다.

  • ViewModel : JUnit Test 를 사용하여 ViewModel 클래스를 테스트할 수 있습니다. Repository만 Mock 객체로 테스트하면 됩니다.

  • Repository : 역시 JUnit Test로 테스트할 수 있습니다. WebService난 DAO를 Mock 객체로 테스트하면 됩니다.

    • Repository가 올바른 WebService를 호출하는지
    • Repository가 데이터베이스에 결과를 저장하는지
    • 데이터가 캐시되고 최신인 경우 Respository가 불필요한 요청은 안만드는지
  • WebService와 DAO 는 모두 인터페이스이므로 더 복잡한 테스트를 위해 Mock이나 fake 구현을 만들 수 있습니다.

  • DAO : Instrumentation Test를 사용하여 테스트합니다. UI 구성요소가 필요하지 않으므로 빠르게 실행되며, 각 테스트에서 메모리 내에 데이터베이스를 만들어 테스트 부작용이 없게 합니다. (ex. 디스크의 DB 파일 변경)

    • Room에서 데이터베이스 구현을 지정하도록 허용하므로 SupportSQLiteOpenHelper 의 JUnit 구현을 제공하여 DAO를 테스트할 수 있습니다. 하지만 기기에서 실행 중인 SQLite 버전이 개발 PC의 SQLite 버전과 다를 수 있으므로 이 방법은 권장되지 않습니다.

  • WebService : 테스트에서 백엔드로 네트워크 호출하지 않도록 합니다. 웹 기반 테스트를 포함한 모든 테스트가 외부로부터 독립적이어야 합니다. MockWebServer 를 포함하여 여러 라이브러리를 활용하여 가짜 로컬 서버를 만들 수 있습니다.

  • 아티팩트 테스트 : 아키텍처 구성요소는 백그라운드 스레드를 제어할 수 있는 maven 아티팩트를 제공합니다.

    `androidx.arch.core:core-testing` 아티팩트에는 다음과 같은 JUnit 규칙이 있습니다.
    • InstantTaskExecutorRule : 호출 스레드에서 백그라운드 작업을 즉시 실행합니다.
    • CountingTaskExecutorRule : 아키텍처 구성요소의 백그라운드 작업을 기다립니다. Espresso와 연결할 수 있습니다.

권장사항

다음 권장사항은 필수는 아니지만 경험에 의한 권장사항을 따르는 경우 장기적으로 더 강력하고 테스트 및 유지관리가 쉬운 코드 베이스를 만들 수 있습니다.

  • Activity, Service, Broadcast Receiver와 같은 앱의 진입점을 데이터 소스로 지정하지 마세요.
  • 앱의 다양한 모듈 간 책임이 잘 정의된 경계를 만듭니다.
  • 각 모듈에서 가능하면 적게 노출합니다.
  • 각 모듈을 독립적으로 테스트하는 방법을 고려합니다.
  • 다른 앱과 차별되도록 앱의 고유한 핵심에 초점을 맞춥니다.
  • 가능한 한 관련성이 높은 최신 데이터를 보존합니다.
  • 하나의 데이터 소스를 단일 소스 저장소로 지정합니다.

추가: 네트워크 상태에 따른 노출 처리 로직

먼저 SonarQube 서버가 확보된 상태에서 시작합니다.

서버가 없으시다면 아쉬운 대로 개인 로컬에서 서버를 돌린 뒤 사용해보시면 됩니다.

참고 : https://nobase-dev.tistory.com/275

간단합니다.

build.gradle에 아래 내용만 추가해주면 됩니다.

buildscript {
    dependencies {
        classpath "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8"
    }
}

apply plugin: "org.sonarqube"
sonarqube {
    properties {
        property "sonar.host.url", "http://localhost:9000/"
        property "sonar.login", "admin"        // 로그인 id 또는 인증토큰
        property "sonar.password", "admin"    // 인증토큰 사용시는 공백으로
        property "sonar.projectKey", "projectKey"
        property "sonar.projectName", "projectName"
        property "sonar.projectVersion", "1.0"
        property "sonar.sourceEncoding", "UTF-8"
        //property "sonar.sources", "src/"
        property "sonar.profile", "Sonar way"
        property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/coverageReport/coverageReport.xml"    // Test Coverage Report 생성한 경우 사용
    }
}

SonarQube에서 다양한 Property들을 제공하고 있습니다.

보다 많은 설정이나 상세한 내용은 아래 링크를 참고하시면 됩니다.

https://docs.sonarqube.org/latest/analysis/analysis-parameters/

다 되었다면 Android StudioTerminal 탭에서 실행하면 됩니다.

> gradlew sonarqube

그리고 SonarQube 프로젝트 리스트에서 리포트가 잘 생성이 되었는지 확인하면 끝~

은 아니고 이제부터 시작입니다.

버그와 취약점, 악취들을 제거하기 위한 준비는 끝~ 입니다~

참고링크 :

https://question0.tistory.com/2

https://plugins.gradle.org/plugin/org.sonarqube

Android Studio에서는 간단히 Run ... with coverage 를 통해 Test Coverage를 확인할 수 있습니다.

다만 SonarQube 사용시 Coverage 항목을 위해서는 xml 형태의 Test Coverage Report가 필요하였고요.

그래서 JaCoCo 플러그인을 사용하여 간단히 뽑아볼 수 있었습니다.

아래의 포스팅에 잘 정리가 되어 있어 따라가면 되었고요.

https://www.androidhuman.com/lecture/quality/2016/02/13/jacoco_unit_test_android/

 

JaCoCo를 사용하여 안드로이드 프로젝트 유닛 테스트 커버리지 측정하기

#Android, #Koltin, and #Tesla

www.androidhuman.com

 

다만!! 일부 설정이 제 환경과 다른 부분들이 발생을 하였습니다.

해당 포스팅에서 설정은 다 gradle 파일 안에서 이루어지면 크게 3단계로 나뉘어져 있습니다.

  1. JaCoCo 플러그인 설정
  2. 커버리지 측정 태스크 정의
  3. 커버리지 측정 실행

이 중에서 제 환경에서 다른 부분은 커버리지 측정 태스크 정의 에서 일부 항목이었습니다.

    classDirectories.from = fileTree(
            dir: "${buildDir}/tmp/kotlin-classes/debug",
            excludes: ['**/R.class',
                       '**/R$*.class',
                       '**/BuildConfig.*',
                       '**/Manifest*.*',
                       'com/android/**/*.class']
    )

    sourceDirectories.from = files(coverageSourceDirs)
    executionData.from = files("${buildDir}/jacoco/testDebugUnitTest.exec")

 

차이라면 제가 작성한 테스트 프로젝트는 Kotlin 코드만 있었습니다.

그러다 보니 classDirectories 경로가 달랐습니다.

  • dir: "${buildDir}/intermediates/classes/debug",

  • dir: "${buildDir}/tmp/kotlin-classes/debug",

그리고 몇몇 속성이 Deprecated 되었다고 떠서 검색해보니 .from 을 추가하라 하더군요.

그래서 아래 항목들에 .from이 추가되었습니다.

  • classDirectories.from
  • sourceDirectories.from
  • executionData.from

 

추가로 Flaovr 를 사용하는 프로젝트에서는 아래의 항목들이 FlavorBuildType 옵션에 맞춰 설정이 필요하였습니다.

ex) Flavor 가 dev이고, BuildType이 debug일 경우

task coverageReport(type: JacocoReport, dependsOn: 'testDevDebugUnitTest') {
    ...
    classDirectories = fileTree(
            dir: "${buildDir}/intermediates/classes/dev/debug",
    ...
    )
    ...
    executionData = files("${buildDir}/jacoco/testDevDebugUnitTest.exec")
    ...
}

 

제가 사용한 최종 수정본입니다~

apply plugin: 'jacoco'

jacoco {
    reportsDir = file("${buildDir}/reports")
}

task coverageReport(type: JacocoReport, dependsOn: 'testDevDebugUnitTest') {
    group = "Reporting"
    description = "Generate Jacoco coverage reports"

    def coverageSourceDirs = ['src/']

    classDirectories = fileTree(
            dir: "${buildDir}/intermediates/classes/dev/debug",
            excludes: ['**/R.class',
                       '**/R$*.class',
                       '**/BuildConfig.*',
                       '**/Manifest*.*',
                       'com/android/**/*.class']
    )

    sourceDirectories = files(coverageSourceDirs)
    executionData = files("${buildDir}/jacoco/testDevDebugUnitTest.exec")

    reports {
        xml.enabled = true
        html.enabled = true
    }
}

 

빌드 완료 후에 지정된 폴더 밑에 Report 파일이 잘 들어갔는지 확인해봅니다.

 

SonarQube 사용 중이라면 저 Report 경로를 잡아주면 gradle task 에 추가하면 됩니다.

apply plugin: 'org.sonarqube'
sonarqube {
    properties {
        ...
        property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/coverageReport/coverageReport.xml"
    }
}

 

하는 김에 SonarQube 도 다시 돌려봅니다.

> gradlew sonarqube

 

SonarQube 프로젝트 리스트에서 Coverage를 확인합니다. -끝-

+ Recent posts