앱 기본사항

Jetpack Navigation을 사용하여 유형 안전성을 갖춘 딥 링크 빌드3

스피커

  • 클라라 폭

    소프트웨어 엔지니어

  • 이안 레이크

    엔지니어링 관리자

정보

강연 소개

모듈식 Android 앱 전반에서 딥 링크를 관리하면 경로가 손상되고 런타임에 예기치 않은 문제가 발생하기 쉽습니다.이 세션에서는 Compose를 위한 Jetpack Navigation3의 새로운 유형 안전 딥 링크 아키텍처를 살펴보고, 이를 통해 견고하고 확장 가능한 라우팅 시스템을 빌드하는 방법을 알아봅니다. 기본 제공 딥 링크 일치 API를 구현하고, 복잡한 유형 안전 인수를 위한 맞춤 직렬 변환기를 작성하고, 사용자 탐색을 예측 가능하게 유지하는 동적 계층 구조 백 스택을 구성하는 방법을 살펴보겠습니다.

요약

  • Navigation 3 딥 링크 API는 수신되는 딥 링크 요청을 탐색 키로 변환하여 초기 백 스택을 결정합니다. KMP 호환 DeepLinkRequest 클래스는 Android 인텐트 생성자를 포함하여 입력을 캡슐화합니다.
  • UriDeepLinkMatcher는 Kotlin 직렬화를 사용하여 URI 패턴을 일치시키고 인수를 유형 안전 NavKey 속성으로 역직렬화합니다. 기본 유형은 자동으로 작동하며 값을 파싱할 수 없는 경우 일치가 실패합니다.
  • 개발자는 DeepLinkSerializer를 확장하거나 matchArguments를 재정의하여 복잡한 딥 링크 문자열을 처리할 수 있습니다. UriDeepLinkMatcher를 서브클래스로 지정하면 Kotlin 직렬화가 실행되기 전에 원시 URI 자리표시자를 사전 처리할 수 있습니다.
  • 매처에서 withBackStack를 호출하면 최적의 일치를 위해 합성 백 스택이 지연 생성됩니다. 이렇게 하면 다른 모든 유효한 일치 항목에 대해 불필요한 백 스택을 계산하지 않고도 적절한 시스템 뒤로 동작이 보장됩니다.

리소스

Gemini Notebook

주제

  • jetpack-navigation-3
  • deep-links
  • kotlin-serialization
  • kotlin-multiplatform
  • android-architecture

스크립트

소개 및 Navigation 3 레시피

  • 안녕하세요. 저는 Jetpack 아키텍처팀의 엔지니어링 관리자인 이안 레이크입니다.

  • 저는 Nav 3의 기술 리드인 클라라 폭입니다.

  • 오늘은 제가 정말 좋아하는 기능인 탐색, 아니 딥 링크에 대해 이야기해 보려고 합니다.

  • 사실 저는 이 기능이 너무 마음에 들어서 Nav3의 1.2 출시의 일환으로 Nav2와 Nav3 모두에 이 기능을 구현했습니다.

  • Nav 3 레시피에서도 구현을 진행했으니 사실상 세 번 하신 것 같네요.

  • 네, 맞습니다.

  • Nav3의 Compose 우선 접근 방식에 잘 맞도록 API를 여러 번 반복했습니다.

  • 이러한 변화는 레시피를 강조하면서 시작되었습니다.

  • 예, Navigation 3의 주요 목표 중 하나는 개발자를 방해하지 않는 것이었습니다.

  • 특히 야심 찬 기능을 빌드할 때 실제로 이를 실현하기 위해 Google에 기능 요청을 할 필요가 없습니다.

  • 따라서 "어떻게 해야 하나요?"라는 질문을 받으면

  • 또는 '이 문제에 어떻게 접근해야 할까?'라고 질문할 수 있습니다.

  • 처음부터 맞춤 API를 빌드하는 대신 Navigation 3를 변경하지 않고도 앱에 해당 기능을 추가하는 방법을 설명하는 가이드인 레시피를 빌드합니다.

  • 이러한 '말하지 않고 보여주는' 스타일 덕분에 GitHub에 Nav3 레시피 저장소를 만들 수 있었습니다.

  • 이제 여러 백 스택 및 하단 시트와 같은 일반적인 사용 사례를 위한 35개 이상의 레시피가 제공되며, 앱에 구현하는 데 필요한 모든 코드가 제공됩니다.

  • 즉, 2025년 11월에 출시된 Nav3 1.0 버전에서도 딥 링크를 구현하는 방법이 이미 마련되어 있었습니다. 많은 개발자에게 이 기능이 중요하다는 것을 알고 있었기 때문입니다.

  • 하지만 레시피에 앱에 복사하여 붙여넣어야 하는 도우미 메서드가 많이 필요하다는 것을 알게 되면, 도우미를 가져와서 '이 도우미가 Navigation 3 자체의 일부라면 실제로 어떤 모습일까?'라고 생각하는 '졸업'이라는 프로세스를 고려합니다.

  • 이렇게 하면 이미 해당 코드를 복사하여 붙여넣은 개발자도 문제가 없습니다.

  • 이 코드는 보관하셔도 됩니다.

  • 사용 중인 맞춤 솔루션이 있다면, 잘 작동하는 경우 그대로 사용해도 좋습니다.

  • 강제 이전이 없습니다.

  • 하지만 새로운 문제나 새로운 요구사항이 있는 경우, 탐색의 일부로 직접 사용할 수 있는 검증된 솔루션을 쉽게 얻을 수 있습니다.

  • 하지만 개인적으로 가장 좋은 점은 도우미를 추가한 후 레시피를 업데이트하고 코드를 삭제하는 짜릿한 도파민을 얻는 것입니다.

  • 이렇게 하면 해당 문제에 대한 권장 접근 방식의 레시피를 살펴보는 모든 사용자가 최신 솔루션을 사용할 수 있습니다.

  • 물론 지나고 나면 더 잘 보이는 법입니다. 이 졸업 과정을 통해 레시피에서 빌드한 도우미를 면밀히 살펴보고 '이것이 실제로 가장 적합한 API인가?'라고 자문해 볼 수 있습니다.

  • 이 경우 라이브러리로 이동하면서 이러한 부분을 개선할 수 있는 기회가 생깁니다.

  • 딥 링크도 마찬가지였습니다.

딥 링크 API 설계 및 URI 패턴 일치

  • 이안, 감사합니다.

  • 따라서 개념적으로 레시피와 최종 API는 매우 유사합니다. Nav 3에서는 개발자가 백 스택을 소유하기 때문입니다.

  • 즉, 딥 링크 API의 초점은 초기 백 스택이 무엇인지, 특히 사용자가 딥 링크를 클릭하여 앱에 들어갈 때 어떤 화면이 표시되는지 파악하는 것입니다.

  • 하지만 사용자가 웹 URI를 클릭하면 실제로 어떤 일이 일어날까요?

  • 이제 Nav3 사용 여부와 관계없이 Android 프레임워크에서는 앱에서 지원하는 각 딥 링크에 대해 인텐트 필터를 선언해야 합니다.

  • 이제 흥미로운 부분은 딥 링크가 Activity를 시작하도록 트리거한 후에 발생하는 일입니다.

  • 여기서 입력은 앱이 시작된 인텐트, 즉 시작된 Activity입니다.

  • 하지만 딥 링크는 다양한 형태를 취할 수 있습니다.

  • 예를 들어 테스트를 위해 수동으로 구성하거나 Kotlin 멀티플랫폼 지원을 활용할 수 있습니다.

  • 따라서 딥 링크 입력을 KMP 호환 DeepLinkRequest 클래스로 캡슐화했습니다.

  • Android에는 Android 인텐트를 사용하는 DeepLinkRequest 생성자가 있습니다.

  • 다음으로, 요청이 주어졌을 때 백 스택에 어떤 NavKey를 넣어야 하는지 파악하는 것이 목표입니다.

  • 여기서 DeepLinkMatcher 클래스가 필요합니다.

  • 요청을 받아 일치 항목과 연결된 NavKey가 포함된 MatchResult를 출력합니다.

  • 특히 웹 URI의 경우 지정된 요청을 MatchResult로 변환하는 데 필요한 모든 로직을 캡슐화하는 UriDeepLinkMatcher가 있습니다.

    val matcher = UriDeepLinkMatcher(
      ...,
      ...
    )
  • 이제 UriDeepLinkMatcher가 사용하는 첫 번째 매개변수는 URI 패턴입니다.

    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri(...),
      ...
    )
  • ADS 앱을 빌드하고 있고 홈 화면에 딥 링크를 추가하려고 한다고 가정해 보겠습니다.

  • 여기서 패턴은 정확한 URI이며 HTTP 스키마를 생략하여 HTTP와 HTTPS를 모두 자동으로 일치시킬 수 있습니다.

    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri("ads.android.com/home"),
      ...
    )
  • 하지만 특정 광고 세션에 딥 링크를 연결하려면 이제 딥 링크에 추출해야 하는 정보, 즉 세션 ID가 포함됩니다.

    // target NavKey
    @Serializable data class Session(val id: String) : NavKey
    
    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
      …
    )
  • 이 경우 패턴은 중괄호를 사용하여 딥 링크에서 추출해야 하는 정보의 자리표시자 역할을 합니다.

  • 이미 모든 종류의 입력을 클래스 인스턴스로 변환하는 편리한 라이브러리가 있는데, 바로 Kotlin 직렬화입니다.

  • 따라서 UriDeepLinkMatcher는 Kotlin 디코더를 활용하여 입력 URI를 역직렬화하고 NavKey 인스턴스를 출력합니다.

    // target NavKey
    @Serializable data class Session(val id: String) : NavKey
    
    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
      serializer = serializer<Session>()
    )
  • 이제 디코더는 작업할 클래스를 알아야 하므로 NavKey의 직렬 변환기도 매처에 전달해야 합니다.

    // target NavKey
    @Serializable data class Session(val id: String) : NavKey
    
    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri("http://ads.android.com/session/{id}"),
      serializer = serializer<Session>()
    )
  • 디코더가 자리표시자를 키의 속성과 올바르게 일치시키려면 자리표시자의 이름이 속성 이름과 일치해야 합니다.

  • 따라서 이 예시에서 속성 이름은 세션의 ID 매개변수와 일치하는 ID입니다.

  • 이제 UriDeepLinkMatcher가 있으므로 수신 딥 링크를 사용하여 DeepLinkRequest를 구성해야 합니다.

    // target NavKey
    @Serializable data class Session(val id: String) : NavKey
    
    val matcher = UriDeepLinkMatcher(
      uriPattern = DeepLinkUri("http://ads.android.com/session/{id}"),
      serializer = serializer<Session>()
    )
    
    val request = DeepLinkRequest(
      uri = DeepLinkUri("http://ads.android.com/session/abcd")
    )
    
    val matchResult: UriMatchResult<Session>? = matcher.match(request)
    
    val backStack = listOf(matchResult?.key ?: Home)
  • 그런 다음 요청을 전달하는 일치 함수를 호출하여 일치가 수행됩니다.

  • 이렇게 하면 NavKey가 포함된 null을 허용하는 MatchResult가 제공됩니다. 이 예에서는 세션 키가 되며, 결과가 null이면 홈 화면으로 기본 설정할 수 있습니다.

  • 이제 여러 딥 링크를 여러 일치자로 작성해야 합니다(딥 링크당 일치자 하나).

  • 예를 들어 여기에는 세션 키에 대한 두 가지 URI 패턴이 있습니다. 하나는 기존 뉴스레터 패턴이고 다른 하나는 일반 웹 URI입니다.

  • 또한 ADS 홈 화면으로 연결되는 딥 링크가 있으므로 이를 위한 세 번째 일치자를 추가합니다.

  • 그리고 모든 일치자는 반복하는 동일한 목록에 추가됩니다.

  • 이제 일치 항목은 목록의 모든 일치 항목을 반복하는 루프가 되며, 각 일치 항목에서 일치 항목을 호출하여 요청과 일치하는 모든 일치 항목을 찾습니다.

  • 이렇게 하려면 mapNotNull를 사용하여 일치하지 않는 항목을 필터링하고 유효한 일치 항목만 반환하면 됩니다.

    val matchers = listOf(...)
    
    // gets all non-null MatchResult
    val matchResults = matchers.mapNotNull { matcher ->
      matcher.match(request)
    }
  • 대부분의 경우 목록에 null이 아닌 일치 항목이 하나만 있지만 유효한 일치 항목이 두 개 이상 있을 수 있습니다.

  • 예를 들어 동일한 세션 URI 패턴을 재사용하는 특별한 기조연설 화면을 도입하지만 이제 ID 플레이스홀더 대신 정적 기조연설 경로가 있다고 가정해 보겠습니다.

  • 따라서 이 경우 동일한 요청이 ID가 keynote인 세션 키와 keynote NavKey라는 두 일치자와 일치할 수 있습니다.

  • MatchResult는 Comparable을 구현하므로 max 함수를 사용하여 목록의 모든 유효한 일치 항목을 비교하거나, 요청과 일치하는 딥 링크가 없는 경우를 처리하기 위해 maxOrNull를 사용하여 가장 일치하는 항목을 찾을 수 있습니다.

    val matchers = listOf(...)
    
    // gets all non-null MatchResult
    val matchResults = matchers.mapNotNull { matcher ->
      matcher.match(request)
    }
    
    // compare matches
    val backStack = listOf(
      matchResults.maxOrNull()?.key ?: Home
    )
  • 라이트닝 토크에서는 정적 일치자 목록이 적합할 수 있지만, 화면이 여러 모듈에 분산될 수 있는 것처럼 이러한 모듈이 DI 시스템을 통해 전역 일치자 목록에 기여할 것으로 예상됩니다.

    // Feature A Module
    @ContributesTo(ActivityScope::class)
    @BindingContainer
    object FeatureAModule {
        @IntoSet
        @Provides
        fun provideDeepLinkMatcher() : UriDeepLinkMatcher<NavKey> =
          UriDeepLinkMatcher(
            uriPattern = DeepLinkUri("ads.android.com/featureA"),
            serializer<FeatureBKey>(),
          )
    }
    
    // in MainActivity
    @Inject lateinit var matchers: Set<UriDeepLinkMatcher<NavKey>>
  • 이 접근 방식을 사용하여 레시피 저장소의 Hilt, Metro, Koin 레시피가 딥 링크를 포함하도록 업데이트되었습니다.

