基于OpenHarmony开发的健康生活应用(ArkTS)

2023-11-18

健康生活应用(ArkTS)

介绍

本篇Codelab介绍了如何实现一个简单的健康生活应用,主要功能包括:

  1. 用户可以创建最多6个健康生活任务(早起,喝水,吃苹果,每日微笑,刷牙,早睡),并设置任务目标、是否开启提醒、提醒时间、每周任务频率。

  2. 用户可以在主页面对设置的健康生活任务进行打卡,其中早起、每日微笑、刷牙和早睡只需打卡一次即可完成任务,喝水、吃苹果需要根据任务目标量多次打卡完成。

  3. 主页可显示当天的健康生活任务完成进度,当天所有任务都打卡完成后,进度为100%,并且用户的连续打卡天数加一。

  4. 当用户连续打卡天数达到3、7、30、50、73、99天时,可以获得相应的成就。成就在获得时会以动画形式弹出,并可以在“成就”页面查看。

  5. 用户可以查看以前的健康生活任务完成情况。

相关概念

  • @AppStorage:应用程序中的单例对象,为应用程序范围内的可变状态属性提供中央存储。

  • @Observed 和 @ObjectLink:@Observed适用于类,表示类中的数据变化由UI页面管理;@ObjectLink应用于被@Observed装饰类的对象。

  • @Consume 和 @Provide:@Provide作为数据提供者,可以更新子节点的数据,触发页面渲染。@Consume检测到@Provide数据更新后,会发起当前视图的重新渲染。

  • Flex:一个功能强大的容器组件,支持横向布局,竖向布局,子组件均分和流式换行布局。

  • List:List是很常用的滚动类容器组件之一,它按照水平或者竖直方向线性排列子组件, List的子组件必须是ListItem,它的宽度默认充满List的宽度。

  • TimePicker:TimePicker是选择时间的滑动选择器组件,默认以00:00至23:59的时间区创建滑动选择器。

  • Toggle:组件提供勾选框样式、状态按钮样式及开关样式。

  • 关系型数据库(Relational Database,RDB):一种基于关系模型来管理数据的数据库。

环境搭建

软件要求

  • DevEco Studio版本:DevEco Studio 3.1 Beta2及以上版本。
  • OpenHarmony SDK版本:API version 9及以上版本。

硬件要求

环境搭建

完成本篇Codelab我们首先要完成开发环境的搭建,本示例以RK3568开发板为例,参照以下步骤进行:

  1. 获取OpenHarmony系统版本:标准系统解决方案(二进制)。以3.2 Release版本为例:

  2. 搭建烧录环境。

    1. 完成DevEco Device Tool的安装
    2. 完成RK3568开发板的烧录
  3. 搭建开发环境。

    1. 开始前请参考工具准备,完成DevEco Studio的安装和开发环境配置。
    2. 开发环境配置完成后,请参考使用工程向导创建工程(模板选择“Empty Ability”),选择JS或者eTS语言开发。
    3. 工程创建完成后,选择使用真机进行调测

代码结构解读

本篇Codelab只对核心代码进行讲解,完整代码可以直接从gitee获取。

├──entry/src/main/ets                 // 代码区
│  ├──common
│  │  ├──bean
│  │  │  ├──ColumnInfo.ets            // 数据表信息接口
│  │  │  ├──DayInfo.ets               // 每日信息接口
│  │  │  ├──GlobalInfo.ets            // 全局信息接口
│  │  │  └──TaskInfo.ets              // 任务信息接口
│  │  ├──constants
│  │  │  └──CommonConstants.ets       // 公共常量
│  │  ├──database
│  │  │  ├──rdb                       // 数据库封装类
│  │  │  │  ├──RdbHelper.ets
│  │  │  │  ├──RdbHelperImp.ets
│  │  │  │  ├──RdbUtils.ets
│  │  │  │  └──TableHelper.ets
│  │  │  └──tables                    // 数据表
│  │  │     ├──DayInfoApi.ets
│  │  │     ├──GlobalInfoApi.ets
│  │  │     └──TaskInfoApi.ets
│  │  └──utils
│  │     ├──Logger.ets                // 日志类
│  │     ├──BroadCast.ets             // 通知
│  │     ├──HealthDataSrcMgr.ets      // 数据管理单例
│  │     └──Utils.ets                 // 工具类
│  ├──entryability
│  │  └──EntryAbility.ts              // 程序入口类
│  ├──model                           // model
│  │  ├──AchieveModel.ets
│  │  ├──DatabaseModel.ets            // 数据库model
│  │  ├──Mine.ets
│  │  ├──NavItemModel.ets             // 菜单栏model
│  │  ├──RdbColumnModel.ets  
│  │  ├──TaskInitList.ets
│  │  └──WeekCalendarModel.ets        // 日历model
│  ├──pages
│  │  ├──AdvertisingPage.ets          // 广告页
│  │  ├──MainPage.ets                 // 应用主页面
│  │  ├──MinePage.ets                 // 我的页面
│  │  ├──SplashPage.ets               // 启动页
│  │  ├──TaskEditPage.ets             // 任务编辑页面
│  │  └──TaskListPage.ets             // 任务列表页面
│  ├──view
│  │  ├──dialog                       // 弹窗组件
│  │  │  ├──AchievementDialog.ets     // 成就弹窗
│  │  │  ├──CustomDialogView.ets      // 自定义弹窗
│  │  │  ├──TaskDetailDialog.ets      // 打卡弹窗
│  │  │  ├──TaskDialogView.ets
│  │  │  ├──TaskSettingDialog.ets     // 任务编辑相关弹窗
│  │  │  └──UserPrivacyDialog.ets
│  │  ├──home                         // 主页面相关组件
│  │  │  ├──AddBtnComponent.ets       // 添加任务按钮组件
│  │  │  ├──HomeTopComponent.ets      // 首页顶部组件
│  │  │  ├──TaskCardComponent.ets     // 任务item组件件
│  │  │  └──WeekCalendarComponent.ets // 日历组件
│  │  ├──task                         // 任务相关组件
│  │  │  ├──TaskDetailComponent.ets   // 任务编辑详情组件
│  │  │  ├──TaskEditListItem.ets      // 任务编辑行内容
│  │  │  └──TaskListComponent.ets     // 任务列表组件
│  │  ├──AchievementComponent.ets     // 成就页面
│  │  ├──BadgeCardComponent.ets       // 勋章卡片组件
│  │  ├──BadgePanelComponent.ets      // 勋章面板组件
│  │  ├──HealthTextComponent.ets      // 自定义text组件
│  │  ├──HomeComponent.ets            // 首页页面
│  │  ├──ListInfo.ets                 // 用户信息列表
│  │  ├──TitleBarComponent.ets        // 成就标题组件
│  │  └──UserBaseInfo.ets             // 用户基本信息
│  └──viewmodel                       // viewmodel
│     ├──AchievementViewModel.ets     // 成就相关模块
│     ├──CalendarViewModel.ets        // 日历相关模块
│     ├──HomeViewModel.ets            // 首页相关模块
│     └──TaskViewModel.ets            // 任务设置相关模块
└──entry/src/main/resources           // 资源文件夹

应用架构分析

本应用的基本架构如下图所示,数据库为其他服务提供基础的用户数据,主要业务包括:用户可以查看和编辑自己的健康任务并进行打卡、查看成就。UI层提供了承载上述业务的UI界面。

应用主页面

本节将介绍如何给应用添加一个启动页,设计应用的主界面,以及首页的界面开发和数据展示。

启动页

首先我们需要给应用添加一个启动页,启动页里我们需要用到一个定时器来实现启动页展示固定时间后跳转应用主页的功能

打开应用时会进入此页面,具体实现逻辑是:

通过修改/entry/src/main/ets/entryability里的loadContent路径可以改变应用的入口文件,我们需要把入口文件改为我们写的SplashPage启动页面。

windowStage.loadContent("pages/SplashPage", (err, data) => {    
  if (err.code) {...}    
  Logger.info('windowStage','Succeeded in loading the content. Data: ' + JSON.stringify(data))
});

在SplashPage启动页的文件里通过首选项来实现是否需要弹“权限管理”的弹窗,如果需要弹窗的情况下,用户点击同意权限后通过首选项对用户的操作做持久化保存。相关代码如下:

// SplashIndex.ets
import data_preferences from '@ohos.data.preferences';

