웹뷰 연동

매체사(WebView -> Web)

매체사(네이티브 앱) 웹뷰 호스트가 콘텐츠 웹의 자바스크립트 인터페이스를 호출하기 위한 구현 가이드입니다. AOS(Kotlin), iOS(Swift), Flutter(Dart) 샘플 코드를 제공합니다.

초기설정

  • 데이터는 단일 데이터의 경우 해당 값을 String으로 전달. 다중 데이터는 JSON 형태의 String으로 전달한다.

AOS

kotlin
//init
addJavascriptInterface(CustomWebAppInterface(context, this), "addedBridge")


class CustomWebAppInterface(private val context: Context, private val webView: WebView) {

    @JavascriptInterface
    fun interfaceName(message : String){
    	//요청값 callback
    	webView?.evaluateJavascript(
                "addedBridgeCallback.interfaceName('value')",
                null
      )
    }
}

iOS

swift
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "addedBridge")


 func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == "addedBridge" else { return }

        // message.body는 JS에서 보낸 객체
        guard let body = message.body as? [String: Any],
              let method = body["method"] as? String,
              let param = body["message"] as? String else { return }

        switch method {
	        case "interfaceName":
	            // 요청값 callback
	            let value = "value"
	            let escaped = value.replacingOccurrences(of: "'", with: "\\'")
	            webView.evaluateJavaScript(
	                "addedBridgeCallback.interfaceName('\(escaped)')"
	            )
	        default:
	            break
        }
    }

Flutter

dart
WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'addedBridge',
        onMessageReceived: (JavaScriptMessage message) {
          // message.message = JSON 문자열
          final body = jsonDecode(message.message);
          final method = body['method'] as String;
          final param = body['message'] as String;

          switch (method) {
            case 'interfaceName':
              // 요청값 callback
              final value = 'value';
              _controller.runJavaScript(
                "addedBridgeCallback.interfaceName('$value')",
              );
              break;
          }
        },
      )

필수

finish()

  • 현재 웹뷰를 종료합니다.

AOS

kotlin
@JavascriptInterface
fun finish(message: String) {
    Log.i("addedBridge", "finish")
    (context as? ComponentActivity)?.runOnUiThread {
        (context as? ComponentActivity)?.finish()
    }
}

iOS

swift
case "finish":
	DispatchQueue.main.async {
	  if let nav = self.navigationController {
	    nav.popViewController(animated: true)
    } else {
	    self.dismiss(animated: true)
    }
}

Flutter

dart
case 'finish':
  if (mounted) Navigator.of(context).pop();
  break;

registerBackKey()

  • 시스템 뒤로가기(제스처/버튼)가 호출되면 네이티브에서 addedBridgeCallback.onBackKeyListener() 를 호출합니다.
  • @param {string} true|false

AOS

kotlin

@JavascriptInterface
fun registerBackKey( message : String ) {
	Log.i("addedBridge", "registerBackKey")
	isBackKeyRegistered = message == "true";
}


var isBackKeyRegistered by remember { mutableStateOf(false) }

// 시스템 뒤로가기(제스처/버튼) → onBackKeyListener
BackHandler(enabled = true) {
    when {
        isBackKeyRegistered -> {
            webView?.evaluateJavascript(
                "addedBridgeCallback.onBackKeyListener()", null
            )
        }
        canGoBack -> webView?.goBack()
        else -> (webView?.context as? ComponentActivity)?.finish()
    }
}

iOS

swift
var isBackKeyRegistered = false

case "registerBackKey":
    let flag = (body["message"] as? String) ?? "false"
    isBackKeyRegistered = (flag == "true")


//시스템 뒤로가기 제스처
@objc private func onEdgeSwipe(_ gesture: UIScreenEdgePanGestureRecognizer) {
    guard gesture.state == .recognized else { return }

    if isBackKeyRegistered {
        webView.evaluateJavaScript("addedBridgeCallback.onBackKeyListener()")
    } else if webView.canGoBack {
        webView.goBack()
    } else {
         if let nav = navigationController {
            nav.popViewController(animated: true)
        } else {
            dismiss(animated: true)
        }
    }
}

Flutter

dart
bool _isBackKeyRegistered = false;

case 'registerBackKey':
  setState(() {
     _isBackKeyRegistered = message == "true";
  });
  break;


