쾌락코딩

Android support library(서포트 라이브러리 이해하기)

|

support 라이브러리는 표준 프레임워크 API에서 사용할 수 없었던 손쉬운 개발 및 여러 기기에 걸친 지원을 위한 추가 편의 클래스 및 기능을 제공한다.

서포트 라이브러리 버전에 따라 다르긴 하지만 손 쉽게 Material 디자인을 사용할 수도, RecyclerView를 사용할 수도, 표준에서 제공되는 클래스의 업데이트된 버전을 사용할 수도 있게 해준다.

지원 라이브러리의 용도

가장 중요한 것은, 이전 버전에 대한 하위 호환성문제 해결이다. 예를 들어 안드로이드 버전이 올라가면서 새로운 API가 나왔다면, 그 이전 버전에서도 새로운 API를 사용 할 수 있도록 지원해주는 것이다. 공식 문서에서는 Fragment를 예로 설명한다. Fragment는 API Level 11(Android 3.0)에서 표준 기능으로 새롭게 추가된 뷰인데, API Level 10 이하에서도 Fragment를 사용할 수 있게 도와준다. 서포트 라이브러리가 없었다면, 코드에서 API레벨을 확인하고, API Level 11 이상인 경우와 그렇지 않은 경우를 나누어 줘야 했을 것이다.

또한 표준은 아니지만 RecyclerView같은 편의 클래스들도 지원해준다.

자세한 내용은 공식 문서를 참고하자.

minSdk는 14 이상으로

원래는 support-v4와 support-v7과 같이 v# 표기법 으로 지원하는 최소 API 레벨을 나타냈다고 한다. v4는 API Level 4 이상 기기부터 지원하고, v7는 API Level 7 이상 기기부터 서포트 패키지를 지원했다고 한다. 그러나 support libarary 26.0.0(2017년 7월 버전)부터 지원되는 모든 API 레벨이 최소 Android 4.0(API레벨 14)로 변경되었다. 따라서 현재는 v#에 적혀있는 버전이 큰 의미가 없으며 v4 와 v7라이브러리 패키지는 동일한 최소 API 수준(API Level 14)을 지원한다.

출시 버전

implement 'com.android.support:appcompat-v7:26.1.0', implement 'com.android.support:support-v4:26.1.0' 와 같이 v# 뒤에 출시 버전을 적어준다. implement 'com.android.support:appcompat-v7:26.1.0' 의 경우 이 서포트 라이브러리가 빌드된 API 버전을 나타낸다. 즉 당신이 사용하려고 하는 서포트라이브러리 버전이 26.1.0이라면, 이 라이브러리는 API Level 26에서 빌드해보며 만든 라이브러리이니 참고하라는 것이다. 이 말은 곧 당신이 API Level 26을 타겟으로 앱을 만들고 있다면, 문제없이 사용 가능하다는 것이다. 물론 API Level 26 이하, 14 이상의 기기도 하위 호환성이 어느정도 보장된다는 뜻이다. 다만 27 이상을 타겟으로 할 경우는 새롭게 나온 API 버전에 출시된 모든 기능과 호환될 것이라고 가정하면 안된다. 만약 당신이 가장 최근 API Level인 Pie(API Level 28)을 타겟으로 앱을 만들다면, 서포트 라이브러리 버전을 28.0.0 으로 바꾸는 것을 고려해보아야 할것이다.

참고로 android.support 패키지 형태로 배포하는 버전은 28.0.0이 마지막이다. 28 이상의 API Level부터는 모두 AndroidX 형태로 제공할 것이라고 한다. 새로운 프로젝트라면 androidx를 사용할 것을 권장하고 있고, 심지어 기존에 android.support로 사용했던 패키지도 모두 androidx로 migration할 것을 권장하고있다. 아마 androidx가 Jetpack의 구성요소를 포함하고 있기 때문에 사용을 장려하는 것으로 보인다.

코루틴을 이해하기 위한 발악 1편

|

코루틴이 완전히 처음이라면 코틀린 코루틴 개념익히기 추천!

