2018년 2월 5일 구글은 안드로이드 KTX를 발표했는데 구글의 소개 문장을 인용하면 다음과 같다.

Android KTX provides a nice API layer on top of both Android framework and Support Library to make writing your Kotlin code more natural.

멋진 API 계층을 안드로이드 프레임워크와 지원 라이브러리위에 올려 코틀린 코드를 보다 자연스럽게 쓰도록 하겠다는 것이다.

문자열을 Uri로 변환

구글이 공개한 예제를 먼저 살펴보고 이것이 실제로 어떻게 돌아가는지 실제 코드와 개념을 설명한다. 참고를 위해 코틀린 코드, KTX를 적용한 코틀린 코드, KTX 소스 코드 순으로 인용하며 그때 그때 적절한 설명을 첨가하여 부연한다.

val uri = Uri.parse(myUriString)

코틀린 코드도 길지 않다.

val uri = myUriString.toUri()

코드 블록 자체가 길지 않기 때문에 우와 하는 놀라움은 없는게 솔직한 감상이다.

하지만 Uri 객체의 스태틱 메서드를 참고하지 않고 String에서 Uri로 변환을 한다는게 재밌다. (코딩에서 재미도 포기할 수 없다.) 코틀린 코드를 작업할 때는 스태틱 메서드를 잘 만들지 않기 때문에 이런 확장을 쓰는 것은 컨벤션 통합에서 의미가 있다.

inline fun String.toUri(): Uri = Uri.parse(this)

실제 구현은 간단하다. 기존 클래스의 이름을 적고 .을 이어서 붙이이면 그 클래스의 기능을 확장하겠다는 것이다. 재밌게도 이 개념의 이름도 확장(Extension)이다. 이제 String 클래스는 toUri 메서드를 가지게 된다.

간단한 함수이기 때문에 중괄호로 시작하는 코드 블록대신에 = Uri.parse(this)가 바로 이어진다. 위의 코드는 다음의 코드와 실제로 동일하다.

inline fun String.toUri(): Uri {
  return Uri.parse(this)
}

간단한 함수의 경우에는 코드 블록 대신 표현식(Expression)을 쓰는 함수로 정의하는 것이 훨씬 실용적이다.

코드의 내용은 간단하다. String 클래스에 Uri.parse 스태틱 메서드를 실행한 결과를 반환하는 toUri라는 메서드를 추가하는 것이다.

이 과정에 inline 키워드를 함수 정의에 추가하여 별도의 비용없이 이 확장을 사용할 수 있게 한 것은 덤이다.

개인적인 경험으로는 아래 두 상황에서 확장을 통해 기존 클래스의 기능을 추가하는 것이 유익하였다.

  1. 스태틱 메서드가 있는 경우.
  2. 우리 프로젝트에 공통된 형태로 클래스를 다루는 기능이 있는 경우.

SharedPreferences

다음으로 볼 것은 공유 프리퍼런스다.

sharedPreferences.edit()
  .putBoolean(key, value)
  .apply()

이 코드는 아래의 형식으로 변환된다.

sharedPreferences.edit {
  putBoolean(key, value)
}

메서드 체이닝 대신 람다를 쓴다. apply가 누락될지 걱정할 필요가 없으며 개인적인 소감으로는 메서드 체이닝보다 더 깔끔하다.

덧붙여 자바 코드를 주로 보다가 괄호 없이 중괄호로 바로 시작하는 코틀린 람다를 보니 속이 시원하다. 코틀린은 마지막 인자가 람다인 경우 중괄호로 대체할 수 있다. 물론 인자가 두개 이상이면 람다를 제외한 인자를 위한 괄호는 남겨두어야 한다.

이제 구현을 보자.

inline fun SharedPreferences.edit(action: SharedPreferences.Editor.() -> Unit) {
  val editor = edit()
  action(editor)
  editor.apply()
}

처음으로 등장하는 개념이 있다. 인자 action의 형이 SharedPreferences.Editor.() -> Unit로 지정되어 있다. () -> Unit까지만 보면 인자를 받지 않고 반환하지도 않는 메서드인데 그 앞에 SharedPreferences.Editor.가 붙어있는 것이 묘한 점이다. 이 키워드가 붙어 있으면 마치 람다가 SharedPreferences.Editor에 소속된 듯 작동된다. 확장(Extension)으로 만든 함수가 그 클래스에 소속된 메서드인양 동작한다.

