0%

HybridStart v1.0开发纪要

自混合应用前端开发框架HybridStart v1.0升级计划开始后,经过近一周的开发测试,现已发布预览版,基本实现了最初定下的四个目标:核心易用、UI 可剥离、开发模式清晰、开发体验优秀,这也是我理想中的以 web 前端技术为主的,混合应用开发的正确姿势。在这个过程中将一些笼统的思路细化并落地,也将一些过去思路不对的地方推倒了重构,在通用性方面也做了更多的考量,下面就从核心和 UI 两大部分入手,详细拆解一下升级后的 HybridStart。

核心

移除依赖

之前的core.js直接集成部分第三方插件,并且内部实现也互相依赖,这对于不同技术栈的开发者来说很不友好,比如有的开发者喜欢用 Vue 做模板渲染,那他看到依赖 jQuery 后心里一定恶心无比,因此要做的第一件事就是移除核心库的依赖。

移除掉 jQuery 势必就要自己动手做一个util工具类,以简化原生 JavaScript 语法,这里我偷了个懒直接把 mui 的部分代码拿过来,稍作修剪和扩充就 ok 了。在功能取舍方面,除了满足核心库的需求外还增加了少数几个常用操作,使这部分功能对外开放后能一定程度上发挥 jQuery 的作用,通过app.util可以获取到这个内部工具集合,经过内置示例的开发体验,应该说只要 DOM 操作不是很重的情况,基本可以让 jQuery 歇息了,当然前提是大量的 jQuery 语法糖都不能用了,其实用习惯了原生语法,会觉得除了单词长一点也并没有多麻烦。

功能梳理

框架功能都挂载在app对象上,主要提供这五类功能:核心功能、窗口操作、数据操作、设备访问、原生控件。

核心功能

核心功能以 APP 运行周期内的事件或操作为主,比如各种事件监听、按键监听,全局事件的发布/订阅,原生能力就绪的回调方法等,这些方法都直接挂载在app对象上,例如app.ready(callback)

着重说一下原生能力就绪回调,HybridStart 里一个典型的页面 js 文件是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* script
*/
define(function (require) {
require("sdk/common");
var $ = app.util;
//立即执行

app.ready(function () {
//runtime就绪
});
});

可以看到正文明显被app.ready()方法分隔成上下两部分,上面空白处的代码将在页面加载后立即执行,ready 回调内的代码将等待原生能力就绪后被执行,我们鼓励将所有不需要原生能力的操作放在上面,以提升脚本响应速度,这个没什么问题。

但可能遇到的一个问题是,如果一个依赖原生能力的功能被开发者立即执行了,将会因为 runtime 未就绪而报错,也就是说需要开发者明确的知道那些功能依赖 runtime 哪些不依赖,如果试图解决这个问题很容易想到的一个办法是,将所有依赖 runtime 的功能在内部用 ready 方法包裹一下,这样表面上可以解决问题,但因为 ready 的异步特性,可能导致代码执行顺序与书写顺序不一致,这无疑是不可接受的。最终在两者间做了妥协,将这些功能在内部用另一个 readyEval 方法包裹,readyEval 方法仅仅在检测到 runtime 未就绪时给控制台抛出调试信息,而不会中断后续代码的执行,算是一个容错性的处理吧。

窗口操作

窗口操作包括对 window 和 frame 的常用操作,比如打开、关闭、移动、执行脚本等,这些方法都挂载在app.window对象上,例如app.window.open()

作为最基础也最常用的操作,封装目标就是易用,比如打开窗口这个操作,即便有了app.window.open()也仍然觉得不够简单,因此进一步封装了app.openView(),可以说让绝大多数场景下的打开窗口变得极致简单了,看下两个方法的对比:

1
2
3
4
5
6
7
8
app.window.open({
url: "./view/member/index/temp.html",
pageParam: {
id: 123,
},
});

app.openView(123, "member", "index");

openView 方法的详细介绍可以参见这里,openView 的参数传递是借助本地存储实现的,这次升级也为其配套了一个获取参数的方法app.getParam(),专门用来获取 openView 方法传递的参数,并且支持对象类型的存取。

内建机制

简化开发另一个很重要的方向是内建机制,举个例子,实现会员退出登录,需要跳转到登录页同时关闭所有后台页面,关闭后台页面的功能 apicloud 提供了,但本着尽量不使用特定平台提供的特殊能力原则,这个功能在框架中用另一种方式实现了,而且使用起来超简单,比如可以这样:

1
2
3
4
5
6
7
app.openView(
{
closeback: true,
},
"member",
"login"
);