// 시스템 뒤로가기 / 제스처
PopScope(
  canPop: !_isBackKeyRegistered && !_canGoBack,
  onPopInvokedWithResult: (didPop, result) {
    if (didPop) return;

    if (_isBackKeyRegistered) {
      _controller.runJavaScript("addedBridgeCallback.onBackKeyListener()");
    } else if (_canGoBack) {
      _controller.goBack();
    }
  },
)

registerBackButton()

  • 앱바 영역에 백버튼이 존재하는 경우에만 필요합니다.
  • 앱바의 백버튼이 터치되면 네이티브에서 addedBridgeCallback.onBackButtonListener() 를 호출합니다.
  • @param {string} true|false

AOS

kotlin
var isBackButtonRegistered by remember { mutableStateOf(false) }

case 'registeBackButton':
  setState(() {
    isBackButtonRegistered = (param == 'true');
  });
  break;


IconButton(onClick =
{
	if(isBackButtonRegistered){
		webView?.evaluateJavascript("addedBridgeCallback.onBackButtonListener()", null)
	} else if(canGoBack){
		webView?.goBack()
	} else {
		finish()
	}

}) {
	Icon(Icons.AutoMirrored.Filled.ArrowBack, "뒤로가기")
}

iOS

swift
var isBackButtonRegistered = false

case "registeBackButton":
    let flag = (body["message"] as? String) ?? "false"
    isBackButtonRegistered = (flag == "true")


//시스템 뒤로가기 제스처
@objc private func onEdgeSwipe(_ gesture: UIScreenEdgePanGestureRecognizer) {
    guard gesture.state == .recognized else { return }

    if isBackKeyRegistered {
        webView.evaluateJavaScript("addedBridgeCallback.onBackKeyListener()")
    } else if webView.canGoBack {
        webView.goBack()
    } else {
         if let nav = navigationController {
            nav.popViewController(animated: true)
        } else {
            dismiss(animated: true)
        }
    }
}


@objct private func onBackButtonTapped() {

    webView.evaluateJavaScript(
        "addedBridgeCallback.onBackButtonListener()"
    )
}

Flutter

dart
bool _isBackButtonRegistered = false;

case 'registerBackButton':
  setState(() {
     _isBackButtonRegistered = message == "true";
  });
  break;

AppBar(
      title: const Text('WebView'),
      leading: IconButton(
        icon: const Icon(Icons.arrow_back),
        onPressed: () {
	        if (_isBackKeyRegistered) {
            _controller.runJavaScript("addedBridgeCallback.onBackButtonListener()");
          } else if (_canGoBack) {
            _controller.goBack();
          } else {
            Navigator.of(context).pop();
          }
        },
      ),
    ),

선택

getAppVersion()

  • 네이티브 앱의 현재 버전 정보를 조회하여 웹으로 전달합니다.
  • 응답은 addedBridgeCallback.getAppVersion(version) 으로 전달합니다.

AOS

kotlin
@JavascriptInterface
fun getAppVersion(message: String) {
    Log.i("addedBridge", "getAppVersion")
    val version = context.packageManager
        .getPackageInfo(context.packageName, 0).versionName ?: ""
    (context as? ComponentActivity)?.runOnUiThread {
        webView?.evaluateJavascript(
            "addedBridgeCallback.getAppVersion('$version')",
            null
        )
    }
}

iOS

swift
case "getAppVersion":
    let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
    DispatchQueue.main.async {
        self.webView.evaluateJavaScript(
            "addedBridgeCallback.getAppVersion('\(version)')"
        )
    }

Flutter

dart
// pubspec.yaml: package_info_plus 패키지 필요
case 'getAppVersion':
  final info = await PackageInfo.fromPlatform();
  final version = info.version;
  _controller.runJavaScript(
    "addedBridgeCallback.getAppVersion('$version')",
  );
  break;

getADID()

  • 디바이스 광고 식별값(ADID)을 조회하여 웹으로 전달합니다.
  • 응답은 addedBridgeCallback.getADID(adId) 으로 전달합니다.

AOS

