Compose Navigation - WebView Recomposition 해결?



개인 광고 영역

Compose Navigation에서 WebView 활용 시 Recompistion이 발생한다.

  • A Composable에서 WebView를 그린다.
  • B Composable에서 Other Composable을 그린다.

A에서 B로 navigate 했다가 다시 A로 돌아오는 경우 Recomposition이 발생한다.(당연하게도)

Recomposition을 막을 순 없지만 WebView를 유지할 수 있는 방법은 없을까?


이 글에서 알아볼 내용

  • Compose Navigation에서 WebView를 잘 활용하기 위한 방법은?


현상부터 보자

현상을 파악하기 위해 샘플 코드를 작성하였다.

  • Navigation WEB - WebView
  • Navigation OTHER - Other Screen

이 경우 Other Screen에서 WebView Navigation으로 다시 돌아온다면 어떻게 동작할까? 아래 이미지를 참고하시길

  • OTHER 화면에서 백을 하면 잠시 흰 화면이 노출되어지고, Web load가 일어난다.

before


화면별 코드를 살펴보자

전체 코드는 아래에서 확인할 수 있고 주요 코드는 아래와 같다.

Github - Compose Navigation - WebView


MainScreen

MainScreen에서 Compose Navigation을 활용하여 이동하도록 구현하였다.

WEB에서 OTHER로 넘어갈 때는 saveState를 적용해두었다.

NavHost(
    navController = navController,
    startDestination = "WEB",
) {
    composable(route = "WEB") {
        Box(
            modifier = Modifier
                .fillMaxSize()
        ) {
            WebSampleScreen()

            Button(
                onClick = {
                    navController.navigate("OTHER") {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        restoreState = true
                    }
                },
            ) { /* 생략 */ }
        }
    }
    composable(route = "OTHER") {
        OtherSampleScreen()
    }
}


WebViewScreen

아직 Compose에서 직접 WebView를 제공하지는 않아서 AndroidView를 이용하여 사용해야 한다.

WebView는 보통 라이브러리를 활용하기보단 직접 구현하는 편이 적합한데 적용 시 요구사항이 다르니 직접 만드는 것이 더 좋은 선택이다. 그래서 Accompanist WebView를 Deprecated 시켜두었지만 그래도 UI가 안 나오는 버그까지는 패치해둔 상태인데, FrameLayout을 한 번 더 감싼 코드까지는 제공해 주고 있다.

대부분의 코드는 생략하고 아래와 같다.

var webView by remember { mutableStateOf<WebView?>(null) }
AndroidView(
    factory = {
        val parentLayout = FrameLayout(context).apply {
            webView = WebView(context).apply {
                this.layoutParams = layoutParams
                settings.run {
                    javaScriptEnabled = true
                    defaultTextEncodingName = "UTF-8"
                }
            }

            addView(webView)
        }
        parentLayout
    },
    update = {
       // 생략
    },
    onRelease = {
       // 생략
    }
)

OtherScreen

테스트용이니 Text 하나 노출하는 화면을 구성하였다.

Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
        .fillMaxSize()
) {
    Text(
        text = "other screen",
    )
}


문제를 파악해 보자

Navigation의 route - OTHER에서 route - WEB으로 돌아오면 WEB은 Recomposition이 발생한다. 이때 AndroidView를 이용한 WebView 역시 다시 그려진다. 간단하게 로그를 출력하면 아래와 같다.

// 처음 진입 시
AndroidView factory - null
AndroidView update - android.webkit.WebView

// navigate OTHER로 이동하는 경우
AndroidView onRelease - android.webkit.WebView

// 다시 돌아오면
AndroidView factory - null
AndroidView update - android.webkit.WebView

로그로 볼 수 있지만 remember로 들고 있던 WebView는 null로 초기화 되어 있음을 알 수 있다.

WebView 자체에 대한 상태를 저장하지 않기 때문에 당연한 결과이다.


rememberSaveable을 활용할 수 있을까?

답은 그렇지 않다.

Save UI state in Compose

적용 시 즉시 아래와 같은 오류를 볼 수 있다.

The default implementation only supports types which can be stored inside the Bundle.

사실 이게 가능하다면 가장 쉬운 방법이다. 상태 관리를 가장 쉽게 할 수 있으니깐 말이다.


