APICloud多端案例源码《点餐模板》深度解析
原创 柚子君 APICloud 昨天
-
AVM多端框架是在兼容和继承APICloud所有API、模块、技术栈以及用户体验的基础上,我们定义了一套新的代码编写标准(DSL):基于标准Web Components组件化思想,兼容Vue / React语法特性,通过一次编码,分别编译为Android和iOSAPP、小程序代码,实现多端开发。
为了让开发者更加快速的学习和了解APICloud多端开发技术,APICloud平台特别推出一款多端源码-《餐饮点餐》,可以体验一套代码编译Android和iOS app+小程序。
《点餐》项目是一个餐饮商户单商家堂食下单应用。主要功能包括浏览商家主页信息、查看推荐菜品、下单商品、取餐等号等功能。可以适用于小吃快餐餐饮商户的堂食点单管理,也可以进行稍微二开成为外卖、店铺或者是虚拟服务等电商小应用。

项目架构
APICloud 多端技术实现了一套代码,多端运行。支持编译成 Android & iOS App 以及微信小程序。APICloud 数据云3.0 来构建的:通过编写云函数自动管理维护接口和数据,详细可以参考数据云的文档。也可以自定义后端接口,通过自写服务器完成开发。使用步骤
widget 目录下面,此目录也就是应用的根目录。源码文件目录结构
widget 目录下。其中该目录下的文件结构如下:│ ├─empty-block.shtml 空数据占位图组件
│ ├─goods-action.shtml 商品下单动作组件
│ ├─goods-counter.shtml 商品加购计数器组件
│ ├─goods-list-item.shtml 主页商品列表单品组件
│ ├─order-item.shtml 订单列表单品组件
│ ├─radio-box.shtml 自定义选择器组件
├─css/ css样式目录
├─image/ 图片素材图标资源目录
├─pages/ 新版的AVM页面目录
│ ├─goods_add
│ │ └─goods_add.stml 加购浮层
│ ├─goods_detail
│ │ └─goods_detail.stml 商品详情页
│ ├─main_cart
│ │ └─main_cart.stml 主tab-2 购物车页面
│ ├─main_home
│ │ └─main_home.stml 主tab-0 商家主页
│ ├─main_menu
│ │ └─main_menu.stml 主tab-1 点餐菜单页面
│ ├─main_user
│ │ └─main_user.stml 主tab-3 用户主页
│ ├─pay_result
│ │ └─pay_result.stml 支付结果页
│ ├─pending_order
│ │ └─pending_order.stml 待付款结算页
├─script/ JavaScript脚本目录
└─config.xml 应用配置文件
首页 TabBar 结构的处理
为什么需要一个 app.json 配置文件
APP 原生端 上面, 我们可以借助 FrameGroup 来实现这样的切换组。小程序原生上则是使用 app.json 配置文件来 配置定义 TabBar 的相关属性 。为了统一两端的差异问题,通过在 weight 根目录下定义一个 app.json 文件,具体字段说明请参考《openTabLayout布局文档》 。所以,如果只书写原生端 APP ,而不计划支持小程序的话,这个配置文件就是可选的了。TabBar页面的组织
pages目录准备建立这四个页面。分别是 “商家主页” main_home 、 “菜单页面” main_menu 、 “购物车页面” main_cart 和 “用户主页” main_user 。为了兼容小程序目录结构,需要使用同名文件夹对其包裹一层。商家主页 main_home 的编写

