目录
项目介绍:
黑马健康软件是一款基于全民健康的软件,主要有三个页面组成,分别是欢迎页面,统计记录页面,食物列表页面。
分析:
饮食记录业务层开发
- 本项目主要解决的问题是如何把数据库中的数据写入页面中。
- 在viewmodel中导入已经封装好的StatsInfo.ets,作为当日统计信息的数据模型;将写死的分组信息类GroupInfo.ets改成通用的;完成食物列表的业务逻辑层,从数据库到页面数据的转换过程,需要在ets文件夹下新建service文件夹,再新建RecordService.ets文件;
数据持久化和页面交互
- 将查询的任务放在RecordIndex.ets中,只查询一次,并且该查询任务在build函数加载之前就开始加载:
async aboutToAppear(){
this.records = await RecordService.queryRecordByDate(this.selectedDate)
}
- 供给统计卡片 StatsCard()和 记录列表 RecordList()使用
- 父子组件的值传递采用@Prop装饰器,父子组件单向传值,父组件向子组件传递值,子组件向父组件不传回值。
- 其中,统计卡片 StatsCard()中有统计热量和营养素,统计热量需要从父组件StatsCard传值过来,传递总摄入热量和总消耗热量;统计营养素需要从父组件StatsCard传递值过去,传递 碳水、蛋白质和脂肪的值;记录列表 RecordList()采用和上面的方式一样在页面中渲染。
- 页面跳转设置:当点击食物列表页中的“早餐”、“加餐”、“运动”等标签时,应实现页面跳转到ItemPage;
- 并且判断类型的isFood也由前面的父组件ItemIndex传递过来,父组件通过生命周期函数获取页面跳转传递过来的值时,通过传递过来的点击饮食距记录的类型来更爱isFood的类型
//生命周期钩子,取出页面跳转时传递过来的值
onPageShow(){
//1.获取页面跳转时的参数
let params: any = router.getParams()
//2.获取点击的饮食记录类型
this.type = params.type
//3.更改isFood的类型,不是运动就等于true
this.isFood = this.type.id !== RecordTypeEnum.WORKOUT
}
- 当弹出了panel框后,用户更改好信息,点击“提交”,将数据持久化保存到数据库。
.onClick(()=>{
//点击提交后信息持久化保存到数据库
//1.持久化保存
RecordService.insert(this.type.id,this.item.id,this.amount)
.then(()=>{
//2.新增成功,关闭弹窗
this.showPanel = false
})
})
页面实现效果:
实现了持久化操作以及页面的交互:
当点击食物项时,图标会变绿,且页面初始时,不会变绿
出现的问题:
问题1:
点击一个食物列表组别,所有组别都变色了
解决办法:
通过forEach循环结构获取点击的食物列表组的下标,通过这个下标与当前正在渲染的下标相比较,如果相等就填充为主题色,不等就填充为浅灰色;
//定义状态变量来记录当前的组别的下标
@State currentItemIndex:number = 0
//封装颜色选择
selectColor(itemIndex){
return this.currentItemIndex === itemIndex ? $r('app.color.primary_color'):$r('app.color.gray')
}
build() {
List({space:CommonConstants.SPACE_12}){
ForEach(this.groups,(group:GroupInfo<RecordType,RecordVO>,index: number)=>{
ListItem(){
Column(){
// 1.分组的标题
Row({space:CommonConstants.SPACE_4}){
Image(group.type.icon).width(27)
.fillColor(this.selectColor(index))
Text(group.type.name).fontWeight(CommonConstants.FONT_WEIGHT_700).fontSize(18)
Text(`建议${group.type.min}~${group.type.max}千卡`).grayText()
Blank()
Text(group.calorie.toFixed(0)).fontColor($r('app.color.primary_color')).fontSize(14)
Text('千卡').grayText()
Image($r('app.media.ic_public_add_norm_filled')).width(20).fillColor($r('app.color.primary_color'))
.onClick(()=>{
//TODO 页面跳转
router.pushUrl({
url: "pages/ItemIndex",
params: {type: group.type}//传递类型参数
})
//当点击后,该图标的图像就会发生变化
this.currentItemIndex = index
})
}.width('100%')
.onClick(()=>{
//当点击后,该图标的颜色就会发生变化
this.currentItemIndex = index
})
更改之后:
问题2:
当选择的几项饮食之后,再返回主页面,却发现没有上面的Swiper组件里面没有数据
出错原因:用来查询数据———页面交互渲染的数据来源,生命周期函数只在build函数执行之前进行触发,导致跳转页面选择几项饮食之后,页面不会重新触发组件,也就不会显示数据。
aboutToAppear(){
//在build函数加载前就注册设备监听
this.breakpointSystem.register()
}
修改:想要在返回index时就加载数据,就要在index页面上响应,定义状态变量来判断当前是否加载的这个index页面
@State isPageShow:boolean = false
onPageShow(){
this.isPageShow = true
}
onPageHide(){
this.isPageShow = false
}
之后将这个状态参数传递给RecordIndex({isPageShow:this.isPageShow})
在RecordIndex里面监听当isPageShow==true的变化,之后触发生命周期函数,重新查询数据,渲染页面。
实现效果:
问题3:
但是上面的Swiper并没有发生更新
原因:数据经过再次查询是传递过去了,但是没有进行页面渲染。因为Swiper组件所在的函数StatsCard使用了@Builder构建内部函数进行页面渲染,Builder函数默认情况下只传递的值而不是传递的引用,导致当值发生变更时,页面不会重新渲染。
修改:将builder函数的参数类型变成引用类型,在参数前面统一加上$$。
@Builder statsBuilder($$:{label:string, value:number, tips?:string}){
Column({space:CommonConstants.SPACE_6}){
Text($$.label)
.fontColor($r('app.color.gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
Text($$.value.toFixed(0))
.fontSize(20)
.fontWeight(CommonConstants.FONT_WEIGHT_700)
if($$.tips){
Text($$.tips)
.fontSize(12)
.fontColor($r('app.color.light_gray'))
}
}
}
并且传递参数值的时候就改成传递对象:
this.statsBuilder({label:'饮食摄入',value:this.intake})
同理也将ItemCard中的builder的参数等改成引用类型。
效果展示:
问题4:
尝试自己写如何删除饮食列表中已经添加的食物:
在原来渲染这个页面的基础上,将原来的删除按钮部分改成下面这种形式,最初的尝试是括号里面传递的是这个ForEach数组的下标,结果删不掉;追踪RecordService.deleteById(id:number)事件,发现需要传递的id不能是数组的下标,需要传递的是已经构建的饮食记录的页面数据模型的id,这个id才是真正映射到表的id,才能在数据库中将其删除。
.swipeAction({end:this.deleteButton(item.id)})
删除按钮的构造函数里面改成:
@Builder deleteButton(index:number){
Image($r('app.media.ic_public_delete_filled'))
.fillColor(Color.Red)
.width(25)
.margin(5)
.onClick(()=>{
//点击删除图标之后,执行数据的删除
RecordService.deleteById(index)
.then(()=>{
//删除成功,
console.log('删除成功','testTag')
})
})
}
删除之前:
删除午餐中的全麦面包之后:
阶段项目代码:
饮食记录业务层开发关键代码
import RecordPO from '../common/bean/RecordPO'
import DateUtil from '../common/utils/DateUtil'
import ItemModel from '../model/ItemModel'
import RecordModel from '../model/RecordModel'
import { RecordTypeEnum, RecordTypes } from '../model/RecordTypeModel'
import GroupInfo from '../viewmodel/GroupInfo'
import RecordType from '../viewmodel/RecordType'
import RecordVO from '../viewmodel/RecordVO'
import StatsInfo from '../viewmodel/StatsInfo'
/**
* 食物列表的业务逻辑层,从数据库到页面数据的转换过程
*/
class RecordService {
/**
* 新增功能
* 传递typeId
* itemId
* amount
*/
insert(typeId:number,itemId:number,amount:number){
// 1.获取时间,如果没取到就获取当前的时间
let createTime = (AppStorage.Get('selectedDate') || DateUtil.beginTimeOfDay(new Date()) ) as number
// 2.新增,使用匿名对象
return RecordModel.insert({typeId,itemId,amount,createTime})
}
/**
* 删除操作
*/
deleteById(id:number){
return RecordModel.deleteById(id)
}
/**
* 查询操作,封装一个查询方法,按照日期查询
* 数据查询一遍就可以,查询保存在RecordVO[]中
*/
async queryRecordByDate(date:number): Promise<RecordVO[]>{//TODO 注意这里不能直接返回因为是异步,要加上Promise
// 1.查询数据库的RecordPO
let rps = await RecordModel.listByDate(date)
// 2.将recordPO转换为recordVO
return rps.map(rp =>{
// 2.1 获取PO和VO中共同的
let rv = {id:rp.id, typeId:rp.typeId, amount: rp.amount} as RecordVO
//2.2 查询记录项
rv.recordItem = ItemModel.getById(rp.itemId, rp.typeId !== RecordTypeEnum.WORKOUT)
// 2.3计算热量
rv.calorie = rp.amount*rv.recordItem.calorie
return rv
})
}
/**
* 把RecordVO[]数组中的数据转成StatsInfo
*
*
*/
calculateStatsInfo(records:RecordVO[]):StatsInfo{
// 1.准备结果
let info = new StatsInfo()
// 2.计算统计数据
if(!records || records.length<=0){
return info
}
records.forEach(r=>{
//累加
if(r.typeId === RecordTypeEnum.WORKOUT){
//运动,累加消耗热量
info.expend += r.calorie
}else{
//食物,累加摄入热量
info.intake += r.calorie
info.carbon += r.recordItem.carbon
info.protein += r.recordItem.protein
info.fat += r.recordItem.fat
}
})
// 3.返回
return info
}
/**
* 把RecordVO[]数组中的数据转成GroupInfo,相当于将查到的数据分组
*
*/
calculateGroupInfo(records:RecordVO[]):GroupInfo<RecordType,RecordVO>[]{
// 1.创建空的记录类型分组
let groups = RecordTypes.map(recordType => new GroupInfo(recordType,[]))
if(!records || records.length<=0){
return groups
}
// 2.遍历所有饮食记录,
records.forEach(record => {
// 2.1把每个饮食记录存入对应类型的分组中
groups[record.typeId].items.push(record)
// 2.2计算该组的总热量
groups[record.typeId].calorie += record.calorie
})
return groups
}
}
let recordService = new RecordService()
export default recordService as RecordService
数据持久化和页面交互关键代码:
/**
* 首页开发
*/
import BreakpointType from '../common/bean/BreanpointType'
import BreakpointConstants from '../common/constants/BreakpointConstants'
import { CommonConstants } from '../common/constants/CommonConstants'
import BreakpointSystem from '../common/utils/BreakpointSystem'
import RecordIndex from '../view/record/RecordIndex'
@Entry
@Component
struct Index {
//定义状态变量来记录当前的页面下标
@State currentIndex:number = 0
private breakpointSystem:BreakpointSystem = new BreakpointSystem()
//定义当前的设备并初始化
@StorageProp('currentBreakpoint') currentBreakpoint:string = BreakpointConstants.BREAKPOINT_SM
//
@State isPageShow:boolean = false
onPageShow(){
this.isPageShow = true
}
onPageHide(){
this.isPageShow = false
}
//定义内部构造函数,参数直接传题目,图片,下标
@Builder TabBarBuilder(title:ResourceStr,image:ResourceStr, index:number){
Column({space:CommonConstants.SPACE_8}){
Image(image)
.width(22)
.fillColor(this.selectColor(index))
Text(title)
.fontSize(14)
.fontColor(this.selectColor(index))
}
}
aboutToAppear(){
//在build函数加载前就注册设备监听
this.breakpointSystem.register()
}
aboutToDisappear(){
//在退出应用时取消注册
this.breakpointSystem.unregister()
}
//封装颜色选择
selectColor(index){
return this.currentIndex === index ? $r('app.color.primary_color'):$r('app.color.gray')
}
build() {
//让导航栏位于页面底部
//根据设备选择导航的布局
Tabs({barPosition: BreakpointConstants.BAR_POSITION.getValue(this.currentBreakpoint)}){
TabContent(){
// 饮食记录页面
RecordIndex({isPageShow:this.isPageShow})
}.tabBar(this.TabBarBuilder($r('app.string.tab_record'),$r('app.media.ic_calendar'),0))
TabContent(){
Text('发现页面')
}.tabBar(this.TabBarBuilder($r('app.string.tab_discover'),$r('app.media.discover'),1))
TabContent(){
Text('我的主页')
}.tabBar(this.TabBarBuilder($r('app.string.tab_user'),$r('app.media.ic_user_portrait'),2))
}
.width('100%')
.height('100%')
.vertical(new BreakpointType({
//传参
sm:false,
md:true,
lg:true
}).getValue(this.currentBreakpoint))
.onChange(index => this.currentIndex = index)//也可以直接对TabBarBuilder加点击事件
}
}
import router from '@ohos.router'
import { CommonConstants } from '../common/constants/CommonConstants'
import { RecordTypeEnum, RecordTypes } from '../model/RecordTypeModel'
import RecordService from '../service/RecordService'
import ItemCard from '../view/item/ItemCard'
import ItemList from '../view/item/ItemList'
import ItemPanelHeader from '../view/item/ItemPanelHeader'
import NumberKeyBoard from '../view/item/NumberKeyBoard'
import RecordItem from '../viewmodel/RecordItem'
import RecordType from '../viewmodel/RecordType'
/**
* 食物记录页面
*/
@Entry
@Component
struct ItemIndex {
@State value: string = ''//记录按下的键的内容
@State amount: number = 1 //需要和其他组件互动
@State showPanel:boolean = false
@State item:RecordItem = null //并且将数组传入的数据项记录下来
@State type:RecordType = RecordTypes[0]
@State isFood:boolean = true
onPanelShow(item:RecordItem){
this.value = ''
this.amount = 1
this.item = item
this.showPanel = true
}
//生命周期钩子,取出页面跳转时传递过来的值
onPageShow(){
//1.获取页面跳转时的参数
let params: any = router.getParams()
//2.获取点击的饮食记录类型
this.type = params.type
//3.更改isFood的类型,不是运动就等于true
this.isFood = this.type.id !== RecordTypeEnum.WORKOUT
}
build() {
Column(){
// 1.头部导航栏
this.Header()
// 2. 列表
ItemList({showPanel:this.onPanelShow.bind(this),isFood:this.isFood})
.layoutWeight(1)
// 3.TODO 底部面板
Panel(this.showPanel) {
// 3.1顶部日期
ItemPanelHeader()
// 3.2记录项信息卡片
if(this.item){
ItemCard({amount:this.amount,item:$item})
}
// 3.3数字键盘
NumberKeyBoard({amount:$amount,value:$value})//对象传递值时使用$(link),不用this,
// 3.4按钮
Row({space:6}){
Button('取消')
.width(120)
.backgroundColor($r('app.color.light_gray'))
.type(ButtonType.Normal)
.borderRadius(6)
.onClick(()=>this.showPanel = false)
Button('提交')
.width(120)
.backgroundColor($r('app.color.primary_color'))
.type(ButtonType.Normal)
.borderRadius(6)
.onClick(()=>{
//点击提交后信息持久化保存到数据库
//1.持久化保存,插入是往表中插入
RecordService.insert(this.type.id,this.item.id,this.amount)
.then(()=>{
//2.新增成功,关闭弹窗
this.showPanel = false
})
})
}.margin({top:10})
}.mode(PanelMode.Full)//占屏幕全部
.dragBar(false)//可调高度
.backgroundMask($r('app.color.light_gray')) //背景蒙版的颜色
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
@Builder Header(){
Row(){
Image($r('app.media.ic_public_back'))
.width(25)
.onClick(()=> router.back())
Blank()
Text(this.type.name).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
}.width('94%')
.height(36)
}
}
import DateUtil from '../../common/utils/DateUtil';
import RecordService from '../../service/RecordService';
import RecordVO from '../../viewmodel/RecordVO';
import RecordList from './RecordList';
import SearchHeader from './SearchHeader'
import StatsCard from './StatsCard';
/**
* 首页饮食记录页面
*/
@Component
export default struct RecordIndex{
//获取到儿子的日期
@StorageProp('selectedDate') selectedDate:number = DateUtil.beginTimeOfDay(new Date())
//另外监控日期的变更,当日期发生变化的时候再次查询一遍
@Watch('aboutToAppear')
@Provide records:RecordVO[] = []
@Prop @Watch('handlePageShow') isPageShow:boolean
handlePageShow(){
if(this.isPageShow){
this.aboutToAppear()
}
}
async aboutToAppear(){
this.records = await RecordService.queryRecordByDate(this.selectedDate)
}
build(){
Column(){
// 1.头部搜索栏
SearchHeader()
// 2.统计卡片
StatsCard();
// 3.记录列表
RecordList()
.layoutWeight(1)//占据剩余所有高度
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.index_page_background'))
}
}
import router from '@ohos.router';
import { CommonConstants } from '../../common/constants/CommonConstants'
import { RecordTypes } from '../../model/RecordTypeModel';
import RecordService from '../../service/RecordService';
import GroupInfo from '../../viewmodel/GroupInfo';
import RecordType from '../../viewmodel/RecordType';
import RecordVO from '../../viewmodel/RecordVO';
/**
* 记录列表
* (类似于代办列表)
*/
@Extend(Text) function grayText(){
.fontSize(14)
.fontColor($r('app.color.light_gray'))
}
@Component
export default struct RecordList {
@Consume @Watch('handleRecordsChange') records: RecordVO[]
@State groups:GroupInfo<RecordType,RecordVO>[] = []
//定义状态变量来记录当前的组别的下标
@State currentItemIndex:number = -1
handleRecordsChange(){
this.groups = RecordService.calculateGroupInfo(this.records)
}
//封装颜色选择
selectColor(itemIndex){
return this.currentItemIndex === itemIndex ? $r('app.color.primary_color'):$r('app.color.gray')
}
build() {
List({space:CommonConstants.SPACE_12}){
ForEach(this.groups,(group:GroupInfo<RecordType,RecordVO>,index: number)=>{
ListItem(){
Column(){
// 1.分组的标题
Row({space:CommonConstants.SPACE_4}){
Image(group.type.icon).width(27)
.fillColor(this.selectColor(index))
Text(group.type.name).fontWeight(CommonConstants.FONT_WEIGHT_700).fontSize(18)
Text(`建议${group.type.min}~${group.type.max}千卡`).grayText()
Blank()
Text(group.calorie.toFixed(0)).fontColor($r('app.color.primary_color')).fontSize(14)
Text('千卡').grayText()
Image($r('app.media.ic_public_add_norm_filled')).width(20).fillColor($r('app.color.primary_color'))
.onClick(()=>{
//TODO 页面跳转
router.pushUrl({
url: "pages/ItemIndex",
params: {type: group.type}//传递类型参数
})
//当点击后,该图标的图像就会发生变化
this.currentItemIndex = index
})
}.width('100%')
.onClick(()=>{
//当点击后,该图标的颜色就会发生变化
this.currentItemIndex = index
})
// 2.组内记录列表
List(){
ForEach(group.items,(item:RecordVO,index:number)=>{
ListItem(){
Row({space:CommonConstants.SPACE_6}){
Image(item.recordItem.image).width(50)
Column(){
Text(item.recordItem.name).fontWeight(CommonConstants.FONT_WEIGHT_500)
Text(`${item.amount}${item.recordItem.unit}`).grayText()
}
Blank()
Text(`${item.calorie.toFixed(0)}千卡`).grayText()
}.width('100%')
.padding(CommonConstants.SPACE_6)
}.swipeAction({end:this.deleteButton(item.id)})// TODO 学习
})
}
}
.backgroundColor(Color.White)
.width('100%')
.borderRadius(CommonConstants.DEFAULT_18)
.padding(CommonConstants.SPACE_12)
}
})
}.width(CommonConstants.THOUSANDTH_940)
.margin({top:10})
}
@Builder deleteButton(index:number){
Image($r('app.media.ic_public_delete_filled'))
.fillColor(Color.Red)
.width(25)
.margin(5)
.onClick(()=>{
//点击删除图标之后,执行数据的删除
RecordService.deleteById(index)
.then(()=>{
//删除成功,
console.log('删除成功','testTag')
})
})
}
}
参考黑马课堂老师的讲解,欢迎大家的批评和指正。