본문 바로가기
안드로이드

[Android] 컴포즈 내비게이션 딥 다이브 (under the hood of Compose Navigation)

by algosketch 2024. 5. 16.

머리말

이 글에서는 Compose Navigation의 내부 구조에 대해서 상세하게 다룬다. Compose Navigation 2.7.6 버전을 기준으로 작성되었으며, 최근에 추가된 2.8.0의 safe args에 대해서는 다루지 않는다. 이에 대해서는 나중에 분석하여 별개의 포스트로 업로드할 예정이다.

Navigation 관련 함수와 클래스에 대해 설명하려면 필연적으로 엮여 있는 클래스도 함께 설명해야 한다. 각 클래스에 대한 모든 내용을 한 번에 설명하면 오히려 이해하는데 어렵다고 생각하여, 클래스보다는 하나의 기능 단위로 엮여 있는 클래스들을 필요한 부분만 발췌하여 설명할 예정이다. 최대한 자연스럽게 읽을 수 있도록 순서를 배치했다. 먼저 주요 클래스에 대해 간단하게 소개하고, 이후 자세하게 설명할 예정이다.

보통의 문서보다는 자세하게 설명하겠지만, 모든 코드를 첨부하기엔 양이 많고 depth가 깊기 때문에 일부 코드만 가져올 수밖에 없었다. 따라서 로직에 대한 완전한 이해를 원한다면, 이 글의 내용을 참고하여 실제 코드를 살펴보기를 바란다.

실제 라이브러리 코드는 애니메이션 처리를 포함하여 굉장히 복잡하게 구성되어 있다. 아래 리포지토리는 라이브러리에서 핵심 로직만 남겨 내비게이션을 구현했다.

https://github.com/HamBP/algo-navigation

 

GitHub - HamBP/algo-navigation: Compose Navigation을 만들어 보자

Compose Navigation을 만들어 보자. Contribute to HamBP/algo-navigation development by creating an account on GitHub.

github.com

 

 

내비게이션의 핵심 컨셉

Compose Navigation의 핵심 컨셉에는 다음과 같이 두 가지가 있다.

  • Graph
  • Back Stack

Graph는 트리 구조로 이루어지며, 이 내비게이션이 구성하는 전체 화면 구조를 나타낸다. Back Stack은 이미 익숙한 단어일 거라 생각이 든다. 화면이 쌓여 있는 스택을 나타내며, top 부분에 해당하는 NavBackStackEntry를 화면에 출력한다.

 

주요 클래스에 대한 간단한 설명

NavDestination

내비게이션 그래프의 각 노드를 구성하는 클래스이다. ComposeNavigator.Destination, NavGraph, DialogNavigator.Destination 세 가지 하위 클래스가 기본적으로 제공된다. 아래 그림은 트리의 예시이며, 각 노드들은 NavDestination을 상속받은 클래스의 객체이다.

NavHost, composable, navigation, dialog

컴포저블 함수나 확장 함수를 통해 쉽게 트리를 구성할 수 있다. NavGraph는 NavHost 또는 navigation 확장 함수를 통해 만들어지며, ComposeNavigator.Destination은 composable 확장 함수를 통해 만들어진다. (중첩 클래스라 이름이 너무 길다...) 그래프의 루트는 NavGraph가 되며, NavGraph는 자식 노드를 가질 수 있다.

NavHost(
    navController = navController,
    startDestination = "home",
) {
    composable(route = "home") {
        // ...
    }
    
    navigation(
        route = "detail",
        startDestination = "summary",
    ) {
        composable(route = "summary") { entry ->
            // ...
        }
    }
    
    // ...
}

이때 NavHost는 그래프의 루트를 구성하는데 사용되며, navigation은 중첩 그래프를 표현하는데 사용된다. 추가로 NavHost는 NavController에 완성된 그래프를 등록하는 역할도 담당하며, route 정보가 필요하지 않다.

NavBackStackEntry

