Compose Type-Safe Navigation에서 Custom NavType 정의하기

leeeha·2024년 11월 24일

Trouble Shooting

목록 보기
4/5
post-thumbnail

LoveMarker 프로젝트를 개발하며 발생했던 이슈들을 기록합니다!

💥 어떤 문제가 발생했나요?

SearchScreen → ContentScreen 화면 전환 시 SearchPlace 타입의 데이터를 전달하기 위해 이 블로그 링크를 참고하여 Custom NavType을 정의해주었다. 그런데, 어째서인지 ContentScreen에서 계속 null이 반환되는 이슈가 발생했다. (컴파일이나 런타임 예외는 발생하지 않았다. 그래서 디버깅이 더 어려웠다.)

package com.capstone.lovemarker.core.model

import kotlinx.serialization.Serializable

@Serializable
data class SearchPlace(
    val name: String,
    val address: String,
    val latitude: Double,
    val longitude: Double
)
package com.capstone.lovemarker.core.navigation

import android.os.Bundle
import androidx.navigation.NavType
import com.capstone.lovemarker.core.model.SearchPlace
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

val SearchPlaceNavType = object : NavType<SearchPlace?>(isNullableAllowed = true) {
    override fun get(bundle: Bundle, key: String): SearchPlace? {
        return bundle.getString(key)?.let { Json.decodeFromString(it) }
    }

    override fun parseValue(value: String): SearchPlace? {
        return Json.decodeFromString(value)
    }

    override fun put(bundle: Bundle, key: String, value: SearchPlace?) {
        bundle.putString(key, Json.encodeToString(value))
    }
}

참고로, SearchPlace 타입을 nullable로 설정한 이유는 PhotoScreen에서 ContentScreen으로 전환할 때는 SearchPlace 타입이 null이기 때문이다.

🤔 어떻게 해결했나요?

참고 링크: https://everyday-develop-myself.tistory.com/361#article-3--복잡한-인수를-전달하기

Custom NavType을 정의하는 코드에서 문제가 발생한 거 같아서, 다른 분의 코드를 참고하여 아래와 같이 구현하니까 ContentScreen에서 정상적으로 데이터가 반환되었다!! (거의 2~3시간을 삽질했다…)

inline fun <reified T : Any> serializableType(
    isNullableAllowed: Boolean = false,
    json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
    override fun get(bundle: Bundle, key: String) =
        bundle.getString(key)?.let<String, T>(json::decodeFromString)

    override fun parseValue(value: String): T = json.decodeFromString(value)

    override fun put(bundle: Bundle, key: String, value: T) {
        bundle.putString(key, json.encodeToString(value))
    }
    
    override fun serializeAsValue(value: T): String = json.encodeToString(value)
}
composable<UploadRoute.Content>(
        typeMap = mapOf(
            typeOf<SearchPlace?>() to serializableType<SearchPlace?>(isNullableAllowed = true)
        )
    ) { backStackEntry ->
        val content = backStackEntry.toRoute<UploadRoute.Content>()
        
        ContentRoute(
            navigateUp = navigateUp,
            searchPlace = content.searchPlace,
            // ... 
        )
    }

🤷‍♀️ 왜 발생했나요?

처음 작성했던 코드에서는 serializeAsValue 메서드를 오버라이딩 하지 않았다.

이로 인해 SearchPlace 객체의 직렬화 과정이 제대로 수행되지 않아서, 역직렬화 했을 때도 null이 반환되었던 것이다!

NavType 추상 클래스를 상속 받을 때, serializeAsValue추상 메서드가 아니어서 오버라이딩을 하지 않아도 컴파일 에러가 발생하지 않았다.

그런데, NavType 추상 클래스에 정의된 serializeAsValue 메서드의 기본 구현을 살펴보면 다음과 같다.

