0%

基于APICloud的混合应用开发框架

接上一篇对各种混合应用开发方案的探讨,个人觉得现阶段最适合自己的还是以APICloud为代表的混合应用云平台,对于不懂原生开发的前端来说,其他方案的坑真的踩不起,为了让踩过的坑不再坑人,我将自己基于云平台的项目经验总结并封装到了一个混合应用开发框架中去,下面就聊聊这个框架HybridStart

为什么需要HybridStart

平台提供什么

APICloud集成了包括窗口系统、应用管理、网络通信、数据存储、消息事件、设备访问、UI组件、多媒体等功能,这些功能不需要额外插件支持就可以直接调用,可以说是当之无愧的”开箱即用”,官方文档对api的介绍还是非常细致的,第一次打开文档可能会觉得信息量太大,不知从何看起,这里我们就以窗口系统为例,介绍一下APICloud到底为我们提供了什么样的能力,以及为什么在实际项目中仍然需要对其做二次封装。

webview是APICloud的核心,这意味着APP像网站一样,是由若干个独立页面组成的,APP的使用过程会依次打开多个页面,这些页面形成一个堆栈,最新打开的显示在顶层,曾经打开的堆在底下,通过返回、跳转、关闭等操作可以在页面堆栈中穿梭,这些操作能力就由窗口系统提供,他至少包括以下功能:

  • 打开/关闭页面
  • 打开/关闭浮动窗口
  • 跳转页面
  • 跳转浮动窗口
  • 跨页面执行脚本
  • 本地存储
  • 页面状态监听
  • 全局事件发布/订阅

这些功能足以满足窗口管理中的所有需求,有一些功能甚至非常强大,比如跨页面执行脚本,意味着你可以在A页面遥控B页面执行指定脚本;还有的功能可以直接操作堆栈,比如将指定页面/浮动窗口置顶/置底;还有非常实用的发布订阅机制,是一种有效的穿透页面隔阂的工具。

功能是够用了,但估计看完了你还是不会写代码。

平台欠缺什么

平台不缺功能,但我仍然有很多疑问,起码我在第一次接触到窗口系统时,心里就有很多疑问。

第一个疑问,什么是浮动窗口?浮动窗口产生的背景是,安卓机上只有<body>节点产生的滚动才具有流畅的原生弹动效果,<div>或其他标签产生的滚动则很生涩,那在APP上我们要做局部滚动怎么办呢,我们通过在当前webview上覆盖一个小点的webview,来实现平顺的局部滚动,非常像web开发中的<iframe>,这就是浮动窗口。浮动窗口与主窗口具有从属关系,浮动窗口不能调用关闭方法关闭自己的父窗口。细心的同学会问,那为什么不用<iframe>呢,因为有兼容问题。

第二个疑问,窗口之间怎么传参?主要有两种方式,第一种是打开窗口的方法本身支持传参,可以在新窗口通过指定api获取参数,然而这个官方方法并不是最佳方案,最大的缺点是需要等待原生功能就绪,就是说要在一个异步回调函数里才能取得参数,不够快;另一种方法是利用本地存储,在原页面存参数,打开新页面后取参数,本地存储是web能力,可以直接调用而不必等待原生功能就绪,所以效率更高;理论上还有第三第四种方法,比如用跨页面脚本执行去获取另一个页面中的变量,或者用事件监听机制让两个页面建立联系从而传参,不过这些就很奇葩了,需要说明一下理论上给url后面加?a=b&c=d这种方式也是可以的,但在部分安卓系统上不兼容,因此不能用。

第三个疑问,多页面如何同步状态?多页面机制最大的问题就是状态被分散在每个页面中,需要手动同步状态,比如打开了N个页面后用户突然退出登录了,后台页面就需要更新到非登陆状态,这时就要用到全局事件的发布和订阅了;还有一个场景是列表数据的实时更新,可以通过监听列表页面的前后台状态,使页面每次回到前台时执行更新操作。还有一个”点对点”的做法是跨页面执行脚本,可以让任意页面执行任意操作,很强大但使用场景有限。可以看到,这些操作虽然都能实现,但都挺麻烦,而且好像没有哪个是标准实现,怎样都行。

