理解 iOS 端的 WebView 同层组件
一 起始
同层渲染是利用原生技术来优化 Web 渲染一种技术,很多人了解它是起于微信开放社区发布的一篇关于小程序渲染原理剖析的文章。我将链接附上:
https://developers.weixin.qq.com/community/develop/article/doc/000c4e433707c072c1793e56f5c813
大部分的 Web 应用,所有的元素和组件都是渲染在 WebView 内部的,有时候这导致我们无法充分利用原生的强大能力,例如音视频播放,地图功能等。因此,在微信小程序开发框架中,还提供了一些以”cover-“开头的组件,这些组件本身是原生的,只是贴在了 WebView 上面。借助原生组件,可以极大的提高应用的性能体验,但是也有一些弊端。
同层组件的出现正为解决这些问题。
二 原理
同层组件的目标是将原生组件渲染在与其他 Web 组件同一层级中。在 iOS 中,我们使用 WKWebView 来创建 Web 视图,WKWebView 在进行解析渲染时,会将 Web 组件渲染到 WKCompositingView 上,这个 View 是一个原生的 UIView 子类,通常 WKWebView 内核会将多个组件共同渲染到同一个 WKCompositingView 上,但是如果某个 HTML 标签的 style 设置了 overflow: scroll 属性,并且内容超出容器的大小,WKWebView 就会为其单独的创建一个 WKChildScrollView,因此如果我们可以找到这个 View,并和对应的 Web 组件一一关联起来,就可以将原生的组件渲染到这个 View 中,从而实现同层渲染。
我们可以先写一个简单的 Web 示例页面:
<!DOCTYPE html>
<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 传递数据到原生端处理。
评论