유형 안전 역직렬화 및 맞춤 직렬 변환기

  • 클라라가 언급했듯이 UriDeepLinkMatcher는 Kotlin 직렬화를 사용하여 딥 링크 요청에서 필드를 추출하고, 앞서 보여드린 세션 클래스와 같은 유형 안전 클래스로 만듭니다.

  • 여기에 작성된 것처럼 이는 매우 간단한 변환입니다.

  • URI는 문자열입니다.

  • 필드는 문자열입니다.

  • 정말 간단하네요.

  • 하지만 문자열 속성만으로는 충분하지 않다는 것을 알고 있었습니다.

  • 이 부분에서 Kotlin 직렬화는 이미 작업에 사용할 수 있는 탄탄한 기반을 제공합니다.

  • 불리언, 정수, 부동 소수점 수, Enum과 같은 모든 기본 유형은 즉시 작동하며, 딥 링크 값을 특정 유형으로 파싱할 수 없는 경우 자동으로 일치에 실패하고 null을 반환합니다.

  • 예를 들어 세션의 ID 매개변수가 실제로 문자열이 아닌 정수인 경우 정수로 파싱할 수 없는 문자열을 전달하면 일치가 실패합니다.

    // pattern = "http://ads.android.com/session/{id}"
    @Serializable data class Session(val id: Int) : NavKey
  • 마찬가지로, 웹 URL에 연결된 분석 데이터와 같은 선택적 인수를 사용하려는 경우 기본값이 있는 키의 속성이 될 수 있습니다.

    // pattern = "http://ads.android.com/session/{id}#referrer={referrer}
    @Serializable data class Session(
      val id: Int,
      val referrer: String? = null,
    ) : NavKey
  • 여기에서 볼 수 있듯이, Google은 웹 URL의 구조를 앱의 딥 링크 일치를 나타내는 유형 안전 클래스 또는 클래스에서 분리하기 위해 최선을 다했습니다. 웹 URL의 구조는 다른 팀에서 소유할 수도 있습니다.

  • 일부 개발자는 정말 끔찍한 딥 링크를 처리하고 있기 때문입니다.

    // "id={id}&base64={placeholderImage}"
    @Serializable data class Session(
      val id: String,
      val placeholderImage: Bitmap? = null
    ) : NavKey
    
    // "jsonBlob={user}"
    @Serializable data class Profile(
      val user: User,
    ) : NavKey
  • 딥 링크에 base64로 인코딩된 이미지가 연결되어 있을 수도 있습니다.

  • 예를 들어 거대한 JSON blob을 앱의 모든 URL에 넣기로 결정했다고 가정해 보겠습니다.

  • 이제 제공된 웹 URL을 변경할 수 없더라도 해당 URL에서 일부 정보를 추출해야 하는 경우에도 해결 방법이 있습니다.

  • 첫 번째 기법은 DeepLinkSerializer 클래스를 확장하여 인수가 문자열에서 유형 안전 속성으로 역직렬화되거나 변환되는 방식을 정확하게 제어하는 것입니다.

    object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
      ...
    }
  • DeepLinkSerializer는 Kotlin 직렬화를 둘러싼 도우미 래퍼로, 직렬 변환기 클래스 구현을 간소화합니다. 이 경우 역직렬 변환기 입력은 항상 웹 URL의 문자열이기 때문입니다.

    public abstract class DeepLinkSerializer<T : Any> : KSerializer<T>
  • 이제 DeepLinkSerializer를 구현하려면 세 가지 메서드를 작성합니다.

  • 먼저 연속 프로그램 이름을 선택합니다.

    object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
      override val serialName: String
        get() = "Bitmap"
    
      ...
    }
  • 네임스페이스를 지정할 필요는 없지만 Kotlin 직렬화에서 직렬 변환기를 사용해야 하는 시점을 알 수 있을 만큼 고유해야 합니다.

  • 다음으로, 웹 URL에서 해당 문자열을 전달받아 유형 안전 객체를 구성하는 deserialize를 구현해야 합니다.

    object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
      override val serialName: String
        get() = "Bitmap"
    
      override fun deserialize(value: String): Bitmap {
        val imageBytes = Base64.decode(value, Base64.DEFAULT)
       // This better be a small image…
        return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
      }
    
       ...
    
    }
  • 마지막으로, 반대 접근 방식인 serialize를 구현합니다. 이는 동일한 키 클래스가 rememberNavBackStack 사례와 같은 경우에 사용됩니다.

    object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
    
      ...
    
      override fun serialize(value: Bitmap): String {
        val byteArrayOutputStream = ByteArrayOutputStream()
        // Compress the bitmap into the output stream (e.g., JPEG or PNG)
        value.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream)
        val byteArray = byteArrayOutputStream.toByteArray()
        // Encode the byte array to a Base64 string
        return Base64.encodeToString(byteArray, Base64.DEFAULT)
      }
    }
  • 이제 약간 특별한 클래스가 있는 속성에 맞춤 직렬 변환기를 적용하면 웹 UriDeepLinkMatcher가 나머지를 처리합니다.

    object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
      ...
    }
    
    @Serializable data class Session(
      val id: String,
      @Serializable(BitmapSerializer::class)
      val placeholderImage: Bitmap? = null
    ) : NavKey

