iOS 原生與H5交互 WKJSWebView

WKJSWebView

iOS UIWebView逐漸被淘汰, WKWebView成為主流. 本文參考EasyJSWebView的交互方式, 對其進行了修改和增加. 可以實現原生調用JS, 也可以JS調用原生.

一. 使用方法

JS調原生

  1. 創建一個交互類, 定義給js的交互接口
// OC
#import <Foundation/Foundation.h>
#import "WKJSWebView.h"
@interface JSInterface : NSObject
- (void)testWithParams:(NSString*)_params callback:(WKJSDataFunction*)_callback;
@end

#import "JSInterface.h"
#import "MJExtension.h"
@implementation JSInterface
- (void)testWithParams:(NSString*)_params callback:(WKJSDataFunction*)_callback
{
    //接收h5 參數
    NSLog(@"H5 調 native, 參數 : %@", _params);
    
    NSString *letter = [NSString stringWithFormat:@"%C", (unichar)(arc4random_uniform(26) + 'A')];
    NSDictionary* p1 = @{@"letter": letter, @"b": @"bb", @"c": @"cc"};
    NSString* p2 = @"param_p2";
    NSString* p3 = @"param_p3";
    NSArray* nativeParams = @[p1, p2, p3];
    //執行h5回調函數
    [_callback executeWithParams:nativeParams completionHandler:^(id response, NSError *error) {
        NSLog(@"completionHandler");
    }];
}
@end
  1. 初始化webView
// OC
- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor lightGrayColor];
    
    CGRect rect = CGRectMake(0, 0, self.view.bounds.size.width, self.view.bounds.size.height-150);
    self.webView = [[WKJSWebView alloc] initWithFrame:rect configuration:[WKWebViewConfiguration new] scripts:nil withJavascriptInterfaces:@{@"native":[JSInterface new]}];
    self.webView.navigationDelegate = self;
   [self.view addSubview:self.webView];
    
    NSString* _urlStr = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
    NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL fileURLWithPath:_urlStr]];
    [self.webView loadRequest:request];
}
  1. JS調原生接口
<script>
  // js
  window.native.testWithParamscallback('abc', (p1, p2, p3) => {
      console.log(p1, p2, p3);
      var obj1 = JSON.parse(p1); 
      let div = document.getElementById("op");
      div.innerHTML = obj1.letter;
  });
</script>

原生調JS

  1. js注冊方法
<script>
  // js
  function changeColor(param) {
      let div = document.getElementById("oi");
      div.style.backgroundColor = param.color;
  };
  window.EasyJS.mount("divChangeColor", changeColor);
</script>
  1. 原生調用JS
// OC
NSDictionary* args = @{@"color": [self Ox_randomColor]};
[self.webView invokeJSFunction:@"divChangeColor" params:args completionHandler:^(id response, NSError *error) {
    NSLog(@"原生調用JS方法完成.");
}];

二. 原理解析

基本思想就是將需要交互的接口掛載到瀏覽器的window上, 然后通過js代碼調用.

原生將js代碼編譯成字符串, 再通過下面的方法執行js:

// OC
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;

js調原生

注入橋接js

首先看下面的js代碼:

