ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS, Android WebView 와 네이티브간의 유용한 통신 방법 - Javascript Interface, Webkit Messaging
    iOS 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 함수 실행합니다.
    • 이전에 딕셔너리에 저장해놨던 콜백함수에, 클라이언트에서 받아온 argsapply 해서 콜백 실행합니다.

    클라이언트 인터페이스

    커맨드 실행

    전역함수 정의

    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);

    모든 것이 순조롭게 돌아갈 것입니다.

Designed by Tistory.