onConfirm() {
  let preferences = data_preferences.getPreferences(globalThis.abilityContext, H_STORE);
  preferences.then((res) => {
    res.put(IS_PRIVACY, true).then(() => {
      res.flush();
      Logger.info('TaskInfoTable',‘isPrivacy is put success’);
    }).catch((err) => {
      Logger.info('TaskInfoTable', ‘isPrivacy put failed. Cause:’ + err);
    });
  })
  this.jumpAdPage();
}

exitApp() {
  globalThis.abilityContext.terminateSelf();
}

jumpAdPage() {
  setTimeout(() => {
    router.replace({ url: 'pages/AdvertisingPage' });
  }, commonConst.LAUNCHER_DELAY_TIME);
}

aboutToAppear() {
  let preferences = data_preferences.getPreferences(globalThis.abilityContext, H_STORE);
  preferences.then((res) => {
    res.get(IS_PRIVACY, false).then((isPrivate) => {
      if (isPrivate === true) {
        this.jumpAdPage();
      } else {
        this.dialogController.open();
      }
    });
  });
}

APP功能入口

我们需要给APP添加底部菜单栏,用于切换不同的应用模块,由于各个模块之间属于完全独立的情况,并且不需要每次切换都进行界面的刷新,所以我们用到了Tabs,TabContent组件。

本应用一共有首页(HomeIndex),成就(AchievementIndex)和我的(MineIndex)三个模块,分别对应Tabs组件的三个子组件TabContent。

Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {  
  TabContent() {    
    HomeIndex({ editedTaskInfo: $editedTaskInfo, editedTaskID: $editedTaskID })  
  }
  .tabBar(this.TabBuilder(TabId.HOME)) 
  TabContent() {    
    AchievementIndex()  
  }
  .tabBar(this.TabBuilder(TabId.ACHIEVEMENT))  
  TabContent() {    
    MineIndex()  
  }
  .tabBar(this.TabBuilder(TabId.MINE))
}

首页

首页包含了任务信息的所有入口,包含任务列表的展示,任务的编辑和新增,上下滚动的过程中顶部导航栏的渐变,日期的切换以及随着日期切换界面任务列表跟着同步的功能,:

具体代码实现我们将在下边分模块进行说明:

  1. 导航栏背景渐变

    Scroll滚动的过程中,在它的onScroll方法里我们通过计算它Y轴的偏移量来改变当前界面的@State修饰的naviAlpha变量值,进而改变顶部标题的背景色,代码实现如下:

    // 视图滚动的过程中处理导航栏的透明度
    onScrollAction() {  
      let yOffset = this.scroller.currentOffset().yOffset;  
      if (yOffset > commonConst.DEFAULT_56) {    
        this.naviAlpha = 1;  
      } else {    
        this.naviAlpha = yOffset / commonConst.DEFAULT_56;
      }
    }
  2. 日历组件

    日历组件主要用到的是一个横向滑动的Scroll组件。

    build() {    
      Row() {      
        Column() {        
          Row() {...}             
          Scroll(this.scroller) {          
            Row() {            
              ForEach(this.homeStore.dateArr, (item: WeekDateModel, index: number) => {              
                Column() {                
                  Text(item.weekTitle)                  
                    .fontColor(sameDate(item.date, this.homeStore.showDate) ? $r('app.color.blueColor') : $r('app.color.titleColor'))                                 
                  Divider()
                    .color(sameDate(item.date, this.homeStore.showDate) ? $r('app.color.blueColor') : $r('app.color.white'))                
                  Image(this.getProgressImg(item))                               
                } 
                .onClick(() => WeekCalendarMethods.calenderItemClickAction.call(this, item, index))            
              })          
             }       
            }               
            .onScrollEnd(() => WeekCalendarMethods.onScrollEndAction.call(this))        
            .onScrollEdge(() => WeekCalendarMethods.onScrollEdgeAction.call(this))      
          }...       
        }...    
      }
    }

    手动滑动页面时,我们通过在onScrollEnd方法里计算Scroll的偏移量来实现分页的效果,同时Scroll有提供scrollPage()方法可供我们点击左右按钮的时候来进行页面切换。

    import display from '@ohos.display';
    ...
    // scroll滚动停止时通过判断偏移量进行分页处理
    function onScrollEndAction() {  
      let scrollWidth = DEFAULT_SCROLL_WIDTH;  
    
      // 获取屏幕的宽度  
      display.getDefaultDisplay((err, data) => {    
        if (!err) {      
          scrollWidth = data.width * DEFAULT_SCROLL_PERCENT;    
        } else {...}  
      })  
    
      // 区分是否是手动滑动,点击左右箭头按钮导致Scroll滑动时不作处理,不然会引起死循环
      if (!this.isPageScroll) {    
        let page = Math.round(this.scroller.currentOffset().xOffset / scrollWidth);   
        page = this.isLoadMore ? page + 1 : page;    
        if (this.scroller.currentOffset().xOffset % scrollWidth != 0 || this.isLoadMore) {      
          let xOffset = page * scrollWidth;   
       
          // 滑动到指定位置
          this.scroller.scrollTo({ xOffset, yOffset: 0 });      
          this.isLoadMore = false;    
        }  
    
        // 处理当前界面展示的数据  
        ...
      }  
      this.isPageScroll = false;
    }

    我们在需要在Scroll滑动到左边边缘的时候去请求更多的历史数据以便Scroll能一直滑动,通过Scroll的onScrollEdge方法我们可以判断它是否已滑到边缘位置。

    function onScrollEdgeAction(side: Edge) {
      if (side == Edge.Top && !this.isPageScroll) {
        Logger.info('HomeIndex', 'onScrollEdge: currentPage ' + this.currentPage);
        if ((this.currentPage + LAZY_DATA_PAGE) * WEEK_DAY_NUM >= this.homeStore.dateArr.length) {
          Logger.info('HomeIndex', 'onScrollEdge: load more data');
          let date: Date = new Date(this.homeStore.showDate);
          date.setDate(date.getDate() - WEEK_DAY_NUM);
          this.homeStore.getPreWeekData(date);
          this.isLoadMore = true;
        }
      }
    }

    homeStore主要是请求数据库的数据并对数据进行处理进而渲染到界面上。

    public getPreWeekData(date: Date) {
      let [initArr, dateArr] = getPreviousWeek(date);
        
      // 请求数据库数据
      this.dayInfoApi.queryList(dateArr, (res: DayInfo[]) => {
    
        // 数据处理
        ...  
        this.dateArr = initArr.concat(...this.dateArr);
      })
    }

    同时我们还需要知道怎么根据当天的日期计算出本周内的所有日期数据。

    export function getPreviousWeek(showDate: Date): [Array<WeekDateModel>, Array<string>] {
      let arr: Array<WeekDateModel> = [];
      let strArr: Array<string> = []; 
    
      // 由于date的getDay()方法返回的是0-6代表周日到周六,我们界面上展示的周一-周日为一周,所以这里要将getDay()数据偏移一天
      let currentDay = showDate.getDay() - 1;
      if (showDate.getDay() == 0) {
        currentDay = 6;
      }
    
      // 将日期设置为当前周第一天的数据(周一)
      showDate.setDate(showDate.getDate() - currentDay);
      for (let index = WEEK_DAY_NUM; index > 0; index--) {
        let tempDate = new Date(showDate);
        tempDate.setDate(showDate.getDate() - index);
        let dateStr = dateToStr(tempDate);
        strArr.push(dateStr);
        arr.push(new WeekDateModel(WEEK_TITLES[tempDate.getDay()], dateStr, tempDate));
      }
      return [arr, strArr];
    }
  3. 悬浮按钮

    由于首页右下角有一个悬浮按钮,所以首页整体我们用了一个Stack组件,将右下角的悬浮按钮和顶部的title放在滚动组件层的上边。

    build() {  
      Stack() {    
        Scroll(this.scroller) {      
          Column() {     
            ...   // 上部界面组件
            Column() {          
              ForEach(TaskList, (item: TaskInfo) => {            
                TaskCard(...,
                  clickAction: (isClick) => this.taskItemAction(item, isClick)) 
              }, item => item.date + item.taskID)} 
            }   
          }
        }
        .onScroll(this.onScrollAction.bind(this))
         // 悬浮按钮
         AddBtn()   
         // 顶部title 
         Row() {       
           Text($r('app.string.MainAbility_label'))
         }
         .position({ x: 0, y: 0 })    
         .backgroundColor(`rgba(${WHITE_COLOR_0X},${WHITE_COLOR_0X},${WHITE_COLOR_0X},${this.naviAlpha})`)    
         CustomDialogView()  
      }  
      .allSize() 
      .backgroundColor($r('app.color.primaryBgColor'))
    }
  4. 界面跳转及传参

    首页任务列表长按时需要跳转到对应的任务编辑界面,同时点击悬浮按钮时需要跳转到任务列表页面。

    页面跳转需要在头部引入router。

    import router from '@ohos.router';

    任务item的点击事件代码如下

    taskItemAction(item: TaskInfo, isClick: boolean) {  
      if (isClick) {  
        // 点击任务打卡    
        let callback: CustomDialogCallback = { confirmCallback: this.onConfirm.bind(this), cancelCallback: null };    
        this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);  
      } else {   
        // 长按编辑任务    
        const editTask: ITaskItem = {...};   
        router.push({ url: 'pages/task/TaskEdit', params: { params: JSON.stringify(editTask) } });  
      }
    }