// js
!function () {
    if (window.EasyJS) {
        return;
    }
    window.EasyJS = {
        /**
         * 存放JS的回調函數
         */
        __callbacks: {},

        /**
         * 存放JS注冊給native的方法
         */
        __events: {},

        /**
         * JS執行此方法,將JS函數掛載到__events供原生調用
         * @param {String} funcName js方法名
         * @param {Function} handler js方法
         */
        mount: function (funcName, handler) {
            EasyJS.__events[funcName] = handler;
        },

        /**
         * 原生執行此方法 調用JS函數
         * @param {String} funcID js方法名
         * @param {JSON} paramsJson 參數
         */
        invokeJS: function (funcID, paramsJson) {
            let handler = EasyJS.__events[funcID];
            if (handler && typeof (handler) === 'function') {
                let args = '';
                try {
                    if (typeof JSON.parse(paramsJson) == 'object') {
                        args = JSON.parse(paramsJson);
                    } else {
                        args = paramsJson;
                    }
                    return handler(args);
                } catch (error) {
                    console.log(error);
                    args = paramsJson;
                    return handler(args);
                }
            } else {
                console.log(funcID + '函數未定義');
            }
        },

        /**
         * native通過此方法執行JS回調函數
         * @param {String} cbID 函數ID
         * @param {Boolean} removeAfterExecute 執行后是否從__callbacks中否移除此回調函數
         */
        invokeCallback: function (cbID, removeAfterExecute) {
            let args = Array.prototype.slice.call(arguments);
            args.shift(); // __cb1577786915804
            args.shift(); // false

            for (let i = 0, l = args.length; i < l; i++) {
                args[i] = decodeURIComponent(args[i]);
            }

            let cb = EasyJS.__callbacks[cbID];
            if (removeAfterExecute) {
                EasyJS.__callbacks[cbID] = undefined;
            }
            return cb.apply(null, args);
        },

        /**
         * 調用原生obj對象的方法
         * @param {String} obj 
         * @param {String} functionName 
         * @param {Array} args 
         */
        call: function (obj, functionName, args) {
            let formattedArgs = [];
            for (let i = 0, l = args.length; i < l; i++) {
                if (typeof args[i] == 'function') {
                    formattedArgs.push('f');
                    let cbID = '__cb' + (+new Date) + Math.random();
                    EasyJS.__callbacks[cbID] = args[i];
                    formattedArgs.push(cbID);
                } else {
                    formattedArgs.push('s');
                    formattedArgs.push(encodeURIComponent(args[i]));
                }
            }

            let argStr = (formattedArgs.length > 0 ? ':' + encodeURIComponent(formattedArgs.join(':')) : '');
            /** NativeListener 要與原生中addScriptMessageHandler的name保持一致 */
            window.webkit.messageHandlers.NativeListener.postMessage(obj + ':' + encodeURIComponent(functionName) + argStr);

            let ret = EasyJS.retValue;
            EasyJS.retValue = undefined;

            if (ret) {
                return decodeURIComponent(ret);
            }
        },

        /**
         * native用來給window添加obj的對象與方法
         * @param {String} obj 添加到window上的對象
         * @param {Array<String>} methods 添加到obj上的方法數組
         */
        inject: function (obj, methods) {
            window[obj] = {};
            let jsObj = window[obj];

            for (let i = 0, l = methods.length; i < l; i++) {
                (function () {
                    let method = methods[i];
                    let jsMethod = method.replace(new RegExp(':', 'g'), '');
                    jsObj[jsMethod] = function () {
                        return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
                    };
                })();
            }
        }
    };
}()

這段js在webView初始化時注入到瀏覽器, 在window上增加一個EasyJS對象, 為交互搭建橋梁.

