Android

[Android] 자바스크립트 인터페이스 실전편

김한토 2025. 5. 14. 14:16
반응형

자바스크립트 인터페이스로 구현한 하이브리드 앱 기능들

기존 글에서 자바스크립트 인터페이스의 기본 원리와 작동 방식을 설명했다면, 이번에는 실제 프로젝트에서 구현한 다양한 기능들을 자세히 살펴보겠습니다.

1. 위치 정보 서비스 구현

안드로이드의 네이티브 위치 정보 API를 사용하여 웹 환경에 사용자의 현재 위치를 제공하는 기능입니다.

1.1 WebAppInterface 구현

class WebAppInterface(
    private val context: Context,
    private val webView: WebView,
) {
    private val TAG = "WebAppInterface"
    private val handler = Handler(Looper.getMainLooper())

    // 위치 리소스 관리를 위한 변수들
    private var locationListener: LocationListener? = null
    private var locationTimeoutRunnable: Runnable? = null

    /**
     * 현재 위치 가져오기 함수 - 웹에서 호출
     */
    @JavascriptInterface
    fun getCurrentLocation() {
        Log.d(TAG, "getCurrentLocation 호출됨")

        handler.post {
            // 기존 위치 요청 정리
            cleanupLocationResources()

            try {
                requestLocation()
            } catch (e: Exception) {
                Log.e(TAG, "위치 정보 가져오기 오류", e)
                sendLocationError("위치 정보를 가져오는 중 오류가 발생했습니다: ${e.message}")
            }
        }
    }
}

이 코드에서 @JavascriptInterface 어노테이션이 달린 getCurrentLocation() 메소드는 웹에서 직접 호출할 수 있습니다. 위치 정보 요청 시 다음과 같은 작업을 수행합니다:

  1. UI 스레드 처리: handler.post를 사용해 UI 스레드에서 위치 요청 작업을 처리합니다.
  2. 리소스 정리: 이전 위치 요청이 있다면 정리합니다.
  3. 예외 처리: 위치 정보 요청 과정에서 발생할 수 있는 예외를 포착하고 적절히 처리합니다.

1.2 위치 정보 요청 및 처리

/**
 * 위치 정보 요청 및 처리
 */
private fun requestLocation() {
    val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager

    if (!hasLocationPermission()) {
        sendLocationError("위치 권한이 허용되지 않았습니다. 설정에서 권한을 확인해주세요.")
        return
    }

    // 위치 서비스 활성화 확인
    if (!isLocationServiceEnabled(locationManager)) {
        sendLocationError("위치 서비스가 비활성화되어 있습니다. 설정에서 위치 서비스를 켜주세요.")
        return
    }

    // 마지막 알려진 위치 확인
    val lastLocation = getLastKnownLocation(locationManager)
    if (lastLocation != null) {
        sendLocationToWeb(lastLocation.latitude, lastLocation.longitude)
        return
    }

    // 새로운 위치 요청
    requestNewLocation(locationManager)
}

이 메소드는 위치 정보를 요청하는 전체 프로세스를 관리합니다:

  1. 권한 확인: 위치 권한이 있는지 검사합니다.
  2. 서비스 활성화 확인: 위치 서비스가 켜져 있는지 확인합니다.
  3. 마지막 위치 재사용: 최근 위치 정보가 있으면 새로운 요청 없이 그것을 사용합니다.
  4. 새로운 위치 요청: 필요한 경우 새로운 위치 요청을 시작합니다.

이러한 단계적 접근 방식은 배터리 소모를 줄이고 사용자 경험을 향상시킵니다.

1.3 위치 정보 웹으로 전달

/**
 * 위치 정보 웹에 전달
 */
private fun sendLocationToWeb(latitude: Double, longitude: Double) {
    Log.d(TAG, "위치 정보 전송: $latitude, $longitude")

    try {
        webView.evaluateJavascript(
            "javascript:window.receiveLocation($latitude, $longitude)",
            null
        )
    } catch (e: Exception) {
        Log.e(TAG, "위치 정보 전송 오류", e)
    }
}