第四个疑问,什么时候用窗口什么时候用浮动窗口?只要不是局部滚动页面都用窗口,有一种可能要用浮动窗口的情况是,希望在父窗口的框架下通过控制多个浮动窗口切换来更新局部内容,这个理论上可以,但需要量力而行,因为父子窗口的通信基本是靠跨页面脚本执行,交互多的话会很麻烦,而且浮动窗口的打开速度比想象的慢,开发中建议规避这种做法。

第五个疑问,一个APP包含很多个页面,代码怎么组织?这个问题和上一个问题其实都应该由官方来回答,然而官方的新手指南基本上没啥用,只能自己看开源代码,然而这些官方示例的代码组织可以说一团糟,不出意外你会在一个文件夹里看到一大堆html文件,每一个文件名由栏目名称、页面名称、页面类型组成,打开html会在底部发现script标签里面写着当前页面的js代码,唯一一点有组织的迹象,大概就是提取了公用css和公用js,弱爆了。

第六个疑问,怎么开始?给个套路?嗯,你只要通读文档,再踩上几个项目的坑,就全明白啦。

可以看到,APICloud对所有的问题都有答案,甚至对有的问题有不止一个答案,唯独缺乏一个清晰的梳理,新手上来免不了要踩坑,HybridStart的定位就是一个基于APICloud平台的混合应用开发脚手架,把可能出现的坑都填上。

HybridStart提供什么

开发模式

如果是跟我一样之前从未接触过APP开发的前端,我认为首先需要知道的是,APP不同于web的地方是需要很多初始化操作,比如判断是否已登录、数据预取、检查更新、注册推送、注册全局监听等等,经过这个过程后APP才能打开第一个页面,进入页面的生命周期。

APICloud里有一个非常重要但官方没怎么强调的概念叫根页面(root),就是APP启动后第一个打开的那个页面,这个页面非常特殊,相当于其他所有页面的父页面,它被关闭了意味着APP退出,他无法被其他页面调用关闭方法关闭,它是到达其他页面的必经之路。综合这些特征,这个页面非常适合用来做APP初始化,初始化完成后再立即切换到首页或者登录页,这时用户看到了第一个页面,但实际上是APP打开的第二个页面。

APP启动后root页就常驻后台,对于安卓机还需要在可能返回到root页的页面上做返回键拦截,提示退出APP而不允许返回到root,因为root是个只有js代码的空白页。那么混合应用的页面生命周期就应该是:

1
root -> index(exit) <=> page <=> page ...

开发中我们第一个要实现的就是root页的初始化功能,比如检查登录状态,然后决定是跳转到登录页还是主页,然后再去实现登录页 or 主页。

APP的数据交互几乎全部依靠后端接口,因此很有必要事先约定一个交互格式,方便统一做异常处理。比如最简单的先把json的大结构定下来,起码状态、数据、提示信息字段都得有,对于列表数据还需要一个信息总数字段,这样下来一个基本的交互格式就像这样:

1
2
3
4
5
6
{
"status": "Y", //请求的状态 "Y"/"N",也可以根据情况扩展其他
"data": [{...}], //请求的数据 数组或对象
"msg": "", //【可选】服务端提示信息
"count": [number] //【可选】当获取列表数据时,需附加count数据指明列表总数,用于前端分页
}

这样我们就可以封装一个数据请求方法,在方法里对某些情况做自动处理,比如当发现status不是”Y”的时候就自动提示msg字段的信息,就不用在每一个业务逻辑里写错误处理了。

代码组织

稍微复杂点的APP有个几十近百的页面很正常,所以APP代码组织首先要解决的是页面组织。

页面肯定得放在一起管理,但又不能直接罗列在一起,那就先建一个view/文件夹,然后按功能模块分二级文件夹,把会员相关页面都放进member/,商品页面都放进product/……;页面的脚本和样式也不希望内联,最好每个页面对应模板、样式、脚本三个文件,那就将他们三个也装进文件夹,以页面名称命名。这样页面文件就形成了channel-page-pagefile的结构,目录就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
view/
|--- member/ //会员栏目
| |--- info/ //会员信息页
| | |--- temp.html
| | |--- style.css
| | `--- script.js
| `--- set/ //会员设置页
| |--- temp.html
| |--- style.css
| `--- script.js
|
|--- home/ //APP首页
| |--- temp.html
| |--- style.css
| `--- script.js
...

这样即使有再多的页面,找起来也有迹可循,不至于在文件堆里看花了眼,将页面样式和脚本拆分出来也是为了开发方便,因为页面代码一旦很长,上上下下的巴拉css和js也挺痛苦的,不如拆开干净利索,反正都是本地文件,几乎没什么加载问题,将页面用文件夹的形式管理还有一个好处,就是可以将页面的独有资源放在各自文件夹内管理,比如图片就不需要全部丢进公用文件夹了,将来打开一看一大堆图片,都分不清哪个有用哪个没用。

然后是脚本组织,APP开发需要写大量的js,组织js的目的就是层层过滤,将非业务代码过滤出去,使注意力可以更多的放在业务脚本的开发上。

首先我们肯定要将类库剥离出来,在类库和业务之间再划分出插件、服务、公用脚本。

公用脚本就是类似返回按钮的监听、图片点击的监听、兼容性处理等,每个页面都得引用它(除了root),可以把他们都抽到common.js里,方便统一修改;还有一些业务上常用的方法,比如格式化、查坐标等等,不是每个页面都能用到,但也很有必要集中在一起管理,暂且就叫他server.js;另外还有一些插件类的脚本,比如上传、表单验证,这种就分别封装成模块,一起放进modules/文件夹;最后是类库,也是框架的核心,我们称之为core.js,这里面放的是常用类库以及对引擎接口做二次封装,二次封装至少有三个好处,一是可以精简api,如果看过APICloud的文档感觉还好的话,建议去看一下Appcan的文档,那醉人的api设计,简直欲仙欲死;二是底层引擎的api假如更新了,不需要修改业务代码,只改core.js中对应的封装就好了;三是便于更换底层,实际上这个框架的雏形就是基于Appcan实现的,后来弃坑转到APICloud无非就是换了一套底层api,框架自身api没有大的改动。

