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가 일어난다.
화면별 코드를 살펴보자
전체 코드는 아래에서 확인할 수 있고 주요 코드는 아래와 같다.
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을 활용할 수 있을까?
답은 그렇지 않다.
적용 시 즉시 아래와 같은 오류를 볼 수 있다.
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
사용해 범위를 지정하여 사용할 수 있는데, 이를 활용해 해결할 수 있다.
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
- After
Comments