위치 정보를 얻은 후에는 evaluateJavascript를 사용해 웹페이지의 window.receiveLocation 함수를 호출합니다. 이는 네이티브에서 웹으로 데이터를 전달하는 중요한 매커니즘입니다.

1.4 오류 처리 및 폴백 전략

/**
 * 위치 정보 오류 웹에 전달 및 기본 위치 전송
 */
private fun sendLocationError(errorMessage: String) {
    Log.e(TAG, "위치 오류: $errorMessage")

    try {
        // 오류 메시지 콘솔 출력
        webView.evaluateJavascript(
            "javascript:console.error('위치 정보 오류: ${errorMessage.replace("'", "\\'")}')",
            null
        )

        // 기본 위치 전송 (서울시청 좌표)
        sendLocationToWeb(37.566535, 126.9779692)
    } catch (e: Exception) {
        Log.e(TAG, "위치 오류 전송 실패", e)
    }
}

이 메소드는 위치 정보를 가져오는 데 실패했을 때 호출되며, 중요한 폴백 전략을 구현합니다:

  1. 오류 로깅: 로그에 오류를 기록합니다.
  2. 웹 콘솔 오류: 웹 콘솔에 오류 메시지를 출력합니다.
  3. 기본 위치 제공: 사용자 경험을 위해 서울시청 좌표를 기본값으로 제공합니다.

이러한 폴백 전략은 앱이 오류 상황에서도 계속 작동할 수 있게 합니다.

2. 파일 다운로드 기능

웹에서 파일을 다운로드할 때 네이티브 DownloadManager를 사용하여 더 안정적이고 사용자 친화적인 경험을 제공합니다.

2.1 다운로드 인터페이스

/**
 * 파일 다운로드 함수 - 웹에서 호출
 */
@JavascriptInterface
fun downloadFile(url: String, fileName: String) {
    Log.d(TAG, "downloadFile 호출됨: $fileName, URL: $url")

    handler.post {
        notifyDownloadStatus("started", "")

        if (url.isBlank() || fileName.isBlank()) {
            notifyDownloadStatus("error", "URL 또는 파일명이 잘못되었습니다")
            return@post
        }

        try {
            startDownloadWithManager(url, fileName)
        } catch (e: Exception) {
            Log.e(TAG, "다운로드 오류", e)
            notifyDownloadStatus("error", "다운로드 오류: ${e.message}")
        }
    }
}

이 함수는 다음 작업을 수행합니다:

  1. 입력 검증: URL과 파일명이 유효한지 확인합니다.
  2. 상태 알림: 다운로드 시작, 오류 등의 상태를 웹에 알립니다.
  3. 다운로드 관리자 사용: 안드로이드의 DownloadManager를 통해 파일 다운로드를 처리합니다.

웹에서는 window.android.downloadFile(url, fileName)으로 이 기능을 호출할 수 있습니다.

2.2 DownloadManager를 사용한 파일 다운로드

/**
 * 다운로드 매니저를 사용한 파일 다운로드
 */
private fun startDownloadWithManager(url: String, fileName: String) {
    val request = DownloadManager.Request(url.toUri()).apply {
        setTitle(fileName)
        setDescription("파일 다운로드")
        setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
        setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
    }

    val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
    val downloadId = downloadManager.enqueue(request)

    // 다운로드 완료 리시버 등록
    val onComplete = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
            if (id == downloadId) {
                handleDownloadCompleted(downloadManager, downloadId, fileName)
                context.unregisterReceiver(this)
            }
        }
    }

    // 안드로이드 버전에 맞는 리시버 등록
    ContextCompat.registerReceiver(
        context,
        onComplete,
        IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
        ContextCompat.RECEIVER_NOT_EXPORTED
    )
}

이 코드는 DownloadManager를 설정하고 사용하는 방법을 보여줍니다:

  1. 다운로드 요청 생성: 파일명, 설명, 알림 가시성, 저장 위치 등을 설정합니다.
  2. 다운로드 큐에 추가: 요청을 DownloadManager의 큐에 추가합니다.
  3. 완료 리시버 등록: 다운로드 완료를 감지하기 위한 BroadcastReceiver를 등록합니다.