任务创建与编辑

本节将介绍如何创建和编辑健康生活任务。

功能概述

用户点击悬浮按钮进入任务列表页,点击任务列表可进入对应任务编辑的页面中,对任务进行详细的设置,之后点击完成按钮编辑任务后将返回首页。:

任务列表与编辑任务

这里主要为大家介绍添加任务列表页的实现、任务编辑的实现、以及具体弹窗设置和编辑完成功能的逻辑实现。

任务列表页

任务列表页由包括上部分的标题、返回按钮以及正中间的任务列表组成。:

使用Navigation以及List组件构成元素,ForEach遍历生成具体列表。这里是Navigation构成页面导航:

// TaskListPage.ets
Navigation() {
  Column() {
    // 页面中间的列表
    TaskList() 
  }
  .width(THOUSANDTH_1000)
  .justifyContent(FlexAlign.Center)
}
.size({ width: THOUSANDTH_1000, height: THOUSANDTH_1000 })
.title(ADD_TASK_TITLE)

列表右侧有一个判断是否开启的文字标识,点击某个列表需要跳转到对应的任务编辑页里。具体的列表实现如下:

// TaskListComponent.ets
List({ space: commonConst.LIST_ITEM_SPACE }) {
  ForEach(this.taskList, (item) => {
    ListItem() {
      Row() {
        Row() {
          Image(item?.icon)
          Text(item?.taskName)
            ...
        }
        .width(commonConst.THOUSANDTH_500)

        // 状态显示
        if (item?.isOpen) {
          Text($r('app.string.already_open'))
        }
        Image($rawfile('task/right_grey.png'))
          .width(commonConst.DEFAULT_8)
          .height(commonConst.DEFAULT_16)
      }
      ...
    }
    ...

    // 路由跳转到任务编辑页
    .onClick(() => {
      router.push({
        url: 'pages/task/TaskEdit',
        params: {
          params: formatParams(item),
        }
      })
    })
    ...
  })
}

任务编辑页

任务编辑页由上方的“编辑任务”标题以及返回按钮,主体内容的List配置项和下方的完成按钮组成:

由于每一个配置项功能不相同,且逻辑复杂,故将其拆分为五个独立的组件。

这是任务编辑页面,由Navigation和一个自定义组件TaskDetail构成:

// TaskEditPage.ets
Navigation() {
  Column() {
    TaskDetail()
  }
  .width(THOUSANDTH_1000)
  .height(THOUSANDTH_1000)
}
.size({ width: THOUSANDTH_1000, height: THOUSANDTH_1000 })
.title(EDIT_TASK_TITLE)

自定义组件由List以及其子组件ListItem构成:

// TaskDetailComponent.ets
List({ space: commonConst.LIST_ITEM_SPACE }) {
  ListItem() {
    TaskChooseItem()
  }
  ...
  ListItem() {
    TargetSetItem()
  }
  ...
  ListItem() {
    OpenRemindItem()
  }
  ...
  ListItem() {
    RemindTimeItem()
  }
  ...
  ListItem() {
    FrequencyItem()
  }
  ...
}
.width(commonConst.THOUSANDTH_940)
.margin({ bottom: commonConst.THOUSANDTH_400 })

其中做了禁用判断,需要任务打开才可以点击编辑:

.enabled(this.settingParams?.isOpen)

一些特殊情况的禁用,如每日微笑、每日刷牙的目标设置不可编辑:

.enabled(
  this.settingParams?.isOpen
  && (this.settingParams?.taskID !== taskType.smile)
  && (this.settingParams?.taskID !== taskType.brushTeeth)
)

提醒时间在开启提醒打开之后才可以编辑:

.enabled(this.settingParams?.isOpen && this.settingParams?.isAlarm)

设置完成之后,点击完成按钮,会向数据库更新现在进行改变的状态信息,并执行之后的逻辑判断:

// TaskDetailComponent.ets
addTask({

  // 相关参数
  ...
})
.then(res => {

  // 成功的状态,成功后跳转首页
  router.back({
    url: 'pages/MainPage',
    params: {
      editTask: this.isOpen === this.settingParams.isOpen ? {} :
      formatParams({
        ...this.settingParams,
        isDone: true,
        finValue: this.settingParams?.targetValue,
      }),
    }
  })
  Logger.info('addTaskFinshed', resasstring);
})
.catch(res => {

  // 失败的状态,失败后弹出提示,并打印错误日志
  prompt.showToast({
    message: commonConst.SETTING_FINISH_FAILED_MESSAGE
  })
  Logger.error('addTaskFailed', res as string);
})

任务编辑弹窗

弹窗由封装的自定义组件CustomDialogView注册事件,并在点击对应的编辑项时进行触发,从而打开弹窗。

CustomDialogView引入实例并注册事件:

// TaskSettingDialog.ets
targetSettingDialog = new CustomDialogController({
  builder: TargetSettingDialog(),
  autoCancel: true,
  alignment: DialogAlignment.Bottom,
  offset: { dx: ZERO, dy: MINUS_20 }
})
...

// 注册事件
this.broadCast.on(
  BroadCastType.SHOW_TARGETSETTING_DIALOG,
  function () {
    self.targetSettingDialog.open();
  })

点击对应的编辑项进行触发:

.onClick(() => {
  this.broadCast.emit(BroadCastType.SHOW_TARGETSETTING_DIALOG);
})

自定义弹窗的实现:

任务目标设置的弹窗较为特殊,故单独拿出来说明。

因为任务目标设置有三种类型:

  • 早睡早起的时间
  • 喝水的量度
  • 吃苹果的个数

故根据任务的ID进行区分,将同一弹窗复用:

// TaskSettingDialog.ets
if ([taskType.getup, taskType.sleepEarly].indexOf(this.settingParams?.taskID) 
    > commonConst.HAS_NO_INDEX) {
  TimePicker({
    selected: commonConst.DEFAULT_SELECTED_TIME,
  })
  ...
} else {
  TextPicker({ range: this.settingParams?.taskID === taskType.drinkWater 
    ? this.drinkRange 
    : this.appleRange })
  ...
}

弹窗确认的时候将修改好的值赋予该项设置,如不符合规则,将弹出提示:

// TaskSettingDialog.ets

// 校验规则
compareTime(startTime: string, endTime: string) {
  if (returnTimeStamp(this.currentTime) < returnTimeStamp(startTime) 
      || returnTimeStamp(this.currentTime) > returnTimeStamp(endTime)) {

    // 弹出提示
    prompt.showToast({
      message: commonConst.CHOOSE_TIME_OUT_RANGE
    })
    return false;
  }
  return true;
}

// 设置修改项
if (this.settingParams?.taskID === taskType.sleepEarly) {
  if (!this.compareTime(commonConst.SLEEP_EARLY_TIME, commonConst.SLEEP_LATE_TIME)) {
    return;
  }
  this.settingParams.targetValue = this.currentTime;
  return;
}
this.settingParams.targetValue = this.currentValue;

其余弹窗实现基本类似,这里不再赘述。

后台代理提醒

健康生活App中提供了任务提醒功能,我们用系统提供的后台代理提醒reminderAgent接口完成相关的开发。

 说明: 后台代理提醒接口需要在module.json5中申请ohos.permission.PUBLISH_AGENT_REMINDER权限,代码如下:

"requestPermissions": [
  {
   "name": "ohos.permission.PUBLISH_AGENT_REMINDER"
  }
]

后台代理提醒entry\src\main\ets\service\ReminderAgent.ts文件中提供了发布提醒任务、查询提醒任务、删除提醒任务三个接口供任务编辑页面调用,跟随任务提醒的开关增加、更改、删除相关后台代理提醒,代码如下:

