自己动手搭建一个基于WKWebView的webview架构

作者: 是的蛮大人 | 来源:发表于2016-09-09 17:55 被阅读2496次

    一、前言

    关于WKWebView(或者UIWebView)的用法,网上的资料很多,也很详细,这篇文章不会详细介绍这些知识点。写这篇文章,是因为项目中有一些界面是通过H5实现的(其实就跟大家每天浏览的网页一样,不要被H5这个词吓到),为了方便使用就封装了一些功能,包括与js之间的相互调用等,再加上看到经常有人在Q群里问webview如何与js交互,所以决定把项目中这部分内容整理一下,开源出来,希望能对一些同行有一点点帮助(其实只是简单的封装,谈不上什么开源...😁 )

    先放源码吧 -> SHWKWebView

    二、应该具备哪些功能

    使用WebView嵌套H5页面,我们经常会遇到以下需求:
    1、导航栏下面要能显示进度条
    2、导航栏上的标题要显示成H5页面上的title
    3、要能跟js互相调用,包括有时候会涉及到一些返回值的处理
    4、能下拉刷新webview内容
    5、页面加载失败要给提示
    6、如果APP里用户已经登录,需要把登录信息(比如token)传给H5
    甚至还会有以下需求:
    7、(接着第6条)H5页面里有时也会有一些超链接跳转(href),需要截获这些跳转自动补上token参数
    8、(接着第7条)当H5页面上的token参数与APP里保存的token参数不一致时,要使用APP里的token替换掉
    9、需要记录H5页面内的跳转历史,点击后退的时候回到的是上一个历史
    10、(接着9)可以指定某个页面后退到的H5页面
    等等等。。。

    我列举出来的这些都是自己项目中实际遇到的 😭 😭 😭 ,当然也是已经解决的。
    这次整理出来的源码里没有包含所有功能(只包含了1、2、3、5这几个功能),有一些涉及到二次封装的内容,如果都放出来略显杂乱,有一些功能现在回想一下感觉并不是很合理。如果大家想了解,我回头再整理一下解决方案和思路吧。

    三、一些实现方案

    • SHWKWebView的封装
      加载url:只需要传url的相对路径和参数即可(这2个方法也支持绝对路径)
    - (void)loadRequestWithRelativeUrl:(nonnull NSString *)relativeUrl;
    
    - (void)loadRequestWithRelativeUrl:(nonnull NSString *)relativeUrl params:(nullable NSDictionary *)params;
    
    

    实现代码:

    
    - (void)loadRequestWithRelativeUrl:(NSString *)relativeUrl params:(NSDictionary *)params {
        
        NSURL *url = [self generateURL:relativeUrl params:params];
        
        [self loadRequest:[NSURLRequest requestWithURL:url]];
    }
    
    - (NSURL *)generateURL:(NSString*)baseURL params:(NSDictionary*)params {
        
        self.webViewRequestUrl = baseURL;
        self.webViewRequestParams = params;
        
        NSMutableDictionary *param = [NSMutableDictionary dictionaryWithDictionary:params];
        
        NSMutableArray* pairs = [NSMutableArray array];
    
      //可以在这里将token参数添加进去,这样就可以实现第6点功能    
    
        for (NSString* key in param.keyEnumerator) {
            NSString *value = [NSString stringWithFormat:@"%@",[param objectForKey:key]];
    
            NSString* escaped_value = (__bridge_transfer NSString *)CFURLCreateStringByAddingPercentEscapes(NULL,
                                                                                  (__bridge CFStringRef)value,
                                                                                  NULL,
                                                                                  (CFStringRef)@"!*'\"();:@&=+$,/?%#[]% ",
                                                                                  kCFStringEncodingUTF8);
            
            [pairs addObject:[NSString stringWithFormat:@"%@=%@", key, escaped_value]];
        }
        
        NSString *query = [pairs componentsJoinedByString:@"&"];
        baseURL = [baseURL stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
        
        NSString* url = @"";
        if ([baseURL containsString:@"?"]) {
            url = [NSString stringWithFormat:@"%@&%@",baseURL, query];
        }
        else {
            url = [NSString stringWithFormat:@"%@?%@",baseURL, query];
        }
        //绝对地址
        if ([url.lowercaseString hasPrefix:@"http"]) {
            return [NSURL URLWithString:url];
        }
        else {
            return [NSURL URLWithString:url relativeToURL:self.baseUrl];
        }
    }
    

    为了做demo测试,额外增加了一个加载本地html文件的方法

    
    /**
     *  加载本地HTML页面
     *
     *  @param htmlName html页面文件名称
     */
    - (void)loadLocalHTMLWithFileName:(nonnull NSString *)htmlName
    

    实现代码:

    
    - (void)loadLocalHTMLWithFileName:(nonnull NSString *)htmlName {
    
        NSString *path = [[NSBundle mainBundle] bundlePath];
        NSURL *baseURL = [NSURL fileURLWithPath:path];
        NSString * htmlPath = [[NSBundle mainBundle] pathForResource:htmlName
                                                              ofType:@"html"];
        NSString * htmlCont = [NSString stringWithContentsOfFile:htmlPath
                                                        encoding:NSUTF8StringEncoding
                                                           error:nil];
        
        [self loadHTMLString:htmlCont baseURL:baseURL];
    }
    
    • SHWKWebViewController的封装
      这是一个Controller,可以直接使用,也可以创建新的Controller继承SHWKWebViewController来使用,我推荐使用继承的方式,因为可以把不同的页面区分开,每个页面加载的url和相关的业务逻辑都可以单独处理,代码易读,也容易维护。而且如果你的项目里需要添加一些统计(比如友盟的页面统计),也很好处理。
      SHWKWebViewController主要完成了对一些功能的封装,比如进度条、页面title以及webview的生命周期。
      进度条和title都是通过KVO实现:
    
      if (self.shouldShowProgress) {
            [self.webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:NULL];
        }
    
        if (self.isUseWebPageTitle) {
            [self.webView addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL];
        }
    

    其中,进度条用的是UINavigationController+SGProgress(已经通过文件的形式引入到项目中)

    
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
        if ([keyPath isEqualToString:@"estimatedProgress"]) {
            
            if (object == self.webView) {
                [self.navigationController setSGProgressPercentage:self.webView.estimatedProgress*100 andTintColor:[UIColor colorWithRed:24/255.0 green:124/255.0 blue:244/255.0f alpha:1.0]];
            }
            else{
                [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
            }
        }
        else if ([keyPath isEqualToString:@"title"]){
            if (object == self.webView) {
                if ([self isUseWebPageTitle]) {
                    self.title = self.webView.title;
                }
            }
            else{
                [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
            }
        }
        else {
            [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
        }
    }
    

    四、关于Objective-C与js的相互调用

    把这一块单独提出来是因为这一块很重要,因为大家遇到问题最多的可能就是这块了。这里说一下项目里对js调用的处理逻辑。
    WKWebView要处理js调用,需要添加ScriptMessageHandler(这一步在SHWKWebView里已经添加)

    [configuration.userContentController addScriptMessageHandler:self name:@"webViewApp"];
    

    然后是实现WKScriptMessageHandler代理

    
    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
        
        NSLog(@"message:%@",message.body);
        if ([message.body isKindOfClass:[NSDictionary class]]) {
            
            NSDictionary *body = (NSDictionary *)message.body;
            
            SHScriptMessage *msg = [SHScriptMessage new];
            [msg setValuesForKeysWithDictionary:body];
            
            if (self.sh_messageHandlerDelegate && [self.sh_messageHandlerDelegate respondsToSelector:@selector(sh_webView:didReceiveScriptMessage:)]) {
                [self.sh_messageHandlerDelegate sh_webView:self didReceiveScriptMessage:msg];
            }
        }
        
    }
    

    可以看到,我们将js脚本调用封装了一个对象SHScriptMessage,

    
    /**
     *  WKWebView与JS调用时参数规范实体
     */
    @interface SHScriptMessage : NSObject
    
    /**
     *  方法名
     *  用来确定Native App的执行逻辑
     */
    @property (nonatomic, copy) NSString *method;
    
    /**
     *  方法参数
     *  json字符串
     */
    @property (nonatomic, copy) NSDictionary *params;
    
    /**
     *  回调函数名
     *  Native App执行完后回调的JS方法名
     */
    @property (nonatomic, copy) NSString *callback;
    
    @end
    

    同时提供delegate方法供SHWKWebViewController实现

    
    /**
     *  JS调用原生方法处理
     */
    - (void)sh_webView:(SHWKWebView *)webView didReceiveScriptMessage:(SHScriptMessage *)message {
        
        NSLog(@"webView method:%@",message.method);
        
        //返回上一页
        if ([message.method isEqualToString:@"tobackpage"]) {
            [self.navigationController popViewControllerAnimated:YES];
        }
        //打开新页面
        else if ([message.method isEqualToString:@"openappurl"]) {
            
            NSString *url = [message.params objectForKey:@"url"];
            if (url.length) {
                SHWKWebViewController *webViewController = [[SHWKWebViewController alloc] init];
                webViewController.url = url;
                
                [self.navigationController pushViewController:webViewController animated:YES];
            }
        }
    }
    

    只需要比较message.method就可以知道js想调用原生的哪个方法了(当然了,method和params是需要跟H5开发人员约定好的)

    可能你还记得,前面我是推荐使用继承的方式来使用SHWKWebViewController了,那关于js调用原生方法的处理应该写在哪里呢?是SHWKWebViewController里还是具体继承的Controller里呢?
    关于这块,我们最开始是写在继承的Controller里的,好处是不同的业务逻辑分开处理,业务代码集中在一个Controller里,这样就更容易理解和维护。后来发现会有很多通用的方法,比如打开新页面openappurl,这个方法可能会在每个H5页面都会有,要是每个Controller里都写肯定是不合适的。
    因此,通用的js方法,最好写在SHWKWebViewController里,其他与业务相关的js最好写在具体的Controller里。
    在Controller里重写这个delegate方法(注意else的时候要调用super的delegate,否则那些通用的js方法就没法在这个Controller里调用了)

    
    - (void)sh_webView:(SHWKWebView *)webView didReceiveScriptMessage:(SHScriptMessage *)message {
    
        if ([message.method isEqualToString:@"hello"]) {
            
            if (message.callback.length) {
                [self.webView callJS:[NSString stringWithFormat:@"%@('hello-JS')",message.callback] handler:^(id  _Nullable response) {
                    NSLog(@"调用callback结果:%@",response);
                }];
            }
        }
        else {
            [super sh_webView:webView didReceiveScriptMessage:message];
        }
    }
    

    上面是原生APP里对js调用的一些准备工作,具体js调用方法如下(具体见main.html文件):

    function call(text) {
                    var message = {
                        'method' : 'hello',
                        'params' : {
                            'name':'张三',
                            'age':28
                        },
                        'callback': 'callback'
                    };
                    window.webkit.messageHandlers.webViewApp.postMessage(message);
                }
    

    关于原生调用js,这个WKWebView本身就提供了方法,而且还可以接收到js的返回值:

    - (void)callJS:(NSString *)jsMethod handler:(void (^)(id _Nullable))handler {
        
        NSLog(@"call js:%@",jsMethod);
        [self evaluateJavaScript:jsMethod completionHandler:^(id _Nullable response, NSError * _Nullable error) {
            if (handler) {
                handler(response);
            }
        }];
    }
    

    另外,再说明下,对于js里的alert和confirm方法,默认在WKWebView是没有效果的,需要重写下面这2个方法:

    #pragma mark - WKUIDelegate
    
    /**
     *  处理js里的alert
     *
     */
    - (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler {
        
        UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:message preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
            completionHandler();
        }]];
        
        [self presentViewController:alert animated:YES completion:nil];
    }
    
    /**
     *  处理js里的confirm
     */
    - (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
    
        UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:message preferredStyle:UIAlertControllerStyleAlert];
        
        [alert addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
            completionHandler(NO);
        }]];
        
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
            completionHandler(YES);
        }]];
        
        [self presentViewController:alert animated:YES completion:nil];
    }
    

    最后

    文笔和能力有限,如果上述内容有误,欢迎指出,我会及时改正!希望对你有一丢丢帮助!
    最后,Enjoy Yourself!

    相关文章

      网友评论

      • iPhone: window.webkit.messageHandlers.webViewApp.postMessage(object); 前端人员在开发时,怎么书写每次都要拷贝,应该提前注入一个obj对前端开发的引用
      • iPhone:最主要的是,iOS8 不能加载css、js、以及其他资源文件的问题,都没有说明,有点坑了
      • 空转风:我要怎么改变webview的frame呢?有时候我不需要全屏,咋办呢?我用的是继承controller
        是的蛮大人:@年光逝也被僵尸号占了 Cookie相关的我之前没有研究过,你可以查一下,貌似要在每次request的时候设置到request里
        空转风:@是的蛮大人 用实例化SHWKWebView实现了,控制器还是不太清楚,谢谢楼主分享,另外能封装下设置cookie的方法吗?
        是的蛮大人:你可以试试:实例化Controller(带webview的),修改controller.view.frame,再把controller.view add到你需要显示的view上
      • 641089041c5c:请问在加载https的链接,获取title研究过吗?kvo不行,怎么获取呢?
      • 0f0d63f24bdd:请问如何实现点击页面内的链接实现下载文件或者继续跳转到别的页面呢?如何拦截的?谢谢哈。
        是的蛮大人:@congdufs 在WKNavigationDelegate里有decidePolicyForNavigationAction这个方法,在这里可以拦截url,然后通过decisionHandler来控制是否可以跳转,你可以断点试一下,不知道能不能满足你的需求

      本文标题:自己动手搭建一个基于WKWebView的webview架构

      本文链接:https://www.haomeiwen.com/subject/hfbeettx.html