코루틴은 우리가 흔히 알고 있는 함수의 상위 개념이라고 볼 수 있다. 일반 함수의 경우 caller가 함수를 호출하면 호출당한 함수는 caller에게 어떤 값을 return하고 끝이난다. 그러나 코루틴은 suspend/resume도 가능하다. 즉, caller가 함수를 call하고, 함수가 caller에게 값을 return하면서 종료하는 것 뿐만 아니라 값을 return하지 않고 잠시 멈추었다가 필요할 때에 다시 이어서 resume(재개)할 수도 있다.

코루틴으로 메인쓰레드를 너무 오래 블락시키는 Long running task문제를 해결 할 수 있다. 안드로이드 플랫폼은 메인쓰레드에서 5초 이상 걸리는 긴 작업을 할 경우 앱을 죽여버린다. 그래서 network나 db접근같이 오래걸리는 작업은 모두 다른 스레드에서 작업하고, 그 결과를 받아 ui를 그려주는 것은 다시 Main 스레드로 돌아와서 작업해야한다. 기존에는 이런 작업을 콜백으로 처리했다.

class MyViewModel: ViewModel() {
    fun fetchDocs() {
        get("dev.android.com") { result ->
            show(result)
        }
    }
}

get 함수는 비록 메인 스레드에서 호출되었지만, 네트워크를 타고 데이터베이스에 접근하는 기능은 다른 스레드에서 해야만 한다. 그리고 result정보가 도착하면 콜백 함수는 메인스레드에서 동작해야 한다. 이런 비동기 작업을 코루틴을 이용해서 더 읽기 쉽고 작성하기 편하게 할 수 있다.

  • 참고로 RxJava, RxKotlin 역시 이런 비동기 처리를 손쉽게 처리할 수 있도록 도와준다. 가끔 코루틴이 RxJava를 완전히 대체할 수 있냐고 묻는 사람들이 있는데, 섣불리 대답하기 어려운 질문이다. 비동기 처리 측면에서 본다면 거의 100% 대체할 수 있다고 볼 수 있다고 생각하지만, 만약 프로젝트를 Rx 프로그래밍으로 진행 중이라면 그 패러다임을 다루는 편의성까지 대체할 순 없기 때문이다.

공식 문서 보면서 발악하기

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch { // launch a new coroutine in background and continue
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
        println("World!") // print after delay
    }
    println("Hello,") // main thread continues while coroutine is delayed
    Thread.sleep(2000L) // block main thread for 2 seconds to keep JVM alive
}
// Hello,
// World!

delay는 suspend함수다. suspend란 잠시 중단한다는 의미이고, 잠시 중단한다면 언젠가는 다시 resume된다는 뜻이다. 위 코드에서는 delay라는 suspend가 끝이나면 그때 caller가 resume시켜 아랫줄 코드를 실행시킨다.

delay라는 함수는 현재 실행중인 thread를 block시키진 않지만 코루틴은 일시 중지시킨다. thread입장에서는 non-blocking이다.

문서에서 blockingnon-blocking이 자주 나오는데, 이것은 쓰레드 입장에서 봐야한다. 우선은 쓰레드를 멈춘다면 blocking이고, 쓰레드를 멈추지 않는다면 non-blocking이라고 이해하자.

위 코드에서 GlobalScope라는 것이 보인다. launch라는 코루틴 빌더는 늘 어떤 코루틴 스코프안에서 코루틴을 launch한다. 위에서는 새로운 코루틴을 GlobalScope에서 launch하도록 했다. 이 말은 Global이 의미하는 것 처럼, 새롭게 launch된 코루틴은 해당 어플리케이션 전체의 생명주기에 적용된다는 말이다.

runBlocking

