ipad&mobile通用webapp框架前哨战
响应式设计的意义
随着移动设备的发展,移动设备以迅猛的势头分刮着PC的占有率,ipad或者android pad的市场占有率稳步提升,所以我们的程序需要在ipad上很好的运行,对于公司来说有以下负担:设备系统上来说主要分为android ios;尺寸上看又以手机与pad为一个分界线,如果再加一个H5站点,其开发所投入资源不可谓不小!
Hybrid的出现,解决了大部分问题,针对尺寸上的问题有一种东西叫做响应式设计,这个响应式设计似乎可以解决我们的问题,所以今天我就来告诉大家什么是响应式设计,或者说我这种外行以为的响应式设计。
响应式Web设计(Responsive Web design)的理念是:集中创建页面的图片排版大小,可以智能地根据用户行为以及使用的设备环境(系统平台、屏幕尺寸、屏幕定向等)进行相对应的布局。
以我粗浅的理解,响应式的提出,其实就是单纯的根据不同的尺寸,以最优的展示方式呈现罢了,仅仅而已,不能再多了,如果真要更多点,便是根据不同的尺寸对静态资源加载上有所控制,节约流量,换句话说,响应式设计不涉及业务逻辑,jser神马都不需要做,css同事便可完全解决,但事实上最近碰到的需求完全不是这么回事嘛。
以最简单图片轮播来说,手机上是这个样子的:
而在ipad横屏上,却变成了这个样子了:
我当时就醉了,iPad竖着保持手机样式,横着iPad样式,什么CSS有这么伟大,可以完成这个功能,而真实的场景是这个样子的:
手机端:首页搜索页->list页面->详情页->预定页
但是到了ipad横屏上:首页左屏是搜索页,右边是日期选择/城市选择/......,然后到了list页面,左边是list,右边是详情页,单击左边的list左边详情直接变化!
其实单独页面做的话,好像没有什么问题,但是手机业务早已铺开了,老板的意思是,代码要重用,还是全局改改CSS实现就好啦,我当时真为我们的UED捏了一把汗。到了具体业务实现的同事那里情况又变了,UED只是给出了两个设计好了的静态html+css,要怎么玩还得那个业务同事自己搞。
那天我去支援时,看到了其牛逼的实现,不由的菊花一紧,里面媒体查询都没有用,直接display: none 搞定一切问题了,这个对手机程序带来了很大的负担:原来一个view就是用于手机,现在无端的在里面加入了大量的pad端程序,直接造成了两个结果:
① 业务逻辑变得复杂,容易出BUG
② js尺寸变大,对手机端来说,流量很宝贵
虽然知道他那种做法不可取,当时忙于其它事情,并且天意难违,天意难测也只有听之任之,但是这里要说一点,响应式布局不太适合业务复杂的webapp,各位要慎重!
ipad版本应该怎么做?
虽然如此,问题还是需要解决,并且需要在框架层做出解决,这类需求本不应强加与CSS,好在曾经我们业务层的View设计基本是满足条件的,现在只需要扩展即可,仍然以blade框架为例:
每个页面片完成的工作仅仅依赖了一个View类,既然View是类,那么继承mobile的View,实现ipad的View,似乎是可能的,这一切的基石便是继承
继承的意义
我们这里的View Controller index.js开始是不完全满足我们的需求的,我们做一些调整,这里是调整前的代码:
1 define(['View', getViewTemplatePath('index'), 'UIGroupList'], function (View, viewhtml, UIGroupList) { 2 3 return _.inherit(View, { 4 onCreate: function () { 5 this.$el.html(viewhtml); 6 this.initElement(); 7 8 this.TXTTIMERRES = null; 9 10 }, 11 12 initElement: function () { 13 this.cancelBtn = this.$('.cui-btn-cancle'); 14 this.searchBox = this.$('.cui-input-box'); 15 this.txtWrapper = this.$('.cui-citys-hd'); 16 this.searchList = this.$('.seach-list'); 17 18 }, 19 20 events: { 21 'focus .cui-input-box': 'seachTxtFocus', 22 'click .cui-btn-cancle': function () { 23 this.closeSearch(); 24 }, 25 'click .seach-list>li': function (e) { 26 var gindex = $(e.currentTarget).attr('data-group'); 27 var index = $(e.currentTarget).attr('data-index'); 28 29 this.forward(this.uidata[gindex].data[index].uiname); 30 } 31 }, 32 33 seachTxtFocus: function (e) { 34 this.openSeach(); 35 }, 36 37 closeSearch: function () { 38 this.txtWrapper.removeClass('cui-input-focus'); 39 this.groupList.show(); 40 this.searchList.hide(); 41 this.searchBox.val(''); 42 }, 43 44 //开启搜索状态 45 openSeach: function () { 46 if (this.TXTTIMERRES) return; 47 48 this.TXTTIMERRES = setInterval($.proxy(function () { 49 // console.log(1); 50 //如果当前获取焦点的不是input元素的话便清除定时器 51 if (!this.isInputFocus()) { 52 if (this.TXTTIMERRES) { 53 clearInterval(this.TXTTIMERRES); 54 this.TXTTIMERRES = null; 55 } 56 } 57 58 var txt = this.searchBox.val().toLowerCase(); 59 if (txt == '') { 60 setTimeout($.proxy(function () { 61 if (!this.isInputFocus()) { 62 this.closeSearch(); 63 } 64 }, this), 500); 65 return; 66 } 67 68 this.txtWrapper.addClass('cui-input-focus'); 69 this.groupList.hide(); 70 this.searchList.show(); 71 72 var list = this.groupList.getFilterList(txt); 73 this.searchList.html(list); 74 75 }, this)); 76 77 78 }, 79 80 isInputFocus: function () { 81 if (document.activeElement.nodeName == 'INPUT' && document.activeElement.type == 'text') 82 return true; 83 return false; 84 }, 85 86 initGoupList: function () { 87 if (this.groupList) return; 88 var scope = this; 89 90 //提示类 91 var groupList1 = [ 92 { 'uiname': 'alert', 'name': '警告框' }, 93 { 'uiname': 'toast', 'name': 'toast框' }, 94 { 'uiname': 'reloading', 'name': 'loading框' }, 95 { 'uiname': 'bubble.layer', 'name': '气泡框提示' }, 96 { 'uiname': 'warning404', 'name': '404提醒' }, 97 { 'uiname': 'layerlist', 'name': '弹出层list' } 98 ]; 99 100 var groupList2 = [ 101 102 { 'uiname': 'identity', 'name': '身份证键盘' }, 103 { 'uiname': 'imageslider', 'name': '图片轮播' }, 104 { 'uiname': 'num', 'name': '数字组件' }, 105 { 'uiname': 'select', 'name': 'select组件' }, 106 { 'uiname': 'switch', 'name': 'switch组件' }, 107 { 'uiname': 'tab', 'name': 'tab组件' }, 108 { 'uiname': 'calendar', 'name': '日历组件' }, 109 { 'uiname': 'group.list', 'name': '分组列表' }, 110 { 'uiname': 'group.list', 'name': '搜索列表(城市搜索,地址搜索,待补充)' } 111 ]; 112 113 var groupList3 = [ 114 { 'uiname': 'radio.list', 'name': '单列表选择组件' }, 115 { 'uiname': 'scroll.layer', 'name': '滚动层组件(可定制化弹出层,比较常用)' }, 116 { 'uiname': 'group.select', 'name': '日期选择类组件' }, 117 { 'uiname': 'scroll', 'name': '滚动组件/横向滚动' }, 118 ]; 119 120 var groupList4 = [ 121 { 'uiname': 'lazyload', 'name': '图片延迟加载' }, 122 { 'uiname': 'inputclear', 'name': '带删除按钮的文本框(todo...)' }, 123 { 'uiname': 'validate1', 'name': '工具类表单验证' }, 124 { 'uiname': 'validate2', 'name': '集成表单验证(todo...)' }, 125 { 'uiname': 'filp', 'name': '简单flip手势工具' } 126 ]; 127 128 var uidata = [ 129 { name: '弹出层类组件', data: groupList1 }, 130 { name: '常用组件', data: groupList2 }, 131 { name: '滚动类组件', data: groupList3 }, 132 { name: '全局类', data: groupList4 } 133 ]; 134 135 this.uidata = uidata; 136 137 this.groupList = new UIGroupList({ 138 datamodel: { 139 data: uidata, 140 filter: 'uiname,name' 141 }, 142 wrapper: this.$('.cui-citys-bd'), 143 onItemClick: function (item, groupIndex, index, e) { 144 scope.forward(item.uiname); 145 } 146 }); 147 148 149 this.groupList.show(); 150 151 }, 152 153 onPreShow: function () { 154 this.turning(); 155 }, 156 157 onShow: function () { 158 this.initGoupList(); 159 }, 160 161 onHide: function () { 162 163 } 164 165 }); 166 });
1 <div id="headerview" style="height: 48px;"> 2 <header> 3 <h1> 4 UI组件demo列表</h1> 5 </header></div> 6 7 <section class="cui-citys-hd "> 8 <div class="cui-input-bd"> 9 <input type="text" class="cui-input-box" placeholder="中文/拼音/首字母"> 10 </div> 11 <button type="button" class="cui-btn-cancle">取消</button> 12 </section> 13 <ul class="cui-city-associate seach-list"></ul> 14 15 <section class="cui-citys-bd"> 16 </section>
调整后的代码如下:
1 define(['View', getViewTemplatePath('index'), 'UIGroupList'], function (View, viewhtml, UIGroupList) { 2 3 return _.inherit(View, { 4 onCreate: function () { 5 this.$el.html(viewhtml); 6 this.initElement(); 7 8 this.TXTTIMERRES = null; 9 10 }, 11 12 initElement: function () { 13 this.cancelBtn = this.$('.cui-btn-cancle'); 14 this.searchBox = this.$('.cui-input-box'); 15 this.txtWrapper = this.$('.cui-citys-hd'); 16 this.searchList = this.$('.seach-list'); 17 18 }, 19 20 events: { 21 'focus .cui-input-box': 'seachTxtFocus', 22 'click .cui-btn-cancle': 'closeSearchAction', 23 'click .seach-list>li': 'searchItemAction' 24 }, 25 26 searchItemAction: function (e) { 27 var gindex = $(e.currentTarget).attr('data-group'); 28 var index = $(e.currentTarget).attr('data-index'); 29 this.forward(this.uidata[gindex].data[index].uiname); 30 }, 31 32 closeSearchAction: function () { 33 this.closeSearch(); 34 }, 35 36 demoItemAction: function (item, groupIndex, index, e) { 37 scope.forward(item.uiname); 38 }, 39 40 seachTxtFocus: function (e) { 41 this.openSeach(); 42 }, 43 44 closeSearch: function () { 45 this.txtWrapper.removeClass('cui-input-focus'); 46 this.groupList.show(); 47 this.searchList.hide(); 48 this.searchBox.val(''); 49 }, 50 51 //开启搜索状态 52 openSeach: function () { 53 if (this.TXTTIMERRES) return; 54 55 this.TXTTIMERRES = setInterval($.proxy(function () { 56 // console.log(1); 57 //如果当前获取焦点的不是input元素的话便清除定时器 58 if (!this.isInputFocus()) { 59 if (this.TXTTIMERRES) { 60 clearInterval(this.TXTTIMERRES); 61 this.TXTTIMERRES = null; 62 } 63 } 64 65 var txt = this.searchBox.val().toLowerCase(); 66 if (txt == '') { 67 setTimeout($.proxy(function () { 68 if (!this.isInputFocus()) { 69 this.closeSearch(); 70 } 71 }, this), 500); 72 return; 73 } 74 75 this.txtWrapper.addClass('cui-input-focus'); 76 this.groupList.hide(); 77 this.searchList.show(); 78 79 var list = this.groupList.getFilterList(txt); 80 this.searchList.html(list); 81 82 }, this)); 83 84 85 }, 86 87 isInputFocus: function () { 88 if (document.activeElement.nodeName == 'INPUT' && document.activeElement.type == 'text') 89 return true; 90 return false; 91 }, 92 93 initGoupList: function () { 94 if (this.groupList) return; 95 var scope = this; 96 97 //提示类 98 var groupList1 = [ 99 { 'uiname': 'alert', 'name': '警告框' }, 100 { 'uiname': 'toast', 'name': 'toast框' }, 101 { 'uiname': 'reloading', 'name': 'loading框' }, 102 { 'uiname': 'bubble.layer', 'name': '气泡框提示' }, 103 { 'uiname': 'warning404', 'name': '404提醒' }, 104 { 'uiname': 'layerlist', 'name': '弹出层list' } 105 ]; 106 107 var groupList2 = [ 108 109 { 'uiname': 'identity', 'name': '身份证键盘' }, 110 { 'uiname': 'imageslider', 'name': '图片轮播' }, 111 { 'uiname': 'num', 'name': '数字组件' }, 112 { 'uiname': 'select', 'name': 'select组件' }, 113 { 'uiname': 'switch', 'name': 'switch组件' }, 114 { 'uiname': 'tab', 'name': 'tab组件' }, 115 { 'uiname': 'calendar', 'name': '日历组件' }, 116 { 'uiname': 'group.list', 'name': '分组列表' }, 117 { 'uiname': 'group.list', 'name': '搜索列表(城市搜索,地址搜索,待补充)' } 118 ]; 119 120 var groupList3 = [ 121 { 'uiname': 'radio.list', 'name': '单列表选择组件' }, 122 { 'uiname': 'scroll.layer', 'name': '滚动层组件(可定制化弹出层,比较常用)' }, 123 { 'uiname': 'group.select', 'name': '日期选择类组件' }, 124 { 'uiname': 'scroll', 'name': '滚动组件/横向滚动' }, 125 ]; 126 127 var groupList4 = [ 128 { 'uiname': 'lazyload', 'name': '图片延迟加载' }, 129 { 'uiname': 'inputclear', 'name': '带删除按钮的文本框(todo...)' }, 130 { 'uiname': 'validate1', 'name': '工具类表单验证' }, 131 { 'uiname': 'validate2', 'name': '集成表单验证(todo...)' }, 132 { 'uiname': 'filp', 'name': '简单flip手势工具' } 133 ]; 134 135 var uidata = [ 136 { name: '弹出层类组件', data: groupList1 }, 137 { name: '常用组件', data: groupList2 }, 138 { name: '滚动类组件', data: groupList3 }, 139 { name: '全局类', data: groupList4 } 140 ]; 141 142 this.uidata = uidata; 143 144 this.groupList = new UIGroupList({ 145 datamodel: { 146 data: uidata, 147 filter: 'uiname,name' 148 }, 149 wrapper: this.$('.cui-citys-bd'), 150 onItemClick: function (item, groupIndex, index, e) { 151 scope.demoItemAction(item.uiname); 152 } 153 }); 154 155 this.groupList.show(); 156 }, 157 158 onPreShow: function () { 159 this.turning(); 160 }, 161 162 onShow: function () { 163 this.initGoupList(); 164 }, 165 166 onHide: function () { 167 168 } 169 170 }); 171 });
PS:上面的代码是我几个月前写的,今天一看又觉得可以优化,当真优化无极限啊!!!
变化的关键点是每次我点击的事件全部放到了Index这个类的prototype上:
1 searchItemAction: function (e) { 2 var gindex = $(e.currentTarget).attr('data-group'); 3 var index = $(e.currentTarget).attr('data-index'); 4 this.forward(this.uidata[gindex].data[index].uiname); 5 }, 6 7 closeSearchAction: function () { 8 this.closeSearch(); 9 }, 10 11 demoItemAction: function (item, groupIndex, index, e) { 12 scope.demoItemAction(item, groupIndex, index, e); 13 },
这里粒度到哪个程度与具体业务相关,我这里不做论述,于是我这里继承至index产生一个新的index类:index.ipad.js,这个是其基本实现:
1 define([getViewClass('index'), getViewTemplatePath('index'), 'UIGroupList'], function (View, viewhtml, UIGroupList) { 2 return _.inherit(View, { 3 4 onCreate: function ($super) { 5 $super(); 6 }, 7 8 onPreShow: function ($super) { 9 $super(); 10 this.turning(); 11 }, 12 13 onShow: function ($super) { 14 $super(); 15 this.initGoupList(); 16 }, 17 18 onHide: function ($super) { 19 $super(); 20 }, 21 22 events: { 23 24 }, 25 26 searchItemAction: function (e) { 27 var gindex = $(e.currentTarget).attr('data-group'); 28 var index = $(e.currentTarget).attr('data-index'); 29 this.forward(this.uidata[gindex].data[index].uiname); 30 }, 31 32 demoItemAction: function (item, groupIndex, index, e) { 33 scope.forward(item.uiname); 34 } 35 36 }); 37 });
这个时候直接运行blade/ipad/debug.html#index.ipad的话,页面与原来index保持一致:
第二步便是重写其事件的关键位置了,比如要跳出的两个事件点:
1 searchItemAction: function (e) { 2 var gindex = $(e.currentTarget).attr('data-group'); 3 var index = $(e.currentTarget).attr('data-index'); 4 this.forward(this.uidata[gindex].data[index].uiname); 5 }, 6 7 demoItemAction: function (item, groupIndex, index, e) { 8 scope.forward(item.uiname); 9 } 10 11 //简单改变 12 13 searchItemAction: function (e) { 14 var gindex = $(e.currentTarget).attr('data-group'); 15 var index = $(e.currentTarget).attr('data-index'); 16 alert(this.uidata[gindex].data[index].uiname); 17 }, 18 19 demoItemAction: function (item, groupIndex, index, e) { 20 alert(item.uiname); 21 }
这个时候原版本的跳转,变成了alert:
这个时候便需要进一步重写了,比如这里:我点击alert,事实上是想在右边加载那个子view,所以框架全局控制器APP需要新增loadSubView的接口了:
新增接口
loadSubView要实现实例化某一View非常简单,但是该接口的工作并不轻松,换句话说会非常复杂,因为:
History与路由归一化是mobile与pad版本整合的难点
mobile的view与ipadview是公用的,所以本身不存在主次关系,是业务给予了其主次,这里需要一个管理关系
子View的实例化会涉及到复杂的History与路由管理,我们这里先绕过去,下个阶段再处理,因为完成pad版本,框架的MVC核心要经过一次重构
1 //这里暂时不处理History逻辑,也不管子View的管理,先单纯实现功能 2 //这样会导致back的错乱,View重复实例化,这里先不予关注 3 loadSubView: function (viewId, wrapper, callback) { 4 5 //子View要在哪里显示需要处理 6 if (!wrapper[0]) return; 7 8 this.loadView(viewId, function (View) { 9 10 var curView = new View(this, viewId, wrapper); 11 12 //这个是唯一需要改变的 13 curView.turning = $.proxy(function () { 14 curView.show(); 15 curView.$el.show(); 16 }, this); 17 curView.onPreShow(); 18 callback && callback(curView); 19 20 }); 21 22 },
在样式上再做一点调整就变成这个样子了:
这里History管理还是乱的,但是整个这个方案是可行的,所以我们前哨战是成功的,方案可行的话便需要详细的设计了
结语
今天,我们对ipad与mobile统一使用一套view代码做了研究,有以下收获与问题:
① 继承可实现ipad与mobile代码复用,并且不会彼此污染,至少不会污染mobile程序
② pad版本中History与路由管理需要重构
③ MVC需要重构,特别是View一块,甚至需要完全重新写
④ 样式方面还需要处理优化
总而言之,今天的收获还是有的,剩下的问题,需要在核心框架上动大动作了,最后的目标是能够出一套同用于ipad与mobile的框架。
源码:
https://github.com/yexiaochai/blade
demo在此:
http://yexiaochai.github.io/blade/ipad/debug.html#index.ipad