커스텀 매처를 사용한 URL 인수 사전 처리

  • 하지만 base64 문자열이나 웹 URL에 연결된 JSON에서 일부 데이터만 가져오려면 어떻게 해야 할까요?

  • 이 경우 Kotlin 직렬화에 도달하기 전에 일부 웹 URL을 사전 처리하는 것이 좋습니다.

  • 이러한 케이스를 처리하려면 UriDeepLinkMatcher를 확장하고 matchArguments 메서드를 재정의하면 됩니다.

  • 이제 JSON blob에서 세션 ID만 필요하고 나머지는 필요하지 않다고 가정해 보겠습니다.

  • 따라서 서브클래스를 만들고 matchArguments를 재정의합니다.

    class SessionUriDeepLinkMatcher(
      uriPattern: DeepLinkUri
    ) : UriDeepLinkMatcher(uriPattern, serializer=serializer<Session>()) {
    
      override fun matchArguments(
        pathArgs: Map<String, List<String>>,
        queryArgs: Map<String, List<String>>,
        fragmentArgs: Map<String, List<String>>
      ): UriMatchResult<Session>? {
        ...
    }
  • 입력은 원시 문자열, 즉 URI 경로에서 추가된 모든 자리표시자입니다.

  • 이렇게 하면 super.matchArguments를 호출하기 전에 해당 값을 사전 처리하고 수정할 수 있습니다.

    class SessionUriDeepLinkMatcher(
      uriPattern: DeepLinkUri
    ) : UriDeepLinkMatcher(uriPattern, serializer=serializer<Session>()) {
    
      override fun matchArguments(
        pathArgs: Map<String, List<String>>,
        queryArgs: Map<String, List<String>>,
        fragmentArgs: Map<String, List<String>>
      ): UriMatchResult<Session>? {
        val finalQueryArgs = buildMap {
          putAll(queryArgs)
          val jsonBlob = remove("session")?.firstOrNull()
          if (jsonBlob != null) { put("session", extractSessionIdFromJson(jsonBlob)) }
        }
        return super.matchArguments(pathArgs, finalQueryArgs, fragmentArgs)
    }
  • 이제 여기에서 값을 간소화할 수 있으며 Kotlin 직렬화를 전혀 건드릴 필요가 없습니다.

딥 링크의 합성 백 스택 빌드

  • 이안, 감사합니다.

  • 이러한 기법을 자주 사용할 필요는 없겠지만, Google은 일치 동작을 제어하는 방법에 대해 많은 고민을 했습니다.

  • 좋습니다.

  • 이제 딥 링크 일치자를 사용하여 NavKey를 가져오는 방법을 알았지만 탐색 키만 있는 백 스택은 최상의 사용자 환경을 제공하지 못할 수 있습니다.

  • 따라서 적절한 뒤로 동작을 보장하려면 합성 백 스택이 필요합니다.

  • 합성 부분은 백 스택에 사용자가 수동으로 이동하지 않은 키가 포함되어 있지만 수동으로 이동한 경우 사용자가 이동했을 경로라는 사실에서 비롯됩니다.

  • 예를 들어 곧 시작될 세션에 관한 알림을 클릭하면 사용자가 해당 세션으로 바로 이동하지만, 시스템 뒤로를 트리거하면 런처로 종료하기 전에 HomeScreen으로 돌아가도록 할 수 있습니다.

  • 이 HomeScreen은 합성 백 스택의 일부가 됩니다.

  • 딥 링크 API의 경우 두 가지 상충되는 요구사항의 균형을 맞추고자 했습니다.

  • 한편, 딥 링크나 일치자가 다르면 합성 백 스택을 빌드하는 로직도 달라야 한다는 것을 알고 있습니다.

  • 예를 들어 모든 딥 링크 앞에 HomeScreen을 추가하면 설정 화면 내의 하위 화면으로 딥 링크를 연결할 때까지 작동할 수 있습니다.

  • 이제 특정 딥 링크의 합성 백 스택에 추가 레이어가 필요합니다.

  • 당연히 이로 인해 백 스택 빌더가 각 딥 링크 일치자에 연결됩니다.

  • 하지만 올바른 화면이 표시되기까지 걸리는 시간이 길어질수록 비용이 발생한다는 점도 알고 있습니다.

  • 따라서 모든 일치 결과에 대해 합성 백 스택을 계산하고 싶지 않았습니다.

  • 가장 일치하는 항목만 원했습니다.

  • 각 매처에 맞춤 로직을 사용하고 싶지만 각 일치 항목에 대해 합성 백 스택을 적극적으로 만들고 싶지 않다면 어떻게 해야 할까요?

  • 몇 번의 시행착오 끝에 두 요구사항의 균형을 맞추는 접근 방식을 찾았습니다.

  • 어떤 종류의 매처가 있든 withBackStack를 호출하여 지정된 MatchResult로 백 스택을 지연 구성함으로써 특정 매처를 보강할 수 있습니다.

    val homeMatcher = UriDeepLinkMatcher(...).withBackStack { matchResult ->
      listOf(Home, matchResult.key)
    }
    
    val sessionMatcher = UriDeepLinkMatcher(...).withBackStack { matchResult ->
      listOf(Home, SessionList, matchResult.key)
    }
    
    val backStackMatchers: List<BackStackMatcher<NavKey, NavKey>> = listOf(
      homeMatcher, sessionMatcher
    )
    
    val matchResults = backStackMatchers.mapNotNull { matcher.match(request) }
    val bestMatch: BackStackMatchResult<NavKey, NavKey>? = matchResults.maxOrNull()
    
    val backStack = bestMatch?.backStack ?: listOf(Home)
  • 따라서 일치자가 여러 개 있는 경우 각 일치자는 자체 withBackStack 호출을 사용하여 특정 NavKey에 적합한 백 스택을 빌드합니다.

  • 호출하는 모든 매처가 withBackStack를 사용하면 가장 깔끔한 코드를 얻을 수 있으며, 이를 통해 백 스택 매처 목록을 얻을 수 있습니다.

  • 가장 일치하는 항목을 찾으면 반환되는 일치 결과는 BackStackMatchResult입니다.

  • 이는 MatchResult의 서브클래스로, withBackStack 람다에 의해 생성된 전체 백 스택인 backStack라는 새 속성을 추가합니다.

  • 이러한 계층화 접근 방식을 통해 매처 내부의 로직을 withBackStack 람다 내부에서 사용하려는 커스텀 로직과 분리할 수 있었습니다.

Lightning 라운드, 요약, 리소스

  • 라이트닝 토크이기 때문에 아쉽게도 시간이 거의 다 되었지만, Navigation 3 레시피에서 찾을 수 있는 Navigation 3의 다른 기능 몇 가지를 살펴보겠습니다.

  • 좋습니다.

  • 따라서 일치자에 필터를 추가하여 일치 로직을 단축하고 작업, MIME 유형 또는 자체 맞춤 필터를 기반으로 일치자를 빠르게 제외할 수 있습니다.

  • 이는 URI 파싱이나 맞춤 직렬화, Kotlin 직렬화를 거치기 전에 발생합니다.

  • 또한 NavKey를 가져오기 위해 파싱을 수행할 필요는 없지만 필터를 적용하려는 경우를 위해 StaticKeyDeepLinkMatcher도 제공합니다.

    @Serializable object ImagesKey: NavKey
    
    // custom filter
    val mimeTypeFilter = DeepLinkMatcher.mimeTypeFilter("image/jpg")
    
    val matcher = StaticKeyDeepLinkMatcher(ImagesKey, listOf(mimeTypeFilter))
  • 이는 JPEG 이미지를 열 수 있는 사진 편집 앱과 같이 작업 또는 MIME 유형 전용 딥 링크를 처리하는 데 적합합니다.

  • 작업 및 MIME 유형과 관련하여, 커스텀 RequestExtras를 DeepLinkRequest에 연결할 수 있으며, 커스텀 필터와 DeepLinkRequest 서브클래스는 이를 로직의 일부로 사용할 수 있습니다.

  • 이를 통해 AB 테스트 프레임워크나 외부 신호를 혼합할 수 있습니다.

  • 또한 추가 항목을 위한 전체 유형 안전 API도 있습니다.

    val extras = requestExtras {
        put(SectionExtrasKey, "A")
    }
  • 이러한 로직은 대부분 Nav3의 공통 KMP 코드에 있으므로 모든 매처와 백 스택 로직을 플랫폼 간에 공유할 수 있습니다.

  • Navigation 3에서 딥 링크를 지원하는 방식을 더 여유롭게 살펴보고 싶다면 Nav3 레시피를 확인하세요.

  • 예, 딥 링크 API를 DI 레시피와 Hero 앱인 NowInAndroid에도 통합했습니다.

  • 라이트닝 토크에 참석해 주셔서 감사합니다. 즐겁게 탐색해 보세요.

  • 감사합니다.

코드

코드 스니펫

'딥 링크 API 설계 및 URI 패턴 일치'의 4분 41초에 나오는 Kotlin 스니펫
val matcher = UriDeepLinkMatcher(
  ...,
  ...
)
'딥 링크 API 설계 및 URI 패턴 일치'의 04:53에 있는 Kotlin 스니펫
val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri(...),
  ...
)
'딥 링크 API 설계 및 URI 패턴 일치'의 5분 4초 지점에 있는 Kotlin 스니펫
val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri("ads.android.com/home"),
  ...
)
'딥 링크 API 설계 및 URI 패턴 일치'의 5분 12초 지점에 있는 Kotlin 스니펫
// target NavKey
@Serializable data class Session(val id: String) : NavKey

