Navigation3 Argument 전달 방법

easyhooon·4일 전
0

Navigation

목록 보기
7/7
post-thumbnail

서두

Navigation3의 Stable version 이 출시가 된 시점에서, Navigation3에서는 Argument를 전달하는 방식이 어떻게 달라졌는지, 기존Navigation2와 큰 차이점이 있는지 확인해보도록 하겠다.

Navigation3가 나오게 된 배경과 기존의 Navigation과의 차이는 이미 잘 정리된 글들이 있어 링크로 대체하도록 하겠다.
Jetpack Navigation3 is Stable
Navigation3 공식 문서
Jetpack Navigation3를 배워보자
Stable 된 Navigation3 사용해보기

이후 Navigation3 를 Nav3 로 줄여 언급

본론

Java Serializavle vs Parcelable vs Kotlinx Serializable

Nav3의 Argument 전달 방식을 이해하려면 먼저 직렬화의 개념과 각 방식의 차이를 알아야 한다.

Java Serializable

Parcelable

Kotlinx Serializable

Nav3는 KMP 지원을 위해 Kotlinx @Serializable을 채택했다. 따라서 개발자는 더 이상 Parcelable 구현을 신경 쓰지 않아도 된다.

그래서 Nav3 도 Bundle 형식으로 데이터 전달하나?

기존에 Nav2는 Bundle 타입으로 Argument 전달했기에, 여러 단점들이 존재하였다.

하지만 Nav3는

ViewModel Argument 처리 방식 변화

skydoves님의 pokedex-compose 레포의 코드를 가져와 설명하도록 하겠다.

Nav2

@HiltViewModel
class DetailsViewModel @Inject constructor(
  detailsRepository: DetailsRepository,
  savedStateHandle: SavedStateHandle,
) : BaseViewModel() {

  internal val uiState: ViewModelStateFlow<DetailsUiState> =
    viewModelStateFlow(DetailsUiState.Loading)

  // savedStateHandle를 통해 argument로 전달받은 pokemon을 조회
  val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
  val pokemonInfo: StateFlow<PokemonInfo?> =
    pokemon.filterNotNull().flatMapLatest { pokemon ->
      detailsRepository.fetchPokemonInfo(
        name = pokemon.nameField.replaceFirstChar { it.lowercase() },
        onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
        onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
      )
    }.stateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5_000),
      initialValue = null,
    )
}

Nav2를 사용할 때는 뷰모델에 savedStateHandle을 생성자 주입, savedStateHandle내에서 전달받은 argument를 꺼내 쓰는 방식으로 주로 구현했었다.

Nav3

@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
  @Assisted private val pokemon: Pokemon,
  private val detailsRepository: DetailsRepository,
) : BaseViewModel() {

  @AssistedFactory
  interface Factory {
    fun create(pokemon: Pokemon): DetailsViewModel
  }

  internal val uiState: ViewModelStateFlow<DetailsUiState> =
    viewModelStateFlow(DetailsUiState.Loading)

  val pokemonInfo: StateFlow<PokemonInfo?> = flow {
    detailsRepository.fetchPokemonInfo(
      // 클래스의 생성자인 pokemon에 접근
      name = pokemon.nameField.replaceFirstChar { it.lowercase() },
      onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
      onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
    ).collect { emit(it) }
  }.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = null,
  )
}

Nav3 방식에서는 전달받는 argument를 직접 뷰모델에 생성자로 주입하는 식으로 구현이 변경되었다!

@AssistedInject?

Hilt 2.49 버전 부터 지원하는 어노테이션으로, 어노테이션을 통해 이 클래스는 생성자에 런타임에 받을 파라미터가 있다는 것을 안내(표시)한다.

Factory가 필요한 이유

@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
    @Assisted private val pokemon: Pokemon,      // ← 런타임 파라미터
    private val detailsRepository: DetailsRepository,  // ← 컴파일타임 DI
) : BaseViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(pokemon: Pokemon): DetailsViewModel
    }
}

Hilt 는 컴파일 타임에 의존성 그래프를 생성하지만, Navigation Argument는 런타임에 결정된다.

한계

1MB 제한은 여전히 존재

Nav3가 아무리 발전된 형태여도 Android 시스템 레벨의 Binder IPC 1MB 제한은 피할 수 없다.

결과

Nav3 라이브러리를 사용할 때 어떻게 argument를 넘기면 되는지, 기존 Nav2와 달라진 차이점과 장점들에 대해 확인해볼 수 있었다.

P.S

@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
  @Assisted private val pokemon: Pokemon,
  private val detailsRepository: DetailsRepository,
) : BaseViewModel() {

  @AssistedFactory
  interface Factory {
    fun create(pokemon: Pokemon): DetailsViewModel
  }
  
  // ...
}

위에서도 언급하였지만, Nav3 가 적용되면서 pokdex-compose 레포의 DetailsViewModel의 변경된 코드인데 Circuit의 Presenter 구조와 매우 유사해졌음을 확인할 수 있었다. 흥미롭...

class OnboardingPresenter @AssistedInject constructor(
    @Assisted private val navigator: Navigator,
    private val repository: UserRepository,
    private val analyticsHelper: AnalyticsHelper,
) : Presenter<OnboardingUiState> {

	@CircuitInject(OnboardingScreen::class, ActivityRetainedComponent::class)
    @AssistedFactory
    fun interface Factory {
        fun create(navigator: Navigator): OnboardingPresenter
    }
    
    // ...
}
@Parcelize
data class BookDetailScreen(
    val userBookId: String,
    val isbn13: String,
) : ReedScreen(name = ScreenNames.BOOK_DETAIL)

@AssistedInject
class BookDetailPresenter(
    @Assisted private val screen: BookDetailScreen,
    @Assisted private val navigator: Navigator,
    private val bookRepository: BookRepository,
    private val recordRepository: RecordRepository,
    private val analyticsHelper: AnalyticsHelper,
) : Presenter<BookDetailUiState> {

    @CircuitInject(BookDetailScreen::class, AppScope::class)
    @AssistedFactory
    fun interface Factory {
        fun create(screen: BookDetailScreen, navigator: Navigator): BookDetailPresenter
    }
    
    // bookRepository.getBookDetail(screen.isbn13)
}

Circuit + Hilt가 적용된 Presenter 의 코드

결국 좋은 패턴들은 수렴한다는 것을 알 수 있었다. 수렴 진화ㄷㄷ

fun interface?

Circuit 공부하느라 계속 봐왔지만 아직도 낯선 문법...

SAM(Single Absract Method) Interface라고도 하며, 기본 interface와 차이점이 있다면 추상 메서드가 딱 1개인 인턴페이스 이다.

fun interface로 선언하면 람다로 인스터스 생성이 가능하다.

// 일반 interface
interface Factory {
    fun create(navigator: Navigator): Presenter
}

// 사용 - 익명 객체 필요
val factory = object : Factory {
    override fun create(navigator: Navigator): Presenter {
        return MyPresenter(navigator)
    }
}

// fun interface (SAM)
fun interface Factory {
    fun create(navigator: Navigator): Presenter
}

// 사용 - 람다로 간단히!
val factory = Factory { navigator -> 
    MyPresenter(navigator) 
}

fun interface 는 당연히 interface로 변환하여 사용이 가능하며, "메서드가 단 하나"라는 의도를 명확히 전달할 때 + 람다 변환 허용을 위해 사용한다고 이해하면 될 듯 하다.

// interface - 이것도 동작함
@AssistedFactory
interface Factory {
    fun create(navigator: Navigator): Presenter
}

// fun interface - 더 명시적
@AssistedFactory
fun interface Factory {
    fun create(navigator: Navigator): Presenter
}

SAM 변환 하면 가장 익숙한 코드를 다들 떠올릴 것이다.

// Before (익명 객체)
button.setOnClickListener(object : View.OnClickListener {
    override fun onClick(v: View) {
        // ...
    }
})

// After (람다) - Android Studio가 노란 전구로 제안 💡
button.setOnClickListener { v ->
    // ...
}

근데 한가지 주의할 것은 Java Interface는 Kotlin에서 자동으로 SAM 변환을 허용하지만, Kotlin interface는 그렇지 않다.

Kotlin이 명시성을 중요시 하는 것 같다. =.=;a

명시적으로 SAM 임을 선언해야(fun interface) 변환된다는 것을 유의하자.

// Kotlin interface는 기본적으로 SAM 변환 안 됨
interface MyCallback {
    fun onResult(data: String)
}

// ❌ 컴파일 에러
doSomething { data -> }

// ✅ 명시적으로 fun interface 선언해야 함
fun interface MyCallback {
    fun onResult(data: String)
}

// 이제 됨
doSomething { data -> }

reference)
https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html
https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html
https://haeti.palms.blog/navigation-3
https://www.youtube.com/watch?v=j8SZsutnl38
https://github.com/skydoves/pokedex-compose
https://github.com/doveletter/dove-letter?tab=readme-ov-file
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation3/?hl=ko
https://developer.android.com/kotlin/parcelize?hl=ko#setup_parcelize_for_kotlin_multiplatform
profile
실력은 고통의 총합이다. Android Developer

4개의 댓글

comment-user-thumbnail
2일 전

오늘도 좋은 글 감사합니다^^

요즘 지훈님 글 보면서 Circuit 도 아장아장 걸어보고 있습니다 😊
혹시 Circuit 이랑 Nav3(혹은 외부 서드파티 네비게이션 라이브러리) 랑 같이 사용할 수 있는 좋은 방법이 있을까용 .. ?!

아무래도 Circuit 은 자체 네비게이션과 같이 묶여있는 개념인것 같아서 분리좀 시켜보고 싶어서요ㅎㅎ
혹쉬 비슷한 경험 있으시면 노하우 부탁드리겠습니다 : )

3개의 답글