加推Weex实践之路(上)
1、背景
1.1 为什么是Weex
在公司快速发展的大环境下,App的更新迭代高速、高频,技术团队平均两周便可诞生一款中型App,但App团队只有6个人(iOS 、Android各3人),在确保效率、质量的前提下,单纯依靠Native的能力显得步履蹒跚——我们亟需提升团队效率,希望单人可完成原本2~3人的工作量。
其一,接入Web页面,一个页面适配两端;
其二,选择Weex、React Native、Flutter、Chameleon等跨平台开发框架,主流框架对比如下:
上手难度 |
一般 |
一般 |
容易 |
接入特点 |
适合开发整体App |
适合开发整体App |
适合单页面 |
维护难度 |
一般 |
一般 |
容易 |
开发语言 |
React |
Dart |
Vue、Rax |
框架体量 |
较重 |
重 |
较轻 |
Bundle大小 |
较大 |
不需要 |
较小 |
社区 |
丰富 |
新起之秀 |
不够完善 |
支持终端 |
Android、iOS |
Android、iOS、Web等 |
Android、iOS、Web |
引擎 |
JSCore、V8 |
Flutter Engine |
JSCore、V8 |
通过对比,最终选择了Weex,有以下几个主要原因:
Weex的上手成本较低,且单页面的支持更符合项目规划
Vue框架,契合团队的大前端环境;
Weex承接了淘宝、飞猪等App的大量页面,给予外界充足的信心。
1.2 Weex与Web
虽然Web页面的上手、维护成本更低,但与Weex相比,同等页面的Web包体积要比Weex大,Web页面无法做到纯Native的体验,且页面加载速度很难极致化,在某些设备上容易出现白屏。Weex所具备的下列优势,足以让一个追求极致的团队所青睐。
Weex的优势:
完全的Native体验
更小的包体积
页面加载速度更快
长列表性能更优
Native扩展性更加
2、Weex基本原理
Weex支持 Vue 和 Rax两个前端框架,由于前端团队使用Vue进行日常开发,为了降低上手成本,我们选择Vue框架进行Weex开发。Weex的基本工作流程如下(来自官网):
Weex we 文件 --------------前端(we源码)
↓ (转换) ------------------前端(构建过程)
JS Bundle -----------------前端(JS Bundle代码)
↓ (部署) ------------------服务器或本地
在服务器或本地上的JS bundle ----服务器或本地
↓ (编译) ------------------ 客户端(JS引擎)
虚拟 DOM 树 --------------- 客户端(Weex JS Framework)
↓ (渲染) ------------------ 客户端(渲染引擎)
Native视图 --------------- 客户端(渲染引擎)
除去前端页面的编写,Weex可划分为DOM、Render、JSBridge三大块(线程)。DOM负责JSBundle的解析、绑定、映射等处理,并通知Render线程进行UI的更新。而Render线程,即UI线程,负责Component的渲染,例如前端编写的<text>的标签,将被渲染成原生封装的UI组件TextComponent。JSBridge负责JS端与Native端的双向通信,通信的具体逻辑处理则由原生实现的一个个Module来完成。下图是一个页面被渲染的完整流程:
3、客户端系统架构
依托于Native,借助于前端,演变成大前端的架构,整体结构如下图。我们希望App作为终端,提供容器的能力,做好底层服务,完美融合Weex、Web等跨平台技术。
4、实践与解决方案
以下记录了实践中遇到的一些较为重要、常见的问题,及其解决方案,它们贯穿了Weex页面的生命周期。
4.1 页面间通信
4.1.1 路由
Weex自身提供的navigator只支持简单的在线资源,而不支持本地文件的加载,同时无法满足动态化的呈现方式和复杂参数传递,需要自行实现一套完整的页面跳转规则。
在大量使用了Web、Weex技术的前提下,为了保证页面跳转、页面间参数传递、回调等的统一和便捷性,以及模块间的解耦,跳转目标可配置化,采用了路由的形式来作为页面间跳转的技术方案。基本的路由形式以及完整的过程如下:
// 打开Web页面
scheme://web/open?bundleUrl=xxx
// 打开Weex页面
scheme://weex/open?bundleUrl=xxx
// 打开native页面
scheme://native/open?url=xxx
// 备注:这种形式的路由除了完成App内的跳转业务外,也能够完美的支持应用间的跳转
Weex中发起一次页面跳转的示例:
navigator.openUrl({
url: 'scheme://weex/open',
params: {
bundleUrl: '/dist/about.js',
name: '这是下一个页面所需的参数',
},
})
// 备注: 而下一个页面只需要在data中声明一个与参数同名的属性来接收具体的参数即可。
4.1.2 反向传值
我们在考虑反向传值的问题时,主要涉及两种场景,一是Weex页面反向传值给Weex页面,二是Native反向传值给Weex页面。我们可以使用Weex提供的基于W3C 规范的BroadcastChannel来轻松满足第一种场景。但是目前并没有现成的API能满足第二种场景,我们需要不断的尝试:
在页面跳转时将WXModuleKeepAliveCallback传入下一个页面,然后在合适的时候执行该回调。这对于iOS客户端很容易实现,但由于Android在页面间传值时需要将参数序列化到内存中,然后在对应的页面从内存中反序列化出来,这会导致生成一个新的对象,从而无法完成回调。
我们有尝试借鉴BroadcastChannel的实现,通过Weex项目全局的JSContext对象来触发广播完成反向传值,但最后无疾而终。
我们最后选择使用fireGlobalEvent,步骤如下:
1.Weex添加事件监听
const globalEvent = weex.requireModule('globalEvent');
globalEvent.addEventListener("eventName", (e) => {
// ...
});
2.原生页面发送事件
weexInstance.fireGlobalEventCallback("eventName", params); // Android
[// iOS ];
代码中的weexInstance,可以理解为一个页面的实例对象,该流程需要在发送监听的页面需要获取上一个Weex页面对应的weexInstance,可以以一种相对优雅的方式告知下一级----在跳转的路由中添加一个needListen来告知下一个页面:
navigator.openUrl({
url: 'scheme://weex/open',
params: {
bundleUrl: '/dist/about.js',
needListen: true,
},
})
4.2 页面配置文件
起初Weex页面导航栏、跳转方式等配置,均在自建Module中进行处理,例如一个页面要设置导航栏,我们需要在mounted方法中加入如下代码:
created () {
navigator.setTitle('导航栏标题')
navigator.setItems([{
title: '按钮',
}])
}
这种不够工程化、标准化的方式给后期的维护、跨项目移植、架构升级造成了极大的干扰。
我们参考小程序的设计思路进行了优化升级,为每一个需要特有化配置的Weex页面添加一个json格式的配置文件,配置文件包括导航栏的配置、页面级别的配置、跳转的配置等,将配置工程化、标准化。
例如我为about页面添加了配置文件,json文件的类容为:
{
"navigationBarTitle": "导航栏标题",
"navigationItems": [{
"title": "按钮"
}]
}
打包后的资源文件目录如下,about.js、about.json文件在同级目录:
那么一个完整的页面打开步骤如下:
经过扩展,配置文件变得更加丰富,先前麻烦的跳转处理、弹框等都可以通过配置文件实现,下面是一些常用的属性介绍:
4.2.1 部分属性
属性 |
类型 |
默认值 |
描述 |
backgroundColor |
HexColor |
同项目配置 |
窗口背景色 |
navigationBarBackgroundColor |
HexColor |
同项目配置 |
导航栏背景色 |
navigationBarHidden |
Bool |
false |
隐藏导航栏 |
navigationBarTitle |
String |
导航栏标题 |
|
navigationBarTitleColor |
HexColor |
同项目配置 |
导航栏标题颜色 |
4.2.2 iOS特殊形式
针对于iOS客户端存在页面需要Present等情况,可以设置以下属性:
present |
Bool |
false |
Present页面 |
presentWithNavigationBar |
Bool |
false |
Present页面,并携带导航栏 |
transition |
Map |
false |
以转场的形式呈现,并规定转场的动画表达式(默认背景alpha从0到1) |
优先级:transition > presentWithNavigationBar > present |
transition中需定义动画的表达式,Native则需要解析该表达式,并按照表达式执行动画。
4.2.3 设置导航栏按钮
navigationItems |
Array |
[] |
包含按钮样式的数组 |
通过fireEvent完成按钮事件的回调。 |
按钮样式说明:
{ "type": "TEXT", // "TEXT", "IMG", "TEXT_IMG",必传
"title": "标题", // 文字标题或者
"image": "刷新", // 是图片地址,图片地址支持本地图片和网络图片
"textColor": "FFFFFF", // 16进制, 默认为白色,可不传
"backgroundColor": "", // 16进制, 默认透明色,可不传
"borderColor": "", // 16进制, 默认无边框,可不传
"borderWidth": 1, // 默认无边框,可不传
"cornerRadius": 1, // 默认无圆角,可不传
"font": 16, // 默认16号字体,可不传
"position": 0, // 默认0,可不传, 0-左侧显示,1-右侧显示
"imagePosition": 0, // 0-图片在左,文字在右,默认, 1- 图片在右,文字在左, 2-图片在上,文字在下, 3-图片在下,文字在上
}
4.2.4 自定义导航栏
例如满足导航栏分段选择的需求:
navigationBarTitleComponent |
String |
无 |
对应的自定义Component的名称 |
Component由原生自行实现,并暴露API与Weex进行交互 |
4.3 阴影处理
Weex对于iOS的支持比较友好,然而Android 端无法显示阴影。 虽然文档有明确指出此问题,但是Android sdk却提供相关方法。也许是阿里的工程师尝试解决,但效果并不理想。比较明显的一点是,如果列表中的item使用阴影, 在列表滑动的时候会把阴影残留在最初绘制的位置。Android的同学一直在尝试解决此问题,最终也没达到一个理想的效果。最后的降级方案是通过图片来替代阴影,以下是Weex官方的注释:
目前仅 iOS 支持 box-shadow 属性,Android 暂不支持,可以使用图片代替。
每个元素只支持设置一个阴影效果,不支持多个阴影同时作用于一个元素。
4.4 网络请求
Weex提供了Stream模块来完成网络请求,如果依赖于该模块,请求头、签名等配置以及请求结果校验都必须在Weex端完成,这对于一个全量Weex App而言无可厚非。但很多App的核心业务是使用Native完成,甚至会嵌套很多Web页面,我们必须将所有的请求统一至Native,让Weex更关心的是UI的呈现而非底层业务。
因此我们提供了自己的网络请求模块,Weex端调用Native提供的方法,并通过参数来决定请求,一些可选的参数:
参数 |
类型 |
必填 |
描述 |
path |
String |
是 |
请求的路径 |
method |
String |
否 |
请求的方式,默认为’GET‘,支持’POST‘、’DELETE‘等 |
params |
Map |
否 |
请求所需的参数 |
timeout |
Number |
否 |
请求超时时间 |
customHost |
String |
否 |
自定义请求的Host |
callback |
Function |
是 |
请求的回调 |
请求示例:
// 1. 获取原生Module
const nativeStream = weex.requireModule('nativeStream')
// 2. 设置基本参数
const options = {
path:'/....',
method: 'POST',
params: {
id: '123'
}}
// 3. 发起请求
nativeStream.fetch(options, (res) => {
if (res.code === 0) {
succesCallback(res)
return
}
failCallback(res)
})
4.5 图片加载
<image>标签图片的加载需要客户端提供handler,目前支持远程链接和打包生成的Bundle资源,并不直接支持相册图片以及拍照生成的图片。其对Base64的支持,为我们显示相册图片提供了一种思路。下图表明了Weex页面中选择相册图片、拍照并进行显示的流程:
通过上图我们可以知道,一个简单的图片显示流程,其实并不简单,其中最为关键的是在进行第5步时所选择方案。先上传图片对于程序员而言是最为便捷的方案,但是比较影响用户体验,图片本应该在需要上传的时候进行上传,而非因为技术隘口而干扰业务。
转换为Base64可以提升用户体验,但是却比较影响性能。在iOS端,将一张1M的图片转换为Base64所需要的时间≥45ms,第6、7步所消耗的时间则是30ms左右,这种时间消耗随图片大小以倍数增加。
综上所述,我们设计了一种本地化方案,为每一个添加的图片生成一个唯一性的ID,Native负责图片的存储、加载。
4.6 刷新组件
由于Weex所提供的<refresh>组件形式较单一,且存在交互Bug,我们自实现了一套刷新组件。通过属性来确定刷新的显示与否,并提供相应的接口实现Weex与Native之间的交互。
4.6.1 属性
属性 |
类型 |
必填 |
描述 |
showRefresh |
Bool |
否 |
是否添加下拉刷新 |
showLoading |
Bool |
否 |
是否添加上拉刷新 |
refreshAtCreated |
Bool |
否 |
是否在初次显示的时候,自动进行下拉刷新 |
4.6.2 事件
refresh 事件:当 <scroller>、<list>、<waterfall> 被下拉完成时触发,该事件由原生回调到Weex。
loading 事件:当 <scroller>、<list>、<waterfall> 被上拉时触发,该事件由原生回调到Weex。
<list ref="list"
:show-loading="loading"
:show-refresh="true"
class="list"
@refresh="refreshList"
@loading="loadMoreList">
</list>
beginRefresh 事件:开始下拉刷新,由Weex调用。
beginLoading 事件:开始上拉刷新,由Weex调用。
endRefresh 事件:结束下拉刷新,由Weex调用。
endLoading 事件:结束上拉刷新,由Weex调用。
this.$refs.list.beginRefresh()
this.$refs.list.endRefresh()
4.6.3 refreshAtCreated属性
如果不使用该属性,则需要在created或者mounted函数中手动调用刷新的方法以触发下拉刷新,然而在某些Android设备上面出现了白屏的情况。Weex对此做出了解释:
和浏览器不同的是,Weex 的渲染流程是异步的,而且渲染出来的结果都是原生系统中的 View,这些数据都无法被 javascript 直接获取到。因此在 Weex 上,Vue 的 mounted 生命周期在当前组件的 virtual-dom (Vue 里的 VNode) 构建完成后就会触发,此时相应的原生视图未必已经渲染完成。
4.7 截屏
Weex中完成截屏,只需要获取到对应的视图层即可,Weex页面在渲染时会为每一个组件生成一个唯一的ID,在JavaScript中更直观的体现是ref,尽管Weex并不存在真正的DOM,但其依然支持ref的使用。具体的做法如下:
// 1. 标签生命ref
<div ref="poster"></div>
// 2. 获取到该element,实际上是一个Map
const poster = this.$refs.poster
// 3. 获取Map中对应的ref
saveViewShot({ ref: poster.ref })
// 4. 获取Component
// iOS
WXComponent *component = [weexInstance componentForRef:ref];
// Android
WXComponent component = WXSDKManager.getInstance().getWXRenderManager().getWXComponent(mWXSDKInstance.getInstanceId(), ref);
// 5. 获取View,进行截图
4.8 优雅的弹窗
这是一个很简单的弹框需求,视图渐渐变大最后全屏展示。然而基于Weex现有的能力是无法实现的,第一点:Weex页面默认的布局是从导航栏下面开始的,第二点:路由的跳转方式并不能直接支持此种弹出方式,页面默认是从右向左推出。
为了实现弹框的功能,我们需要四个步骤:
Native定义PopView组件
Weex搭建页面,并以PopView为基础进行布局
全屏呈现页面并隐藏导航栏
执行动画
原生定义好PopView组件后,Weex页面可以这样布局:
<template>
<pop-view class="pop-view">
<text>测试弹框</text>
</pop-view>
</template>
结合第三点所提出的配置文件,我们将2、3步骤的控制放在配置文件当中,最后写出的配置文件为:
{
navigationBarHidden: true, // 隐藏导航栏
transition: {
property:'scale', // popView的尺寸将由(0,0)变为显示大小
duration: 2, // 动画时间,单位(秒)
},// 转场显示,并规定popView的显示动画
}
这只是最为简单的例子,更复杂的动画需要客户端支持即可。
5、To Be Continued
以上概括了Weex接入的心路历程,以及在实践中遇到的基本问题,表明了Weex在团队中的运用已经畅通并日趋规范化,但是更深入的性能优化、热更新等需要我们继续前行,以下是下一期文章将涉及的知识点:
热更新
资源预加载
配置文件动态化
Weex资源打包自动化,自动加入终端仓库
作者介绍:
本文由赵一定及其团队的小伙伴一起撰写,非常感谢APP团队的倾力之作,使得我们有幸窥探加推的APP是如何实现的。
想要了解更多细节的朋友,可以进入一定的加推名片进行深入探讨: