컴포저블 목적지를 그래프로 선언하고 백스택을 한 곳에서 관리한다.
@Serializable data object Home
@Serializable data class Detail(val id: Long) // 타입 안전 경로 (2.8+)
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onItemClick = { navController.navigate(Detail(it)) })
}
composable<Detail> { entry ->
val args: Detail = entry.toRoute()
DetailScreen(id = args.id, onBack = { navController.popBackStack() })
}
}
왜 타입 안전 경로가 나왔나
예전 방식 — 문자열 경로 navController.navigate("detail/${item.id}") composable("detail/{itemId}") { it.arguments?.getLong("itemId") }
문제
- 오타가 런타임에 드러난다
- 인자 타입이 문자열 파싱에 묶인다
- 인자를 추가하면 두 곳을 맞춰 고쳐야 한다
@Serializable 클래스를 경로로 쓰면 컴파일러가 전부 검사한다
인자는 ViewModel에서도 받을 수 있다
@HiltViewModel
class DetailViewModel @Inject constructor(handle: SavedStateHandle) : ViewModel() {
private val args: Detail = handle.toRoute()
val id = args.id
}
Composable 이 인자를 받아 ViewModel 에 넘기지 않아도 된다
- 프로세스 죽음 뒤 복원도 SavedStateHandle 이 함께 처리한다
무엇을 인자로 넘기나
-
넘겨도 되는 것 — id · 짧은 문자열 · enum 넘기면 안 되는 것 객체 전체 · 리스트 · 비트맵
-
**왜 — 경로는 URL 처럼 취급되고 상태 저장 Bundle 에도 들어간다**
- 큰 객체를 넘기면 크기 한도에 걸리고 딥링크로도 표현할 수 없다
-
id 만 넘기고 목적지가 Repository 에서 다시 읽는다 (SSOT)
화면 간 결과 전달
// 결과를 되돌려 줄 때 — 이전 엔트리의 SavedStateHandle 을 쓴다
navController.previousBackStackEntry
?.savedStateHandle?.set("selected", itemId)
navController.popBackStack()
// 받는 쪽
val selected by navController.currentBackStackEntry
?.savedStateHandle?.getStateFlow<Long?>("selected", null)
!!.collectAsStateWithLifecycle()
면접 함정
- ❌
navController를 하위 컴포저블에 그대로 넘긴다 → 미리보기와 테스트가 어려워진다. 콜백(onItemClick)만 넘긴다. - ❌ "navigate를 빠르게 두 번 누르면 화면이 두 개 쌓인다" → 실제로 그렇다.
launchSingleTop이나 클릭 디바운스가 필요하다.