자바스크립트 인터페이스로 구현한 하이브리드 앱 기능들
기존 글에서 자바스크립트 인터페이스의 기본 원리와 작동 방식을 설명했다면, 이번에는 실제 프로젝트에서 구현한 다양한 기능들을 자세히 살펴보겠습니다.
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() 메소드는 웹에서 직접 호출할 수 있습니다. 위치 정보 요청 시 다음과 같은 작업을 수행합니다:
- UI 스레드 처리: handler.post를 사용해 UI 스레드에서 위치 요청 작업을 처리합니다.
- 리소스 정리: 이전 위치 요청이 있다면 정리합니다.
- 예외 처리: 위치 정보 요청 과정에서 발생할 수 있는 예외를 포착하고 적절히 처리합니다.
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.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)
}
}
이 메소드는 위치 정보를 가져오는 데 실패했을 때 호출되며, 중요한 폴백 전략을 구현합니다:
- 오류 로깅: 로그에 오류를 기록합니다.
- 웹 콘솔 오류: 웹 콘솔에 오류 메시지를 출력합니다.
- 기본 위치 제공: 사용자 경험을 위해 서울시청 좌표를 기본값으로 제공합니다.
이러한 폴백 전략은 앱이 오류 상황에서도 계속 작동할 수 있게 합니다.
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}")
}
}
}
이 함수는 다음 작업을 수행합니다:
- 입력 검증: URL과 파일명이 유효한지 확인합니다.
- 상태 알림: 다운로드 시작, 오류 등의 상태를 웹에 알립니다.
- 다운로드 관리자 사용: 안드로이드의 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를 설정하고 사용하는 방법을 보여줍니다:
- 다운로드 요청 생성: 파일명, 설명, 알림 가시성, 저장 위치 등을 설정합니다.
- 다운로드 큐에 추가: 요청을 DownloadManager의 큐에 추가합니다.
- 완료 리시버 등록: 다운로드 완료를 감지하기 위한 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 클래스에서 웹으로부터 사용자 정보를 받아 처리하는 과정을 보여줍니다:
- JSON 파싱: 웹에서 전달받은 JSON 데이터에서 사용자 ID와 이름을 추출합니다.
- 데이터 검증: checkPushData 메소드를 통해 필요한 데이터가 모두 있는지 확인합니다.
- 토큰 전송: 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 토큰을 관리하는 방법을 보여줍니다:
- 토큰 저장: MainActivity에서 앱 시작 시 FCM 토큰을 가져와 MemberShipPref에 저장합니다.
- 토큰 전송: MainFragment에서는 저장된 토큰과 웹에서 받은 사용자 정보를 함께 서버에 전송합니다.
- 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 패턴을 사용하여 토큰 전송 결과를 처리하는 방식을 보여줍니다:
- LiveData 사용: tokenResult LiveData를 관찰하여 상태 변화에 반응합니다.
- 상태 관리: Loading, Success, Error 상태에 따라 적절한 처리를 합니다.
- 관심사 분리: 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()을 지원하기 위해 다음과 같은 설정이 필요합니다:
- 자바스크립트 활성화: javaScriptEnabled = true로 설정하여 자바스크립트 실행을 허용합니다.
- 팝업 허용: javaScriptCanOpenWindowsAutomatically = true로 설정하여 자바스크립트가 새 창을 열 수 있게 합니다.
- 멀티 윈도우 지원: 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를 구현하여 웹에서의 다양한 이벤트를 처리합니다:
- 팝업 창 생성: onCreateWindow 메소드는 웹에서 window.open()이 호출될 때 실행됩니다. 새로운 WebView를 생성하고 설정한 후, AlertDialog로 팝업 창을 표시합니다.
- 자바스크립트 대화상자: onJsAlert와 onJsConfirm 메소드는 웹에서 alert()와 confirm() 함수가 호출될 때 네이티브 대화상자를 표시합니다.
- 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
}
이 코드는 리소스 관리와 메모리 누수 방지를 위한 방법을 보여줍니다:
- 위치 리스너 제거: 더 이상 필요하지 않은 위치 업데이트 리스너를 제거합니다.
- 타이머 취소: 실행 중인 타이머(Handler의 Runnable)를 취소합니다.
- Fragment 소멸 시 정리: onDestroyView에서 모든 리소스를 정리합니다.
결론
자바스크립트 인터페이스를 활용한 하이브리드 앱 개발은 웹의 유연성과 네이티브의 강력한 기능을 결합할 수 있는 효과적인 방법입니다. 이 프로젝트에서는 다음과 같은 핵심 기능들이 구현되었습니다:
- 위치 정보 서비스: 네이티브 위치 API를 사용하여 더 정확하고 효율적인 위치 정보를 웹에 제공합니다.
- 파일 다운로드: DownloadManager를 활용하여 파일 다운로드를 안정적으로 처리합니다.
- 푸시 알림 토큰 관리: FCM 토큰을 관리하고 서버에 등록하여 푸시 알림을 받을 수 있게 합니다.
- 웹뷰 팝업 창: window.open()을 네이티브 환경에서 적절히 처리하여 팝업 창을 표시합니다.
- 리소스 관리: 위치 서비스와 같은 시스템 리소스를 적절히 관리하여 메모리 누수를 방지합니다.
'Android' 카테고리의 다른 글
[Android] 리사이클러 뷰간 drag and drop 기능 구현 예제 (feat.Kotlin) (0) | 2025.05.16 |
---|---|
코루틴을 활용한 비동기 작업 순서 (3) | 2025.05.15 |
[Android]SQLiteDatabase - 트랜잭션 & 비동기 처리 (0) | 2025.05.12 |
[Android] 자바스크립트 인터페이스 (1) | 2025.05.08 |
[Android] Intent (0) | 2025.02.28 |