// OC
//EASY_JS_INJECT_STRING是上面的js代碼串
[configuration.userContentController addUserScript:[[WKUserScript alloc] initWithSource:EASY_JS_INJECT_STRING injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];

注入原生交互方法

然后, 繼續注入原生交互類:

// OC
// interfaces : @{@"native":[JSInterface new]}
    NSMutableString* injectString = [[NSMutableString alloc] init];
    for(NSString *key in [interfaces allKeys]) {
        [injectString appendString:@"EasyJS.inject(\""];
        [injectString appendString:key];
        [injectString appendString:@"\", ["];
        NSObject* interfaceObj = [interfaces objectForKey:key];
        if ([interfaceObj isKindOfClass:[NSObject class]]) {
            Class cls = object_getClass(interfaceObj);
            while (cls != [NSObject class]) {
                unsigned int mc = 0;
                Method * mlist = class_copyMethodList(cls, &mc);
                for (int i = 0; i < mc; i++) {
                    [injectString appendString:@"\""];
                    [injectString appendString:[NSString stringWithUTF8String:sel_getName(method_getName(mlist[i]))]];
                    [injectString appendString:@"\""];
                    if ((i != mc - 1) || (cls.superclass != [NSObject class])) {
                        [injectString appendString:@", "];
                    }
                }
                free(mlist);
                cls = cls.superclass;
            }
        }
        [injectString appendString:@"]);"]; //@"EasyJS.inject(\"native\", [\"testWithParams:callback:\"]);"
    }
#ifdef DEBUG
    NSLog(@"injectString :\n%@", injectString);
#endif
    [configuration.userContentController addUserScript:[[WKUserScript alloc] initWithSource:injectString injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];

上面代碼調用了EasyJS.inject()方法:

// js
inject: function (obj, methods) {
    window[obj] = {};
    let jsObj = window[obj];
    for (let i = 0, l = methods.length; i < l; i++) {
        (function () {
            let method = methods[i];
            let jsMethod = method.replace(new RegExp(':', 'g'), '');
            jsObj[jsMethod] = function () {
                return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
            };
        })();
    }
}

在window增加native對象,并且把JSInterface的交互方法都加到native對象.這里的native相當于JSInterface在h5中的鏡像, 通過native,就可以調用原生方法:

// js
window.native.testWithParamscallback('abc', (p1, p2, p3) => {
    // h5回調函數
});

發送消息給原生

但是, native.testWithParamscallback長這樣的:

// js
function() {
  return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
};

這是鏡像native的testWithParamscallback方法, 它并不能換起原生, 真正調用原生的是EasyJS.call().

// js
call: function (obj, functionName, args) {
    let formattedArgs = [];
    for (let i = 0, l = args.length; i < l; i++) {
        if (typeof args[i] == 'function') {
            formattedArgs.push('f');
            let cbID = '__cb' + (+new Date) + Math.random();
            EasyJS.__callbacks[cbID] = args[i];
            formattedArgs.push(cbID);
        } else {
            formattedArgs.push('s');
            formattedArgs.push(encodeURIComponent(args[i]));
        }
    }

    let argStr = (formattedArgs.length > 0 ? ':' + encodeURIComponent(formattedArgs.join(':')) : '');
    /** NativeListener 要與原生中addScriptMessageHandler的name保持一致 */
    window.webkit.messageHandlers.NativeListener.postMessage(obj + ':' + encodeURIComponent(functionName) + argStr);

    let ret = EasyJS.retValue;
    EasyJS.retValue = undefined;

    if (ret) {
        return decodeURIComponent(ret);
    }
}

Easy.call()將js的回調函數生成唯一ID對應保存到EasyJS.__callbacks,再將唯一ID和參數按約定的方式編譯放入數組 ,然后用原生約定的監聽名字NativeListener發送消息給原生window.webkit.messageHandlers.NativeListener.postMessage(obj + ':' + encodeURIComponent(functionName) + argStr);

NativeListener在初始化webView時指定, 同時將原生交互類映射interfaces掛載到監聽者.

// OC
// add message handler
WKJSListener *listener = [[WKJSListener alloc] init];
listener.javascriptInterfaces = interfaces;
[configuration.userContentController addScriptMessageHandler:listener name:WKJSMessageHandler];

原生接收消息并執行

js發出消息后, 原生的監聽WKJSListener可以接收到:

// OC
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    NSMutableArray <WKJSDataFunction *>* _funcs = [NSMutableArray new];
    NSMutableArray <NSString *>* _args = [NSMutableArray new];
    
    if ([message.name isEqualToString:WKJSMessageHandler]) {
        __weak WKJSWebView *webView = (WKJSWebView *)message.webView;
        NSString *requestString = [message body];
        // native:testWithParams%3Acallback%3A:s%3Aabc%3Af%3A__cb1577786915804
        NSArray *components = [requestString componentsSeparatedByString:@":"];
        //NSLog(@"req: %@", requestString);
        
        NSString* obj = (NSString*)[components objectAtIndex:0];
        NSString* method = [(NSString*)[components objectAtIndex:1] stringByRemovingPercentEncoding];
        NSObject* interface = [self.javascriptInterfaces objectForKey:obj];
        
        SEL selector = NSSelectorFromString(method);
        NSMethodSignature* sig = [interface methodSignatureForSelector:selector];
        if (sig.numberOfArguments == 2 && components.count > 2) {
            NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]: %@",NSStringFromClass([interface class]),method,@"實際接收參數個數與js傳參數不相等"];
            assertDesc = assertDesc ? : @"";
            NSAssert(NO, assertDesc);
            return;
        }
        if (!sig) {
            NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]:%@",NSStringFromClass([interface class]),method,@"method signature argument cannot be nil"];
            NSAssert(NO, assertDesc);
            return;
        }
        if (![interface respondsToSelector:selector]) {
            NSAssert(NO, @"該方法未實現");
            return;
        }
        
        NSInvocation* invoker = [NSInvocation invocationWithMethodSignature:sig];
        invoker.selector = selector;
        invoker.target = interface;
        if ([components count] > 2){
            NSString *argsAsString = [(NSString*)[components objectAtIndex:2] stringByRemovingPercentEncoding];
            NSArray* formattedArgs = [argsAsString componentsSeparatedByString:@":"];
            if ((sig.numberOfArguments - 2) != [formattedArgs count] / 2) {
                NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]: 實際接收參數個數%@,js傳參個數%@",NSStringFromClass([interface class]),method,@(sig.numberOfArguments - 2),@([formattedArgs count] / 2)];
                assertDesc = assertDesc ? : @"";
                NSAssert(NO, assertDesc);
                return;
            }
            for (unsigned long i = 0, j = 0, l = [formattedArgs count]; i < l; i+=2, j++){
                NSString* type = ((NSString*) [formattedArgs objectAtIndex:i]);
                NSString* argStr = ((NSString*) [formattedArgs objectAtIndex:i + 1]);
                
                if ([@"f" isEqualToString:type]){
                    WKJSDataFunction *func = [[WKJSDataFunction alloc] initWithWebView:webView];
                    func.funcID = argStr;
                    [_funcs addObject:func];
                    [invoker setArgument:&func atIndex:(j + 2)];
                }else if ([@"s" isEqualToString:type]){
                    NSString* arg = [argStr stringByRemovingPercentEncoding];
                    [_args addObject:arg];
                    [invoker setArgument:&arg atIndex:(j + 2)];
                }
            }
        }
        [invoker retainArguments];
        [invoker invoke];
        
        if ([sig methodReturnLength] > 0){
            __unsafe_unretained NSString* tmpRetValue;
            [invoker getReturnValue:&tmpRetValue];
            NSString *retValue = tmpRetValue;
            
            if (retValue == NULL || retValue == nil){
                [webView wk_evaluateJavaScript:@"EasyJS.retValue=null;" completionHandler:nil];
            }else{
                retValue = [retValue stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet letterCharacterSet]];
                retValue = [@"" stringByAppendingFormat:@"EasyJS.retValue=\"%@\";", retValue];
                [webView wk_evaluateJavaScript:retValue completionHandler:nil];
            }
        }
    }
    
    [_funcs removeAllObjects];
    [_args removeAllObjects];
}