위 코드는 쓰레드를 중단시키지 않는 non-blocking delay(...) 함수와 쓰레드를 잠시 멈추는 blocking 함수 Thread.sleep(...) 를 같이 섞어 쓰고있다. 이렇게 섞어 쓰게되면 무엇이 블락킹 함수이고 무엇이 넌블러킹 함수인지 헷갈릴수 있다. runBlocking 코루틴 빌더를 사용해서 blocking을 조금 더 명확하게 명시해보자.

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch { // launch a new coroutine in background and continue
        delay(1000L)
        println("World!")
    }
    println("Hello,") // main thread continues here immediately
    runBlocking {     // but this expression blocks the main thread
        delay(2000L)  // ... while we delay for 2 seconds to keep JVM alive
    }
}

Thread.sleep(2000L) 이 부분이 runBlocking{ ... } 이렇게 바뀌었다. Blocking을 run(실행, 시작)한다는 뜻의 runBlocking. 이름만 보아도 꽤 명시적이다. runBlocking은 이름이 내포하듯이 현재 쓰레드(여기선 main 쓰레드)를 블록킹 시키고 새로운 코루틴을 실행시킨다.

언제까지 블로킹 시킬까? runBlocking 블록 안에 있는 코드가 모두 실행을 끝마칠 때 까지 블록된다. runBlocking { ... } 안에 2초의 delay를 주었으므로 2초동안 메인쓰레드가 블록된다. 2초의 딜레이가 끝나면 main()함수는 종료된다. 메인쓰레드가 블록되어있는 2초 동안에, 이전에 launch했던 코루틴은 계속해서 동작하고있다.

한편 delay는 suspend 함수이기 때문에 코루틴이 아닌 일반 쓰레드에서는 사용이 불가능한데, runBlocking {...} 블락 안에 delay()가 사용가능한 것으로 보아 runBlocking 역시 새로운 코루틴을 생성하는 것으로 보인다. 동시에 자신이 속한 쓰레드를 블로킹 시키기도 한다.

위 코드를 한 번 더 진화시켜보자.

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> { // start main coroutine
    GlobalScope.launch { // launch a new coroutine in background and continue
        delay(1000L)
        println("World!")
    }
    println("Hello,") // main coroutine continues here immediately
    delay(2000L)      // delaying for 2 seconds to keep JVM alive
}

runBlocking을 메인스레드 전체에 걸어줌으로써 시작부터 메인 쓰레드를 블락시키고 top-level 코루틴을 시작한다. 위에서 설명했듯이 runBlocking은 runBlocking {...} 블록 안에있는 모든 코루틴들이 완료될때 까지 자신이 속한 스레드를 종료시키지 않고 블락시킨다. 따라서 runBlocking에서 가장 오래 걸리는 작업인 delay(2초)가 끝날 때 까지 메인쓰레드는 죽지 않고 살아있다.

그런데 1초의 시간뒤에 “World!”라는 단어를 찍기위하여 2초를 기다리는 일은 별로 좋아보이지 않는다. 예를들어 1초의 시간이 어떠한 디비를 접속해서 데이터를 가져오는 비동기 처리 작업이라고 한다면, 그때 걸리는 시간이 무조건 1초가 걸린다고 가정할 수는 없으므로 2초라는 구체적인 시간동안 스레드를 죽이지 않는 건 좋지 못하다. 디비를 갔다가 오는 시간이 3초가 걸릴수도 있지 않은가. 따라서 우리는 데이터베이스를 갔다가 어떤 응답을 가져오면, 그 즉시 어떤 일을 처리하고 프로그램을 종료시킬 방법이 필요하다. Job을 통해 그런 일이 가능하다.

import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
    val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    job.join() // wait until child coroutine completes
//sampleEnd
}

위의 코드에는 1초의 딜레이 이후 “World!”가 찍히는 것을 보기위해 2초동안 프로그램을 종료시키지 않는 delay(2000L)라는 코드가 없다. 위 코드는 GlobalScope.launch로 생성한 코루틴이 제 기능을 다 완수하는 즉시 프로그램을 종료시킨다. job이라는 변수가 특정 코루틴의 레퍼런스를 가지고 있고, job.join()이 job이 끝나기를 계속 기다리기 때문이다. job이 끝나지 않으면 runBlocking()으로 생성한 코루틴은 끝나지 않는다.

