Android에서 일반적인 Kotlin 패턴 사용

이 주제에서는 Android용 개발 과정에서 Kotlin 언어가 갖는 가장 큰 장점을 집중적으로 다룹니다.

프래그먼트로 작업하기

다음 섹션에서는 Fragment의 예를 사용하여 Kotlin의 가장 뛰어난 기능을 설명합니다.

상속

class 키워드를 사용하여 Kotlin에서 클래스를 선언할 수 있습니다. 다음 예에서 LoginFragmentFragment의 서브클래스입니다. 서브클래스와 상위 요소 사이에 : 연산자를 사용하여 상속을 표시할 수 있습니다.

class LoginFragment : Fragment()

이 클래스 선언에서 LoginFragment는 슈퍼클래스 Fragment의 생성자 호출을 담당합니다.

LoginFragment 내에서 여러 수명 주기 콜백을 재정의하여 Fragment의 상태 변경에 응답할 수 있습니다. 함수를 재정의하려면 다음 예와 같이 override 키워드를 사용합니다.

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
): View? {
    return inflater.inflate(R.layout.login_fragment, container, false)
}

상위 클래스에서 함수를 참조하려면 다음 예와 같이 super 키워드를 사용합니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
}

null 허용 여부 및 초기화

앞서의 예시에서 재정의된 메서드의 매개변수 중에는 물음표 ?가 접미사로 붙은 유형이 있습니다. 이것은 이러한 매개변수에 전달된 인수가 null일 수 있다는 의미입니다. null 허용 여부를 안전하게 처리해야 합니다.

Kotlin에서는 객체를 선언할 때 객체의 속성을 초기화해야 합니다. 그러면 클래스의 인스턴스를 가져올 때 액세스 가능한 속성을 즉시 참조할 수 있습니다. 그러나 FragmentView 객체는 Fragment#onCreateView를 호출하기 전까지는 확장 준비가 되지 않으므로 View의 속성 초기화를 연기할 방법이 필요합니다.

lateinit으로 속성 초기화를 연기할 수 있습니다. lateinit을 사용할 때는 최대한 빨리 속성을 초기화해야 합니다.

다음 예에서는 lateinit을 사용하여 onViewCreated에서 View 객체를 할당하는 방법을 보여줍니다.

class LoginFragment : Fragment() {

    private lateinit var usernameEditText: EditText
    private lateinit var passwordEditText: EditText
    private lateinit var loginButton: Button
    private lateinit var statusTextView: TextView

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        usernameEditText = view.findViewById(R.id.username_edit_text)
        passwordEditText = view.findViewById(R.id.password_edit_text)
        loginButton = view.findViewById(R.id.login_button)
        statusTextView = view.findViewById(R.id.status_text_view)
    }

    ...
}

SAM 변환

OnClickListener 인터페이스를 구현하여 Android에서 클릭 이벤트를 수신 대기할 수 있습니다. Button 객체에는 OnClickListener 구현을 활용하는 setOnClickListener() 함수가 포함되어 있습니다.

OnClickListener에는 반드시 구현해야 하는 단일 추상 메서드 onClick()이 있습니다. setOnClickListener()는 항상 OnClickListener를 인수로 가져오고 OnClickListener에는 항상 동일한 단일 추상 메서드가 있으므로 이 구현은 Kotlin에서 익명 함수를 사용하여 표시될 수 있습니다. 이 프로세스를 단일 추상 메서드(Single Abstract Method) 변환 또는 SAM 변환이라고 합니다.

SAM 변환을 사용하면 코드가 훨씬 깔끔해질 수 있습니다. 다음 예에서는 SAM 변환을 사용하여 ButtonOnClickListener를 구현하는 방법을 보여줍니다.

loginButton.setOnClickListener {
    val authSuccessful: Boolean = viewModel.authenticate(
            usernameEditText.text.toString(),
            passwordEditText.text.toString()
    )
    if (authSuccessful) {
        // Navigate to next screen
    } else {
        statusTextView.text = requireContext().getString(R.string.auth_failed)
    }
}