在這里取出對象,方法,參數, 通過javascriptInterfaces映射取原生對象(也就是JSInterface),然后執行方法.

執行js回調

原生方法執行后回調js:

// OC
[_callback executeWithParams:nativeParams completionHandler:^(id response, NSError *error) {
}];

executeWithParams:completionHandler:方法如下:

// OC
- (void)executeWithParams:(NSArray *)params completionHandler:(void (^)(id response, NSError *error))completionHandler {
    
    NSMutableArray * args = [NSMutableArray arrayWithArray:params];
    for (int i=0; i<params.count; i++) {
        NSString* json = [params[i] mj_JSONString];
        [args replaceObjectAtIndex:i withObject:json];
    }
    
    NSMutableString* injection = [[NSMutableString alloc] init];
    [injection appendFormat:@"EasyJS.invokeCallback(\"%@\", %@", self.funcID, self.removeAfterExecute ? @"true" : @"false"];
    
    if (args) {
        for (unsigned long i = 0, l = args.count; i < l; i++){
            NSString* arg = [args objectAtIndex:i];
            NSCharacterSet *chars = [NSCharacterSet characterSetWithCharactersInString:@"!*'();:@&=+$,/?%#[]"];
            NSString *encodedArg = [arg stringByAddingPercentEncodingWithAllowedCharacters:chars];
            [injection appendFormat:@", \"%@\"", encodedArg];
        }
    }
    
    [injection appendString:@");"];
    
    if (_webView){
        [_webView wk_evaluateJavaScript:injection completionHandler:^(id response, NSError *error) {
            if (completionHandler) {completionHandler(response, error);}
        }];
    }
}

通過EasyJS.invokeCallback()傳入回調函數唯一ID, 取出__callbacks中對應的方法并執行:

// js
invokeCallback: function (cbID, removeAfterExecute) {
    let args = Array.prototype.slice.call(arguments);
    args.shift(); // __cb1577786915804
    args.shift(); // false

    for (let i = 0, l = args.length; i < l; i++) {
        args[i] = decodeURIComponent(args[i]);
    }

    let cb = EasyJS.__callbacks[cbID];
    if (removeAfterExecute) {
        EasyJS.__callbacks[cbID] = undefined;
    }
    return cb.apply(null, args);
},

args.shift()移除多余的參數.

至此, js調原生流程結束.

原生調js

js注冊函數

原生調用js, 需要js將方法注冊到window, 注入js中提供了mount()方法給js注冊函數用:

// js
mount: function (funcName, handler) {
    EasyJS.__events[funcName] = handler;
},

mount()方法將JS函數handler存放到__events, 以便提供給原生調用.

js中注冊也很簡單:

// js
window.EasyJS.mount("divChangeColor", changeColor);

這樣就將divChangeColor函數注冊了, 它對應js中的changeColor()方法:

// js
function changeColor(param) {
    let div = document.getElementById("oi");
    div.style.backgroundColor = param.color;
};

原生調js

原生調用js函數divChangeColor:

// OC
[self.webView invokeJSFunction:@"divChangeColor" params:@{@"color": [self Ox_randomColor]} completionHandler:^(id response, NSError *error) {
    NSLog(@"原生調用JS方法完成.");
}];

invokeJSFunction:params:completionHandler:方法如下:

// OC
- (void)invokeJSFunction:(NSString*)jsFuncName params:(id)params completionHandler:(void (^)(id response, NSError *error))completionHandler {
    
    NSString *paramJson = @"";
    if (params) {  paramJson = [params mj_JSONString]; }
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
    
    NSString *script = [NSString stringWithFormat:@"%@('%@', '%@')", @"window.EasyJS.invokeJS", jsFuncName,  paramJson];
    [self wk_evaluateJavaScript:script completionHandler:completionHandler];
}

通過EasyJS.invokeJS(),取出__events中對應divChangeColor的函數并執行.

// js
invokeJS: function (funcID, paramsJson) {
    let handler = EasyJS.__events[funcID];
    if (handler && typeof (handler) === 'function') {
        let args = '';
        try {
            if (typeof JSON.parse(paramsJson) == 'object') {
                args = JSON.parse(paramsJson);
            } else {
                args = paramsJson;
            }
            return handler(args);
        } catch (error) {
            console.log(error);
            args = paramsJson;
            return handler(args);
        }
    } else {
        console.log(funcID + '函數未定義');
    }
}

至此, 原生調用js完成.

三. WKJSWebView代碼

WKJSWebView.h

#import <WebKit/WebKit.h>
#import <Foundation/Foundation.h>

#pragma mark - WKJSWebView

@interface WKJSWebView : WKWebView

- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration*)configuration scripts:(NSArray<NSString*>*)scripts withJavascriptInterfaces:(NSDictionary*)interfaces;

/// 主線程執行js
- (void)wk_evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^)(id, NSError *))completionHandler;

/// native 調用 h5 方法
- (void)invokeJSFunction:(NSString*)jsFuncName params:(id)params completionHandler:(void (^)(id response, NSError *error))completionHandler;

@end

#pragma mark - WKJSListener

@interface WKJSListener : NSObject<WKNavigationDelegate,WKScriptMessageHandler>
@property (nonatomic) NSDictionary *javascriptInterfaces;
@end


#pragma mark - WKJSDataFunction

@interface WKJSDataFunction : NSObject

@property (nonatomic, copy) NSString* funcID;
@property (nonatomic, strong) WKJSWebView *webView;
@property (nonatomic, assign) BOOL removeAfterExecute;

- (instancetype)initWithWebView:(WKJSWebView*)webView;

// 回調JS
- (void)execute:(void (^)(id response, NSError* error))completionHandler;
- (void)executeWithParam:(NSString *)param completionHandler:(void (^)(id response, NSError* error))completionHandler;
- (void)executeWithParams:(NSArray *)params completionHandler:(void (^)(id response, NSError* error))completionHandler;

@end

WKJSWebView.m

#import "WKJSWebView.h"
#import <objc/runtime.h>
#import "MJExtension.h"

static NSString * const EASY_JS_INJECT_STRING = @"!function () {\
    if (window.EasyJS) {\
        return;\
    }\
    window.EasyJS = {\
        __callbacks: {},\
        __events: {},\
        mount: function (funcName, handler) {\
            EasyJS.__events[funcName] = handler;\
        },\
        invokeJS: function (funcID, paramsJson) {\
            let handler = EasyJS.__events[funcID];\
            if (handler && typeof (handler) === 'function') {\
                let args = '';\
                try {\
                    if (typeof JSON.parse(paramsJson) == 'object') {\
                        args = JSON.parse(paramsJson);\
                    } else {\
                        args = paramsJson;\
                    }\
                    return handler(args);\
                } catch (error) {\
                    console.log(error);\
                    args = paramsJson;\
                    return handler(args);\
                }\
            } else {\
               console.log(funcID + '函數未定義');\
            }\
        },\
        invokeCallback: function (cbID, removeAfterExecute) {\
            let args = Array.prototype.slice.call(arguments);\
            args.shift();\
            args.shift();\
            for (let i = 0, l = args.length; i < l; i++) {\
                args[i] = decodeURIComponent(args[i]);\
            }\
            let cb = EasyJS.__callbacks[cbID];\
            if (removeAfterExecute) {\
                EasyJS.__callbacks[cbID] = undefined;\
            }\
            return cb.apply(null, args);\
        },\
        call: function (obj, functionName, args) {\
            let formattedArgs = [];\
            for (let i = 0, l = args.length; i < l; i++) {\
                if (typeof args[i] == 'function') {\
                    formattedArgs.push('f');\
                    let cbID = '__cb' + (+new Date) + Math.random();\
                    EasyJS.__callbacks[cbID] = args[i];\
                    formattedArgs.push(cbID);\
                } else {\
                    formattedArgs.push('s');\
                    formattedArgs.push(encodeURIComponent(args[i]));\
                }\
            }\
            let argStr = (formattedArgs.length > 0 ? ':' + encodeURIComponent(formattedArgs.join(':')) : '');\
            window.webkit.messageHandlers.NativeListener.postMessage(obj + ':' + encodeURIComponent(functionName) + argStr);\
            let ret = EasyJS.retValue;\
            EasyJS.retValue = undefined;\
            if (ret) {\
                return decodeURIComponent(ret);\
            }\
        },\
        inject: function (obj, methods) {\
            window[obj] = {};\
            let jsObj = window[obj];\
            for (let i = 0, l = methods.length; i < l; i++) {\
                (function () {\
                    let method = methods[i];\
                    let jsMethod = method.replace(new RegExp(':', 'g'), '');\
                    jsObj[jsMethod] = function () {\
                        return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));\
                    };\
                })();\
            }\
        }\
    };\
}()";

