[Circuit] Compose Navigation vs Circuit Navigation

이지훈·2024년 12월 24일
2

Circuit

목록 보기
6/9
post-thumbnail

서론

Circuit 은 Decompose, Voyager 같은 Kotlin Multiplatform 을 위한 라이브러리들처럼 자체적인 Navigation 을 제공한다.

이는 각 라이브러리가 개발될 당시, Jetpack Navigation 과 AAC ViewModel 이 Multiplatform 을 지원하지 않았기에, 관련 의존성 없이 화면의 상태 관리, 네비게이션을 지원하기 위한 필요에 의해, 개발된 것으로 추측된다.

또한, Type Safe 를 지원하기 이전의 Compose Navigation 은 그렇게 잘 만들어진 라이브러리가 아니었다는 것에 어느정도 동의하는 부분이 있어, Compose Navigation 의 아쉬운 점들을 해결하기 위해 자체적으로 구현했을 수도 있다.

따라서 이번 글에서는 Circuit 에서 제공하는 Navigation 을 알아보고, 평소에 사용해왔던 Compose Navigation 과 비교해보면서 그 차이점들을 코드 레벨로 확인해보도록 하겠다. 과연 쓸만한지

본론

Navigation 의 핵심적인 기능들에 대해서, Compose Navigation 의 구현 방식을 먼저 소개하고, 이에 대해 Circuit Navigation 에서는 어떤 방식으로 구현 하는지 차례로 알아보도록 하겠다.

백스택 관리

Compose Navigation

  • NavController 에서 제공하는 navigate 함수의 navOptions 통한 백스택 관리
// 기본적인 화면 이동
fun NavController.navigateToBoothDetail(
    boothId: Long,
) {
	// 백스택에 새로운 화면을 추가
    // 사용자가 시스템 백버튼 or 뒤로가기 버튼을 누르면 이전 화면으로 돌아감
    navigate(Route.Booth.BoothDetail(boothId))
}
// Bottom Navigation 에서 탭을 클릭했을 때, 화면 이동
// 같은 탭을 반복적으로 클릭해도 화면이 계속 쌓이지 않음
// 각 탭의 마지막 상태를 유지(보존)
fun navigate(tab: MainTab) {
	val navOptions = navOptions {
    	// 시작 목적지 까지 백스택의 화면들을 제거 
    	popUpTo(navController.graph.findStartDestination().id) {
        	// 이전 화면의 상태를 저장
        	saveState = true
        }
        // 같은 화면이 이미 최상단에 있다면 재생성하지 않음
        launchSingleTop = true
        // 저장된 상태를 복원 
        restoreState = true
    }

	when (tab) {
    	MainTab.HOME -> navController.navigateToHome(navOptions)
        MainTab.WAITING -> navController.navigateToWaiting(navOptions)
        MainTab.MAP -> navController.navigateToMap(navOptions)
        MainTab.STAMP -> navController.navigateToStamp(navOptions)
        MainTab.MENU -> navController.navigateToMenu(navOptions)
    }
}

fun NavController.navigateToHome(navOptions: NavOptions) {
    navigate(MainTabRoute.Home, navOptions)
}
  • popBackStack 함수를 통한 뒤로가기 구현

Circuit Navigation

setContent {
    val backStack = rememberSaveableBackStack(root = HomeScreen)
    val navigator = rememberCircuitNavigator(backStack)
    NavigableCircuitContent(navigator, backStack)
}

@Composable
public fun rememberSaveableBackStack(
  root: Screen,
  init: SaveableBackStack.() -> Unit = {},
): SaveableBackStack =
  rememberSaveable(root, saver = SaveableBackStack.Saver) { SaveableBackStack(root).apply(init) }

SaveableBackStack 의 백스택 관리 방식에 대해선 이전 NavigableCircuitContent 분석 글의 설명을 참고하면 도움이 될 것 같다.

  • pop 함수를 통한 뒤로가기 구현