val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
  …
)
'딥 링크 API 설계 및 URI 패턴 일치'의 05:39에 있는 Kotlin 스니펫
// target NavKey
@Serializable data class Session(val id: String) : NavKey

val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
  serializer = serializer<Session>()
)
'딥 링크 API 설계 및 URI 패턴 일치'의 5분 48초 지점에 있는 Kotlin 스니펫
// target NavKey
@Serializable data class Session(val id: String) : NavKey

val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri("http://ads.android.com/session/{id}"),
  serializer = serializer<Session>()
)
'딥 링크 API 설계 및 URI 패턴 일치'의 06:12에 있는 Kotlin 스니펫
// target NavKey
@Serializable data class Session(val id: String) : NavKey

val matcher = UriDeepLinkMatcher(
  uriPattern = DeepLinkUri("http://ads.android.com/session/{id}"),
  serializer = serializer<Session>()
)

val request = DeepLinkRequest(
  uri = DeepLinkUri("http://ads.android.com/session/abcd")
)

val matchResult: UriMatchResult<Session>? = matcher.match(request)

val backStack = listOf(matchResult?.key ?: Home)
'딥 링크 API 설계 및 URI 패턴 일치'의 07:11에 나오는 Kotlin 스니펫
val matchers = listOf(...)

// gets all non-null MatchResult
val matchResults = matchers.mapNotNull { matcher ->
  matcher.match(request)
}
'딥 링크 API 설계 및 URI 패턴 일치'의 07:48에 있는 Kotlin 스니펫
val matchers = listOf(...)