static NSString * const WKJSMessageHandler = @"NativeListener";


#pragma mark - WKJSWebView

@implementation WKJSWebView

/**
 初始化WKWwebView,并將交互類的方法注入JS
 */
- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration*)configuration scripts:(NSArray<NSString*>*)scripts withJavascriptInterfaces:(NSDictionary*)interfaces
{
    if (!configuration) {
        configuration = [[WKWebViewConfiguration alloc] init];
    }
    if (!configuration.userContentController) {
        configuration.userContentController = [[WKUserContentController alloc] init];
    }
    
    // add script
    for (NSString* script in scripts) {
        [configuration.userContentController addUserScript:[[WKUserScript alloc] initWithSource:script injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
    }
    
    [configuration.userContentController addUserScript:[[WKUserScript alloc] initWithSource:EASY_JS_INJECT_STRING injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
    
    NSMutableString* injectString = [[NSMutableString alloc] init];
    for(NSString *key in [interfaces allKeys]) {
        [injectString appendString:@"EasyJS.inject(\""];
        [injectString appendString:key];
        [injectString appendString:@"\", ["];
        NSObject* interfaceObj = [interfaces objectForKey:key];
        if ([interfaceObj isKindOfClass:[NSObject class]]) {
            Class cls = object_getClass(interfaceObj);
            while (cls != [NSObject class]) {
                unsigned int mc = 0;
                Method * mlist = class_copyMethodList(cls, &mc);
                for (int i = 0; i < mc; i++) {
                    [injectString appendString:@"\""];
                    [injectString appendString:[NSString stringWithUTF8String:sel_getName(method_getName(mlist[i]))]];
                    [injectString appendString:@"\""];
                    if ((i != mc - 1) || (cls.superclass != [NSObject class])) {
                        [injectString appendString:@", "];
                    }
                }
                free(mlist);
                cls = cls.superclass;
            }
        }
        [injectString appendString:@"]);"]; //@"EasyJS.inject(\"native\", [\"testWithParams:callback:\"]);"
    }
#ifdef DEBUG
    NSLog(@"injectString :\n%@", injectString);
#endif
    [configuration.userContentController addUserScript:[[WKUserScript alloc] initWithSource:injectString injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
    
    // add message handler
    WKJSListener *listener = [[WKJSListener alloc] init];
    listener.javascriptInterfaces = interfaces;
    [configuration.userContentController addScriptMessageHandler:listener name:WKJSMessageHandler];
    
    // init
    self = [super initWithFrame:frame configuration:configuration];
    return self;
}

- (void)wk_evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^)(id, NSError *))completionHandler {
    if (![NSThread isMainThread]) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable response, NSError * _Nullable error) {
                if (completionHandler) {completionHandler(response, error);}
            }];
        });
    } else {
        [self evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable response, NSError * _Nullable error) {
            if (completionHandler) {completionHandler(response, error);}
        }];
    }
}

