自己动手搭建一个基于WKWebView的webview架构
一、前言
关于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!