理解iOS端的WebView同层组件
一 起始
大部分的Web应用,所有的元素和组件都是渲染在WebView内部的,有时候这导致我们无法充分利用原生的强大能力,例如音视频播放,地图功能等。因此,在微信小程序开发框架中,还提供了一些以”cover-“开头的组件,这些组件本身是原生的,只是贴在了WebView上面。借助原生组件,可以极大的提高应用的性能体验,但是也有一些弊端。
- 原生组件的层级在WebView之上,因此无法在Web中通过标签的层级来调整组件的z轴位置。
- 原生组件与WebView文档流是完全脱离的,这使得布局的控制变得困难。
同层组件的出现正为解决这些问题。
二 原理
同层组件的目标是将原生组件渲染在与其他Web组件同一层级中。在iOS中,我们使用WKWebView来创建Web视图,WKWebView在进行解析渲染时,会将Web组件渲染到WKCompositingView上,这个View是一个原生的UIView子类,通常WKWebView内核会将多个组件共同渲染到同一个WKCompositingView上,但是如果某个HTML标签的style设置了overflow: scroll属性,并且内容超出容器的大小,WKWebView就会为其单独的创建一个WKChildScrollView,因此如果我们可以找到这个View,并和对应的Web组件一一关联起来,就可以将原生的组件渲染到这个View中,从而实现同层渲染。
我们可以先写一个简单的Web示例页面:
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
<title></title> | |
<link rel="stylesheet" href=""> | |
<style type="text/css"> | |
.block { | |
width: 80%; | |
height: 300pt; | |
margin-top: 50pt; | |
background-color: red; | |
} | |
.content { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
width: 100%; | |
} | |
.title { | |
width: 100%; | |
text-align: center; | |
} | |
.toast { | |
position: fixed; | |
width: 250pt; | |
height: 100pt; | |
background-color: gray; | |
line-height: 100pt; | |
text-align: center; | |
color: white; | |
top: 50%; | |
left: 50%; | |
font-size: 50pt; | |
transform: translate(-50%,-50%); | |
} | |
.native { | |
width: 80%; | |
height: 350pt; | |
margin-top: 50pt; | |
background-color: blue; | |
overflow: scroll; | |
} | |
</style> | |
</head> | |
<body> | |
<!-- 标题 --> | |
<h1 class="title">H5页面Demo</h1> | |
<!-- 内容 --> | |
<div class="content"> | |
<div class="block"></div> | |
<div class="block"></div> | |
<!-- 特殊组件 --> | |
<div class="native"> | |
<div style="width: 101%; height: 101%"> | |
</div> | |
</div> | |
<div class="block"></div> | |
<div class="block"></div> | |
<div class="block"></div> | |
</div> | |
<!-- 弹框 --> | |
<div class="toast show">弹窗提示</div> | |
</body> | |
</html> |
上面代码中,蓝色的色块就是同层组件容器。
在iOS中加载此页面如下:
@interface ViewController () | |
@property (nonatomic, strong) WKWebView *webView; | |
@end | |
@implementation ViewController | |
- (void)viewDidLoad { | |
[super viewDidLoad]; | |
[self.view addSubview:self.webView]; | |
NSString *html = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"web" ofType:@"html"] encoding: NSUTF8StringEncoding error:nil]; | |
[self.webView loadHTMLString:html baseURL:nil]; | |
} | |
- (WKWebView *)webView { | |
if (!_webView) { | |
_webView = [[WKWebView alloc] initWithFrame:self.view.frame]; | |
} | |
return _webView; | |
} | |
@end |
使用Xcode调试工具进行查看,层级如下图所示:
可以看到对于蓝色的色块,WKWebView单独创建了一个WKChildScrollView来承载。
三 尝试
了解了同层组件原理后,我们可以在iOS平台上做下尝试,体验同层组件的渲染效果。首先在HTML文件中补充下面的JS代码:
<script> | |
function insertNativeComponents() { | |
var ct = document.getElementsByClassName("native")[0]; | |
var id = ct.getAttribute("id"); | |
var frame = ct.getBoundingClientRect(); | |
var args = { | |
"frame": { | |
"y": frame.top, | |
"x": frame.left, | |
"width": frame.width, | |
"height": frame.height | |
}, | |
"id": id | |
}; | |
return args | |
} | |
setTimeout(()=>{ | |
window.webkit.messageHandlers.nativeViewHandler.postMessage({ | |
"command": "nativeViewInsert", | |
"args": insertNativeComponents() | |
}); | |
}, 1000); | |
</script> |
上面的insertNativeComponents函数用来找到要插入原生组件的插槽,将其id等信息传递给原生端,我们这里为了演示方便,只传递了很少的数据,实际上可以根据组件的需求向原生端传递非常丰富的数据,原生端根据这些参数来渲染和设置原生组件。
在原生端,需要对WKWebView注册一个JS交互handle,如下:
[_webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeViewHandler"];
对应的,实现协议方法如下:
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { | |
[self insertNativeView:message]; | |
} |
核心的逻辑方法是inserNativeView,这个方法中要实现对JS交互指令的解析,以及原生组件的创建,插槽容器的寻找等,如下:
- (void)insertNativeView:(WKScriptMessage *)message { | |
NSDictionary *params = message.body[@"args"]; | |
NSLog(@"%@", params); | |
// 这里创建一个UILabel 做演示 | |
UIView *v = [self findView:self.webView str:@"" ids:params[@"id"]]; | |
UIView *c = [[UIView alloc] initWithFrame:v.bounds]; | |
UILabel *l = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, v.frame.size.width, 100)]; | |
l.backgroundColor = UIColor.orangeColor; | |
l.font = [UIFont systemFontOfSize:40]; | |
l.text = [NSString stringWithFormat:@"组件ID为:%@的原生同层组件", params[@"id"]]; | |
l.textAlignment = NSTextAlignmentCenter; | |
[c addSubview:l]; | |
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; | |
[button setTitle:@"按钮" forState:UIControlStateNormal]; | |
[button setTitleColor:UIColor.whiteColor forState:UIControlStateNormal]; | |
button.frame = CGRectMake(0, 200, v.frame.size.width, 100); | |
button.titleLabel.font = [UIFont systemFontOfSize:40]; | |
[c addSubview:button]; | |
if (v) { | |
// 查目标容器 | |
for (UIView *sub in v.subviews) { | |
if ([sub isKindOfClass:NSClassFromString(@"WKChildScrollView")]) { | |
c.frame = sub.bounds; | |
[sub addSubview:c]; | |
} | |
} | |
} | |
} |
上面我们创建了一个UILabel和UIButton的原生组件做示例,插槽位置的寻找可以采用递归的方式,如下:
- (UIView *)findView:(UIView *)root str:(NSString *)pre ids:(NSString *)ids { | |
if (!root) { | |
return nil; | |
} | |
NSLog(@"%@%@,%@",pre ,root.class, root.layer.name); | |
if ([root.layer.name containsString:[NSString stringWithFormat:@"id='%@'", ids]]) { | |
return root; | |
} | |
for (UIView *v in root.subviews) { | |
UIView *res = [self findView:v str:[NSString stringWithFormat:@"%@ - ", pre] ids: ids]; | |
if (res) { | |
return res; | |
} | |
} | |
return nil; | |
} |
我们从JS交互命令可以拿到要插入原生组件的容器id,WKWebView在创建WKCompositingView时,其Layer的name会包含id信息,这从打印的信息上可以清楚的看到,如下图:
我们能找到对应的容器,就是靠这个Layer的name属性。现在你可以尝试运行下项目,效果如下图所示:
可以看到,原生组件已经正常渲染到了WebView中,且层级是受CSS控制的,其会出现在Web弹窗组件之下。
四 交互
原生组件渲染成功了,并非完事大吉,如果你为按钮增加了点击事件,会发现其并不会触发,这是因为WebView将事件都进行了拦截。要处理交互问题也非常简单,首先需要先关闭WebView的拦截,在WebView加载完成后,使用如下代码来找到WKContentView,并将其手势拦截关闭:
- (void)handleGestrues { | |
UIScrollView *webViewScrollView = self.webView.scrollView; | |
if ([webViewScrollView isKindOfClass:NSClassFromString(@"WKScrollView")]) { | |
UIView *_WKContentView = webViewScrollView.subviews.firstObject; | |
if (![_WKContentView isKindOfClass:NSClassFromString(@"WKContentView")]) return; | |
NSArray *gestrues = _WKContentView.gestureRecognizers; | |
for (UIGestureRecognizer *gesture in gestrues) { | |
gesture.cancelsTouchesInView = NO; | |
gesture.delaysTouchesBegan = NO; | |
gesture.delaysTouchesEnded = NO; | |
} | |
} | |
} |
需要注意,这个方法的调用要在WebView加载完成后。另外,我们需要将原生组件的容器组件做些修改,例如新建一个ContainerView类,如下:
@interface ContainerView : UIView | |
@end | |
@implementation ContainerView | |
- (BOOL)conformsToProtocol:(Protocol *)aProtocol { | |
if (aProtocol == NSProtocolFromString(@"WKNativelyInteractible")) { | |
return YES; | |
} | |
return [super conformsToProtocol:aProtocol]; | |
} | |
@end |
之后,将此View作为原生组件的容器,渲染到WebView中,即可实现原生组件的事件交互。
五 随想
本文从原理出发,介绍了Web同层组件在iOS端的实现方式。相比直接使用原生组件,同层组件的好处是显而易见的,其既拥有了原生组件强大的能力,又可以被大部分CSS属性进行影响,方便层级和组件间位置控制。本文中也实现了一个简单的Demo来演示同层组件,Demo非常捡漏,希望起到抛砖引玉,帮助你打开创新的思路。下面是一些建议,有兴趣你可以尝试下在iOS端实现一套完整的同层组件渲染框架。
- JS与原生的交互命令可以定制一套完整的协议,如组件插入,组件更新,组件删除等。
- 传递的数据可以定义的完整丰富,例如要插入的组件类型,可能是视频,音频,地图等,各种组件在原生端的属性配置等映射。
- 原生端的交互与更新行为也需要通过JS传递到Web。
- 原生端可能需要一个容器池来维护被插入的同层组件,方便通过id寻找来进行更新等。
- 某些CSS属性对于同层组件可能并不能生效,也是需要通过JS传递数据到原生端处理。