DownloadManager를 사용하면 앱이 백그라운드에 있거나 종료되더라도 다운로드가 계속됩니다.

2.3 다운로드 상태 처리

/**
 * 다운로드 상태 웹에 알림
 */
private fun notifyDownloadStatus(status: String, message: String) {
    try {
        val escapedMessage = message.replace("'", "\\'")
        webView.evaluateJavascript(
            "javascript:window.downloadResult('$status', '$escapedMessage')",
            null
        )
    } catch (e: Exception) {
        Log.e(TAG, "다운로드 상태 알림 오류", e)
    }
}

이 함수는 다운로드 상태(시작, 완료, 오류)를 웹페이지에 알립니다. 웹에서는 window.downloadResult 함수를 통해 이 정보를 받아 처리할 수 있습니다.

3. 푸시 알림 토큰 관리

FCM(Firebase Cloud Messaging)을 통한 푸시 알림을 위해 토큰을 서버에 등록하는 과정을 구현했습니다.

3.1 WebBridge를 통한 사용자 정보 수신

/**
 * 웹과 네이티브 앱 간의 통신을 위한 브릿지 클래스
 */
class WebBridge(
    private val webView: WebView,
    private val fragment: Fragment,
    private val webAppInterface: WebAppInterface
) {
    private val TAG = "WebBridge"
    private val handler = Handler(Looper.getMainLooper())

    /**
     * 푸시 메시지 설정 - 웹에서 호출
     */
    @JavascriptInterface
    fun setMessage(arg: String) {
        Log.d(TAG, "setMessage $arg")

        handler.post {
            try {
                val jsonObject = JSONObject(arg)
                val memberId = jsonObject.optString("id", "")
                val memberName = jsonObject.optString("name", "")

                Log.d(TAG, "Received push data - ID: $memberId, Name: $memberName")

                when {
                    checkPushData(arg) -> {
                        Log.d(TAG, "Push data")
                        (fragment as? MainFragment)?.sendTokenToServer(memberId, memberName)
                    }

                    else -> {
                        Log.d(TAG, "else")
                    }
                }
            } catch (e: JSONException) {
                Log.e(TAG, "Invalid JSON format: $arg", e)
            }
        }
    }
}

이 코드는 WebBridge 클래스에서 웹으로부터 사용자 정보를 받아 처리하는 과정을 보여줍니다:

  1. JSON 파싱: 웹에서 전달받은 JSON 데이터에서 사용자 ID와 이름을 추출합니다.
  2. 데이터 검증: checkPushData 메소드를 통해 필요한 데이터가 모두 있는지 확인합니다.
  3. 토큰 전송: Fragment의 sendTokenToServer 메소드를 호출하여 토큰과 사용자 정보를 함께 서버에 전송합니다.

3.2 FCM 토큰 처리

// MainFragment.kt
fun sendTokenToServer(memberId: String, memberName: String) {
    val token = MemberShipPref.token

    if (token.isEmpty()) {
        Log.e(TAG, "FCM Token is empty. Cannot send to server.")
        return
    }

    viewModel.sendPushToken(id = memberId, name = memberName, token = token)
}

// MainActivity.kt
private fun getToken() {
    val currentToken = MemberShipPref.token
    Log.d(TAG, "getToken() currentToken : $currentToken")

    FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
        if (!task.isSuccessful) {
            Log.w(TAG, "Fetching FCM registration token failed", task.exception)
            return@OnCompleteListener
        }

        // FCM 등록 토큰 가져오기
        val token = task.result

        val msg = "FCM Registration token: $token"

        if (currentToken != token) {
            MemberShipPref.token = token
        }
    })
}

