[Android] WebView 파헤치기 1탄

yuuuzzzinzzzang·2024년 1월 30일
4
post-thumbnail

가끔씩(꽤 자주) 안드로이드 네이티브에서 웹 컨텐츠를 띄워줘야하는 상황이 있습니다.
크롬을 열어 웹페이지로 이동시킬 수도 있겠지만, 상황에 따라 앱의 전환 없이 자연스럽게 앱 내에서 웹 페이지를 보여주어야 하는 경우가 많았기 때문에 그간 웹뷰를 사용할 일이 많았습니다.

또한 업데이트가 잦은 화면의 경우에도 앱의 배포나 별도의 업데이트 없이 웹 페이지만 수정되면 바로 사용자에게 반영이 되기 때문에 유지보수의 측면에서도 많이 사용하게 됩니다.

이런 웹뷰를 사용하기 위해 어떤 세팅이 요구되고, 왜 요구되는 것인지 짚어보면 좋을 것 같아 나름의 경험들을 토대로 정리(를 빙자한 마구잡이로 파헤쳐보기😅)해보려고 합니다!

🌐 WebView wrapper for Jetpack Compose?

  • Accompanist에서 컴포즈를 위해 제공하던 WebView는 deprecated되어 더이상 유지보수되지 않는 것으로 보입니다 🤔
  • 뷰 시스템의 WebView를 AndroidView로 감싸 만들어진 wrapper 형태였기 때문에 굳이 사용할 필요 없이 똑같이 감싸서 구현하면 될 듯 합니다.

🌐 WebView Settings

with(webView.settings) {
    javaScriptEnabled = true
    domStorageEnabled = true
    mediaPlaybackRequiresUserGesture = false
    cacheMode = WebSettings.LOAD_DEFAULT
    textZoom = 100
}
  • javaScriptEnabled: JavaScript를 사용한 웹뷰를 로드한다면 이 옵션을 활성화 해야합니다. 네이티브 앱 코드와 JavaScript 코드 간의 인터페이스를 생성해 활용할 수 있습니다.
  • domStorageEnabled: 웹뷰에서 LocalStorage를 사용해야 하는 경우 활성화가 필요합니다.
  • mediaPlaybackRequiresUserGesture: 사용자 제스처를 통해서가 아닌 자동재생이 필요할 때 비활성화 해야 합니다.
  • cacheMode: 안드로이드 웹뷰 캐시 전략에 대한 모드 설정입니다.
    • LOAD_DEFAULT: 디폴트 값. 캐시가 만료되었으면 네트워크 사용, 그렇지 않으면 캐시 사용

    • LOAD_CACHE_ELSE_NETWORK: 캐시가 만료되었어도 있으면 사용하고, 없으면 네트워크 사용

    • LOAD_CACHE_ONLY: 네트워크 요청 X, 캐시만 사용

    • LOAD_NO_CACHE: 캐시 사용 X, 매번 네트워크 요청

      💡 캐시란?
      캐시란 반복적으로 데이터를 불러오는 경우, 지속적으로 DBMS 또는 서버에 요청하는 게 아니라 메모리에 저장했다가 불러다 쓰는 걸 의미합니다. 네트워크 트래픽을 줄이고 페이지 로딩 속도를 향상시키기 위해 사용됩니다. 자주 사용되고, 그 값이 균일하며 자주 바뀌지 않는 데이터인 경우에 사용을 고려하면 좋습니다.

  • textZoom: 안드로이드 시스템 설정에 의해 웹뷰의 폰트 사이즈가 변경되는 것을 막고 싶다면, 확대 비율을 100으로 박아두어 웹뷰 내 텍스트 배치가 엉망진창 되거나 잘리는 일을 방지할 수 있습니다.
  • 이 외에도 수많은 세팅들이 있습니다.

그 다음으로는 WebViewClientWebChromeClient를 설정해주어야 합니다. 이 클래스들을 상속받는 커스텀한 클래스를 만들어 특정 콜백 함수들을 오버라이드할 수 있습니다.

🌐 WebViewClient

웹 페이지 로드에 대한 이벤트 처리를 담당합니다.