- (void)invokeJSFunction:(NSString*)jsFuncName params:(id)params completionHandler:(void (^)(id response, NSError *error))completionHandler {
    
    NSString *paramJson = @"";
    if (params) {  paramJson = [params mj_JSONString]; }
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
     paramJson = [paramJson stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
    
    NSString *script = [NSString stringWithFormat:@"%@('%@', '%@')", @"window.EasyJS.invokeJS", jsFuncName,  paramJson];
    [self wk_evaluateJavaScript:script completionHandler:completionHandler];
}

@end


#pragma mark - WKJSListener

@implementation WKJSListener

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    NSMutableArray <WKJSDataFunction *>* _funcs = [NSMutableArray new];
    NSMutableArray <NSString *>* _args = [NSMutableArray new];
    
    if ([message.name isEqualToString:WKJSMessageHandler]) {
        __weak WKJSWebView *webView = (WKJSWebView *)message.webView;
        NSString *requestString = [message body];
        // native:testWithParams%3Acallback%3A:s%3Aabc%3Af%3A__cb1577786915804
        NSArray *components = [requestString componentsSeparatedByString:@":"];
        //NSLog(@"req: %@", requestString);
        
        NSString* obj = (NSString*)[components objectAtIndex:0];
        NSString* method = [(NSString*)[components objectAtIndex:1] stringByRemovingPercentEncoding];
        NSObject* interface = [self.javascriptInterfaces objectForKey:obj];
        
        // execute the interfacing method
        SEL selector = NSSelectorFromString(method);
        NSMethodSignature* sig = [interface methodSignatureForSelector:selector];
        if (sig.numberOfArguments == 2 && components.count > 2) {
            // 方法簽名獲取到實際實現的方法無參數 && js調用的方法帶參數
            NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]: %@",NSStringFromClass([interface class]),method,@"oc的交互方法不帶參數,但是js調用的方法傳了參數"];
            //  因為pod報警告,所以加上這句,實際沒有意義
            assertDesc = assertDesc ? : @"";
            NSAssert(NO, assertDesc);
            return;
        }
        if (!sig) {
            NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]:%@",NSStringFromClass([interface class]),method,@"method signature argument cannot be nil"];
            NSAssert(NO, assertDesc);
            return;
        }
        if (![interface respondsToSelector:selector]) {
            NSAssert(NO, @"該方法未實現");
            return;
        }
        
        NSInvocation* invoker = [NSInvocation invocationWithMethodSignature:sig];
        invoker.selector = selector;
        invoker.target = interface;
        if ([components count] > 2){
            NSString *argsAsString = [(NSString*)[components objectAtIndex:2] stringByRemovingPercentEncoding];
            NSArray* formattedArgs = [argsAsString componentsSeparatedByString:@":"];
            if ((sig.numberOfArguments - 2) != [formattedArgs count] / 2) {
                // 方法簽名獲取到實際實現的方法的參數個數 != js調用方法時傳參個數
                NSString *assertDesc = [NSString stringWithFormat:@"*** -[%@ %@]: oc的交互方法參數個數%@,js調用方法時傳參個數%@",NSStringFromClass([interface class]),method,@(sig.numberOfArguments - 2),@([formattedArgs count] / 2)];
                //  因為pod報警告,所以加上這句,實際沒有意義
                assertDesc = assertDesc ? : @"";
                NSAssert(NO, assertDesc);
                return;
            }
            for (unsigned long i = 0, j = 0, l = [formattedArgs count]; i < l; i+=2, j++){
                NSString* type = ((NSString*) [formattedArgs objectAtIndex:i]);
                NSString* argStr = ((NSString*) [formattedArgs objectAtIndex:i + 1]);
                
                if ([@"f" isEqualToString:type]){
                    WKJSDataFunction *func = [[WKJSDataFunction alloc] initWithWebView:webView];
                    func.funcID = argStr;
                    //do this to force retain a reference to it
                    [_funcs addObject:func];
                    [invoker setArgument:&func atIndex:(j + 2)];
                }else if ([@"s" isEqualToString:type]){
                    NSString* arg = [argStr stringByRemovingPercentEncoding];
                    //do this to force retain a reference to it
                    [_args addObject:arg];
                    [invoker setArgument:&arg atIndex:(j + 2)];
                }
            }
        }
        [invoker retainArguments];
        [invoker invoke];
        
        //return the value by using javascript
        if ([sig methodReturnLength] > 0){
            __unsafe_unretained NSString* tmpRetValue;
            [invoker getReturnValue:&tmpRetValue];
            NSString *retValue = tmpRetValue;
            
            if (retValue == NULL || retValue == nil){
                [webView wk_evaluateJavaScript:@"EasyJS.retValue=null;" completionHandler:nil];
            }else{
                retValue = [retValue stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet letterCharacterSet]];
                retValue = [@"" stringByAppendingFormat:@"EasyJS.retValue=\"%@\";", retValue];
                [webView wk_evaluateJavaScript:retValue completionHandler:nil];
            }
        }
    }
    
    //clean up any retained funcs
    [_funcs removeAllObjects];
    //clean up any retained args
    [_args removeAllObjects];
}

@end

#pragma mark - WKJSDataFunction

@implementation WKJSDataFunction

- (instancetype)initWithWebView:(WKJSWebView *)webView {
    self = [super init];
    if (self) {
        _webView = webView;
    }
    return self;
}

- (void)execute:(void (^)(id response, NSError *error))completionHandler {
    [self executeWithParam:nil completionHandler:^(id response, NSError *error) {
        if (completionHandler) {
            completionHandler(response, error);
        }
    }];
}

- (void)executeWithParam:(NSString *)param completionHandler:(void (^)(id response, NSError *error))completionHandler {
    [self executeWithParams:param ? @[param] : nil completionHandler:^(id response, NSError *error) {
        if (completionHandler) {
            completionHandler(response, error);
        }
    }];
}