이 코드는 FCM 토큰을 관리하는 방법을 보여줍니다:

  1. 토큰 저장: MainActivity에서 앱 시작 시 FCM 토큰을 가져와 MemberShipPref에 저장합니다.
  2. 토큰 전송: MainFragment에서는 저장된 토큰과 웹에서 받은 사용자 정보를 함께 서버에 전송합니다.
  3. ViewModel 사용: MVVM 패턴에 따라 실제 서버 통신은 ViewModel에서 처리합니다.

3.3 MVVM 패턴을 활용한 토큰 관리

// MainViewModel.kt 역할
/**
 * LiveData 관찰 설정
 */
private fun observeViewModel() {
    viewModel.tokenResult.observe(viewLifecycleOwner) { result ->
        when (result) {
            is com.posbank.paimembership.Result.Loading -> {
                Log.d(TAG, "Send Token Loading")
            }

            is com.posbank.paimembership.Result.Success<*> -> {
                Log.d(TAG, "Send Token Successfully")
            }

            is com.posbank.paimembership.Result.Error -> {
                // 오류 처리
                Log.e(TAG, "Send Token Error: ${result.message}")
            }
        }
    }
}

이 코드는 MVVM 패턴을 사용하여 토큰 전송 결과를 처리하는 방식을 보여줍니다:

  1. LiveData 사용: tokenResult LiveData를 관찰하여 상태 변화에 반응합니다.
  2. 상태 관리: Loading, Success, Error 상태에 따라 적절한 처리를 합니다.
  3. 관심사 분리: UI 로직과 비즈니스 로직을 분리하여 코드의 유지보수성을 높입니다.

4. 웹뷰 팝업 창 구현

웹에서 window.open()을 사용할 때 네이티브 환경에서 팝업 창을 표시하는 방법입니다.

4.1 WebView 설정

@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
private fun initWebView() {
    WebView.setWebContentsDebuggingEnabled(true)

    webView.settings.apply {
        javaScriptEnabled = true
        domStorageEnabled = true
        loadWithOverviewMode = true
        useWideViewPort = true

        // window.open()
        javaScriptCanOpenWindowsAutomatically = true
        setSupportMultipleWindows(true)
    }
    
    // WebChromeClient 설정...
}

window.open()을 지원하기 위해 다음과 같은 설정이 필요합니다:

  1. 자바스크립트 활성화: javaScriptEnabled = true로 설정하여 자바스크립트 실행을 허용합니다.
  2. 팝업 허용: javaScriptCanOpenWindowsAutomatically = true로 설정하여 자바스크립트가 새 창을 열 수 있게 합니다.
  3. 멀티 윈도우 지원: setSupportMultipleWindows(true)로 설정하여 여러 창을 동시에 지원합니다.

4.2 WebChromeClient 구현

webView.webChromeClient = object : WebChromeClient() {
    override fun onCreateWindow(
        view: WebView,
        isDialog: Boolean,
        isUserGesture: Boolean,
        resultMsg: android.os.Message
    ): Boolean {
        Log.d(TAG, "onCreateWindow 호출됨: isDialog=$isDialog, isUserGesture=$isUserGesture")

        // 새 WebView 생성
        val newWebView = WebView(requireContext())
        newWebView.settings.apply {
            javaScriptEnabled = true
            javaScriptCanOpenWindowsAutomatically = true
            domStorageEnabled = true
            setSupportMultipleWindows(true)
            loadWithOverviewMode = true
            useWideViewPort = true
        }

        // 새 WebView 설정
        newWebView.webViewClient = WebViewClient()

        // WebView.WebViewTransport 설정
        val transport = resultMsg.obj as WebView.WebViewTransport
        transport.webView = newWebView
        resultMsg.sendToTarget()

        // 다이얼로그로 표시
        val dialog = android.app.AlertDialog.Builder(requireContext())
            .setTitle("팝업 창")
            .setView(newWebView)
            .setPositiveButton("닫기") { dialog, _ -> dialog.dismiss() }
            .create()

        dialog.show()

        return true
    }

    // 자바스크립트 alert 다이얼로그 처리
    override fun onJsAlert(
        view: WebView?,
        url: String?,
        message: String?,
        result: android.webkit.JsResult?
    ): Boolean {
        android.app.AlertDialog.Builder(requireContext())
            .setTitle("알림")
            .setMessage(message)
            .setPositiveButton("확인") { _, _ -> result?.confirm() }
            .setCancelable(false)
            .show()
        return true
    }

    // 자바스크립트 confirm 다이얼로그 처리
    override fun onJsConfirm(
        view: WebView?,
        url: String?,
        message: String?,
        result: android.webkit.JsResult?
    ): Boolean {
        android.app.AlertDialog.Builder(requireContext())
            .setTitle("확인")
            .setMessage(message)
            .setPositiveButton("확인") { _, _ -> result?.confirm() }
            .setNegativeButton("취소") { _, _ -> result?.cancel() }
            .setCancelable(false)
            .show()
        return true
    }
}

이 코드는 WebChromeClient를 구현하여 웹에서의 다양한 이벤트를 처리합니다:

  1. 팝업 창 생성: onCreateWindow 메소드는 웹에서 window.open()이 호출될 때 실행됩니다. 새로운 WebView를 생성하고 설정한 후, AlertDialog로 팝업 창을 표시합니다.
  2. 자바스크립트 대화상자: onJsAlert와 onJsConfirm 메소드는 웹에서 alert()와 confirm() 함수가 호출될 때 네이티브 대화상자를 표시합니다.
  3. WebViewTransport: resultMsg.obj as WebView.WebViewTransport를 통해 새 WebView를 시스템에 등록하여 콘텐츠가 로드될 수 있게 합니다.

이러한 구현을 통해 웹에서 window.open()을 호출할 때 네이티브 환경에서 팝업 창이 표시됩니다.

5. 리소스 관리와 메모리 누수 방지

하이브리드 앱에서는 적절한 리소스 관리가 중요합니다. 특히 위치 서비스와 같은 시스템 리소스는 사용 후 정리가 필요합니다.

/**
 * 위치 관련 리소스 정리
 */
fun cleanupLocationResources() {
    try {
        locationListener?.let {
            val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
            locationManager?.removeUpdates(it)
            locationListener = null
        }

        locationTimeoutRunnable?.let {
            handler.removeCallbacks(it)
            locationTimeoutRunnable = null
        }
    } catch (e: Exception) {
        Log.e(TAG, "위치 리소스 정리 중 오류", e)
    }
}

override fun onDestroyView() {
    super.onDestroyView()
    Log.d(TAG, "onDestroyView")

    // 위치 리소스 정리
    if (::webAppInterface.isInitialized) {
        webAppInterface.cleanupLocationResources()
    }

    _binding = null
}

이 코드는 리소스 관리와 메모리 누수 방지를 위한 방법을 보여줍니다:

  1. 위치 리스너 제거: 더 이상 필요하지 않은 위치 업데이트 리스너를 제거합니다.
  2. 타이머 취소: 실행 중인 타이머(Handler의 Runnable)를 취소합니다.
  3. Fragment 소멸 시 정리: onDestroyView에서 모든 리소스를 정리합니다.

결론

자바스크립트 인터페이스를 활용한 하이브리드 앱 개발은 웹의 유연성과 네이티브의 강력한 기능을 결합할 수 있는 효과적인 방법입니다. 이 프로젝트에서는 다음과 같은 핵심 기능들이 구현되었습니다:

  1. 위치 정보 서비스: 네이티브 위치 API를 사용하여 더 정확하고 효율적인 위치 정보를 웹에 제공합니다.
  2. 파일 다운로드: DownloadManager를 활용하여 파일 다운로드를 안정적으로 처리합니다.
  3. 푸시 알림 토큰 관리: FCM 토큰을 관리하고 서버에 등록하여 푸시 알림을 받을 수 있게 합니다.
  4. 웹뷰 팝업 창: window.open()을 네이티브 환경에서 적절히 처리하여 팝업 창을 표시합니다.
  5. 리소스 관리: 위치 서비스와 같은 시스템 리소스를 적절히 관리하여 메모리 누수를 방지합니다.
반응형