그냥 loadUrl을 통해 웹 페이지를 불러오려고 하면 외부 브라우저를 실행하여 띄우게 되므로, 웹뷰 자체에서 페이지를 로드하게 하려면 WebViewClient를 할당해주어야 합니다.

몇 가지 오버라이드 가능한 함수들을 소개해보자면

  • onPageStarted, onPageFinished: 각각 페이지가 로드되는 첫 시점, 끝나는 시점에 호출됩니다. 로딩 중 로딩 애니메이션을 띄우는 경우, 로드의 시작과 종료에 따라 visible 처리를 해줄 수 있습니다.
  • onReceivedError: 페이지를 로드하는 중에 에러가 발생하면 호출됩니다. 로그를 남기거나 에러 상황에 따라 추가적인 동작이 필요한 경우 사용할 수 있습니다. 하지만 이 콜백은 메인 페이지 뿐만 아니라 모든 리소스에 대해 호출되므로 불필요한 작업을 최소화하여 성능에 영향을 덜 주도록 가능한 한 최소한의 작업만을 수행하도록 권장하고 있습니다.
  • shouldOverringUrlLoading: 페이지에서 URL을 호출 할 때, URL을 가로채서 어떻게 열지 제어할 수 있습니다. false를 반환하면 현재 웹뷰에서 URL을 엽니다. 하지만 true를 반환하면 URL은 아래와 같이 스킴별로 정의한 것을 따라 열리고 웹뷰에서는 아무것도 하지 않습니다.
    when (val scheme = uri.scheme) {
        "http",
        "https",
        -> // 웹 브라우저에서 링크가 열리게 하기
        "mailto" -> // 이메일 앱 실행하기
        "intent" -> // Intent 스킴 처리하여 의도된 곳으로 이동시키기
        else -> return false // 웹뷰에서 링크를 엽니다.
    }
    return true // 웹뷰에서 링크를 처리하지 않습니다.

    💡 이 때 startActivity는 try-catch 문으로 감싸주는 것이 좋습니다.
    처리 가능한 앱이 없는 경우(ActivityNotFoundException) 스토어로 이동하게 하거나, false를 반환해 웹뷰에서 링크를 열게 할 수 있기 때문입니다.

  • doUpdateVisitedHistory: 페이지 로드가 끝나면 웹뷰 history stack에 해당 페이지가 쌓이게 되면서 호출되는 함수입니다. 뒤로가기 버튼을 통해 웹뷰 내에서 페이지 이동을 하고 싶다면 이 함수에서 아래와 같이 제어할 수 있습니다.
        var canGoBack by rememberSaveable { mutableStateOf(false) }
        var webView: WebView? = null
    
        AndroidView(
            factory = { context ->
                WebView(context).apply {
                    
    								...
    
    								webViewClient =
                        object : WebViewClient() {
                            override fun doUpdateVisitedHistory(
                                view: WebView?,
                                url: String?,
                                isReload: Boolean,
                            ) {
                                canGoBack = view?.canGoBack() == true
                            }
                        }
    
    								...
    
                }
            },
            update = {
                webView = it
            },
        )
    
        BackHandler(enabled = canGoBack) {
            if (webView?.canGoBack() == true) {
                webView?.goBack()
            }
        }
    canGoBack()은 이전 웹뷰 히스토리 아이템이 있는지 여부를 알려주는 함수입니다. 이를 통해 뒷 히스토리가 있어 뒤로가기 가능한 상태임이 판단되면 웹뷰 자체에서 뒤로가기가 가능하도록 설정해줄 수 있습니다.

🌐 WebChromeClient