navigate될 때마다 백스택에 추가된다. NavDestination과 전달받은 arguments 정보를 갖고 있다. NavDestination에서 화면에 출력해야 할 content를 가져온다.

Navigator

navigate를 담당하는 클래스이다. 한 가지 NavDestination에 대한 백스택을 관리하고 있다. Navigator의 타입 파라미터로 NavDestination을 받는다. 앞서 NavDestination의 종류가 세 가지 있다고 소개했었는데, Navigator도 크게 세 가지 구현체가 존재한다. 각각 navigation, composable, dialog로 만든 노드로 navigate 할 때의 로직을 구현한다.

NavController

모든 Navigator에 대한 백스택을 종합해 전체 백스택을 관리하고 있다. navigate 함수를 구현하고 있으며, 내부적으로는 적절한 Navigator를 가져와 Navigator의 navigate를 처리한다.

 

각 기능에 대한 상세한 설명

지금까지 주요 클래스 및 함수에 대해 간단히 살펴봤다. 이제부터는 각 기능이 실제로 어떻게 구현되어 있는지 상세하게 살펴볼 예정이다.

 

Navigation Graph 구성

NavHost, composable 등의 함수를 통해 내비게이션 그래프를 구성할 수 있다. NavHost는 루트 NavGraph를, navigation은 중첩 NavGraph를 구성한다. NavGraph는 자식 노드를 가질 수 있으며, 다른 NavDestination은 자식 노드를 가질 수 없다. 다만, NavGraph 자체는 화면에 출력될 수는 없기 때문에 startDestination을 필요로 하며, NavGraph로 navigate되면 즉시 startDestination으로 navigate된다.

nodes와 startDest와 관련된 프로퍼티를 확인할 수 있다

composable과 dialog는 빌더의 확장 함수로 NavDestination을 만든다. NavHost 및 navigation의 내부적에서는 빌더에서 생성한 NavDestination을 addDestination() 메서드를 호출하여 부모 노드의 자식 노드로 추가한다. 결과적으로 앞에서 봤던 그림과 같은 형태로 트리가 형성된다. NavHost에서는 이렇게 생성된 NavGraph를 제공받은 NavController의 graph에 등록한다.

composable 함수는 addDestination으로 자식 노드를 등록하는 함수이다
(NavGraphBuilder.kt) 178라인을 통해 NavGraph의 자식 노드로 추가됨을 알 수 있다

route를 설정하는 부분은 단순히 route 값을 설정하는 세터가 아니라 커스텀 세터를 호출하게 된다. 내부적으로 createRoute메서드를 통해 scheme을 추가하여 deepLink 형태로 만들고, deepLinks 목록에 추가한다. 이를 통해 navigate의 파라미터로 route를 넘기는 것과 deepLink를 넘기는 것을 동일하게 처리할 수 있다. 즉, route와 deepLink는 본질적으로 거의 동일하다.

230라인을 통해 route도 deepLinks 목록에 추가됨을 알 수 있다

 

NavController 생성

rememberNavController를 호출하면 createNavController 함수를 통해 NavController를 생성한다. 기본적으로 3종류의 Navigator를 추가하고 있으며, 커스텀 Navigator도 추가할 수 있는 구조다.

 

navigate 동작 원리

navigate 동작 방식은 복잡하다. 우선 핵심 로직부터 알아보자. backStack은 Navigator에서 StateFlow로 관리되며, NavController에서 구독하고 있다. NavHost는 backStack의 최상단에 있는 Entry를 화면에 출력하며, navigate()가 동작되면 Entry를 추가한다.

navigate() 메서드는 route로 호출하는 방법과 Uri(deepLink)로 호출하는 방법이 있다. 하지만 route를 통해 호출하더라도 deepLink 형태로 변형시키기 때문에 두 메서드는 동일하게 처리된다. 내부적으로는 내비게이션 그래프를 순회하면서 uri 형태가 일치하는 destination을 찾는다.

navigate의 인자로 넘겨준 route 혹은 deepLink는 request의 uri라는 프로퍼티 형태로 존재한다.