/**
 * Serialize a value of this NavType into a String.
 *
 * By default it returns value of [kotlin.toString] or null if value passed in is null.
 *
 * This method can be override for custom serialization implementation on types such custom
 * NavType classes. 
 *
 * Note: Final output should be encoded with [Uri.encode]
 *
 * @param value a value representing this NavType to be serialized into a String
 * @return encoded and serialized String value of [value]
 */
public open fun serializeAsValue(value: T): String {
    return value.toString()
}

즉, 오버라이딩을 하지 않으면 단순히 toString()의 실행 결과를 반환하기 때문에, 커스텀 NavType 객체에 대한 직렬화를 수행하려면 반드시 오버라이딩을 해줘야 한다!!

이런 실수가 발생했던 근본적인 이유는, 구글링 했을 때 나오는 코드의 종류가 2가지였기 때문이다.

  1. kotlinx.serialization + @Serializable → serializeAsValue 오버라이딩 필요
  2. Gson + @Parcelize → serializeAsValue 오버라이딩 불필요

@Parcelize을 사용하는 예시 코드는 아래 링크에서 확인 가능하다.

@Parcelize
data class Device(val id: String, val name: String) : Parcelable
class AssetParamType : NavType<Device>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Device? {
        return bundle.getParcelable(key)
    }

    override fun parseValue(value: String): Device {
        return Gson().fromJson(value, Device::class.java)
    }

    override fun put(bundle: Bundle, key: String, value: Device) {
        bundle.putParcelable(key, value)
    }
}

왜 이런 차이가 발생하는 것일까?

우선, @Parcelize는 kotlin-parcelize 플러그인에서 지원하는 어노테이션으로, Parcelable 객체를 쉽게 생성하여 액티비티나 프래그먼트 간의 데이터 전송에 사용된다.

안드로이드 플랫폼에 종속적인 플러그인이지만, 그만큼 안드로이드 메모리 관리에 최적화 되어 있고 속도가 빠르다는 장점이 있다.

커스텀 NavType을 정의할 때는, 안드로이드 기본 직렬화 시스템을 사용하므로 serializeAsValue 메서드를 오버라이딩 하지 않아도 된다고 한다.

반면에, @Serializable은 kotlinx.serialization 라이브러리에서 지원하는 어노테이션으로, JSON, XML, ProtoBuf 등 다양한 데이터 포맷의 직렬화/역직렬화를 지원한다.

안드로이드 플랫폼에 독립적인 라이브러리여서, JVM, JS, Native 같은 다양한 플랫폼에서도 사용 가능하다.

단, 커스텀 NavType을 정의할 때는 serializeAsValue 메서드 오버라이딩을 통한 명시적인 직렬화 로직이 필요하다.

참고로, 현재 프로젝트에서는 kotlinx.serialization 라이브러리를 사용하고 있어서, @Parcelize 대신에 @Serializable 어노테이션을 적용했다.

kotlinx.serialization은 기존에 자주 사용되던 Gson 라이브러리의 여러 문제점들을 해결해준다.

  • Gson은 데이터 클래스의 속성에 디폴트 값을 지정해도 제대로 적용되지 않는다.
  • Gson은 Java 기반이어서 코틀린처럼 강력한 type check를 하지 않기 때문에, non-null 타입에 null이 들어가는 문제가 발생할 수 있다.

kotlinx.serialization을 사용하면 이런 문제들을 해결할 수 있다!

🙏 오늘의 교훈

왜 null이 반환되는지 구체적인 원인 파악에 더 집중했다면, 조금 더 문제를 빨리 해결하지 않았을까 라는 생각이 든다.

현상이 사라졌다고 해서 그냥 넘어가지 말고, 오늘처럼 꼭! 문제 원인부터 해결 과정까지 기록을 남겨두자! 시간은 오래 걸리더라도, 장기적으로 봤을 때 더 성장할 수 있을 것이다!

📌 혹시 제가 원인 파악을 잘못했거나, 설명이 틀린 부분이 있다면 꼭 댓글 남겨주시길 바랍니다! 감사합니다 :)

profile
습관이 될 때까지 📝

0개의 댓글