class InboxPresenter(
  private val navigator: Navigator,
  private val emailRepository: EmailRepository,
) : Presenter<InboxScreen.State> {
  @Composable
  override fun present(): InboxScreen.State {
    val emails by
      produceState<List<Email>>(initialValue = emptyList()) { value = emailRepository.getEmails() }
    return InboxScreen.State(emails) { event ->
      when (event) {
      	// goTo 함수를 통해 이동하려는 화면으로 이동
        is InboxScreen.Event.EmailClicked -> navigator.goTo(DetailScreen(event.emailId))
      }
    }
  }
}
class DetailPresenter(
  private val screen: DetailScreen,
  private val navigator: Navigator,
  private val emailRepository: EmailRepository,
) : Presenter<DetailScreen.State> {
  @Composable
  override fun present(): DetailScreen.State {
    val email = emailRepository.getEmail(screen.emailId)
    return DetailScreen.State(email) { event ->
      when (event) {
        // pop 함수를 통해 뒤로가기 
        DetailScreen.Event.BackClicked -> navigator.pop()
      }
    }
  }
}
  • resetRoot 함수를 통한 root(start destination) 재설정 기능 제공
@CircuitInject(LoginScreen::class)
class LoginPresenter @Inject constructor(
   @Assisted private val navigator: Navigator
) : Presenter<LoginState> {
   @Composable
   override fun present(): LoginState {
       return LoginState(
           ...
       ) { event ->
           when (event) {
               is LoginSuccess -> {
                   // 로그인 성공 시 메인 화면으로 이동하며 백스택 클리어
                   navigator.resetRoot(newRoot = MainScreen)
               }
           }
       }
   }
}

resetRoot 함수의 구현체는 다음과 같으며, navOptions 과 같이 saveState, restoreState 옵션을 통해 화면 상태의 저장 및 복원을 지원한다.(기본값은 false)

// Navigator.kt
@Stable
public interface Navigator : GoToNavigator {
  ...

  public fun resetRoot(
    newRoot: Screen,
    saveState: Boolean = false,
    restoreState: Boolean = false,
  ): ImmutableList<Screen>
  ...
}

public inline fun Navigator.resetRoot(
  newRoot: Screen,
  saveState: (currentRoot: Screen?) -> Boolean = { false },
  restoreState: (currentRoot: Screen?) -> Boolean = { false },
): List<Screen> {
  val root = peekBackStack().lastOrNull()
  return resetRoot(
    newRoot = newRoot,
    saveState = saveState(root),
    restoreState = restoreState(root),
  )
}

Circuit 찍먹해보기글에서도 언급하였지만, Circuit Navigation 을 사용하면, Compose Navigation 을 사용하는 것 보다, 어쩌면 직관적이면서도 더 간단한 방식으로 원하는 플로우를 구현할 수 있다.

화면 간 데이터 전달과 결과 처리

Compose Navigation

     NavHost(
     	navController = navController,
        startDestination = "home",
     ) {
     	composable("home") { entry ->
        	val text = entry.savedStateHandle.get<String>("text")
            Column(
            	modifier = Modifier.fillMaxSize(),
            ) {
            	text?.let {
                	Text(text = text)
                }
                Button(onClick = {
                	navController.navigate("input")
                }) {
                    Text(text = "Go to input")
                }
            }
        }
        composable("input") {
        	Column(
            	modifier = Modifier.fillMaxSize(),
            ) {
            	var text by remember { mutableStateOf("") }
              	OutlinedTextField(
                	value = text,
                    onValueChange = { text = it },
                    modifier = Modifier.width(300.dp)
                )
                Button(onClick = {
                	// popBackStack() 호출시 previousBackStackEntry 의 savedStateHandle 에 접근하여 값 설정 
                	navController.previousBackStackEntry
                    	?.savedStateHandle
                    	?.set("text", text)
                    navController.popBackStack()
                }) {
                	Text(text = "Apply")
                }
            }
        }
    }

또는 다음과 같이 SharedViewModel 을 도입하여, 두 화면간의 공유되는 상태를 관리하는 방법도 존재한다.

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
    navController: NavHostController,
): T {
    val navGraphRoute = destination.parent?.route ?: return hiltViewModel()
    val parentEntry = remember(this) {
        navController.getBackStackEntry(navGraphRoute)
    }
    return hiltViewModel(parentEntry)
}

