-
iOS, Android WebView 와 네이티브간의 유용한 통신 방법 - Javascript Interface, Webkit MessagingiOS 2020. 2. 20. 00:30반응형
개발하고 있는 프로젝트에서 웹뷰는 상당한 레거시로 내려들어왔습니다.
하이브리드로 앱이 개발되었을 시절에 생긴 클라이언트와 백엔드, 웹프론트의 레거시란 레거시가 모두 집약되어 나타나 있습니다.
현재 프로젝트에서 웹뷰와 네이티브가 서로 통신할 때에는
window.location = "sample://action?argument1=2&argument2=2"
형식의 url 을 프론트에서 실행하면, 웹뷰에서 받아서 적절한 네이티브 액션을 실행시키는 것으로 유지보수되어 있습니다.
이 방법은 자주 쓰였지만, 아래와 같은 문제를 가지고 있었습니다.
자바스크립트 로딩을 할 때, window.location 의 실행을 항상 보장하지 않는다.
제가 자바스크립트를 정확히 아는 것은 아니지만, 프론트엔드 개발자 분들의 말에 따르면 window.location 식으로 실행하면 자주 함수의 실행이 무시된다고 합니다. 소위 '씹힌다' 고 불리는 상황인데요. 정확한 현상은 잘 모르겠지만, 자바스크립트를 로드하는 와중에 저게 실행되면 거의 반드시 무시된다고 합니다.
함수의 실행 후 결과값을 받을 방법이 굉장히 파편화되어 있다.
예를 들어 사진을 업로드하는 액션을 전송한다고 합시다.
런타임 퍼미션을 유저가 거부하여 사진을 못 올릴 수도 있습니다. 사진을 올리다가 네트워크 오류가 날 수도 있습니다. 사진을 올리기에 성공해서 클라이언트가 다시 서버에 사진의 절대경로를 올려줄 수도 있습니다.
이런 다양한 리턴값을 주먹구구식으로 처리하다 보니 굉장한 파편화가 되어 있습니다.웹 프론트에서 네이티브의 실행 후 콜백을 받을 방법이 없다.
일반적으로 자바스크립트에서 함수를 작성할 때는 실행 후 비동기적 처리를 위해서 콜백을 사용합니다. 이런 url 방식으로 래핑할 경우에는, 네이티브에 명령을 보내고, 콜백을 받을 적절한 방식이 없습니다.
따라서 여러 방면으로 나이스한 방식이 무엇이 있을 까 고민하던 중,
파이어베이스에서 앱 클라이언트와 웹 클라이언트를 전송하는 메뉴얼을 보고 아이데이션을 얻을 수 있었습니다.
https://firebase.google.com/docs/analytics/webview?hl=ko
Webkit (iOS)와 Javascript Interface
파이어베이스 메뉴얼에 따르면, 로그를 전송할 때 아이폰은 webkit 에 메시지를 보내는 방식으로, Android 는 자바스크립트 인터페이스를 보내는 방식으로 쏘도록 권하고 있습니다.
function logEvent(name, params) { if (!name) { return; } if (window.AnalyticsWebInterface) { // Call Android interface window.AnalyticsWebInterface.logEvent(name, JSON.stringify(params)); } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.firebase) { // Call iOS interface var message = { command: 'logEvent', name: name, parameters: params }; window.webkit.messageHandlers.firebase.postMessage(message); } else { // No Android or iOS interface found console.log("No native APIs found."); } } function setUserProperty(name, value) { if (!name || !value) { return; } if (window.AnalyticsWebInterface) { // Call Android interface window.AnalyticsWebInterface.setUserProperty(name, value); } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.firebase) { // Call iOS interface var message = { command: 'setUserProperty', name: name, value: value }; window.webkit.messageHandlers.firebase.postMessage(message); } else { // No Android or iOS interface found console.log("No native APIs found."); } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any] else { return } guard let command = body["command"] as? String else { return } guard let name = body["name"] as? String else { return } if command == "setUserProperty" { guard let value = body["value"] as? String else { return } Analytics.setUserProperty(value, forName: name) } else if command == "logEvent" { guard let params = body["parameters"] as? [String: NSObject] else { return } Analytics.logEvent(name, parameters: params) } } self.webView.configuration.userContentController.add(self, name: "firebase")
이 방식을 연구해 본 결과,
- WebKit (iOS) 한정으로, JSON 형식으로 메시지를 보낼 수 있다. 매력적이다.
- window.location = href 와 달리, 로드되는 시점에 webkit / javascript interface 로 실행되도 항상 실행을 보장할 수 있다.
는 점을 알 수 있었습니다.
이제 마이그레이션할 방법을 찾기는 했습니다. 그러면 어떻게 콜백을 보장하며, 리턴값을 항상 서버에 보내 주는 세련된 방식을 설계할 수 있을까 고민이 되었습니다.
그 때 생각난 것이,
코르도바
입니다.
코르도바에서 웹뷰와 네이티브가 통신하는 코드를 보고, 아이데이션을 얻을 수 있었습니다.
코르도바 레퍼런스
인터페이스
https://cordova.apache.org/docs/en/9.x/guide/platforms/ios/plugin.html
소스 코드 - 자바스크립트
https://github.com/apache/cordova-ios/blob/master/cordova-js-src/exec.js
소스 코드 - iOS
https://github.com/apache/cordova-ios/blob/master/CordovaLib/Classes/Public/CDVCommandDelegateImpl.m
코르도바에서는 어떻게 구현했는가?
소스코드
https://github.com/apache/cordova-ios/blob/master/cordova-js-src/exec.js
컨셉
웹 클라이언트 → 앱 네이티브로 통신
// Register the callbacks and add the callbackId to the positional // arguments if given. if (successCallback || failCallback) { callbackId = service + cordova.callbackId++; cordova.callbacks[callbackId] = { success: successCallback, fail: failCallback }; }
-
callbackId를 웹 프론트엔드에서 정의하고, 전역 딕셔너리에 콜백 함수를 저장합니다.
-
callbackId는 실행시마다 증가시킵니다.
-
큐에 json 값을 말아서 네이티브에 전송합니다.
var command = [callbackId, service, action, actionArgs]; // Stringify and queue the command. We stringify to command now to // effectively clone the command arguments in case they are mutated before // the command is executed. commandQueue.push(JSON.stringify(command));
앱 네이티브에서 커맨드 실행
- 네이티브에서 스트링 파싱 후 적절한 커맨드를 실행합니다.
NSString* js = [NSString stringWithFormat:@"cordova.require('cordova/exec').nativeCallback('%@',%d,%@,%d, %d)", callbackId, status, argumentsAsJSON, keepCallback, debug]; [self evalJsHelper:js]; }
- 실행 후 서버에서 받아온 callbackId를 자바스크립트로 실행합니다.
- cordova/exec 에 있는 nativeCallback 호출합니다.
웹 프론트엔드에서 콜백 파싱
*/ callbackFromNative: function (callbackId, isSuccess, status, args, keepCallback) { try { var callback = cordova.callbacks[callbackId]; if (callback) { if (isSuccess && status === cordova.callbackStatus.OK) { callback.success && callback.success.apply(null, args); } else if (!isSuccess) { callback.fail && callback.fail.apply(null, args); } /* else Note, this case is intentionally not caught. this can happen if isSuccess is true, but callbackStatus is NO_RESULT which is used to remove a callback from the list without calling the callbacks typically keepCallback is false in this case */ // Clear callback if not expecting any more results if (!keepCallback) { delete cordova.callbacks[callbackId]; } } } catch (err) { var msg = 'Error in ' + (isSuccess ? 'Success' : 'Error') + ' callbackId: ' + callbackId + ' : ' + err; console && console.log && console.log(msg); console && console.log && err.stack && console.log(err.stack); cordova.fireWindowEvent('cordovacallbackerror', { 'message': msg }); throw err; } },
- 자바스크립트에서
callbackFromNative
함수 실행합니다. - 이전에 딕셔너리에 저장해놨던 콜백함수에, 클라이언트에서 받아온
args
를apply
해서 콜백 실행합니다.
클라이언트 인터페이스
커맨드 실행
전역함수 정의
exec(<successFunction>, <failFunction>, <service>, <action>, [<args>]);
- successFunction : 안드로이드 네이티브 함수가 정상 호출 되었으때 호출되는 자바스크립트 콜백 함수
- failFunction : 안드로이드 네이티브 함수의 호출 결과 에러가 발생했을때 호출 되는 자바스크립트 콜백 함수
- service : 플러그인 설정파일인 plugin.xml에서 설정된 플러그인의 Name
- action : 서비스 구분을 위해 안드로이드 네이티브 함수로 전달되는 파라미터
- args : 안드로이드 네이티브 함수 호출에 필요한 파라미터 배열
전역함수 사용 예제
cordova.exec(function(winParam) {}, function(error) {}, "service", "action", ["firstArgument", "secondArgument", 42, false]);
action
이라고 정의한 함수를 실행함
전역함수를 개개 커맨드로 래핑 예제
window.echo = function(str, callback) { cordova.exec(callback, function(err) { callback('Nothing to echo.'); }, "Echo", "echo", [str]); };
-
Echo
커맨드를 선정의하고,window.echo
로 래핑해서 사용함 -
실패 콜백은 없으므로 생략함
window.echo("echome", function(echoValue) { alert(echoValue == "echome"); // should alert true. });
결국 전역 큐에 유니크한 id를 각각의 함수마다 부여하여, 클라이언트에게 id를 쏴 주고 있습니다. 그리고 그 아이디에 매칭되는 성공, 실패 콜백 함수 오브젝트를 전역 함수에 집어넣습니다.
이제 클라이언트가 액션을 실행시키면... 아규먼트로 id 를 같이 보내주면 됩니다.
그럼 웹 프론트엔드는 미리 저장해뒀던 성공, 실패 콜백 함수 오브젝트를 다시 꺼내어,
apply 함수로 실행시켜 주는 겁니다.
그러면 액션 입장에서는 성공과 실패 콜백이 실행될 뿐이니... 굉장히 세련된 방식으로 함수를 짜낼 수 있습니다.
코르도바의 구현체에서 필요한 부분만 적당히 빼내어, 아래와 같은 자바스크립트를 짜내었습니다.
Javascript Interface 규약
프론트엔드 자바 스크립트
window.interface = { /** * 네이티브로 실행할 함수의 콜백 아이디 * 고유한 아이디를 가지고, 새로고침해도 겹치지 않도록 random 값을 준다. */ callbackID: Math.floor(Math.random() * 2000000000), /** * 실행한 함수가 콜백을 실행하기 전까지, 콜백을 저장한다. */ callbacks: {}, /** * * 네이티브에서 커맨드를 실행한 후, 네이티브 코드가 호출한다. * 인자에 따라서 콜백을 가져온다. * @param {number} callbackID - 실행할 때 네이티브에 전송했던 콜백 아이디 * @param {boolean} isSuccess - 커맨드가 성공적으로 실행되었는지 여부 * @param {Object} args - 네이티브에서 전송하는 JSON 객체 * @param {boolean} keepCallback - 콜백을 실행할 필요가 있는지 여부 */ nativeCallback: function(callbackID, isSuccess, args, keepCallback) { var callback = window.interface.callbacks[callbackID]; if (callback) { if (isSuccess) { if (callback.success) { callback.success.apply(null, [args]); } } else if (!isSuccess) { if (callback.fail) { callback.fail.apply(null, [args]); } } if (!keepCallback) { delete window.interface.callbacks[callbackID]; } } }, /** * 현재 플랫폼 상태 */ platform: function() { return 'aos'; }, /** * 네이티브에 필요한 액션을 실행시킨다. * 웹 프론트엔드에서 실행해 네이티브로 명령을 넘긴다. * @param {Object} successCallback - 액션이 성공했을 때 불리는 함수 객체 * @param {Object} failCallback - 액션이 실패했을 때 불리는 함수 객체 * @param {string} action - 어떤 액션인지 구분하는 값 * @param {Object} actionArgs - 액션의 인자 */ executeInterface: function(successCallback, failCallback, action, actionArgs) { var callbackID = null; if (successCallback || failCallback) { callbackID = window.interface.callbackID; window.interface.callbackID += 1; window.interface.callbacks[callbackID] = { success: successCallback, fail: failCallback }; } if (window.interface.platform() === 'ios') { window.interface.iosCommand(callbackID, action, actionArgs); } else if (window.interface.platform() === 'aos') { window.interface.aosCommand(callbackID, action, actionArgs); } }, /** * iOS WKWebView에 스크립트 메시지를 전송하여 명령을 전송한다. * @param {number} callbackID - 콜백을 추적하기 위한 아이디 * @param {string} action - 어떤 액션인지 구분하는 값 * @param {Object} actionArgs - 액션의 인자 */ iosCommand: function(callbackID, action, actionArgs) { var callbackIDString = callbackID.toString(); var message = { callbackID: callbackIDString, action: action, actionArgs: actionArgs }; window.webkit.messageHandlers.interface.postMessage(message); }, /** * AOS WebView에 Javascript Interface를 실행하여 명령을 전송한다. * @param {number} callbackID - 콜백을 추적하기 위한 아이디 * @param {string} action - 어떤 액션인지 구분하는 값 * @param {Object} actionArgs - 액션의 인자 */ aosCommand: function(callbackID, action, actionArgs) { var actionArgsStringfy = JSON.stringify(actionArgs); var callbackIDString = callbackID.toString(); window.AndroidInterface.postAction(callbackIDString, action, actionArgsStringfy); } };
Android javascript interface
@JavascriptInterface fun postAction(callbackID: String, action: String, actionArgs: String) { }
Android javascript callback
window.interface.nativeCallback(callbackID, isSuccess, args, keepCallback);
사용 예제
showMenu 라는 콜백이 있다고 합시다.
이 콜백은 메뉴창을 보여주는 것인데, 클라이언트에서 "클릭한 메뉴의 idx"와 "클릭한 메뉴의 title" 을 보내주도록 구현해야 된다고 합시다.
프론트엔드에서는,
window.showMenu = function (titles, successCallback, failCallback) { var action = 'showMenu'; var args = { titles: titles }; window.interface.executeInterface(successCallback, failCallback, action, args); };
이런식으로 선언을 하고,
window.showMenu( ['A', 'B', 'C'], function(response) { var index = response.index; var title = response.title; console.log("Success"); }, function(errorCode) { console.log('Error'); console.log(errorCode); } );
이런 식으로 활용한다면, 내부에 뭐가 있는지는 관심이 없어도 세련되게 래핑할 수가 있습니다.
반면 안드로이드 클라이언트의 예시를 들면,
postAction(1, "showMenu", "{\\"titles\\" : {\\"Menu 1\\", \\"Menu 2\\",\\"Menu 3\\"}}");
이런식의 자바스크립트가 프론트에서 실행될 것입니다.
이제 이것을
@JavascriptInterface fun postAction(callbackID: String, action: String, actionArgs: String) { val jsonValue = Gson(actionArgs.... // GSON 으로 파싱할 것 val titles: [String] = .... if (action == "showMenu") { /// 다이얼로그를 띄울 것 } }
이런 느낌으로 파싱해서 사용할 수 있습니다.
그리고 다 실행해 준 다음에, 메인 스레드에서 아래와 같이 자바스크립트를 실행해 주면,
window.interface.nativeCallback(1, true, {"index": 1, "title": "Menu 2"}, true);
모든 것이 순조롭게 돌아갈 것입니다.
반응형'iOS' 카테고리의 다른 글
UIWebView 를 WKWebView 로 이전할 때 반드시 알아야 하는 7가지 주의 사항 (0) 2020.02.28 UICollectionViewDiffableDataSource, UITableViewDiffableDataSource 로 깔끔한 콜렉션 뷰 데이터 관리하기 (0) 2020.02.26 IOS 에서 머터리얼 디자인의 물결 이펙트 버튼 만들기 (0) 2019.12.26 Let Swift 2019 탐방 견문록, 정기 iOS 개발 행사를 엿보다 (0) 2019.11.17 iOS 13을 대응해야 하는 개발자가 알아야 하는 8가지 급한 불 리스트 (1) 2019.08.30