先看到主页效果图,然后大致分析一下页面结构。源代码在 /widget/pages/main_home/main_home.stml 。页面主要部分是一个滚动效果,需要使用一个 scroll-view 来做滚动部分的容器。头部有一个固定头部,并跟随上面提到的 scroll-view 的滚动高度来做透明度反馈。
flex 布局。有一点需要注意的是, flex 布局的 flex-direction 默认是 column , 也就是竖着排列的方向,这一点是和传统网页中不一定地方。另外,每一个组件默认会附带 display:flex;属性。请求接口数据 (数据处理和请求库封装)
apiready 中,有一个 this.getData()的方法,就是在请求数据。GET('shops/getInfo')
.then(data => {
this.data.shopInfo = data;
})
}
GET 方法实现的。这个方法来自于:import {GET} from "../../script/req";
this.data.shopInfo = data 将数据交给到页面的数据域中,以便于接下来的数据绑定显示。商家头图和主要信息 (数据绑定)
scroll-view 一起滚动的,所以它应该在滚动容器的外部。使用一个 img 图片标签来显示图片。其数据是来自服务器接口的数据, 使用 avm.js 提供的《数据绑定》 来处理数据。<img class="shop-photo" style={{'height:'+photoRealHeight+'px'}} src={{shopInfo.img}} alt=""/>
style={{'margin-top:'+photoRealHeight+'px'}}>
<view class="shop-header flex-h">
<text class="shop-name flex-1 ellipsis-1">{{ shopInfo.name }}</text>
<img class="shop-phone" @click="callPhone" src="../../image/icon/icon-home-phone.png" alt=""/>
</view>
<view class="content-wrap">
<text class="shop-text shop-address">
{{ shopInfo.city }} {{ shopInfo.country }} {{ shopInfo.address }}
</text>
</view>
<view class="shop-operation content-wrap">
<text class="shop-text">营业中 09:00 - 13:00,16:00 - 22:00</text>
</view>
</view>
拨打电话的动作 (事件绑定)
callPhone ,并在 methods 去实现:if (isMP()) {
wx.makePhoneCall({
phoneNumber: this.data.shopInfo.phone
})
} else {
api.call({
type: 'tel_prompt',
number: this.data.shopInfo.phone
});
}
}
推荐菜品和栏目 (v-for循环和组件)
一个主标题 加上 一组菜品 这样的结构来循环。其中 一组菜品 再使用循环,渲染出单品。<goods-list-item class="goods-item" :list="item.togc" :title="item.name"></goods-list-item>
</view>
<goods-list-item /> 组件。这个组件来自于自定义组件:import goodsListItem from '../../components/goods-list-item.stml';
intoGoodsDetail 事件来实现跳转到商品详情页。api.openWin({
name: 'goods_detail',
url: '../../pages/goods_detail/goods_detail.stml',
pageParam: {
item
}
})
}
页面头部header
style={{'opacity:'+this.data.opacity+';padding-top:'+safeAreaTop+'px'}}>
<text class="nav-title shop-name">{{ shopInfo.name }}</text>
</view>
view + text 的结构。为了实现滚动处理透明度,为其绑定一个动态的 style 属性。动态改变其透明度 opacity。opacity 的取值依赖于 scroll-view 的滚动高度。 scroll-view 的滚动会触发相关数据的变动,所以为其绑定上一个滚动事件 @scroll="onScroll" 和相关处理逻辑 onScroll 。const y = isMP() ? e.detail.scrollTop : e.detail.y;
let threshold = this.photoRealHeight - y;
if (threshold < 0) {
threshold = 0;
}
this.data.opacity = 1 - threshold / this.photoRealHeight;
api.setStatusBarStyle && api.setStatusBarStyle({
style: this.statusBarStyle
});
}
onScroll 中能够拿到相应的滚动高度,并且计算出透明度的最终结果。同时发现透明度的更改也会伴随着顶部状态栏文本的颜色变化。使用端能力 api.setStatusBarStyle 来进行相应设置。商品详情页 (组件通信、全局数据和事件)

CART-DATA的全局数据中,在页面生命周期函数 apiready中拿到相关数据:let cartList = api.getPrefs({sync: true, key: 'CART-DATA'}); // 获取加购数量
if (cartList) {
cartList = JSON.parse(cartList)
this.data.cartData = cartList[this.data.goods.id];
if (this.data.cartData) {
this.data.count = this.data.cartData.count;
}
}
计数器组件 goods_counter
goods_counter,是一个商品计数器。以后其他页面可能也会使用到,所以将其封装起来。:count="count" 将刚刚获取到的当前商品的加购数量传入。在 goods_counter 内部,点击加减按钮触发 countChange 事件。在事件中向父页面传递:if (this.props.count + change === 0) {
return api.toast({
msg: '不能再减少了\n可在购物车编辑模式下移除',
location: 'middle'
})
}
this.fire('CountChange', {
change,
props: this.props
})
}
onCountChange={this.countChange.bind(this)} 。这里的 this.countChange 是 goods_detail 的函数,在创建组件的时候作为 props 传递到了子组件中, 在子组件中可以直接执行这个函数,或者是使用 fire 的方式“引燃”这个函数。加购动作条 goods_action
goods_action,是一个商品加购动作条。主体是两个按钮,一个加购,一个结算。fire 的方式上抛给一个 addCart的事件到父页面,因为可能不同的页面的加购后续逻辑不太一样,具体实现就交给父级。所以视线还是转回到 goods_detail 的 addCart 的实现。let cartList = api.getPrefs({sync: true, key: 'CART-DATA'}) || '{}'
cartList = JSON.parse(cartList)
cartList[this.data.goods.id] = {
goods: this.data.goods, count: this.data.count
};
api.setPrefs({
key: 'CART-DATA',
value: cartList
});
api.toast({
msg: '成功加入' + this.data.count + '个到购物车', location: 'middle'
})
setTabBarBadge(2, Object.keys(cartList).length);
}
菜单点餐页面