fun NavGraphBuilder.boothNavGraph(
    padding: PaddingValues,
    navController: NavHostController,
    popBackStack: () -> Unit,
    navigateToBoothLocation: () -> Unit,
    navigateToWaiting: () -> Unit,
) {
    navigation<Route.Booth>(
        startDestination = Route.Booth.BoothDetail::class,
    ) {
        composable<Route.Booth.BoothDetail> { navBackStackEntry ->
            val viewModel = navBackStackEntry.sharedViewModel<BoothViewModel>(navController)
            BoothDetailRoute(
                padding = padding,
                onBackClick = popBackStack,
                navigateToBoothLocation = navigateToBoothLocation,
                navigateToWaiting = navigateToWaiting,
                viewModel = viewModel,
            )
        }
        composable<Route.Booth.BoothLocation> { navBackStackEntry ->
            val viewModel = navBackStackEntry.sharedViewModel<BoothViewModel>(navController)
            BoothLocationRoute(
                onBackClick = popBackStack,
                viewModel = viewModel,
            )
        }
    }
}

Navigation 과 ViewModel 간의 관계에 대해선 아래의 글을 참고해보면 좋을 것 같다.
Compose Navigation Argument를 ViewModel의 SavedStateHandle로 전달받을 수 있는 이유

Circuit Navigation

  • goTo 함수를 통해 화면 전환 시 필요한 데이터를 Screen 객체의 생성자 파라미터로 전달
// Presenter에서 다른 화면으로 네비게이션 할 때 데이터 전달
class HomePresenter @AssistedInject constructor(
    ...
    @Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {

    @Composable
    override fun present(): HomeScreen.State {
        ...
        return HomeScreen.State(
            lectureList = lectureList,
            studentList = studentList,
            studentGradeList = studentGradeList,
        ) { event ->
            when (event) {
                is HomeScreen.Event.OnLectureClick -> navigator.goTo(
                	// Screen 객체를 생성하면서 필요한 데이터를 생성자 파라미터로 전달
                    DetailScreen(
                        lectureName = event.lectureName,
                        studentGradeList = event.studentGradeList,
                        lecture = event.lecture,
                        studentList = event.studentList,
                    ),
                )
            }
        }
    }
    ...
 }
 
@Parcelize
data class DetailScreen(
    val lectureName: String,
    val studentGradeList: List<Int>,
    val lecture: Lecture,
    val studentList: List<Student>,
) : Screen {
    data class State(
        ...
        val eventSink: (Event) -> Unit,
    ) : CircuitUiState

    sealed interface Event : CircuitUiEvent {
        data object OnBackClick : Event
    }
}

객체의 생성자 파라미터로 데이터를 전달하기 때문에, 기본 타입이나 커스텀 data class 타입 모두 별도의 처리 없이 전달이 가능하다.

커스텀 data class 타입을 전달하려 할 때, 특수한 처리가 필요 했던 Compose Navigation 의 방법과는 대조적인 부분이다.
또한 argument 로 URL 형태의 데이터를 전달할 때, 별도의 인코딩 처리 역시 필요하지 않다.

  • Result API 를 통해(PopResult, rememberAnsweringNavigator) 결과 전달 패턴 제공

Activity 의 Result API 와 유사한 형태로, Navigation 으로 이동한 화면에서의 결과 처리를 구현할 수 있다.

// Activity 에서의 결과 처리
val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
    // Handle the returned Uri
}

위의 TextField 관련 예제 코드를 Circuit 으로 migration 하면 다음과 같다.

Home

class HomePresenter @AssistedInject constructor(
    @Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {

    @Composable
    override fun present(): HomeScreen.State {
        var text by remember { mutableStateOf("") }
        val inputNavigator = rememberAnsweringNavigator<InputScreen.Result>(navigator) { result ->
            text = result.text
        }

        return HomeScreen.State(
            text = text,
        ) { event ->
            when (event) {
                is HomeScreen.Event.NavigateToInput -> inputNavigator.goTo(InputScreen)
            }
        }
    }
    ...
}

@Parcelize
data object HomeScreen : Screen {
    data class State(
        val text: String = "",
        val eventSink: (Event) -> Unit,
    ) : CircuitUiState

    sealed interface Event : CircuitUiEvent {
        data object NavigateToInput : Event
    }
}

@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@Composable
fun Home(
    state: HomeScreen.State,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.fillMaxSize(),
    ) {
        if (state.text.isNotEmpty()) {
            Text(text = state.text)
        }
        Button(
            onClick = {
                state.eventSink(HomeScreen.Event.NavigateToInput)
            },
        ) {
            Text(text = "Go to input")
        }
    }
}

Input

class InputPresenter @AssistedInject constructor(
    @Assisted private val navigator: Navigator,
) : Presenter<InputScreen.State> {

    @Composable
    override fun present(): InputScreen.State {
        var text by remember { mutableStateOf("") }
        return InputScreen.State(
            text = text,
        ) { event ->
            when (event) {
                is InputScreen.Event.OnTextChanged -> {
                    text = event.text
                }
                is InputScreen.Event.OnApplyClicked -> {
                    navigator.pop(InputScreen.Result(text))
                }
            }
        }
    }
	...
}

@Parcelize
data object InputScreen : Screen {
    data class State(
        val text: String = "",
        val eventSink: (Event) -> Unit,
    ) : CircuitUiState

    @Parcelize
    data class Result(val text: String) : PopResult

    sealed interface Event : CircuitUiEvent {
        data class OnTextChanged(val text: String) : Event
        data object OnApplyClicked : Event
    }
}

@CircuitInject(InputScreen::class, ActivityRetainedComponent::class)
@Composable
fun Input(
    state: InputScreen.State,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier.fillMaxSize(),
    ) {
        OutlinedTextField(
            value = state.text,
            onValueChange = {
                state.eventSink(InputScreen.Event.OnTextChanged(it))
            },
            modifier = Modifier.width(300.dp),
        )
        Button(
            onClick = {
                state.eventSink(InputScreen.Event.OnApplyClicked)
            },
        ) {
            Text(text = "Apply")
        }
    }
}

전체 코드는 아래의 레포에서 확인할 수 있다.
https://github.com/easyhooon/CircuitNavigationResult

상태 관리

Compose Navigation

  • SavedStateHandle 을 통한 상태 저장 및 복원
    • Bundle 에 저장 가능한 타입(ex. Parcelable)에 대해 configuration change 와 프로세스 종료로 부터 상태 유지
    • BackstackEntry 마다 자체 savedStateHandle 을 가져 화면 간 데이터 전달에 활용
    • savedStateHandle 의 데이터는 해당 BackstackEntry 가 백스택에서 제거될 때 까지 유지

Circuit Navigation

  • SaveableBackStack 을 통해 Configuration changes 발생 시, Screen 들의 순서와 각 Screen이 가진 arguments 를 자동으로 저장하고 복원

중첩 네비게이션

Compose Navigation

  • navigation() 블록을 통한 중첩 네비게이션 그래프 구성
NavHost(navController, startDestination = "home") {
    // 최상위 레벨 화면들
    composable("home") { HomeScreen() }
    
    // 중첩된 네비게이션 그래프
    navigation(startDestination = "list", route = "section") {
        composable("list") { ListScreen() }
        composable("detail/{id}") { DetailScreen() }
    }
}

Circuit Navigation

  • CircuitContent 를 통한 중첩된 화면 구조 구성
  • 자식 컴포넌트의 Navigation 을 부모 컴포넌트가 제어
  • CircuitContent 의 onNavEvent 를 통한 이벤트 위임
@Composable 
fun NestedPresenter(navigator: Navigator): NestedState {
  // These are forwarded up!
  navigator.goTo(AnotherScreen)
  ...
}

@Composable 
fun ParentUi(state: ParentState, modifier: Modifier = Modifier) {
  // 부모 화면에서 CircuitContent 사용
  CircuitContent(
  	NestedScreen, 
  	modifier = modifier, 
  	onNavEvent = { navEvent -> 
        // 자식의 네비게이션 이벤트를 부모의 eventSink로 전달
  		state.eventSink(NestedNav(navEvent))
  	}
  )
}