内部实现是,每一个页面打开后会在 window 上挂载一个”isBack”属性,通过监听本窗口的前后台状态更改这个属性的值,当 openView 方法的 closeback 被设置为true时,将在打开新页面前在本地存储埋下一个标记,新页面打开后通过这个标记得知自己的任务,然后发布一个相应的全局事件,所有页面都能通过这个事件得知自己的任务,比如任务是关闭后台页面,那么就会检查自己的window.isBack属性,发现是真值就关闭自身,从而完成这个任务。

其实就是利用全局通信能力建立起来的关闭机制,依据这个思路,还可以扩展出打开新窗口同时关闭自身的方法,比如订单提交场景,提交成功后通常会跳转到一个提示页,但是我们不希望从这个提示页可以返回到刚才的提交订单页,所以希望打开提示页的同时关闭订单页,那么实现的代码将是:

1
2
3
4
5
6
7
app.openView(
{
closeself: true,
},
"shop",
"orderSubmitSuccess"
);

closeselfcloseback的区别仅仅是给当前页面增加了一个”closeByNew”的属性,然后本地存储埋了另外一个标记,发布了另外一个任务,新页面打开后照例发布全局事件公布任务,订单页收到任务后发现自己具有”closeByNew”属性,于是关闭了自身。

这些功能都集成在 openView 方法的配置中,说起来很罗嗦,用起来确特别简单,这类问题只在安卓系统上有,因为 IOS 没有返回键,只要界面不提供返回按钮用户是不可能随意返回上一个页面的。

框架另外还做了一件事,就是为 frame 页面的 window 对象扩展了一个”selfTop”属性,属性值是当前 frame 距离屏幕顶部的距离,这个值当在 frame 需要打开带界面的原生插件时很有用,比如打开百度地图,需要你指定地图距离屏幕顶部的距离,如果 frame 不知道自己距离屏幕顶部有多远,就不可能知道这个值应该是多少,这也算一个隐性需求,不用不知道,用了都说好。

数据操作

数据操作分为数据请求和数据存储两块内容。

数据请求也就是app.ajax()方法,主要用来异步获取数据,当然也包括上传和下载,但他们都被单独封装成了插件,这里不做讨论。

app.ajax()在易用性上的改进体现为增加了默认错误处理,约定交互格式以及格式检查,api 风格几乎照搬jQuery.ajax,没有太多值得说的。在此基础上app.ajax()还针对 APP 开发场景做了两项功能扩展,一是请求加密,二是快照式缓存。

请求加密通过默认集成的加密模块app.crypto实现,加密算法为 3DES,加密后的所有 Ajax 请求将集中发送到一个 url 地址,本次请求的真实 url 和参数以特定方式组织并加密,并将密文以参数形式发送,服务端需要有对应的解密方法得知请求 url 和参数,并将返回数据也做 3DES 加密返回给前端,前端解密后得到真实数据,整个过程中的关键是 3DES 算法的secret,这个值使用 apicloud 提供的加密存储方式存储,APP 被反编译也无法拿到这个密匙,因此理论上实现不可逆加密。至于加密请求为什么要集中发送到一个 url,其实是因为之前有的项目后端是这样处理的,如果要修改这个加密逻辑其实也很简单,详细的加密过程参考文档,这里不做赘述(如果发现文档没写完,也请不要奇怪- -!)。

ajax 缓存功能 apicloud 的原生接口也有提供,不过他的缓存是没有更新机制的,一次缓存终生使用,除非做全局的缓存清理,简单说,这个功能很鸡肋。app.ajax()专门增加了一种快照式缓存,每一次请求成功后都会将结果保存为快照,下次这个请求再发起时会先将快照结果返回,待真实数据到达后再返回真实数据,也就是说启用快照缓存的请求将执行两次回调,这个听起来有点奇葩,但应用场景确很普遍,比如说打开一个列表页,通常要有一个 loading 然后请求到数据后显示到页面上,而使用快照缓存的结果是,打开页面马上呈现最近一次的数据,待新数据拿到后再更新一次页面,我认为这是体验更佳的方式。

可能有的同学会想,如果单纯只是渲染页面还好,万一请求数据后还有一些业务操作,那你执行两次肯定是不行的,没错,为了解决这个问题,快照数据如果是对象的话,会自动为这个数据增加一个”snapshoot”属性,你可以通过检测这个属性来得知当前数据是否为快照,以避免业务操作重复执行。

快照缓存目前来看的问题是,没有做新数据与快照是否相同的检测,导致如果两次数据相同,也会让页面白白重新渲染一次,后续会考虑改进这个功能。

数据存储模块提供本地数据的增删改查功能,适用于少量应用数据的存储方法挂载在app.storage对象上,比如app.storage.val()