setOnClickListener()에 전달된 익명 함수 내의 코드는 사용자가 loginButton을 클릭할 때 실행됩니다.

컴패니언 객체

컴패니언 객체는 개념적으로 특정 유형에 연결되지만 특정 객체에 연결되지는 않는 변수나 함수를 정의하는 메커니즘을 제공합니다. 컴패니언 객체는 변수와 메서드에 Java의 static 키워드를 사용하는 것과 유사합니다.

다음 예에서 TAGString 상수입니다. LoginFragment의 각 인스턴스에 String의 고유한 인스턴스가 필요하지 않으므로 동반 객체에서 정의해야 합니다.

class LoginFragment : Fragment() {

    ...

    companion object {
        private const val TAG = "LoginFragment"
    }
}

파일의 최상위 수준에서 TAG를 정의할 수도 있지만, 파일 자체도 최상위 수준에서 정의된 변수, 함수, 클래스를 많이 갖고 있을 수 있습니다. 동반 객체는 클래스의 특정 인스턴스를 참조하지 않고 변수, 함수, 클래스 정의를 연결하는 데 유용합니다.

속성 위임

속성을 초기화할 때 Fragment 내에서 ViewModel에 액세스하는 것과 같이 Android의 더 일반적인 패턴을 반복할 수 있습니다. 과도한 중복 코드를 피하려면 Kotlin의 속성 위임 구문을 사용하면 됩니다.

private val viewModel: LoginViewModel by viewModels()

속성 위임은 앱 전체에서 재사용할 수 있는 일반적인 구현을 제공하고, Android KTX는 일부 속성 위임을 제공합니다. 예를 들어 viewModels는 현재 Fragment로 범위가 지정된 ViewModel을 검색합니다.

속성 위임은 리플렉션을 사용하므로 성능 오버헤드가 추가됩니다. 반면 개발 시간을 절약하는 간결한 구문이 생기는 장점도 있습니다.

null 허용 여부

Kotlin은 앱 전체에서 유형 안전성을 유지하는 엄격한 null 허용 여부 규칙을 제공합니다. Kotlin에서는 객체 참조에 기본적으로 null 값이 포함될 수 없습니다. null 값을 변수에 할당하려면 ?를 기본 유형의 끝에 추가하여 null을 허용하는 변수 유형을 선언해야 합니다.

예를 들어 다음 표현식은 Kotlin에서 허용되지 않습니다. nameString 유형이며 null을 허용하지 않습니다.

val name: String = null

null 값을 허용하려면 다음 예와 같이 null을 허용하는 String 유형인 String?를 사용해야 합니다.

val name: String? = null

상호운용성

Kotlin의 엄격한 규칙으로 코드가 더 안전하고 간결해집니다. 이러한 규칙은 앱의 비정상 종료를 유발할 수 있는 NullPointerException이 발생할 가능성을 낮춥니다. 코드에서 실행해야 하는 null 검사의 횟수도 줄입니다.

Android 앱을 작성할 때 Kotlin이 아닌 코드를 호출해야 하는 경우도 많습니다. Android API가 대부분 자바 프로그래밍 언어로 작성되기 때문입니다.

null 허용 여부는 자바와 Kotlin의 동작이 달라지는 핵심적인 부분입니다. 자바는 null 허용 여부 구문에 대해 상대적으로 덜 엄격합니다.

예를 들어 Account 클래스에는 name이라는 String 속성을 비롯한 몇 가지 속성이 있습니다. 자바에는 null 허용 여부에 대해 Kotlin과 같은 규칙이 없고, 대신 선택적인 null 허용 여부 주석에 의존하여 null 값을 할당할 수 있는지 명시적으로 선언합니다.

Android 프레임워크가 주로 자바로 작성되기 때문에 null 허용 여부 주석 없이 API를 호출할 때 이러한 시나리오가 발생할 수 있습니다.

플랫폼 유형

Kotlin을 사용하여 자바 Account 클래스에서 정의된 주석 처리되지 않은 name 멤버를 참조한다면 컴파일러는 Kotlin에서 StringString에 매핑되는지, 아니면 String?에 매핑되는지 알지 못합니다. 이 모호성은 플랫폼 유형 String!를 통해 표시됩니다.