import reminderAgentManager from'@ohos.reminderAgentManager';
import Notification from '@ohos.notification';
import { Logger } from '../utils/log/Logger';

// publishReminder
function publishReminder(params) {
    if(!params) return;
    let timer = fetchData(params);
    reminderAgentManager.publishReminder(timer).then((reminderId) => {
        Logger.info("reminderAgent", `promise, reminderId: ${reminderId}`);
    }).catch((err) => {
        Logger.error("reminderAgent", `publishReminder err: ${err}`);
    })
}

// cancelReminder
function cancelReminder(reminderId) {
    if(!reminderId) return;
    reminderAgentManager.cancelReminder(reminderId).then((data) => {
        Logger.info("reminderAgent", `cancelReminder promise: ${data}`);
    }).catch((err) => {
        Logger.error("reminderAgent", `cancelReminder err: ${err}`);
    })
}

// fetchData
function fetchData(params): reminderAgentManager.ReminderRequestAlarm {
    return {
        reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,
        hour: params.hour || 0,
        minute: params.minute || 0,
        daysOfWeek: params.daysOfWeek || [],
        wantAgent: {
            pkgName: "com.example.exercisehealth",
            abilityName: "MainAbility"
        },
        title: params.title || '',
        content: params.content || '',
        notificationId: params.notificationId || -1,
        slotType: Notification.SlotType.SOCIAL_COMMUNICATION
    }
}

// hasNotificationId
async function hasNotificationId(params: number) {
    if(!params) return;
    await reminderAgentManager.getValidReminders().then((reminders) => {
        if (!reminders.length) {
            return false;
        }
        let notificationIdList = [];
        for (let i = 0; i < reminders.length; i++) {
            notificationIdList.push(reminders[i].notificationId)
        }
        const flag = notificationIdList.indexOf(params);

        return flag === -1 ? false : true;
    })
}

const reminder = {
    publishReminder,
    cancelReminder,
    hasNotificationId
}

export default reminder

实现打卡功能

首页会展示当前用户已经开启的任务列表,每条任务会显示对应的任务名称以及任务目标、当前任务完成情况。用户只可对当天任务进行打卡操作,用户可以根据需要对任务列表中相应的任务进行点击打卡。如果任务列表中的每个任务都在当天完成则为连续打卡一天,连续打卡多天会获得成就徽章。:

任务列表

使用List组件展示用户当前已经开启的任务,每条任务对应一个TaskCard组件,clickAction包装了点击和长按事件,用户点击任务卡时会触发弹起打卡弹窗,从而进行打卡操作;长按任务卡时会跳转至任务编辑界面,对相应的任务进行编辑处理。代码如下:

// 任务列表
List({ space: commonConstants.LIST_SPACE }) {
  ForEach(this.homeStore.getTaskListOfDay(), (item: TaskInfo) => {
    ListItem() {
      TaskCard({
        taskInfoStr: JSON.stringify(item),
        clickAction: (isClick) => this.taskItemAction(item, isClick)
      })
    }
  }, item => JSON.stringify(item))
}
...
CustomDialogView() // 自定义弹窗中间件

自定义弹窗中间件CustomDialogView

在组件CustomDialogView的aboutToAppear生命周期中注册SHOW_TASK_DETAIL_DIALOG的事件回调方法 ,当通过emit触发此事件时即触发回调方法执行。代码如下:

@Component
export struct CustomDialogView {
  @Consume broadCast: BroadCast;
  @Provide currentTask: TaskInfo = TaskItem;
  @Provide dialogCallBack: CustomDialogCallback = { confirmCallback: null, cancelCallback: null };
  // 任务打卡弹窗
  taskDialog = new CustomDialogController({
    builder: TaskDetailDialog(),
    autoCancel: true,
    customStyle: true
  })
  aboutToAppear() {
    Logger.debug('CustomDialogView', 'aboutToAppear')
    let self = this;
    // 任务打卡弹窗  注册 “SHOW_TASK_DETAIL_DIALOG” 事件回调
    this.broadCast.on(BroadCastType.SHOW_TASK_DETAIL_DIALOG, function (currentTask: TaskInfo, dialogCallBack: CustomDialogCallback) {
      Logger.debug('CustomDialogView', 'SHOW_TASK_DETAIL_DIALOG')
      self.currentTask = currentTask; // 接收当前任务参数 以Provide Consume 方式向子组件透传
      self.dialogCallBack = dialogCallBack;  // 接收当前任务确认打卡回调 以Provide Consume 方式向子组件透传
      self.taskDialog.open(); // 弹出打卡弹窗
    })
  }
  ...
}

点击任务卡片

点击任务卡片会emit触发 “SHOW_TASK_DETAIL_DIALOG” 事件,同时把当前任务,以及确认打卡回调方法传递下去。代码如下:

// 任务卡片事件
taskItemAction(item: TaskInfo, isClick: boolean) {
  ...
  if (isClick) {
    // 点击任务打卡
    let callback: CustomDialogCallback = { confirmCallback: this.onConfirm.bind(this), cancelCallback: null };
    // 触发弹出打卡弹窗事件  并透传当前任务参数(item) 以及确认打卡回调
    this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);
  } else {
    // 长按编辑任务
    ...
  }
}
// 确认打卡
onConfirm(task) {
  this.homeStore.taskClock(task).then((res) => {
    // 打卡成功后 根据连续打卡情况判断是否 弹出成就勋章  以及成就勋章级别
    if (res.showAchievement) {
      // 触发弹出成就勋章SHOW_ACHIEVEMENT_DIALOG 事件, 并透传勋章类型级别
      this.broadCast.emit(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, [res.achievementLevel]);
    }
  })
}

打卡弹窗组件TaskDetailDialog

打卡弹窗组件根据当前任务的ID获取任务名称以及弹窗背景图片资源。

打卡弹窗组件由两个小组件构成,代码如下:

Column() {
  // 展示任务的基本信息
  TaskBaseInfo({
    taskName: TaskMapById[this.currentTask?.taskID].taskName, // 根据当前任务ID获取任务名称
  });
  // 打卡功能组件 (任务打卡、关闭弹窗)
  TaskClock({
    confirm: () => {
      this.dialogCallBack.confirmCallback(this.currentTask); // 任务打卡确认回调执行 
      this.controller.close();
    },
    cancel: () => {
      this.controller.close();
    },
    showButton: this.showButton
  })
}
...

TaskBaseInfo组件代码如下:

@Component
struct TaskBaseInfo {
  taskName: string | Resource;
  build() {
    Column({ space: DEFAULT_8 }) {
      Text(this.taskName)
        ...
    }
    ...
  }
}

TaskClock组件代码如下:

@Component
struct TaskClock {
  confirm: () => void;
  cancel: () => void;
  showButton: boolean = false;
  build() {
    Column({ space: DEFAULT_12 }) {
      Button() {
        Text($r('app.string.clock_in')) // 打卡
          ...
      }
      ...
      .onClick(() => {
         this.confirm();
      })
      .visibility(!this.showButton ? Visibility.None : Visibility.Visible)
      Text($r('app.string.got_it')) // 知道了
        ...
        .onClick(() => {
           this.cancel();
        })
    }
  }
}

打卡接口调用

public async taskClock(taskInfo: TaskInfo) {
  let taskItem = await this.updateTask(taskInfo);
  // 更新任务失败 
  if (!taskItem) {
    return Promise.resolve({
      achievementLevel: 0,
      showAchievement: false
    });
  }
  // 更新当前时间的任务列表
  this.selectedDayInfo.taskList = this.selectedDayInfo.taskList.map((item) => item.taskID == taskItem?.taskID ? taskItem : item);
  let achievementLevel;
  if(taskItem.isDone) {
    // 更新每日任务完成情况数据
    let dayInfo = await this.updateDayInfo();
    ...
    if(dayInfo) {
      // 当日任务完成数量等于总任务数量时 累计连续打卡一天
      if (dayInfo.finTaskNum === dayInfo?.targetTaskNum) {
        // 更新成就勋章数据 判断是否弹出获得勋章弹出及勋章类型
        achievementLevel = await this.updateAchievement(this.selectedDayInfo.dayInfo);
      }
    }
  }
  ...
  return Promise.resolve({
    achievementLevel: achievementLevel,
    showAchievement: ACHIEVEMENT_LEVEL_LIST.includes(achievementLevel)
  });
}
// 更新当天任务列表
updateTask(task: TaskInfo): Promise<TaskInfo> {
  return new Promise((resolve, reject) => {
    let {taskID, targetValue, finValue} = task;
    let updateTask = Object.assign({}, task);
    let step = TaskMapById[taskID].step; // 任务步长
    if (step === 0) { // 任务步长为0 打卡一次即完成该任务
      updateTask.isDone = true; // 打卡一次即完成该任务
      updateTask.finValue = targetValue;
    } else {
      let value = Number(finValue) + step; // 任务步长非0 打卡一次 步长与上次打卡进度累加
      updateTask.isDone = value >= Number(targetValue); // 判断任务是否完成
      updateTask.finValue = updateTask.isDone ? targetValue : `${value}`;
    }
    TaskInfoTableApi.updateDataByDate(updateTask, (res) => { // 更新数据库
      if (!res) {
        Logger.error('taskClock-updateTask', res);
        reject(res);
      }
      resolve(updateTask);
    })
  })
}

获取成就

本节将介绍成就页面。

功能概述

成就页面展示用户可以获取的所有勋章,当用户满足一定的条件时,将点亮本页面对应的勋章,没有得到的成就勋章处于熄灭状态。共有六种勋章,当用户连续完成任务打卡3天、7天、30天、50天、73天、99天时,可以获得对应的 "连续xx天达成" 勋章。

页面布局与 ArkTS 代码对应关系

标题部分TitleBar是一个横向容器Row里包含一个子组件Text。

// TitleBarComponent.ets
@Component
export struct TitleBar {
  build() {
    Row() {
      Text($r('app.string.achievement')) 
      ... // 省略属性设置  
    }.width(commonConst.FULL_WIDTH)
  }
}

每个勋章卡片BadgeCard是一个竖向容器Column一个图片子组件Image和一个文字子组件Text。

// BadegeCardComponent.ets
@Component
export struct BadgeCard {
  @Prop content: string;
  @Prop imgSrc: string;
  build() { 
    Column({space: commonConst.DEFAULT_18}) {
      Image($rawfile(this.imgSrc))
      ... // 省略属性设置
      Text($r('app.string.task_achievement_level', Number(this.content))) 
      ... // 省略属性设置
    }
  }
}

整体的勋章面板使用Flex一个组件即可以实现均分和换行的功能。

// BadgePanelComponent.ets
@Component
export struct BadgePanel {
  @StorageProp(ACHIEVEMENT_LEVEL_KEY) successiveDays: number = 0;
  aboutToAppear() {
    getAchievementLevel();
  } 
  build() {
    Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { 
      ForEach(getBadgeCards(this.successiveDays), (item) => { 
        BadgeCard({ content: item[0], imgSrc: item[1]})
      })
    }.width(commonConst.FULL_WIDTH)
  }
}

获取数据

进入界面第一次获取数据在aboutToAppear()声明周期中从数据库GlobalInfo表中获取存储的勋章数据, 通过@StorageProp装饰器刷新界面,其他的地方只要通过AppStorage更新勋章数据即可。

// BadgePanelComponent.ets
aboutToAppear() {
  getAchievementLevel();
} 

// AchieveModel.ets
export function getAchievementLevel() {
  let globalInfoTable: GlobalInfoTable = new GlobalInfoTable();
  globalInfoTable.query((res) => {
    ... // 省略数据验证
    if (achievements.length > 0) {
      AppStorage.Set<Number>(ACHIEVEMENT_LEVEL_KEY, Number(achievements[achievements.length - 1]));
    }
  }
}

// BadgePanelComponent.ets
@StorageProp(ACHIEVEMENT_LEVEL_KEY) successiveDays: number = 0;

ForEach(getBadgeCards(this.successiveDays), (item) => { 
  BadgeCard({ content: item[0], imgSrc: item[1]})
})

// AchievementViewModel.ets
export function getBadgeCardsItems(successiveDays: number):[string, string][] {
  let badgeMileStones = ACHIEVEMENT_LEVEL_LIST;
  let cardItems:[string, string][] = [];
  for (let i = 0; i < badgeMileStones.length; i++) { 
    ... // 省略数据拼装细节
    cardItems.push(oneItem);
  }    
  return cardItems;
}

搭建关系数据库

本节将介绍如何调用关系型数据库接口在本地搭建数据库,并读写相应的用户数据。

创建数据库

要使用关系型数据库存储用户数据,首先要进行数据库的创建,并提供基本的增、删、查、改接口。

导入关系型数据库模块:

import dataRdb from '@ohos.data.relationalStore';

关系型数据库提供以下两个基本功能:

获取RdbStore

首先要获取一个RdbStore来操作关系型数据库,代码如下:

// RdbHelperImp.ets
getRdb(context: any): Promise<RdbHelper> {
  Logger.info(`initRdb getRdb success`);
  this.storeConfig = {
    // 配置数据库文件名、安全级别
    name: this.mDatabaseName, securityLevel: dataRdb.SecurityLevel.S1
  };
  return new Promise<RdbHelper>((success, error) => {
    dataRdb.getRdbStore(context, this.storeConfig).then(dbStore => {
      this.rdbStore = dbStore;  // 获取RdbStore
      success(this);
    }).catch(err => {
      Logger.error(`initRdb err : ${JSON.stringify(err)}`);
      error(err);
    })
  })
}

封装增、删、改、查接口

关系型数据库接口提供的增、删、改、查操作均有callback和Promise两种异步回调方式,本Codelab使用了callback异步回调,其中插入数据使用了insert()接口,实现代码如下:

// RdbHelperImp.ets
insert(tableName: string, values: dataRdb.ValuesBucket | Array<dataRdb.ValuesBucket>): Promise<number> {
  return new Promise<any>((success, error) => {
    Logger.info(`insert tableName : ${tableName}, values : ${JSON.stringify(values)}`);
    ...
    if (Array.isArray(values)) {
      // 如果插入一组数据,则批量插入
      Logger.info(`insert values isArray = ${values.length}`);
      this.rdbStore.beginTransaction();
      this.saveArray(tableName, values).then(data => {
        Logger.info(`insert success, data : ${JSON.stringify(data)}`);
        success(data);
        this.rdbStore.commit();
      }).catch(err => {
        Logger.error(`insert failed, err : ${err}`);
        error(err);
        this.rdbStore.commit();
      })
    } else {
      this.rdbStore.insert(tableName, values).then(data => {
        // 调用insert()接口插入数据
        Logger.info(`insert success id : ${data}`);
        success(data);
        this.rdbStore.commit();
      }).catch(err => {
        Logger.error(`insert failed, err : ${err}`);
        error(err);
        this.rdbStore.commit();
      })
    }
  })
}

删除数据使用了delete()接口,实现代码如下:

// RdbHelperImp.ets
delete(rdbPredicates: dataRdb.RdbPredicates): Promise<number> {
  Logger.info(`delete rdbPredicates : ${JSON.stringify(rdbPredicates)}`);
  return this.rdbStore.delete(rdbPredicates);
}

更新数据使用了update()接口,实现代码如下:

// RdbHelperImp.ets
update(values: dataRdb.ValuesBucket, rdbPredicates: dataRdb.RdbPredicates): Promise<number> {
  return this.rdbStore.update(values, rdbPredicates);
}

查找数据使用了query()接口,实现代码如下:

// RdbHelperImp.ets
query(rdbPredicates: dataRdb.RdbPredicates, columns?: Array<string>): Promise<dataRdb.ResultSet> {
  Logger.info(`query rdbPredicates : ${JSON.stringify(rdbPredicates)}`);
  return this.rdbStore.query(rdbPredicates, columns);
}

数据表定义

根据健康生活APP的使用场景和业务逻辑,定义了三个数据对象,并使用三张数据表来存储,分别是健康任务信息表、每日信息表和全局信息表。

健康任务信息表

目前健康生活应用提供了6个基本的健康任务,分别是早起、喝水、吃苹果、每日微笑、睡前刷牙和早睡。用户可以选择开启或关闭某个任务,开启的任务可以选择是否开启提醒,在指定的时间段内提醒用户进行打卡。任务也可以选择开启的频率,如只在周一到周五开启等。需要记录每项任务的目标值和实际完成值,在用户打卡后判断任务是否已经完成,并记录在数据库中。因此,需要创建一张存储每天的健康任务信息的表,表头如下:

每日信息表

在主页面,用户可以查看当天健康任务的完成进度,需要创建一张表记录当天开启的任务个数和已经完成的任务个数,表头如下:

全局信息表

用户连续多日打卡完成所有创建的任务可以获得相应的成就,因此,需要有一张表记录连续打卡天数和已达成的成就项。另外,考虑应用多日未打开的情况,需要记录应用第一次打开的日期和最后一次打开的日期以向数据库回填数据,表头如下:

创建数据表

根据上文设计的表结构,创建对应的数据表,实现对相应数据的读写操作。

健康任务信息数据表

在获取RdbStore后,需要使用executeSql接口执行SQL语句来创建相应的表结构和初始化数据,SQL语句如下:

CREATE TABLE IF NOT EXISTS taskInfo(
  id INTEGER PRIMARY KEY AUTOINCREMENT, 
  date TEXT NOT NULL, 
  taskID INTEGER, 
  targetValue TEXT NOT NULL, 
  isAlarm BOOLEAN, 
  startTime TEXT NOT NULL, 
  endTime TEXT NOT NULL, 
  frequency TEXT NOT NULL, 
  isDone BOOLEAN, 
  finValue TEXT NOT NULL, 
  isOpen BOOLEAN
)

健康任务信息数据表需要提供插入数据的接口,以在用户当天第一次打开应用时创建当天的健康任务信息,实现代码如下:

// TaskInfoApi.ets
insertData(taskInfo: TaskInfo, callback) {
  // 根据输入数据创建待插入的数据行
  const valueBucket = generateBucket(taskInfo);
  RdbUtils.insert('taskInfo', valueBucket).then(result => {
    callback(result);
  });
  Logger.info('TaskInfoTable', `Insert taskInfo {${taskInfo.date}:${taskInfo.taskID}} finished.`);
}

其中generateBucket()代码如下:

// TaskInfoApi.ets
function generateBucket(taskInfo: TaskInfo) {
  let obj = {};
  TASK_INFO.columns.forEach((item) => {
    obj[item] = taskInfo[item];
  });
  return obj;
}

// CommonConstants.ets
export const TASK_INFO = {
  tableName: 'taskInfo',
  columns: [
    'id', 
    'date', 
    'taskID', 
    'targetValue', 
    'isAlarm', 
    'startTime', 
    'endTime', 
    'frequency', 
    'isDone', 
    'finValue', 
    'isOpen'
  ]
}

用户开启和关闭任务,改变任务的目标值、提醒时间、频率等,用户打卡后修改任务的实际完成值都是通过更新数据接口来实现的,代码如下:

// TaskInfoApi.ets
updateDataByDate(taskInfo: TaskInfo, callback) {
  const valueBucket = generateBucket(taskInfo);
  let predicates = new dataRdb.RdbPredicates(TASK_INFO.tableName);

  // 根据date和taskID匹配要更新的数据行
  predicates.equalTo('date', taskInfo.date).and().equalTo('taskID', taskInfo.taskID);
  RdbUtils.update(valueBucket, predicates).then(result => {
    callback(result);
  });
  Logger.info('TaskInfoTable', `Update data {${taskInfo.date}:${taskInfo.taskID}} finished.`);
}

用户可以查看当天和以前某日的健康任务信息,需要提供查找数据接口,实现代码如下:

// TaskInfoApi.ets
query(date: string, isOpen: boolean = true, callback) {
  let predicates = new dataRdb.RdbPredicates(TASK_INFO.tableName);
  predicates.equalTo('date', date);

  // 如果isOpen为true,则只查找开启的任务
  if (isOpen) {
    predicates.equalTo('isOpen', true);
  }
  predicates.orderByAsc('taskID');  // 查找结果按taskID排序
  RdbUtils.query(predicates, function(resultSet) {
    let count = resultSet.rowCount;
    
    // 查找结果为空则返回空数组,否则返回查找结果数组
    if (count === 0 || typeof count === 'string') {
      Logger.info('TaskInfoTable', `${date} query no results!`);
      callback([]);
    } else {
      resultSet.goToFirstRow();
      const result = [];
      for (let i = 0; i < count; i++) {
        let tmp = new TaskInfo(0, '', 0, '', false, '', '', '', false, '');
        tmp.id = resultSet.getDouble(resultSet.getColumnIndex('id'));
        ...  // 省略赋值代码
        result[i] = tmp;
        resultSet.goToNextRow();
      }
      callback(result);
    }
    return;
  });
}

每日信息数据表

创建每日信息数据表的SQL语句如下:

CREATE TABLE IF NOT EXISTS dayInfo(
  date TEXT NOT NULL PRIMARY KEY, 
  targetTaskNum INTEGER, 
  finTaskNum INTEGER
)

在当天第一次打开应用时需要初始化每日信息数据,页面需要根据用户编辑任务和打卡的情况来更新当天目标任务个数和完成任务个数,所以需要提供插入数据和更新数据的接口,写法与上一条中相应接口类似,不再赘述。

页面需要查找对应日期的目标任务个数和完成任务个数用以在页面显示任务进度,因此需要查找数据的接口。且页面在打开时需要显示当周每天任务的完成情况,因此需要允许一次调用查找一周的每日任务信息。实现代码如下:

// DayInfoApi.ets
queryList(dates: string[], callback) {
  let predicates = new dataRdb.RdbPredicates(DAY_INFO.tableName);
  predicates.in('date', dates);  // 匹配日期数组内的所有日期
  RdbUtils.query(predicates, function(resultSet) {
    let count = resultSet.rowCount;
    if (count === 0) {
      Logger.info('DayInfoTable','query no results.');
      callback([]);
    } else {
      resultSet.goToFirstRow();
      let result = [];
      for (let i = 0; i < count; i++) {
        let tmp = new DayInfo('', 0, 0);
        tmp.date = resultSet.getString(resultSet.getColumnIndex('date'));
        ...  // 省略赋值代码
        result[i] = tmp;
        resultSet.goToNextRow();
      }
      callback(result);
    }
    return;
  });
}

全局信息数据表

创建全局信息数据表的SQL语句如下:

CREATE TABLE IF NOT EXISTS globalInfo(
  id INTEGER PRIMARY KEY, 
  firstDate TEXT NOT NULL, 
  lastDate TEXT NOT NULL, 
  checkInDays INTEGER, achievements TEXT NOT NULL
)

全局信息数据表同样需要提供插入数据、更新数据和查找数据的接口,写法与本节前两条中相应接口类似,不再赘述。

数据库初始化

应用首次打开时,数据库中没有数据,要做数据库的初始化,写入一组空数据。另外,如果用户连续几天没有打开APP,再次打开时需要将数据回写至数据库。因此需要实现一个数据库接口,在应用打开时调用,进行上述操作。代码如下:

// DatabaseModel.ets
query(date: string, callback) {
  let result = [];
  let self = this;
  GlobalInfoApi.query(function(globalResult) {
    // 如果查不到全局信息,就写入全局信息
    if (globalResult.length === 0) {
      ...  // 插入健康任务信息、每日信息和全局信息
      callback(result, dayInfo);
    } else {
      // 如果查到全局信息,那么查询当日任务信息
      let newGlobalInfo = globalResult;
      let preDate = globalResult.lastDate;
      newGlobalInfo.lastDate = date;
      ...  // 更新全局信息

      // 查询当日任务信息
      TaskInfoApi.query(date, false, (taskResult) => {
        let dayInfo = new DayInfo(date, 0, 0);
        // 如果查不到当日任务信息,就查询全局任务信息
        if (taskResult.length === 0) {
          ...
          TaskInfoApi.query(GLOBAL_KEY, false, (globalTaskResult) => {
            ...  // 回写没打开应用的时间段的健康任务信息和每日信息
          })
        } else {
        // 计算当日健康任务的开启个数和完成数
          let dayInfoList = self.calFinishNum(taskResult, result);
          dayInfo.targetTaskNum = dayInfoList[0];
          dayInfo.finTaskNum = dayInfoList[1];
          callback(result, dayInfo);
        }
      });
    }
  });
}

通用工具类

本节将介绍日志打印、时间换算等通用工具类的编写和使用,工具类可以简化应用代码编写和业务流程处理。

日志类

日志类Logger旨在提供一个全局的日志打印、日志管理的地方,既可以规范整个应用的日志打印,也方便日后对日志工具类进行修改,而不需要去改动代码中每一个调用日志的地方,目前分info,debug,warn,error四个级别。

使用方法如下:

  1. import Logger日志类:

    import Logger from '../../utils/Logger';
  2. 调用对应级别的静态方法:

    Logger.debug('MyAbilityStage', 'onCreate');

    Logger目前在打印日志时会拼装本应用的唯一标识,方便筛选日志和调试:

     // Logger.ets
     class Logger {
       private domain: number;
       private prefix: string;
    
       constructor(prefix: string = '', domain: number = 0xFF00) {
         this.prefix = prefix;
         this.domain = domain;
       }
       ...
     }
    
     export default new Logger(LOGGER_PREFIX, 0xFF02);

时间工具

为全局提供时间工具,避免重复定义。

  1. 常用时间相关常量:

    const CHINESE_OF_WEEK: string[] = ['一', '二', '三', '四', '五', '六', '日'];
    const YEAR: string = '年';
    const MONTH: string = '月';
    const DAY: string = '日';
    const WEEK: string = '星期'; 
  2. 时间函数示例(由时间常量衍生出星期一到星期日和数字 1-7 的字典映射):

    export const oneWeekDictFunc = () => {
      const oneWeekDict = {};
      CHINESE_OF_WEEK.forEach((item, index) => {
        oneWeekDict[index + 1] = `${ WEEK }${ CHINESE_OF_WEEK[index] }`;
      })
      return oneWeekDict;
    }

单位转换工具

把比例等分浮点数转换为百分比字符串。

例如成就页面,每一行平均分布三个徽章,可以先定义一个浮点数代表等分比例,再转换为百分比字符串。

export function ratio2percent(ratio: number): string {
  return `${ ratio * 100 }%`;
}

使用方法如下:

  1. import 工具方法:

    import { ratio2percent } from '../common/utils/Utils'
  2. 引用工具方法 ( 例如成就页面,每个徽章占据屏幕宽度的三分之一 ) :

    // 引用工具方法( 例如成就页面,每个徽章占据屏幕宽度的三分之一 ) :
    Column({ space: commonConst.DEFAULT_18 }) { 
      ...  // 省略徽章卡片的 UI 布局细节
    }
    .width(ratio2percent(achieveConst.ACHIEVE_SPLIT_RATIO))

事件分发类

事件分发类提供应用全局的事件注册,分发,接受,可以实现组件之间的解耦。

事件分发类全局共享一个实例, 将事件处理统一管理(HealthDataSrcMgr是单例):

获取事件分发实例:

// HomeIndex.ets
@Provide broadCast: BroadCast = HealthDataSrcMgr.getInstance().getBroadCast();

// HealthDataSrcMgr.ets
public getBroadCast(): BroadCast {
  return this.broadCast;
}

事件注册:

// CustomDialogView.ets
aboutToAppear() {
  ...
  this.broadCast.on(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, function (achievementLevel: number) { 
    ... // 省略回调细节
  })
  ...
}
  
// BroadCast.ets
public on(event, callback) {
  (this.callBackArray[event] || (this.callBackArray[event] = [])).push(callback);
}

取消事件注册:

// HomeIndex.ets
aboutToDisappear() {
  this.broadCast.off(null, null);
}

// BroadCast.ets
public off(event, callback) {
  ... // 省略入参检查
  const cbs = this.callBackArray[event];
  if (!cbs) {
    return;
  }
  if (!callback) {
    this.callBackArray[event] = null;
  }
  cbs.splice(cbs.indexOf(callback), 1);  
}

发送事件:

// HomeIndex.ets
taskItemAction(item: TaskInfo, isClick: boolean) {
  ...
  if (isClick) {
    // 点击任务打卡 
    ...
    this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);
  }
  else {
    ...
  }
}