因为是依托localStorage实现的,所以原来不支持对象类型的存取,这次升级支持了对象类型,其实也就是内部自动做了转化;另外增加了一个app.storage.clear()方法,用来清除存储的数据,但我们常常会有一些数据是希望能不受影响的、持久的存储,比如用户信息、权限等等,那么可以将这些值的 key 加到配置文件中的appcfg.set.safeStorage安全存储项目里,多个值用逗号隔开,”clear”方法默认会跳过不清理这些存储项,除非启用强制清理。

顺带说另外一个相关的配置appcfg.set.temporary临时存储,这个配置的意思是这些值每次 APP 退出后都将自动清除。

设备访问

设备访问能力提供对手机硬件的信息获取和其他操作能力,比如获取系统信息、拨打电话、安装文件等等,他们被挂载在app.device对象上,例如app.device.call()

这部分就是单纯的封装引擎功能,没什么可说的,目前支持的功能并不很多,因为这些东西我用的不多,不确定哪些是必要的,所以这部分有待后期观察,再做调整。

原生控件

原生控件就是系统自带的 UI 控件,比如 loading、alert、confirm、actionSheet 等,因为还比较常用所以直接挂载到了app对象上,比如app.alert()

这部分一开始我还纠结要不要封装,因为他们应该归到 UI 层面,既然是 UI 的东西核心里不应该集成,但想了想,目前 apicloud 没有一个拿得出手的同类插件,总得有东西用啊,所以就封装进来了。这肯定不是个长久之计,因为大部分安卓系统的原生控件实在太丑,这个后期再想想办法,争取解决掉。

目前有一个不成熟的思路是用 web 来做,但 web 有一个致命的问题是可能受到 frame 窗口的限制,无法做到模态,还可能被其他控件遮挡,这个问题可以通过打开一个透明 window 来解决,在这个 window 上显示控件,操作后再隐藏到底层去,可能的问题有两个,一个是响应速度不知道够不够快,再就是跨窗口通信内容比较多,可能导致实现很复杂,进一步拖慢速度。最好还是找到一个靠谱的原生插件。

UI

css 组件

框架自带一套 css 组件放在sdk/ui.css中,这次经过小幅修改,着重删掉了一些冗余代码和微调了部分组件的样式。

为了实现UI 可剥离,放弃了之前做的主题功能,这个主题功能简单说就是页面一开始是隐藏的,模板引擎解析得到主题 css 后动态插入页面才让页面显示出来,从性能角度讲放弃这种做法也算是走上了正道,但我记得之前在一次项目中发现,部分安卓机会出现页面打开之初先按照物理分辨率解析,随后布局抖动再恢复为像素分辨率,感觉是 webview 打开过程发生了一个异步的调整,这个体验是毁灭性的,主题功能的另一个作用就是解决这个问题,不过现在我已经找不到那台测试机了,目前这个问题是否还存在是未知的,有待经过实际项目检验。

如果不满意这套 UI 是可以直接抛弃掉的,跟框架其他部分几乎没有耦合,如果感觉还能凑合用,换主题功能就只能通过修改less文件来实现了,less文件估计将在文档写完后放出,在这之前暂时只能手动改样式了。

js 插件

框架内置了部分常用插件,比如图片轮显、相册、各种选择器、滚动加载、图片懒加载等等,体验都还不错,部分来自Flow-UI的插件库,针对移动端做了微调,使用上还是一贯的模块化。

虽然有了app.util之后就不再提倡使用 jQuery 了,但如果有人在乎的话,内置 jQuery 的版本已经升级到 3.x。

原来内置在core.js里的etpl也成为了一个插件模块,用来实现前端页面渲染。应该说开发混合应用免不了大量的页面渲染,Vue 当然是最好用的工具之一,但把一个功能完备的 MVVM 框架拿来做渲染,总觉得的有点冗余,而且依我过去的项目经验,大部分渲染其实都是单向的,也就是展示型的,需要将界面操作反应到数据中的情况不太多,在这种情况下,单从代码利用率的角度讲前端模板引擎是“实惠”的选择。

但模板引擎的使用体验比 Vue 差太多了,先要解析模板,再应用数据,最后填充到页面中,为了减轻这部分负担插件库中提供了一个Render插件,可以实现数据=>界面的单向绑定,除了不是双向绑定,在渲染操作上已经接近 Vue 的体验了,当然差别还是有的,因为内部是使用etpl实现的,并没有高大上的差量更新,所以大范围的页面更新理论上效率不如 Vue,这个有待低端机测试,千元以上的手机应该不太会看出差别。

当然,这些也都属于 UI,可以用自己喜欢的任意方案替换掉。

其他

还有一些功能,散布在框架sdk/里的 common.js 和 server.js 中,严格来说这些代码已经不属于框架核心范畴,开发者可以根据自己的业务情况做删改,但其中有一些还是很实用的,举个例子。

不知道大家有没有发现,apicloud 在引擎层面对页面显示做了优化,打开一个页面前多少会有一点停顿,猜测是在页面没有完全渲染完之前不会开始进场动画,因此有动态渲染内容的页面打开会很迟钝,纯静态的页面打开就利索很多,虽然这可以有效解决布局闪动的问题,但有时候这并不是开发者想要的,而造成打开速度差异的最重要原因就是图片元素的加载,所以为了解决这个问题,我们可以先将页面里的图片”src”值赋给”data-src”,使图片不会立即加载,当页面显示完毕后再将”data-src”赋给”src”以加载图片,从而绕过引擎的优化方案,提升页面打开的响应速度,这个操作已经在框架默认的sdk/common.js中实现了,并在示例 APP 中部分应用,效果明显。

common.js 和 server.js 中还有很多实用的功能,比如图片自动缓存、给按钮添加点击效果、封装获取经纬度功能、通过经纬度反查地址功能、推送功能等等,具体有啥就自己去看吧,这里不一一列举了。

体验

瓶颈明显

混合应用目前的体验确实不理想,为此我还特地对比性的研究了下 Dcloud,感觉文档好专业好极客,各种优化手段好极致,但他们的体验 APP 也并有明显的流畅性差异,所以我甚至认为,这就是以 web 技术为主的混合应用开发模式的瓶颈,这种体验跟当下大家对主流 APP 的期待已经产生了不小的差距,做混合应用很重要的一项工作就是修补这些瑕疵。

要说 apicloud 跟 Dcloud 完全没差别也是不准确的,粗略的看至少有两点 apicloud 不如 Dcloud 做的好,第一是 apicloud 的后台页面更容易被回收,当连续打开几个 apicloud 应用页面后切到其他稍微重一点的 APP 操作一会儿,再切回 apicloud,然后返回上一个页面会发现页面已经空白了,需要重新渲染;同样的手机同样的场景 Dcloud 应用不存在这个问题。第二是 apicloud 缺少页面预加载功能,Dcloud 的示例应用中利用预加载做了列表到详细页最佳实践,有力证明了预加载的价值,而这个需求被提交给 apicloud 后,管理员的回复是

“打开页面其实用不着进行预加载,正常的 openWin 打开然后加载已经足够了。”

最终示例 APP 中只能勉强用 frame 模拟了详细页预加载,用 frame 模拟的缺点有两个,一是 frame 无法实现“推入”效果,只能“飞入”,因此可能与 APP 的全局页面切换效果相违背;第二点更致命,因为 frame 是依赖 window 的,也就是说不同的列表页无法共享预加载的详细页,即便同一个列表页只要退出了,下次进来也需要重新预加载详细页。

曲线救国

刚开始接触混合应用时很喜欢搞一些看上去“很原生”的效果,比如划出菜单、滑动选项卡式列表之类的,后来发现实现是能实现,但结果太糟糕了,因为这些东西太重了,不是 web 能消费得起的,不要用 web 的弱点去死磕。

在整体的体验把控上我的看法是,只要功能实现了,有没有某个特效是第二位的,APP 的流畅体验和赏心悦目永远是第一位的,尤其要避免任何反常的界面表现,比如 web 特有的布局抖动和界面先空白后闪现,都会给用户造成“不稳定”的心理暗示,这些问题稍微用点心其实都可以克服。比如 frame 第一次打开就会出现典型的闪动现象,这时候就需要做预加载,可以参考示例 APP 的首页第四个栏目,在做了预加载之后有效避免了闪动,而且可以秒开。

像侧滑菜单这种东西,多数情况都可以用一个从左往右打开的页面来代替,流畅性有保证,开发难度也低,不一定非得是侧滑到屏幕一半。

web 开发的优势在于布局的灵活性,利用好这一点有时候能让原本不那么好的体验变得可以接受,比如给列表页实现占位元素,实现成本非常低,却能有效降低等待加载的焦虑感,可以参考示例 APP 的列表到详细页

总结起来,从绝对性能上混合不可能比得过原生,混合能做的就是用各种手段提高用户的忍耐阈值,或者转移用户的注意力。

后记

文档正在撰写中,目前线上的文档版本仅供娱乐。

未经实际项目检验,渴望暴风雨猛烈抽打,感兴趣的戳此Star。

前端路上原创技术文章,转载请注明出处:https://refined-x.com/2017/07/07/HybridStart v1.0开发纪要/

看风景-公众号

不甘平庸的你,快来跟我一起充电吧,关注看风景,获取更多精彩内容。