本文将介绍小程序的核心视图层逻辑层分离架构,并通过 iOS 的代码来模拟这种双线程模型。
什么是小程序
小程序是一种新的移动应用程序格式,是一种依赖 Web 技术,但也集成了原生应用程序功能的混合解决方案。
目前市面上小程序平台微信、支付宝、百度、头条、京东、凡泰等;小程序一些特性有助于填补 Web 和原生平台之间的鸿沟,因此小程序受到了一些超级应用程序的欢迎。
- 它不需要安装,支持热更新。
- 具备多个 Web 视图以提高性能。
- 它提供了一些通过原生路径访问操作系统功能(原生接口)或数据的机制。
- 它的内容通常更值得信赖,因为应用程序需要由平台验证。
- 小程序可以分发到多个小程序平台(Web、原生应用,甚至是 OS)。这些平台还为小程序提供了入口,帮助用户轻松找到所需的应用。
小程序核心功能
分离视图层与逻辑层
在小程序中,视图层通常与逻辑层分离。
- 视图层 View 负责渲染小程序页面,包括 Web 组件和原生组件渲染,可以将其视为混合渲染。例如,Web 组件渲染可以由 WebView 处理,但 WebView 不支持某些 Web 组件渲染,或者是性能受限;小程序还依赖于某些原生组件,例如地图、视频等。
- 逻辑层 Service 是用主要用于执行小程序的 JS 逻辑。主要负责小程序的事件处理、API 调用和生命周期管理。扩展的原生功能通常来自宿主原生应用程序或操作系统,这些功能包括拍照、位置、蓝牙、网络状态、文件处理、扫描、电话等。它们通过某些 API 调用。当小程序调用原生 API 时,它会将 API 调用传递给扩展的原生功能,以便通过 JSBridge 进一步处理,并通过 JSBridge 从扩展的原生功能获取结果。Service 为每个 Render 建立连接,传输需要渲染的数据以进一步处理。
- 如果事件由小程序页面中的组件触发,则此页面将向 Service 发送事件以进一步处理。同时,页面将等待 Service 发送的数据来重新渲染小程序页面。
- 渲染过程可被视为无状态,并且所有状态都将存储在 Service 中。
视图层和逻辑层分离有很多好处:
- 方便多个小程序页面之间的数据共享和交互。
- 在小程序的生命周期中具有相同的上下文可以为具备原生应用程序开发背景的开发人员提供熟悉的编码体验。
- Service 和 View 的分离和并行实现可以防止 JS 执行影响或减慢页面渲染,这有助于提高渲染性能。
- 因为 JS 在 Service 层执行,所以 JS 里面操作的 DOM 将不会对 View 层产生影响,所以小程序是不能操作 DOM 结构的,这也就使得小程序的性能比传统的 H5 更好。
小程序双线程模型模拟
~~先看一下运行结果
接下来我们将用 iOS 代码来模拟上述的双线程模型。首先我们来实现视图层与逻辑层的数据通讯
如上图所示,视图层与逻辑层都分别通过 JS Bridge 的 publish 和 subscribe 来实现数据的收发。
模拟实现
1、视图层调用JSBridge.publish把事件传递给原生;参数: eventName: \'\', data:
//点击按钮,通知JS执行业务逻辑
function onTest()
console.log(\'aaa\')
FinChatJSBridge.subscribe(\'PAGE_EVENT\', function (params)
document.getElementById(\'testId\').innerHTML = params.data.title )
FinChatJSBridge.publish(\'PAGE_EVENT\',
eventName: \'onTest\',data:
)
2、原生 view 层收到 page 的事件,把事件传递转发给 service 层处理
if ([message.name isEqualToString:@"publishHandler"])
NSString *e = message.body[@"event"];
[self.service callSubscribeHandlerWithEvent:e param:message.body[@"paramsString"]];
3、原生 service 层收到原生 view 层的事件,通过 jsbridge 把事件及参数传递给视图 ervice 层执行 js 逻辑
NSString *js = [NSString stringWithFormat:@"ServiceJSBridge.subscribeHandler(\'%@\',%@)",eventName,jsonParam];
[self evaluateJavaScript:js completionHandler:nil];
4、视图 service,收到事件后,执行 JS 业务代码
var Page =
setData: function(data)
//向原生视图层发送更新数据信息
ServiceJSBridge.publish(\'PAGE_EVENT\',
eventName: \'onPageDataChange\',
data: data
)
,
methods:
onTest: function()
// 执行JS方法,模拟小程序的setData,把数据更新到视图层
Page.setData(
title: \'我来自JS代码更新\'
)
console.log(\'my on Test\')
var onWebviewEvent = function(fn)
ServiceJSBridge.subscribe(\'PAGE_EVENT\', function(params)
console.log(\'FinChatJSBridge.subscribe\')
var data = params.data,
eventName = params.eventName
fn(
data: data,
eventName: eventName
)
)
var doWebviewEvent = function(pEvent, params)
// do dom ready
if (Page.methods.hasOwnProperty(pEvent))
// 收到视图层的事件,执行JS对应的方法
Page.methods[pEvent].call(params)
5、执行业务 JS 代码后,把数据更新传递给视图层去更新 UI 界面展示数据
ServiceJSBridge.publish(\'PAGE_EVENT\',
eventName: \'onPageDataChange\',
data: data
)
6、原生 service 层收到视图 service 层的事件,把事件传递给原生视图层
if ([message.name isEqualToString:@"publishHandler"])
NSString *e = message.body[@"event"];
[self.controller callSubscribeHandlerWithEvent:e param:message.body[@"paramsString"]];
7、原生视图层把收到的事件,传递给视图 view 层
NSString *js = [NSString stringWithFormat:@"FinChatJSBridge.subscribeHandler(\'%@\',%@)",eventName,jsonParam];
[self evaluateJavaScript:js completionHandler:nil];
8、视图 view 层,收到事件后,更新界面
FinChatJSBridge.subscribe(\'PAGE_EVENT\', function(params)
document.getElementById(\'testId\').innerHTML = params.data.title
)
订阅数据回调
// 首先订阅数据回调
JSBridge.subscribe(\'PAGE_EVENT\', function(params)
// ... 这里对返回的数据进行处理
)
// 向JS Bridge发布数据
// eventName: 用于标识事件名
// data: 为传递的数据
JSBridge.publish(\'PAGE_EVENT\', eventName: \'onTest\', data: )
WKWebView 初始化,
WKUserContentController *userContentController = [WKUserContentController new];
NSString *souce = @"window.__fcjs_environment=\'miniprogram\'";
WKUserScript *script = [[WKUserScript alloc] initWithSource:souce injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:true];
[userContentController addUserScript:script];
[userContentController addScriptMessageHandler:self name:@"publishHandler"];
WKWebViewConfiguration *wkWebViewConfiguration = [WKWebViewConfiguration new];
wkWebViewConfiguration.allowsInlineMediaPlayback = YES;
wkWebViewConfiguration.userContentController = userContentController;
if (@available(iOS 9.0, *))
[wkWebViewConfiguration.preferences setValue:@(true) forKey:@"allowFileAccessFromFileURLs"];
WKPreferences *preferences = [WKPreferences new];
preferences.javaScriptCanOpenWindowsAutomatically = YES;
wkWebViewConfiguration.preferences = preferences;
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:wkWebViewConfiguration];
self.webView.clipsToBounds = YES;
self.webView.allowsBackForwardNavigationGestures = YES;
[self.view addSubview:self.webView];
NSString *urlStr = [[NSBundle mainBundle] pathForResource:@"view.html" ofType:nil];
NSURL *fileURL = [NSURL fileURLWithPath:urlStr];
[self.webView loadFileURL:fileURL allowingReadAccessToURL:fileURL];
WKWebView 事件回调处理
// 执行视图层事件回调
- (void)callSubscribeHandlerWithEvent:(NSString *)eventName param:(NSString *)jsonParam
NSString *js = [NSString stringWithFormat:@"FinChatJSBridge.subscribeHandler(\'%@\',%@)",eventName,jsonParam];
[self evaluateJavaScript:js completionHandler:nil];
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void(^)(id result,NSError *error))completionHandler
[self.webView evaluateJavaScript:javaScriptString completionHandler:completionHandler];
#pragma mark - WKScriptMessageHandler
// 视图层JSBridge请求接收处理
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
if ([message.name isEqualToString:@"publishHandler"])
NSString *e = message.body[@"event"];
[self.service callSubscribeHandlerWithEvent:e param:message.body[@"paramsString"]];
视图层代码
function onTest()
console.log(\'aaa\')
FinChatJSBridge.subscribe(\'PAGE_EVENT\', function(params)
document.getElementById(\'testId\').innerHTML = params.data.title
)
FinChatJSBridge.publish(\'PAGE_EVENT\',
eventName: \'onTest\',
data:
)
<div id="testId">我来自视图层!</div>
<input type="button" value="调用JS逻辑层setData" style="border-radius:15px;background:#ed0c50;border: #EDD70C;color: white;font-size: 14px; width: 80%;" onclick="onTest();" />
逻辑层代码
// page 对像模拟
var Page =
setData: function(data)
ServiceJSBridge.publish(\'PAGE_EVENT\',
eventName: \'onPageDataChange\',
data: data
)
,
methods:
onTest: function()
Page.setData(
title: \'我来自JS代码更新\'
)
console.log(\'my on Test\')
var onWebviewEvent = function(fn)
ServiceJSBridge.subscribe(\'PAGE_EVENT\', function(params)
var data = params.data,
eventName = params.eventName
fn(
data: data,
eventName: eventName
)
)
var doWebviewEvent = function(pEvent, params)
// do dom ready
if (Page.methods.hasOwnProperty(pEvent))
Page.methods[pEvent].call(params)
onWebviewEvent(function(params)
var eventName = params.eventName
var data = params.data
return doWebviewEvent(eventName, data)
)
文档中心: Document
本文示例代码: https://github.com/finogeeks/fino-applet
MINA
MINA
是在微信中开发小程序的框架。其目标是通过尽可能简单、高效的方式让开发者可以在微信中开发具有原生 APP
体验的服务。
MINA
提供了自己的视图层描述语言 WXML
和 WXSS
,以及基于 JavaScript
的逻辑层框架,核心是一个响应的数据绑定系统。整个系统分为视图层(View
)和逻辑层(App Service
),并在视图层与逻辑层间提供了数据传输和事件系统,可以让开发者可以方便的聚焦于数据与逻辑上。
MINA
让数据与视图保持同步非常简单。当做数据修改的时候,只需要在逻辑层修改数据,视图层就会做相应的更新。MINA
是腾讯给微信小程序命名的框架,实际上应用的是目前业界最著名的 MVVM
模式。
wxml的真实面目
我们都知道小程序提供了很多方便快捷的自定义组件(标签),但你知道小程序的这些组件编译过后会渲染成什么吗?先说答案,其实 wxml
经过编译后会渲染成 html
。很简单的一点,你发现在小程序内编写 html
标签,最终也可以运行。
探寻
光说可能体会不到,下面开始探寻小程序真实渲染的样子。先看下开发者工具内 wxml
的内容,待会和真实渲染的内容做对比。
接下来一步步找到小程序 wxml
渲染完成的真实样子,工具菜单栏点击微信开发者工具,选择调试微信开发者工具。打开的控制台可以调试整个微信开发者工具,用调试箭头指向小程序内容区域,这时可以看到小程序视图层是被嵌套在 webview
的 iframe
内。
但是当我们点开 iframe
是无法查看到里面内容的。如果想要查看调试 webview
,只需选中 webview
打开它的调试工具即可,在控制台输入以下代码:
$$(\'webview\')[0].showDevTools(true)
可以看到又打开了一个调试窗口,这里面就是小程序视图层渲染的真实样子:
可以看到结构和 wxml
里的内容几乎一模一样,只是 topbar
变成了 wx-topbar
,view
变成了 wx-view
等。这些都是内部实现的一套对应小程序标签的 webComponent
组件,而 webComponent
实际渲染出来还是 html
标签。
转换过程
转换过程是微信开发者工具内部通过一个可执行编译工具实现对小程序文件转换。在微信开发者工具控制台输入 openVendor()
会打开一个文件夹,里面存放着微信的基础库及工具,在里面可以找到 wcc.exe
、wcsc.exe
执行文件,分别对应 wxml
和 wxss
的文件转换。
该工具可以单独对小程序文件进行转换,使用方法 ./wcc -d wxml文件路径 >> 输出路径
。例如,将工具复制到一个文件夹内,再将一个 wxml
放入该文件夹内,命令行输入 :
./wcc -d index.wxml >> index.js
可能有人很好奇为什么是生成 js
文件,而不是 html
文件。原因很简单,因为需要处理 wxml
的动态绑定数据。看看这个 js
文件生成的是什么:
因为这些都是混淆压缩过的代码,基本没有可读性。这里只需要注意一个函数就好,那就是 $gwx
。这是个很关键的函数,它的作用是生成虚拟dom树,用于渲染真实节点。
接下来回到 webview
调试窗口,在 head
内找到这段插入的 script
标签代码:
有没有很熟悉,没错,就是和上面转换后的代码是同一个东西。也就是说,我们的 wxml
文件通过编译,最终在视图层中执行的就是这段 js
代码(这里只是可以大概这么理解,实际需要向逻辑层获取数据才能渲染页面)。控制台输入 $gwx
发现这个函数存在,那么这个函数如何生成虚拟dom呢?$gwx
函数的第一个参数接收一个路径参数,这个路径就是 wxml
文件路径,此时在控制台输入:
let generateFunc = $gwx(\'./pages/index/index.wxml\')
generateFunc()
这时页面虚拟dom就生成出来了:
单纯调用 generateFunc
生成出来的虚拟dom是没有动态绑定数据的,如果想要动态的绑定数据,在调用 generateFunc
时传入一个数据对象。但是数据全在逻辑层里,这时就需要进行通信了。
数据通信
首先要知道小程序时运行在基础库之上的,但它们都是压缩打包好的,后面找到反编译出来的基础库代码,其中最重要的就是 WAService.js
和 WAWebview.js
,它们分别是视图层和逻辑层的核心实现。
它们之间需要一个桥梁来进行通信,那就是 JS Bridge
。JS Bridge
提供调用原生功能的接口(摄像头,定位等),它的核心是构建原生和非原生间消息通信的通道,而且这个通信的通道是双向的。通过 JS Bridge
的发布订阅方法,视图层和逻辑层进行数据通信。
通信流程
接下来看看视图层和逻辑层的交互流程:
wxml
转换成对应的 js
文件,等待生成虚拟dom函数 $gwx
准备完成,使用 dispatchEvent
通知 WAWebview
。
WAWebview
监听到 generateFuncReady
事件触发,使用 WeixinJSBridge.publish
向逻辑层通信。
-
逻辑层处理逻辑,也就是我们平常写的小程序 js
文件里的东西,然后通过 JS Bridge
通知并返回数据给视图层。
-
视图层接收到数据,将数据传入生成虚拟dom的函数内,渲染页面,当然小程序也有相应的diff算法。
例如在 wxml
中绑定一个动态数据 title
,视图层接收到数据后,重新生成虚拟dom
generateFunc({
title: \'标题\'
})
- 初始化完成后,就会走对应的其他生命周期,或者用户触发事件,数据都会在逻辑层处理完成后通过
JS Bridge
通知到视图层,视图层再次调用生成虚拟dom的函数,更新页面。
wxss如何工作
wxss
工作原理和 wxml
差不多,都是通过工具转换为 js
。为什么又是转换成 js
,因为有 rpx
单位,需要根据手机尺寸进行设置 px
。
wcsc.exe
转换命令如下:
./wcsc -js index.wxss >> index.js
可以看到文件开头就是对 rpx
的转换
之后创建 style
标签,动态添加到视图层中
最后
附上 WAService.js 和 WAWebview.js 的代码作为学习参考。