유지할 수 있을까?

유지하기 위해서 어떤 코드를 작성해야 할까?

일반적으로 Navigation에서 상태를 유지하고, 복귀하기 위한 코드는 아래와 같다.

popUpTo(navController.graph.findStartDestination().id) {
    saveState = true
}
// Restore state when reselecting a previously selected item
restoreState = true

뷰 안에서의 상태를 유지해야 하는 경우를 뜻한다. 우리가 개발하는 대부분의 안드로이드는 AAC-ViewModel을 활용하고 있을 것이다. 그래서 이 방식을 굳이 적용하지 않아도 문제는 없지만 scroll 상태 유지를 위해서는 이 코드는 필수로 필요하다.

그럼 웹뷰는 어떻게 유지하는게 좋을까?


가장 쉽게 싱글턴 사용하기

싱글턴에 WebView를 임시 보관하는 것이다. 하지만 이 방법은 Context 메모리 릭을 발생시킨다.

생성한 WebView를 싱글턴으로 저장하는 것이다. 결과적으로 원하는 동작은 한다.

object KeepWebView {

    var webView: WebView? = null
}

그리고 아래와 같이 저장한다.

// Don't

AndroidView(
    factory = {
        val parentLayout = FrameLayout(context).apply {
            val web = KeepWebView.webView ?: WebView(context).apply {}.also {
                KeepWebView.webView = it
            }
        }
    }
)

이론적으론 싱글턴으로 사용하여 유지할 수 있다. 싱글 액티비티를 사용한다면 큰 문제는 없을 수도 있다.(onDestroy에서 clear는 해야 한다.)


Compose에서 제공하는 방법을 활용해 보자.

Compose에선 CompositionLocalProvider 사용해 범위를 지정하여 사용할 수 있는데, 이를 활용해 해결할 수 있다.

CompositionLocalProvider 링크

Navigation을 가진 뷰 상위에 CompositionLocalProvider 적용하여 WebView를 keep 할 수 있도록 구현하는 방법이다.

이렇게 하면 최소한 WebView를 네비게이션 이동할 때마다 다시 생성하는 부분은 막아 줄 수 있다.


적용 코드

WebView 용 CompositionLocalProvider 아래와 같이 작성하였다.

internal object LocalWebOwner {

    private val LocalComposition = staticCompositionLocalOf<WebView?> { null }

    val current: WebView?
        @Composable
        get() = LocalComposition.current

    infix fun provides(registerOwner: WebView?): ProvidedValue<WebView?> =
        LocalComposition provides registerOwner
}

사용할 때는 아래와 같이 사용할 수 있도록 기존 remember 부분의 코드를 변경한다.

val webView = LocalWebOwner.current

AndroidView(
    factory = {
        val parentLayout = FrameLayout(context).apply {
            val web = webView ?: WebView(context).apply {
                this.layoutParams = layoutParams
            }
            addView(web)
        }
    }
)

webView가 결국 null 일 수 있으니 초기화 코드는 동일하게 포함되어야 한다.

이 코드를 그대로 적용하면

The specified child already has a parent. You must call removeView() on the child's parent first.

그래서 기존 View가 release 동작시 remove를 시켜주어야 한다는 점이다.

onRelease = { parentFrame ->
    (parentFrame.children.first() as? WebView)?.let {
        parentFrame.removeView(it)
    }
}

마지막으로 Navigation을 감싸는 Screen 코드를 아래와 같이 수정해 주어야 한다.

  • 위치는 Activity일 수도 있다.
val context = LocalContext.current
CompositionLocalProvider(
    LocalWebOwner provides WebView(context)
) {
    NavHost(){}
}


마무리

이 방법은 WebView는 Navigation Scope 내부에서는 동일한 WebView를 사용하게 됨으로써 최소한 WebView 객체를 처음부터 다시 생성하는 부분은 막을 수 있지만 Web 자체의 리로드를 막는 코드는 아니다.

그래도 WebView가 새로 생성되어 두 배 세배 더 느려지지 않는다면 만족스러운 결과를 볼 수 있을 것이다.

결과적으론 아래와 같은데, 웹 자체는 reload가 호출됨을 알 수 있다.

  • Before before
  • After after


About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments