HybridStart 发布 v1.0-beta,附开发纪要

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

核心

移除依赖

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

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

功能梳理

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

核心功能

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

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

/*
 * 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(),可以说让绝大多数场景下的打开窗口变得极致简单了,看下两个方法的对比:

app.window.open(
    url:'./view/member/index/temp.html',
    pageParam: 
        id: 123
    
});

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

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

内建机制

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

app.openView(
    closeback: true
, 'member', 'login');

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

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

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的列表到详细页

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

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

See the article here: 

HybridStart 发布 v1.0-beta,附开发纪要