根据黑马程序员(https://www.bilibili.com/video/BV1Sa4y1Z7B1?p=36&vd_source=756db0e287d884b2979ab5b54a59c305)编写
目录
本次实现饮食记录业务层开发和实现数据持久化和网页交互
最终实现效果描述:进入记录页面,如下方图(1)所示,在点击绿色添加按钮之后弹出添加界面图(2)图(3),然后再次点击添加按钮弹出图(4),此时在下方会弹出数字键盘,点击数字之后上方会实时更新数字键盘数据,点击提交以后图(1)会实时更新数据如图(5)和图(6)所示。
实现效果图:
记录页面和添加
![]() | ![]() | ![]() |
图1 | 图2 | 图3 |
实现实时交互
![]() | ![]() | ![]() |
图4 | 图5 | 图6 |
饮食记录业务层开发
实现需求
1.为了实现图(5)和图(6)的实时交互我们需要一个数据模型(StatsInfo)将其数据对应起来。
2.为了实现图1中饮食记录的分组展示,我们需要将数据库中查询到的数据需要先分组(GroupInfo)处理之后再渲染页面,而改动GroupInfo之后之前使用其的页面(ItemList)也需要跟随改变。
3.将数据库中查出的数据转化成需要的数据,需要封装业务逻辑(RecordService)。
实现代码
StatsInfo.ets:
export default class StatsInfo{
//当日摄入卡路里总量
intake: number = 0
//当日运动消耗能量
expend: number = 0
//当日摄入碳水总量
carbon: number = 0
//当日摄入蛋白总量
protein: number = 0
//当日摄入脂肪总量
fat: number = 0
constructor(intake: number = 0, expend: number = 0, carbon: number = 0, protein: number = 0, fat: number = 0) {
this.intake = intake
this.expend = expend
this.carbon = carbon
this.protein = protein
this.fat = fat
}
}
GroupInfo.ets:
export default class GroupInfo<TYPE, ELEMENT> {
//分组类型
type: TYPE
//组内数据集合
items: ELEMENT[]
//组内记录的总热量
calorie: number = 0
constructor(type: TYPE, items: ELEMENT[]) {
this.type = type
this.items = items
}
}
ItemList.ets
将ForEach循环中的group: GroupInfo改为group: GroupInfo<ItemCategory,RecordItem>
RecordService.ets
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 {
/**
* 新增饮食记录
* @param typeId 记录类型id
* @param itemId 记录项id
* @param amount 记录项数量(食物量、运动时长)
* @returns 新增数量
*/
insert(typeId: number, itemId: number, amount: number): Promise<number>{
// 1.获取时间
let createTime = (AppStorage.Get('selectedDate') || DateUtil.beginTimeOfDay(new Date())) as number
// 2.新增
return RecordModel.insert({typeId, itemId, amount, createTime})
}
/**
* 根据id删除饮食记录
* @param id 记录id
* @returns 删除条数
*/
deleteById(id: number): Promise<number>{
return RecordModel.deleteById(id)
}
/**
* 根据日期查询饮食记录列表
* @param date 要查询的日期
* @returns 记录列表
*/
async queryRecordByDate(date: number): Promise<RecordVO[]>{
// 1.查询数据库的RecordPO
let rps = await RecordModel.listByDate(date)
// 2.将RecordPO转为RecordVO
return rps.map(rp => {
// 2.1.获取po中的基本属性
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
})
}
/**
* 根据记录列表信息统计出热量、营养素信息
* @param records 饮食记录列表
* @returns 热量、营养素信息
*/
calculateStatsInfo(records: RecordVO[]): StatsInfo{
// 1.准备结果
let info = new StatsInfo()
if(!records || records.length <= 0){
return info
}
// 2.计算统计数据
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
}
/**
* 将记录列表按照记录类型分组
* @param records 记录列表
* @returns 分组记录信息
*/
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
实现数据持久化和页面交互
实现需求:
1.为了实现饮食记录界面交互的持久化保存统计卡片(StatsCard)和记录列表(RecordList)都需要用到饮食记录列表,所以为了方便在其共同父组件(RecordIndex)中设置@provide供子组件使用。
2.子组件再进行渲染数据,在渲染时需要在热量统计(CalorieStats)和营养素统计(NutrientStats)还有纪录列表(RecordList)三张卡片中渲染,所以其数据要改为状态变量(@Prop)。
3.完成点击饮食界面的标题完成跳转页面(ItemIndex)功能,然后在其内完成持久化保存功能
4.给主页面(Index)增加状态变数通知RecordIndex,渲染页面显示状态
5.因为@Builder函数不会触发视图的重新渲染,需要给其传递引用值(运用$$:{}),再用引用值改变其数据。所以需要更改(ItemCard.ets、CalorieStats.ets、NutrientStats.ets)中的数据
实现代码
StatsCard.ets:
import BreakpointConstants from '../../common/constants/BreakpointConstants'
import { CommonConstants } from '../../common/constants/CommonConstants'
import DateUtil from '../../common/utils/DateUtil'
import RecordService from '../../service/RecordService'
import RecordVO from '../../viewmodel/RecordVO'
import StatsInfo from '../../viewmodel/StatsInfo'
import CalorieStats from './CalorieState'
import DatePickDialog from './DatePickDialog'
import NutrientStats from './NutrientState'
@Component
export default struct StatsCard {
@StorageProp('selectedDate') selectedDate: number = DateUtil.beginTimeOfDay(new Date())
@StorageProp('currentBreakpoint') currentBreakpoint:string = BreakpointConstants.BREAKPOINT_SM
//监控变更
@Consume @Watch('handleRecordsChange') records:RecordVO[]
@State info:StatsInfo = new StatsInfo()
handleRecordsChange(){
this.info=RecordService.calculateStatsInfo(this.records)
}
controller: CustomDialogController = new CustomDialogController({
builder: DatePickDialog({selectedDate: new Date(this.selectedDate)})
})
build() {
Column(){
// 1.日期信息
Row(){
Text(DateUtil.formatDate(this.selectedDate))
.fontColor($r('app.color.secondary_color'))
Image($r('app.media.ic_public_spinner'))
.width(20)
.fillColor($r('app.color.secondary_color'))
}
.padding(CommonConstants.SPACE_8)
.onClick(() => this.controller.open())
//2.统计信息
Swiper(){
// 2.1.热量统计
CalorieStats({intake:this.info.intake,expend:this.info.expend})
// 2.2.营养素统计
NutrientStats({carbon:this.info.carbon,protein:this.info.protein,fat:this.info.fat})
}
}
.width(CommonConstants.THOUSANDTH_940)
.backgroundColor($r('app.color.stats_title_bgc'))
.borderRadius(CommonConstants.DEFAULT_18)
}
}
RecordList.ets:
import router from '@ohos.router'
import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordService from '../../service/RecordService'
import GroupInfo from '../../viewmodel/GroupInfo'
import RecordType from '../../viewmodel/RecordType'
import RecordVO from '../../viewmodel/RecordVO'
//对Text组件提出,方便以后调用,减少代码冗余
@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> [] = []
handleRecordsChange() {
//分组统计饮食记录
this.groups = RecordService.calculateGroupInfo(this.records)
}
build() {
List({ space: CommonConstants.SPACE_10 }) {
//用ForEach循环,用以循环渲染页面,此处1,2,3,4是暂时先渲染一样的,后面方便进行更改
ForEach(this.groups, (groups: GroupInfo<RecordType, RecordVO>) => {
ListItem() {
Column({ space: CommonConstants.SPACE_8 }) {
// 1.分组的标题
Row({ space: CommonConstants.SPACE_4 }) {
Image(groups.type.icon).width(24)
Text(groups.type.name).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
Text(`建议${groups.type.min}~${groups.type.max}千卡`).grayText()
//布局
Blank()
Text(groups.calorie.toFixed(0)).fontSize(14).fontColor($r('app.color.primary_color'))
Text('千卡').grayText()
Image($r('app.media.ic_public_add_norm_filled'))
.width(20)
.fillColor($r('app.color.primary_color'))
}
.width('100%')
.onClick(()=>{
router.pushUrl({
url:'pages/ItemIndex',
params:{type:groups.type}
})
})
// 2.组内记录列表
List() {
//此处ForEach同上
ForEach(groups.items, (item:RecordVO) => {
ListItem() {
Row({ space: CommonConstants.SPACE_6 }) {
Image(item.recordItem.image).width(50)
Column({ space: CommonConstants.SPACE_4 }) {
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.bind(this) })
})
}
.width('100%')
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(CommonConstants.DEFAULT_18)
.padding(CommonConstants.SPACE_12)
}
})
}
.width(CommonConstants.THOUSANDTH_940)
.height('100%')
.margin({ top: 10 })
}
@Builder deleteButton() {
Image($r('app.media.ic_public_delete_filled'))
.width(20)
.fillColor(Color.Red)
.margin(5)
}
}
RecordIndex.ets:
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{
@Prop @Watch('handlePageShow') isPageShow :boolean
@Provide records:RecordVO[]=[]
//监控日期变更
@Watch('aboutToAppear')
@StorageProp('selectedDate') selectedDate: number = DateUtil.beginTimeOfDay(new Date())
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'))
}
}
CalorieStats.ets:
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct CalorieStats {
@Prop intake: number
@Prop expend: number
recommend: number = CommonConstants.RECOMMEND_CALORIE
remainCalorie(){
return this.recommend - this.intake + this.expend
}
//抽取函数方便调用
@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'))
}
}
}
build() {
Row({space: CommonConstants.SPACE_6}){
// 1.饮食摄入
this.StatsBuilder({label:'饮食摄入', value:this.intake})
// 2.还可以吃
Stack(){
// 2.1.进度条
Progress({
value: this.intake,
total: this.recommend,
type: ProgressType.Ring
})
.width(120)
.style({strokeWidth: CommonConstants.DEFAULT_10})
.color($r('app.color.primary_color'))
// 2.2.统计数据
this.StatsBuilder({label:'还可以吃', value:this.remainCalorie(),tips:`推荐${this.recommend}`})
}
// 3.运动消耗
this.StatsBuilder({label:'运动消耗', value:this.expend})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
//设置边距
.padding({top: 30, bottom: 35})
}
}
NutrientStats.ets:
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct NutrientStats {
@Prop carbon: number
@Prop protein: number
@Prop fat: number
recommendCarbon: number = CommonConstants.RECOMMEND_CARBON
recommendProtein: number = CommonConstants.RECOMMEND_PROTEIN
recommendFat: number = CommonConstants.RECOMMEND_FAT
//抽取组件方便调用
@Builder StatsBuilder($$: {
label: string,
value: number,
recommend: number,
color: ResourceStr
}) {
Column({ space: CommonConstants.SPACE_6 }) {
Stack() {
Progress({
value: $$.value,
total: $$.recommend,
type: ProgressType.Ring
})
.width(95)
.style({ strokeWidth: CommonConstants.DEFAULT_6 })
.color($$.color)
Column({ space: CommonConstants.SPACE_6 }) {
Text('摄入推荐')
.fontSize(12)
.fontColor($r('app.color.gray'))
Text(`${$$.value.toFixed(0)}/${$$.recommend.toFixed(0)}`)
.fontSize(18)
.fontWeight(CommonConstants.FONT_WEIGHT_600)
}
}
Text('(克)')
.fontSize(12)
.fontColor($r('app.color.light_gray'))
}
}
build() {
Row({ space: CommonConstants.SPACE_6 }) {
this.StatsBuilder({
label: '碳水化合物',
value: this.carbon,
recommend: this.recommendCarbon,
color: $r('app.color.carbon_color')
})
this.StatsBuilder({
label: '蛋白质',
value: this.protein,
recommend: this.recommendProtein,
color: $r('app.color.protein_color')
})
this.StatsBuilder({
label: '脂肪',
value: this.fat,
recommend: this.recommendFat,
color: $r('app.color.fat_color')
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding({ top: 30, bottom: 35 })
}
}
ItemIndex.ets:
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 showPanel:boolean = false
@State amount:number=1
@State value: string = ''
//item用于接受点击的数据
@State item:RecordItem=null
@State type:RecordType=RecordTypes[0]
@State isFood:boolean = true
onPanelShow(item:RecordItem){
//控制面板展示
this.amount=1
this.value=''
this.item=item
this.showPanel=true
}
onPageShow(){
//1.获取跳转时的参数
let params:any=router.getParams()
//2.获取点击的饮食记录类型
this.type=params.type
//3.判断是否是食物
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.底部面板
Panel(this.showPanel){
//3.1.顶部日期
ItemPanelHeader()
//3.2记录项卡片
if (this.item){
ItemCard({amount:this.amount,item:$item})
}
//3.3数字键盘
NumberKeyboard({amount:$amount,value:$value})
//3.4按钮
Row({space: CommonConstants.SPACE_6}){
Button('取消')
.width(120)
.backgroundColor($r('app.color.light_gray'))
.type(ButtonType.Normal)
.borderRadius(6)
.onClick(() => this.showPanel = false)
Button('提交')
.width(120)
.type(ButtonType.Normal)
.borderRadius(6)
.backgroundColor($r('app.color.primary_color'))
.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)
}
.height('100%')
}
//头部
@Builder Header(){
Row(){
Image($r('app.media.ic_public_back'))
.width(30)
.onClick(()=>router.back())
Blank()
Text(this.type.name).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_600)
}.width(CommonConstants.THOUSANDTH_940)
.height(32)
}
}
Index.ets:
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
@State isPageShow: boolean = false
private breakpointSystem:BreakpointSystem=new BreakpointSystem()
@StorageProp ('currentBreakpoint') currentBreakpoint :string =BreakpointConstants.BREAKPOINT_SM
//页面显示
onPageShow(){
this.isPageShow = true
}
//页面隐藏
onPageHide(){
this.isPageShow = false
}
aboutToAppear(){
this.breakpointSystem.register()
}
aboutToDisappear(){
this.breakpointSystem.unregister()
}
@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))
}
}
//颜色选择
selectColor(index: number) {
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(false)
.onChange(index => this.currentIndex = index)
.vertical(new BreakpointType({
sm: false,
md: true,
lg: true
}).getValue(this.currentBreakpoint))
}
}
ItemCard.ets
import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordItem from '../../viewmodel/RecordItem'
@Component
export default struct ItemCard {
@Prop amount:number
//接收数据
@Link item:RecordItem
build() {
Column({space:CommonConstants.SPACE_8}){
//1.图片信息
Image(this.item.image)
.width(150)
//2.名称信息
Row(){
Text(this.item.name).fontWeight(CommonConstants.FONT_WEIGHT_700)
}
.backgroundColor($r('app.color.lightest_primary_color'))
.padding({top:5,bottom:5,left:15,right:12})
//水平线
Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)
//3.营养素信息
Row({space:CommonConstants.SPACE_8}){
this.NutrientInfo({label:'热量(千卡)',value:this.item.calorie})
//判断ID是否小于10000,因为运动没有碳水等信息且我们将运动ID设置为10000以上
if (this.item.id<10000){
this.NutrientInfo({label:'碳水(克)',value:this.item.carbon})
this.NutrientInfo({label:'蛋白质',value:this.item.protein})
this.NutrientInfo({label:'脂肪(克)',value:this.item.fat})
}
}
Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)
//4.数量信息
Row(){
Column({space:CommonConstants.SPACE_4}) {
Text(this.amount.toFixed(1))
.fontSize(50).fontColor($r('app.color.primary_color'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
Divider().color($r('app.color.primary_color'))
}
Text(this.item.unit)
.fontColor($r('app.color.light_gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
} .width(150)
}
}
@Builder NutrientInfo($$:{label:string,value:number}){
Column({space:CommonConstants.SPACE_8}){
Text($$.label).fontSize(14).fontColor($r('app.color.light_gray'))
Text(($$.value * this.amount).toFixed(1)).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
}
}
}
小结
本次开发任务中,我们成功实现了饮食记录业务层的关键功能,不仅涵盖了数据持久化,还深入到了页面交互的开发。通过精心设计和细致编程,我们顺利完成了记录页面的开发工作,并且确保了其具备实时交互和高效渲染的能力。
在业务层方面,我们针对饮食记录的需求,建立了清晰的数据模型和业务流程。通过业务逻辑的处理,用户能够方便地记录他们的饮食信息,包括食物的种类、数量、时间等关键数据。同时,我们也为食物和运动信息提供了增加、修改和删除的功能,使得用户能够根据自己的实际情况灵活调整记录内容。
在数据持久化方面,我们采用了高效稳定的数据存储方案,确保用户的数据能够安全、可靠地保存在服务器上。通过数据持久化,我们为用户提供了持久化的数据服务,即使在软件关闭或重启后,用户的饮食记录也能得以保留,方便用户随时查看和管理。
在页面交互方面,我们注重用户体验和交互设计。通过精心设计的用户界面和流畅的用户操作,我们为用户提供了便捷、直观的操作体验。用户可以在记录页面上轻松添加食物或运动信息,查看历史记录,以及进行各种查询和筛选操作。同时,我们也充分利用了现代前端技术,实现了实时交互和高效渲染,使得页面在加载和更新数据时能够迅速响应,极大地提升了用户体验。
最终,我们成功地将这些功能集成到了黑马健康APP中,为用户提供了一个全面、便捷的饮食记录工具。