모든 코루틴 빌더(runBlocking {}, launch {} 등등)는 빌더로 인해 생성되는 코드 블록 안에다가 CoroutineScope 객체를 추가한다. 위 코드에서는 runBlocking의 블록 안에서 GlobalScope로 코루틴을 만들어 launch했지만, GlobalScope를 사용하지 않고 runBlocking 이 만든 코루틴 스코프와 같은 스코프로 코루틴을 만들 수 있다(그냥 launch { }를 호출하면 바로 바깥의 스코프와 동일한 스코프에 생긴다). 게다가 바깥에 있는 코루틴은 안쪽에 있는 코루틴이 끝날때 까지 끝나지 않는다는 성질을 이용해서 코드를 더 깔끔하게 만들 수 있다.

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    launch { // launch a new coroutine in the scope of runBlocking
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

suspend & resume

이쯤 되서 다시 suspend와 resume을 정리해보자

  • suspend : 현재의 코루틴을 멈춘다(현재의 코루틴을!).
  • resume : 멈춰있던 코루틴 부분을 다시 시작한다.

suspend와 resume은 콜백을 대체하기 위해 같이 쓰인다.

class MyViewModel: ViewModel() {
    fun fetchDocs() {
        get("dev.android.com") { result ->
            show(result)
        }
    }
}

이 함수에서 콜백을 제거하기 위해 코루틴을 사용해보자.

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.IO
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}
// look at this in the next section
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

suspend 함수(get)가 제 역할(network요청 처리)을 끝내면 메인쓰레드에 콜백으로 알려주는 것이 아니라, 그저 멈춰있던 suspended coroutine 부분을 시작하는 것이다.

코루틴은 스스로 suspend(중단) 할 수 있으며 dispatcher는 코루틴을 resume하는 방법을 알고 있다. 약간 어려운 말이긴 한데, Coroutines on Android (part I): Getting the background라는 글에 애니메이션으로 잘 설명이 되어있으니 참조하자.

자주하는 오해

함수 앞에 suspend를 적어준다고 해서 그것이 함수를 백그라운드 스레드에서 실행시킨다는 뜻은 아니다. 코루틴은 메인 쓰레드 위에서 돈다. 메인스레드에서 하기에는 너무 오래 걸리는 작업을 하기 위해서는 코루틴을 DefaultIO dispatcher에서 관리되도록 해야한다. 코루틴이 메인 스레드 위에서 실행되더라도 꼭 dispatcher에 의해서 동작해야만 한다.

dispatcher

coroutine context는 어떤 쓰레드에서 해당 coroutine을 실행할지에 대한 dispatcher 정보를 담고 있다.

코루틴들이 어디서 실행되는지를 명시하기 위해 코틀린은 3가지 유형의 Dispatchers를 제공한다. 자세한 내용은 CoroutineDispatcher을 클릭해서 확인하자.

결국 개발자가 선택한 Dispatcher에 따라서 실행되는 쓰레드가 달라진다.

마무리

코루틴을 제대로 공부하기로 마음먹었지만 쉽지만은 않은 것 같다. 조금씩 감은 잡히지만 긴가민가 한 내용들이 많다. 아직 제대로 알고 쓰는 글이 아니라 어수선한 글이 되었지만, 2편 3편을 쓰다보면 개념도, 글도 깔끔해지지 않을까 기대해본다 ㅠ.ㅠ

아직까지 코루틴을 공부하기엔 한글 자료가 별로없다. 대부분 영어 자료를 읽고 공부하는 중이라 시간도 꽤 걸리는 편이지만 확실히 영어로 눈을 돌리니 좋은 자료가 많다. 코루틴을 익숙해 질 때까지 공부하면서 이참에 영어에 대한 거부감도 더 줄여나가야겠다.

Android this and that

|

Drawable 가져오기

getString을 제외하고는 Context 에서 ContextCompat으로 바뀌었다.

ContextCompat.getDrawable(context, R.drawable.blabla)

Color 가져오기

ContextCompat.getColor(context, R.color.colorBlabla)

하나의 문자열 마음대로 꾸미기