// gets all non-null MatchResult
val matchResults = matchers.mapNotNull { matcher ->
  matcher.match(request)
}

// compare matches
val backStack = listOf(
  matchResults.maxOrNull()?.key ?: Home
)
'딥 링크 API 설계 및 URI 패턴 일치'의 8분 4초 지점에 있는 Kotlin 스니펫
// Feature A Module
@ContributesTo(ActivityScope::class)
@BindingContainer
object FeatureAModule {
    @IntoSet
    @Provides
    fun provideDeepLinkMatcher() : UriDeepLinkMatcher<NavKey> =
      UriDeepLinkMatcher(
        uriPattern = DeepLinkUri("ads.android.com/featureA"),
        serializer<FeatureBKey>(),
      )
}

// in MainActivity
@Inject lateinit var matchers: Set<UriDeepLinkMatcher<NavKey>>
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 9분 15초 지점에 있는 Kotlin 스니펫
// pattern = "http://ads.android.com/session/{id}"
@Serializable data class Session(val id: Int) : NavKey
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 09:27에 나오는 Kotlin 스니펫
// pattern = "http://ads.android.com/session/{id}#referrer={referrer}
@Serializable data class Session(
  val id: Int,
  val referrer: String? = null,
) : NavKey
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 9분 53초 지점에 있는 Kotlin 스니펫
// "id={id}&base64={placeholderImage}"
@Serializable data class Session(
  val id: String,
  val placeholderImage: Bitmap? = null
) : NavKey

