일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- ANR이란
- 안드로이드 라이선스 종류
- 안드로이드 레트로핏 사용법
- 큐 자바 코드
- 안드로이드 라이선스
- 스택 자바 코드
- android ar 개발
- 서비스 쓰레드 차이
- 안드로이드 유닛 테스트 예시
- ar vr 차이
- 플러터 설치 2022
- 2022 플러터 안드로이드 스튜디오
- jvm이란
- jvm 작동 원리
- 안드로이드 유닛테스트란
- 안드로이드 유닛 테스트
- android retrofit login
- 안드로이드 os 구조
- 멤버변수
- rxjava hot observable
- 자바 다형성
- rxjava disposable
- 안드로이드 레트로핏 crud
- rxjava cold observable
- 클래스
- 2022 플러터 설치
- Rxjava Observable
- 객체
- 스택 큐 차이
- 서비스 vs 쓰레드
- Today
- Total
나만을 위한 블로그
[Android] 웹뷰 브릿지 연동 방법 본문
이 포스팅에선 안드로이드에서 간단한 브릿지 통신으로 웹뷰와 통신하는 방법을 확인한다.
시작 전에 난 프론트엔드 분야는 몰라서 html 파일은 안드로이드 프로젝트의 assets 폴더에 inner html 파일을 만들어 사용했고 html 내용은 클로드를 참고해서 작성했음을 미리 써 둔다.
먼저 매니페스트에 인터넷 권한을 추가한다. 이건 필수다.
<uses-permission android:name="android.permission.INTERNET" />
index.html은 아래처럼 구성한다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>안드로이드 웹뷰 브릿지 테스트</title>
<style>
body {
font-family: 'Arial', sans-serif;
margin: 20px;
text-align: center;
}
button {
padding: 10px 15px;
margin: 10px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>안드로이드 웹뷰 브릿지 테스트</h1>
<button onclick="callAndroid('웹뷰에서 이 값을 전달할 것')">안드로이드 함수 호출</button>
<button onclick="callAndroidWithJson()">JSON 데이터 전송</button>
<div id="result">결과가 여기에 표시됩니다</div>
<script>
// 안드로이드에서 이 함수를 호출
function receiveFromAndroid(message) {
document.getElementById('result').innerText = "안드로이드로부터 수신: " + message;
return "자바스크립트에서 응답: " + message;
}
// 안드로이드 함수 호출
function callAndroid(message) {
// android : 안드로이드에서 JavascriptInterface로 등록한 객체명(addJavascriptInterface()의 2번 매개변수)
if (window.android) {
window.android.receiveMessage(message);
} else {
document.getElementById('result').innerText = "브릿지가 설정되지 않았습니다";
}
}
// JSON 데이터로 안드로이드 함수 호출
function callAndroidWithJson() {
const data = {
action: "테스트 액션",
value: "테스트 데이터 value",
timestamp: new Date().getTime()
};
if (window.android) {
window.android.receiveJson(JSON.stringify(data));
} else {
document.getElementById('result').innerText = "브릿지가 설정되지 않았습니다";
}
}
</script>
</body>
</html>
이 파일을 작성하면 에디터 화면 오른쪽 위에 아래와 같은 아이콘들이 보일 것이다. 안 보인다면 에디터 화면을 클릭하거나 마우스를 움직이면 나올 것이다.
브라우저를 선택하면 이런 화면이 나온다.
이제 액티비티의 기본 틀을 구성한다. 웹뷰 하나만 존재하는 액티비티다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
tools:context=".presentation.webview.WebViewBridgeActivity">
<WebView
android:id="@+id/wvTest"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.regacyviewexample.databinding.ActivityWebViewBridgeBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class WebViewBridgeActivity : AppCompatActivity() {
private lateinit var binding: ActivityWebViewBridgeBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityWebViewBridgeBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
이걸 바탕으로 웹뷰 설정부터 시작해본다. 웹뷰 초기화를 수행하는 함수를 추가한다.
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebSettings
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.regacyviewexample.R
import com.example.regacyviewexample.databinding.ActivityWebViewBridgeBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class WebViewBridgeActivity : AppCompatActivity() {
private val TAG = this::class.simpleName
private lateinit var binding: ActivityWebViewBridgeBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityWebViewBridgeBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initWebView()
}
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
binding.wvTest.apply {
settings.run {
javaScriptEnabled = true
domStorageEnabled = true
textZoom = 100
// 웹 페이지 전체가 표시되게 자동 축소
loadWithOverviewMode = true
// PC 버전 웹 페이지를 지원할 건지 여부
useWideViewPort = true
// 웹뷰에서 파일에 접근하게 허용할 건지 여부
allowFileAccess = true
// https에서 http 컨텐츠도 표시될 수 있게 함
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// 웹뷰 캐시 정책 설정
cacheMode = WebSettings.LOAD_DEFAULT
}
// 자바스크립트 인터페이스 등록
// html -> 안드로이드 접근을 위한 필수 처리
addJavascriptInterface(MyWebInterface(), "android")
// inner html 불러오기
loadUrl("file:///android_asset/index.html")
}
}
}
addJavascriptInterface()의 2번째 매개변수인 "android"는 html에서 "window.android"의 android와 일치해야 한다.
웹뷰 캐시 정책은 아래 링크를 참고한다.
https://onlyfor-me-blog.tistory.com/826
[Android] WebView Cache 전략 설정 방법
이전 포스팅에서 캐시에 대해 확인했다. https://onlyfor-me-blog.tistory.com/825 캐시란? 웹뷰를 사용하다 보면 캐시라는 말을 자주 듣는다. 프론트엔드 개발자가 존재한다면 캐시라는 키워드를 더 자주
onlyfor-me-blog.tistory.com
중요한 설정은 javaScriptEnabled, domStorageEnabled를 true로 설정하는 것과 addJavascriptInterface(), loadUrl()이다.
다른 설정들은 요구되는 웹뷰 스펙에 맞춰 바꿔주면 되지만 위 4개는 웹뷰 브릿지 통신을 구현한다면 절대적으로 반드시 필요하다.
각 속성이 없으면 발생하는 상황을 정리했다.
- javaScriptEnabled가 없으면 = 웹뷰의 자바스크립트 기능을 쓸 수 없다
- domStorageEnabled가 없으면 = 웹뷰의 로컬 스토리지, 세션 스토리지라는 저장소를 사용할 수 없다. 즉 이 저장소를 사용하는 기능이 필요할 때 쓸 수 없거나 제한이 생긴다
- addJavascriptInterface()가 없으면 = 브릿지 함수 호출 자체가 불가능하다. UI는 있지만 클릭해도 어떤 응답도 하지 않는 이쁜 쓰레기가 완성된다
- loadUrl()이 없으면 = 웹뷰 자체를 띄울 수 없다. 아래의 완성 코드에서 loadUrl()을 제거하면 다크 모드를 설정한 경우 검은 화면만 표시된다
domStorageEnabled를 처리하지 않은 경우 발생할 수 있는 사고로는 노션 페이지를 웹뷰에서 띄울 때 표시되지 않는 것이다. 심하면 백화현상이라는 흰 화면만 표시되고 아무 일도 발생하지 않는 현상이 발생할 수 있다. 그러니 다른 건 몰라도 이 4가지는 반드시 추가해야 한다.
이제 MyWebInterface라는 클래스를 구현해야 한다. 이 클래스에는 @JavascriptInterface가 붙은 함수를 구현한다.
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.example.regacyviewexample.R
import com.example.regacyviewexample.databinding.ActivityWebViewBridgeBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.json.JSONObject
@AndroidEntryPoint
class WebViewBridgeActivity : AppCompatActivity() {
private val TAG = this::class.simpleName
private lateinit var binding: ActivityWebViewBridgeBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityWebViewBridgeBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initWebView()
}
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
binding.wvTest.apply {
settings.run {
javaScriptEnabled = true
domStorageEnabled = true
// 웹뷰 텍스트 크기가 시스템 폰트에 영향 받지 않고 100%로 고정시킴
// 시스템 폰트 = 핸드폰 설정 앱에서 설정한 글자 크기
textZoom = 100
// PC 버전 웹 페이지를 지원할 건지 여부
useWideViewPort = true
// 웹뷰에서 파일에 접근하게 허용할 건지 여부
allowFileAccess = true
// https에서 http 컨텐츠도 표시될 수 있게 함
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// 웹뷰 캐시 정책 설정
cacheMode = WebSettings.LOAD_DEFAULT
}
// 자바스크립트 인터페이스 등록
// html -> 안드로이드 접근을 위한 필수 처리
addJavascriptInterface(MyWebInterface(), "android")
// inner html 불러오기
loadUrl("file:///android_asset/index.html")
}
}
inner class MyWebInterface {
@JavascriptInterface
fun receiveMessage(message: String) {
lifecycleScope.launch {
Toast.makeText(this@WebViewBridgeActivity, "웹뷰에서 전달받음 : $message", Toast.LENGTH_SHORT).show()
binding.wvTest.evaluateJavascript(
"javascript:receiveFromAndroid('이 텍스트가 웹뷰에 표시됨')",
null
)
}
}
@JavascriptInterface
fun receiveJson(json: String) {
try {
val jsonObj = JSONObject(json)
val action = jsonObj.getString("action")
val value = jsonObj.getString("value")
val timeStamp = jsonObj.getLong("timestamp")
Log.d(TAG, "## [webview] 웹뷰에서 받은 값 - action : $action, value : $value, timestamp : $timeStamp")
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
예시에선 inner class로 구현했지만 어떻게 구현하든 상관없다.
@JavascriptInterface가 붙은 함수는 실제로 호출하지 않는다. 주석에 써놨듯 addJavascriptInterface()는 웹뷰에서 안드로이드 함수에 접근하기 위해 선언하는 함수다. 안드로이드에서 호출하기 위해 작성하는 함수들이 아니다.
또한 이 브릿지 함수에서 UI 작업을 실행하거나 카메라 같은 네이티브 기능에 접근해야 한다면 lifecycleScope.launch 또는 runOnUiThread로 감싸는게 좋다.
Java exeption was raised during method invocation이란 에러가 발생하면서 앱 크래시가 발생할 수 있기 때문이다. 자세한 내용은 아래 포스팅을 참고한다.
https://onlyfor-me-blog.tistory.com/774
[Android] 웹뷰 브릿지 통신 시 Java exception was raised during method invocation 에러 해결법
브릿지로 웹뷰와 통신할 때, 웹뷰에서 앱의 카메라 기능을 다루는 함수를 호출하려고 하면 제목의 에러가 발생했다.저 문장을 번역하면 메서드 호출 중에 자바 예외가 발생했다는 뜻이다.이 에
onlyfor-me-blog.tistory.com
예시에서 lifecycleScope.launch를 없애도 토스트는 정상적으로 표시된다. 그러나 메인 쓰레드에서 해야 하는 UI 작업이 있을 경우 안전하게 처리하기 위해 lifecycleScope.launch로 감싸주는 게 좋은 방법이라고 생각된다.
완성된 코드를 실행하면 안드로이드 함수 호출 버튼을 누를 경우 토스트가 표시되고, JSON 데이터 전송 버튼을 누르면 로그캣에 웹뷰에서 전달받은 값들이 표시될 것이다.
이제 안드로이드에서 웹뷰로 데이터를 보내고, 웹뷰에서 받은 데이터를 안드로이드에서 토스트와 로그로 표시하는 것까지 확인했다. 다음으로 웹뷰의 버튼을 누르면 안드로이드 기기에서 사진을 선택한 다음 웹뷰에 사진을 표시하는 예시를 확인한다.
다시 말하지만 html 코드는 AI를 통해 만든 것이다. 절대 실제로 사용해선 안 된다. 아래는 index.html의 전체 코드다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>안드로이드 웹뷰 브릿지 테스트</title>
<style>
body {
font-family: 'Arial', sans-serif;
margin: 20px;
text-align: center;
}
button {
padding: 10px 15px;
margin: 10px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
#imagePreview {
margin-top: 20px;
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<h1>안드로이드 웹뷰 브릿지 테스트</h1>
<button onclick="callAndroid('웹뷰에서 이 값을 전달할 것')">안드로이드 함수 호출</button>
<button onclick="callAndroidWithJson()">JSON 데이터 전송</button>
<button onclick="openImagePicker()">사진 선택하기</button>
<div id="result">결과가 여기에 표시됩니다</div>
<img id="imagePreview" style="display: none;" alt="선택한 이미지">
<script>
// 안드로이드에서 이 함수를 호출
function receiveFromAndroid(message) {
document.getElementById('result').innerText = "안드로이드로부터 수신: " + message;
return "자바스크립트에서 응답: " + message;
}
// 안드로이드 함수 호출
function callAndroid(message) {
if (window.android) {
window.android.receiveMessage(message);
} else {
document.getElementById('result').innerText = "브릿지가 설정되지 않았습니다";
}
}
// JSON 데이터로 안드로이드 함수 호출
function callAndroidWithJson() {
const data = {
action: "테스트 액션",
value: "테스트 데이터 value",
timestamp: new Date().getTime()
};
if (window.android) {
window.android.receiveJson(JSON.stringify(data));
} else {
document.getElementById('result').innerText = "브릿지가 설정되지 않았습니다";
}
}
// 사진 선택 요청
function openImagePicker() {
if (window.android) {
window.android.openImagePicker();
} else {
document.getElementById('result').innerText = "브릿지가 설정되지 않았습니다";
}
}
// 안드로이드에서 이미지 받아서 표시
function displaySelectedImage(base64Image) {
try {
console.log("이미지 데이터 수신 (길이: " + base64Image.length + ")");
const imagePreview = document.getElementById('imagePreview');
imagePreview.src = "data:image/jpeg;base64," + base64Image;
imagePreview.style.display = "block";
document.getElementById('result').innerText = "이미지가 선택되었습니다";
} catch (e) {
console.error("이미지 표시 오류:", e);
document.getElementById('result').innerText = "이미지 표시 중 오류 발생: " + e.message;
}
}
</script>
</body>
</html>
브라우저에서 확인하면 아래와 같이 표시된다. 핸드폰에서 확인하면 사진 선택하기 버튼이 아래에 표시될 수 있다.
매니페스트에 권한을 추가해준다. 인터넷 밑의 2개를 추가하면 된다.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
액티비티 코드는 아래처럼 수정한다.
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.example.regacyviewexample.R
import com.example.regacyviewexample.databinding.ActivityWebViewBridgeBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.ByteArrayOutputStream
@AndroidEntryPoint
class WebViewBridgeActivity : AppCompatActivity() {
private val TAG = this::class.simpleName
private lateinit var binding: ActivityWebViewBridgeBinding
private val imagePickerLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { selectedUri ->
try {
// uri에서 비트맵 얻음
val bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, selectedUri))
// 비트맵 이미지를 Base64 인코딩
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val byteArray = outputStream.toByteArray()
val base64Image = Base64.encodeToString(byteArray, Base64.NO_WRAP)
// 웹뷰에 이미지 전달
lifecycleScope.launch {
binding.wvTest.evaluateJavascript("javascript:displaySelectedImage('$base64Image')", null)
}
Log.d(TAG, "## [webview] 이미지 전송 (길이: ${base64Image.length})")
} catch (e: Exception) {
Log.e(TAG, "## [webview] 이미지 처리 오류 : $e")
e.printStackTrace()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityWebViewBridgeBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initWebView()
}
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
binding.wvTest.apply {
settings.run {
javaScriptEnabled = true
domStorageEnabled = true
textZoom = 100
useWideViewPort = true
allowFileAccess = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
addJavascriptInterface(MyWebInterface(), "android")
loadUrl("file:///android_asset/index.html")
}
}
inner class MyWebInterface {
@JavascriptInterface
fun receiveMessage(message: String) {
lifecycleScope.launch {
Toast.makeText(this@WebViewBridgeActivity, "웹뷰에서 전달받음 : $message", Toast.LENGTH_SHORT).show()
binding.wvTest.evaluateJavascript(
"javascript:receiveFromAndroid('이 텍스트가 웹뷰에 표시됨')",
null
)
}
}
@JavascriptInterface
fun receiveJson(json: String) {
try {
val jsonObj = JSONObject(json)
val action = jsonObj.getString("action")
val value = jsonObj.getString("value")
val timeStamp = jsonObj.getLong("timestamp")
Log.d(TAG, "## [webview] 웹뷰에서 받은 값 - action : $action, value : $value, timestamp : $timeStamp")
} catch (e: Exception) {
e.printStackTrace()
}
}
@JavascriptInterface
fun openImagePicker() {
lifecycleScope.launch {
imagePickerLauncher.launch("image/*")
}
}
}
}
실행한 후 사진 선택하기 버튼을 누르면 안드로이드 15 기준으로 photo picker가 표시되면서 사진을 선택할 수 있게 된다.
사진을 선택하면 로그캣에 디버그 로그로 이미지 전송 (길이: 57480)과 같은 형태로 로그가 표시된다. 이 로그는 크롬 인스펙터에서도 확인할 수 있다.
사진을 선택할 때마다 크롬 인스펙터에 로그가 쌓여가는 게 보일 것이다.
openImagePicker()는 앞서 말한대로 안드로이드에서 직접 호출하지 않는다. 대신 웹뷰에서 openImagePicker()를 호출했을 때 안드로이드에선 어떻게 처리할지에 대한 코드를 openImagePicker() 본문에 작성하면 된다. 예시에선 photo picker를 여는 것일 뿐이다.
이렇게 하면 안드로이드에서 웹뷰 브릿지 통신을 구현할 수 있다.
'Android' 카테고리의 다른 글
[Android] XML에서 FlowLayout 비슷한 UI 구현하는 법 (0) | 2025.05.13 |
---|---|
[Android] View의 생명주기 (0) | 2025.05.12 |
[Kotlin] 코루틴 디스패처 (0) | 2025.05.03 |
[Android] SQLite vs Room DB 비교 및 구현 - 2 - (0) | 2025.04.28 |
[Android] SQLite vs Room DB 비교 및 구현 - 1 - (0) | 2025.04.13 |