kotlin
// Google Play Services Ads Identifier 의존성 필요
// implementation("com.google.android.gms:play-services-ads-identifier:18.x.x")
@JavascriptInterface
fun getADID(message: String) {
    Log.i("addedBridge", "getADID")
    Thread {
        try {
            val info = AdvertisingIdClient.getAdvertisingIdInfo(context)
            val adId = info.id ?: ""
            (context as? ComponentActivity)?.runOnUiThread {
                webView?.evaluateJavascript(
                    "addedBridgeCallback.getADID('$adId')", null
                )
            }
        } catch (e: Exception) { e.printStackTrace() }
    }.start()
}

iOS

swift
// import AdSupport
// iOS 14+ 의 경우 ATTrackingManager 권한 요청 필요
case "getADID":
    let adId = ASIdentifierManager.shared().advertisingIdentifier.uuidString
    DispatchQueue.main.async {
        self.webView.evaluateJavaScript(
            "addedBridgeCallback.getADID('\(adId)')"
        )
    }

Flutter

dart
// pubspec.yaml: advertising_id 패키지 필요
case 'getADID':
  final adId = await AdvertisingId.id(true) ?? '';
  _controller.runJavaScript(
    "addedBridgeCallback.getADID('$adId')",
  );
  break;

getIsAgeUnder14()

  • 유저의 나이가 14세 미만인지 여부를 조회하여 웹으로 전달합니다. ("true" | "false")
  • 응답은 addedBridgeCallback.getIsAgeUnder14(isUnder14) 으로 전달합니다.

AOS

kotlin
@JavascriptInterface
fun getIsAgeUnder14(message: String) {
    Log.i("addedBridge", "getIsAgeUnder14")
    // 앱에서 관리하는 유저 나이 정책에 맞게 구현
    val isUnder14 = userRepository.isAgeUnder14()  // true | false
    (context as? ComponentActivity)?.runOnUiThread {
        webView?.evaluateJavascript(
            "addedBridgeCallback.getIsAgeUnder14('$isUnder14')", null
        )
    }
}

iOS

swift
case "getIsAgeUnder14":
    // 앱에서 관리하는 유저 나이 정책에 맞게 구현
    let isUnder14 = UserRepository.shared.isAgeUnder14 ? "true" : "false"
    DispatchQueue.main.async {
        self.webView.evaluateJavaScript(
            "addedBridgeCallback.getIsAgeUnder14('\(isUnder14)')"
        )
    }

Flutter

dart
case 'getIsAgeUnder14':
  // 앱에서 관리하는 유저 나이 정책에 맞게 구현
  final isUnder14 = userRepository.isAgeUnder14 ? 'true' : 'false';
  _controller.runJavaScript(
    "addedBridgeCallback.getIsAgeUnder14('$isUnder14')",
  );
  break;

getDeviceInfo()

  • 기기 정보(network / model / manufacturer / osVersion)를 조회하여 웹으로 전달합니다.
  • 응답은 JSON 문자열로 addedBridgeCallback.getDeviceInfo(deviceInfoJson) 으로 전달합니다.
  • ex) {"network":"wifi","model":"SM-S911N","manufacturer":"Samsung","osVersion":"14"}

AOS

kotlin
@JavascriptInterface
fun getDeviceInfo(message: String) {
    Log.i("addedBridge", "getDeviceInfo")
    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val caps = cm.getNetworkCapabilities(cm.activeNetwork)
    val network = when {
        caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
        caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "mobile"
        else -> ""
    }
    val json = JSONObject().apply {
        put("network", network)
        put("model", Build.MODEL)
        put("manufacturer", Build.MANUFACTURER)
        put("osVersion", Build.VERSION.RELEASE)
    }.toString().replace("'", "\\'")
    (context as? ComponentActivity)?.runOnUiThread {
        webView?.evaluateJavascript(
            "addedBridgeCallback.getDeviceInfo('$json')", null
        )
    }
}

iOS

swift
// import Network
case "getDeviceInfo":
    let network = NetworkMonitor.shared.currentType()  // "wifi" | "mobile"
    let info: [String: String] = [
        "network": network,
        "model": UIDevice.current.model,
        "manufacturer": "Apple",
        "osVersion": UIDevice.current.systemVersion
    ]
    if let data = try? JSONSerialization.data(withJSONObject: info),
       let json = String(data: data, encoding: .utf8) {
        let escaped = json.replacingOccurrences(of: "'", with: "\\'")
        DispatchQueue.main.async {
            self.webView.evaluateJavaScript(
                "addedBridgeCallback.getDeviceInfo('\(escaped)')"
            )
        }
    }

Flutter

dart
// pubspec.yaml: device_info_plus, connectivity_plus 패키지 필요
case 'getDeviceInfo':
  final deviceInfo = DeviceInfoPlugin();
  final connectivity = await Connectivity().checkConnectivity();
  final network = connectivity == ConnectivityResult.wifi ? 'wifi' : 'mobile';
  String model = '', manufacturer = '', osVersion = '';
  if (Platform.isAndroid) {
    final android = await deviceInfo.androidInfo;
    model = android.model;
    manufacturer = android.manufacturer;
    osVersion = android.version.release;
  } else if (Platform.isIOS) {
    final ios = await deviceInfo.iosInfo;
    model = ios.model;
    manufacturer = 'Apple';
    osVersion = ios.systemVersion;
  }
  final json = jsonEncode({
    'network': network,
    'model': model,
    'manufacturer': manufacturer,
    'osVersion': osVersion,
  }).replaceAll("'", "\\'");
  _controller.runJavaScript(
    "addedBridgeCallback.getDeviceInfo('$json')",
  );
  break;

pushUrl()

  • 현재 웹뷰를 재사용하여 전달받은 URL로 이동합니다.
  • @param {string} JSON string { url, replace? } — replace=true 면 history 스택 교체, false면 push

AOS

kotlin
@JavascriptInterface
fun pushUrl(message: String) {
    Log.i("addedBridge", "pushUrl")
    val obj = JSONObject(message)
    val url = obj.optString("url")
    val replace = obj.optBoolean("replace", false)
    (context as? ComponentActivity)?.runOnUiThread {
        if (replace) {
            webView?.loadUrl(url)
            // replace=true: 이동 완료 후 history 정리 필요시 clearHistory()
        } else {
            webView?.loadUrl(url)
        }
    }
}

iOS

swift
case "pushUrl":
    guard let data = param.data(using: .utf8),
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let urlStr = json["url"] as? String,
          let url = URL(string: urlStr) else { return }
    let replace = json["replace"] as? Bool ?? false
    DispatchQueue.main.async {
        self.webView.load(URLRequest(url: url))
        // replace=true: WKWebView 제약으로 별도 history 관리 정책 필요
    }

Flutter

dart
case 'pushUrl':
  final data = jsonDecode(message);
  final url = data['url'] as String;
  final replace = (data['replace'] as bool?) ?? false;
  _controller.loadRequest(Uri.parse(url));
  // replace=true 의 경우 history 관리 정책 필요
  break;

openInternalBrowser()

  • 새로운 앱 화면(Activity / UIViewController)에 웹뷰를 띄웁니다.
  • @param {string} url

AOS

kotlin
@JavascriptInterface
fun openInternalBrowser(message: String) {
    Log.i("addedBridge", "openInternalBrowser")
    val url = message
    (context as? ComponentActivity)?.runOnUiThread {
        val intent = Intent(context, WebViewActivity::class.java).apply {
            putExtra("url", url)
        }
        context.startActivity(intent)
    }
}

iOS

swift
case "openInternalBrowser":
    guard let url = URL(string: param) else { return }
    DispatchQueue.main.async {
        let vc = WebViewController(url: url)
        if let nav = self.navigationController {
            nav.pushViewController(vc, animated: true)
        } else {
            self.present(vc, animated: true)
        }
    }

Flutter

dart
case 'openInternalBrowser':
  Navigator.of(context).push(MaterialPageRoute(
    builder: (_) => WebViewPage(url: message),
  ));
  break;

setTitle()

  • 앱바 영역의 타이틀을 수정합니다. 앱바를 앱에서 관리하고 타이틀이 존재할 때만 사용합니다.
  • @param {string} title

AOS

kotlin
@JavascriptInterface
fun setTitle(message: String) {
    Log.i("addedBridge", "setTitle")
    (context as? ComponentActivity)?.runOnUiThread {
        // Compose 상태값 업데이트 등 앱바 구현에 맞게 반영
        appBarTitle.value = message
    }
}

iOS

swift
case "setTitle":
    DispatchQueue.main.async {
        self.title = param
        self.navigationItem.title = param
    }

Flutter

dart
case 'setTitle':
  setState(() {
    _title = message;
  });
  break;

관련 문서