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

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를 쓰시느라 볼 일이 별로 없으시겠지만요...

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

결론부터 말하자면 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에서도 문법적으로는 문제가 없어 컴파일 오류로 잡아주질 않아 자칫 잘못하면 헤매기 쉬웠던 문제였죠.

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

 

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

1. 아래와 같은 로그가 떨어짐.

ERROR: The specified Gradle distribution 'https://services.gradle.org/distributions/gradle-4.4-all.zip' does not appear to contain a Gradle distribution.

2. Android Gradle plugin 버전 및 사용하려는 gradle 버전 확인

./build.gradle

buildscript { dependencies { classpath 'com.android.tools.build:gradle:3.1.4' } }

.\gradle\wrapper\gradle-wrapper.properties

distributionUrl=https://services.gradle.org/distributions/gradle-4.4-all.zip

3. 버전별 최소 gradle 버전 확인

https://developer.android.com/studio/releases/gradle-plugin#updating-gradle

3 - 1. 최소 gradle 버전에 부합한 경우 맞춰서 수정

build.gradle 이나 gradle-wrapper.properties 파일 내 버전을 한쪽에 맞춰서 수정

4. 최소 gradle 버전에 적합한데도 발생한 경우

예전에 직접 gradle 쪽 초기화를 하고 싶어 직접 gradle 경로에서 파일들을 삭제 하던 중 폴더만 남고 삭제가 안된 경우가 있었음. 그로 인해 gradle 있지만 동작 안하는 것으로 감지한 듯함. 직접 gradle zip 파일을 받아서 해당 경로에 풀어 주고 다시 gradle sync

  • gradle zip 다운로드 : https://services.gradle.org/distributions/gradle-4.4-all.zip

  • gradle 경로 확인 : Android Studio -> File -> Settings -> Build, Execution, Deployment -> Gradle -> Service directory path 확인

  • \.gradle\wrapper\dists\gradle-4.4-all\9br9xq1tocpi6njlyu5op1\gradle-4.4\ 위치에 zip 압축 해제

  • Android Studio에서 다시 sync

Android 앱 개발시 Activity의 Launch Mode에 따른 동작들이 헷갈릴 때가 많습니다.

 

볼 때 다시 찾아보고 다시 기억을 되새김 해야 하는데 대부분 글과 그림으로 설명이 되어 있지요.

 

좀 더 보기 쉽도록 애니메이션으로 만들어 올린 링크가 있어 공유합니다.

 

https://itnext.io/the-android-launchmode-animated-cheatsheet-6657e5dd9b0f

 

The Android LaunchMode Animated CheatSheet - ITNEXT

Before API 11, we used activities show every new page on the screen. Now with Fragments and the navigation tools in API 28, it’s totally…

itnext.io

 

위와 같이 보기 쉽게 여러가지 경우들을 만들어 놓으셨네요.

 

기억이 안나실 때 한번씩 볼만 할 것 같습니다.

 

앞으로 Google Play에 앱을 올릴 경우 64비트 대응이 되어야 올릴 수가 있다고 합니다.

 

https://developers-kr.googleblog.com/2019/01/get-your-apps-ready-for-64-bit.html

 

Google Play의 64비트 요구 사항에 맞춰 앱을 준비하세요

<블로그 원문은 이곳 에서 확인하실 수 있으며 블로그 번역 리뷰는 양찬석(Google)님이 참여해 주셨습니다> 게시자: Vlad Radu(Play 제품 관리자), Diana Wong(Android 제품 관리자) 64비트 CPU는 사용자...

developers-kr.googleblog.com

일정은 2019년 8월 1일 부터이며, 2021년 8월 1일부터는 64비트 대응이 안된 앱은 서비스 종료를 시킨다네요.

 

참 무서운 이야기이고, 뭘 해야하나 싶은 이야기입니다.

 

  • 네이티브 코드를 포함하는 모든 새 앱과 앱 업데이트는 Google Play에 게시할 때 32비트 버전 외에 64비트 버전도 함께 제공되어야 합니다.

Google Developer 블로그에는 위와 같이 설명이 되어 있습니다.

 

즉, 네이티브 코드를 쓰던 부분들이 문제가 되는 것으로 보이고요. 

 

제 경우엔 다음? 카카오? 맵 SDK가 해당이 되는 경우가 발생하였습니다.

 

맵 SDK가 so 파일로 제공이 되었지만, 64비트로는 아직 제공이 안되고 있는데요.

 

만약 개인이 직접 so 파일을 만들고 사용 중이라면 64비트짜리도 만들어 대응을 하셔야 합니다.

 

참고로 다음? 카카오? 맵 SDK는 다음주 초, 즉 2019년 7월 4주 초쯤에 대응이 될거 같다는 것 같습니다.

 

https://devtalk.kakao.com/t/topic/80108

 

Kakao DevTalk_

카카오 데브톡. 카카오 플랫폼 서비스 관련 질문 및 답변을 올리는 개발자 커뮤니티 사이트입니다.

devtalk.kakao.com

 

이번 시련도 다들 잘 넘어가시길 기원하겠습니다.

 

 


 

오늘(7/24) 확인해보니 7월 22일자로 카카오 지도 API 업데이트가 되었나 봅니다. 

 

http://apis.map.kakao.com/android/guide/

불러오는 중입니다...

다소 타이트하겠지만 기존에 쓰시던 분들은 대응이 되셔야 할 것 같네요.

 