@Composable 
fun ParentPresenter(navigator: Navigator): ParentState {
  return ParentState(...) { event ->
    when (event) {
      // 자식으로부터 전달받은 네비게이션 이벤트 처리
      is NestedNav -> navigator.onNavEvent(event.navEvent)
    }
  }
}

CircuitContent 를 통해 자식 컴포넌트에게 Navigator 를 제공하고, 이를 통해 발생하는 Navigation 이벤트를 부모 컴포넌트가 처리한다.

더욱 자세한 설명은 이전 CircuitContent 분석 글을 참고하면 도움이 될 것 같다.

ETC

Shared Element Transition

그밖에 Compose Navigation 에서 Compose 1.7.0 버전부터 실험적으로 사용할 수 있는 Shared Element Transition 도 Circuit 에서 지원하는데 공식 문서에서 언급은 따로 없지만, 구현 코드는 아래 Circuit Sample 예제인 Star 레포를 참고하면 좋을 듯 하다.
HomeScreen in Star

@OptIn(ExperimentalSharedTransitionApi::class)
@CircuitInject(screen = HomeScreen::class, scope = AppScope::class)
@Composable
fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) =
  SharedElementTransitionScope {
    var contentComposed by rememberRetained { mutableStateOf(false) }
    Scaffold(
      modifier = modifier.fillMaxWidth(),
      contentWindowInsets = WindowInsets(0, 0, 0, 0),
      containerColor = Color.Transparent,
      bottomBar = {
        val scope = requireAnimatedScope(Navigation)
        val isInOverlay =
          isTransitionActive && scope.transition.targetState == EnterExitState.Visible
        val fraction by
          remember(scope) {
            derivedStateOf {
              val progress = scope.progress().value / .8f
              EaseInOutCubic.transform(progress.coerceIn(0f, 1f))
            }
          }
        StarTheme(useDarkTheme = true) {
          Layout(
            modifier = Modifier,
            measurePolicy = { measurables, constraints ->
              val placeable = measurables.first().measure(constraints)
              if (isInOverlay) {
                // Slide in the bottom bar
                val height = (placeable.height * fraction).roundToInt()
                layout(placeable.width, height) { placeable.place(IntOffset.Zero) }
              } else {
                layout(placeable.width, placeable.height) { placeable.place(IntOffset.Zero) }
              }
            },
            content = {
              BottomNavigationBar(
                selectedIndex = state.selectedIndex,
                onSelectedIndex = { index -> state.eventSink(ClickNavItem(index)) },
                modifier =
                  Modifier.renderInSharedTransitionScopeOverlay(
                    renderInOverlay = { isInOverlay },
                    zIndexInOverlay = 1f,
                  ),
              )
            },
          )
        }
      },
    ) { paddingValues ->
      contentComposed = true
      val screen = state.navItems[state.selectedIndex].screen
      CircuitContent(
        screen,
        modifier = Modifier.padding(paddingValues),
        onNavEvent = { event -> state.eventSink(ChildNav(event)) },
      )
    }
    Platform.ReportDrawnWhen { contentComposed }
  }

Predictive back gesture

Predictive back gesture 를 통한 뒤로가기의 경우 circuitx-gesture-navigation 의존성을 추가하면 사용할 수 있다.

dependencies {
  implementation("com.slack.circuit:circuitx-gesture-navigation:<version>")
}
NavigableCircuitContent(
  navigator = navigator,
  backStack = backstack,
  decoration = GestureNavigationDecoration(
    // Pop the back stack once the user has gone 'back'
    navigator::pop
  )
)

결론

Circuit Navigation 의 주요 특징을 정리해보면 다음과 같다.

백스택 관리

  • SaveableBackStack 을 통해 화면 백스택을 관리
  • resetRoot 함수를 통해 Root 재설정 및 화면의 상태 저장, 복원 지원
  • goTo, pop 등 직관적이고 간단한 네비게이션 인터페이스 제공

화면 간 데이터 전달 및 결과 처리

  • Screen 객체의 생성자 파라미터를 통해 어떤 타입의 데이터든 쉽게 전달 가능
  • Result API 를 통한 Activity Result API 와 유사한 방식으로 화면 간 결과 처리