// "jsonBlob={user}"
@Serializable data class Profile(
  val user: User,
) : NavKey
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 10:19에 나오는 Kotlin 스니펫
object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
  ...
}
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 10:32에 있는 Kotlin 스니펫
public abstract class DeepLinkSerializer<T : Any> : KSerializer<T>
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 10:51에 있는 Kotlin 스니펫
object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
  override val serialName: String
    get() = "Bitmap"

  ...
}
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 11:01에 나오는 Kotlin 스니펫
object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
  override val serialName: String
    get() = "Bitmap"

  override fun deserialize(value: String): Bitmap {
    val imageBytes = Base64.decode(value, Base64.DEFAULT)
   // This better be a small image…
    return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
  }

   ...

}
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 11:13에 있는 Kotlin 스니펫
object BitmapSerializer : DeepLinkSerializer<Bitmap>() {

  ...

  override fun serialize(value: Bitmap): String {
    val byteArrayOutputStream = ByteArrayOutputStream()
    // Compress the bitmap into the output stream (e.g., JPEG or PNG)
    value.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream)
    val byteArray = byteArrayOutputStream.toByteArray()
    // Encode the byte array to a Base64 string
    return Base64.encodeToString(byteArray, Base64.DEFAULT)
  }
}
'유형 안전 역직렬화 및 맞춤 직렬 변환기'의 11:24에 있는 Kotlin 스니펫
object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
  ...
}