// BroadCast.ets
public emit(event, args?: any[]) { 
  ... // 省略入参检查
  let cbs = [...this.callBackArray[event]];
  if (cbs) {
    let len = cbs.length;
    for (let i = 0; i < len; i++) {
      try {
        cbs[i].apply(_self, args);
      } catch (error) {
        new Error(error);
      }
    }
  }
}

总结

您已经完成了本次Codelab的学习,并了解到以下知识点:

  1. ArkUI基础组件、容器组件的使用。

  2. 使用页面路由跳转到指定页面并传递所需参数。

  3. 基于基础组件封装自定义组件,如日历、弹窗等。

  4. 数据驱动UI组件刷新。

  5. 使用首选项接口实现应用权限管理。

  6. 使用关系型数据库读写关系型数据。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

基于OpenHarmony开发的健康生活应用(ArkTS) 的相关文章

  • MySQL中设置自增主键id从1开始

    可能遇到过这种问题 当你只想新增一条数据时 发现使用Insert语句后 发现id并不是从1开始的 握草 怎么回事 其实很简单 通过执行一下SQL 对应你的表就可以解决 ALTER TABLE user AUTO INCREMENT 1 具体
  • 【计算机开题报告】基于JSP的服装店销售管理系统

    1 选课目的意义 21世纪是一个信息化时代 随着中国经济的发展和人民生活水平的提高 服装商场的普及程度日益增大 竞争也在逐渐白炽化 为了进一步提高服装商场的经营效率 在服装店销售管理中引入计算机管理系统成为了必然的选择 由于中国环境的特殊性
  • 【计算机开题报告】智能社区管理系统

    一 设计目的及意义 随着经济的发展 人们生活水平的提高 工作和日常事务繁忙 人们对服务就有了更深入 更精细的要求 而计算机技术的迅猛发展 使得这种需求变为可能 传统的社区服务业也与互联网技术结合更加密切 这是社会发展的必然趋势 为解决社区中
  • 【计算机开题报告】 医药信息管理系统

    一 选题依据 简述国内外研究现状 生产需求状况 说明选题目的 意义 列出主要参考文献 1 研究背景 随着医药事业的不断壮大 相关单位对于医药信息的管理变得越来越重要 传统的手工管理效率低 易出错 费时费力 不能及时精确的收集 传递 存储 加
  • 如何处理不稳定的自动化测试?

    abluecolor 在解决这个问题之前 请停止编写更多测试 因为这将花费你较高的测试维护成本 你需要尽快行动起来对不稳定的原因进行深入研究 找到不稳定的根因 并且尝试在流程 环境和代码方面做一些优化工作解决它 MasterKindew 如
  • Vue + Element-ui组件上传图片报错问题解决方案

    在使用Vue和Element ui组件上传图片时 可能会遇到一些报错问题 以下是一些常见的问题及解决方案 报错 TypeError Cannot read property name of undefined 解决方案 这个错误通常是因为在
  • 拼多多详情API开启运营比价新纪元

    随着互联网的快速发展 电商行业正在迅速崛起 拼多多作为一家新兴的电商平台 凭借其独特的营销策略和创新的商业模式 成为了电商行业的一匹黑马 在拼多多的成功背后 其详情API接口营销起到了至关重要的作用 本文将详细介绍拼多多详情API接口营销的
  • 亚信安慧AntDB引领数字化转型:浙江移动成功实现CRM系统全域改造

    数字时代 通信运营商在不断迭代的背景下 需要不断探索数字化转型的路径 以适应快速发展的市场和技术环境 在这一浪潮中 浙江移动站在前沿 率先完成了其CRM系统的全域改造 采用了亚信安慧公司研发的AntDB数据库 为整个行业树立了数字化转型的标
  • 智能时代:自然语言生成SQL与知识图谱问答实战

    语义解析 前言 语义解析的应用场景 总结概论 语义解析和大模型的关系 延伸阅读 前言 语义解析技术可以提高人机交互的效率和准确性 在自然语言处理 数据分析 智能客服 智能家居等领域都有广泛的应用前景 特别是在大数据时代 语义解析能够帮助企业
  • Navicat 16 for MySQL:打造高效数据库开发管理工具

    随着数据的快速增长和复杂性的提升 数据库成为了现代应用开发中不可或缺的一部分 而在MySQL数据库领域 Navicat 16 for MySQL作为一款强大的数据库开发管理工具 正受到越来越多开发者的青睐 Navicat 16 for My
  • 【计算机毕业设计】航空信息管理系统

    传统信息的管理大部分依赖于管理人员的手工登记与管理 然而 随着近些年信息技术的迅猛发展 让许多比较老套的信息管理模式进行了更新迭代 飞机票信息因为其管理内容繁杂 管理数量繁多导致手工进行处理不能满足广大用户的需求 因此就应运而生出相应的航空
  • 【计算机毕业设计】基于web的山东红色旅游信息管理系统

    有效的处理想要的相关信息和如何传播有效的信息 一直是人类不断探索的动力 人类文明火种的传承都是通过了多种媒介作为载体 也是随着社会生产力的发展不断的更新 随着互联网的到来 信息传播与管理都上升了一个新的台阶 并且方便应用的同时也要考虑信息传
  • 【计算机毕业设计】北关村基本办公管理系统

    在如今社会上 关于信息上面的处理 没有任何一个企业或者个人会忽视 如何让信息急速传递 并且归档储存查询 采用之前的纸张记录模式已经不符合当前使用要求了 所以 对北关村基本办公信息管理的提升 也为了对北关村基本办公信息进行更好的维护 北关村基
  • 深入了解 Python MongoDB 操作:排序、删除、更新、结果限制全面解析

    Python MongoDB 排序 对结果进行排序 使用 sort 方法对结果进行升序或降序排序 sort 方法接受一个参数用于 字段名 一个参数用于 方向 升序是默认方向 示例 按名称按字母顺序对结果进行排序 import pymongo
  • 深入了解 Python MongoDB 查询:find 和 find_one 方法完全解析

    在 MongoDB 中 我们使用 find 和 find one 方法来在集合中查找数据 就像在MySQL数据库中使用 SELECT 语句来在表中查找数据一样 查找单个文档 要从MongoDB的集合中选择数据 我们可以使用 find one
  • Oracle EBS AP发票导入 API Rejection List 第二部分

    Oracle EBS AP发票导入 API Rejection List 第二部分 The report lists the reason the invoice could not be imported and prints a bri
  • 一文让你了解UI自动化测试

    测试都起什么作用 是项目的保险 但不是项目的救命草 测试无实际产出 但作用远大于实际产出 测试是从项目维度保证质量 而不是测试阶段 UI自动化 下面简称自动化 基于UI进行自动功能测试 以Web端作为例子 一般的UI功能自动化都是基于HTM
  • 温室气体排放更敏感的模型(即更高的平衡气候敏感性(ECS))在数年到数十年时间尺度上也具有更高的温度变化(Python代码实现)

    欢迎来到本博客 博主优势 博客内容尽量做到思维缜密 逻辑清晰 为了方便读者 座右铭 行百里者 半于九十 本文目录如下 目录 1 概述 2 运行结果 3 参考文献 4 Python代码 数据
  • 一文从0到1手把手教学UI自动化测试之数据驱动!

    在UI的自动化测试中 我们需要把测试使用到的数据分离到文件中 如果单纯的写在我们的测试模块里面 不是一个好的设计 所以不管是什么类型的自动化测试 都是需要把数据分离出来的 当然分离到具体的文件里面 文件的形式其实有很多的 这里主要说明JSO
  • 光波导结构

    摘要 增强现实和混合现实 AR MR 领域的新应用引起了人们对带有光栅区域的光波导系统的越来越多的关注 这些光波导系统用于输入和输出耦合以及扩瞳目的 VirtualLab Fusion为这类系统的仿真和设计提供了几个强大的工具 其中一个是具