String!는 Kotlin 컴파일러에 특별한 의미가 없습니다. String!String 또는 String?를 표시할 수 있고 컴파일러로 두 유형 중 하나의 값을 할당할 수 있습니다. 유형을 String으로 표시하고 null 값을 할당하면 NullPointerException이 발생할 위험이 있습니다.

이 문제를 해결하려면 자바에서 코드를 작성할 때마다 null 허용 여부 주석을 사용해야 합니다. 이러한 주석은 자바와 Kotlin 개발자에게 모두 도움이 됩니다.

예를 들어 다음은 자바에서 정의된 Account 클래스입니다.

public class Account implements Parcelable {
    public final String name;
    public final String type;
    private final @Nullable String accessId;

    ...
}

멤버 변수 중 하나인 accessId@Nullable로 주석 처리되어 있어서 null 값을 보유할 수 있다는 것을 표시합니다. 그러면 Kotlin은 accessIdString?로 취급합니다.

변수가 null이 될 수 없다는 것을 표시하려면 @NonNull 주석을 사용하세요.

public class Account implements Parcelable {
    public final @NonNull String name;
    ...
}

이 시나리오에서 name은 Kotlin에서 null을 허용하지 않는 String으로 간주됩니다.

null 허용 여부 주석은 새로운 Android API 전체 및 기존의 여러 Android API에 포함되어 있습니다. 많은 자바 라이브러리에도 null 허용 여부 주석이 추가되어 Kotlin과 자바 개발자 모두에게 도움이 되고 있습니다.

null 허용 여부 처리

자바 유형을 잘 모르는 경우에는 null을 허용하는 것으로 간주해야 합니다. 예를 들어 Account 클래스의 name 멤버는 주석 처리되지 않으므로 null을 허용하는 String으로 가정해야 합니다.

name을 잘라서 그 값의 앞이나 뒤에 공백이 포함되지 않게 하려면 Kotlin의 trim 함수를 사용하면 됩니다. String?를 여러 방법으로 안전하게 자를 수 있습니다. 이러한 방법 중 하나가 다음 예와 같이 null이 아닌 어설션 연산자 !!를 사용하는 것입니다.

val account = Account("name", "type")
val accountName = account.name!!.trim()

!! 연산자는 왼쪽에 있는 모든 것을 null이 아닌 것으로 취급하므로 이 경우에는 name을 null이 아닌 String으로 취급하고 있습니다. 왼쪽 표현식의 결과가 null이면 앱에서 NullPointerException이 발생합니다. 이 연산자는 쉽고 빠르지만 꼭 필요한 경우에만 사용해야 합니다. 코드에 NullPointerException 인스턴스를 다시 발생시킬 수 있기 때문입니다.

더 안전한 방법은 다음 예와 같이 안전 호출 연산자 ?.를 사용하는 것입니다.

val account = Account("name", "type")
val accountName = account.name?.trim()

안전 호출 연산자를 사용하면 name이 null이 아닌 경우 name?.trim()의 결과가 앞이나 뒤에 공백이 없는 이름 값입니다. name이 null이라면 name?.trim()의 결과는 null입니다. 즉, 이 문을 실행할 때 앱이 NullPointerException을 발생시킬 수 없습니다.

안전 호출 연산자를 사용하면 NullPointerException의 발생 가능성을 막을 수 있지만 다음 문에 null 값이 전달됩니다. 대신 다음 예와 같이 Elvis 연산자(?:)를 사용하여 즉시 null 사례를 처리할 수 있습니다.

val account = Account("name", "type")
val accountName = account.name?.trim() ?: "Default name"

Elvis 연산자의 왼쪽에 있는 표현식의 결과가 null이면 오른쪽의 값이 accountName에 할당됩니다. 이 기법은 다른 경우에 null이 될 수 있는 기본값을 제공하는 데 유용합니다.