SpannableString 문자열 하나를 두고서 앞, 중간, 뒤에 이미지를 넣거나 특정 서브 스트링에 스타일링을 할 수 있다.

DataBinding + String resource + Html

resource + html

<string name="my_text"><![CDATA[<font color=#FF7815>안녕하세요.</font>]]>안드로이드 개발자입니다.</string>

layout xml + databinding

<data>
    <import type="androidx.core.text.HtmlCompat" />
</data>
....
<TextView
    ...
     android:text="@{HtmlCompat.fromHtml(@string/receive_this_month_info1, HtmlCompat.FROM_HTML_MODE_COMPACT)}"
    ...
/>
</TextView>

Recycler view

패딩값 설정

android:clipToPadding

  • android:clipToPadding 속성 리사이클러뷰의 크기를 줄이지 않고, 랜더링되기 전에 시작과 끝에 패딩값을 넣어 줄 수 있다.
layoutManager xml에서 관리
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem
tools:listitem="@layout/list_item_garden_planting"

listviewrecycler뷰에서 tools:listitem 속성이 실제 프로덕션에서 영향을 미치진 않는다. 다만 해당 리사이클러뷰의 각 item list의 뷰(레이아웃)을 바로 찾아갈 수 있고, 개발 도중 일종의 미리보기처럼 볼 수 있다.

Drwable Layout

android:fitsSystemWindows = true 옆에서 슬며시 나오는 뷰가 status bar와 키보드 영역을 덮지 않음을 설정하는 부분. true라면 status바를 덮지 않는다.

Databinding

finish Activity
...
<import type="android.app.Activity"/>
...
<ImagaeView
    android:onClick="@{(view) -> ((Activity)(view.getContext())).finish()}" />
...
@string databinding

@string 파일에 아래와 같이 작성

<string name="planted_date" translation_description="식물 심은 날짜 표기">%1$s planted on %2$s</string>

%1$s%2$s는 각각 첫 번째 스트링으로 대체될 부분, 두 번째 스트링으로 대체될 부분을 뜻한다. 이들을 xml에서 databinding으로 적용하는 코드는 아래와 같다.

<TextView
    android:id="@+id/plant_date"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:text="@{@string/planted_date(viewModel.plantName, viewModel.plantDateString)}" />

Tools

tools:layout
<fragment
    ...
    tools:layout="@layout/fragment_view"
    ...
>

디자인 탭에서 액티비티 xml 내에 fragment는 보통 회색으로 보여진다. 그러나 tools:layout을 사용하면 Fragment의 내용도 함께 보여진다.

Material

툴바 좌측에 홈 or 상위 화면으로 이동하는 버튼 노출시키기
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        supportActionBar?.setDisplayHomeAsUpEnabled(true) // <- 이 부분
        ...
}

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        when (item?.itemId) {
            android.R.id.home -> onBackPressed()
        }
        return super.onOptionsItemSelected(item)
    }}

style

statusBar color와 statusBar icon들의 색상 변경

<style name="DarkStatusDialog" parent="ThemeOverlay.AppCompat.Dark">
    <item name="android:statusBarColor">@color/colorStatusBarBackground</item>
    <item name="android:windowLightStatusBar">false</item>
</style>

statusBarColor로 status bar의 배경색을 지정하고, windowLightStatusBar로 밝음 색임을 알려주면 icon 색상이 어둡게 나타난다.

적용할 view Class에서 아래와 같이 적용해줌.

init {
    setStyle(STYLE_NO_TITLE, R.style.DarkStatusDialog)
}

ScrollView

SrollView 내부의 중간 내용물이 적어도, 특정 뷰를 레이아웃 하단에 고정시키고 싶은 경우 키워드 -> fillViewport=true

ImageView

이미지의 가로세로 비율에 따라 크기를 조절해주는 키워드 -> android:adjustViewBounds = true

Glide4

Glide4 에서 이미지 Border 간단하게 처리하기

GlideApp.with(context).load("imageUrl")
            .transform(RoundedCorners(50))
            .into(imageView)