상태 관리

  • SaveableBackStack 을 통해 Configuration changes 발생 시, Screen 들의 순서와 각 Screen 이 가진 arguments 를 자동으로 저장하고 복원

중첩 네비게이션

  • CircuitContent 를 통해 자식 컴포넌트(중첩된 화면)의 네비게이션을 부모 컴포넌트에서 제어

소감

Circuit Navigation 은 기존의 Compose Navigation 이 가지고 있던 고질적인 문제점을 해결하고,
Compose Navigation 이 지원하지 않는, 구현하려면 다소 복잡한 구현을 요구하는 기능들을 간편한 함수를 통해 지원하는 것을 확인할 수 있었다.

Circuit 은 AAC ViewModel 과 Compose Navigation 을 둘다 사용하지 않는 방식을 택하였기에, migration 비용이 매우 높은 편이다.

하지만, 자체적으로 제공하는 Navigation 이 이정도로 높은 퀄리티라면, Compose Navigation 을 사용하지 못하는 것은 단점이 되지 않는다고 생각한다.
하지만 ViewModel 못 쓰는 건 조금 큰 단점인 것 같다...

더욱이, 기존의 프로젝트와의 상호운용을 위한 지원에 있어서도, Navigation 관련하여 다양한 함수들을 제공하는 있는 것으로 알고 있는데, 이는 추후에 알아보도록 하겠다.

더 나아가 Circuit Navigation 활용

Bottom Navigation 과 연동

Bottom Navigation 과 연동하여 Circuit Navigation 을 사용하려면 어떤식으로 구현하면 될까?

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var circuit: Circuit

    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent {
            CircuitCompositionLocals(circuit) {
                val backstack = rememberSaveableBackStack(HomeScreen)
                val navigator = rememberCircuitNavigator(backstack)

                MaterialTheme {
                    Scaffold(
                        modifier = Modifier.fillMaxSize(),
                        bottomBar = {
                            NavigationBar(modifier = Modifier.fillMaxWidth()) {
                                NavigationBarItem(
                                    modifier = Modifier.weight(1f),
                                    selected = backstack.topRecord?.screen is HomeScreen,
                                    icon = {
                                        Icon(
                                            painterResource(R.drawable.ic_home),
                                            contentDescription = "Home Icon",
                                        )
                                    },
                                    label = { Text("Home") },
                                    onClick = { navigator.popUntilOrGoTo(HomeScreen) },
                                )
                                NavigationBarItem(
                                    modifier = Modifier.weight(1f),
                                    selected = backstack.topRecord?.screen is FavoriteScreen,
                                    icon = {
                                        Icon(
                                            painterResource(R.drawable.ic_favorite),
                                            contentDescription = "Favorite",
                                        )
                                    },
                                    label = { Text("Favorites") },
                                    onClick = { navigator.popUntilOrGoTo(FavoriteScreen) },
                                )
                            }
                        },
                    ) { innerPadding ->
                        ContentWithOverlays {
                            NavigableCircuitContent(
                                navigator = navigator,
                                backStack = backstack,
                                modifier = Modifier
                                    .fillMaxSize()
                                    .padding(innerPadding),
                            )
                        }
                    }
                }
            }
        }
    }
}

탭을 이동할 때마다, 백스택에 같은 화면이 중복해서 쌓이지 않기 위해 다음과 같은 함수를 구현해보았다.

fun Navigator.popUntilOrGoTo(screen: Screen) {
    if (screen in peekBackStack()) {
        popUntil { it == screen }
    } else {
        goTo(screen)
    }
}

기존에 Bottom Navigation 을 구현할 때처럼, 익숙한 Material3 Component 들을 사용하여 구현할 수 있었다.

이를 구현해보면서 이전에 품었던 의문 중 하나를 해결할 수 있었다. Scaffold 의 content slot 에 NavigableCircuitContent 를 구성하면, Scaffold 의 padding 을 기존처럼 모든 화면에 적용할 수 있어, 각각의 화면들을 Scaffold 로 구현하지 않아도 된다!

그래서 Circuit Navigation 은 좋은 점만 있을까?

그렇지 않다!

이전 글에서도 언급했었지만, Circuit Navigation 의 goTo 함수는 단점이 존재한다.


각각의 화면이 같은 모듈에 존재한다면 상관없겠지만,goTo 함수를 통해 화면을 이동하기 위해선 이동하려는 화면(Screen)이 존재하는 모듈의 의존성을 가지고 있어야 한다는 것이다. 이는 feature 모듈간의 불필요한 의존성을 유발하고, 모듈 간 순환 참조를 발생시킬 수 있다.

잠깐, 여기서 짚고 넘어가야할 점은, Circuit 에서 Screen 과 UI 는 분리되어 있는 개념이라는 것이다.

Screen

  • Presenter 와 UI 를 연결하는 Key 이자, 하나의 화면 단위
  • 해당 화면의 State 와 Event 를 정의
@Parcelize
data object CounterScreen : Screen {
  	data class CounterState(
    	val count: Int,
    	val eventSink: (CounterEvent) -> Unit,
  	) : CircuitUiState
  	sealed interface CounterEvent : CircuitUiEvent {
    	data object Increment : CounterEvent
    	data object Decrement : CounterEvent
  	}
}

UI

  • Screen 의 State 를 받아서 실제 UI 를 구성하는 Composable 함수
  • 사용자 이벤트를 State 의 eventSink 를 통해 Presenter 로 전달
@CircuitInject(CounterScreen::class, AppScope::class)
@Composable
fun Counter(state: CounterState) {
  	Box(Modifier.fillMaxSize()) {
    	Column(Modifier.align(Alignment.Center)) {
      		Text(
       	  		modifier = Modifier.align(CenterHorizontally),
          		text = "Count: ${state.count}",
          		style = MaterialTheme.typography.displayLarge
      		)
      		Spacer(modifier = Modifier.height(16.dp))
      		Button(
        		modifier = Modifier.align(CenterHorizontally),
        		onClick = { state.eventSink(CounterEvent.Increment) }
      		) { Icon(rememberVectorPainter(Icons.Filled.Add), "Increment") }
      		Button(
        		modifier = Modifier.align(CenterHorizontally),
        		onClick = { state.eventSink(CounterEvent.Decrement) }
        	) { Icon(rememberVectorPainter(Icons.Filled.Remove), "Decrement") }
      	}
  	}
}

Circuit 을 설명할 때, 가장 처음 소개해야 할 핵심 개념인데, 마땅한 설명 기회를 찾지 못해, 맥락은 다르지만 여기서 언급을 해보았다.

아무튼, 이러한 feature 모듈 간 불필요한 의존성이 유발되는 단점을 이전에 feature 모듈간의 순환 참조를 해결했던 방식 처럼, Navigator 를 interface 화(추상화)하여, 각 feature 모듈 간의 의존성을 제거하고, 하나의 navigation 을 위한 모듈만을 바라볼 수 있도록 해결해 볼 수 있을 것 같다.

하지만 이는 입코딩에 불과하고 아직 직접 구현해본 것은 아니라, 정말 해결이 가능한지 확인을 해봐야 할 것 같다. TODO 추가 1

또한, Navigation 을 통한 deeplink 구현 관련 언급이 Circuit 공식 문서에 존재하지 않는데, 어떻게 구현할 수 있는지도 추후에 알아보도록 해야겠다. TODO 추가 2


easy deeplinking 이라고만 언급이 되어있고, 왜 easy 인지 추가적인 설명이나 코드는 확인할 수 없었다...

레퍼런스)
https://slackhq.github.io/circuit/navigation/
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957
https://medium.com/@desilio/navigate-back-with-result-with-jetpack-compose-e91e6a6847c9
https://youtu.be/NhoV78E6yWo
https://github.com/philipplackner/NavigateBackWithResult/blob/master/app/src/main/java/com/plcoding/navigatebackwithresult/MainActivity.kt
https://voyager.adriel.cafe/
https://arkivanov.github.io/Decompose/
https://youtu.be/h61Wqy3qcKg?si=OqctoATR5MGbypOW
https://github.com/jisungbin/dog-browser-circuit
https://slackhq.github.io/circuit/circuitx/#gesture-navigation

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글