分类和菜品的双向滚动交互
滑动右侧菜品,左侧分类高亮会随其更改。
点击左侧菜品分类,右侧菜品回滚到到对应区域。
scroll-view 触发头部透明度的逻辑。所以同样地为右侧的 scroll-view 绑定上 @scroll="onScroll" 函数。scroll-view 需要滚动到指定位置。使用属性来进行位置绑定: scroll-top={scrollTo} 。此时只需要在左边的分类点击事件 @click="switchCategory(index)" 计算出正确的 scrollTo 即可实现。this.data.categoryIndex = index;
this.data.CD = new Date().getTime() + 500; // 手动切换分类后需要锁定500毫秒 避免右侧scroll-view滚动时带来次生问题
this.data.scrollTo = this.offsetList[index];
}
菜品和加购处理 (跨端特性处理)
@click="openAdd(goods)" 事件,用于打开加购页面。if (isMP()) {
this.data.currentGoods = goods;
wx.hideTabBar();
} else {
api.openFrame({
name: 'goods_add',
url: '../goods_add/goods_add.stml',
pageParam: {goods}
})
}
}
APICloud 的 frame 的概念, 所以新弹出的页面在小程序上,是一个页面内部组件实现的。APP 原生端也是支持的。如果需要进一步提高性能,发挥原生优势,则可以使用原生端的frame 来完成。此时,将目标页面封装在一个自定义组件中,并把当前菜品数据传递进去。frame 页面的获参形式暂时不同。在 goods_add 这个组件中的 installed 生命周期中可以看到如下的兼容片段:this.data.goods = this.props.goods ? this.props.goods : api.pageParam.goods;
goods_action,所以大致逻辑也是获取商品数据和加购数,并实现一下addCart函数。实际上这个页面很类似商品详情页,只是展示UI不太相同。沉浸式状态栏 safe-area
avm.js 中提供一个 safe-area 组件,用于自动处理异形屏的边界问题。<view class="header">
<text class="title">菜单</text>
</view>
</safe-area>
this.data.safeAreaTop = api.safeArea ? api.safeArea.top : 0;
购物车页面 computed 计算和v-if的条件渲染

this.getCartData() 拿到本地存储的购物车所有的数据。let cartData = api.getPrefs({sync: true, key: 'CART-DATA'});
if (cartData) {
cartData = JSON.parse(cartData);
this.data.cartData = cartData;
this.generateCartList();
setTabBarBadge(2, Object.keys(cartData).length);
}
}
generateCartList 逻辑。let cartData = this.data.cartData;
let arr = [];
for (let i in cartData) {
arr.push({checked: true, ...cartData[i]});
}
this.data.cartList = arr;
}
checked 属性。然后再页面部分通过 v-for 来循环当前购物车的数据。<radio-box class="main-cart-radio-box" :checked="item.checked"
onChange={this.radioToggle.bind(this)}
:item="item"></radio-box>
<img class="main-cart-goods-pic" mode="aspectFill" src={{item.goods.thumbnail}} alt=""/>
<view class="main-cart-goods-info">
<text class="main-cart-goods-name">{{ item.goods.name }}</text>
<view class="main-cart-flex-h">
<text class="main-cart-goods-price-signal">¥</text>
<text class="main-cart-goods-price-num">{{ item.goods.curt_price }}</text>
<goods-counter onCountChange={this.countChange.bind(this)}
:count="item.count" :item="item"></goods-counter>
</view>
</view>
</view>
<radio-box/> 自定义组件。这个组件担负的任务很简单,就是使用自定的样式来渲染一个单选框。当然 avm.js 自带的系统组件 radio 也是可以实现的。computed 的使用
const checked = !this.allChecked;
for (let i = 0; i < this.data.cartList.length; i++) {
this.data.cartList[i].checked = checked;
}
}
this.allChecked 则是一个计算属性。在 computed 中能找到它的实现:return !this.cartList.some((item) => { // 也可以使用 every 来修改相反逻辑实现
return !item.checked;
})
}
totalPrice :// 先筛选出选中项
let list = this.data.cartList.filter(item => {
return item.checked;
})
// 再计算总和并且格式化结果
return (list.length ? list.reduce((total, item) => {
return total + item.goods.curt_price * item.count;
}, 0) : 0).toFixed(2);
}
<text class="main-cart-footer-text">合计</text>
<text class="main-cart-footer-price">¥{{ totalPrice }}</text>
</view>
computed 是可以通过一些逻辑计算出需要的结果,并且会暴露给实例本身, 在模板中能够同数据一样绑定。同时能够自动处理所依赖的数据变化,做出实时的更新。v-if 条件渲染
isEdit,用来表示当前页面是否是在处于编辑状态。<text class="main-cart-finnish-text" v-if="isEdit">完成</text>
<view v-else class="main-cart-action">
<img class="main-cart-action-icon" src="../../image/icon/icon-cart-edit.png" alt=""/>
<text class="main-cart-action-text">编辑</text>
</view>
</view>
v-if 来判断渲染。下面的结算、移除按钮也是一样,只不过是在模板中使用了三元表达式来做显示。用户页面