행운을 빌겠습니다~

 

  형태 인자 반환
let fun <T, R> T.let(block: (T) -> R): R 호출한 객체 블록 결과값
apply fun T.apply(block: T.() -> Unit): T 호출한 객체 내 메서드 및 속성 객체 자체 반환
run fun <T, R> T.run(block: T.() -> R): R 호출한 객체 내 메서드 및 속성 블록 결과값
with fun <T, R> with(receiver: T, block: T.() -> R): R 호출한 객체 내 메서드 및 속성 블록 결과값

run과 with가 유사하지만 run에서만 Safe Calls를 지원함.

Android 앱 개발시 EditText 에 키보드 옵션을 줄 수 있습니다.

흔히 주는 옵션으로는 `imeOptions``inputType` 등이 있지요. (아래 링크 참조)

 

https://developer.android.com/reference/android/text/InputType

불러오는 중입니다...

https://developer.android.com/training/keyboard-input/style

불러오는 중입니다...

 

imeOptions로 키보드에 버튼 타입 등을 조정할 수 있습니다.

(엔터 버튼 대신 `다음` 또는 `완료` 같은 키로 나오는 등)

 

inputType으로 키보드로 입력 가능한 타입을 정할 수 있습니다.

(비밀번호 입력 형태 등)

 

다만 안드로이드 단말 특성상 제조사별로 다른 키보드가 들어가 있다보니 작은 이슈가 있었습니다.

 

특정 앱에서 삼성 갤럭시 단말의 쿼티 자판으로 한글이 입력이 안되는 경우가 발견된 거죠.

(같은 단말 천지인 자판으로 변경 후는 한글이 잘 입력됨.)

 

재미있는 건 LG 단말에서는 쿼티 자판으로도 한글이 잘 입력됩니다.

 

그리고 넥서스 단말에선 아예 쿼티에서 한글 전환이 불가했고요.

 

확인 결과 문제가 발생하는 경우 inputType 값이 `textVisiblePassword`로 지정되어 있었습니다.

저 타입을 제거할 경우 정상적으로 한글이 잘 입력되더군요.

 

영어, 숫자만 입력이 가능하게 한 것이 목적이었던 것 같은데요.

아마 다른 방법을 찾아야 할 것으로 보이네요.

3일 전까지 문제없이 잘 돌아가던 안드로이드 프로젝트에서 갑자기 빌드가 안되는 문제가 발생하였습니다.

주말이 지났을 뿐인데 아래와 같은 로그와 함께 gradle sync도 안되는 문제였습니다.

org.gradle.api.ProjectConfigurationException: A problem occurred configuring project ':mymodule'.
...
Caused by: groovy.lang.MissingPropertyException: Could not get unknown property 'assemble' for task ':mymodule:assembleDebug' of type org.gradle.api.DefaultTask.

혹시나 하는 마음에 소스를 1년 전 껄로 돌려보아도 동일하고, 딱히 형상관리에 문제도 없어 보였죠.

gradle 살펴보던 중 가변적인 요소가 하나 눈에 들어왔고, 그 부분이 원인이었습니다.

classpath 'io.fabric.tools:gradle:1.+'

앱 최상위에 있는 build.gradle에 정의된 Fabric 설정이 문제였습니다.

혹시나 해서 Fabric 쪽 릴리즈 페이지를 찾아보이 3월 15일에 업데이트가 있었네요.

https://docs.fabric.io/android/changelog.html#fabric-gradle-plugin

그 전 버전인 1.27.1 버전으로 수정 후 정상동작을 확인하였습니다.

//classpath 'io.fabric.tools:gradle:1.+'
classpath 'io.fabric.tools:gradle:1.27.1'


어제 구글플레이를 통한 APK 업로드가 무조건 오류가 떨어지는 사례들이 많이 나왔습니다.

국내 카카오톡 오픈채팅방 중 안드로이드 개발자 방에서도 이로 인해 어려움을 호소하는 분들이 많았었죠.

이에 대한 원인은 아직 정확히 못찾아봤는데요. 사람들은 아래 기사의 장애들로 인한 여파로 보는 것 같았습니다.


https://news.naver.com/main/read.nhn?mode=LSD&mid=sec&sid1=105&oid=366&aid=0000429027


어쨋든 배포를 해야하는 개발자들에겐 큰 재앙같은 소식이었죠.

그나마 구글 쪽 문제였다는 걸 공유하고 받은 사람들은 구글 탓이라도 하였겠지만,

그런 소식을 못받은 사람들은 APK 에서 원인을 찾으려 삽질을 했을 수도 있겠죠.


구글에서는 자사 서비스의 상태를 아래 링크를 통해 확인할 수는 있습니다.


https://www.google.com/appsstatus#hl=ko&v=status


다만 구글 플레이는 없는 것 같아 문제는 여전하죠.

그래서 구글에서 직접 고지하는 건 아니지만 이런 장애 소식을 알려주는 곳이 있나 찾아보다가 아래 링크를 찾았습니다.


https://downdetector.com/status/google-play


구글 플레이에 장애 발생시 유저들이 그 장애에 대해 보고하는 사이트 같았습니다.

여기서 그래프 및 댓글들로 구글 플레이에 장애가 있는지 여부를 참고할 수 있을 것 같습니다.

앞으로 의심이 될때 한번씩 찾아봐야겠습니다.



+ Recent posts