随机推荐

  • 关于区块链几个证书的理解

    关于区块链几个证书的理解 FISCO BCOS 1 说明 在FISCO BCOS中 证书这个概念有许多的概念 会在环境与节点配置 部署 构链等步骤中反复出现 例如链证书 机构证书 节点证书和客户端证书等 如下对这些概念和关系进行一个适当的理
  • 使用 source insight 代码跳转时出现 symbol not found 问题

    P S 本篇博客是根据自己的经验来写的 如果大家有不同意见随时交流 1 使用 source insight 代码跳转功能时出现 symbol not found 问题一般是有三种可能 在你添加代码时没有选择 Add tree 选项 创建 p
  • 用Requests和正则表达式爬取猫眼电影(TOP100+最受期待榜)

    目标站点分析 目标站点 猫眼榜单TOP100 如下图 猫眼电影的翻页offset明显在URL中 所以只要搞定第一页的内容加上一个循环加上offset就可以爬取前100 流程框架 1 抓取单页内容 利用requests请求目标站点 得到单个网
  • spring——IOC控制反转

    IOC控制反转思想 一 IOC简介 二 spring创建对象的三种方式 1 无参构造 2 静态工厂 3 实例工厂 三 Bean标签 1 bean标签的scope属性 singleton prototype多例 request session
  • import报错

    错误 import im6 q16 unable to read X window image Resource temporarily unavailable error xwindow c XImportImage 4977 impor
  • MyDLNote - Detection : 2019 CVPR 使用解耦表示(Disentangled Representation)学习的罕见事件检测

    CVPR 2019 Rare Event Detection using Disentangled Representation Learning Ryuhei Hamaguchi Ken Sakurada and Ryosuke Naka
  • Pandas函数set_option()学习笔记

    pd set option 函数用法 import pandas as ps pd set option expand frame repr True True就是可以换行显示 设置成False的时候不允许换行 pd set option
  • Shiro总结和常见面试题

    Shiro总结和常见面试题 一 什么是shiro Shiro是一个强大易用的java安全框架 提供了认证 授权 加密 会话管理 与web集成 缓存等功能 对于任何一个应用程序 都可以提供全面的安全服务 相比其他安全框架 shiro要简单的多
  • 电力系统有哪些大扰动?

    电力系统有哪些大扰动 答 电力系统大扰动主要指 各种短路故障 各种突然断线故障 断路器无故障跳闸 非同期并网 包括发电机非同期并列 大型发电机失磁 大容量负荷突然启停等
  • 游戏笔记本电脑可以进行 3D 建模和渲染吗?有哪些优势与缺点?

    3D 建模和渲染是创建令人惊叹的数字艺术 动画和游戏体验的最流行和最广泛使用的工具之一 随着技术的进步 对运行这些模型的强大计算机的需求呈指数级增长 对于那些寻求强大机器来处理 3D 建模任务的人来说 游戏笔记本电脑已成为一个可行的选择 游
  • R语言系列教程-----一起来学shiny吧(1)

    什么是shiny Shiny是一个R包 可让您轻松地直接从 R 构建交互式 Web 应用程序 应用程序 本系列是个长教程 带你由浅入深学习shiny 我们先使用系统自带的一个例子来介绍一下shiny 我们先导入shiny包 library
  • 华为OD机试 - 矩形相交的面积(Java)

    题目描述 给出3组点坐标 x y w h 1000
  • MD5 JavaCript调用法

    JS代码 code
  • 2、Java入门教程【IDEA】

    1 下载 IDEA社区版 下载地址 IDEA管理JAVA程序的结构 project 项目 工程 module 模块 package 包 class 类 2 创建工程 创建 project 点击 create 后 项目结构如下 创建 pack
  • 计蒜客T1113——整理药名

    先看题干 这道题并不难 如果你对编程语言的基础有很好的掌握 很容易理清整个逻辑 实际上 不涉及到算法的题目都是简单题 笔者带领大家理一下这道题的考察点 1 输入多个不定长字符串 2 将字符串的首字母一律变为大写 分本来就是大写和本来是小写两
  • 【GAN】基础原理讲解及代码实践

    首先什么是 的模型结构 设计 模型的关键 GAN的算法原理 这里输入噪声的随机性就可以带来生成图像的多样性 GAN公式讲解 D 表示判别器对真实图片的判别 取对数函数后我们希望其值趋于 也就是D 趋于 也就是放大损失
  • 【踩坑专栏】idea中的target缺失

    target文件夹缺失 但是在文件中能找到 这个是idea禁止了 我想起来之前我是因为在提交代码时有一些文件显示在commit里 所以禁止了一些文件 应该是那时候把target误禁了 解决办法 1 Ctrl Alt S 找到target 删
  • OpenCV之摄像头捕捉图像

    代码 数据类型 运行效果 代码 之前我找过directshow CameraDS VedioCaptureFromCam之类的东西 发现都不可以用 directshow是因为版本太老了 会出现 http www opencv org cn
  • Ajax简介和实例

    目录 什么是 AJAX AJAX实例 ajax get无参 ajax get有参 对象和查询字符串的互转 ajax post ajax post 表单 AJAX 是一种在无需重新加载整个网页的情况下 能够更新部分网页的技术 什么是 AJAX
  • 基于OpenHarmony开发的健康生活应用(ArkTS)

    健康生活应用 ArkTS 介绍 本篇Codelab介绍了如何实现一个简单的健康生活应用 主要功能包括 用户可以创建最多6个健康生活任务 早起 喝水 吃苹果 每日微笑 刷牙 早睡 并设置任务目标 是否开启提醒 提醒时间 每周任务频率 用户可以