이런 특징을 수신자가 있는 함수 리터럴이라고 부른다. 이름은 조금 어려워 보이지만 SharedPreferences.Editor가 람다의 수신자(Receiver)이며 람다는 이에 속한 멤버 함수처럼 동작한다고 외우면 된다.

따라서 우리가 전달한 람다 action에서 putBoolean(key, value)를 호출할 수 있다. actionthisSharedPreferences.Editor의 레퍼런스가 되기 때문에 이런 식의 사용이 가능하다.

Path 이동하기

조금 더 흥미로운 예제를 살펴보자.

val pathDifference = Path(myPath1).apply {
  op(myPath2, Path.Op.DIFFERENCE)
}

val myPaint = Paint()

canvas.apply {
  val checkpoint = save()
  translate(0F, 100F)
  drawPath(pathDifference, myPaint)
  restoreToCount(checkpoint)
}

KTX의 예제를 볼 차례다.

val pathDifference = myPath1 - myPath2

canvas.withTranslation(y = 100F) {
   drawPath(pathDifference, myPaint)
}

기존의 상태를 저장하고(save) 복구시키는(restoreToCount) 것은 Canvas를 다룰 때 항상 하는 패턴인데 이 패턴을 모두 자동으로 처리한다.

y의 이동에 대해서만 명명된 인자를 통해 지정하고 있다. x만 이동하고 싶다면 (x = 100F) 등의 형태로 지정할 수 있고 둘다 이동하고 싶다면 (x = 100F, y = 100F)(100f, 100f)의 형태로 지정할 수 있다. xy 모두 기본값은 0으로 지정되어 있고 한쪽을 생략하는 형태로 사용할 수 있어 유용하다. (두쪽 다 생략할 수 있긴 하겠지만 그렇다면 withTranslation을 쓰는 의미가 퇴색된다.)

실제 코드의 내용을 살펴보기 전에 Canvas.withSave를 먼저 살펴보자. 이 코드가 Canvas에 대한 KTX 확장의 기본형이다.

inline fun Canvas.withSave(block: Canvas.() -> Unit) {
    val checkpoint = save()
    try {
        block()
    } finally {
        restoreToCount(checkpoint)
    }
}

수신자를 Canvas로 지정하여 람다 사용자의 편의를 돕고 기존 설정을 저장하며 finally를 이용하여 가능한 복구시키도록 한다.

이제 Canvas.withTranslation을 살펴보자.

inline fun Canvas.withTranslation(
    x: Float = 0.0f,
    y: Float = 0.0f,
    block: Canvas.() -> Unit
) {
    val checkpoint = save()
    translate(x, y)
    try {
        block()
    } finally {
        restoreToCount(checkpoint)
    }
}

흥미로운 포인트는 xy값이 기본값 0.0f를 갖는다는 점이다. 이렇게 기본 값이 설정되어 있기 때문에 필요하지 않는 값을 생략할 수 있다. 그리고 마지막 인자가 람다이기 때문에 파라미터 리스트에서 밖으로 빼고 바로 중괄호로 시작하는 코드 블록으로 사용할 수 있다. 가능한 람다는 마지막 인자로 쓰자.

유사한 패턴들이 Cavas에 적용되어 있다.

inline fun Canvas.withRotation(
    degrees: Float = 0.0f,
    pivotX: Float = 0.0f,
    pivotY: Float = 0.0f,
    block: Canvas.() -> Unit
) {
    val checkpoint = save()
    rotate(degrees, pivotX, pivotY)
    try {
        block()
    } finally {
        restoreToCount(checkpoint)
    }
}

개인적으로 이 패턴이 가장 효율적으로 사용되었다고 생각되는 코드가 있는데 View.updatePadding이다.

fun View.updatePadding(
    @Px left: Int = paddingLeft,
    @Px top: Int = paddingTop,
    @Px right: Int = paddingRight,
    @Px bottom: Int = paddingBottom
) {
    setPadding(left, top, right, bottom)
}

안드로이드는 패딩 전체를 가져오고 전체를 설정해야 하기 때문에 사용성이 떨어지는데 View.updatePadding을 쓰면 지정하지 않은 값을 기존의 값으로 사용할 수 있어 편하게 원하는 값으로 갱신할 수 있다.