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);
})
}