다음 예와 같이 Elvis 연산자를 사용하여 함수에서 초기에 반환할 수도 있습니다.

fun validateAccount(account: Account?) {
    val accountName = account?.name?.trim() ?: "Default name"

    // account cannot be null beyond this point
    account ?: return

    ...
}

Android API 변경사항

Android API가 점점 더 Kotlin에 적합하게 바뀌고 있습니다. AppCompatActivityFragment를 비롯하여 Android에서 가장 일반적으로 활용되는 대부분의 API에 null 허용 여부 주석이 포함되어 있고 Fragment#getContext와 같은 특정 호출에는 Kotlin에 더욱 적합한 대안이 존재합니다.

예를 들어 FragmentContext에 액세스하면 거의 항상 null이 아닌 값이 반환됩니다. Fragment에서 실행하는 호출의 대부분이 FragmentActivity(Context의 서브클래스)에 연결되어 있는 동안 발생하기 때문입니다. 하지만 Fragment#getContext가 항상 null이 아닌 값을 반환하는 것은 아닙니다. FragmentActivity에 연결되지 않은 시나리오가 있기 때문입니다. 따라서 Fragment#getContext의 반환 유형은 null을 허용합니다.

Fragment#getContext에서 반환된 Context가 null을 허용하고 @Nullable로 주석 처리되므로 Kotlin 코드에서 Context?로 취급해야 합니다. 즉, 앞에서 언급된 연산자 중 하나를 적용하여 속성 및 함수에 액세스하기 전에 null 허용 여부를 해결합니다. 일부 시나리오의 경우 Android에 이러한 편의성을 제공하는 대체 API가 포함되어 있습니다. 예를 들어 Fragment#requireContext는 null이 아닌 Context를 반환하고 Context가 null일 수 있을 때 호출되면 IllegalStateException을 발생시킵니다. 이런 식으로 안전 호출 연산자나 해결 방법을 필요로 하지 않고 결과 Context를 null이 아닌 것으로 취급할 수 있습니다.

속성 초기화

Kotlin의 속성은 기본적으로 초기화되지 않습니다. 인클로징 클래스가 초기화될 때 속성이 초기화되어야 합니다.

여러 가지 방법으로 속성을 초기화할 수 있습니다. 다음 예에서는 클래스 선언에서 값을 할당하여 index 변수를 초기화하는 방법을 보여줍니다.

class LoginFragment : Fragment() {
    val index: Int = 12
}

이 초기화는 초기화 프로그램 블록에서도 정의할 수 있습니다.

class LoginFragment : Fragment() {
    val index: Int

    init {
        index = 12
    }
}

위 예에서 indexLoginFragment가 구성될 때 초기화됩니다.

그러나 객체 구성 중에 초기화할 수 없는 속성이 있을 수 있습니다. 예를 들어 Fragment 내에서 View를 참조하고자 하는 경우가 있습니다. 즉, 레이아웃을 먼저 확장해야 한다는 의미입니다. 확장은 Fragment가 구성될 때 발생하지 않습니다. 대신 Fragment#onCreateView를 호출할 때 확장됩니다.

이 시나리오를 해결하는 한 가지 방법은 다음 예와 같이 뷰를 null을 허용하는 것으로 선언하고 최대한 빨리 초기화하는 것입니다.

class LoginFragment : Fragment() {
    private var statusTextView: TextView? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView?.setText(R.string.auth_failed)
    }
}

다만 이 작업이 예상대로 작동하더라도 이제부터는 참조할 때마다 View의 null 허용 여부를 관리해야 합니다. 더 나은 해결 방법은 다음 예와 같이 View 초기화에 lateinit를 사용하는 것입니다.

class LoginFragment : Fragment() {
    private lateinit var statusTextView: TextView

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView.setText(R.string.auth_failed)
    }
}

lateinit 키워드를 사용하면 객체가 구성될 때 속성을 초기화하는 것을 방지할 수 있습니다. 속성을 초기화하기 전에 참조하면 Kotlin이 UninitializedPropertyAccessException을 발생시키므로 최대한 빨리 속성을 초기화해야 합니다.