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 | /* |
可以看到正文明显被app.ready()
方法分隔成上下两部分,上面空白处的代码将在页面加载后立即执行,ready 回调内的代码将等待原生能力就绪后被执行,我们鼓励将所有不需要原生能力的操作放在上面,以提升脚本响应速度,这个没什么问题。
但可能遇到的一个问题是,如果一个依赖原生能力的功能被开发者立即执行了,将会因为 runtime 未就绪而报错,也就是说需要开发者明确的知道那些功能依赖 runtime 哪些不依赖,如果试图解决这个问题很容易想到的一个办法是,将所有依赖 runtime 的功能在内部用 ready 方法包裹一下,这样表面上可以解决问题,但因为 ready 的异步特性,可能导致代码执行顺序与书写顺序不一致,这无疑是不可接受的。最终在两者间做了妥协,将这些功能在内部用另一个 readyEval 方法包裹,readyEval 方法仅仅在检测到 runtime 未就绪时给控制台抛出调试信息,而不会中断后续代码的执行,算是一个容错性的处理吧。
窗口操作
窗口操作包括对 window 和 frame 的常用操作,比如打开、关闭、移动、执行脚本等,这些方法都挂载在app.window
对象上,例如app.window.open()
。
作为最基础也最常用的操作,封装目标就是易用,比如打开窗口这个操作,即便有了app.window.open()
也仍然觉得不够简单,因此进一步封装了app.openView()
,可以说让绝大多数场景下的打开窗口变得极致简单了,看下两个方法的对比:
1 | app.window.open({ |
openView 方法的详细介绍可以参见这里,openView 的参数传递是借助本地存储实现的,这次升级也为其配套了一个获取参数的方法app.getParam()
,专门用来获取 openView 方法传递的参数,并且支持对象类型的存取。
内建机制
简化开发另一个很重要的方向是内建机制,举个例子,实现会员退出登录,需要跳转到登录页同时关闭所有后台页面,关闭后台页面的功能 apicloud 提供了,但本着尽量不使用特定平台提供的特殊能力原则,这个功能在框架中用另一种方式实现了,而且使用起来超简单,比如可以这样:
1 | app.openView( |
内部实现是,每一个页面打开后会在 window 上挂载一个”isBack”属性,通过监听本窗口的前后台状态更改这个属性的值,当 openView 方法的 closeback 被设置为true
时,将在打开新页面前在本地存储埋下一个标记,新页面打开后通过这个标记得知自己的任务,然后发布一个相应的全局事件,所有页面都能通过这个事件得知自己的任务,比如任务是关闭后台页面,那么就会检查自己的window.isBack
属性,发现是真值就关闭自身,从而完成这个任务。
其实就是利用全局通信能力建立起来的关闭机制,依据这个思路,还可以扩展出打开新窗口同时关闭自身的方法,比如订单提交场景,提交成功后通常会跳转到一个提示页,但是我们不希望从这个提示页可以返回到刚才的提交订单页,所以希望打开提示页的同时关闭订单页,那么实现的代码将是:
1 | app.openView( |
closeself
与closeback
的区别仅仅是给当前页面增加了一个”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。