웹 페이지에서 발생하는 여러가지 이벤트 처리를 담당합니다. 몇 가지 오버라이드 가능한 함수들을 소개해보자면

  • onCreateWindow: 웹뷰에서는 우편번호 검색과 같은 팝업창이 필요한 경우 window.open()을 통해 새 창을 띄우는데, 이때 호출되는 함수입니다. 새로운 웹뷰를 Dialog에 붙여 보여줄 수 있습니다.
  • onCloseWindow: 웹뷰에서 창을 닫는 시점에 호출됩니다. 위에서 설명한 onCreateWindow를 통해 띄워진 새로운 웹뷰가 닫힐 때, 여기서 Dialog 종료 처리를 해줄 수 있습니다.
  • onProgressChanged: 웹뷰가 로딩 중일 때 호출되며, 0~100 사이의 진행도를 알 수 있습니다. 웹뷰 로딩 중 프로그레스바를 띄워야한다면 이 진행도를 통해 프로그레스를 지정할 수 있습니다.
  • onShowCustomView, onHideCustomView: 각각 웹 페이지가 Full Screen Mode로 진입, 해제할 때 호출됩니다. 웹뷰에서 비디오를 보여줄 때, Full Screen Mode로 처리를 해주기 위해서는 이 콜백 함수를 따로 처리해주어야 합니다. 아래는 웹뷰에서 버튼을 눌러 접근한 유튜브 빠더너스 채널 동영상 페이지입니다. (땅훈씨 사랑합니다..🫶)

별다른 처리를 해주지 않으면 위와 같이 재생바 우상단의 전체화면 아이콘을 눌러도 화면이 그대로인 상황이 발생합니다. 이를 위해 아래와 같이 프레임 레이아웃에 전체 화면을 덮는 뷰를 추가, 제거하는 로직을 추가해주면 풀 스크린 모드 진입 시 화면이 회전되면서 전체 화면을 꽉 채워 보여주도록, 해제 시 다시 화면이 회전되면서 이전처럼 축소해 보여주도록 동작하게 됩니다.

        val activity = LocalView.current.context as Activity
        var isFullScreen by rememberSaveable { mutableStateOf(false) }
    
        LaunchedEffect(isFullScreen) {
            activity.requestedOrientation =
                if (isFullScreen) {
                    ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
                } else {
                    ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
                }
        }
    
        AndroidView(
            factory = { context ->
                WebView(context).apply {
    
    								...
    
                    webChromeClient =
                        object : WebChromeClient() {
                            var customView: View? = null
    
                            override fun onShowCustomView(
                                view: View?,
                                callback: CustomViewCallback?,
                            ) {
                                super.onShowCustomView(view, callback)
                                isFullScreen = true
    
                                if (customView != null) {
                                    onHideCustomView()
                                    return
                                }
    
                                customView = view
                                (activity.window.decorView as FrameLayout).addView(
                                    customView,
                                    FrameLayout.LayoutParams(
                                        ViewGroup.LayoutParams.MATCH_PARENT,
                                        ViewGroup.LayoutParams.MATCH_PARENT,
                                    ),
                                )
                            }
    
                            override fun onHideCustomView() {
                                super.onHideCustomView()
                                isFullScreen = false
                                (activity.window.decorView as FrameLayout).removeView(customView)
                                customView = null
                            }
                        }
            	  ...
        )

😢 화면이 회전되면 웹뷰가 재시작되는데요..?
→ 화면이 회전되거나 폰트 사이즈가 바뀌는 등 Configuration Change가 발생하면 액티비티가 재생성되면서 당연히 웹뷰도 초기화됩니다. 이를 방지하려면 AndroidManifest 파일의 해당 액티비티의 configChanges를 아래처럼 재정의해주면 됩니다. 그렇게 되면 액티비티 재생성을 방지할 수 있습니다.

android:configChanges="fontScale|orientation|screenSize
  • onConsoleMessage: 웹뷰 콘솔 메시지를 logcat에 표시하려면 이 함수를 통해 메시지를 로그로 띄울 수 있습니다.

느낀점

그동안 이유도 모르고 설정해왔던 Settings 변수들이나 WebViewClient, WebChromeClient의 함수들이 어 떠한 액션에 대한 콜백을 처리하는지 알아볼 수 있었습니다 ! 꽤 많은 것들을 모르고 사용해왔었네요 😅 

이어서는 안드로이드 ↔ 웹뷰간의 통신, 즉 브릿지에 대한 것을 공부하고 포스트로 작성해보도록 하겠습니다 !

참고

WebSettings
WebViewClient
WebViewChromeClient
WebView wrapper for Jetpack Compose
Android, iOS 웹뷰에서 딥링크 열기
Custom WebChromeClient to Attain Full-Screen Features within WebView in Jetpack Compose

profile
yuuuzzzin의 개발 블로그

1개의 댓글

comment-user-thumbnail
2024년 2월 18일

좋은 글 감사합니다~

답글 달기