(NavGraph.kt) 그래프 순회

순회 과정에 uri를 파싱하여 arguments를 찾는 과정이 포함되어 있다. matchDeepLink()의 반환 값인 DeepLinkMatch의 matchingArgs가 uri에 포함된 arguemtns이다.

이렇게 얻은 destination과 arguments를 이용해 NavBackStackEntry를 만들어 backStack에 추가하는 구조이다. 이미 언급했듯이 destination 종류마다 처리할 수 있는 Navigator가 다르다. 이는 navigatorProvier에 저장되어 있으므로, 적절한 Navigator를 찾아 navigate() 메서드를 호출한다.

NavController.kt

composable 함수를 통해 생성된 destination은 ComposeNavigator가 처리하고, navigation 함수를 통해 생성된 destination(NavGraph)은 NavGraphNavigator가 처리한다. NavGraph로 navigate 할 경우 곧바로 startDestination으로 navigate하는 메서드를 호출한다. 이때 startDestination도 NavGraph일 수 있기 때문에 해당 로직은 재귀적으로 처리된다.

NavGraphNavigator.kt

예시를 통해 살펴보자.

NavHost(
    navController = navController,
    startDestination = "home",
) {
    composable(route = "home") {
        // ...
    }
    
    navigation(
        route = "detail",
        startDestination = "summary",
    ) {
        composable(route = "summary") { entry ->
            // ...
        }
    }
}

위 내비게이션 그래프는 아래 그림과 같다.

따라서 home에서 detail으로 이동하면, 아래와 같이 백스택이 쌓인다.

아래는 아까 살펴봤던 navigate()메서드의 일부 코드이다.

NavController.kt

1869라인에 있는 addEntryToBackStack()가 NavController의 백스택에 entry를 추가하는 코드이다. 이 로직은 람다 형태로 아래 메서드에 넘겨주게 된다.

addEntryToBackStack() 메서드는 핵심 로직에 비해 코드가 너무 길어 첨부하지 못했다. 내용을 요약하자면, NavController의 백스택에 navigate 대상 entry를 추가하는 것이다. 이 entry는 최종 목적지이기 때문에 중간 목적지가 NavGraph인 경우 node와 람다의 파라미터로 들어온 entry와 destination이 다를 수 있다. 따라서 최종 목적지의 조상들을 먼저 백스택에 추가하고, 마지막으로 entry를 추가한다.

이렇게 받은 람다는 addToBackstackHandler에 담기고, navigator의 백스택에 push될 때마다 이 핸들러가 호출된다.

 

Navigator 구조

Navigator.kt

Navigator는 state라는 이름으로 backStack을 관리하고 있으며, NaviatorState는 backStack이라는 프로퍼티를 갖고 있다.

NavigatorState는 추상 클래스이며 push()라는 메서드를 갖고 있는데, 이는 backStack에 push하는 역할을 한다. 바로 이 추상 클래스를 NavController에 inner class 형태로 상속받는다. 여기서 push() 메서드를 여기서 오버라이드하며 외부 클래스인 NavController의 handler를 실행한다. 이 핸들러가 바로 위에서 봤던 navigateInternal()에서 초기화한 핸들러이다.

따라서 push()메서드는 navigator의 backStack에 push하는 역할도 하지만, NavController의 백스택에도 push하는 역할을 한다. NavController의 백스택은 backQueue라는 이름으로 존재하며, 실제로는 자료구조 덱(Deque)을 사용한다.

(NavController.kt) push 메서드를 오버라이드하며 addToBackStackHandler를 호출한다

navigate 대상이 NavGraph일 경우 Navigator 내에서 재귀적으로 navigate()가 호출되는데, NavController에서는 이를 감지할 방법이 필요하다. 그 방법으로 NavigatorState를 NavController에서 구현해 사용한 것으로 보인다.

 

Uri에서 arguments를 찾는 방법

navigate()의 matchDeepLink() 메서드를 타고 들어가다 보면 아래 코드를 찾을 수 있다.

NavDeepLink.kt

파라미터로 들어온 bundle을 조작하는 형태로, 사실상 이 bundle이 실질적인 반환값이다. 파라미터 값을 변경하는 건 가독성을 해치는데, 이 부분의 코드는 다소 C언어스럽다는 느낌이 든다. 각설하고, 파라미터로 들어온 matcher와 arguments는 내비게이션 그래프를 생성할 때 초기화되었던 값이다. 그중 matcher는 사전 컴파일된 route에 대한 정규표현식, 그리고 navigate를 통해 들어온 uri를 통해 만들어진 Matcher이다.

this.pathArgs는 파라미터 arguments와 동일한 key, value 쌍을 갖고 있다. 다만 arguments는 Map의 형태로 route상에서 어떤 순서로 argument가 배치되어 있는지 모른다. this.pathArgs는 내비게이션 그래프를 만들 때 route로 정규표현식을 만들게 되는데, 이때 {중괄호}로 감싼 argument가 발견되는 순서대로 저장한 변수이다.

NavDeepLink.kt

buildRegex()의 결과로 ".../{arg1}/.../{arg2}"와 같은 형태를 찾을 수 있는 정규표현식이 완성되며, matcher.group(0)은 원본 문자열, matcher.group(1)은 arg1, matcher.group(2)는 arg2를 가져오게 된다. 이렇게 가져온 문자열 형태의 argument를 NavArgument 타입에 맞게 파싱하여 bundle에 넣어준다. 위는 path variable를 위한 정규표현식이고 query parameter 형태의 argument 또한 별도로 파싱한다.

 

popBackStack 동작 원리

NavHost에서는 내부적으로 BackHandler(시스템 뒤로가기)를 구현하고 있다. ComposeNavigator의 backStack의 사이즈가 2 이상일 때만 동작한다.

NavHost.kt
NavController.kt

popBacksStackInternal() 메서드는 목표 destination까지 백스택에서 제거한다. 여기서 목표 destination은 보통 composable일 텐데, composable을 제거하면 백스택의 최상단에 NavGraph가 남을 수 있다. NavGraph는 content를 갖지 않으므로 백스택의 최상단에 있어도 보여줄 화면이 없다. 따라서 자식 entry가 모두 제거되었다면 dispatchOnDestinationChanged() 메서드에서 NavGraph를 제거한다.

backStack에서 pop하는 로직 역시 push할 때와 마찬가지로 Navigator의 backStack을 감지하여 backQueue(NavController의 백스택)에서도 pop해준다.

 

결론

내비게이션 라이브러리에서는 많은 기능을 제공하고 있으며, 우리는 그중 핵심이 되는 navigate, popBackStack 그리고 이관련된 여러 클래스와 함수에 대해 살펴봤다. 일부 코드만 발췌하였기에 이해가 안 되더라도 이상한 일이 아니다. 이 경우 실제 코드를 보며 해설지로 삼거나, 서두에 링크로 걸어둔 리포지토리를 확인하길 바란다. 이 리포지토리는 애니메이션과 각종 예외 처리 등 내비게이션 로직을 이해하는 데 중요하지 않은 코드들을 제거하고, 핵심 로직들은 구현하였다.

 

요약

  • Navigator : 하나의 NavDestination에 대한 navigate를 담당한다.
  • NavDestination : 내비게이션 트리의 요소이다. NavHost, composable, navigation 등으로 생성할 수 있다.
  • NavController : Navigator들을 갖고 있으며 이를 통해 navigate를 처리한다.
  • NavHost : 내비게이션 그래프를 만들어 NavController와 연결해 준다. 백스택 최상단 entry를 화면에 렌더링한다.
  • navigate() : 내비게이션 그래프에서 destination을 찾아 NavBackStackEntry를 생성하고, Navigator 및 NavController의 백스택에 추가한다.
  • popBackStack() : Navigator 및 NavController의 백스택에서 entry를 제거한다.