- (void)executeWithParams:(NSArray *)params completionHandler:(void (^)(id response, NSError *error))completionHandler {
    
    NSMutableArray * args = [NSMutableArray arrayWithArray:params];
    for (int i=0; i<params.count; i++) {
        NSString* json = [params[i] mj_JSONString];
        [args replaceObjectAtIndex:i withObject:json];
    }
    
    NSMutableString* injection = [[NSMutableString alloc] init];
    [injection appendFormat:@"EasyJS.invokeCallback(\"%@\", %@", self.funcID, self.removeAfterExecute ? @"true" : @"false"];
    
    if (args) {
        for (unsigned long i = 0, l = args.count; i < l; i++){
            NSString* arg = [args objectAtIndex:i];
            NSCharacterSet *chars = [NSCharacterSet characterSetWithCharactersInString:@"!*'();:@&=+$,/?%#[]"];
            NSString *encodedArg = [arg stringByAddingPercentEncodingWithAllowedCharacters:chars];
            [injection appendFormat:@", \"%@\"", encodedArg];
        }
    }
    
    [injection appendString:@");"];
    
    if (_webView){
        [_webView wk_evaluateJavaScript:injection completionHandler:^(id response, NSError *error) {
            if (completionHandler) {completionHandler(response, error);}
        }];
    }
}

@end

四. demo代碼

iOS

//
//  ViewController.m
//  TMEasyJSWebView
//
//  Created by 吉久東 on 2019/8/13.
//  Copyright ? 2019 JIJIUDONG. All rights reserved.
//

#import "ViewController.h"
#import "WKJSWebView.h"
#import "JSInterface.h"
#import "MJExtension.h"

@interface ViewController ()<WKNavigationDelegate>
@property (nonatomic, strong) WKJSWebView *webView;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor lightGrayColor];
    
    CGRect rect = CGRectMake(0, 0, self.view.bounds.size.width, self.view.bounds.size.height-150);
    self.webView = [[WKJSWebView alloc] initWithFrame:rect configuration:[WKWebViewConfiguration new] scripts:nil withJavascriptInterfaces:@{@"native":[JSInterface new]}];
    self.webView.navigationDelegate = self;
   [self.view addSubview:self.webView];
    
    NSString* _urlStr = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
    NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL fileURLWithPath:_urlStr]];
    [self.webView loadRequest:request];
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    
    UILabel* l = [UILabel new];
    l.text = @"這里灰色部分是原生界面";
    l.frame = CGRectMake(5, self.view.bounds.size.height - 150, 310, 20);
    [self.view addSubview:l];
    
    UIButton * b = [UIButton buttonWithType:UIButtonTypeCustom];
    b.backgroundColor = [UIColor yellowColor];
    [b setTitle:@"黃色是原生按鈕" forState:UIControlStateNormal];
    [b setTitleColor:[UIColor blueColor] forState:UIControlStateNormal];
    [b setTitleColor:[UIColor redColor] forState:UIControlStateHighlighted];
    [b addTarget:self action:@selector(nativeButtonClicked) forControlEvents:UIControlEventTouchUpInside];
    b.frame = CGRectMake(5, self.view.bounds.size.height-100, 310, 50);
    [self.view addSubview:b];
}

- (void)nativeButtonClicked {
    NSLog(@"點擊了原生按鈕");
    [self.webView invokeJSFunction:@"divChangeColor" params:@{@"color": [self Ox_randomColor]} completionHandler:^(id response, NSError *error) {
        NSLog(@"原生調用JS方法完成.");
    }];
}

- (NSMutableString*)Ox_randomColor {
    NSMutableString* color = [[NSMutableString alloc] initWithString:@"#"];
    NSArray * STRING = @[@"0",@"1",@"2",@"3",@"4",@"5",@"6",@"7",@"8",@"9",@"A",@"B",@"C",@"D",@"E",@"F"];
    for (int i=0; i<6; i++) {
        NSInteger index = arc4random_uniform((uint32_t)STRING.count);
        NSString *c = [STRING objectAtIndex:index];
        [color appendString:c];
    }
    return color;
}

@end

h5

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        .a {
            width: 300px;
            height: 80px;
            font-size: 32px;
            text-align: center;
            line-height: 80px;
            margin-bottom: 10px;
        }
    </style>
    <script>
        function getCharacter() {
            window.native.testWithParamscallback('abc', (p1, p2, p3) => {
                console.log(p1, p2, p3);
                var obj1 = JSON.parse(p1);
                let div = document.getElementById("op");
                div.innerHTML = obj1.letter;
            });
        };

        function changeColor(param) {
            let div = document.getElementById("oi");
            div.style.backgroundColor = param.color;
        };

        window.EasyJS.mount("divChangeColor", changeColor);
    </script>
</head>

<body>
    <p>這里是 h5 web 頁面</p>
    <p>1.點擊下面按鈕,調用原生方法獲取隨機字母并顯示到h5</p>
    <div id="op" class="a" style="background-color: pink;" onclick="getCharacter()"></div>
    <p>2.原生調用h5方法,改變該元素背景色</p>
    <div id="oi" class="a" style="background-color: aqua;" onclick="changeColor()"></div>
</body>

</html>
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,517評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,087評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,521評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,493評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,207評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,603評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,624評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,813評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,364評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,110評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,305評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,874評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,532評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,953評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,209評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,033評論 3 396
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,268評論 2 375