前端想要写 APP
开篇想以这样的方式开头,从 APP 开始火到现在,前端同学就一直想要写 APP,各方技术也是为了让前端同学写上 APP 操碎了心。
为什么要前端同学来写 APP,站在整个技术链上来看,都是在做页面呈现,页面交互,对于技术而言只是将产品在不同的端上进行呈现,所以很早之前就有提倡说大前端的概念。既然功能都差不多,就没有分家的理由。
再者,开发客户端现在的常规平台有 iOS 和 android 两个大的平台,在前端领域越来越细分的大环境下,传统意义上的客户端开发至少需要两拨开发者来做,成本就会有增加。并且伴随着成本增加还带来了需要两套体系化的语言开发,开发效率也会大打折扣。相较于两大客户端平台的开发人员,前端的开发人员是要更多的,并且随着移动端的兴起前端开发者会有资源剩余的情况,前端来写 APP 也可以平衡资源。
所以不仅仅是前端同学想要来写 APP,更多的是未来发展需要这样的技术来提高生产力,所以才会有各种各样的方案出现,各种方案的诞生也发现了前端写 APP 的不足,所以又有了不断的演进来适应未来发展。送给每一个前端人,我们的征途是星辰大海
web APP
就是我们常说的 h5 应用,这个阶段属于满足展示功能阶段,首先是解决手机端能展示页面的问题。
实现方式就是客户端提供 webView 组件,就是一个基于 webkit 引擎、展现 web 页面的控件。web APP 实际上就是客户端提供了一个浏览器的宿主环境,能在手机上显示对应的网页内容,但是各大手机厂商对于内核都会有所谓的“优化”,这些所谓的优化或者内核版本升级所带来的兼容性问题,也是让前端同学咬牙切齿。
客户端提供了类似浏览器的宿主环境,前端将自己的 HTML/CSS/JS 运行起来,就可以看到页面了。但是在 PC 端原来我们看到的页面放在手机上,展示和交互都不太合适。之所以我们要叫 h5 应用,是说我们在移动端上需要用到 HTML5/CSS3 的新功能,通过这些功能能在客户端的浏览器上做适配展示页面。
响应式的布局方式,新式的 meta 标签,媒体查询等功能让前端以前显示在 PC 端上的页面能展示在手机端。随着 PWA 的出现,现在手机端逐渐对 Service Worker 的支持(Safari 还在踌躇犹豫),web APP 又重新焕发了青春。
优点:
- 基于 webView 的跨平台,调试方便(控制台调试)
- 开发速度快,只要有浏览器就能打开,适合小版本的试错
- 无需安装,不占内存,随时更新,维护成本低
缺点:
- 打开白屏时间长,用户体验较差,交互功能受限。
- 产品也受限于浏览器,并且留存低,适合拉新
- 不采用 PWA 的方案就无法离线,没有网络就失去了活力,现在不用网络的 APP 情况已经很少了。重复打开重复加载
代表产品:
- 所有可以使用手机浏览器打开的网页(不做兼容展示会有问题)
- 微信公众号里纯文章
hybrid 应用
跨过了 web APP 的满足展示功能阶段,这个时候开始想有更多的功能满足交互需求,比如调起原生的摄像头(虽然 input 也可以实现),这个时候催生了 hybrid APP,也进入了丰富功能阶段。
前端 webView 的交互实现无法直接与摄像头的 API 进行交互,类似这样的功能是客户端的能力,webView 想要完成这样的功能,如果不能直接完成是不是可以通过调用客户端来完成这样的功能,但是前端和客户端是两种不同的语言及实现方式,如何进行通信?
想要通信完成通信,其实就是需要再前端与和客户端搭建一个沟通桥梁,就是现在常说的 js bridge。介绍击中常见的 js bridge 通信方式。
js bridge
js 调用 native:
- 请求拦截
- 弹窗拦截
- 注入 js 方法
native 调用 js:
- 直接执行 js 代码
请求拦截
webView 发送请求都会经过客户端的请求发送模块,客户端可以在请求发送出去之前做拦截,大家约定好一种 URI 的实现协议,如果符合实现协议的请求,就拦截下来进行约定协议解析,其他的就当做真正的请求发送出去。
首先约定一个简单的协议
eros://bmImage/camera?params={"imageNum":2,"allowCrop":true,"callbackId":"bmImage.camera1527235458519"}
eros // 作为协议存在,用于做拦截的方式
bmImage/camera // 用于约定是调用客户端的方法名,可以是想要调用相机上传两种图片的接口
params // query 表示参数,其中有一个参数名叫 params 的参数是一个 json,是调用客户端的方法所需要的三个参数
复制代码
不管 js 如何封装,就是需要发送一个请求出去,最好前端能做统一封装通过 iframe 或者直接通过 ajax 发送请求。
客户端如何拦截
android 的拦截方式 shouldOverrideUrlLoading
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
// 根据协议判断是否需要拦截
if (true){
// 解析协议路径得知调用方法
// 解析 query 得到参数
// 通过反射的方式去调用对应的原生方法
return true;
}
return super.shouldOverrideUrlLoading(view, url);
}
复制代码
iOS 的拦截方式(UIWebView) webView:shouldStartLoadWithRequest:navigationType:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
// 根据协议判断是否需要拦截
if (true){
// 解析协议路径得知调用方法
// 解析 query 得到参数
// 通过 AOP 的方式去调用去调用对应的原生方法
return NO;
}
return YES;
}
复制代码
上述过程就完成了 js 向 native 的通信的一种方式,客户端完成了操作之后如何告知 js 后面会讲到。这种方式不好的地方在于只能支持异步的调用方式,网络 I/O 就决定了这种方式不支持同步。既然是 URI 的协议就会有长度限制,超长了请求就会被截断
更为严重的是无序性和有丢消息的可能,这也导致了这个方式非常不稳定,也是早期没有其他选择使用的方式。
弹窗拦截
js 调用弹窗时一般有 alert/confirm/prompt 三种弹窗,这三种弹窗对应客户端都会有方法实现,可以直接做拦截。
var actionInfo = {
type: 'eros',
action: 'bmImage/camera',
params:{
"imageNum":2,
"allowCrop":true,
"callbackId":"bmImage.camera1527235458519"
}
}
prompt(JSON.stringify([actionInfo]))
复制代码
android 拦截 prompt
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
// 解析传过来的字符串判断 type 类型是不是解析的方式
if (true){
// 解析协议路径得知调用方法
// 解析 query 得到参数
// 通过 AOP 的方式去调用去调用对应的原生方法
// 如果是异步返回
return true;
// 如果是同步,待上面的程序执行完毕之后返回结果
// return res
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}
复制代码
iOS 拦截 prompt(使用 WKWebView)
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler{
// 解析传过来的字符串判断 type 类型是不是解析的方式
if (true){
// 解析协议路径得知调用方法
// 解析 query 得到参数
// 通过 AOP 的方式去调用去调用对应的原生方法
// 如果是异步返回
NSInvocation *invocation = [执行方法]
[invocation invoke];
return invocation;
// 如果是同步,待上面的程序执行完毕之后返回结果
return nil
}else{
执行对应的弹窗
}
}
复制代码
这种方式比请求拦截好的地方是可以支持同步调用了。但是 iOS 中有两种 UIWebView 和 WKWebView,前者在 webView 中屏蔽的弹窗的功能。后者虽然内存要比前者控制的好但是有很多兼容性的问题。
js 注入上下文
上面的使用方式还是比较曲折,发展到现在有了 JavaScriptCore,也是现在大部分混合应用解决方案核心,JavaScriptCore 没有 BOM 对象也没有 DOM 对象,甚至还缺失一些浏览器的方法,但是这块是 js 和 native 都能访问到的公共区域。在打开 webView 之后,往 js 的上下文中注入方法,供 js 进行调用。
iOS JavaScriptCore 注入(UIWebView)
// 获取 WebView 中 JS上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
//注入 callNative 函数方法
context[@"callNative"] = ^( JSValue * params )
{
// 解析参数找到对应的方法进行处理
// 同步回调
NSInvocation *invocation = [执行方法]
[invocation invoke];
return invocation;
// 异步回调
return nil
}
复制代码
js 调用方式
callNative({
action: 'bmImage/camera',
params:{
"imageNum":2,
"allowCrop":true,
"callbackId":"bmImage.camera1527235458519"
}
});
复制代码
android webView 注入
// 通过 addJavascriptInterface 将对象映射到 JS 对象
// android 对象
// js 的对象名
mWebView.addJavascriptInterface(new JavaScriptBridge(), "callNative");
复制代码
js 调用方式
callNative.bmImage.camera({
"imageNum":2,
"allowCrop":true,
"callbackId":"bmImage.camera1527235458519"
})
复制代码
这种方式就更接近写代码的方式了,不管是调用方式、传递参数的方式还是同步异步回调都更好了。但是这种方式也有需要注意的地方,首先需要注意客户端注入的时机,在 loadUrl 之前注入是无效的,但是在 FinishLoad 之后注入可能 webView 已经调用了方法,此时会出现调用不到该方法,所以还需要其他的机制来保证这个问题。比如微信公众号的 wx.config 接口。
native 调用 js
前面已经讲了 js 调用 客户端,js 在向客户端发出调用指令之后,如果是同步方法,比如 获取当前客户端版本能立即得到结果,如果是异步调用,之前每次调用客户端的时候都传递了一个 callbackId,这个就是为客户端回调 js 做准备。
首先 js 会提供一个方法等待客户端调用
window.nativeCallbackMap = {};
const callJs = (data) => {
var data = JSON.parse(data);
var callback = window.nativeCallbackMap[data['callbackId']
if(callback){
callback.call(null, data['resData'])
}
delete window.nativeCallbackMap[data['callbackId']
}
复制代码
iOS 调用 js
NSString *resDataString = [self _serializeMessageData:data];
NSString* javascriptCommand = [NSString stringWithFormat:@"callJs('%@');", resDataString];
if ([[NSThread currentThread] isMainThread]) {
[self.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
} else {
__strong typeof(self)strongSelf = self;
dispatch_sync(dispatch_get_main_queue(), ^{
[strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
});
}
复制代码
android 调用 js
final int version = Build.VERSION.SDK_INT;
// 因为该方法在 Android 4.4 版本才可使用,所以使用时需进行版本判断
if (version < 18) {
mWebView.loadUrl("javascript:callJs()");
} else {
mWebView.evaluateJavascript("javascript:callJs()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
// js 的同步返回
}
});
}
复制代码
上述 js bridge 的实现大致就是现在大部分的客户端与 js 的通信方式,现在 webView 也丰富了自己的功能,有能力调用客户端提供的方法,完成所有客户端能做的功能,js bridge 设计完之后剩下的就是需求推动,根据所需的功能提供不同的方法供前端调用。
如果需要与原生页面间互相通信,应该约定一种 scheme,也可以理解成一种 URI 的实现,通过 js bridge 的接口调用客户端(此时还可以传一个回调到客户端,当客户端完成某个操作之后返回该页面执行回调,比如日期选择),客户端解析协议,打开对应的原生页面,完成对应的操作。scheme 还是手机浏览器会识别的一种协议,如果有对应的 APP 可以直接呼起 APP,但是当你打开客户端页面的参数太多时,你的 scheme 会变得超长,浏览器会将 URL 截断,这个时候还需要简单的短链服务转化
优点:
- 首先具有所有 webView 所有的优点
- 解决了单纯 webView 的交互功能受限,能调用原生接口完成需求
缺点
- 也无法避免 webView 所带来的性能问题,打开白屏时间长,用户体验较差,等问题,也存在受限与浏览器,及重复打开需要重复加载等问题
- 所有新需要客户端提供的功能都需要客户端发版
- 随着业务发展客户端定义的接口必须向下兼容,这会带来很多冗余代码
代表产品:
- 公众号使用到微信 sdk 的功能
- AppCan、PhoneGap、cordova 等 hybrid 框架
小程序
在 hybrid 混合应用稳定之后,大家发现功能上已经能满足开发需求了,所以下个阶段应该追求体验和性能上的提升,就是 RN 的方案了,ReactNative 的方案已经逐渐被大家所熟知并逐步落到项目中,混合应用方案也是本文介绍的重点,后面会详述。
这里想先讲一讲小程序,因为在技术的演进上小程序才应该是下一小步的提升,并且小程序严格意义上来说并非 APP 的一种方案,更像是微信分发生态中的体验进化,在使用公众号这类 hybrid 的方案时,发现体验和交互上的一些问题,比如加载白屏时间,页面之间跳转的交互,页面内容的缓存等等。
由于小程序现在实现方案面向开发者也是一个黑盒,我也是一边了解一边猜小程序如何实现的,小程序的框架包含两部分:View 视图层和 APP service。前者用来渲染页面结构,后者用来做逻辑处理,接口调用。他们在两个进程里运行,有点类似在当前的页面里使用 web worker,关系如下图:
APP View
我们写的视图层和逻辑代码是分离的,WXML 和 WXSS 构图了视图层的代码,WXML 通过 wcc 工具转换成 VDOM,WXSS 通过 wxsc 转化 style 标签。底层通过 WAWebview.js 来提供底层的封装,每一个视图就会有一个 webView 来渲染,这也就提高了页面渲染性能的问题,多个视图页面时就会有多个 webView 进程,所以小程序对页面层级是有限制的,内存受限。视图层主要包括以下内容:
- WeixinJSBridge 封装和上述的 hybrid 的 jsBridge 一样
- 小程序提供的组件注册,一些能操作 DOM 的 API
- 渲染的实现:VDOM、diff、render UI(在高人知道下了解到渲染出来的本质还是 webview,在原生端只支持 flex 布局,但是小程序里还支持 web 的布局,由此可以看出,这里一定是 webView 的渲染,并非原生)
- 页面生命周期管理
APP Service
逻辑处理的代码全部加载到一个进程 APP service 中,和视图 webView 不同,所有的代码逻辑都会一次性全部加载到这个进程中,因为主要的瓶颈还是来自于渲染性能,并且所有的逻辑代码都加载到进程中保证视图切换的流程性,并且加上小程序有 2MB 大小的限制,在现在的网络环境下一次加载体验会更好。APP Service 包含以下内容:
- WeixinJSBridge 封装和上述的 hybrid 的 jsBridge 一样
- 所有小程序提供的 API 方法注入,全局方法注入
- AMD 模块化实现
小程序的开发环境
小程序运行在开发环境中和线上环境是不同的,线上环境 iOS 和 android 都有真正的 webView 环境提供,在开发过程中小程序提供了一个 IDE,IDE 是基于 nwjs 实现的,钉钉客户端也是基于这个实现的。其实就是在 PC 端的客户端环境下尽可能提供原生能力,如果是移动端才会有的差异化能力就会 mock 掉。两边环境不同还体现在 APP Service 中,APP Service 主要是调用客户端底层,所以底层的不同也影响着这层的封装。
APP View 与 APP service 通信
当我们理解了前面的 hybrid 的通信原理,这里的通信就比较好理解了。小程序的 bridge 实现原理就和 hybrid 一样了。iOS 和 android 就不多说了。基于 nwjs 的客户端是通过 window.postMessage 实现的,使用 chrome 扩展的接口注入一个 contentScript.js,封装了 postMessage 方法。
// 发送消息通过
window.postMessage(data, ‘*’);
// 接受消息通过
window.addEventListener(‘message’, messageHandler);
复制代码
小程序的推动主要来自微信平台的大流量,这就是所谓的微信流量红利,公众号已经承载了传播和拉新的低成本方式,但是体验一直被诟病,限制于 hybrid 的体验问题让追求体验的开发者逐渐无法忍受,此时小程序原生的组件,良好多页面切换,几乎没有白屏时间的等待,让大家又有了探索无限的可能。
优点:
- 首先具有所有 hybrid 所有的优点
- 无需安装,极速打开
- 原生的组件有了体验上质的提升
- 性能上也有了新的飞跃,白屏时间,页面切换也有了更好的体验(为了减少白屏时间,小程序采用预加载的方案,而分不清哪个会是逻辑里的下一个页面,所以小程序将所有 webView 全局预加载,这也是页面层级和包大小限制的主要原因)
缺点
- 只能用于微信平台,如果有多端的用户需求,开发成本增加了
- 组件还是较少,很多操作 DOM 的方式也会有所限制,完成需求受限
- 为了性能的体验,包的大小受限,打开页面的层级受限
代表产品:
- 各种客户端的小程序
快应用
快应用是对标微信小程序的一场阻截,几大手机厂商联合发布这个方案,一瞬间吸引了大家的眼球,越来越多端让前端追都追不过来(这么多技术确实让人很绝望,甚至前段时间大家去恶意灌水了很多大项目的 issue,但这也是前端蓬勃发展的表现,当这些前沿的技术落地在项目中甚至你只是完成一个 demo 的时候,你还是会有抑制不住的兴奋和成就感)
快应用对自我的介绍是基于手机硬件平台的新型应用形态,用的是新型应用形态,并非新技术,类似这样的实现有 PWA,只是 PWA 缺少了手机硬件平台的支持。
快应用标准是由手机厂商组成的快应用联盟联合制定,快应用标准的诞生将在研发接口、能力接入、开发者服务等层面建设标准平台。底层的硬件平台提供底层 API 让开发者调用,原生渲染组件,hybrid bridge 通信,原理大致也与小程序实现类似,这里就不详细说明,下面是几张快应用发布会的 PPT 截图,如果理解了上面的原理,这个应该就是比较好理解了。
之所以说是小程序的一场阻截,小程序曾放话说,未来两年内,小程序将取代 80% 的 APP 市场,而手机厂商并不希望大量的 APP 被取代,所以催生了快应用的落地。但是建立在几大国内手机厂商硬件平台上的方案,就目前来看支持性,通用性,社交性上都是比不上小程序的。两者的前景不敢妄加揣测,但是小程序凭借微信平台的用户量和用户粘性,比几大手机厂商实现的标准化还是一目了然的。
优缺点就不详述了,一目了然
Flutter
ReactNative 的开疆扩土进一步奠定了,React 的霸主地位,React 也由一个框架演进成了,以 React 为基础的前端多端解决方案,技术栈的生态方案。google 怎么可能放弃自己在科技领域的地位,即使是前端相关的技术,所以出了 Flutter 技术方案完成 APP 的一环。
借由本身的 android 和 chrome 的 平台优势,准备重磅推出 Fuchsia OS,打造自己不基于 Linux 的底层系统,而 Fuchsia OS 钦定 UI Toolkit 就是 Flutter。Flutter 最大的改进就是自己重做了渲染引擎去渲染页面,将混合应用的渲染性能推向了另一个高度。
Flutter 之于 RN 来讲最大的区别在于渲染,脱离了 JSFramework 的 Component 递归传递和逐个计算绘制,更像是 canvas 绘制,将 UI 直接绘制出来,Flutter 都是在状态变更时重新构建 Widget Tree,Flutter 的渲染引擎在将 Widget Tree 转化成渲染的 Render Tree,最后交给操作系统调用 GPU 去渲染,Flutter 由 Dart 程序写的,Dart 和 native 之间仍然存在一个接口,可以进行数据编码和解码,这可能比 JavaScript bridge 快好几个数量级。
使用 Dart 是整个框架不太被前端所接受的问题,但是代码都是相通的,上手还是很快,尝试 demo 期间还是很快就能模仿出一个页面,语言上的争论就不说了,毕竟都不如 PHP。
由于自己使用 Flutter 还在 demo 阶段,并没有真正围绕 Flutter 做过相关的解决方案,具体的细节实现也没有清楚的认知,也不适合展开详述,很早之前以矢量图起家的 Adobe 也尝试过类似的方案,但是最终没有把这条路走下去。
如果有兴趣可以移步:
weex 和 ReactNative
在讲这两种混合应用之前,再啰嗦几句,上面大致了解了各种混合应用的方案,这些方案了解其原理和使用场景,在合适的场景去使用对应的方案,不要为了落地某项方案而把业务生搬硬套,没有一种方案是绝对的好,在不同的业务需求的驱动下权衡利弊再做决策。
这两者一直有人在试图去比较分析得出利弊,看哪种更好,甚至有人偏激的认为 Weex 就不配合 RN 相提并论,但是我还是想说理性的看待这个问题,客观的面对前端技术,曾经 React 和 Vue 也是这样的差距,直到现在依旧有人认为使用 Vue 低俗,使用 React 高雅,但雅俗之分真的如此吗?还是应该理解其原理,辨别自己的业务场景,团队学习成本,开发体验等等一系列的原因,然后再做评判。
就目前的各方面情况讲,RN 确实更优,横空出世,背靠当时火的不行 React,一路都是风光无限。这种解决方案出来之后,如果没有竞争者的挑战,没有对比,没有选择,也不一定是好事儿,虽然现在两者还有很大的差距。
我猜 Weex 始于 KPI,毕竟大厂造轮子来升级是非常好的途径之一,但是往后发展,却成了两大阵营完善生态的方案,现在基于 React 的一整套的前端解决方案覆盖PC、Web、Native 可谓是成了大公司前端方案的标准套路,后起之秀 Vue 用良好的开发体验和低门槛的入门方式开始抢占用户,各种前端培训学校让一些前端不懂 js 基础,但是 Vue 溜的飞起。但Vue 并没有形成对 Native 的覆盖,这个可能也是 Weex 和 Vue 一拍即合的原因,双方都有需求,就开始了合作。
扯会正题,在大前端逐渐融合的背景下,作为以 Vue 建立技术栈的团队开始寻找客户端的方案,做技术选型开始考虑业务达成、团队学习成本、开发效率,性能效率等问题,我们一开始也是将 Weex 和 RN 作为比较,在上一家公司使用的是 RN,但是现团队建立技术栈基于 Vue,考虑到第二个因素偏向 Weex,实际调研过程中发现 Weex 的坑确实很多,但是好在有很多问题已经有解决方案了,当然不管想要使用上述两种方案,都需要有一定的原生能力。实际开发过程中发现随着对 Weex 的了解越来越多,开发效率也越来越快,入坑之后才发现了第三个和第四个甚至第五第六个优势。
至于 Weex 最大的卖点,兼容三端,Web 端这个优势每个人都有自己的看法,我们尝试了去兼容 Web 但是发现反而牺牲了开发效率,有兼容的时间,单独开发一套也出来了,并且更重要的是由于用户习惯的不一致,Web 端和 Native 针对的用户群体也不一样,在这个流量就是金子的环境下牺牲用户体验和开发效率去兼容 Web 并非是我们的初衷。
当然也遇到了一些问题,一开始 Weex 没有托管 Apache 的时候还有 issue,很多问题确实不能及时得到解决,所以决定脱离 Weex 的所有 module,自行开发所有 module,重新设计,这也让我们对项目有了一些控制。
决定了自行开发 module 和组件之后,减少了对 Weex 本身的依赖,项目本身受到的限制也越来越小,业务很快也完成了。当然现在类似 demo 跑不起来,毁灭式的升级,市场组件无法接入,bug 横飞,组件不足以完成业务功能,兼容性不足,热更新,公共文件导致包过大,社区经营不好等等这些问题,也都是一个技术产品成长的必经之路。
两种方案在选型的时候,一定有可取的地方才会选择,工程师的精神就是趟坑,找准正确的方向然后一往无前,两种方案都有已经上线的优秀产品,说明是这条路至少是能走通的,当然我们也在过程中总结了我们的解决方案,希望能帮助在 Weex 路上举步维艰的同行人,不管选择哪种方案,选择了就把他做好。Eros项目地址请戳(不要吝啬你的 star)。Eros文档地址请戳。我们公司已有三个项目通过 Eros 上线,也有几十个应用通过 Eros 发布上线。
当然还有很多优秀的方案没有一一枚举,比如:kotlin、WebAssembly,每个方案都沉淀了很多前端工程师的积累,提供了各种能落地可实行的方案 phonegap -> cordova、mui、appcan、apicloud 等等