리사이클러뷰와 동적 높이를 가진 이미지 랜더링

아래와 같이 설정하면 채팅과 같이 동적인 비율의 이미지가 많이 랜더링 되는 화면에서 버벅임 없이 비율에 맞게 이미지가 표시된다. (이미지 가로를 고정하고, 높이를 wrap_content로 주었을 땐 심하게 버벅임이 일어난다.)

GlideApp.with(context)
        .load("imageUrl")
        .override(500, 500)
        .into(imageView)
<androidx.appcompat.widget.AppCompatImageView
    android:id="@+id/imageView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:adjustViewBounds="true"
    android:background="@color/transparent"
    android:maxWidth="200dp"
    android:maxHeight="400dp"
/>

AAC LiveData setValue() vs postValue()

|

AAC ViewModel을 사용하면서 구글이 제공하는 LiveData를 많이 사용한다. LiveData는 setValue()postValue()메서드를 제공한다. 두가지 메소드 모두 MutableLiveData의 값을 변경시킬 수 있다. 꼭 이 두가지 메서드로만 데이터를 변경해야 데이터 변화를 감지할 수 있다.

setValue()

말그대로 값을 set하는 함수다. LiveData를 구독하고 있는 옵저버가 있는 상태에서 setValue()를 통해 값을 변경시킨다면 메인 쓰레드에서 그 즉시 값이 반영된다. 중요한 점은 메인 쓰레드에서 값을 dispatch시킨다는 점이다.

postValue()

백그라운드 thread인 상황에서 LiveData 값을 set 하고 싶을 때가 있다. 그럴 때 사용하는 메서드이다. 내부적으로 new Handler(Looper.mainLooper()).post(() -> setValue()) 이런 코드가 실행된다. 즉 setting하고 싶은 값을 main lopper로 보내끼 때문에 결국 메인 쓰레드에서 값을 변경하게 된다. 공식문서에는 아래와 같이 설명되어있다.

If you need set a value from a background thread, you can use postValue(Object) Posts a task to a main thread to set the given value. If you called this method multiple times before a main thread executed a posted task, only the last value would be dispatched.

따라서 postValue()를 한 다음 바로 다음 라인에서 LiveData의 getValue()를 호출한다면, 변경된 값을 받아오지 못할 가능성이 크다. 반면 setValue()로 값을 변경하면 메인쓰레드에서 변경하는 것이기 때문에 바로 다음 라인에서 getValue()로 변경된 값을 읽어올 수 있다.

아래의 코드를 보자.

 liveData.postValue("a");
 liveData.setValue("b");

우선 처음엔 “b”가 설정되고, 이후 메인스레드는 postValue(“a”)로 부터 값을 “a”로 변경시킨다.

또한 옵저버가 없는 필드에서 postValue()를 호출하고 getValue()를 호출한다면 postValue()로 변경한 값을 얻어올 수 없다.

코틀린 const란

|

코틀린에서는 자바와 다르게 파일의 최상위 수준에 프로퍼티를 놓을 수 있다(함수도 가능). app.kt라는 파일의 최상위에 프로퍼티를 만들어 보자.

// app.kt

val CONST_COUNTRY = "KR"

최상위 함수에 val로 프로퍼티를 선언했으므로 다른 파일에서 CONST_COUNTRY 프로퍼티를 상수처럼 사용할 수 있다. 그러나 결국 프로퍼티이기 때문에 getter로 접근해야 한다는 점이 상수스럽지 못하다. 자바에서 상수를 선언할 때 어떻게 했는지 보자.

public static final String CONST_COUNTRY = "KR"

위의 코드처럼 정말 상수처럼 사용하려면 코틀린 코드가 public static final필드로 컴파일 되야한다. const 변경자를 추가하여 프로퍼티를 public static final필드로 컴파일 하게 만들 수 있다. 단, 원시타입과 String 타입의 프로퍼티만 const로 지정할 수 있다.

위의 코드를 만드려면, app.kt를 아래와 같이 하면 된다.

// app.kt

const val CONST_COUNTRY = "KR"