最后剩下的就是散落在各个页面里的script.js了,那么最终的脚本组织是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
|--- sdk/
| |--- modules/
| | |--- upload.js
| | |--- ...
| |--- core.js
| |--- server.js
| `--- common.js
|--- view/
| |--- page/
| | |--- script.js
| |--- ...

css以及其他静态资源的组织就很简单了,没必要细讲,再上一个完整的目录结构吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
|-- docs/       //文档(不需要上传打包平台)
|-- error/ //app错误页
|-- res/ //app静态资源(图片、模板等)
|-- sdk/
| |-- modules/ //插件模块
| |-- font/ //字体图标
| |-- core.js //核心库
| |-- server.js //业务方法
| |-- common.js //页面公用代码
| `-- ui.css //公共样式
|-- view/ //app页面
|-- config.js //框架配置
`-- config.xml //APICloud配置

技术栈

js分的这么零碎肯定离不开模块化,因此整个项目是基于seajs实现的模块化加载;DOM操作用的jQuery 2.x,很多人觉得做混合应用还上jQuery太low,我要说多webview模式让混合应用真的很像一个网站,DOM操作少不了,当然你大可换成zepto或自己封装几个方法去用,我觉得差别不见得有多大,都是本地资源差个几KB有区别吗;模板引擎用的etpl,这个很有用,大量的异步数据渲染,没有模板引擎不行。

类库都是直接将压缩后的代码放进core.js顶部,理论上可以随意增删改,但上述三个类库在其后的app对象实现中也有应用,因此不能直接删掉。除这三个以外的类库如果不需要可以删,比如xss.js,一个防御跨站脚本攻击的库。

HybridStart的意义

目的及原则

我有一点代码洁癖,体现在我不喜欢任何二次封装的东西,我希望通过最短的路径去触及功能实现的关键,所以抱着这样的目的,最开始我连官方的js SDK也不用,直接调用引擎api开发业务,我认为这是最快、性能最高的方式。

然而事实是,引擎提供的api效率真心不高,而且可靠性堪忧,当年用Appcan开发第一个项目的时候,简直难受的想死,bug多到”举步维艰”你能想象吗,转到APICloud后虽然没有这么多明显的bug了,但部分api偶发性失灵还是有的,这种问题基本就没办法了,后来看了一些对混合应用实现原理的介绍才知道,这玩意本来就是个hack,反射弧就是比较长,体验上”不利索”啊,偶发性的失灵啊,也就可以理解了,其实难怪,要真能像调用原生一样快那还要原生干什么。

所以后来我改变了思路,不能再面向引擎编程了,因为你不知道一个api背后是怎样实现的,就不知道这个api的真实使用成本,所以我开始接受二次封装,并且原则上尽量少的使用引擎能力

一开始是修改官方的js SDK,将无用的功能删掉,将需要的功能加上,改着改着发现这个js SDK跟我的需求差别太大,干脆就重写了一个,该有的有,该扩的扩,用起来很爽。随着开发的深入,越来越发现其实利用有限的几个api就可以实现绝大多数需求,如果仔细研究引擎的api,会发现真有些功能是非必需的,或者说是语法糖,怎么说呢,感觉就是api”设计的不优雅”。甚至有的功能实现还不如js模拟来的效果好,背后的开发质量可见一斑。

在这样的目的和原则下,引擎api被二次封装进了app对象,除了常用核心方法被直接挂载在app上之外,还包括了app.cryptoapp.lsapp.windowapp.ajax几个模块。

app.openView

app对象里封装了所有混合应用开发需要的功能,但是很多琐碎的功能实现都尽量的被隐藏起来了,可能开发中只需要修改一个配置就能使用,目的就是为了简化开发。这里我们就说一下app.openView()这个方法,这个方法用来打开一个页面,可以说是开发中最常用的方法,借此也让大家对HybridStart到底做了什么有一个感性的认识。

首先我们看引擎本来提供的api是什么样的:

1
2
3
4
5
6
7
8
9
api.openWin({
name: 'page1', //为窗口命名,方便调用关闭方法将其关闭
url: './page1.html', //页面路径
pageParam: { //参数
name: 'test'
},
animation: 'push', //动画效果
subType: 'form_right' //动画方向
});

这个方法的配置项还有很多,列出来的是开发中最常用到的几个,即便只是这几个配置每次写也已经够罗嗦了,app.openView()可以说就是对这个api 的封装,希望通过各种方式在不牺牲功能的前提下简化配置,那我们就从这几个配置入手,挨个来看怎么简化。

name属性用来为一个窗口命名,这个名称将来可以用于调用某些方法对其进行操作。我们要省掉这个配置就只能自动生成,但这个名称日后还有用,所以不能随机生成,必须有一定的规律,这里可以结合页面组织来解决,按照我们前面讲的规则组织后页面分为两种,一级页面"/view/channel/temp.html"和二级页面"/view/channel/page/temp.html",规律还是很明显的,只要提供页面所属的channel名称以及如果是二级页面的话再加上page名称,就可以定位到这个页面,并且通过channel + "_" + page来得到一个唯一的name值。那我们就先假定openView方法需要channelpage两个参数,page是可选的,调用时将是这样:

1
2
3
4
app.openView('home');     //url: "/view/home/temp.html", name: "home"

app.openView('member','set'); //url: "/view/member/set/temp.html", name: "member_set"

还不错,nameurl都解决了,属性pageParam的处理相对复杂,我们放在后面说,先来看animationsubType

这两个属性是最应该被封装掉的,页面切换的动画类型肯定要集中到一个全局配置中管理,调用时animation可以省掉;动画方向配置基本上就是个伪需求,打开自然就是右推,关闭自然就是左推,分别封装进打开和关闭页面方法里就好了,subType也可以省掉。

现在来看pageParam,用来给页面传参,参数格式是Object。好,这个需求必须有,我们要让app.openView()支持传参,语法将变成这个样子:

1
2
app.openView(param[Object], channel[String], page[String]);

因为page是可选的,放在最后便于实现,因此将param参数放到前面。好像看上去也还行,但肯定还会有其他配置,不能一再的往上加参数吧,怎么办。

这里有一条经验,页面传参多数发生在从列表页打开详细页的时候,这时我们传的参数是一个id,也就是一个字符串,实际上绝大多数情况下的页面传参都只是一个字符串,需要Object的情况不多,基于这个前提,我们将param参数扩展一下,既可以接受字符串也可以接受对象,当接受字符串时将该值作为参数传递给新页面,当是对象时允许该对象包含对openView方法的所有配置,当然其中也包括了页面参数,说起来有点绕,看代码:

1
2
3
4
5
6
7
8
9
10
11
app.openView('newsID', 'news', 'detail');     //实际开发中最常用的字符串传参

app.openView(null, 'home'); //如果不需要传参,抱歉必须传一个null/undefined占位

app.openView({ //Object类型的参数得这么传
param: Object
}, 'home');

app.openView({ //这里还可以配置openView方法的其他参数
duration: 350
}, 'home');

这样所有的问题都解决了,但有一个小瑕疵,就是没有参数必须传null/undefined占位,因为page参数已经是可省的了,param参数实在没办法再做判断,不过这个null/undefined传的也不是一点意义没有,这里又得说来话长了。

前面说过给页面传参有两种方法,一种是通过api提供的pageParam,另一种是通过localStorage跨页面存取值,pageParam的问题是新页面取值比较慢,取值代码可能是这样的:

1
2
3
4
5
6
//原生功能就绪回调
app.ready(function(){
var pageParam = api.pageParam;
//基于pageParam的后续操作,比如页面渲染、表单验证,事件绑定
...
});

app.ready()是框架封装的原生功能就绪回调,这是一个异步回调,通常,为了提高脚本响应速度我们会把不需要原生能力的操作放在app.ready()之外,使其同步执行,问题在于,如果基于页面参数的后续操作恰好是不需要原生能力的,但为了等待取参数,也必须被放进app.ready()内执行,这就很不爽了。

所以框架提倡的传参方式是用localStorage,在新页面可以同步取值,这种方式唯一的问题是可能造成资源浪费,各种参数放进本地,怎么清理?我的方法是约定一个专门用来传参的键crossParam,每次传参都写进这里,反复擦写最终留下的只是最后一次的参数值,app.openView()已经对此做了封装,参数将自动存进localStorage.crossParam,参数如果是对象类型将做JSON.stringfiy()处理,因此如果传的是对象,取值后需要自己做JSON.parse()处理

1
2
3
4
5
6
7
8
9
//同步取得页面参数
var param = app.ls('crossParam');
//执行不需要原生能力的操作
...

app.ready(function(){
//执行需要原生能力的操作
...
})

回到app.openView()方法第一个参数必须占位的问题,他的意义在于,当app.openView()检测到null/undefined时会将本地存储中的crossParam键删掉,将造成浪费的可能性降至最低。

当然,官方的pageParam方式也没有废弃,如果传递的参数是对象的话,pageParamlocalStorage两种方式都生效,通过api.pageParam 的方式也可以取到值。

经过这些封装,打开页面的语法已经非常简单了,但app.openView()还有很多其他功能,比如以弹窗形式打开页面、以带标题栏的形式打开页面、打开新页面同时关闭当前页面、或者打开一个网页,这些功能的实现都相对复杂,就不一一展开了,这里只着重介绍封装思路,如果有兴趣可以去HybridStart 文档看一看。

后记

吹了半天,还得回到选型上来,我并不觉得多数项目适合这种方案,我甚至觉得只有少数项目,或者只有项目的起步时期,可以用这种方案快速上马快速迭代,我理想中的混合应用形态是原生为主web为辅的,但从一个前端的角度看,我并没有发现更好的可行性方案,有人可能会说React Native,但那个东西还是需要原生开发基础的好吗,而且如果APICloud在UI组件方面再进一步,貌似也可以接近React Native的效果。

总之,如果你觉得自己的项目正好适合这个方案的话,这个框架可能对你有帮助。

源码: Github

框架本身就是一个演示APP,自带部分功能的演示页面,将代码同步到你的项目就可以编译下载了,如果这个项目对你有帮助的话,请去Github尽情的Star不要客气。

前端路上原创技术文章,转载请注明出处:https://refined-x.com/2017/06/26/基于APICloud的混合应用开发框架/

看风景-公众号

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