头部用户信息
* 获取用户信息
* @returns {boolean|any}
*/
function getUser() {
let user = api.getPrefs({
sync: true,
key: 'USER'
});
if (user) {
return JSON.parse(user)
}
return false;
}
v-if 条件渲染来展示登录界面。<img class="user-avatar" src={{userInfo.avatarUrl}} alt=""/>
<text class="user-name">{{ userInfo.nickName }}</text>
</view>
<view class="user-info flex flex-h flex-center-v" v-else @click="wxLogin">
<img class="user-avatar" src="../../image/icon/icon-user-avatar.png" alt=""/>
<text class="user-name">使用微信登录</text>
</view>
登录逻辑
wxLogin 方法:if (isMP()) {
this.mpLogin();
} else {
this.doLogin({ssid: getDeviceId()});
}
}
/widget/pages/main_user/main_user.stml 中还展示了一些使用原生模块来调用微信来登录的逻辑。loginSuccess ,可以保存相关用户信息和会话信息,以备以后的使用。同时还需要刷新用户的购物列表。如果在真实项目中其他已经打开的页面也需要监测用户状态变化,可以借助广播事件来处理详细的逻辑。api.setPrefs({
key: 'USER',
value: userInfo
});
this.data.userInfo = userInfo;
this.getOrderList();
}
页面的下拉刷新
scroll-view 的相关事件绑定和实现。enable-back-to-top refresher-enabled
refresher-triggered={{loading}}
@refresherrefresh="onRefresh">
<view v-if="orderList.length">
<order-item :order="order" v-for="order in orderList"
onOrderAction={this.orderAction.bind(this)}></order-item>
</view>
<view class="empty-block" v-else>
<empty-block text="暂无订单哦~" type="order"></empty-block>
</view>
</scroll-view>
@refresherrefresh="onRefresh" 就是在下拉刷新需要触发的逻辑。 refresher-triggered={{loading}} 就是下拉刷新的状态。(用于通知回弹和设置刷新中)。this.data.loading = true; // 设置正在刷新
if (this.data.userInfo) { //有用户信息了才刷新
this.getOrderList();
} else {
setTimeout(_ => {
this.data.loading = false;
api.toast({
msg: '请登录后查看历史订单'
})
}, 1000)
}
}
待付款页面 (表单数据)

<text class="order-note-key">备注</text>
<input class="order-note-input" placeholder="如需备注请输入"
onBlur="onBlur" maxlength="30" id="remark"/>
</view>
onBlur="onBlur" 来动态获取数据。this.data.remark = e.target.value;
}
input以及其他表单组件文档。POST('orders/app_addorder', this.formData).then(data => {
// 打开结果页
api.openWin({
name: 'pay_result',
url: '../pay_result/pay_result.stml'
});
// 通知支付成功 刷新订单页面
api.sendEvent({
name: 'PAY-SUCCESS'
})
// 清空购物车
api.setPrefs({
key: 'CART-DATA',
value: {}
});
setTabBarBadge(2, 0);
})
}