@Serializable data class Session(
  val id: String,
  @Serializable(BitmapSerializer::class)
  val placeholderImage: Bitmap? = null
) : NavKey
'Preprocessing URL arguments with custom matchers'의 12:09에 나오는 Kotlin 스니펫
class SessionUriDeepLinkMatcher(
  uriPattern: DeepLinkUri
) : UriDeepLinkMatcher(uriPattern, serializer=serializer<Session>()) {

  override fun matchArguments(
    pathArgs: Map<String, List<String>>,
    queryArgs: Map<String, List<String>>,
    fragmentArgs: Map<String, List<String>>
  ): UriMatchResult<Session>? {
    ...
}
'Preprocessing URL arguments with custom matchers'의 12:21에 나오는 Kotlin 스니펫
class SessionUriDeepLinkMatcher(
  uriPattern: DeepLinkUri
) : UriDeepLinkMatcher(uriPattern, serializer=serializer<Session>()) {

  override fun matchArguments(
    pathArgs: Map<String, List<String>>,
    queryArgs: Map<String, List<String>>,
    fragmentArgs: Map<String, List<String>>
  ): UriMatchResult<Session>? {
    val finalQueryArgs = buildMap {
      putAll(queryArgs)
      val jsonBlob = remove("session")?.firstOrNull()
      if (jsonBlob != null) { put("session", extractSessionIdFromJson(jsonBlob)) }
    }
    return super.matchArguments(pathArgs, finalQueryArgs, fragmentArgs)
}
'딥 링크를 위한 합성 백 스택 빌드'의 14:31에 나오는 Kotlin 스니펫
val homeMatcher = UriDeepLinkMatcher(...).withBackStack { matchResult ->
  listOf(Home, matchResult.key)
}

val sessionMatcher = UriDeepLinkMatcher(...).withBackStack { matchResult ->
  listOf(Home, SessionList, matchResult.key)
}

val backStackMatchers: List<BackStackMatcher<NavKey, NavKey>> = listOf(
  homeMatcher, sessionMatcher
)

val matchResults = backStackMatchers.mapNotNull { matcher.match(request) }
val bestMatch: BackStackMatchResult<NavKey, NavKey>? = matchResults.maxOrNull()

val backStack = bestMatch?.backStack ?: listOf(Home)
'Lightning round, summary, and resources'의 15:52에 나오는 Kotlin 스니펫
@Serializable object ImagesKey: NavKey

// custom filter
val mimeTypeFilter = DeepLinkMatcher.mimeTypeFilter("image/jpg")

val matcher = StaticKeyDeepLinkMatcher(ImagesKey, listOf(mimeTypeFilter))
'Lightning round, summary, and resources'의 16:24에 나오는 Kotlin 스니펫
val extras = requestExtras {
    put(SectionExtrasKey, "A")
}